Test Specs Common - MZC-CSC/mc-web-console GitHub Wiki

Test Specs โ€” Common (lib ยท ํ—ฌํผ ยท ์„ค์ •)

์ƒ์„ฑ: 2026-10-16 ยท ๊ธฐ์ค€: v07 ์„ค์น˜ ํ›„ ํ†ตํ•ฉ ๊ฒ€์ฆ(IV) ยท ์›๋ณธ: ์—ฌ์ •๋ณ„ ๊ณ„ํš์„œ(Excel) โ† Test Scenarios ยท IV ํ—ˆ๋ธŒ ยท ๊ธฐ๋Šฅ TC

์—ฌ์ •๋ณ„ spec ์ด ๊ณตํ†ต์œผ๋กœ ์“ฐ๋Š” ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค. ์‹คํ–‰ ๋ฐฉ๋ฒ•์€ ์•„๋ž˜ ์š”์•ฝ์„ ์ฐธ๊ณ ํ•˜์„ธ์š”.

์‹คํ–‰ ์š”์•ฝ

  • ์‹คํ–‰ ํ™˜๊ฒฝ: WSL(Ubuntu) + Playwright(Chromium headless), workers: 1, ์˜์ƒ 1920ร—1080
  • CSP ์„ ํƒ: ํ™˜๊ฒฝ๋ณ€์ˆ˜ TC_VARIANT(aws, azure, alibaba, gcp, tencent, nhncloud, ncp, ibm, kt). ๋ฏธ์ง€์ •์ด๋ฉด ์ „์ฒด
  • ๊ฒฐ๊ณผ ํƒœ๊ทธ: RUN_TAG(๊ธฐ๋ณธ v07) โ†’ ์˜์ƒ results/v07/recordings/v07_<๋…นํ™”ํŒŒ์ผ๋ช…>.webm
  • ์‹œ๋‚˜๋ฆฌ์˜ค ์›๋ณธ์€ ์—ฌ์ •๋ณ„ Excel ๊ณ„ํš์„œ์ด๋ฉฐ, export-plan ์ด scenarios.json/run-order.json ์„ ๋งŒ๋“ค๊ณ  gen-specs ๊ฐ€ ์Šคํ… spec ์„ ๋งŒ๋“ ๋‹ค
ํŒŒ์ผ ์ค„ ํ•ด์‹œ
lib/cost.ts 139 d69da166
lib/credentials.ts 119 0acb8c5e
lib/data.ts 138 2f346d65
lib/fixtures.ts 84 a71f8f2b
lib/forms.ts 184 097a9dfb
lib/groups.ts 206 22dfda0e
lib/infra.ts 476 c4531357
lib/iv3-k8s.ts 298 628097d3
lib/iv3-mc10.ts 149 edd949fa
lib/iv3-wf.ts 314 475b2fb4
lib/iv4-infra.ts 448 450851f7
lib/iv4-k8s.ts 70 aac3d6e6
lib/iv4-kubectl.ts 54 9ff3568f
lib/iv4-ssh.ts 40 53ca0beb
lib/iv5-app.ts 166 8046b305
lib/iv5-objstore.ts 238 04dddc19
lib/iv5.ts 280 7ae5fb5b
lib/iv7-obs-logs.ts 24 baf23c89
lib/iv7-obs.ts 111 ad7aa6e1
lib/iv8-nrdb.ts 225 d4d2856a
lib/iv8-os.ts 245 93c3f995
lib/iv8-rdb.ts 327 0da9732e
lib/k8s.ts 75 30879d5b
lib/menu-access.ts 128 1aab1499
lib/projects.ts 52 92fbb0b8
lib/reco.ts 76 fb58a69b
lib/resources.ts 229 c011a892
lib/roles.ts 94 9c919115
lib/runtag.ts 7 d6bac09c
lib/sync.ts 118 f0046219
lib/ui.ts 137 bab45566
lib/workflow.ts 164 426003f4
tests/iv7/monitoring.ts 168 5f35b1cd
playwright.config.ts 33 4d3a401d

lib/cost.ts

ํ•ด์‹œ d69da166 ยท 139์ค„

import { expect } from '@playwright/test';
import type { Page, Frame } from '@playwright/test';
import type { Csp } from './data';
import { test } from './fixtures';
import { loginAsAdmin, selectWorkspaceProject } from './ui';
import { WORKSPACE, DEFAULT_PROJECT } from './data';

// IV6(Cost Analysis) ํ™”๋ฉด ํ—ฌํผ โ€” 2026-10-07 AWS ํƒ์ƒ‰(์ฝ๊ธฐ ์ „์šฉ)์œผ๋กœ ํ™•์ธ
// - ์‚ฌ์ด๋“œ๋ฐ” Analytics > Cost Analysis ์•„๋ž˜ ๋ณ„๋„ ๋ฉ”๋‰ด 4๊ฐœ: Dash Board(/_view/costdashboard) ยท Billing Report(/_view/billingreport) ยท Cost Alarm(/_view/costalarm) ยท Budget(/_view/budget)
//   (๋ณธ๋ฌธ iframe ์ฃผ์†Œ๋Š” :7780/ , /billing-report , /alarm , /budget ๋กœ ๋ฉ”๋‰ด๋งˆ๋‹ค ๋‹ค๋ฅด๋‹ค)
// - Billing Report ์˜ [Cost Setup] โ†’ ๋ชจ๋‹ฌ(CSP ํƒญ: ํ˜„์žฌ AWS, GCP ๋งŒ). ๋ชจ๋‹ฌ ๋ฒ„ํŠผ Close(.btn-secondary) / Reconfigure(AWS) / Check Now(GCP) โ€” ๋ˆ„๋ฅด์ง€ ์•Š๋Š”๋‹ค
export type CostMenu = 'dashboard' | 'billing' | 'alarm' | 'budget';
const SLUG: Record<CostMenu, string> = { dashboard: 'costdashboard', billing: 'billingreport', alarm: 'costalarm', budget: 'budget' };
const note = (type: string, description: string) => test.info().annotations.push({ type, description });

export const costFrame = (page: Page): Frame => page.frames().find((x) => x !== page.mainFrame() && x.url().includes(':7780'))!;
export const frameText = async (page: Page) => (await costFrame(page).locator('body').innerText()).replace(/\s+/g, ' ');

/** ๋กœ๊ทธ์ธ โ†’ ์‚ฌ์ด๋“œ๋ฐ” Cost Analysis ๊ทธ๋ฃน ํŽผ์นจ โ†’ ํ•ด๋‹น ๋ฉ”๋‰ด ํด๋ฆญ. ํ™”๋ฉด ๋ณธ๋ฌธ ํ…์ŠคํŠธ๋ฅผ ๋Œ๋ ค์ค€๋‹ค. project ๊ธฐ๋ณธ๊ฐ’ default(2026-10-16: ๋น„์šฉ ์—†๋Š” Project ์กฐํšŒ์šฉ ์ธ์ž ์ถ”๊ฐ€) */
export async function openCost(page: Page, menu: CostMenu, project: string = DEFAULT_PROJECT) {
  await loginAsAdmin(page, WORKSPACE, project);
  const link = page.locator(`a[href*="/_view/${SLUG[menu]}"]`).first();
  if (!(await link.isVisible().catch(() => false))) await page.getByText('Cost Analysis', { exact: true }).first().click(); // ๊ทธ๋ฃน ํŽผ์นจ
  await link.click();
  await page.waitForURL(new RegExp(SLUG[menu]));
  await selectWorkspaceProject(page, WORKSPACE, project, true); // ๋ฉ”๋‰ด ์ด๋™ ์‹œ ์„ ํƒ์ด ์ดˆ๊ธฐํ™”๋  ์ˆ˜ ์žˆ์Œ
  await expect.poll(() => !!costFrame(page), { timeout: 60_000 }).toBe(true);
  await page.waitForTimeout(8000); // iframe ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ(+Project ์„ ํƒ ์‹œ ์žฌ๋กœ๋“œ)
  const text = await frameText(page);
  note('screen', text.slice(0, 300));
  return text;
}
/** Billing Report ์—์„œ Cost Setup ๋ชจ๋‹ฌ์„ ์—ด๊ณ  CSP ํƒญ ์œ ๋ฌด๋ฅผ ํ™•์ธ. ํƒญ์ด ์žˆ์œผ๋ฉด ์„ ํƒํ•œ ์ƒํƒœ๋กœ ๋Œ๋ ค์ค€๋‹ค. */
export async function openCostSetupTab(page: Page, c: Csp) {
  await openCost(page, 'billing');
  const f = costFrame(page);
  await f.getByText('Cost Setup', { exact: true }).first().click();
  const modal = f.locator('.modal.show, [role=dialog]').first();
  await expect(modal).toBeVisible({ timeout: 15_000 });
  const tabs = (await modal.locator('a.nav-link').allInnerTexts()).map((s) => s.replace(/[โœ“\s]+/g, ' ').trim());
  note('cost-setup-tabs', JSON.stringify(tabs));
  const tab = modal.locator('a.nav-link').filter({ hasText: new RegExp(`^\\s*${c.code}\\b`, 'i') }).first();
  const has = (await tab.count()) > 0;
  if (has) { await tab.click(); await page.waitForTimeout(1500); }
  return { f, modal, tabs, has };
}

// โ”€โ”€ ๋น„์šฉ ๋ฐ์ดํ„ฐ(2026-10-14 ์ ๊ฒ€) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// ํ™”๋ฉด์ด ๋ถ€๋ฅด๋Š” BE API(:9090 /api/costopti/be/โ€ฆ) ์‘๋‹ต์œผ๋กœ ๊ธˆ์•ก์„ ์ฝ๋Š”๋‹ค. ํ™”๋ฉด ๋ฌธ๊ตฌ๋Š” ๋ฐ˜์˜ฌ๋ฆผ(0.43 USD)์ด๋ผ API ๊ฐ’์œผ๋กœ ํ•ฉ๊ณ„๋ฅผ ๋งž์ถ˜๋‹ค.
//   Billing Report: invoice/getBillingBaseInfo โ†’ [{csp:'AWS'|'AZURE'|'GCP'|'NCP', cost}], invoice/getInvoice โ†’ {invoice:[{csp, productID, bill, resourceID}]}
//   Dash Board   : getTop5Bill โ†’ {top5bill:[{csp, resourceNm, bill}]}, getCurMonthBill โ†’ {curMonthBill}
// Cost Analysis ๊ฐ€ ๋‹ค๋ฃจ๋Š” CSP ๋Š” AWSยทAzureยทGCPยทNCP 4๊ฐœ(์š”์ฒญ selectedCsps ๊ธฐ์ค€). ๋‚˜๋จธ์ง€ CSP ๋Š” ๋น„์šฉ ํ™”๋ฉด ๋Œ€์ƒ์ด ์•„๋‹ˆ๋‹ค.
export const COST_CSP: Record<string, string> = { AWS: 'AWS', Azure: 'AZURE', GCP: 'GCP', NCP: 'NCP' };
export const costCspOf = (c: Csp) => COST_CSP[c.code] ?? null;

/** ํŽ˜์ด์ง€์—์„œ ๋น„์šฉ BE API ์‘๋‹ต์„ ๋ชจ์€๋‹ค. ๋ฐ˜ํ™˜ ํ•จ์ˆ˜๋กœ ์ด๋ฆ„(getTop5Bill ๋“ฑ) โ†’ ๋งˆ์ง€๋ง‰ Data ๋ฅผ ๊บผ๋‚ธ๋‹ค. */
export function costApi(page: Page) {
  const data: Record<string, any> = {};
  page.on('response', async (r) => {
    const m = r.url().match(/\/api\/costopti\/be\/(?:invoice\/)?(\w+)/);
    if (!m || r.request().resourceType() === 'document') return;
    try { const j = await r.json(); data[m[1]] = j?.Data ?? j; } catch { /* JSON ์•„๋‹˜ */ }
  });
  return (name: string) => data[name];
}

/** Billing ReportยทDash Board ๋ฅผ ์—ด์–ด CSP ๋ณ„ ๊ธˆ์•กยทTop5ยทInvoice ๋ฅผ ๋ชจ์€๋‹ค */
export async function readCosts(page: Page) {
  const get = costApi(page);
  const billing = await openCost(page, 'billing');
  await expect.poll(() => !!get('getBillingBaseInfo') && !!get('getInvoice'), { timeout: 60_000 }).toBe(true);
  const base: { csp: string; cost: number }[] = get('getBillingBaseInfo') ?? [];
  const invoice: { csp: string; productID: string; bill: number; resourceID: string }[] = get('getInvoice')?.invoice ?? [];
  const dashboard = await openCost(page, 'dashboard');
  await expect.poll(() => !!get('getTop5Bill') && !!get('getCurMonthBill'), { timeout: 60_000 }).toBe(true);
  const top5: { csp: string; resourceNm: string; bill: number }[] = get('getTop5Bill')?.top5bill ?? [];
  const cur: number = get('getCurMonthBill')?.curMonthBill ?? NaN;
  const total = Number(billing.match(/Total Amount\s*([\d.,]+)\s*USD/i)?.[1] ?? NaN);
  note('cost-base-info', base.map((b) => `${b.csp} ${b.cost.toFixed(4)}`).join(' / ') + ` | ํ™”๋ฉด Total ${total} USD | ๋‹น์›”(Dash Board) ${cur.toFixed(4)} USD`);
  note('cost-top5', top5.map((t) => `${t.resourceNm}(${t.csp}) ${t.bill.toFixed(4)}`).join(' / ') || '(์—†์Œ)');
  note('cost-invoice', invoice.map((i) => `${i.csp} ${i.productID} ${i.bill.toFixed(4)}`).join(' / ') || '(์—†์Œ)');
  return { billing, dashboard, base, invoice, top5, cur, total };
}

/**
 * ๋น„์šฉ ์ˆ˜์ง‘ ๊ฒฐ๊ณผ ํ™”๋ฉด ๋ฐ˜์˜(IV6_S07~S09 ๊ณตํ†ต) โ€” ์‹œํŠธ ๊ธฐ๋Œ€๊ฒฐ๊ณผ '๋Œ€์‹œ๋ณด๋“œ ์›”๋น„์šฉ/Top5/์š”์•ฝ์— ๋ฐ˜์˜' ๊ธฐ์ค€:
 * Billing Report Base Info ๊ธˆ์•ก > 0, Dash Board Top 5 ์— ์„œ๋น„์Šค. Invoices ํ–‰์€ ๊ธฐ๋ก๋งŒ ํ•œ๋‹ค(2026-10-14: GCP ๋Š” ๊ธˆ์•ก์ด ์žˆ์–ด๋„ Invoices ์— ํ–‰ ์—†์Œ).
 * ๊ธˆ์•ก์ด ์—†์œผ๋ฉด ์ˆ˜์ง‘ ์‚ฌ์ „ ์„ค์ •(์ธ์ฆํ‚คยท๊ถŒํ•œ) ๋ฏธ๋น„๋กœ ๋ณด๊ณ  SKIP(์‚ฌ์šฉ์ž ์ง€์‹œ 2026-10-14).
 */
export async function checkCollected(page: Page, c: Csp) {
  const csp = costCspOf(c)!;
  const { base, invoice, top5 } = await readCosts(page);
  const mine = base.find((b) => b.csp === csp);
  test.skip(!mine || mine.cost <= 0, `${csp} ๋น„์šฉ ์—†์Œ โ€” ์ˆ˜์ง‘ ์‚ฌ์ „ ์„ค์ •(์ธ์ฆํ‚คยท๊ถŒํ•œ) ๋ฏธ๋น„๋กœ ํŒ๋‹จ(SKIP)`);
  const inv = invoice.filter((i) => i.csp === csp);
  note('invoice-check', inv.length ? `Invoices ์— ${csp} ${inv.length}ํ–‰` : `Invoices ์— ${csp} ํ–‰ ์—†์Œ(Base InfoยทTop 5 ์—๋Š” ๋ฐ˜์˜) โ€” ์ฐธ๊ณ `);
  expect(top5.map((t) => t.csp), `Top 5 Services ์— ${csp}`).toContain(csp);
}

// โ”€โ”€ TC-COST-013~020 (2026-10-16 ํ™”๋ฉด ํƒ์ƒ‰) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//   Billing Report > Invoices: ๋‚ด๋ณด๋‚ด๊ธฐ ์•„์ด์ฝ˜ ๋ฒ„ํŠผ 2๊ฐœ(svg.tabler-icon-file-type-csv / -xls) โ†’ ๋ฐ”๋กœ ๋‹ค์šด๋กœ๋“œ(invoice_YYYY-MM-DD.csv)
//   Archive(๐Ÿ—„ Archive) โ†’ ๋ชจ๋‹ฌ 'Cost Archive': Target month select(archive/months), CSP ์ฒดํฌ๋ฐ•์Šค AWSยทGCPยทNCPยทAZURE(๊ธฐ๋ณธ ๋ชจ๋‘ ์ฒดํฌ), Close / Archive(month ๋ฏธ์„ ํƒ ์‹œ disabled)
//   Budget > CSP Budget Setting: ํ–‰ AWSยทAzureยทNCPยทGCP, ์—ด TotalยทJan~Dec(์…€์€ ํด๋ฆญํ•˜๋ฉด ํŽธ์ง‘), Save Budget / Reset. API budget/{year}, budget/comparison/{year}
//   Alarm > Resource Recommendation: Provider ์˜์—ญ(ํ‚ค ์—†์œผ๋ฉด 'No API key registered yetโ€ฆ'), ๐Ÿ”‘ API Key Management, ์ธ์Šคํ„ด์Šค ํ‘œ(์ฒดํฌ๋ฐ•์Šค), ๋น ๋ฅธ ์งˆ๋ฌธ ์นฉ 4๊ฐœ, Recommend(์„ ํƒ 0 ์ด๋ฉด disabled)
//   Alarm > History: Mailing/Slack ApplyยทTest ๋ฒ„ํŠผ, Recommendation History ํ‘œ(DATEยทCSPยทRESOURCE IDยทRESOURCE TYPEยทALARM TYPEยทALARM MESSAGEยทRECOMMENDATION TYPE)
//   LLM ํ‚ค ์ƒํƒœ: GET llm_recommender/apikey/{openai|google|anthropic}/status?nsId=<project>
export const LLM_PROVIDERS = ['openai', 'google', 'anthropic'] as const;
/** ํ™”๋ฉด์ด ๋ถ€๋ฅด๋Š” ํ‚ค ์ƒํƒœ API ์‘๋‹ต์„ ๋ชจ์€๋‹ค(openCost ์ „์— ํ˜ธ์ถœ) */
export function llmKeyStatus(page: Page) {
  const st: Record<string, boolean | undefined> = {};
  page.on('response', async (r) => {
    const m = r.url().match(/llm_recommender\/apikey\/(\w+)\/status/);
    if (m) try { const j = await r.json(); st[m[1]] = !!(j?.Data ?? j)?.registered; } catch { /* ๋ฌด์‹œ */ }
  });
  return st;
}

/** .xlsx(zip) ์˜ sharedStringsยทsheet1 ์—์„œ ๋ฌธ์ž์—ด์„ ๊บผ๋‚ธ๋‹ค โ€” ๋ณ„๋„ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์—†์ด zlib ๋กœ ๋กœ์ปฌ ํ—ค๋”๋ฅผ ์ฐจ๋ก€๋กœ ์ฝ์Œ */
export function xlsxStrings(buf: Buffer): string[] {
  const zlib = require('zlib') as typeof import('zlib');
  const out: string[] = [];
  let i = 0;
  while (i + 30 <= buf.length && buf.readUInt32LE(i) === 0x04034b50) {
    const method = buf.readUInt16LE(i + 8), csize = buf.readUInt32LE(i + 18), nlen = buf.readUInt16LE(i + 26), xlen = buf.readUInt16LE(i + 28);
    const name = buf.toString('utf8', i + 30, i + 30 + nlen);
    const start = i + 30 + nlen + xlen, data = buf.subarray(start, start + csize);
    if (/sharedStrings\.xml$|worksheets\/sheet1\.xml$/.test(name)) {
      const xml = (method === 8 ? zlib.inflateRawSync(data) : data).toString('utf8');
      for (const m of xml.matchAll(/<t[^>]*>([^<]*)<\/t>|<v>([^<]*)<\/v>|<is><t[^>]*>([^<]*)<\/t>/g)) out.push((m[1] ?? m[2] ?? m[3] ?? '').trim());
    }
    if (csize === 0 && (buf.readUInt16LE(i + 6) & 8)) break; // ๋ฐ์ดํ„ฐ ๋””์Šคํฌ๋ฆฝํ„ฐ ๋ฐฉ์‹(ํฌ๊ธฐ 0) โ€” ๋” ๋ชป ์ฝ์Œ
    i = start + csize;
  }
  return out;
}

export async function closeModal(modal: import('@playwright/test').Locator) {
  await modal.locator('button.btn-secondary').click().catch(() => {});
}

lib/credentials.ts

ํ•ด์‹œ 0acb8c5e ยท 119์ค„

import { expect, type Page, type Locator } from '@playwright/test';
import { test } from './fixtures';
import { loginAsAdmin } from './ui';

// Credential(Settings > Cloud SPs > Credentials) ํ™”๋ฉด ํ—ฌํผ โ€” 2026-10-14 ์†Œ์Šค(credentials.html/js) ๊ธฐ์ค€
// ์ถœ์ฒ˜: ์œ„ํ‚ค Test-Specs-IV10 ์˜ lib/iv10.ts(๋‹ค๋ฅธ ์„ธ์…˜ ์ž‘์„ฑ). 2026-10-14 IV1_S32~S37(TC-INFRA-001~006)๋กœ ํŽธ์ž…ํ•˜๋ฉด์„œ ์ด๋ฆ„๋งŒ ๋ฐ”๊ฟˆ
//  - ๋ชฉ๋ก: Tabulator #credential-table (Credential Holder, Providers, Connections, Verified, Default)
//  - ํ–‰ ํด๋ฆญ โ†’ #credential-detail-panel (#detail-holder-id/provider/connections/verified/default)
//  - Register: #credential-create-modal โ†’ #create-holder-name, #create-holder-provider(์„ ํƒ ์‹œ KV ํ–‰ ์ž๋™ ์ƒ์„ฑ), #kv-list-container(.kv-key/.kv-value), Register ๋ฒ„ํŠผ
export const CREDENTIALS_URL = '/webconsole/settings/environment/cloudsps/credentials';
export const TEST_HOLDER = process.env.CRED_HOLDER ?? 'test';

export async function gotoCredentials(page: Page) {
  await page.goto(CREDENTIALS_URL);
  await expect(page.locator('#credential-table')).toBeVisible({ timeout: 30_000 });
  await expect(page.locator('#credential-table .tabulator-row').first()).toBeVisible({ timeout: 30_000 });
}

export const holderRow = (page: Page, holder: string): Locator =>
  page.locator('#credential-table .tabulator-row').filter({ has: page.locator('[tabulator-field="credentialHolder"]', { hasText: new RegExp(`^\\s*${holder}\\s*$`) }) });

export async function rowCells(row: Locator): Promise<Record<string, string>> {
  const cells = row.locator('.tabulator-cell');
  const out: Record<string, string> = {};
  for (let i = 0; i < await cells.count(); i++) {
    const c = cells.nth(i);
    out[(await c.getAttribute('tabulator-field')) ?? `col${i}`] = (await c.innerText()).replace(/\s+/g, ' ').trim();
  }
  return out;
}

// ์›น ์ฝ˜์†” API(๊ณต์šฉ proxy)๋กœ mc-infra-manager ํ˜ธ์ถœ โ€” ํ™”๋ฉด ๊ฐ’๊ณผ ๋น„๊ตํ•  ๋•Œ๋งŒ ์‚ฌ์šฉ
export async function consoleApi(page: Page, operationId: string, data: unknown = {}) {
  return page.evaluate(async ([op, body]) => {
    const r = await (window as any).webconsolejs['common/api/http'].commonAPIPost(`/api/mc-infra-manager/${op}`, body);
    return { status: r?.status, data: r?.data };
  }, [operationId, data] as const);
}

// โ”€โ”€ ๋“ฑ๋ก โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// ์ž๊ฒฉ์ฆ๋ช… ๊ฐ’ ํŒŒ์ผ(CRED_VALUES_FILE, JSON {csp: {key: value}})์„ ์ฝ๋Š”๋‹ค. ๊ฐ’์€ ๋ฆฌํฌํŠธยท๋…นํ™”์— ๋‚จ๊ธฐ์ง€ ์•Š๋Š”๋‹ค.
import fs from 'node:fs';
export function credValues(csp: string): Record<string, string> {
  const f = process.env.CRED_VALUES_FILE;
  if (!f || !fs.existsSync(f)) throw new Error('CRED_VALUES_FILE(์ž๊ฒฉ์ฆ๋ช… ๊ฐ’ JSON) ๋ฏธ์ง€์ •');
  return JSON.parse(fs.readFileSync(f, 'utf8'))[csp] ?? {};
}
export const scrub = (s: string, values: Record<string, string>) => Object.values(values).reduce((t, v) => (v && v.length > 3 ? t.split(v).join('***') : t), s);

export type HolderInfo = { credentialHolder?: string; providers?: string[]; connectionCount?: number; verifiedConnectionCount?: number };
export async function getHolder(page: Page, holder: string): Promise<{ status: number; info: HolderInfo }> {
  const r = await consoleApi(page, 'GetCredentialHolder', { pathParams: { holderId: holder } });
  return { status: r.status, info: r.data?.responseData ?? {} };
}

// Register Credential ๋ชจ๋‹ฌ๋กœ ๋“ฑ๋ก. ํ™”๋ฉด์ด ์ž๋™์œผ๋กœ ์ฑ„์šด Key ๋ฅผ ๊ทธ๋Œ€๋กœ ์“ฐ๊ณ  Value ๋งŒ ์ž…๋ ฅํ•œ๋‹ค.
// ๋…นํ™”์— ๋น„๋ฐ€๊ฐ’์ด ์ฐํžˆ์ง€ ์•Š๋„๋ก Value ์ž…๋ ฅ๋ž€์€ ๊ธ€์ž๋ฅผ ๊ฐ€๋ฆฐ๋‹ค(-webkit-text-security: disc, ํ™”๋ฉด ๋™์ž‘์—๋Š” ์˜ํ–ฅ ์—†์Œ).
export async function uiRegister(page: Page, holder: string, provider: string, values: Record<string, string>) {
  await page.getByRole('button', { name: /Register Credential/ }).first().click();
  const modal = page.locator('#credential-create-modal');
  await expect(modal).toBeVisible();
  await page.addStyleTag({ content: '#credential-create-modal .kv-value{-webkit-text-security:disc;}' });
  await page.locator('#create-holder-name').fill(holder);
  await page.locator('#create-holder-provider').selectOption(provider);
  const keyInputs = page.locator('#kv-list-container .kv-key');
  await expect(keyInputs.first()).toBeVisible();
  const keys = (await keyInputs.evaluateAll((els) => els.map((e) => (e as HTMLInputElement).value))).filter(Boolean);
  const missing: string[] = [];
  for (let i = 0; i < keys.length; i++) {
    const v = values[keys[i]];
    if (v === undefined) { missing.push(keys[i]); continue; }
    await page.locator('#kv-list-container .kv-value').nth(i).fill(v);
  }
  const respP = page.waitForResponse((r) => r.url().includes('/RegisterCredential'), { timeout: 180_000 });
  await modal.getByRole('button', { name: /^Register$/ }).click();
  const resp = await respP;
  let body = '';
  try { body = await resp.text(); } catch { /* ignore */ }
  const toast = await page.locator('.toast, .toastify, [class*="toast"]').last().innerText({ timeout: 10_000 }).catch(() => '');
  if (await modal.isVisible()) await modal.getByRole('button', { name: 'Cancel' }).click().catch(() => {});
  return { keys, missing, status: resp.status(), body: scrub(body, values).slice(0, 600), toast: scrub(toast, values) };
}

// โ”€โ”€ UI ๋“ฑ๋ก ํ…Œ์ŠคํŠธ ๋ณธ๋ฌธ (IV1_S35 AWS, IV1_S36 CSP๋ณ„) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// ์ถœ์ฒ˜: ์œ„ํ‚ค Test-Specs-IV10 ์˜ tests/iv10/_register.ts. spec ํด๋”๊ฐ€ ์•„๋‹ˆ๋ผ lib ์— ๋‘์–ด ์œ„ํ‚ค Test-Specs-Common ์— ํ•จ๊ป˜ ๊ฒŒ์‹œํ•œ๋‹ค.
// ์ž๊ฒฉ์ฆ๋ช… ๊ฐ’์€ CRED_VALUES_FILE(JSON {provider: {key: value}})์—์„œ ์ฝ๊ณ  ๋ฆฌํฌํŠธยท๋…นํ™”์—๋Š” ๋‚จ๊ธฐ์ง€ ์•Š๋Š”๋‹ค.
export function uiRegisterTest(id: string, tc: string, provider: string) {
  test(`${id} ${tc} Credential ๋“ฑ๋ก โ€” ${provider} UI`, async ({ page }) => {
    const v = credValues(provider);
    expect(Object.keys(v).length, `${provider} ์ž๊ฒฉ์ฆ๋ช… ๊ฐ’ ์—†์Œ`).toBeGreaterThan(0);
    let r: Awaited<ReturnType<typeof uiRegister>>;

    await test.step('1. admin ๋กœ๊ทธ์ธ ํ›„ Settings > Cloud SPs > Credentials ์ด๋™', async () => { await loginAsAdmin(page); await gotoCredentials(page); });

    await test.step(`2. Register Credential โ†’ holder ${TEST_HOLDER}, Provider ${provider} ์„ ํƒ โ†’ ์ž๋™ ์ƒ์„ฑ๋œ Key ์— Value ์ž…๋ ฅ โ†’ Register`, async () => {
      r = await uiRegister(page, TEST_HOLDER, provider, v);
      test.info().annotations.push({ type: 'keys', description: `ํ™”๋ฉด ์ž๋™ Key: ${r.keys.join(', ')}${r.missing.length ? ` / ๊ฐ’ ์—†๋Š” Key: ${r.missing.join(', ')}` : ''}` });
      test.info().annotations.push({ type: 'response', description: `RegisterCredential HTTP ${r.status}: ${r.body}` });
      test.info().annotations.push({ type: 'toast', description: r.toast || '(ํ† ์ŠคํŠธ ์—†์Œ)' });
    });

    await test.step('3. ๋“ฑ๋ก ์„ฑ๊ณต: ์„œ๋ฒ„ ๊ฑฐ๋ถ€ ์—†์Œ ยท ์„ฑ๊ณต ํ† ์ŠคํŠธ', async () => {
      expect(r.missing, '์ž๊ฒฉ์ฆ๋ช… ๊ฐ’์ด ์—†๋Š” Key').toEqual([]);
      expect(r.body, '์„œ๋ฒ„๊ฐ€ ๋“ฑ๋ก์„ ๊ฑฐ๋ถ€ํ•จ').not.toMatch(/invalid credential key|"code":\s*[45]\d\d|Internal Server Error|Bad Request/i);
      expect(r.status).toBe(200);
      expect(r.toast).toMatch(/registered successfully/i);
    });

    await test.step(`4. GetCredentialHolder(${TEST_HOLDER}) providers ์— ${provider} ํฌํ•จ`, async () => {
      const h = await getHolder(page, TEST_HOLDER);
      test.info().annotations.push({ type: 'holder', description: JSON.stringify(h.info) });
      expect(h.info.providers ?? []).toContain(provider);
    });
  });
}

// lib/data.ts ์˜ CSP code โ†’ Credentials ํ™”๋ฉด Provider ๊ฐ’
export const PROVIDER: Record<string, string> = { AWS: 'aws', Azure: 'azure', Alibaba: 'alibaba', GCP: 'gcp', Tencent: 'tencent', NHNCloud: 'nhn', NCP: 'ncp', IBM: 'ibm', KT: 'kt' };

lib/data.ts

ํ•ด์‹œ 2f346d65 ยท 138์ค„

// ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์ •์˜. MCMP_IV_Test_Plan.xlsx ์˜ "์กฐ์ง๋„_์‚ฌ์šฉ์ž", "Infra_๊ตฌ์„ฑ๋„", "K8s_๊ตฌ์„ฑ๋„" ์‹œํŠธ์™€ ๋™์ผํ•˜๊ฒŒ ์œ ์ง€ํ•  ๊ฒƒ.

export const ADMIN = {
  id: process.env.ADMIN_ID ?? 'mcmp',
  pw: process.env.ADMIN_PW ?? '<ADMIN_PW>',
};

export const WORKSPACE = 'ws01';
export const DEFAULT_PROJECT = 'default';

/** ์‚ฌ์šฉ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™: {id}_password */
export const pwOf = (id: string) => `${id}_<PW>`;

// ---------- ์กฐ์ง / ์‚ฌ์šฉ์ž / ์—ญํ•  ----------
export const ROOT_ORG = 'mcmp';
export const TEAMS = ['iam', 'infra', 'workflow', 'app', 'cost', 'monitor'] as const;
export type Team = (typeof TEAMS)[number];

export type Role = 'admin' | 'operator' | 'viewer' | 'billviewer' | 'billadmin';
export const ROLES: Role[] = ['admin', 'operator', 'viewer', 'billviewer', 'billadmin'];

export interface TestUser {
  id: string; // ๋กœ๊ทธ์ธ ID (= ์ด๋ฉ”์ผ ์•ž๋ถ€๋ถ„)
  first: string;
  last: string;
  email: string;
  team: Team;
  role: Role;
  via: 'admin' | 'signup'; // admin: ๊ด€๋ฆฌ์ž๊ฐ€ Add User, signup: ์‚ฌ์šฉ์ž๊ฐ€ ์ง์ ‘ ๊ฐ€์ž… ํ›„ ์Šน์ธ
}

const mk = (id: string, first: string, last: string, team: Team, role: Role, via: TestUser['via']): TestUser => ({
  id, first, last, email: `${id}@mcmp.test`, team, role, via,
});

export const USERS: TestUser[] = [
  mk('iam01', 'iam', '01', 'iam', 'admin', 'admin'),
  mk('infra01', 'infra', '01', 'infra', 'operator', 'admin'),
  mk('workflow01', 'workflow', '01', 'workflow', 'operator', 'signup'),
  mk('app01', 'app', '01', 'app', 'operator', 'signup'),
  mk('monitor01', 'monitor', '01', 'monitor', 'viewer', 'signup'),
  mk('cost01', 'cost', '01', 'cost', 'billviewer', 'signup'),
  mk('cost02', 'cost', '02', 'cost', 'billadmin', 'signup'),
];
export const PENDING_USER = mk('pending01', 'pending', '01', 'iam', 'viewer', 'signup'); // ๊ฐ€์ž…๋งŒ ํ•˜๊ณ  ์Šน์ธํ•˜์ง€ ์•Š์Œ

// ---------- CSP ----------
export interface Csp {
  n: number; // CSP ๋ฒˆํ˜ธ (Infra mc{n}, K8s k{n}, Project p{n}, nodegroup g{n})
  name: string;
  code: string;
  skip?: string; // ์‚ฌ์œ ๊ฐ€ ์žˆ์œผ๋ฉด SKIP
  infra: { conn: string; spec: string; image: string; note: string };
  k8s: { conn: string; version: string; spec: string; note: string };
}

export const CSPS: Csp[] = [
  { n: 1, name: 'AWS', code: 'AWS',
    infra: { conn: 'aws-ap-northeast-2', spec: 't3.micro', image: 'ami-0afe1fd15675c3f15', note: '์ƒ์„ฑ PASS ์ด๋ ฅ' },
    k8s: { conn: 'aws-ap-northeast-2', version: '1.33~1.35 ์ค‘ ์„ ํƒ', spec: 't3.medium', note: 'ํด๋Ÿฌ์Šคํ„ฐ ์ƒ์„ฑ ์‹œ nodegroup ์ง€์ • ๋ถˆ๊ฐ€ โ†’ ์ƒ์„ฑ ํ›„ ์ถ”๊ฐ€. ์‚ญ์ œ ์‹œ nodegroup ๋จผ์ € ์‚ญ์ œ' } },
  { n: 2, name: 'Azure', code: 'Azure',
    infra: { conn: 'azure-koreacentral', spec: 'Standard_B1s', image: 'Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:22.04.202606110', note: '์ด๋ฏธ์ง€๋Š” ๊ฐ™์€ ๊ณ„์—ด ์ตœ์‹ ์œผ๋กœ ๋Œ€์ฒด๋  ์ˆ˜ ์žˆ์Œ' },
    k8s: { conn: 'azure-koreacentral', version: '(๋ฏธ๊ฒ€์ฆ)', spec: 'Standard_B2s (ํ›„๋ณด)', note: '์‹ค์ƒ์„ฑ ๊ธฐ๋ก ์—†์Œ. ๊ฒฐ๊ณผ ๊ทธ๋Œ€๋กœ ๊ธฐ๋ก' } },
  { n: 3, name: 'Alibaba', code: 'Alibaba',
    infra: { conn: 'alibaba-ap-northeast-2', spec: 'ecs.t6-c1m2.large', image: 'ubuntu_22_04_x64_20G_alibase_20260615.vhd', note: '์ฝ˜์†” nodegroup ์ถ”๊ฐ€ ์‹œ spec ๋ชฉ๋ก์— ์—†์œผ๋ฉด ecs.c7nex.large' },
    k8s: { conn: 'alibaba-ap-northeast-2', version: '1.35.7-aliyun.1', spec: 'ecs.e-c1m4.xlarge', note: 'ํ—ˆ์šฉ ๋ฒ„์ „๋งŒ ์„ ํƒ(1.36.2/1.35.7/1.34.10-aliyun.1). ๋ฃจํŠธ ๋””์Šคํฌ cloud_essd 40GB' } },
  { n: 4, name: 'GCP', code: 'GCP',
    infra: { conn: 'gcp-asia-northeast3', spec: 'n1-standard-1', image: '.../ubuntu-os-cloud/global/images/ubuntu-2204-jammy-v20260623', note: '์ƒ์„ฑ ์„ฑ๊ณต ์ด๋ ฅ' },
    k8s: { conn: 'gcp-asia-northeast3', version: '(๋ฏธ๊ฒ€์ฆ)', spec: 'e2-standard-2 (ํ›„๋ณด)', note: '์‹ค์ƒ์„ฑ ๊ธฐ๋ก ์—†์Œ. ๊ฒฐ๊ณผ ๊ทธ๋Œ€๋กœ ๊ธฐ๋ก' } },
  { n: 5, name: 'Tencent', code: 'Tencent',
    infra: { conn: 'tencent-ap-seoul', spec: 'S5.MEDIUM2', image: 'img-487zeit5', note: '์ฝ˜์†” nodegroup ์ถ”๊ฐ€ ์‹œ BF1.MEDIUM2 (์‘๋‹ต 504์ด๋‚˜ node๋Š” ์ƒ์„ฑ๋จ)' },
    k8s: { conn: 'tencent-ap-seoul', version: '1.34.1', spec: 'S8.LARGE16', note: '์‹œ์Šคํ…œ ๋””์Šคํฌ CLOUD_BSSD 50GB ํ•„์ˆ˜(๊ธฐ๋ณธ๊ฐ’์ด๋ฉด node 0๋Œ€). nodegroup์€ ์ƒ์„ฑ ํ›„ ์ถ”๊ฐ€' } },
  { n: 6, name: 'NHN Cloud', code: 'NHNCloud',
    infra: { conn: 'nhn-kr1', spec: 'm2.c1m2', image: '0f07c795-2a46-44fc-a61b-fa0d96763ce2', note: '๊ธฐ์กด ๊ณต์šฉ vNet ์‚ฌ์šฉ ํ•„์ˆ˜(์‹ ๊ทœ VPC ์ƒ์„ฑ ๊ธˆ์ง€: ๊ณผ๊ธˆ/์ฟผํ„ฐ ์ดˆ๊ณผ)' },
    k8s: { conn: 'nhn-kr1', version: 'v1.33', spec: 'r2.c4m16', note: '๋งˆ์ง€๋ง‰ nodegroup ์‚ญ์ œ ๊ฑฐ๋ถ€. ๊ธฐ์กด ๊ณต์šฉ vNet ์‚ฌ์šฉ' } },
  { n: 7, name: 'NCP', code: 'NCP',
    infra: { conn: 'ncp-kr', spec: 'ci2-g3', image: '23214590', note: 'g3 spec ํ•„์ˆ˜(g2์—๋Š” Ubuntu 22.04 ์ด๋ฏธ์ง€ ์—†์Œ)' },
    k8s: { conn: 'ncp-kr', version: '1.34 (์˜ˆ์ •)', spec: 'c2-g3 (ํ›„๋ณด)', note: '๊ณ„์ • VM ์ฟผํ„ฐ 0(ERROR_VM_LIMIT) ์‹คํŒจ ์ด๋ ฅ โ†’ BLOCKED ๊ฐ€๋Šฅ' } },
  { n: 8, name: 'IBM', code: 'IBM',
    infra: { conn: 'ibm-jp-tok', spec: 'bx2-2x8', image: 'Ubuntu 22.04 Minimal(๋ฆฌ์ „๋ณ„ ID)', note: '6์›” e2e ์ƒ์„ฑ ์‹คํŒจ ๊ธฐ๋ก. 2026-10-12 ์‚ฌ์ „์ ๊ฒ€์œผ๋กœ jp-tok/bx2-2x8 ๊ฒฐ์ •. ๊ฒฐ๊ณผ ๊ทธ๋Œ€๋กœ ๊ธฐ๋ก' },
    k8s: { conn: 'ibm-jp-tok', version: '1.34.10', spec: 'bx2-4x16', note: 'Expert ์ƒ์„ฑ๋งŒ ๊ฐ€๋Šฅ(Simple์€ ํ•ญ์ƒ ์‹คํŒจ). Active๊นŒ์ง€ ์•ฝ 28๋ถ„' } },
  // KT: Credential ๋“ฑ๋ก๋จ(2026-10-08), ์—ฐ๊ฒฐ kt-kr1. IV2(vNet/SG/SSH Key/Disk) ์™ธ InfraยทK8s ์ž…๋ ฅ๊ฐ’(spec/image)์€ ์•„์ง ๋ฏธ๊ฒ€์ฆ
  { n: 9, name: 'KT', code: 'KT',
    infra: { conn: 'kt-kr1', spec: '(๋ฏธ๊ฒ€์ฆ)', image: '(๋ฏธ๊ฒ€์ฆ)', note: 'IV2 ์ž์› ์ƒ์„ฑ๋งŒ ํ™•์ธ. Infra spec/image ๋Š” ์‚ฌ์ „์ ๊ฒ€ ํ•„์š”' },
    k8s: { conn: 'kt-kr1', version: '(๋ฏธ๊ฒ€์ฆ)', spec: '(๋ฏธ๊ฒ€์ฆ)', note: 'K8s ๋Š” AWS ์— ํ•œํ•ด ์ง„ํ–‰' } },
];

// ---------- TC_VARIANT: ์‹คํ–‰ํ•  CSP ์„ ํƒ ----------
// ์‚ฌ์šฉ ์˜ˆ)  TC_VARIANT=azure npx playwright test tests/iv2_to_cleanup.pending.spec.ts
//   - ๊ฐ’์€ CSP ์ด๋ฆ„/์ฝ”๋“œ(๋Œ€์†Œ๋ฌธ์žยท๊ณต๋ฐฑยท_ยท- ๋ฌด์‹œ): aws, azure, alibaba, gcp, tencent, nhn(๋˜๋Š” nhncloud), ncp, ibm, kt
//   - ์ง€์ •ํ•˜์ง€ ์•Š์œผ๋ฉด KT ๋ฅผ ์ œ์™ธํ•œ ๋ชจ๋“  CSP ๋ฅผ ์‹คํ–‰. CSP ์™€ ๋ฌด๊ด€ํ•œ ์‹œ๋‚˜๋ฆฌ์˜ค(IV1 ๋“ฑ)๋Š” ์˜ํ–ฅ ์—†์Œ.
const norm = (s: string) => s.toLowerCase().replace(/[\s_-]/g, '');
export const VARIANT = norm(process.env.TC_VARIANT ?? '');
const aliases: Record<string, string> = { nhn: 'nhncloud' };
const wanted = aliases[VARIANT] ?? VARIANT;
if (wanted && !CSPS.some((c) => norm(c.code) === wanted || norm(c.name) === wanted)) {
  throw new Error(`TC_VARIANT=${process.env.TC_VARIANT} ์— ํ•ด๋‹นํ•˜๋Š” CSP ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์‚ฌ์šฉ ๊ฐ€๋Šฅ: ${CSPS.map((c) => c.code).join(', ')}`);
}
/**
 * TC_VARIANT ๋กœ ์„ ํƒ๋œ CSP (KT ๋“ฑ skip ์‚ฌ์œ ๊ฐ€ ์žˆ๋Š” CSP ๋Š” ์ œ์™ธ).
 * only: ์‹œ๋‚˜๋ฆฌ์˜ค๊ฐ€ ์—‘์…€์—์„œ ํŠน์ • CSP ์—๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ(์˜ˆ: IV3_S09 ๋Š” AWS) ๊ทธ CSP ๋ชฉ๋ก์œผ๋กœ ํ•œ์ •ํ•œ๋‹ค.
 */
export const activeCsps = (only?: string[]) => {
  const set = only && only.length ? new Set(only.map(norm)) : null;
  return CSPS.filter(
    (c) => !c.skip && (!set || set.has(norm(c.code)) || set.has(norm(c.name))) && (!wanted || norm(c.code) === wanted || norm(c.name) === wanted),
  );
};

// ---------- ์ด๋ฆ„ ๊ทœ์น™ ----------
export const infraName = (c: Csp) => `mc${c.n}`;
/** Infra nodegroup 2๊ฐœ: g{n}a, g{n}b */
export const infraNodegroups = (c: Csp) => [`g${c.n}`, `g${c.n}b`]; // ์ง์ ‘ ์ƒ์„ฑ ์‹œ g{n}, ์ƒ์„ฑ ํ›„ ์ถ”๊ฐ€ํ•˜๋Š” nodegroup g{n}b

/** Workflow ๋กœ ๋งŒ๋“  Infra: mw{n} (์ง์ ‘ ์ƒ์„ฑ Infra ๋Š” mc{n}) */
export const wfInfraName = (c: Csp) => `mw${c.n}`;
/** IV2 ์ง์ ‘ ์ƒ์„ฑ ์ž์› ์ด๋ฆ„ */
export const vnetName = (c: Csp) => `v${c.n}`;
export const sgName = (c: Csp) => `sg${c.n}`;
export const keyName = (c: Csp) => `key${c.n}`;
export const diskName = (c: Csp) => `disk${c.n}`;
export const diskNameB = (c: Csp) => `disk${c.n}b`;
/** Infra node ์—์„œ ๋งŒ๋“  Custom Image */
export const imageName = (c: Csp) => `img${c.n}`;
export const k8sName = (c: Csp) => `k${c.n}`;
/** K8s nodegroup 1๊ฐœ: g{n} */
export const k8sNodegroup = (c: Csp) => `g${c.n}`;
export const k8sProject = (c: Csp) => `p${c.n}`;

/** ๋ฉ€ํ‹ฐ CSP Infra mc10: KT ์ œ์™ธ CSP๋ณ„ nodegroup 1๊ฐœ (g10a, g10b, ...) */
export const MC10 = {
  name: 'mc10',
  nodegroups: CSPS.filter((c) => !c.skip).map((c, i) => ({ name: `g10${String.fromCharCode(97 + i)}`, csp: c })),
};

/** ํ…Œ์ŠคํŠธ ID: IV3_S01_AWS / IV1_S01 */
export const tid = (scn: string, csp?: Csp) => (csp ? `${scn}_${csp.code}` : scn);

lib/fixtures.ts

ํ•ด์‹œ a71f8f2b ยท 84์ค„

import { test as base, expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { RUN_DIR, REC_PREFIX } from './runtag';

/**
 * ๊ณตํ†ต fixture
 * 1) ๋„ค์ดํ‹ฐ๋ธŒ alert/confirm ์„ ์ž๋™ ์ˆ˜๋ฝํ•˜๊ณ  ๋ฉ”์‹œ์ง€๋ฅผ ๊ธฐ๋กํ•œ๋‹ค.
 *    (์ฝ˜์†”์ด "Group created successfully." ๊ฐ™์€ ๋„ค์ดํ‹ฐ๋ธŒ alert ๋ฅผ ์‚ฌ์šฉํ•˜๋ฏ€๋กœ ์ž๋™ํ™”์—์„œ ๋ฐ˜๋“œ์‹œ ์ฒ˜๋ฆฌํ•ด์•ผ ํ•œ๋‹ค.
 *     ์ˆ˜๋™ ๋…นํ™” ์‹œ์—๋Š” ์‚ฌ๋žŒ์ด ์ง์ ‘ "ํ™•์ธ"์„ ๋ˆŒ๋Ÿฌ์•ผ ํ•œ๋‹ค.)
 * 2) ํ…Œ์ŠคํŠธ๊ฐ€ ๋๋‚˜๋ฉด ๋น„๋””์˜ค๋ฅผ results/recordings/{๋…นํ™”ํŒŒ์ผ๋ช…}.webm ์œผ๋กœ ๋ณต์‚ฌํ•œ๋‹ค.
 *    ํ…Œ์ŠคํŠธ ID(=๋…นํ™”๋ฒˆํ˜ธ) = ํ…Œ์ŠคํŠธ ์ œ๋ชฉ์˜ ์ฒซ ๋‹จ์–ด (์˜ˆ: "IV3_S01_AWS ์ด๋ฆ„...").
 *    ๋…นํ™”ํŒŒ์ผ๋ช…์€ ์—‘์…€ '๋…นํ™”ํŒŒ์ผ๋ช…' ์—ด(run-order.json ์˜ rec)์ด๊ณ , ๋น„์–ด ์žˆ์œผ๋ฉด ํ…Œ์ŠคํŠธ ID ๋ฅผ ์“ด๋‹ค.
 *    ๊ทœ์น™(2026-10-02): {์‹œ๋‚˜๋ฆฌ์˜ค๋ฒˆํ˜ธ}_{ํ™”๋ฉด}_{๋™์ž‘}[_{CSP}][_NG]  ์˜ˆ) IV1_S01_Groups_CreateOrg, IV3_S02_InfraWorkloads_Create_AWS, IV1_S10_Login_WrongPassword_NG
 */
let recNames: Record<string, string> | undefined;
function recordingName(id: string): string {
  if (!recNames) {
    recNames = {};
    try {
      for (const r of JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'run-order.json'), 'utf-8'))) if (r.rec) recNames[r.id] = r.rec;
    } catch {
      // run-order.json ์ด ์—†๊ฑฐ๋‚˜ ์ฝ์„ ์ˆ˜ ์—†์œผ๋ฉด ํ…Œ์ŠคํŠธ ID ๋กœ ์ €์žฅ
    }
  }
  return recNames[id] || id;
}

export const test = base.extend<{ dialogs: string[]; recording: void; netRetry: void }>({
  // WSL ์—์„œ Docker ๋ธŒ๋ฆฟ์ง€ ๋“ฑ ๋„คํŠธ์›Œํฌ ์ธํ„ฐํŽ˜์ด์Šค๊ฐ€ ๋ฐ”๋€Œ๋ฉด Chromium ์ด ๋กœ๋”ฉ ์ค‘ ERR_NETWORK_CHANGED ๋กœ ๋Š๋Š”๋‹ค(2026-10-01 ๊ด€์ฐฐ).
  // ํ™˜๊ฒฝ ์ผ์‹œ ์˜ค๋ฅ˜์ด๋ฏ€๋กœ page.goto / page.reload ๋ฅผ ์ตœ๋Œ€ 3ํšŒ ์žฌ์‹œ๋„ํ•œ๋‹ค(reload ๋„ ๊ฐ™์€ ์˜ค๋ฅ˜๋กœ ์‹คํŒจ, 2026-10-05).
  netRetry: [
    async ({ page }, use) => {
      const retry = <T>(fn: (...a: any[]) => Promise<T>) => async (...args: any[]): Promise<T> => {
        for (let i = 0; ; i++) {
          try {
            return await fn(...args);
          } catch (e) {
            if (i < 3 && /ERR_NETWORK_CHANGED|ERR_NETWORK_IO_SUSPENDED|ERR_CONNECTION_RESET|ERR_NAME_NOT_RESOLVED/.test(String(e))) {
              await page.waitForTimeout(1500);
              continue;
            }
            throw e;
          }
        }
      };
      (page as any).goto = retry(page.goto.bind(page));
      (page as any).reload = retry(page.reload.bind(page));
      await use();
    },
    { auto: true },
  ],
  dialogs: [
    async ({ page }, use, testInfo) => {
      const log: string[] = [];
      page.on('dialog', async (d) => {
        log.push(`${d.type()}: ${d.message()}`);
        await d.accept();
      });
      await use(log);
      testInfo.annotations.push({ type: 'dialogs', description: log.join(' | ') || '(์—†์Œ)' });
    },
    { auto: true },
  ],
  recording: [
    async ({ page }, use, testInfo) => {
      await use();
      const video = page.video();
      await page.close();
      if (!video) return;
      const id = testInfo.title.split(' ')[0];
      const dir = path.join(RUN_DIR, 'recordings'); // ์˜ˆ: results/v07/recordings/v07_IV1_S01_Groups_CreateOrg.webm
      fs.mkdirSync(dir, { recursive: true });
      await video.saveAs(path.join(dir, `${REC_PREFIX}${recordingName(id)}.webm`));
    },
    { auto: true },
  ],
});

export { expect };

/** ์•Œ๋ ค์ง„ ์ œํ’ˆ ์ด์Šˆ: ํ…Œ์ŠคํŠธ๋Š” ์‹คํŒจํ•  ๊ฒƒ์œผ๋กœ ๊ธฐ๋Œ€ํ•œ๋‹ค(์ˆ˜์ •๋˜๋ฉด ํ†ต๊ณผ๋กœ ๋ฐ”๋€Œ์–ด ์•Œ ์ˆ˜ ์žˆ์Œ). */
export const knownIssue = (t: typeof test, reason: string) => t.fail(true, reason);

lib/forms.ts

ํ•ด์‹œ 097a9dfb ยท 184์ค„

import type { Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { pwOf, type TestUser } from './data';

export const URL_GROUPS = '/webconsole/settings/accountnaccess/organizations/groups';
export const URL_USERS = '/webconsole/settings/accountnaccess/organizations/users';
export const URL_APPROVALS = '/webconsole/settings/accountnaccess/organizations/approvals';
export const URL_ROLES = '/webconsole/operations/manage/workspaces/roles';

/** <select> ์—์„œ ๋ผ๋ฒจ์ด prefix ๋กœ ์‹œ์ž‘ํ•˜๋Š” ์˜ต์…˜์„ ์„ ํƒ (์กฐ์ง์€ "iam (0201)" ์ฒ˜๋Ÿผ ์ฝ”๋“œ๊ฐ€ ๋ถ™๋Š”๋‹ค). */
export async function selectByPrefix(select: Locator, prefix: string) {
  // ์˜ต์…˜์€ ๋น„๋™๊ธฐ๋กœ ์ฑ„์›Œ์ง„๋‹ค(ํŒ์—…์„ ์—ฐ ์งํ›„์—๋Š” "Select a group" ํ•˜๋‚˜๋ฟ) โ†’ 2๊ฐœ ์ด์ƒ์ด ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ
  await expect.poll(async () => select.locator('option').count(), { timeout: 15_000 }).toBeGreaterThan(1);
  const labels = await select.locator('option').allTextContents();
  const label = labels.map((s) => s.trim()).find((s) => s === prefix || s.startsWith(`${prefix} (`));
  if (!label) throw new Error(`์˜ต์…˜ ์—†์Œ: ${prefix} / ์กด์žฌ: ${labels.join(', ')}`);
  await select.selectOption({ label });
}

/** Groups > Create Org ๋กœ ์กฐ์ง ์ƒ์„ฑ. parent ๊ฐ€ ์—†์œผ๋ฉด ์ตœ์ƒ์œ„. */
export async function createOrg(page: Page, name: string, parent?: string) {
  await page.getByRole('button', { name: 'Create Org' }).click();
  const dlg = page.getByRole('dialog');
  await dlg.getByPlaceholder('Enter group name').fill(name);
  if (parent) await selectByPrefix(dlg.getByRole('combobox'), parent);
  await dlg.getByRole('button', { name: 'Save' }).click();
}

/** Users > Add User (๊ด€๋ฆฌ์ž๊ฐ€ ์‚ฌ์šฉ์ž ์ƒ์„ฑ). ํผ์ด ์ด์ „ ๊ฐ’์„ ์ง€์šฐ์ง€ ์•Š์œผ๋ฏ€๋กœ fill ๋กœ ๋ฎ์–ด์“ด๋‹ค. */
export async function adminCreateUser(page: Page, u: TestUser) {
  await page.goto(URL_USERS);
  await page.getByRole('link', { name: 'Add User' }).click();
  await page.getByPlaceholder('Enter first name').fill(u.first);
  await page.getByPlaceholder('Enter last name').fill(u.last);
  await page.getByPlaceholder('Login ID (e.g. abc)').fill(u.id);
  await page.getByPlaceholder('Enter email address').fill(u.email);
  await page.getByRole('button', { name: 'Deploy' }).click();
}

/** Users ๋ชฉ๋ก์— ์‚ฌ์šฉ์ž๊ฐ€ ์ด๋ฏธ ์žˆ๋Š”์ง€(์žฌ์‹คํ–‰ ๊ฐ€๋Šฅํ•˜๋„๋ก "์—†์„ ๋•Œ๋งŒ ์ƒ์„ฑ"ํ•˜๋Š” ๋ฐ ์‚ฌ์šฉ). admin ๋กœ๊ทธ์ธ ์ƒํƒœ ํ•„์š”. */
export async function userExists(page: Page, u: TestUser): Promise<boolean> {
  await page.goto(URL_USERS);
  await page.getByRole('gridcell').first().waitFor({ timeout: 20_000 }).catch(() => {});
  return (await page.getByRole('gridcell', { name: `${u.first} ${u.last}` }).count()) > 0;
}

/** Users ๋ชฉ๋ก์˜ Platform Roles ์—ด์ด Y ์ธ์ง€(= ์—ญํ• ์ด ์ด๋ฏธ ํ• ๋‹น๋๋Š”์ง€). ํ–‰ ํ…์ŠคํŠธ "โ€ฆ Enabled Y Y N" ์—์„œ ์ฝ๋Š”๋‹ค. */
export async function platformRoleAssigned(page: Page, u: TestUser): Promise<boolean> {
  await page.goto(URL_USERS);
  await page.getByRole('gridcell').first().waitFor({ timeout: 20_000 }).catch(() => {});
  const row = page.getByRole('row').filter({ hasText: u.email }).first();
  // ํ–‰ ํ…์ŠคํŠธ๋Š” ์…€ ์‚ฌ์ด ๊ณต๋ฐฑ ์—†์ด ๋ถ™์–ด์„œ ์ฝํžŒ๋‹ค: "workflow [email protected]"
  const text = ((await row.innerText().catch(() => '')) || '').replace(/\s+/g, ' ');
  const m = text.match(/(Enabled|Disabled)\s*([YN])\s*([YN])\s*([YN])/);
  return m ? m[2] === 'Y' : false;
}

/** Approvals ์— ์Šน์ธ ๋Œ€๊ธฐ ์ค‘์ธ์ง€. admin ๋กœ๊ทธ์ธ ์ƒํƒœ ํ•„์š”. */
export async function pendingApproval(page: Page, u: TestUser): Promise<boolean> {
  await page.goto(URL_APPROVALS);
  await page.waitForTimeout(2500);
  return (await page.getByRole('row', { name: new RegExp(u.email) }).count()) > 0;
}

/** Users ๋ชฉ๋ก์—์„œ ์‚ฌ์šฉ์ž ํ–‰ ์„ ํƒ. */
export async function selectUserRow(page: Page, u: TestUser) {
  await page.getByRole('gridcell', { name: `${u.first} ${u.last}` }).click();
}

/** Users > ์‚ฌ์šฉ์ž ์„ ํƒ > Add Group (Assign Group ํŒ์—…). */
export async function addGroupFromUsers(page: Page, u: TestUser) {
  await page.getByRole('button', { name: 'Add Group' }).click();
  const dlg = page.getByRole('dialog');
  await selectByPrefix(dlg.getByRole('combobox'), u.team);
  await dlg.getByRole('button', { name: 'Assign' }).click();
}

/** Groups > ์กฐ์ง ์„ ํƒ > Invite Member (์‚ฌ์šฉ์ž ์ฒดํฌ ํ›„ Invite). */
export async function inviteMemberFromGroups(page: Page, u: TestUser) {
  await page.goto(URL_GROUPS);
  // ํŠธ๋ฆฌ ํ•ญ๋ชฉ์€ Playwright ์—์„œ treeitem ์—ญํ• ๋กœ ์žกํžˆ์ง€ ์•Š์•„(2026-10-01) ํŠธ๋ฆฌ ์•ˆ์˜ ํ…์ŠคํŠธ๋กœ ์ฐพ๋Š”๋‹ค.
  await page.getByRole('tree').getByText(u.team, { exact: true }).first().click();
  // ์ƒ์„ธ(Path /mcmp/{team})๊ฐ€ ๋œจ๊ธฐ ์ „์— Invite Member ๋ฅผ ๋ˆ„๋ฅด๋ฉด ํด๋ฆญ์ด ๋ฌด์‹œ๋œ๋‹ค(2026-10-01 Playwright ํ™•์ธ) โ†’ ์ƒ์„ธ๋ฅผ ๊ธฐ๋‹ค๋ฆฐ ๋’ค ํด๋ฆญ
  await expect(page.getByText(`/mcmp/${u.team}`, { exact: true })).toBeVisible();
  await page.getByRole('button', { name: /Invite Member/ }).click();
  const dlg = page.getByRole('dialog');
  await expect(dlg).toBeVisible();
  const cb = dlg.locator('li, tr, div').filter({ hasText: u.email }).getByRole('checkbox').first();
  await cb.waitFor();
  if (await cb.isDisabled()) {
    // ์ด๋ฏธ ์ด ์กฐ์ง์˜ ๋ฉค๋ฒ„์ด๋ฉด ์ฒดํฌ๋ฐ•์Šค๊ฐ€ disabled ๋กœ ํ‘œ์‹œ๋œ๋‹ค โ†’ ๊ฑด๋„ˆ๋œ€
    await dlg.getByRole('button', { name: 'Cancel' }).click();
    return;
  }
  await cb.check();
  await dlg.getByRole('button', { name: 'Invite' }).click();
  // exact: ๋ถ€๋ถ„ ์ผ์น˜๋ฉด ๋‹ซํžŒ ์ดˆ๋Œ€ ํŒ์—…์˜ ์ด๋ฉ”์ผ span(hidden)์„ ๋จผ์ € ์žก๋Š”๋‹ค(2026-10-04, rec1004b)
  await expect(page.getByText(u.id, { exact: true }).first()).toBeVisible();
}

/** Add Role Mapping ํŒ์—…: platform / workspace ์—ญํ•  ๋ถ€์—ฌ. Role Type ์€ ๋ฒ„ํŠผ์— ๋”ฐ๋ผ ์ž๋™ ์„ ํƒ๋˜๋ฏ€๋กœ Role ๋งŒ ๊ณ ๋ฅธ๋‹ค. */
export async function addRole(page: Page, kind: 'platform' | 'workspace', role: string) {
  await page.getByRole('button', { name: kind === 'platform' ? /Add platform role/i : /Add Workspace role/i }).click();
  const dlg = page.getByRole('dialog');
  await dlg.getByRole('combobox').nth(1).selectOption({ label: role });
  await dlg.getByRole('button', { name: 'Add Role' }).click();
}

export async function addBothRoles(page: Page, role: string) {
  await addRole(page, 'platform', role);
  await addRole(page, 'workspace', role);
}

/**
 * ์‚ฌ์šฉ์ž ์ง์ ‘ ๊ฐ€์ž…: ๋กœ๊ทธ์ธ ํ™”๋ฉด -> Sign Up.
 * ๊ฐ€์ž… ํ›„์—๋Š” ๋กœ๊ทธ์ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•˜์ง€ ์•Š๊ณ  /auth/signup ์— ๋จธ๋ฌผ๋ฉฐ ๋‹ค์Œ ์•ˆ๋‚ด๋ฅผ ๋ณด์—ฌ ์ค€๋‹ค(2026-10-01 Playwright ํ™•์ธ):
 *   "Registration request submitted. You can log in after administrator approval. You will be notified by email upon approval." + [Go to Login]
 */
export async function signUp(page: Page, u: TestUser) {
  await page.goto('/auth/login');
  await page.getByRole('link', { name: 'Sign Up' }).click();
  await page.getByPlaceholder('Your email address').fill(u.email);
  await page.getByPlaceholder('Your password').fill(pwOf(u.id));
  await page.getByPlaceholder('Your first name').fill(u.first);
  await page.getByPlaceholder('Your last name').fill(u.last);
  await page.getByRole('button', { name: 'Sign Up' }).click();
  await expect(page.getByText(/Registration request submitted/)).toBeVisible();
}

/** Users > Reset Password (๊ด€๋ฆฌ์ž๊ฐ€ ๋งŒ๋“  ์‚ฌ์šฉ์ž์˜ ๋น„๋ฐ€๋ฒˆํ˜ธ ์„ค์ •). ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” {id}_password. */
export async function resetPassword(page: Page, u: TestUser) {
  await page.getByRole('button', { name: 'Reset Password' }).click();
  const dlg = page.getByRole('dialog');
  await dlg.getByPlaceholder('Enter new password').fill(pwOf(u.id));
  await dlg.getByPlaceholder('Confirm new password').fill(pwOf(u.id));
  await dlg.getByRole('button', { name: 'Reset Password' }).click();
}

/**
 * Groups > Export JSON. ํ™”๋ฉด์ด Blob ๋‹ค์šด๋กœ๋“œ๋กœ {์ตœ์ƒ์œ„ ์กฐ์ง๋ช…}-OrganizationTree-YYYYMMDD.json ์„ ๋‚ด๋ ค์ค€๋‹ค(2026-10-02 ์‚ฌ์ „์ ๊ฒ€).
 * ๋‚ด๋ ค๋ฐ›์€ ํŒŒ์ผ์„ savePath ์— ์ €์žฅํ•˜๊ณ  ํŒŒ์ผ๋ช…์„ ๋Œ๋ ค์ค€๋‹ค.
 */
export async function exportGroupsJson(page: Page, savePath: string): Promise<string> {
  const dl = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Export JSON' }).click();
  const d = await dl;
  await d.saveAs(savePath);
  return d.suggestedFilename();
}

/**
 * Groups > Import JSON. ๋ฒ„ํŠผ์€ ์ˆจ๊ฒจ์ง„ ํŒŒ์ผ ์ž…๋ ฅ(#import-groups-file, .json)์„ ์—ด๊ณ , ํŒŒ์ผ์„ ๊ณ ๋ฅด๋Š” ์ฆ‰์‹œ ๊ฐ€์ ธ์˜จ๋‹ค(ํ™•์ธ์ฐฝ ์—†์Œ).
 * ๋™์ž‘(2026-10-02 ์†Œ์Šค ํ™•์ธ): organization_code ๋กœ ๊ธฐ์กด ๊ทธ๋ฃน๊ณผ ๋น„๊ต โ†’ ์ด๋ฆ„ ๊ฐ™์œผ๋ฉด ๋ณ€๊ฒฝ ์—†์Œ, ๋‹ค๋ฅด๋ฉด ์ด๋ฆ„ ์ˆ˜์ •, ์—†์œผ๋ฉด ์ƒ์„ฑ.
 * ๊ฒฐ๊ณผ๋Š” #import-result-body("Import complete." + N group(s) created/updated ...), JSON ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ ์•Œ๋Ÿฟ "Invalid JSON file.".
 */
export async function importGroupsJson(page: Page, file: string | { name: string; mimeType: string; buffer: Buffer }) {
  const chooser = page.waitForEvent('filechooser');
  await page.getByRole('button', { name: 'Import JSON' }).click();
  await (await chooser).setFiles(file);
}


/**
 * ๋‹ค์šด๋กœ๋“œ ๊ฒฐ๊ณผ๋ฅผ ๋…นํ™”์— ๋‚จ๊ธฐ๊ธฐ ์œ„ํ•œ ํ™”๋ฉด ํ‘œ์‹œ. ํ—ค๋“œ๋ฆฌ์Šค ๋ธŒ๋ผ์šฐ์ €๋Š” ๋‹ค์šด๋กœ๋“œ ํ‘œ์‹œ์ค„(ํŒŒ์ผ๋ช…)์ด ์˜์ƒ์— ์žกํžˆ์ง€ ์•Š์œผ๋ฏ€๋กœ
 * ํŽ˜์ด์ง€ ์œ„์— (1) ์šฐ์ƒ๋‹จ '๋‹ค์šด๋กœ๋“œ ์™„๋ฃŒ: ํŒŒ์ผ๋ช…' ์•Œ๋ฆผ (2) ๋‚ด๋ ค๋ฐ›์€ ํŒŒ์ผ ๋‚ด์šฉ(JSON ์€ ๋“ค์—ฌ์“ฐ๊ธฐ) ํŒจ๋„์„ ๋„์šฐ๊ณ  holdMs ๋™์•ˆ ๋ณด์—ฌ ์ค€๋‹ค.
 */
export async function showDownloadOverlay(page: Page, fileName: string, content: string, holdMs = 6000) {
  let body = content;
  try { body = JSON.stringify(JSON.parse(content), null, 2); } catch { /* JSON ์ด ์•„๋‹ˆ๋ฉด ์›๋ฌธ */ }
  await page.evaluate(([name, text]) => {
    const bubble = document.createElement('div');
    bubble.id = 'e2e-download-bubble';
    bubble.style.cssText = 'position:fixed;top:16px;right:16px;z-index:2147483647;background:#fff;color:#1f2937;border:1px solid #d1d5db;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.25);padding:12px 16px;font:14px/1.4 sans-serif;min-width:320px';
    bubble.innerHTML = '<div style="color:#16a34a;font-weight:600">๋‹ค์šด๋กœ๋“œ ์™„๋ฃŒ</div><div id="e2e-dl-name" style="margin-top:4px;font-family:monospace"></div>';
    document.body.appendChild(bubble);
    (document.getElementById('e2e-dl-name') as HTMLElement).textContent = name;
    const panel = document.createElement('div');
    panel.id = 'e2e-download-content';
    panel.style.cssText = 'position:fixed;top:96px;right:16px;bottom:16px;width:760px;z-index:2147483646;background:#0f172a;color:#e2e8f0;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.35);padding:12px 16px;font:12px/1.5 monospace;overflow:auto;white-space:pre';
    panel.textContent = text;
    document.body.appendChild(panel);
  }, [fileName, body]);
  await page.waitForTimeout(holdMs);
}

lib/groups.ts

ํ•ด์‹œ 22dfda0e ยท 206์ค„

import { test, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { URL_APPROVALS, URL_GROUPS, URL_USERS, selectByPrefix } from './forms';
import type { TestUser } from './data';

// Groups ํ™”๋ฉด(์กฐ์ง) ์ƒ์„ฑ/์ƒ์„ธ/์ˆ˜์ •/์‚ญ์ œ ์…€๋ ‰ํ„ฐ: 2026-10-04 ํ”„๋กœ๋ธŒ๋กœ ํ™•์ธ
//   ์ƒ์„ธ ํŒจ๋„ ๋ฒ„ํŠผ #btn-add-child, #btn-edit, #btn-delete
//   Create Org ํŒ์—… #group-name, #group-description, #group-parent-select, #group-code(๋น„์šฐ๋ฉด ์ž๋™)
//   Edit ํŒ์—… #edit-group-modal: #edit-group-name, #edit-group-description, #edit-group-parent-select, #edit-group-code
//   Delete ํŒ์—… #delete-group-modal: "Groups with child organizations or assigned users cannot be deleted."

/** ๋…นํ™”์šฉ ์กฐ์ง(์‹ค์ œ ์กฐ์ง mcmp/iamโ€ฆ ์€ ์‚ฌ์šฉ์ž ๋ฐฐ์ •์ด ์žˆ์–ด ์ˆ˜์ •ยท์‚ญ์ œ ๋Œ€์ƒ์—์„œ ์ œ์™ธ, 2026-10-04 ์‚ฌ์šฉ์ž ๊ฒฐ์ •) */
export const REC_ORG = 'recorg';
export const REC_TEAM = 'recteam';
export const REC_TEAM_EDITED = 'recteam2';

/**
 * ๋…นํ™”์šฉ ์‚ฌ์šฉ์ž(IV1_S02~S04). stage ๋Š” ์‚ฌ์šฉ์ž ์‚ญ์ œ๊ฐ€ ๋ถˆ๊ฐ€ํ•˜๋ฏ€๋กœ ์žฌ๋…นํ™” ๋•Œ๋งˆ๋‹ค ์ ‘๋ฏธ์‚ฌ๋ฅผ ๋ฐ”๊พผ๋‹ค(REC_SUFFIX, ๊ธฐ๋ณธ 1004 = 2026-10-04).
 * ๊ถŒํ•œ ์ตœ์†Œํ™”: ์กฐ์ง monitor, ์—ญํ•  viewer.
 */
const REC_SUFFIX = process.env.REC_SUFFIX ?? '1004';
const recUser = (id: string, via: TestUser['via']): TestUser => ({ id, first: 'rec', last: id.slice(3), email: `${id}@mcmp.test`, team: 'monitor', role: 'viewer', via });
export const REC_ADMIN_USER = recUser(`rec${REC_SUFFIX}a`, 'admin');   // IV1_S02: ๊ด€๋ฆฌ์ž๊ฐ€ Add User
export const REC_SIGNUP_USER = recUser(`rec${REC_SUFFIX}b`, 'signup'); // IV1_S03 ๊ฐ€์ž… โ†’ IV1_S04 ์Šน์ธ
/** IV1_S22 ์‚ญ์ œ ๋Œ€์ƒ: ์ด๋ฆ„์ด 'rec' + ์ˆซ์ž๋กœ ์‹œ์ž‘ํ•˜๋Š” ๋…นํ™”์šฉ ์‚ฌ์šฉ์ž(Users ๋ชฉ๋ก์—์„œ ๋™์ ์œผ๋กœ ์ˆ˜์ง‘) */
export const REC_USER_ID = /^rec\d{4}[a-z]{1,2}$/;
export const recUserFromId = (id: string) => recUser(id, 'admin');

/**
 * Users ๋ชฉ๋ก์„ ์—ด๊ณ  ๋ชจ๋“  ์‚ฌ์šฉ์ž๊ฐ€ ํ•œ ํŽ˜์ด์ง€์— ๋ณด์ด๊ฒŒ ํ•œ๋‹ค.
 * ๋…นํ™”์šฉ ์‚ฌ์šฉ์ž๊ฐ€ ๋Š˜์–ด ๊ธฐ์กด ์‚ฌ์šฉ์ž๊ฐ€ 2ํŽ˜์ด์ง€๋กœ ๋ฐ€๋ฆฌ๋ฉด forms.ts userExists/platformRoleAssigned ๊ฐ€ '์—†์Œ'์œผ๋กœ ์˜คํŒ(2026-10-05 ๊ด€์ฐฐ) โ†’ ํŽ˜์ด์ง€ ํฌ๊ธฐ ์ตœ๋Œ€.
 */
export async function gotoUsersAll(page: Page) {
  await page.goto(URL_USERS);
  // ์ฃผ์†Œ ์ด๋™ ์งํ›„ ๋ชฉ๋ก์ด 90์ดˆ ๋„˜๊ฒŒ ๋น„์–ด ์žˆ๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์—ˆ์Œ(IV1_S28, 2026-10-05 2ํšŒ ์—ฐ์†) โ†’ 30์ดˆ์”ฉ ๊ธฐ๋‹ค๋ฆฌ๋ฉฐ ์ƒˆ๋กœ๊ณ ์นจ ์žฌ์‹œ๋„
  for (let attempt = 1; ; attempt++) {
    if (await page.getByRole('gridcell').first().waitFor({ timeout: 30_000 }).then(() => true, () => false)) break;
    if (attempt >= 3) throw new Error('Users ๋ชฉ๋ก์ด 90์ดˆ ๋™์•ˆ ํ‘œ์‹œ๋˜์ง€ ์•Š์Œ');
    test.info().annotations.push({ type: 'list-retry', description: `์‹œ๋„ ${attempt}: Users ๋ชฉ๋ก ๋ฏธํ‘œ์‹œ โ†’ ์ƒˆ๋กœ๊ณ ์นจ` });
    await page.reload();
  }
  const sizeSel = page.locator('select.tabulator-page-size').first();
  if (await sizeSel.count()) {
    const vals = (await sizeSel.locator('option').evaluateAll((os) => os.map((o) => (o as HTMLOptionElement).value))).filter((v) => /^\d+$/.test(v));
    const max = vals.sort((a, b) => Number(b) - Number(a))[0];
    if (max) await sizeSel.selectOption(max);
    await page.waitForTimeout(1500);
  }
}

export async function userExistsAll(page: Page, u: TestUser) {
  await gotoUsersAll(page);
  return (await page.getByRole('gridcell', { name: `${u.first} ${u.last}` }).count()) > 0;
}

/** Platform Roles ์—ด Y ์—ฌ๋ถ€(ํ–‰ ํ…์ŠคํŠธ 'โ€ฆ Enabled Y Y N'). ์ „์ฒด ํŽ˜์ด์ง€ ๊ธฐ์ค€ */
export async function roleAssignedAll(page: Page, u: TestUser) {
  await gotoUsersAll(page);
  const text = ((await page.getByRole('row').filter({ hasText: u.email }).first().innerText().catch(() => '')) || '').replace(/\s+/g, ' ');
  const m = text.match(/(Enabled|Disabled)\s*([YN])\s*([YN])\s*([YN])/);
  return m ? m[2] === 'Y' : false;
}

/**
 * Approvals ์— ์Šน์ธ ๋Œ€๊ธฐ ์ค‘์ธ์ง€. forms.ts pendingApproval ์€ 2.5์ดˆ๋งŒ ๊ธฐ๋‹ค๋ ค stage ์ง€์—ฐ ์‹œ '์—†์Œ'์œผ๋กœ ์˜คํŒ(2026-10-05) โ†’
 * waitMs ๋™์•ˆ ๋Œ€๊ธฐ ํ–‰์ด ๋‚˜ํƒ€๋‚˜๋Š”์ง€ ํ™•์ธ(๋…นํ™”์šฉ ์‚ฌ์šฉ์ž์ฒ˜๋Ÿผ ๋Œ€๊ธฐ ์ค‘์ด์–ด์•ผ ํ•˜๋Š” ๋Œ€์ƒ์— ์‚ฌ์šฉ).
 */
export async function pendingApprovalWait(page: Page, u: TestUser, waitMs = 60_000) {
  await page.goto(URL_APPROVALS);
  return page.getByRole('row', { name: new RegExp(u.email) }).first().waitFor({ timeout: waitMs }).then(() => true, () => false);
}

/**
 * Users ๋ชฉ๋ก์—์„œ ์‚ฌ์šฉ์ž ์‚ญ์ œ: **ํ–‰ ํด๋ฆญ(์‚ฌ์šฉ์ž ์„ ํƒ)** โ†’ ๋ชฉ๋ก โŒ„ ๋ฉ”๋‰ด Delete โ†’ #commonDefaultModal('Would you like to delete this user ?') Confirm.
 * ์ฒดํฌ๋ฐ•์Šค๋งŒ ์„ ํƒํ•˜๋ฉด Confirm ํ›„ ์‚ญ์ œ ์š”์ฒญ์ด ๋‚˜๊ฐ€์ง€ ์•Š์Œ(2026-10-05 ๊ด€์ฐฐ) โ†’ ์‚ฌ์šฉ์ž ์ง€์‹œ๋กœ ํ–‰ ํด๋ฆญ ๋ฐฉ์‹(2026-10-05).
 * ๋ฐ˜ํ™˜: ํ™•์ธ ๋ชจ๋‹ฌ ๋ฌธ๊ตฌ(์‚ญ์ œ API ์š”์ฒญยท์‘๋‹ต์€ annotation 'user-delete-api').
 */
export async function deleteUserByRow(page: Page, u: TestUser) {
  await gotoUsersAll(page);
  await page.getByRole('row').filter({ hasText: u.email }).first().getByRole('gridcell', { name: `${u.first} ${u.last}` }).click();
  await page.waitForTimeout(2000); // ์ƒ์„ธ ๋กœ๋”ฉ(์„ ํƒ ์‚ฌ์šฉ์ž ์ง€์ •)
  const apis: string[] = [];
  const onResp = async (r: import('@playwright/test').Response) => {
    if (/delete/i.test(r.url()) && r.request().method() !== 'OPTIONS') apis.push(`${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, '')} ${(r.request().postData() || '').slice(0, 150)}`);
  };
  page.on('response', onResp);
  await page.locator('.dropdown').filter({ has: page.locator('.dropdown-item', { hasText: /^\s*Delete\s*$/ }) }).locator('[data-bs-toggle="dropdown"]').first().click();
  await page.locator('.dropdown-item', { hasText: /^\s*Delete\s*$/ }).first().click();
  const modal = page.locator('#commonDefaultModal');
  await expect(modal).toBeVisible();
  const text = (await modal.innerText()).replace(/\s+/g, ' ');
  await modal.getByRole('button', { name: 'Confirm' }).click();
  await page.waitForTimeout(4000);
  page.off('response', onResp);
  test.info().annotations.push({ type: 'user-delete-api', description: apis.join(' | ') || '(์š”์ฒญ ์—†์Œ)' });
  return text;
}

/** Groups > ์กฐ์ง ์„ ํƒ > Invite Member: ์‚ฌ์šฉ์ž ์ฒดํฌ ํ›„ Invite โ†’ ๋ฉค๋ฒ„ ๋ชฉ๋ก์— ํ‘œ์‹œ(ํŠธ๋ฆฌ ๋กœ๋”ฉ์„ ๊ธฐ๋‹ค๋ฆผ) */
export async function inviteToGroup(page: Page, u: TestUser) {
  await gotoGroups(page);
  await openGroup(page, u.team, `/mcmp/${u.team}`);
  // ์ƒ์„ธ๊ฐ€ ๋‹ค ๋œจ๊ธฐ ์ „ ํด๋ฆญ์€ ๋ฌด์‹œ๋œ๋‹ค(2026-10-01 ๊ด€์ฐฐ, 2026-10-05 ์žฌ๋ฐœ) โ†’ ํŒ์—…์ด ์—ด๋ฆด ๋•Œ๊นŒ์ง€ ์ตœ๋Œ€ 3ํšŒ ํด๋ฆญ
  const dlg = page.getByRole('dialog');
  for (let i = 1; ; i++) {
    await page.locator('#btn-invite-member').click();
    if (await dlg.waitFor({ timeout: 5_000 }).then(() => true, () => false)) break;
    if (i >= 3) await expect(dlg, 'Invite Member ํŒ์—…').toBeVisible();
    test.info().annotations.push({ type: 'invite-retry', description: `Invite Member ํด๋ฆญ ํ›„ ํŒ์—… ์—†์Œ(์‹œ๋„ ${i})` });
    await page.waitForTimeout(1500);
  }
  // ์‚ฌ์šฉ์ž ํ•œ ์ค„ = div.invite-user-row(์ด๋ฏธ ๋ฉค๋ฒ„๋ฉด ์ฒดํฌ๋ฐ•์Šค ๋น„ํ™œ์„ฑ + 'Already a member'). ๋„“์€ div ๋กœ ์ฐพ์œผ๋ฉด ๋ชฉ๋ก ์ „์ฒด๊ฐ€ ๊ฑธ๋ ค
  // ์ฒซ ํ–‰(๋‹ค๋ฅธ ์‚ฌ์šฉ์ž)์˜ ๋น„ํ™œ์„ฑ ์ฒดํฌ๋ฐ•์Šค๋ฅผ ์ง‘์–ด '์ด๋ฏธ ๋ฉค๋ฒ„'๋กœ ์˜คํŒํ–ˆ์Œ(2026-10-05 IV1_S27) โ†’ ํ–‰ ํด๋ž˜์Šค๋กœ ํ•œ์ •
  const cb = dlg.locator('.invite-user-row').filter({ hasText: u.email }).getByRole('checkbox');
  await cb.waitFor({ timeout: 30_000 });
  if (await cb.isDisabled()) {
    test.info().annotations.push({ type: 'preexisting', description: `${u.id}: ์ด๋ฏธ ${u.team} ๋ฉค๋ฒ„` });
    await dlg.getByRole('button', { name: 'Cancel' }).click();
    return;
  }
  await cb.check();
  await dlg.getByRole('button', { name: 'Invite' }).click();
  await expect(page.getByText(u.id, { exact: true }).first(), `${u.team} ๋ฉค๋ฒ„์— ${u.id}`).toBeVisible({ timeout: 20_000 });
}

/** Users ๋ชฉ๋ก์—์„œ ์‚ฌ์šฉ์ž ์„ ํƒ โ†’ ์ƒ์„ธ ํŒจ๋„ ํ…์ŠคํŠธ(์‚ฌ์šฉ์ž ์ด๋ฆ„ ์ดํ›„) ๊ธฐ๋ก */
export async function userDetail(page: Page, u: TestUser) {
  await page.getByRole('gridcell', { name: `${u.first} ${u.last}` }).first().click();
  await page.waitForTimeout(2500);
  const body = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
  const i = Math.max(body.lastIndexOf(u.email), body.lastIndexOf(`${u.first} ${u.last}`));
  if (i < 0) throw new Error(`${u.id} ์ƒ์„ธ ํ…์ŠคํŠธ๋ฅผ ์ฐพ์ง€ ๋ชปํ•จ`);
  const text = body.slice(i, i + 800);
  test.info().annotations.push({ type: `user:${u.id}`, description: text.slice(0, 700) });
  return text;
}

export const groupTree = (page: Page) => page.getByRole('tree');
// jstree ํ•ญ๋ชฉ <a class="jstree-anchor" role="treeitem"> (getByRole('treeitem') ์ด ์•ˆ ์žกํžŒ ์ด๋ ฅ์ด ์žˆ์–ด ์•ต์ปค ํ…์ŠคํŠธ๋กœ ์ฐพ๋Š”๋‹ค, forms.ts ์ฐธ๊ณ )
export const treeItem = (page: Page, name: string) => groupTree(page).locator('a.jstree-anchor').filter({ hasText: new RegExp(`^\\s*${name}\\s*$`) });

export async function gotoGroups(page: Page) {
  await page.goto(URL_GROUPS);
  // ํŠธ๋ฆฌ๋Š” ๋น„๋™๊ธฐ๋กœ ์ฑ„์›Œ์ง„๋‹ค โ†’ ์ตœ์ƒ์œ„ mcmp ๊ฐ€ ๋ณด์ผ ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ(stage ์ง€์—ฐ ์‹œ 20์ดˆ ์ดˆ๊ณผ ๊ด€์ฐฐ, 2026-10-05).
  // ์ฃผ์†Œ ์ด๋™ ์งํ›„ ํŠธ๋ฆฌ๊ฐ€ 60์ดˆ ๋„˜๊ฒŒ ๋น„์–ด ์žˆ๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์—ˆ์Œ(IV1_S18ยทS20, 2026-10-02/05) โ†’ 30์ดˆ์”ฉ ๊ธฐ๋‹ค๋ฆฌ๋ฉฐ Refresh ๋ฒ„ํŠผ โ†’ ์ƒˆ๋กœ๊ณ ์นจ ์ˆœ์œผ๋กœ ์žฌ์‹œ๋„
  const root = treeItem(page, 'mcmp').first();
  for (let attempt = 1; ; attempt++) {
    if (await root.waitFor({ timeout: 30_000 }).then(() => true, () => false)) break;
    if (attempt >= 3) throw new Error('Groups ํŠธ๋ฆฌ(mcmp)๊ฐ€ 90์ดˆ ๋™์•ˆ ํ‘œ์‹œ๋˜์ง€ ์•Š์Œ');
    test.info().annotations.push({ type: 'tree-retry', description: `์‹œ๋„ ${attempt}: ํŠธ๋ฆฌ ๋ฏธํ‘œ์‹œ โ†’ ${attempt === 1 ? 'Refresh ๋ฒ„ํŠผ' : '์ƒˆ๋กœ๊ณ ์นจ'}` });
    if (attempt === 1) await page.getByRole('button', { name: 'Refresh' }).click().catch(() => page.reload());
    else await page.reload();
  }
  await page.getByRole('button', { name: 'Expand All' }).click();
  await page.waitForTimeout(500);
}

/** ์กฐ์ง ํด๋ฆญ โ†’ ์ƒ์„ธ ํŒจ๋„(Code, Level, Path, Users, Description) ํ…์ŠคํŠธ ๋ฐ˜ํ™˜ */
export async function openGroup(page: Page, name: string, path: string) {
  await treeItem(page, name).first().click();
  await expect(page.getByText(path, { exact: true }), `์ƒ์„ธ Path ${path}`).toBeVisible({ timeout: 20_000 });
  await page.waitForTimeout(800);
  const body = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
  const m = body.match(new RegExp(`Add Child Edit Delete (.*?)(Platform Roles|$)`));
  const detail = (m?.[1] ?? '').trim();
  test.info().annotations.push({ type: `detail:${name}`, description: detail.slice(0, 300) });
  return detail;
}

/** Create Org ํŒ์—…์œผ๋กœ ์ƒ์„ฑ(parent ์—†์œผ๋ฉด ์ตœ์ƒ์œ„). Save ํ›„ ์•Œ๋Ÿฟ 'Group created successfully.' ๋Š” fixtures ๊ฐ€ ์ˆ˜๋ฝ */
export async function createGroup(page: Page, name: string, description: string, parent?: string) {
  await page.getByRole('button', { name: 'Create Org' }).click();
  const dlg = page.getByRole('dialog');
  await dlg.locator('#group-name').fill(name);
  await dlg.locator('#group-description').fill(description);
  if (parent) await selectByPrefix(dlg.locator('#group-parent-select'), parent);
  await dlg.getByRole('button', { name: 'Save' }).click();
  await expect(treeItem(page, name).first(), `ํŠธ๋ฆฌ์— ${name} ํ‘œ์‹œ`).toBeVisible({ timeout: 20_000 });
}

/** ์„ ํƒ๋œ ์กฐ์ง Edit: ์ด๋ฆ„ยท์„ค๋ช… ๋ณ€๊ฒฝ ํ›„ Save */
export async function editGroup(page: Page, newName: string, description: string) {
  await page.locator('#btn-edit').click();
  const dlg = page.locator('#edit-group-modal');
  await expect(dlg).toBeVisible();
  await dlg.locator('#edit-group-name').fill(newName);
  await dlg.locator('#edit-group-description').fill(description);
  await dlg.getByRole('button', { name: 'Save' }).click();
  await expect(dlg).toBeHidden({ timeout: 20_000 });
}

/** ์„ ํƒ๋œ ์กฐ์ง Delete โ†’ ํ™•์ธ ํŒ์—… ๋ฌธ๊ตฌ ๋ฐ˜ํ™˜ ํ›„ Delete ํด๋ฆญ */
export async function deleteGroup(page: Page) {
  await page.locator('#btn-delete').click();
  const dlg = page.locator('#delete-group-modal');
  await expect(dlg).toBeVisible();
  const text = (await dlg.innerText()).replace(/\s+/g, ' ');
  await dlg.getByRole('button', { name: 'Delete', exact: true }).click();
  await page.waitForTimeout(2500);
  return text;
}

/** ํ™”๋ฉด ํ† ์ŠคํŠธ(์˜ค๋ฅ˜ ํฌํ•จ) ํ…์ŠคํŠธ */
export const groupToasts = async (page: Page) =>
  (await page.locator('[class*="toast"], [role="alert"]').allInnerTexts()).map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);

lib/infra.ts

ํ•ด์‹œ c4531357 ยท 476์ค„

import { test, expect } from '@playwright/test';
import type { Page, Locator } from '@playwright/test';
import type { Csp } from './data';
import { WORKSPACE, DEFAULT_PROJECT } from './data';
import { selectWorkspaceProject, toasts } from './ui';

// Infra Workloads ํ™”๋ฉด ์…€๋ ‰ํ„ฐ: 2026-10-02 IV3 ํ”„๋กœ๋ธŒ๋กœ ํ™•์ธ (Add Infra ํผ id, Spec/Image ์ถ”์ฒœ ํŒ์—… id)
export const INFRA_URL = '/webconsole/operations/manage/workloads/infraworkloads';

/**
 * CSP ๋ณ„ Add Infra ์ž…๋ ฅ๊ฐ’. Spec ์ถ”์ฒœ ํŒ์—…์€ ์ด๋ฆ„ ๊ฒ€์ƒ‰์ด ์—†์–ด Memory/vCPU ๋ฒ”์œ„๋กœ ์ขํžŒ ๋’ค spec ์ด๋ฆ„+region ์œผ๋กœ ํ–‰์„ ๊ณ ๋ฅธ๋‹ค.
 * Image ๋Š” OS Type ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์ค‘ BASIC(โœ“)ยทGPU ์•„๋‹˜ ์ฒซ ํ–‰(์นดํƒˆ๋กœ๊ทธ์— ๊ณ„ํš์„œ AMI ๊ฐ€ ์—†์–ด ์ด๋ฆ„ ๊ณ ์ • ๋ถˆ๊ฐ€).
 * ํ™•์ธ๋œ CSP ๋งŒ ์ฑ„์šด๋‹ค(AWS 2026-10-02). ๋‚˜๋จธ์ง€๋Š” ์‚ฌ์ „์ ๊ฒ€ ํ›„ ์ถ”๊ฐ€.
 */
// priority: Node Recommendation ํŒ์—…์˜ 'Priority Option / Limit (location)' ๊ฐ’(seoul/london/newyork). ์ง€์ •ํ•˜์ง€ ์•Š์œผ๋ฉด ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š๋Š”๋‹ค.
//   clean ์„ค์น˜ ํ›„(v07) AWS ๋Š” priority=seoul + Cloud Provider=aws ์ฒดํฌ ์กฐ๊ฑด์—์„œ ap-northeast-2/t3.micro ๊ฐ€ ์ถ”์ฒœ๋œ๋‹ค(์‚ฌ์šฉ์ž ํ™•์ธ 2026-10-06).
// expert: ์ง€์ •ํ•˜๋ฉด Deployment Algorithm=Expert ๋กœ ๊ธฐ์กด vNet/Subnet/SG/SSH Key ๋ฅผ ๊ณ ๋ฅธ๋‹ค(๊ฐ’์€ ์˜ต์…˜ value ์ ‘๋‘์–ด). NHN ์€ ์‹ ๊ทœ VPC ์ƒ์„ฑ ๊ธˆ์ง€๋ผ ํ•„์š”(2026-10-12)
export interface ExpertNet { vnet: string; subnet: string; sg: string; sshkey: string }
export interface InfraInput { provider: string; region: string; spec: string; mem: number; cpu: number; os: string; image?: string; priority?: string; expert?: ExpertNet }
export const INFRA_INPUT: Record<string, InfraInput | undefined> = {
  // ๊ณ„ํš์„œ AMI ami-0afe1fd15675c3f15 ๋Š” stage ์นดํƒˆ๋กœ๊ทธ์— ์—†์Œ(2026-10-05 SearchImage ์žฌํ™•์ธ) โ†’ Ubuntu 22.04 ๊ธฐ๋ณธ ์ด๋ฏธ์ง€ ์‚ฌ์šฉ
  AWS: { provider: 'aws', region: 'ap-northeast-2', spec: 't3.micro', mem: 1, cpu: 2, os: 'ubuntu 22.04', image: 'ami-0efae34c528c68a9c', priority: 'seoul' },
  // GCP: ๊ณ„ํš์„œ e2-small(2 vCPU, 2GB), gcp-asia-northeast3. Image ๋Š” Ubuntu 22.04 ๊ธฐ๋ณธ ์ด๋ฏธ์ง€(์ถ”์ฒœ ๊ฒฐ๊ณผ ์ฒซ server ์ด๋ฏธ์ง€) โ€” 2026-10-05 mc1 ์— g1c ์ถ”๊ฐ€๋กœ ์ฒซ ์‚ฌ์šฉ
  // priority=seoul: AWS ์™€ ๊ฐ™์€ ์กฐ๊ฑด(Priority Option location=Seoul + Provider ์ฒดํฌ). v07 ์—์„œ GCP e2-small ์ด ์ถ”์ฒœ์— ์—†์–ด ์ถ”๊ฐ€(2026-10-06, ํ™•์ธ ํ•„์š”)
  // ์‚ฌ์šฉ์ž ์ง€์‹œ(2026-10-07): GCP ๋Š” asia-northeast3(Seoul), spec ์€ e2-small ๋Œ€์‹  n1-standard-1(1 vCPU, 3.75GB)๋กœ ์‹œ๋„
  GCP: { provider: 'gcp', region: 'asia-northeast3', spec: 'n1-standard-1', mem: 3.75, cpu: 1, os: 'ubuntu 22.04', priority: 'seoul' },
  // KT: ์‚ฌ์šฉ์ž ์ง€์‹œ(2026-10-08) region kt-kr1, spec 1x1.itl, image edbd8ef7-f120-4657-8be7-1b3f3bf4fccc (ubuntu). mem/cpu ๋Š” ์ด๋ฆ„(1x1)์—์„œ ์ถ”์ •(1 vCPU, 1GB) โ€” ์ถ”์ฒœ ๊ฒฐ๊ณผ์— ์—†์œผ๋ฉด ์กฐ์ •
  // 2026-10-12: edbd8ef7-โ€ฆ ๋Š” GPU ์„œ๋ฒ„์šฉ(ubuntu-24.04-64bit-gpusvr-cu130-260618)์ด๋ผ 1 vCPU/1GB ์ผ๋ฐ˜ํ˜• spec ๊ณผ ๋งž์ง€ ์•Š์•„ VM ์ƒ์„ฑ ํ›„ ์ƒํƒœ ์กฐํšŒ ์‹คํŒจ โ†’ GPU ๊ฐ€ ์•„๋‹Œ Ubuntu 24.04 ์ด๋ฏธ์ง€ 56e47487-โ€ฆ (os-ubuntu24.04-image-20260929)๋กœ ๋ณ€๊ฒฝ
  KT: { provider: 'kt', region: 'kr1', spec: '1x1.itl', mem: 1, cpu: 1, os: 'ubuntu', image: '56e47487-c6bb-4c7d-89f4-3bf4c294924c' },
  // 2026-10-12 ์‚ฌ์ „์ ๊ฒ€(Spec/Image ์ถ”์ฒœ ํŒ์—…, Priority=Seoul + Provider ์ฒดํฌ, ์กฐํšŒ๋งŒ) ๊ฒฐ๊ณผ๋กœ ์ถ”๊ฐ€ โ€” mc2~mc8 (IV5 ์„ธ์…˜, ์‚ฌ์šฉ์ž ์ง€์‹œ)
  //   Azure: ๊ณ„ํš Standard_B1s ๋Š” koreacentral ์ถ”์ฒœ ๊ฒฐ๊ณผ์— ์—†์Œ โ†’ Standard_B1ms(1 vCPU, 1.95GB)
  Azure: { provider: 'azure', region: 'koreacentral', spec: 'Standard_B1ms', mem: 1.95, cpu: 1, os: 'ubuntu 22.04', image: 'Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2', priority: 'seoul' },
  Alibaba: { provider: 'alibaba', region: 'ap-northeast-2', spec: 'ecs.t6-c1m2.large', mem: 4, cpu: 2, os: 'ubuntu 22.04', image: 'ubuntu_22_04_x64_20G_alibase', priority: 'seoul' },
  Tencent: { provider: 'tencent', region: 'ap-seoul', spec: 'S5.MEDIUM2', mem: 2, cpu: 2, os: 'ubuntu 22.04', image: 'img-487zeit5', priority: 'seoul' },
  //   NHN: ๊ธฐ์กด ๊ณต์šฉ vNet ์‚ฌ์šฉ ํ•„์ˆ˜(์‹ ๊ทœ VPC ์ƒ์„ฑ ๊ธˆ์ง€) โ€” Express ๋Š” ๊ณต์šฉ vNet ์ด ์—†์œผ๋ฉด ๋งŒ๋“ ๋‹ค โ†’ ์‹คํ–‰ ์ „ vNet ํ™•์ธ
  //   โ†’ Expert ๋ชจ๋“œ๋กœ ๊ธฐ์กด vNet nhn-kr1-22c68100โ€ฆ(subnet-0), SG sg6, SSH Key key6 ์„ ํƒ(2026-10-12 ์‚ฌ์ „์ ๊ฒ€, ์‚ฌ์šฉ์ž ์ง€์‹œ)
  NHNCloud: { provider: 'nhn', region: 'kr1', spec: 'm2.c1m2', mem: 2, cpu: 1, os: 'ubuntu 22.04', image: '0f07c795-2a46-44fc-a61b-fa0d96763ce2', priority: 'seoul',
    expert: { vnet: 'nhn-kr1-22c68100-6909-4091-8736-21d467389a63', subnet: 'tbndnafj64ik03nun2qn-subnet-0', sg: 'sg6', sshkey: 'key6' } },
  //   NCP: Express ๋Š” ๊ณต์šฉ vNet default-shared-ncp-kr ์„ ๋งŒ๋“ค๋ ค๋‹ค VPC ํ•œ๋„๋กœ ์‹คํŒจ(1015009 'You can no longer create a product', 2026-10-12)
  //   โ†’ Expert ๋กœ ๊ธฐ์กด vNet ncp-kr-142413, SG ncp-kr-365868, SSH Key key7 ์„ ํƒ(์‚ฌ์šฉ์ž ์ง€์‹œ)
  //   subnet-0 ์€ 'Invalid Subnet information. (Subnet type mismatch)'(1001023) ๋กœ VM ์ƒ์„ฑ ์‹คํŒจ โ†’ subnet-2 ์‚ฌ์šฉ(์‚ฌ์šฉ์ž ์ง€์ • 2026-10-12)
  NCP: { provider: 'ncp', region: 'kr', spec: 'ci2-g3', mem: 4, cpu: 2, os: 'ubuntu 22.04', image: '23214590', priority: 'seoul',
    expert: { vnet: 'ncp-kr-142413', subnet: 'tbr0u59a2lnobtpbef14-subnet-2', sg: 'ncp-kr-365868', sshkey: 'key7' } },
  //   IBM: ๊ณ„ํš์„œ Infra ์—ฐ๊ฒฐ ๋ฏธ๊ฒ€์ฆ โ†’ K8s ์™€ ๊ฐ™์€ jp-tok. ์ด๋ฏธ์ง€๋Š” ๋ฆฌ์ „๋ณ„ ID ๋ผ ๊ณ ์ •ํ•˜์ง€ ์•Š์Œ(BASIC ์ฒซ ํ–‰: Ubuntu 22.04 Minimal)
  IBM: { provider: 'ibm', region: 'jp-tok', spec: 'bx2-2x8', mem: 8, cpu: 2, os: 'ubuntu 22.04', priority: 'seoul' },
};
export const infraInputBlocker = (c: Csp) =>
  INFRA_INPUT[c.code] ? undefined : `${c.name}: Add Infra Spec/Image ์„ ํƒ๊ฐ’ ๋ฏธํ™•์ •(์‚ฌ์ „์ ๊ฒ€ ํ•„์š”) โ†’ AWS ํ™•์ • ํ›„ ํ™•์žฅ`;

/** Infra ๋ชฉ๋ก ํ™”๋ฉด์œผ๋กœ ์ด๋™(์ฃผ์†Œ ์ด๋™ ์‹œ Workspace/Project ์„ ํƒ์ด ์ดˆ๊ธฐํ™”๋˜๋ฏ€๋กœ ๋‹ค์‹œ ์„ ํƒ). */
export async function gotoInfraList(page: Page) {
  await page.goto(INFRA_URL);
  // ์„ ํƒ ์งํ›„ Workspace/Project ๊ฐ€ ๋‹ค์‹œ 'Please select' ๋กœ ์ดˆ๊ธฐํ™”๋˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์Œ(2026-10-02 ๊ด€์ฐฐ) โ†’ ํ™•์ธ ํ›„ ์žฌ์„ ํƒ
  for (let attempt = 1; attempt <= 3; attempt++) {
    await selectWorkspaceProject(page, WORKSPACE, DEFAULT_PROJECT, true);
    // ๋ชฉ๋ก์€ GetAllInfra ์‘๋‹ต ํ›„ ์ฑ„์›Œ์ง„๋‹ค โ†’ "Showing n rows" ๋ฌธ๊ตฌ ๋Œ€๊ธฐ
    await page.getByText(/Showing \d+/).first().waitFor({ timeout: 30_000 }).catch(() => {});
    await page.waitForTimeout(2000);
    const sel = async (id: string) => page.locator(`${id} option:checked`).innerText().catch(() => '');
    if ((await sel('#select-current-workspace')).trim() === WORKSPACE && (await sel('#select-current-project')).trim() === DEFAULT_PROJECT) {
      // Infra ๊ฐ€ 5๊ฐœ๋ฅผ ๋„˜์œผ๋ฉด ๊ธฐ๋ณธ page size 5 ์—์„œ ๋Œ€์ƒ์ด 2ํŽ˜์ด์ง€๋กœ ๋ฐ€๋ฆฐ๋‹ค(2026-10-12) โ†’ ๊ฐ€์žฅ ํฐ page size ๋กœ
      const ps = listTable(page).locator('.tabulator-page-size');
      if (await ps.count()) {
        const last = await ps.locator('option').evaluateAll((os) => (os[os.length - 1] as HTMLOptionElement).value);
        await ps.selectOption(last).catch(() => {});
        await page.waitForTimeout(1000);
      }
      return;
    }
    test.info().annotations.push({ type: 'selection-reset', description: `์‹œ๋„ ${attempt}: Workspace/Project ์„ ํƒ์ด ์ดˆ๊ธฐํ™”๋จ โ†’ ์žฌ์„ ํƒ` });
  }
}

const listTable = (page: Page) => page.locator('.tabulator').filter({ has: page.getByRole('columnheader', { name: 'Total Nodes' }) }).first();

/** ๋ชฉ๋ก์—์„œ Infra ํ–‰(์ด๋ฆ„ ์ •ํ™•ํžˆ ์ผ์น˜). */
export const infraRow = (page: Page, name: string) =>
  listTable(page).locator('.tabulator-row').filter({ has: page.getByRole('gridcell', { name, exact: true }) });

/** ํ–‰์˜ ์…€ ๊ฐ’์„ tabulator-field โ†’ ํ…์ŠคํŠธ๋กœ ์ฝ๋Š”๋‹ค. */
export async function rowCells(row: Locator): Promise<Record<string, string>> {
  return row.locator('[tabulator-field]').evaluateAll((els) =>
    Object.fromEntries(els.map((e) => [e.getAttribute('tabulator-field') ?? '', (e.textContent ?? '').trim() || (e.querySelector('img')?.getAttribute('alt') ?? '')])),
  );
}

/** ์ƒํƒœ ์—ด ๊ฐ’(ํ•„๋“œ ์ด๋ฆ„์— status ํฌํ•จ). ๋ชฉ๋ก ํ•„๋“œ ์ด๋ฆ„์€ ์‹คํ–‰ํ•˜๋ฉฐ ํ™•์ธ(annotation list-row). */
export const statusOf = (cells: Record<string, string>) => Object.entries(cells).find(([k]) => /status/i.test(k))?.[1] ?? '';

/** Infra ์ƒํƒœ๊ฐ€ Running ์ด ๋  ๋•Œ๊นŒ์ง€ ๋ชฉ๋ก์„ ๋‹ค์‹œ ์ฝ์œผ๋ฉฐ ๋Œ€๊ธฐ. ๋ฐ˜ํ™˜: ๋งˆ์ง€๋ง‰ ์…€ ๊ฐ’ */
export async function waitInfraRunning(page: Page, name: string, timeoutMs = 20 * 60_000) {
  const end = Date.now() + timeoutMs;
  let cells: Record<string, string> = {};
  for (;;) {
    await gotoInfraList(page);
    const row = infraRow(page, name);
    if (await row.count()) {
      cells = await rowCells(row.first());
      const st = statusOf(cells);
      if (/running/i.test(st) && !/partial/i.test(st)) return cells;
      if (/failed/i.test(st)) throw new Error(`${name} ์ƒํƒœ Failed: ${JSON.stringify(cells)}`);
    }
    if (Date.now() > end) throw new Error(`${name} ๊ฐ€ ${timeoutMs / 60000}๋ถ„ ์•ˆ์— Running ์ด ๋˜์ง€ ์•Š์Œ: ${JSON.stringify(cells)}`);
    await page.waitForTimeout(20_000);
  }
}

/**
 * Node Configuration ์„ ์ฑ„์šฐ๊ณ  Done. Spec/Image ๋Š” ์ถ”์ฒœ ํŒ์—…์—์„œ ์„ ํƒ.
 * plus: ํผ์„ ์—ฌ๋Š” ๋ฒ„ํŠผ โ€” Add Infra ๋Š” '+ NodeGroup', ๊ธฐ์กด Infra ์˜ Add server(#addserver) ํ™”๋ฉด์€ '+ Node'(#vm_plusVmIcon).
 * ๋ฐ˜ํ™˜: ์„ ํƒ๋œ connection, image
 */
export async function addNodeGroup(page: Page, c: Csp, ngName: string, size = 1, plus?: Locator) {
  const inp = INFRA_INPUT[c.code]!;
  await (plus ?? page.getByText('+ NodeGroup').first()).click();
  await page.locator('#ep_name').waitFor();
  await page.locator('#ep_name').fill(ngName);

  // Spec: Node Recommendation ํŒ์—…(#spec-search)
  await page.locator('a[data-bs-target="#spec-search"]:visible').first().click();
  const sm = page.locator('#spec-search');
  // ์กฐ๊ฑด ์ˆœ์„œ: Priority Option(location) โ†’ Cloud Provider ์ฒดํฌ โ†’ Spec Config(Memory/vCPU) โ†’ ๊ฒ€์ƒ‰
  if (inp.priority) await sm.locator('#assistRecommendSpecConnectionName').selectOption(inp.priority);
  await sm.locator(`#spec-provider-${inp.provider}`).check();
  // ์†Œ์ˆ˜ ๋ฉ”๋ชจ๋ฆฌ(GCP n1-standard-1 3.75GB)๋Š” floor~ceil ๋ฒ”์œ„๋กœ ๊ฒ€์ƒ‰(์ •์ˆ˜ ๊ฐ’์€ min=max ๊ทธ๋Œ€๋กœ)
  await sm.locator('#assist_min_memory').fill(String(Math.floor(inp.mem)));
  await sm.locator('#assist_max_memory').fill(String(Math.ceil(inp.mem)));
  await sm.locator('#assist_min_cpu').fill(String(Math.floor(inp.cpu)));
  await sm.locator('#assist_max_cpu').fill(String(Math.ceil(inp.cpu)));
  await sm.locator('a[onclick*="getRecommendVmInfo"]').click();
  const specRow = sm.locator('.tabulator-row')
    .filter({ has: page.locator('[tabulator-field="connectionName"]', { hasText: `${inp.provider}-${inp.region}` }) })
    .filter({ hasText: inp.spec }).first();
  await expect(specRow, `Spec ์ถ”์ฒœ ๊ฒฐ๊ณผ์— ${inp.spec}(${inp.region})`).toBeVisible({ timeout: 60_000 });
  await specRow.click();
  await sm.getByRole('button', { name: 'Apply' }).click();
  await expect(page.locator('#ep_specId')).toHaveValue(inp.spec);
  const conn = await page.locator('#ep_connectionName').inputValue();
  expect(conn, 'Spec ์„ ํƒ์œผ๋กœ Connection ์ž๋™ ์ง€์ •').toBe(c.infra.conn);

  // Image: Image Recommendation ํŒ์—…(#image-search). Provider/Region ์€ Spec ์—์„œ ์ž๋™ ์ž…๋ ฅ
  await page.locator('a[onclick*="validateAndOpenImageModal"]:visible').first().click();
  const im = page.locator('#image-search');
  await im.locator('#assist_os_type').fill(inp.os);
  await im.locator('a[onclick*="getRecommendImageInfo"]').click();
  // ์ง€์ • ์ด๋ฏธ์ง€(inp.image)๊ฐ€ ๊ฒฐ๊ณผ์— ์žˆ์œผ๋ฉด BASIC/GPU/์ƒํƒœ ์—ด ์กฐ๊ฑด ์—†์ด ๊ทธ ํ–‰์„ ์„ ํƒ โ€” KT ๋Š” ์—ด์ด BASIC / OS TYPE / IMAGE NAME ๋ฟ์ด๋ผ ์•„๋ž˜ ํ•„ํ„ฐ๊ฐ€ ๋งž์ง€ ์•Š๋Š”๋‹ค(2026-10-08)
  const wanted = im.locator('.tabulator-row').filter({ has: page.locator('[tabulator-field="name"]', { hasText: inp.image ?? '\u0000' }) }).first();
  if (inp.image && (await wanted.waitFor({ state: 'visible', timeout: 30_000 }).then(() => true, () => false))) {
    await wanted.click();
    // Expert ๋ชจ๋“œ(NHN, 2026-10-12)์—์„œ๋Š” ํ–‰ ํด๋ฆญ์œผ๋กœ ์„ ํƒ๋˜์ง€ ์•Š์•„ Apply ์‹œ 'Please select an image first.' โ†’ ํ–‰ ์ฒดํฌ๋ฐ•์Šค๋ฅผ ์ง์ ‘ ์„ ํƒ
    const cb = wanted.locator('input[type="checkbox"]').first();
    if ((await cb.count()) && !(await cb.isChecked())) await cb.check();
    await im.getByRole('button', { name: 'Apply' }).click();
    expect(await page.locator('#ep_imageId').inputValue(), 'Image ์„ ํƒ(์ง€์ • ์ด๋ฏธ์ง€)').toContain(inp.image);
    await applyExpertNet(page, inp);
    await page.locator('#ep_vm_add_cnt').fill(String(size));
    await page.locator('#express_done_btn').click();
    await confirmModal(page, 'NodeGroup Done');
    await expect(page.getByText(`${ngName}(${size})`).first(), `NodeGroup ์นด๋“œ ${ngName}(${size})`).toBeVisible();
    test.info().annotations.push({ type: 'nodegroup', description: `${ngName}: conn=${conn}, spec=${inp.spec}, image=${inp.image}(์ง€์ •), size=${size}` });
    return { conn, image: inp.image };
  }
  const imgRow = im.locator('.tabulator-row')
    .filter({ has: page.locator('[tabulator-field="isBasicImage"]', { hasText: 'โœ“' }) })
    .filter({ has: page.locator('[tabulator-field="isGPUImage"]', { hasText: '-' }) })
    .filter({ has: page.locator('[tabulator-field="imageStatus"]', { hasText: 'Available' }) })
    .first();
  await expect(imgRow, `Image ์ถ”์ฒœ ๊ฒฐ๊ณผ(${inp.os}, BASIC, GPU ์•„๋‹˜)`).toBeVisible({ timeout: 60_000 });
  // ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์ •๋ ฌ์ด ์‹คํ–‰๋งˆ๋‹ค ๋‹ฌ๋ผ์งˆ ์ˆ˜ ์žˆ์Œ(2026-10-01) โ†’ server ์ด๋ฏธ์ง€(minimal ์•„๋‹˜) ์šฐ์„ 
  // ์„ ํ˜ธ ์ด๋ฏธ์ง€(inp.image)๊ฐ€ ๊ฒฐ๊ณผ์— ์žˆ์œผ๋ฉด ๊ทธ๊ฒƒ, ์—†์œผ๋ฉด server ์ด๋ฏธ์ง€(minimal ์•„๋‹˜) ์šฐ์„ 
  const preferred = im.locator('.tabulator-row').filter({ has: page.locator('[tabulator-field="name"]', { hasText: inp.image ?? '\u0000' }) });
  const server = imgRow.filter({ hasNot: page.locator('[tabulator-field="osDistribution"]', { hasText: /minimal/i }) });
  const pick = inp.image && (await preferred.count()) ? preferred.first() : (await server.count()) ? server.first() : imgRow;
  await pick.click();
  await im.getByRole('button', { name: 'Apply' }).click();
  const image = await page.locator('#ep_imageId').inputValue();
  expect(image, 'Image ์„ ํƒ').not.toBe('');
  await applyExpertNet(page, inp);

  await page.locator('#ep_vm_add_cnt').fill(String(size));
  await page.locator('#express_done_btn').click();
  await confirmModal(page, 'NodeGroup Done');
  await expect(page.getByText(`${ngName}(${size})`).first(), `NodeGroup ์นด๋“œ ${ngName}(${size})`).toBeVisible();
  test.info().annotations.push({ type: 'nodegroup', description: `${ngName}: conn=${conn}, spec=${inp.spec}, image=${image}, size=${size}` });
  return { conn, image };
}

/** Expert ๋ชจ๋“œ: ๊ธฐ์กด vNet โ†’ Subnet โ†’ Security Group โ†’ SSH Key ์„ ํƒ(์˜ต์…˜์€ Spec/Image ์„ ํƒ ํ›„, vNet ์„ ํƒ ํ›„ ๋น„๋™๊ธฐ๋กœ ์ฑ„์›Œ์ง) */
async function applyExpertNet(page: Page, inp: InfraInput) {
  const x = inp.expert;
  if (!x) return;
  const pick = async (id: string, prefix: string) => {
    const opt = page.locator(`${id} option`).filter({ hasText: prefix }).first();
    await expect(opt, `${id} ์— ${prefix}`).toHaveCount(1, { timeout: 60_000 });
    const v = await opt.getAttribute('value');
    await page.locator(id).selectOption(v!);
    return v;
  };
  const vals = [await pick('#ep_expert_vnet', x.vnet), await pick('#ep_expert_subnet', x.subnet), await pick('#ep_expert_sg', x.sg), await pick('#ep_expert_sshkey', x.sshkey)];
  test.info().annotations.push({ type: 'expert-net', description: `vNet=${vals[0]} subnet=${vals[1]} SG=${vals[2]} SSHKey=${vals[3]} (๊ธฐ์กด ์ž์› ์„ ํƒ, ์‹ ๊ทœ VPC ์ƒ์„ฑ ์—†์Œ)` });
}

/**
 * ๊ธฐ์กด Infra ์— NodeGroup ์ถ”๊ฐ€: ์ƒ์„ธ Node List 'Add server'(#addserver, โŒ„ ๋ฉ”๋‰ด 'New' ์™€ ๊ฐ™์€ ํ™”๋ฉด) โ†’ '+ Node' โ†’ Node Configuration โ†’ Done โ†’ Deploy.
 * 2026-10-05 ํ”„๋กœ๋ธŒ ํ™•์ธ: #extend_mci_name(Infra ์ด๋ฆ„ ๊ณ ์ •), #vm_deploy_algorithm, #vm_plusVmIcon, Deploy(deployVm)
 */
export async function addNodeGroupToInfra(page: Page, c: Csp, infra: string, ngName: string, size = 1) {
  await openInfraDetail(page, infra);
  await page.getByText('Add server').first().click();
  await expect(page.locator('#extend_mci_name')).toHaveValue(infra, { timeout: 20_000 });
  const r = await addNodeGroup(page, c, ngName, size, page.locator('#vm_plusVmIcon'));
  await page.getByRole('button', { name: 'Deploy', exact: true }).click();
  await confirmModal(page, 'Add server Deploy', 10_000);
  await page.waitForTimeout(3000);
  const t = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
  test.info().annotations.push({ type: 'deploy-toast', description: t.join(' | ').slice(0, 300) || '(์—†์Œ)' });
  return r;
}

/** ๋ชฉ๋ก์—์„œ Infra ์˜ ์ „์ฒด ๋…ธ๋“œ ์ˆ˜๊ฐ€ n ์ด์ƒ์ด๊ณ  Running ์ˆ˜ = ์ „์ฒด ๊ฐ€ ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ */
export async function waitInfraNodes(page: Page, name: string, n: number, timeoutMs = 20 * 60_000) {
  const end = Date.now() + timeoutMs;
  let cells: Record<string, string> = {};
  for (;;) {
    await gotoInfraList(page);
    const row = infraRow(page, name);
    if (await row.count()) {
      cells = await rowCells(row.first());
      const total = Number(cells['statusCount.countTotal']); const run = Number(cells['statusCount.countRunning']);
      if (total >= n && run === total) return cells;
      if (Number(cells['statusCount.countFailed']) > 0) throw new Error(`${name} ๋…ธ๋“œ Failed: ${JSON.stringify(cells)}`);
    }
    if (Date.now() > end) throw new Error(`${name} ๋…ธ๋“œ ${n}๊ฐœ Running ๋ฏธ๋„๋‹ฌ: ${JSON.stringify(cells)}`);
    await page.waitForTimeout(20_000);
  }
}

/**
 * ์ฝ˜์†” ํ™•์ธ ๋ชจ๋‹ฌ(๋ถ€ํŠธ์ŠคํŠธ๋žฉ, ์˜ˆ: 'NodeGroup Validation', 'Infra Deployment Warning')์ด ๋œจ๋ฉด ๋ฌธ๊ตฌ๋ฅผ ๊ธฐ๋กํ•˜๊ณ  Confirm.
 * ๋„ค์ดํ‹ฐ๋ธŒ alert/confirm ์€ fixtures ์˜ dialogs ๊ฐ€ ์ฒ˜๋ฆฌํ•œ๋‹ค.
 */
export async function confirmModal(page: Page, step: string, timeout = 5000) {
  const btn = page.locator('.modal.show').getByRole('button', { name: 'Confirm' }).last();
  if (await btn.waitFor({ state: 'visible', timeout }).then(() => true, () => false)) {
    const text = (await page.locator('.modal.show').last().innerText()).replace(/\s+/g, ' ').slice(0, 300);
    test.info().annotations.push({ type: 'confirm-modal', description: `${step}: ${text}` });
    await btn.click();
    return text;
  }
  return '';
}

/** Add Infra ๋กœ Infra ์ƒ์„ฑ (Express, Continue). ์˜ค๋ฅ˜ ํ† ์ŠคํŠธ๊ฐ€ ์žˆ์œผ๋ฉด ๊ธฐ๋ก. */
export async function createInfra(page: Page, c: Csp, name: string, ngName: string) {
  // Deploy ์š”์ฒญ์˜ API ์‘๋‹ต(์ƒํƒœยท๋ณธ๋ฌธ)์„ ๊ธฐ๋ก: ํ™”๋ฉด์—๋Š” ์‹คํŒจ ์›์ธ์ด ์•ˆ ๋‚˜์˜จ๋‹ค(2026-10-08 KT mc9 Failed, ๋…ธ๋“œ 0๊ฐœ)
  const apiLog: string[] = [];
  page.on('response', async (r) => {
    if (r.request().method() === 'POST' && /\/api\/mc-infra-manager\//.test(r.url()) && !/\/Get|async-requests/.test(r.url())) {
      const body = await r.text().catch(() => '');
      apiLog.push(`${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, '')} ${body.replace(/\s+/g, ' ').slice(0, 500)}`);
    }
  });
  await gotoInfraList(page);
  await page.getByText('Add Infra', { exact: true }).first().click();
  await page.locator('#mci_name').waitFor();
  await page.locator('#mci_name').fill(name);
  await page.locator('#mci_deploy_algorithm').selectOption(INFRA_INPUT[c.code]?.expert ? 'expert' : 'express');
  await page.locator('#mci_policy_on_partial_failure').selectOption('continue');
  await addNodeGroup(page, c, ngName, 1);
  await page.getByRole('button', { name: 'Deploy', exact: true }).click();
  await confirmModal(page, 'Deploy', 10_000);
  await page.waitForTimeout(3000);
  const t = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
  if (t.length) test.info().annotations.push({ type: 'toast', description: t.join(' | ').slice(0, 300) });
  await page.waitForTimeout(5000);
  if (apiLog.length) test.info().annotations.push({ type: 'deploy-api', description: apiLog.join(' || ').slice(0, 1500) });
}

/**
 * Infra ์ƒ์„ธ Default ํƒญ Node List ์—์„œ node ์นด๋“œ(#server_status_icon_{nodeId}) ํด๋ฆญ โ†’ Node Info [ {ng} / {node} ] ํ‘œ์‹œ.
 * ๋ฐ˜ํ™˜: Node Info ์˜์—ญ ํ…์ŠคํŠธ(๊ณต๋ฐฑ ์ •๋ฆฌ). Detail ํƒญ ๊ฐ’(Node Status, Image ID ๋“ฑ)์„ ํฌํ•จํ•œ๋‹ค.
 */
export async function openNodeInfo(page: Page, ng: string, nodeId: string) {
  // Infra ์ƒ์„ธ๋ฅผ ์—ฐ ์งํ›„ Node List ์นด๋“œ๊ฐ€ ๋Šฆ๊ฒŒ ๊ทธ๋ ค์ง€๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์Œ(2026-10-05) โ†’ ์นด๋“œ ํ‘œ์‹œ ๋Œ€๊ธฐ ํ›„ ํด๋ฆญ
  await page.locator(`#server_status_icon_${nodeId}`).waitFor({ state: 'visible', timeout: 60_000 });
  await page.locator(`#server_status_icon_${nodeId}`).click();
  // ์ƒ์„ฑ ์งํ›„ node ๋Š” ์ƒ์„ธ(GetVm) ๋กœ๋”ฉ์ด ๋Šฆ์–ด 'Node Info' ์ œ๋ชฉ๋งŒ ๋จผ์ € ๋ณด์ธ๋‹ค(2026-10-02, 30์ดˆ ์ดˆ๊ณผ ๊ด€์ฐฐ) โ†’ ๋ณธ๋ฌธ ํ…์ŠคํŠธ๋กœ ์ตœ๋Œ€ 2๋ถ„ ๋Œ€๊ธฐ
  const want = new RegExp(`Node Info \\[\\s*${ng}\\s*/\\s*${nodeId}\\s*\\][\\s\\S]*Node Status`);
  const read = async () => (await page.locator('body').innerText()).replace(/\s+/g, ' ');
  await expect.poll(async () => want.test(await read()), { message: `Node Info [ ${ng} / ${nodeId} ] ์ƒ์„ธ ํ‘œ์‹œ`, timeout: 120_000, intervals: [2000] }).toBe(true);
  await page.waitForTimeout(2000);
  const body = await read();
  const i = body.indexOf(`Node Info [`);
  return body.slice(i, i + 2500);
}

/** Node Info ํ…์ŠคํŠธ์—์„œ ๋ผ๋ฒจ ๋‹ค์Œ ๊ฐ’ ์ถ”์ถœ(๋‹ค์Œ ๋ผ๋ฒจ ์ „๊นŒ์ง€ ํ•œ ํ† ํฐ~๋ช‡ ํ† ํฐ). */
export const nodeField = (text: string, label: string, re = '\\S+') => text.match(new RegExp(`${label} (${re})`))?.[1] ?? '';

/** Infra ์ƒ์„ธ(ํ–‰ ํด๋ฆญ โ†’ Infra Info) ์—ด๊ธฐ. */
export async function openInfraDetail(page: Page, name: string) {
  await gotoInfraList(page);
  await infraRow(page, name).first().click();
  await expect(page.getByText(`Infra Info`).first()).toBeVisible({ timeout: 30_000 });
  await page.waitForTimeout(3000); // ์ƒ์„ธ ๋กœ๋”ฉ(์ฒ˜์Œ ํด๋ฆญ ์งํ›„ ๋ชฉ๋ก์ด ๋น„์–ด ๋ณด์ผ ์ˆ˜ ์žˆ์Œ)
}

// ---------- Data Disk (IV3_S04/S05) โ€” 2026-10-05 ํ”„๋กœ๋ธŒ ํ™•์ธ ----------
//   Cloud Resources > Disks: Create Data Disk ํŒ์—… #create-disk-connection, #create-disk-name, #create-disk-type(optional), #create-disk-size, #create-disk-execute-btn
//   Node Info > Block Device '+ Attach Disk' โ†’ #node-attach-disk-modal: #node-attach-disk-target(์˜ˆ 'mc1 / g1b-2'), #node-attach-mode-existing / -new,
//     #node-attach-disk-select(๊ฐ™์€ ์—ฐ๊ฒฐ์˜ Available disk), #node-attach-disk-execute-btn(Attach)
export const DISKS_URL = '/webconsole/settings/environment/cloudresources/disks';

export async function gotoDisks(page: Page) {
  await page.goto(DISKS_URL);
  await selectWorkspaceProject(page, WORKSPACE, DEFAULT_PROJECT, true);
  await page.getByText(/Showing \d+-\d+ of \d+ rows|No registered/).first().waitFor({ timeout: 30_000 }).catch(() => {});
  await page.waitForTimeout(1500);
  // ๋ชฉ๋ก์€ ๊ธฐ๋ณธ 10ํ–‰์ด๋ผ disk ๊ฐ€ ๋Š˜๋ฉด(CSP ๋ณ„ disk ๋ˆ„์ ) 11๋ฒˆ์งธ ์ดํ›„๊ฐ€ ์ฒซ ํŽ˜์ด์ง€์— ์—†๋‹ค(2026-10-12 KT disk9b~ 'not found' ์˜คํŒ) โ†’ Page Size 50
  const size = page.locator('select').filter({ has: page.locator('option', { hasText: /^50$/ }) }).last();
  if (await size.count()) { await size.selectOption({ label: '50' }).catch(() => {}); await page.waitForTimeout(1500); }
}
export const diskRow =(page: Page, name: string) => page.getByRole('row').filter({ has: page.getByRole('gridcell', { name, exact: true }) });

/** Disks ๋ชฉ๋ก์˜ ๋””์Šคํฌ ํ–‰ ์…€(tabulator-field โ†’ ๊ฐ’) */
export async function diskCells(page: Page, name: string) {
  await gotoDisks(page);
  const r = diskRow(page, name);
  return (await r.count()) ? rowCells(r.first()) : {};
}

/** Create Data Disk. ์ด๋ฏธ ์žˆ์œผ๋ฉด ์ƒ์„ฑ ์ƒ๋žต(preexisting). */
export async function createDisk(page: Page, conn: string, name: string, size = 10, diskType?: string) {
  await gotoDisks(page);
  if (await diskRow(page, name).count()) {
    test.info().annotations.push({ type: 'preexisting', description: `disk ${name} ์ด๋ฏธ ์กด์žฌ โ†’ ์ƒ์„ฑ ๊ฑด๋„ˆ๋œ€` });
    return;
  }
  await page.getByRole('button', { name: 'Create Data Disk' }).click();
  const dlg = page.getByRole('dialog');
  // Connection ๋ชฉ๋ก์€ ๋น„๋™๊ธฐ๋กœ ์ฑ„์›Œ์ง โ€” stage ์ง€์—ฐ ์‹œ 20์ดˆ ์ดˆ๊ณผ(2026-10-05) โ†’ ์˜ต์…˜์ด ์ƒ๊ธธ ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ
  await dlg.locator('#create-disk-connection option', { hasText: new RegExp(`^\\s*${conn}\\s*$`) }).first().waitFor({ state: 'attached', timeout: 90_000 });
  await dlg.locator('#create-disk-connection').selectOption({ label: conn });
  await dlg.locator('#create-disk-name').fill(name);
  if (diskType) { // Disk Type ์€ 'SSD|์ตœ์†Œ|์ตœ๋Œ€|GB' ํ˜•์‹์˜ ์˜ต์…˜ โ†’ ์ด๋ฆ„์ด diskType ๋กœ ์‹œ์ž‘ํ•˜๋Š” ์˜ต์…˜์„ ๊ณ ๋ฅธ๋‹ค
    await page.waitForTimeout(1500);
    const opt = await dlg.locator('#create-disk-type option').evaluateAll((os, t) => (os as HTMLOptionElement[]).map((o) => o.label).find((l) => l.split('|')[0].trim().toLowerCase().includes((t as string).toLowerCase())), diskType);
    if (opt) await dlg.locator('#create-disk-type').selectOption({ label: opt });
    test.info().annotations.push({ type: 'disk-type', description: `${diskType} โ†’ ${opt ?? '(์˜ต์…˜ ์—†์Œ, ๊ธฐ๋ณธ ํƒ€์ž…)'}` });
  }
  await dlg.locator('#create-disk-size').fill(String(size));
  await dlg.locator('#create-disk-execute-btn').click();
  await expect(dlg).toBeHidden({ timeout: 120_000 });
  const t = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
  test.info().annotations.push({ type: 'disk-create', description: t.join(' | ').slice(0, 300) || '(ํ† ์ŠคํŠธ ์—†์Œ)' });
  await gotoDisks(page);
  await expect(diskRow(page, name).first(), `Disks ๋ชฉ๋ก์— ${name}`).toBeVisible({ timeout: 60_000 });
}

/** Node Info ์˜ Block Device ์˜์—ญ ํ…์ŠคํŠธ */
export const blockDevice = (info: string) => {
  const i = info.indexOf('Block Device');
  return i < 0 ? '' : info.slice(i, info.indexOf('Keypair Name', i) > 0 ? info.indexOf('Keypair Name', i) : i + 300);
};

/** Node Info > + Attach Disk(Existing Disk) ๋กœ disk attach */
export async function attachExistingDisk(page: Page, disk: string) {
  await page.getByText('+ Attach Disk').first().click();
  const m = page.locator('#node-attach-disk-modal');
  await expect(m).toBeVisible();
  const target = await m.locator('#node-attach-disk-target').inputValue();
  await m.locator('#node-attach-mode-existing').check();
  await m.locator('#node-attach-disk-select').selectOption({ label: disk });
  await m.locator('#node-attach-disk-execute-btn').click();
  await expect(m).toBeHidden({ timeout: 120_000 });
  await page.waitForTimeout(3000);
  const t = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
  test.info().annotations.push({ type: 'attach', description: `target=${target} disk=${disk} / ${t.join(' | ').slice(0, 300) || '(ํ† ์ŠคํŠธ ์—†์Œ)'}` });
  return target;
}

/**
 * Node Info > + Attach Disk > Create New ๋กœ disk ๋ฅผ ๋งŒ๋“ค์–ด attach. ๋Œ€์ƒ node ์™€ ๊ฐ™์€ AZ ์— ๋งŒ๋“ค์–ด์ง„๋‹ค
 * (Existing Disk ๋Š” disk ์˜ AZ ๊ฐ€ node ์™€ ๋‹ค๋ฅด๋ฉด ์„œ๋ฒ„๊ฐ€ 500 ์œผ๋กœ ๊ฑฐ๋ถ€ โ€” 2026-10-06 AWS 2a/2d ๊ด€์ฐฐ).
 * ๋ชจ๋‹ฌ ํ•„๋“œ(2026-10-07 ํ”„๋กœ๋ธŒ): #node-attach-new-name, #node-attach-new-size, #node-attach-new-type(optional), #node-attach-new-description
 */
export async function attachNewDisk(page: Page, name: string, size = 10, diskType?: string) {
  await page.getByText('+ Attach Disk').first().click();
  const m = page.locator('#node-attach-disk-modal');
  await expect(m).toBeVisible();
  const target = await m.locator('#node-attach-disk-target').inputValue();
  await m.getByText('Create New', { exact: true }).click(); // ๋ผ๋””์˜ค๋Š” ์ˆจ๊ฒจ์ง„ ํ† ๊ธ€ ๋ฒ„ํŠผ์ด๋ผ ๋ผ๋ฒจ์„ ํด๋ฆญ
  await m.locator('#node-attach-new-name').fill(name);
  await m.locator('#node-attach-new-size').fill(String(size));
  if (diskType) { // ์„ ํƒ ํ•ญ๋ชฉ #node-attach-new-type: 'SSD|์ตœ์†Œ|์ตœ๋Œ€|GB' ํ˜•์‹ ์˜ต์…˜์—์„œ ์ด๋ฆ„์ด diskType ์ธ ๊ฒƒ
    const sel = m.locator('#node-attach-new-type');
    const opt = await sel.locator('option').evaluateAll((os, t) => (os as HTMLOptionElement[]).map((o) => o.label).find((l) => l.split('|')[0].trim().toLowerCase().includes((t as string).toLowerCase())), diskType).catch(() => undefined);
    if (opt) await sel.selectOption({ label: opt });
    test.info().annotations.push({ type: 'disk-type', description: `${diskType} โ†’ ${opt ?? '(์˜ต์…˜ ์—†์Œ, ๊ธฐ๋ณธ ํƒ€์ž…)'}` });
  }
  await m.locator('#node-attach-disk-execute-btn').click();
  await expect(m).toBeHidden({ timeout: 180_000 });
  await page.waitForTimeout(3000);
  const t = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
  test.info().annotations.push({ type: 'attach-new', description: `target=${target} disk=${name}(${size}GB) / ${t.join(' | ').slice(0, 300) || '(ํ† ์ŠคํŠธ ์—†์Œ)'}` });
  return target;
}

/**
 * Node Info Block Device ์˜ disk ํ•ญ๋ชฉ 'Detach'(confirmDetachDisk(ns, disk)) โ†’ 'Detach Disk / Detach disk "{disk}"?' Confirm.
 * ํ™•์ธ์€ Disks ๋ชฉ๋ก ๊ธฐ์ค€(Status Available, Attached To '-') โ€” detach ์งํ›„ Node Info ์žฌํ‘œ์‹œ๊ฐ€ 2๋ถ„ ๋„˜๊ฒŒ ๊ฑธ๋ฆฐ ์  ์žˆ์Œ(2026-10-05)
 */
export async function detachDisk(page: Page, disk: string) {
  const det = page.locator(`[onclick*="confirmDetachDisk"][onclick*="'${disk}'"]`).first();
  await expect(det, `${disk} Detach ๋ฒ„ํŠผ`).toBeVisible({ timeout: 20_000 });
  await det.click();
  const m = page.locator('.modal.show').last();
  await expect(m).toBeVisible();
  const text = (await m.innerText()).replace(/\s+/g, ' ').slice(0, 200);
  await m.getByRole('button', { name: 'Confirm' }).click();
  await page.waitForTimeout(3000);
  const t = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
  test.info().annotations.push({ type: 'detach', description: `${text} / ${t.join(' | ').slice(0, 200) || '(ํ† ์ŠคํŠธ ์—†์Œ)'}` });
}

/** Disks ๋ชฉ๋ก์—์„œ disk ์ƒํƒœ๊ฐ€ ๊ธฐ๋Œ€๊ฐ’์ด ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ(attach: Attached + Attached To ์— node, detach: Available) */
export async function waitDiskState(page: Page, disk: string, want: 'attached' | 'available', node?: string, timeoutMs = 240_000) {
  let cells: Record<string, string> = {};
  await expect.poll(async () => {
    cells = await diskCells(page, disk);
    const s = `${cells.status ?? ''}|${cells._attachedTo ?? ''}`;
    return want === 'attached' ? /attached/i.test(s) && (!node || s.includes(node)) : /available/i.test(cells.status ?? '');
  }, { message: `${disk} ${want}`, timeout: timeoutMs, intervals: [10_000] }).toBe(true);
  test.info().annotations.push({ type: `disk-${want}`, description: JSON.stringify(cells) });
  return cells;
}

/** Infra ์ƒ์„ธ์—์„œ ๋Œ€์ƒ node ๊ฒฐ์ •: ์ง€์ • node ๊ฐ€ Running ์ด๋ฉด ๊ทธ๊ฒƒ, ์•„๋‹ˆ๋ฉด ์ฒซ Running node */
export async function pickRunningNode(page: Page, infra: string, prefer?: string) {
  await openInfraDetail(page, infra);
  await page.locator('[id^="server_status_icon_"]').first().waitFor({ state: 'visible', timeout: 60_000 });
  if (prefer) {
    const card = page.locator(`#server_status_icon_${prefer}`);
    if ((await card.count()) && /green/.test((await card.getAttribute('class')) ?? '')) return prefer;
  }
  const running = page.locator('[id^="server_status_icon_"][class*="green"]').first();
  expect(await running.count(), `${infra} ์— Running node ์žˆ์Œ`).toBeGreaterThan(0);
  const node = ((await running.getAttribute('id')) ?? '').replace('server_status_icon_', '');
  test.info().annotations.push({ type: 'node-fallback', description: `${prefer ?? '(์ง€์ • ์—†์Œ)'} Running ์•„๋‹˜ โ†’ ${node} ์‚ฌ์šฉ` });
  return node;
}

/**
 * CSP ๋ณ„ Disk ํ…Œ์ŠคํŠธ ๋Œ€์ƒ(IV3_S04, S14~S16). infra = node ๊ฐ€ ์žˆ๋Š” Infra, node = ์„ ํ˜ธ node(Running ์•„๋‹ˆ๋ฉด ์ฒซ Running node),
 * disk = S04(ํ†ตํ•ฉ) ๋””์Šคํฌ, diskSplit = S14~S16(๋ถ„๋ฆฌ) ๋””์Šคํฌ. ํ™•์ธ๋œ CSP ๋งŒ(AWS 2026-10-05).
 */
export const DISK_TARGET: Record<string, { infra: string; node?: string; disk: string; diskSplit: string; diskNew?: string; size: number; diskType?: string } | undefined> = {
  // KT: ์‚ฌ์šฉ์ž ์ง€์‹œ(2026-10-12) Disk ๋Š” SSD 50GB, ๋Œ€์ƒ node ๋Š” mc9 ์˜ g9-1 (disk ์ด๋ฆ„์€ CSP ๋ฒˆํ˜ธ 9)
  KT: { infra: 'mc9', node: 'g9-1', disk: 'disk9c', diskSplit: 'disk9d', diskNew: 'disk9e', size: 50, diskType: 'SSD' },
  // diskNew: IV3_S25 ์—์„œ Attach ๋ชจ๋‹ฌ 'Create New' ๋กœ node ์™€ ๊ฐ™์€ AZ ์— ๋งŒ๋“ค์–ด attach ํ•˜๋Š” disk
  // 2026-10-12 ์‚ฌ์šฉ์ž ์ง€์‹œ: S04/S05/S15 ์žฌํ…Œ์ŠคํŠธ๋Š” TARGET_INFRA=mt1 TARGET_NODE=g1-1, disk ๋Š” node ์™€ ๊ฐ™์€ AZ(2d) ๊ฒƒ์œผ๋กœ TARGET_DISK / TARGET_DISK_SPLIT ์ง€์ •
  //   (Disks ํ™”๋ฉด Create Data Disk ๋Š” Zone ์„ ํƒ์ด ์—†์–ด 2a ์— ์ƒ์„ฑ๋จ โ†’ 2d disk ๋Š” Node Info 'Create New' ๋กœ ๋งŒ๋“  ๋’ค detach ํ•ด์„œ ์ค€๋น„)
  AWS: { infra: process.env.TARGET_INFRA ?? 'mc1', node: process.env.TARGET_NODE ?? 'g1b-2', disk: process.env.TARGET_DISK ?? 'disk1c',
    diskSplit: process.env.TARGET_DISK_SPLIT ?? 'disk1d', diskNew: 'disk1e', size: 10 },
  // 2026-10-16 ์‚ฌ์šฉ์ž ์ง€์‹œ(TC-INFRA-011 ๋ฏธ์‹คํ–‰ CSP ์ง„ํ–‰): AWSยทKT ์™€ ๊ฐ™์€ ๊ทœ์น™(mc{n}/g{n}-1, disk{n}c/d/e), ํฌ๊ธฐ๋Š” CSP ์ตœ์†Œ๊ฐ’ ์ฐจ์ด(Alibaba cloud_essd 20GB ๋“ฑ)๋กœ 20GB ํ†ต์ผ
  Azure: { infra: 'mc2', node: 'g2-1', disk: 'disk2c', diskSplit: 'disk2d', diskNew: 'disk2e', size: 20 },
  Alibaba: { infra: 'mc3', node: 'g3-1', disk: 'disk3c', diskSplit: 'disk3d', diskNew: 'disk3e', size: 20 },
  GCP: { infra: 'mc4', node: 'g4-1', disk: 'disk4c', diskSplit: 'disk4d', diskNew: 'disk4e', size: 20 },
  Tencent: { infra: 'mc5', node: 'g5-1', disk: 'disk5c', diskSplit: 'disk5d', diskNew: 'disk5e', size: 20 },
  NHNCloud: { infra: 'mc6', node: 'g6-1', disk: 'disk6c', diskSplit: 'disk6d', diskNew: 'disk6e', size: 20 },
  // NCP: ์ตœ์†Œ 100GiB(PostDataDisk 500 'not a configurable SIZE range [<ACCOUNT_ID>..]', 2026-10-16). ํ™”๋ฉด Disk Type ์˜ต์…˜์€ 'HDD|10|2000|GB' ๋กœ 10GB ๋ผ๊ณ  ์•ˆ๋‚ด(๋ถˆ์ผ์น˜)
  NCP: { infra: 'mc7', node: 'g7-1', disk: 'disk7c', diskSplit: 'disk7d', diskNew: 'disk7e', size: 100 },
  IBM: { infra: 'mc8', node: 'g8-1', disk: 'disk8c', diskSplit: 'disk8d', diskNew: 'disk8e', size: 20 },
};
export const diskTargetBlocker = (c: Csp) => (DISK_TARGET[c.code] ? undefined : `${c.name}: Disk ํ…Œ์ŠคํŠธ ๋Œ€์ƒ Infra/node ๋ฏธ์ง€์ •(์‚ฌ์ „์ ๊ฒ€ ํ•„์š”)`);

lib/iv3-k8s.ts

ํ•ด์‹œ 628097d3 ยท 298์ค„

import { test, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { WORKSPACE } from './data';
import { selectWorkspaceProject } from './ui';

// IV3 ์„ธ์…˜ K8s Workloads ํ—ฌํผ(IV3_S17~S22, IV4_S02). 2026-10-05 ํ”„๋กœ๋ธŒ ํ™•์ธ.
// (lib/k8s.ts ๋Š” ๋‹ค๋ฅธ ์„ธ์…˜์ด ๋ณ„๋„ ์šฉ๋„๋กœ ์‚ฌ์šฉ โ€” 2026-10-05 23:06 ๋ฎ์–ด์”€ โ†’ IV3 ๋Š” ์ด ํŒŒ์ผ์„ ์“ด๋‹ค)
//   ๋ชฉ๋ก 'List of Cluster' ์ปฌ๋Ÿผ: ProviderImg, Status, Name, Node Group, VPC, Subnet, Security Group
//   'Add cluster' ๋งํฌ(#createcluster) โ†’ ๊ธฐ๋ณธ ํผ: #cluster_name_dynamic, #cluster_provider_dynamic, #cluster_region_dynamic('[AWS] aws-ap-northeast-2'),
//     #cluster_desc_dynamic, #cluster_cloudconnection_dynamic, #cluster_version_dynamic(๋น„์šฐ๋ฉด ๊ธฐ๋ณธ), Deploy(deployPmkDynamic)
//   'Expert Creation'(toggleExpertCreation) โ†’ #cluster_name, #cluster_desc, #cluster_provider, #cluster_region, #cluster_cloudconnection, #cluster_vpc(ํ˜„์žฌ Project vNet ๋งŒ),
//     #cluster_subnet(stage ๋‹จ์ผ / localhost:3009 ๋‹ค์ค‘ 'Select at least 2 subnets in different availability zones'), #cluster_sg, #cluster_version(datalist, Expert ํ•„์ˆ˜)
export const K8S_URL = '/webconsole/operations/manage/workloads/k8sworkloads';

/** K8s Workloads ์ง„์ž… + Workspace โ†’ Project ์„ ํƒ. 'Add cluster' ๋งํฌ๊ฐ€ ๋ณด์ผ ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ(์ง€์—ฐ ์‹œ 1ํšŒ ์žฌ์ง„์ž…) */
export async function gotoK8s(page: Page, project: string) {
  for (let attempt = 1; ; attempt++) {
    await page.goto(K8S_URL);
    await selectWorkspaceProject(page, WORKSPACE, project, true);
    if (await page.getByText('Add cluster').first().waitFor({ timeout: 90_000 }).then(() => true, () => false)) break;
    if (attempt >= 2) throw new Error('K8s Workloads ํ™”๋ฉด ๋กœ๋”ฉ ์•ˆ ๋จ');
  }
  await page.waitForTimeout(3000);
}

export const clusterRow = (page: Page, name: string) =>
  page.locator('.tabulator-row').filter({ has: page.getByRole('gridcell', { name, exact: true }) });

/** ๋ชฉ๋ก ํ–‰ ์…€ ๊ฐ’(tabulator-field โ†’ ํ…์ŠคํŠธ) */
export async function clusterCells(page: Page, name: string): Promise<Record<string, string>> {
  const r = clusterRow(page, name);
  if (!(await r.count())) return {};
  return r.first().locator('[tabulator-field]').evaluateAll((els) =>
    Object.fromEntries(els.map((e) => [e.getAttribute('tabulator-field') ?? '', (e.textContent ?? '').replace(/\s+/g, ' ').trim()])));
}

const hasOpt = (page: Page, id: string, v: string) => page.locator(`#${id}`).evaluate((s, val) => [...(s as HTMLSelectElement).options].some((o) => o.value === val), v);

export interface ClusterInput { name: string; provider: string; conn: string; version?: string; desc?: string }

/** Add cluster(๊ธฐ๋ณธ ํผ) ์ž…๋ ฅ โ€” Deploy ๋Š” ๋ˆ„๋ฅด์ง€ ์•Š๋Š”๋‹ค. <option> ์€ value ๋กœ ํ™•์ธ(๋‹ซํžŒ select ์˜ hasText ๋Œ€๊ธฐ ์‹คํŒจ) */
export async function fillCreateCluster(page: Page, inp: ClusterInput) {
  await page.getByText('Add cluster').first().click();
  await page.locator('#cluster_name_dynamic').waitFor({ timeout: 30_000 });
  await page.locator('#cluster_name_dynamic').fill(inp.name);
  await page.locator('#cluster_provider_dynamic').selectOption(inp.provider);
  const region = `[${inp.provider}] ${inp.conn}`;
  await expect.poll(() => hasOpt(page, 'cluster_region_dynamic', region), { message: `Region ์˜ต์…˜ ${region}`, timeout: 60_000 }).toBe(true);
  await page.locator('#cluster_region_dynamic').selectOption(region);
  await expect.poll(() => hasOpt(page, 'cluster_cloudconnection_dynamic', inp.conn), { message: `Connection ์˜ต์…˜ ${inp.conn}`, timeout: 60_000 }).toBe(true);
  await page.locator('#cluster_cloudconnection_dynamic').selectOption(inp.conn);
  if (inp.desc) await page.locator('#cluster_desc_dynamic').fill(inp.desc);
  await page.locator('#cluster_version_dynamic').fill(inp.version ?? '');
  const vals = await page.evaluate(() => ['cluster_name_dynamic', 'cluster_provider_dynamic', 'cluster_region_dynamic', 'cluster_cloudconnection_dynamic', 'cluster_version_dynamic']
    .map((i) => `${i}=${(document.getElementById(i) as any)?.value}`).join(', '));
  test.info().annotations.push({ type: 'form', description: vals });
}

/** Deploy ํด๋ฆญ โ†’ ํ™•์ธ ๋ชจ๋‹ฌ์ด ๋œจ๋ฉด ๋ฌธ๊ตฌ ๊ธฐ๋ก ํ›„ Confirm. ํ† ์ŠคํŠธ ๊ธฐ๋ก */
export async function deployCluster(page: Page) {
  await page.getByRole('button', { name: 'Deploy', exact: true }).click();
  const m = page.locator('.modal.show').last();
  if (await m.isVisible({ timeout: 8000 }).catch(() => false)) {
    test.info().annotations.push({ type: 'deploy-modal', description: (await m.innerText()).replace(/\s+/g, ' ').slice(0, 300) });
    await m.getByRole('button', { name: /confirm|deploy|ok|yes/i }).last().click();
  }
  await page.waitForTimeout(4000);
  const t = (await page.locator('[class*="toast"], [role="alert"]').allInnerTexts()).map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
  test.info().annotations.push({ type: 'deploy-toast', description: t.join(' | ').slice(0, 600) || '(ํ† ์ŠคํŠธ ์—†์Œ)' });
  return t;
}

/** ๋ชฉ๋ก์—์„œ ํด๋Ÿฌ์Šคํ„ฐ ์ƒํƒœ๊ฐ€ pattern ์ด ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ(๋ชฉ๋ก ์ƒˆ๋กœ ์ง„์ž…) */
export async function waitClusterStatus(page: Page, project: string, name: string, pattern: RegExp, timeoutMs = 180_000) {
  let cells: Record<string, string> = {};
  await expect.poll(async () => { await gotoK8s(page, project); cells = await clusterCells(page, name); return cells.status ?? Object.values(cells).join(' '); },
    { message: `${name} ์ƒํƒœ ${pattern}`, timeout: timeoutMs, intervals: [15_000] }).toMatch(pattern);
  test.info().annotations.push({ type: `list:${name}`, description: JSON.stringify(cells) });
  return cells;
}

/** ๋ชฉ๋ก์—์„œ ํด๋Ÿฌ์Šคํ„ฐ ์„ ํƒ โ†’ ์ƒ์„ธ(Cluster Info, NodeGroup List / Status). ์ƒ์„ธ๊ฐ€ ์•ˆ ์—ด๋ฆฌ๋ฉด ์ตœ๋Œ€ 3ํšŒ ํด๋ฆญ */
export async function openCluster(page: Page, name: string) {
  const cell = clusterRow(page, name).first().getByRole('gridcell', { name, exact: true });
  await cell.waitFor({ timeout: 60_000 });
  for (let k = 1; ; k++) {
    await cell.click();
    if (await page.getByText('NodeGroup List').first().waitFor({ timeout: 20_000 }).then(() => true, () => false)) break;
    if (k >= 3) throw new Error(`${name} ์ƒ์„ธ(NodeGroup List)๊ฐ€ ์—ด๋ฆฌ์ง€ ์•Š์Œ`);
    test.info().annotations.push({ type: 'open-cluster-retry', description: `์‹œ๋„ ${k}` });
  }
  await page.waitForTimeout(3000);
  const body = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
  const i = body.indexOf('Cluster Info');
  return body.slice(i, i + 2500);
}

export interface NodeGroupInput { name: string; spec: string; mem: number; cpu: number; size: number; image?: string }

/**
 * ์ƒ์„ธ 'Add NodeGroup' โ†’ Add node(#addnode) โ†’ '+ NodeGroup'(#addnodegroup_plusIcon) โ†’ NodeGroup Configuration:
 *   #node_name, Spec(#spec-search-pmk: #assist_min/max_memory-pmk, #assist_min/max_cpu-pmk, getRecommendVmInfoPmk, Apply=applySpecInfoPmk),
 *   Image(image ์ง€์ • ์‹œ #image-search-pmk ๊ฒ€์ƒ‰ โ€” OS Architecture ๋Š” Spec ์—์„œ ์ž๋™, ์•„๋‹ˆ๋ฉด 'Use Default' โ€” x86 ๊ณ ์ •์ด๋ผ ARM spec ๊ณผ ๋ถˆ์ผ์น˜),
 *   #node_sshkey, #node_desirednodesize, Done(clusterFormDone_btn). Deploy ๋Š” deployNodeGroup
 */
export async function addNodeGroupForm(page: Page, ng: NodeGroupInput, opts: { expert?: boolean } = {}) {
  if (opts.expert) {
    // Expert ํผ(Create Cluster Expert Mode) ํ•˜๋‹จ '+ nodeGroup'(#nodegroup_plusIcon, displayNewNodeForm) โ€” Azure ๋Š” NodeGroup ํ•„์ˆ˜(2026-10-06)
    await page.locator('#nodegroup_plusIcon').click();
  } else {
    await page.getByText('Add NodeGroup').first().click();
    await page.locator('#node_cluster_name').waitFor({ state: 'visible', timeout: 30_000 });
    await page.locator('#addnodegroup_plusIcon').click();
  }
  await page.locator('#node_name').waitFor({ state: 'visible', timeout: 30_000 });
  await page.locator('#node_name').fill(ng.name);
  await page.locator('a[data-bs-target="#spec-search-pmk"]:visible').first().click();
  const sm = page.locator('#spec-search-pmk');
  expect(await pickSpecInModal(page, ng), `Spec ์ถ”์ฒœ ๊ฒฐ๊ณผ์— ${ng.spec}`).toBe(true);
  await sm.locator('button[onclick*="applySpecInfoPmk"]').click();
  await expect(page.locator('#node_specid')).toHaveValue(new RegExp(ng.spec), { timeout: 10_000 });
  if (ng.image) {
    await page.locator('a[onclick*="clustercreate"][onclick*="validateAndOpenImageModal"]:visible').first().click();
    const im = page.locator('#image-search-pmk');
    await im.waitFor({ state: 'visible' });
    test.info().annotations.push({ type: 'image-arch', description: await im.locator('#image-os-architecture-pmk').inputValue() });
    await im.locator('a[onclick*="getRecommendImageInfoPmk"]').click();
    const irow = im.locator('.tabulator-row').filter({ hasText: ng.image }).first();
    await expect(irow, `Image ์ถ”์ฒœ ๊ฒฐ๊ณผ์— ${ng.image}`).toBeVisible({ timeout: 90_000 });
    await irow.click();
    await im.locator('button[onclick*="applyImageInfoPmk"]').click();
    await expect(page.locator('#node_imageid')).toHaveValue(new RegExp(ng.image), { timeout: 15_000 });
  } else {
    await page.locator('a[onclick*="useDefaultImage"]:visible').first().click();
    await expect(page.locator('#node_imageid')).not.toHaveValue('', { timeout: 30_000 });
  }
  const keys = await page.locator('#node_sshkey option').evaluateAll((os) => os.map((o) => (o as HTMLOptionElement).value).filter(Boolean));
  if (keys.length) await page.locator('#node_sshkey').selectOption(keys[0]);
  await page.locator('#node_desirednodesize').fill(String(ng.size));
  const vals = await page.evaluate(() => ['node_cluster_name', 'node_name', 'node_specid', 'node_imageid', 'node_sshkey', 'node_rootdisk', 'node_rootdisksize', 'node_desirednodesize']
    .map((i) => `${i}=${(document.getElementById(i) as any)?.value}`).join(', '));
  test.info().annotations.push({ type: 'ng-form', description: vals });
  await page.locator('button[onclick*="clusterFormDone_btn"]:visible').click();
  const m = page.locator('.modal.show').last();
  if (await m.isVisible({ timeout: 5000 }).catch(() => false)) {
    test.info().annotations.push({ type: 'done-modal', description: (await m.innerText()).replace(/\s+/g, ' ').slice(0, 300) });
    await m.getByRole('button', { name: /confirm|ok|yes/i }).last().click();
  }
  await expect(page.getByText(new RegExp(`${ng.name}\\s*\\(${ng.size}\\)`)).first(), `NodeGroup ์นด๋“œ ${ng.name}(${ng.size})`).toBeVisible({ timeout: 15_000 });
}

export interface DynamicNodeGroupInput { name: string; spec: string; mem: number; cpu: number; size: number }

/**
 * ํƒ€์ž… B(Azure/GCP) ๊ธฐ๋ณธ ํผ์˜ NodeGroup ์ž…๋ ฅ๋ž€(Provider ์„ ํƒ ์‹œ ๋‚˜ํƒ€๋‚จ, 2026-10-06 ํ”„๋กœ๋ธŒ):
 *   #nodegroup_name_dynamic, Spec(a[data-bs-target="#spec-search-pmk"] โ†’ ๊ฐ™์€ Spec ํŒ์—…), Image('Use Default' useDefaultImageDynamic),
 *   #nodegroup_rootdisk_dynamic(๊ธฐ๋ณธ๊ฐ’ ์œ ์ง€), #nodegroup_desirednodesize_dynamic.
 * Spec ํŒ์—… ๊ฒฐ๊ณผ๋Š” ํŽ˜์ด์ง€ ๋‹จ์œ„(์ตœ๋Œ€ 20) โ†’ spec ์ด๋ฆ„์ด ๋ณด์ผ ๋•Œ๊นŒ์ง€ Next. ๋ชป ์ฐพ์œผ๋ฉด false ๋ฐ˜ํ™˜(Deploy ์•ˆ ํ•จ)
 */
/** Spec ํŒ์—…(#spec-search-pmk)์ด ์—ด๋ฆฐ ์ƒํƒœ์—์„œ cpu/mem ์œผ๋กœ ๊ฒ€์ƒ‰ โ†’ ๊ฒฐ๊ณผ๋ฅผ ํŽ˜์ด์ง€ ๋„˜๊ธฐ๋ฉฐ spec ์ด๋ฆ„ ํ–‰ ํด๋ฆญ(Apply ๋Š” ํ˜ธ์ถœ์ž). ๋ชป ์ฐพ์œผ๋ฉด Escape ํ›„ false */
export async function pickSpecInModal(page: Page, ng: { spec: string; mem: number; cpu: number }): Promise<boolean> {
  const sm = page.locator('#spec-search-pmk');
  await sm.waitFor({ state: 'visible' });
  // mem/cpu ๊ฐ€ 0 ์ด๋ฉด ์กฐ๊ฑด ์—†์ด ๊ฒ€์ƒ‰ํ•˜๊ณ  ์ด๋ฆ„์œผ๋กœ ์ฐพ๋Š”๋‹ค(GCP ๋Š” vCPUยท๋ฉ”๋ชจ๋ฆฌ ์ •ํ™• ์ผ์น˜ ๊ฒ€์ƒ‰์ด 0๊ฑด, 2026-10-15)
  await sm.locator('#assist_min_memory-pmk').fill(ng.mem ? String(ng.mem) : '');
  await sm.locator('#assist_max_memory-pmk').fill(ng.mem ? String(ng.mem) : '');
  await sm.locator('#assist_min_cpu-pmk').fill(ng.cpu ? String(ng.cpu) : '');
  await sm.locator('#assist_max_cpu-pmk').fill(ng.cpu ? String(ng.cpu) : '');
  await sm.locator('a[onclick*="getRecommendVmInfoPmk"]').click();
  await sm.locator('.tabulator-row').first().waitFor({ timeout: 120_000 }).catch(() => {});
  const ps = sm.locator('.tabulator-page-size');
  if (await ps.count()) await ps.selectOption('20').catch(() => {});
  const nameCell = sm.getByRole('gridcell', { name: ng.spec, exact: true });
  const seen: string[] = [];
  for (let p = 0; p < 30; p++) {
    await page.waitForTimeout(800);
    if (await nameCell.count()) break;
    seen.push(...(await sm.locator('.tabulator-row .tabulator-cell').allInnerTexts()).map((s) => s.trim()).filter((s) => /^[A-Za-z]+_[\w-]+$/.test(s)));
    const next = sm.locator('.tabulator-page[data-page="next"]');
    if (!(await next.count()) || await next.isDisabled()) break;
    await next.click();
  }
  test.info().annotations.push({ type: 'spec-search', description: `${ng.spec} cpu=${ng.cpu} mem=${ng.mem}: ${(await nameCell.count()) ? '์ฐพ์Œ' : '์—†์Œ'} (${(await sm.locator('.tabulator-footer').innerText().catch(() => '')).replace(/\s+/g, ' ').slice(0, 60)}) ๋ณธ ๋ชฉ๋ก: ${seen.join(',').slice(0, 300)}` });
  if (!(await nameCell.count())) { await page.keyboard.press('Escape'); return false; }
  await nameCell.first().click();
  return true;
}

export async function fillDynamicNodeGroup(page: Page, ng: DynamicNodeGroupInput): Promise<boolean> {
  await page.locator('#nodegroup_name_dynamic').fill(ng.name);
  await page.locator('#nodegroup_spec_dynamic').locator('xpath=../..').locator('a[data-bs-target="#spec-search-pmk"]').click();
  const sm = page.locator('#spec-search-pmk');
  if (!(await pickSpecInModal(page, ng))) return false;
  await sm.locator('button[onclick*="applySpecInfoPmk"]').click();
  await expect(page.locator('#nodegroup_spec_dynamic')).toHaveValue(new RegExp(ng.spec), { timeout: 10_000 });
  await page.locator('a[onclick*="useDefaultImageDynamic"]').click();
  await expect(page.locator('#nodegroup_image_dynamic')).not.toHaveValue('', { timeout: 30_000 });
  await page.locator('#nodegroup_desirednodesize_dynamic').fill(String(ng.size));
  const vals = await page.evaluate(() => [...document.querySelectorAll('[id$="_dynamic"]')].filter((e) => /nodegroup/.test(e.id) && (e as any).type !== 'hidden' || /commonSpecId|provider|connectionName/.test(e.id))
    .map((e) => `${e.id.replace('nodegroup_', '').replace('_dynamic', '')}=${(e as any).value}`).join(', '));
  test.info().annotations.push({ type: 'ng-form', description: vals.slice(0, 600) });
  return true;
}

/** Add node ํ™”๋ฉด Deploy(deployNode) โ†’ ํ™•์ธ ๋ชจ๋‹ฌ Confirm โ†’ ํ† ์ŠคํŠธ ๊ธฐ๋ก */
export async function deployNodeGroup(page: Page) {
  await page.locator('button[onclick*="deployNode"]:visible').click();
  const m = page.locator('.modal.show').last();
  if (await m.isVisible({ timeout: 8000 }).catch(() => false)) {
    test.info().annotations.push({ type: 'deploy-modal', description: (await m.innerText()).replace(/\s+/g, ' ').slice(0, 300) });
    await m.getByRole('button', { name: /confirm|deploy|ok|yes/i }).last().click();
  }
  await page.waitForTimeout(4000);
  const t = (await page.locator('[class*="toast"], [role="alert"]').allInnerTexts()).map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
  test.info().annotations.push({ type: 'deploy-toast', description: t.join(' | ').slice(0, 400) || '(ํ† ์ŠคํŠธ ์—†์Œ)' });
  return t;
}

/** NodeGroup ์นด๋“œ(#nodeGroup_status_icon_{ng}) ์„ ํƒ โ†’ NodeGroup Info / Node List(node ์นด๋“œ #node_status_icon_{id}) */
export async function selectNodeGroup(page: Page, cluster: string, ng: string) {
  await openCluster(page, cluster);
  const card = page.locator(`#nodeGroup_status_icon_${ng}`);
  await expect(card, `NodeGroup ${ng} ์นด๋“œ`).toBeVisible({ timeout: 60_000 });
  if (!(await page.locator(`#node_checkbox_${ng}`).isChecked().catch(() => false))) await card.click();
  await page.getByText('NodeGroup Info').first().waitFor({ timeout: 60_000 });
  await page.waitForTimeout(3000);
  const body = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
  const i = body.indexOf('NodeGroup Info');
  const info = body.slice(i, i + 900);
  // node ์นด๋“œ id = node_status_icon_{node id}(AWS i-โ€ฆ, Azure ๋“ฑ์€ CSP ๋ณ„ ์ด๋ฆ„)
  const nodes = (await page.locator('li[id^="node_status_icon_"]').evaluateAll((els) => els.map((e) => e.id.replace('node_status_icon_', '')))).filter(Boolean);
  return { info, nodes, desired: Number(info.match(/Desired Node Size (\d+)/)?.[1] ?? NaN) };
}

/** NodeGroup List โŒ„ 'Edit Scaling' โ†’ #nodegroup-scaling-modal ์—์„œ Desired ๋ฅผ -/+ ๋กœ ๋งž์ถฐ Apply */
export async function editScaling(page: Page, desired: number) {
  const ngCard = page.locator('.card').filter({ hasText: 'NodeGroup List' }).last();
  await ngCard.locator('[data-bs-toggle="dropdown"]').first().click();
  await page.locator('#nodegroup-edit-scaling-item').click();
  const m = page.locator('#nodegroup-scaling-modal');
  await expect(m).toBeVisible();
  const current = (await m.innerText()).replace(/\s+/g, ' ').match(/Current:[^]*?node\(s\)/)?.[0] ?? '';
  const cur = Number(await m.locator('#ng-scaling-desired').inputValue());
  for (let k = cur; k < desired; k++) await m.locator('#ng-scaling-desired-inc').click();
  for (let k = cur; k > desired; k--) await m.locator('#ng-scaling-desired-dec').click();
  await expect(m.locator('#ng-scaling-desired')).toHaveValue(String(desired));
  const vals = `desired=${await m.locator('#ng-scaling-desired').inputValue()} min=${await m.locator('#ng-scaling-min').inputValue()} max=${await m.locator('#ng-scaling-max').inputValue()} autoscaling=${await m.locator('#ng-scaling-enabled').isChecked()}`;
  test.info().annotations.push({ type: 'scaling', description: `${current} โ†’ ${vals}` });
  await expect(m.locator('#ng-scaling-submit-btn')).toBeEnabled();
  await m.locator('#ng-scaling-submit-btn').click();
  await page.waitForTimeout(4000);
  const t = (await page.locator('[class*="toast"], [role="alert"]').allInnerTexts()).map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
  test.info().annotations.push({ type: 'apply-toast', description: t.join(' | ').slice(0, 400) || '(ํ† ์ŠคํŠธ ์—†์Œ)' });
  return { current, toasts: t };
}

export interface ExpertClusterInput extends ClusterInput { vpc: string; subnet: string; sg: string }

/** Expert Creation ์ž…๋ ฅ(Deploy ์•ˆ ํ•จ). VPC ์„ ํƒ์ง€ ์ตœ๋Œ€ 12์ดˆ ๋Œ€๊ธฐ, ์—†์œผ๋ฉด null. Subnet ์ด ๋‹ค์ค‘ ์„ ํƒ์ด๋ฉด VPC ์˜ subnet ์ „์ฒด ์„ ํƒ */
export async function fillExpertCluster(page: Page, inp: ExpertClusterInput) {
  await page.getByText('Add cluster').first().click();
  await page.locator('#cluster_name_dynamic').waitFor({ state: 'visible', timeout: 30_000 });
  await page.getByRole('button', { name: 'Expert Creation' }).click();
  await page.locator('#cluster_name').waitFor({ state: 'visible', timeout: 30_000 });
  await page.locator('#cluster_name').fill(inp.name);
  if (inp.desc) await page.locator('#cluster_desc').fill(inp.desc);
  await page.locator('#cluster_provider').selectOption(inp.provider);
  const region = `[${inp.provider}] ${inp.conn}`;
  await expect.poll(() => hasOpt(page, 'cluster_region', region), { timeout: 30_000 }).toBe(true);
  await page.locator('#cluster_region').selectOption(region);
  await expect.poll(() => hasOpt(page, 'cluster_cloudconnection', inp.conn), { timeout: 30_000 }).toBe(true);
  await page.locator('#cluster_cloudconnection').selectOption(inp.conn);
  const okVpc = await expect.poll(() => hasOpt(page, 'cluster_vpc', inp.vpc), { timeout: 12_000 }).toBe(true).then(() => true, () => false);
  const vpcOpts = await page.locator('#cluster_vpc option').evaluateAll((os) => os.map((o) => (o as HTMLOptionElement).value).filter(Boolean));
  test.info().annotations.push({ type: 'vpc-options', description: vpcOpts.join(', ') || '(์—†์Œ)' });
  if (!okVpc) return null;
  await page.locator('#cluster_vpc').selectOption(inp.vpc);
  await expect.poll(() => hasOpt(page, 'cluster_subnet', inp.subnet), { timeout: 30_000 }).toBe(true);
  const multiple = await page.locator('#cluster_subnet').evaluate((s) => (s as HTMLSelectElement).multiple);
  const subnetOpts = await page.locator('#cluster_subnet option').evaluateAll((os) => os.map((o) => (o as HTMLOptionElement).value).filter(Boolean));
  const hint = await page.locator('#cluster_subnet_hint').innerText().catch(() => '');
  test.info().annotations.push({ type: 'subnet-options', description: `${subnetOpts.join(', ')} (multiple=${multiple}) ${hint}` });
  await page.locator('#cluster_subnet').selectOption(multiple ? subnetOpts : inp.subnet);
  await expect.poll(() => hasOpt(page, 'cluster_sg', inp.sg), { timeout: 30_000 }).toBe(true);
  await page.locator('#cluster_sg').selectOption(inp.sg);
  const versions = await page.locator('#cluster_version_options option').evaluateAll((os) => os.map((o) => (o as HTMLOptionElement).value));
  test.info().annotations.push({ type: 'version-options', description: versions.join(', ') || '(์—†์Œ)' });
  await page.locator('#cluster_version').fill(inp.version ?? versions[0] ?? '');
  const vals = await page.evaluate(() => {
    const sel = document.getElementById('cluster_subnet') as HTMLSelectElement | null;
    const subnets = sel ? [...sel.selectedOptions].map((o) => o.value).join('+') : '';
    return ['cluster_name', 'cluster_provider', 'cluster_region', 'cluster_cloudconnection', 'cluster_vpc', 'cluster_sg', 'cluster_version']
      .map((i) => `${i}=${(document.getElementById(i) as any)?.value}`).join(', ') + `, cluster_subnet=${subnets}`;
  });
  test.info().annotations.push({ type: 'expert-form', description: vals });
  return vals;
}

lib/iv3-mc10.ts

ํ•ด์‹œ edd949fa ยท 149์ค„

import { test, expect } from '@playwright/test';
import type { Page, FrameLocator } from '@playwright/test';
import { gotoWorkflows, openRun } from './workflow';
import { openInfraDetail, gotoInfraList, waitInfraStatus, infraRowText } from './iv4-infra';

// IV3_S08ยทS11, IV4_S03ยทS06ยทS08, CL_S02 (TC-WF-008ยท009, TC-INFRA-028ยท032ยท049ยท056) ๊ณต์šฉ โ€” 2026-10-16
// ๋ฉ€ํ‹ฐ CSP Infra mc10: Workflow multi-csp-vm-deploy ๋กœ ๋งŒ๋“  Infra. NodeGroup ์ด๋ฆ„์€ {INFRA_NODEGROUP_PREFIX}-{csp}(์˜ˆ g10-aws), node g10-aws-1
// (Drive TC-INFRA-049 ๋ฌธ๊ตฌ์˜ g10a~g10h ๋Š” ์ง์ ‘ ์ƒ์„ฑ ๊ณ„ํš ์ด๋ฆ„ โ€” Workflow ์ƒ์„ฑ ๊ฒฐ๊ณผ์™€ ๋‹ค๋ฆ„)
export const MC10_NAME = 'mc10';
export const MC10_PREFIX = 'g10';
/** Drive TC-WF-008 ์ž…๋ ฅ๊ฐ’ CSP_LIST ์ˆœ์„œ(KT ์ œ์™ธ) */
export const MC10_CSPS = ['alibaba', 'aws', 'azure', 'gcp', 'ibm', 'ncp', 'nhn', 'tencent'];
/** data.ts Csp.code โ†’ Workflow CSP ์ด๋ฆ„ */
export const WF_CSP: Record<string, string> = { AWS: 'aws', Azure: 'azure', Alibaba: 'alibaba', GCP: 'gcp', Tencent: 'tencent', NHNCloud: 'nhn', NCP: 'ncp', IBM: 'ibm', KT: 'kt' };
export const mc10Ng = (wfCsp: string) => `${MC10_PREFIX}-${wfCsp}`;
export const mc10Node = (wfCsp: string) => `${mc10Ng(wfCsp)}-1`;

export interface NodeTarget { infra: string; ng: string; node: string; why: string; resume?: boolean; origStatus?: string }

/**
 * CSP๋ณ„ Infra ํ™”๋ฉด TC(IV4_S06 Terminal, IV4_S08 Scale Policy)์˜ ๋Œ€์ƒ node ๊ณ ๋ฅด๊ธฐ โ€” ํ™”๋ฉด ๋ชฉ๋ก์ด ๋ถ€๋ฅด๋Š” GetAllInfra(ns default) ์‘๋‹ต ๊ธฐ์ค€.
 * ์šฐ์„ ์ˆœ์œ„(์‚ฌ์šฉ์ž ์ง€์‹œ 2026-10-16):
 *   1) ํ™˜๊ฒฝ๋ณ€์ˆ˜ IV4_TARGET_INFRA(+IV4_TARGET_NODE) ์ง€์ •๊ฐ’  2) IV3_S01 Workflow ๊ฒฐ๊ณผ mw{n} ์˜ ํ•ด๋‹น CSP node ๊ฐ€ Running
 *   3) ์ด ์„ธ์…˜์ด IV3_S11 ๋กœ ๋งŒ๋“  mc10 ์˜ g10-<csp>-1 ์ด Running
 *   4) Running ์ด ์—†์œผ๋ฉด Suspended ์ธ mw{n} โ†’ Drive ์ž…๋ ฅ๊ฐ’ Infra mc{n}(node g{n}-1) ์ˆœ์œผ๋กœ ๊ณจ๋ผ resume=true
 *      ("Test์— ํ•„์š”ํ•œ infra๊ฐ€ suspended ์ƒํƒœ๋ฉด resume ํ›„ ํ…Œ์ŠคํŠธ ํ•œ๋‹ค. ํ…Œ์ŠคํŠธ ์™„๋ฃŒ ํ›„ ๋‹ค์‹œ suspended ํ•œ๋‹ค." โ€” withResumed ์‚ฌ์šฉ)
 *   5) ์—†์œผ๋ฉด undefined(์‚ฌ์ „ ์กฐ๊ฑด ์—†์Œ โ†’ SKIP)
 * ๋ฐ˜ํ™˜ why: ์„ ํƒ ๊ทผ๊ฑฐ / seen: ๊ด€์ฐฐํ•œ ์ƒํƒœ
 */
export async function pickNodeTarget(page: Page, n: number, code: string): Promise<{ target?: NodeTarget; seen: string }> {
  const csp = WF_CSP[code];
  const all: { id: string; nodes: { ng: string; id: string; status: string; conn: string }[] }[] = await page.evaluate(async () => {
    const r = await fetch('/api/mc-infra-manager/GetAllInfra', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pathParams: { nsId: 'default' } }) }).catch(() => null);
    const j = r ? await r.json().catch(() => null) : null;
    return (j?.responseData?.infra ?? []).map((m: any) => ({ id: m.id, nodes: (m.node ?? m.vm ?? []).map((v: any) => ({ ng: v.nodeGroupId ?? v.subGroupId ?? '', id: v.id, status: v.status, conn: v.connectionName ?? '' })) }));
  });
  const seen = all.filter((m) => new RegExp(`^(mw${n}|mc10|mc${n})$`).test(m.id)).map((m) => `${m.id}[${m.nodes.map((v) => `${v.id}:${v.status}`).join(',')}]`).join(' ') || '(mw*ยทmc10 ์—†์Œ)';
  const env = process.env.IV4_TARGET_INFRA;
  const find = (infra: string, pred: (v: { ng: string; id: string; status: string; conn: string }) => boolean) =>
    all.find((m) => m.id === infra)?.nodes.find((v) => pred(v) && /^Running/i.test(v.status));
  if (env) {
    const v = find(env, (x) => (process.env.IV4_TARGET_NODE ? x.id === process.env.IV4_TARGET_NODE : x.conn.startsWith(`${csp}-`)));
    return { target: v ? { infra: env, ng: v.ng, node: v.id, why: `ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์ง€์ • ${env}` } : undefined, seen };
  }
  const mw = find(`mw${n}`, (x) => x.conn.startsWith(`${csp}-`));
  if (mw) return { target: { infra: `mw${n}`, ng: mw.ng, node: mw.id, why: `IV3_S01 Workflow ๊ฒฐ๊ณผ mw${n}` }, seen };
  const mc = find(MC10_NAME, (x) => x.id === mc10Node(csp));
  if (mc) return { target: { infra: MC10_NAME, ng: mc.ng, node: mc.id, why: `IV3_S11 ๊ฒฐ๊ณผ mc10 ์˜ ${csp} node(์ด ์„ธ์…˜ ์ƒ์„ฑ)` }, seen };
  // 4) Suspended โ†’ Resume ๋Œ€์ƒ
  for (const [infra, pred, why] of [
    [`mw${n}`, (x: { conn: string }) => x.conn.startsWith(`${csp}-`), `IV3_S01 Workflow ๊ฒฐ๊ณผ mw${n}(Suspended โ†’ Resume)`],
    [`mc${n}`, (x: { id: string; conn: string }) => x.id === `g${n}-1` || x.conn.startsWith(`${csp}-`), `Drive ์ž…๋ ฅ๊ฐ’ Infra mc${n}(Suspended โ†’ Resume)`],
  ] as const) {
    const m = all.find((x) => x.id === infra);
    const run = m?.nodes.find((x) => (pred as any)(x) && /^Running/i.test(x.status));
    if (m && run) return { target: { infra, ng: run.ng, node: run.id, why: why.replace('(Suspended โ†’ Resume)', '(Running โ€” ๋‹ค๋ฅธ ์„ธ์…˜์ด ์ด๋ฏธ Resume)') }, seen };
    const v = m?.nodes.find((x) => (pred as any)(x) && /^Suspended/i.test(x.status));
    if (m && v && m.nodes.every((x) => /^(Suspended|Running)/i.test(x.status))) {
      return { target: { infra, ng: v.ng, node: v.id, why, resume: true, origStatus: m.nodes.map((x) => `${x.id}:${x.status}`).join(',') }, seen };
    }
  }
  return { seen };
}

/**
 * Suspended ๋Œ€์ƒ Infra ๋ฅผ ํ™”๋ฉด์—์„œ Resume โ†’ fn ์‹คํ–‰ โ†’ (์„ฑ๊ณตยท์‹คํŒจ์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ) ๋‹ค์‹œ Suspend.
 * ์‚ฌ์šฉ์ž ์ง€์‹œ 2026-10-16: "Test์— ํ•„์š”ํ•œ infra๊ฐ€ suspended ์ƒํƒœ๋ฉด resume ํ›„ ํ…Œ์ŠคํŠธ ํ•œ๋‹ค. ํ…Œ์ŠคํŠธ ์™„๋ฃŒ ํ›„ ๋‹ค์‹œ suspended ํ•œ๋‹ค."
 * ์›๋ž˜ ์ƒํƒœยทResumeยท์žฌ Suspend ๊ฒฐ๊ณผ๋ฅผ annotation(resume-restore)์œผ๋กœ ๋‚จ๊ธด๋‹ค.
 */
export async function withResumed<T>(page: Page, t: NodeTarget, fn: () => Promise<T>): Promise<T> {
  if (!t.resume) return fn();
  const log: string[] = [`์›๋ž˜ ์ƒํƒœ ${t.infra}: ${t.origStatus}`];
  try {
    await infraListAction(page, t.infra, 'Resume');
    await gotoInfraList(page);
    log.push(`Resume โ†’ ${await waitInfraStatus(page, t.infra, /^Running/i, 15 * 60_000)}`);
    await page.waitForTimeout(60_000); // ๋ถ€ํŒ… ํ›„ SSH ์ค€๋น„(KT ๋Š” 30์ดˆ๋กœ Terminal ๋ฏธํ‘œ์‹œ, 2026-10-16)
    return await fn();
  } finally {
    try {
      await page.keyboard.press('Escape').catch(() => {});
      await page.goto('/webconsole/operations/manage/workloads/infraworkloads').catch(() => {});
      await gotoInfraList(page);
      const now = await infraRowText(page, t.infra);
      // 2026-10-16: ๋ชฉ๋ก์€ Suspend ์™„๋ฃŒ ์ƒํƒœ๋ฅผ 'Stopped'(counts Suspended=์ „์ฒด)๋กœ ํ‘œ์‹œ(mw1) โ†’ Stopped ๋„ ์™„๋ฃŒ๋กœ ๋ณธ๋‹ค
      if (!/^(Suspended|Stopped)/i.test(now)) {
        await infraListAction(page, t.infra, 'Suspend');
        await gotoInfraList(page);
        log.push(`์žฌ Suspend โ†’ ${await waitInfraStatus(page, t.infra, /^(Suspended|Stopped)/i, 15 * 60_000)}`);
      } else log.push(`์ด๋ฏธ Suspended: ${now}`);
    } catch (e) {
      log.push(`์žฌ Suspend ์‹คํŒจ: ${String(e).split('\n')[0].slice(0, 200)}`);
    }
    test.info().annotations.push({ type: 'resume-restore', description: log.join(' / ') });
  }
}

/**
 * Infra ๋‹จ์œ„ ์ œ์–ด(๋ชฉ๋ก ์นด๋“œ 'List of Infra' ํˆด๋ฐ” โŒ„ ๋ฉ”๋‰ด: MciSuspend / MciTerminate / MciDelete โ€ฆ) โ€” IV4_S05ยทCL_S02(TC-INFRA-052ยท056)
 * ๋Œ€์ƒ Infra ํ–‰์„ ํด๋ฆญํ•ด ์ƒ์„ธ(Infra Info [ name ])๋ฅผ ์—ฐ ์ƒํƒœ์—์„œ ์‹คํ–‰ํ•œ๋‹ค. ์ฒดํฌ๋ฐ•์Šค๋งŒ ์„ ํƒํ•˜๋ฉด ๋Œ€์ƒ ID ๊ฐ€ ๋นˆ ๊ฐ’์œผ๋กœ ์ „์†ก๋˜๋Š” ๊ฒฐํ•จ์ด ์žˆ์—ˆ์Œ
 * (2026-10-02 'DelInfra infraId:""' 404) โ†’ ํ–‰ ํด๋ฆญ์œผ๋กœ ํ˜„์žฌ Infra ๋ฅผ ์ง€์ •ํ•˜๊ณ , Confirm ์ดํ›„ ์ œ์–ด API ์š”์ฒญ ๋ณธ๋ฌธ์— ์ด๋ฆ„์ด ์žˆ๋Š”์ง€ ํ™•์ธํ•œ๋‹ค.
 * ๋ฐ˜ํ™˜: ํ™•์ธ ๋ชจ๋‹ฌ ๋ฌธ๊ตฌ, ์ œ์–ด API ๊ธฐ๋ก
 */
export async function infraListAction(page: Page, name: string, item: 'Suspend' | 'Resume' | 'Terminate' | 'Delete' | 'Reboot') {
  await openInfraDetail(page, name);
  const card = page.locator('.card').filter({ has: page.locator('.card-title', { hasText: 'List of Infra' }) }).first();
  const menu = card.locator('.dropdown-menu').filter({ hasText: 'Terminate' }).first();
  await menu.locator('xpath=preceding-sibling::*[@data-bs-toggle="dropdown"][1]').click();
  await card.locator('.dropdown-menu.show .dropdown-item', { hasText: new RegExp(`^\\s*${item}\\s*$`) }).first().click();
  const modal = page.locator('#commonDefaultModal');
  await expect(modal).toBeVisible();
  const msg = (await modal.innerText()).replace(/\s+/g, ' ').trim();
  test.info().annotations.push({ type: `infra-${item.toLowerCase()}-modal`, description: `${name}: ${msg}` });
  expect(msg, 'Infra ๋‹จ์œ„ ์ œ์–ด ํ™•์ธ ๋ชจ๋‹ฌ').toMatch(/Infra/);
  const api: string[] = [];
  const onReq = (r: import('@playwright/test').Request) => {
    if (r.url().includes('/api/') && !/GetAll|GetWorkspace|GetProject|GetRequest|GetInfra\b/i.test(r.url())) api.push(`${r.method()} ${r.url().split('/api/')[1]} ${(r.postData() ?? '').slice(0, 300)}`);
  };
  page.on('request', onReq);
  await modal.locator('#commonDefaultModal-confirm-btn').click();
  await expect(modal).toBeHidden({ timeout: 15_000 });
  await page.waitForTimeout(6000);
  page.off('request', onReq);
  test.info().annotations.push({ type: `infra-${item.toLowerCase()}-api`, description: api.join(' || ').slice(0, 1200) || '(์š”์ฒญ ์—†์Œ)' });
  expect(api.join(' '), `์ œ์–ด ์š”์ฒญ ๋ณธ๋ฌธ์— ๋Œ€์ƒ Infra ${name}`).toContain(name);
  return { msg, api };
}

/**
 * RUN ํŒ์—… ์—ด๊ธฐ(์žฌ์‹œ๋„). RUN ํด๋ฆญ ์งํ›„ ํŒ์—… ๋ณธ๋ฌธ์ด 20์ดˆ ๋„˜๊ฒŒ ๋น„์–ด ์žˆ๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์Œ(2026-10-16 IV3_S08 1์ฐจ) โ†’ Cancel ํ›„ ๋ชฉ๋ก๋ถ€ํ„ฐ ๋‹ค์‹œ, ์ตœ๋Œ€ 3ํšŒ
 */
export async function openRunRetry(page: Page, name: string): Promise<FrameLocator> {
  for (let attempt = 1; ; attempt++) {
    const f = await gotoWorkflows(page);
    const ps = f.locator('.tabulator-page-size').first();
    if (await ps.count()) await ps.selectOption('100').catch(() => {});
    await page.waitForTimeout(1500);
    try {
      await openRun(f, name);
      return f;
    } catch (e) {
      if (attempt >= 3) throw e;
      test.info().annotations.push({ type: 'run-popup-retry', description: `์‹œ๋„ ${attempt}: RUN ํŒ์—… ํŒŒ๋ผ๋ฏธํ„ฐ ๋ฏธํ‘œ์‹œ โ†’ ๋‹ค์‹œ ์—ด๊ธฐ` });
      await f.locator('.modal.show').getByText('Cancel', { exact: true }).click().catch(() => page.keyboard.press('Escape'));
      await page.waitForTimeout(3000);
    }
  }
}

lib/iv3-wf.ts

ํ•ด์‹œ 475b2fb4 ยท 314์ค„

import { test, expect } from '@playwright/test';
import type { Page, FrameLocator, Frame } from '@playwright/test';
import { WORKSPACE, DEFAULT_PROJECT } from './data';
import { selectWorkspaceProject } from './ui';
import { gotoWorkflows, workflowRow } from './workflow';

// IV3 mc-workflow-manager ํ™”๋ฉด TC(TC-WF-001~006, 012~019) ๊ณต์šฉ ํ—ฌํผ โ€” 2026-10-14 ํƒ์ƒ‰(wsl/probe/wf-explore*.spec.ts)์œผ๋กœ ํ™•์ธ
// - Workflows: ์ฝ˜์†” /webconsole/_view/mcworkflows โ†’ iframe :18083/web/workflow/list. ํ–‰ ๋ฒ„ํŠผ #detail-btn/#delete-btn/#run-btn/#log-btn
//   Detail โ†’ iframe ์ด /web/workflows/workflow/detail/{idx} ๋กœ ๋ฐ”๋€œ, New Workflow โ†’ /web/workflows/workflow/new
// - Event Listeners: ์ฝ˜์†” /webconsole/_view/mceventlistener โ†’ iframe :18083/web/workflows/eventListener/list
//   ์ปฌ๋Ÿผ eventListenerName / workflowName / eventListenerDesc / eventListenerUrl, New Event Listener ๋Š” ๋ชจ๋‹ฌ(Name, Duplicate Check, Description, Workflow select, Parameters Add, Create)
// - OSS(Jenkins) ๊ด€๋ฆฌ๋Š” ์ฝ˜์†” ๋ฉ”๋‰ด์— ์—†๋‹ค โ†’ mc-workflow-manager ํ™”๋ฉด :18083/web/oss/list ์ง์ ‘ ์ง„์ž…(์‹œํŠธ ์ž…๋ ฅ๊ฐ’ Server URL ๊ณผ ๊ฐ™์Œ)
//   EDIT ๋ชจ๋‹ฌ: OSS Type / OSS Name / OSS Description / URL / OSS ID / OSS PW, Duplicate Check ยท Connection Check ยท Edit
export const EL_URL = '/webconsole/_view/mceventlistener';
/** mc-workflow-manager ํ™”๋ฉด ์ถœ์ฒ˜(์ฝ˜์†” BASE_URL ์˜ ํฌํŠธ๋งŒ 18083 ์œผ๋กœ) */
export const WF_ORIGIN = (process.env.BASE_URL ?? 'https://<STAGE_HOST>').replace(/:\d+\/?$/, ':18083');
export const SEED_WORKFLOWS = [
  'vm-mariadb-backup-import-data-init', 'vm-mariadb-data-init-cleanup',
  'k8s-mariadb-backup-import-data-init', 'k8s-mariadb-data-init-cleanup',
  'multi-csp-vm-deploy', 'multi-csp-vm-cleanup',
  'multi-csp-k8s-cluster-deploy', 'multi-csp-k8s-cluster-cleanup',
  'vm-object-storage-data-lab-init', 'vm-object-storage-data-lab-cleanup',
];

const note = (type: string, d: string) => test.info().annotations.push({ type, description: d.slice(0, 2000) });
export const clean = (s: string) => s.replace(/\s+/g, ' ').trim();

/** ์ฝ˜์†” ํŽ˜์ด์ง€ ์•ˆ์˜ :18083 iframe(Frame). ๋ชฉ๋ก ๋กœ๋”ฉ(Showing โ€ฆ rows) ๊นŒ์ง€ ๋Œ€๊ธฐ */
export async function wfFrame(page: Page, timeout = 60_000): Promise<Frame> {
  const end = Date.now() + timeout;
  while (Date.now() < end) {
    const f = page.frames().find((x) => x.url().includes(':18083'));
    if (f) return f;
    await page.waitForTimeout(500);
  }
  throw new Error(':18083 iframe ์—†์Œ');
}

/** Tabulator ๋ชฉ๋ก Page Size ๋ฅผ 100 ์œผ๋กœ(๊ธฐ๋ณธ 10: seed 10์ข… + ํ…Œ์ŠคํŠธ Workflow ๋Š” 2ํŽ˜์ด์ง€๋กœ ๋„˜์–ด๊ฐ, 2026-10-14) */
export async function showAllRows(f: FrameLocator) {
  const sel = f.locator('.tabulator-page-size').first();
  if (await sel.count()) { await sel.selectOption('100'); await f.locator('.tabulator-row').first().waitFor().catch(() => {}); }
}

/** Workflows ํ™”๋ฉด์˜ ๋ชฉ๋ก ํ–‰(์ด๋ฆ„ โ†’ {purpose,status,params,runDate}) โ€” ํŽ˜์ด์ง€ ํฌ๊ธฐ๋ฅผ 100์œผ๋กœ ๋Š˜๋ ค ์ „์ฒด๋ฅผ ์ฝ๋Š”๋‹ค */
export async function workflowRows(f: FrameLocator) {
  await showAllRows(f);
  const rows = f.locator('.tabulator-row');
  const out: Record<string, { purpose: string; status: string; params: string; runDate: string; buttons: string[] }> = {};
  for (let i = 0; i < (await rows.count()); i++) {
    const r = rows.nth(i);
    const cell = async (field: string) => clean(await r.locator(`[tabulator-field="${field}"]`).innerText().catch(() => ''));
    const name = await cell('workflowInfo.workflowName');
    const cells = await r.locator('.tabulator-cell').allInnerTexts();
    out[name] = {
      purpose: await cell('workflowInfo.workflowPurpose'),
      status: await cell('workflowInfo.status'),
      params: clean(cells[3] ?? ''),
      runDate: await cell('runDate'),
      buttons: (await r.locator('button').allInnerTexts()).map(clean),
    };
  }
  return out;
}

/** Event Listeners ํ™”๋ฉด ์ง„์ž… โ†’ iframe ๋ฐ˜ํ™˜(๋ชฉ๋ก ๋กœ๋”ฉ๊นŒ์ง€) */
export async function gotoEventListeners(page: Page): Promise<FrameLocator> {
  for (let attempt = 1; ; attempt++) {
    await page.goto(EL_URL);
    await selectWorkspaceProject(page, WORKSPACE, DEFAULT_PROJECT, true);
    const f = page.frameLocator('iframe').first();
    const ok = await f.getByText(/Showing (\d+-\d+ of \d+|0) rows/).first().waitFor({ timeout: 60_000 }).then(() => true, () => false);
    if (ok) return f;
    if (attempt >= 3) throw new Error('Event Listeners iframe ๋ชฉ๋ก์ด 3ํšŒ ๋ชจ๋‘ ๋กœ๋”ฉ๋˜์ง€ ์•Š์Œ');
    note('el-iframe-retry', `์‹œ๋„ ${attempt}`);
  }
}

export const elRow = (f: FrameLocator, name: string) =>
  f.locator('.tabulator-row').filter({ has: f.locator('[tabulator-field="eventListenerName"]', { hasText: new RegExp(`^${name}$`) }) });

/** :18083 ์˜ JSON API ๋ฅผ ๋ธŒ๋ผ์šฐ์ € ์ปจํ…์ŠคํŠธ๋กœ ํ˜ธ์ถœ(ํ™”๋ฉด์ด ๋ถ€๋ฅด๋Š” ๊ฒƒ๊ณผ ๊ฐ™์€ ์ถœ์ฒ˜). ๋ฐ˜ํ™˜ {status, body} */
export async function wfApi(page: Page, method: 'GET' | 'POST' | 'PATCH', path: string, data?: unknown) {
  const origin = WF_ORIGIN; // iframe ์ด ์—†๋Š” ํ™”๋ฉด์—์„œ๋„ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ๊ฒŒ ๊ณ ์ • ์ถœ์ฒ˜ ์‚ฌ์šฉ
  const res = method === 'GET'
    ? await page.request.get(origin + path)
    : await page.request.fetch(origin + path, { method, data, headers: { 'Content-Type': 'application/json' } });
  const text = await res.text();
  let body: any = text; try { body = JSON.parse(text); } catch { /* ํ…์ŠคํŠธ ์‘๋‹ต */ }
  return { status: res.status(), body };
}

/** Workflow ํผ(New/Detail)์˜ ํŒŒ๋ผ๋ฏธํ„ฐ ํ–‰: PARAM_KEY ์ž…๋ ฅ๋ž€๊ณผ ๊ฐ™์€ ํ–‰์˜ Value ์ž…๋ ฅ๋ž€ */
export async function formParams(f: Frame) {
  return f.getByPlaceholder('PARAM_KEY').evaluateAll((els) => els.map((e) => {
    let row: HTMLElement | null = e.parentElement;
    while (row && !row.querySelector('input[placeholder="Value"], textarea[placeholder="Value"]')) row = row.parentElement;
    const v = row?.querySelector('input[placeholder="Value"], textarea[placeholder="Value"]') as HTMLInputElement | null;
    return { key: (e as HTMLInputElement).value, value: v?.value ?? '' };
  }));
}

/** ํŒ”๋ ˆํŠธ(Infra/K8s/App/Database/Object Storage/Utility) ๊ทธ๋ฃน์„ ํŽผ์ณ Stage ํ•ญ๋ชฉ ํด๋ฆญ โ†’ ํŒŒ์ดํ”„๋ผ์ธ ๋(DEFAULT_END ์•ž)์— ์ถ”๊ฐ€ */
export async function addStage(page: Page, f: Frame, group: string, stage: string) {
  const open = f.locator('.accordion-collapse.show .paletteItem').filter({ hasText: new RegExp(`^\\s*${stage}\\s*$`) });
  if (!(await open.count())) { await f.getByRole('button', { name: group, exact: true }).click(); await page.waitForTimeout(800); }
  await open.first().click();
  await page.waitForTimeout(1200);
}

/** ํ™”๋ฉด์˜ ํŒŒ์ดํ”„๋ผ์ธ Stage ๋ธ”๋ก ์ˆœ์„œ: "Utility (namespace-ensure)" ํ˜•ํƒœ์˜ ์ œ๋ชฉ์—์„œ Stage ์ด๋ฆ„๋งŒ */
export async function pipelineStages(f: Frame) {
  const t = await f.locator('body').innerText();
  return [...t.matchAll(/(?:Infra|K8s|App|Database|Object Storage|Utility) \(([a-z0-9-]+)\)/g)].map((m) => m[1]);
}

/** ํ† ์ŠคํŠธ ๋ฌธ๊ตฌ๋ฅผ ์ผ์ • ์‹œ๊ฐ„ ๋ชจ์€๋‹ค(vue-toastification ์€ 5์ดˆ ํ›„ ์‚ฌ๋ผ์ง) */
export async function collectToasts(page: Page, f: Frame, ms = 6000) {
  const seen = new Set<string>();
  const end = Date.now() + ms;
  while (Date.now() < end) {
    for (const t of await f.locator('[class*="toast"]').allInnerTexts().catch(() => [] as string[])) { const c = clean(t).replace(/\s*ร—\s*$/, ''); if (c) seen.add(c); }
    await page.waitForTimeout(300);
  }
  return [...seen];
}

/**
 * New Workflow: ์ด๋ฆ„ โ†’ Duplicate Check โ†’ Purpose โ†’ Create Script โ†’ ํŒ”๋ ˆํŠธ Stage ์ถ”๊ฐ€ โ†’ (Regist).
 * stages: [๊ทธ๋ฃน, Stage ์ด๋ฆ„][]. regist=false ๋ฉด Regist ์ง์ „์—์„œ ๋ฉˆ์ถ˜๋‹ค. ๋ฐ˜ํ™˜: Duplicate/Regist ํ† ์ŠคํŠธ, ํ™”๋ฉด Stage ์ˆœ์„œยทํŒŒ๋ผ๋ฏธํ„ฐ
 */
export async function createWorkflow(page: Page, name: string, purpose: string, stages: [string, string][], regist = true) {
  const fl = await gotoWorkflows(page);
  await fl.getByText('New Workflow', { exact: true }).first().click();
  const f = await wfFrame(page);
  await f.waitForURL(/workflow\/new/);
  await f.getByPlaceholder('Enter the workflow name').waitFor();
  await page.waitForTimeout(1500);
  await f.getByPlaceholder('Enter the workflow name').fill(name);
  await f.getByRole('button', { name: 'Duplicate Check' }).click();
  const dupToasts = await collectToasts(page, f, 2500);
  await f.locator('select').first().selectOption({ label: purpose });
  await f.getByRole('button', { name: 'Create Script' }).click();
  await page.waitForTimeout(2500);
  for (const [g, st] of stages) await addStage(page, f, g, st);
  const shownStages = await pipelineStages(f);
  const params = await formParams(f);
  let registToasts: string[] = [];
  if (regist) {
    await f.getByRole('button', { name: 'Regist', exact: true }).click();
    registToasts = await collectToasts(page, f, 6000);
  }
  return { f, dupToasts, shownStages, params, registToasts };
}

/** ์ด๋ฆ„์œผ๋กœ Workflow ์ƒ์„ธ(API: ํ™”๋ฉด Detail ์ด ์“ฐ๋Š” /eventlistener/workflowDetail/{idx}/N) */
export async function workflowDetailByName(page: Page, name: string) {
  const list: any[] = (await wfApi(page, 'GET', '/eventlistener/workflowList/N')).body?.data ?? [];
  const w = list.find((x) => x.workflowInfo?.workflowName === name);
  if (!w) return undefined;
  const d = (await wfApi(page, 'GET', `/eventlistener/workflowDetail/${w.workflowInfo.workflowIdx}/N`)).body?.data;
  return d as { workflowInfo: any; workflowParams: any[]; workflowStageMappings: any[] };
}

/** ์ €์žฅ ํ‚ค ์ˆ˜ ๊ทœ์น™(์‹œํŠธ ๊ธฐ๋Œ€๊ฒฐ๊ณผ): ๊ณต๋ฐฑ ํ‚ค ์ œ์™ธ ยท ๋Œ€๋ฌธ์ž ์ •๊ทœํ™” ํ›„ ์ค‘๋ณต ์ œ๊ฑฐ */
export const normalizedKeyCount = (keys: string[]) => new Set(keys.map((k) => k.trim().toUpperCase()).filter(Boolean)).size;

/** ๋ชฉ๋ก DELETE โ†’ ํ™•์ธ ๋ชจ๋‹ฌ Delete. ๋ฐ˜ํ™˜: ํ† ์ŠคํŠธ */
export async function deleteWorkflow(page: Page, name: string) {
  const fl = await gotoWorkflows(page);
  await showAllRows(fl);
  await workflowRow(fl, name).locator('#delete-btn').click();
  const m = fl.locator('.modal.show');
  await expect(m.getByText(`Are you sure you want to delete ${name}?`)).toBeVisible();
  await m.getByText('Delete', { exact: true }).last().click();
  return collectToasts(page, await wfFrame(page), 5000);
}

/** ํผ ํŒŒ๋ผ๋ฏธํ„ฐ Value ์ž…๋ ฅ(PARAM_KEY ๊ฐ€ key ์ธ ํ–‰). ์ ‘ํ˜€ ์žˆ๋Š”(Advanced) ํ–‰์ด๋ฉด false */
export async function setFormParam(f: Frame, key: string, value: string) {
  const tag = await f.getByPlaceholder('PARAM_KEY').evaluateAll((els, k) => {
    for (const e of els) {
      if ((e as HTMLInputElement).value.trim().toUpperCase() !== k) continue;
      let row: HTMLElement | null = e.parentElement;
      while (row && !row.querySelector('input[placeholder="Value"], textarea[placeholder="Value"]')) row = row.parentElement;
      const v = row?.querySelector('input[placeholder="Value"], textarea[placeholder="Value"]') as HTMLInputElement | null;
      if (v) { v.setAttribute('data-e2e-param', k); return true; }
    }
    // ํ‚ค๊ฐ€ ์ž…๋ ฅ๋ž€์ด ์•„๋‹ˆ๋ผ ๋ผ๋ฒจ๋กœ ํ‘œ์‹œ๋˜๋Š” ํ–‰(์˜ˆ: NAMESPACE, 2026-10-14 EL ๋ชจ๋‹ฌ) โ†’ ํ…์ŠคํŠธ๊ฐ€ ์ •ํ™•ํžˆ ํ‚ค์ธ ์š”์†Œ์—์„œ ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด Value ์ž…๋ ฅ๋ž€
    const root = (els[0] as HTMLElement | undefined)?.closest('.modal, form, body') ?? document.body;
    for (const e of Array.from(root.querySelectorAll('span, code, label, div, small, strong'))) {
      if (e.children.length || (e.textContent || '').trim() !== k) continue;
      let row: HTMLElement | null = e.parentElement;
      for (let i = 0; i < 5 && row && !row.querySelector('input[placeholder="Value"], textarea[placeholder="Value"]'); i++) row = row.parentElement;
      const v = row?.querySelector('input[placeholder="Value"], textarea[placeholder="Value"]') as HTMLInputElement | null;
      if (v) { v.setAttribute('data-e2e-param', k); return true; }
    }
    return false;
  }, key.toUpperCase());
  if (!tag) return false;
  const input = f.locator(`[data-e2e-param="${key.toUpperCase()}"]`).first();
  // Advanced / Show Secret ์•„๋ž˜ ์ ‘ํžŒ ํ–‰์ด๋ฉด ํŽผ์นœ๋‹ค
  for (const b of [/^Advanced/, /^Show Secret/]) {
    if (await input.isVisible()) break;
    const btn = f.getByRole('button', { name: b }).first();
    if (await btn.isVisible().catch(() => false)) { await btn.click(); await f.page().waitForTimeout(600); }
  }
  if (!(await input.isVisible())) return false;
  await input.fill(value);
  await expect(input).toHaveValue(value);
  return true;
}

/** ํŒŒ์ดํ”„๋ผ์ธ ๋ธ”๋ก ์‚ญ์ œ: "๊ทธ๋ฃน (stage)" ์ œ๋ชฉ ๋ธ”๋ก์˜ delete ๋ฒ„ํŠผ */
export async function removeStage(page: Page, f: Frame, stage: string) {
  const ok = await f.locator('button').evaluateAll((els, st) => {
    for (const b of els) {
      if ((b.textContent || '').trim().toLowerCase() !== 'delete') continue;
      let box: HTMLElement | null = b.parentElement;
      for (let i = 0; i < 4 && box; i++) {
        if (new RegExp(`\\(${st}\\)`).test(box.innerText.split('\n').slice(0, 3).join(' '))) { b.setAttribute('data-e2e-del', st); return true; }
        box = box.parentElement;
      }
    }
    return false;
  }, stage);
  if (!ok) throw new Error(`ํŒŒ์ดํ”„๋ผ์ธ์—์„œ ${stage} ๋ธ”๋ก์˜ delete ๋ฒ„ํŠผ์„ ์ฐพ์ง€ ๋ชปํ•จ`);
  await f.locator(`[data-e2e-del="${stage}"]`).click();
  await page.waitForTimeout(1200);
}

/** Workflows ๋ชฉ๋ก โ†’ ๋Œ€์ƒ Detail ํ™”๋ฉด(Frame) */
export async function openWorkflowDetail(page: Page, name: string) {
  const fl = await gotoWorkflows(page);
  await showAllRows(fl);
  await workflowRow(fl, name).locator('#detail-btn').click();
  const f = await wfFrame(page);
  await f.waitForURL(/workflow\/detail\/\d+/);
  await f.getByPlaceholder('Enter the workflow name').waitFor();
  await page.waitForTimeout(3000); // ํŒŒ๋ผ๋ฏธํ„ฐยทStage ๋กœ๋”ฉ
  return f;
}

/** ์ƒ์„ธ API ์š”์•ฝ: ์‚ฌ์šฉ์ž Stage ์ˆœ์„œ, ํŒŒ๋ผ๋ฏธํ„ฐ keyโ†’value, script */
export function detailSummary(d: any) {
  const stages = (d?.workflowStageMappings ?? []).filter((m: any) => !m.isDefaultScript && m.workflowStageName)
    .sort((a: any, b: any) => a.stageOrder - b.stageOrder).map((m: any) => m.workflowStageName as string);
  const params: Record<string, string> = {};
  for (const p of d?.workflowParams ?? []) params[String(p.paramKey)] = String(p.paramValue ?? '');
  return { stages, params, script: String(d?.workflowInfo?.script ?? '') };
}

/** Event Listener ์ƒ์„ธ(API GET /eventlistener/{idx}) โ€” ์ด๋ฆ„์œผ๋กœ ์ฐพ์Œ */
export async function elByName(page: Page, name: string) {
  const list: any[] = (await wfApi(page, 'GET', '/eventlistener/list')).body?.data ?? [];
  const e = list.find((x) => x.eventListenerName === name);
  if (!e) return undefined;
  const d = (await wfApi(page, 'GET', `/eventlistener/${e.eventListenerIdx}`)).body?.data;
  return { ...e, ...d } as any;
}

/** EL ๋ชฉ๋ก โ†’ ํ–‰ Delete โ†’ ํ™•์ธ ๋ชจ๋‹ฌ Delete */
export async function deleteEventListener(page: Page, name: string) {
  const f = await gotoEventListeners(page);
  await elRow(f, name).locator('#delete-btn').click();
  const m = f.locator('.modal.show');
  await m.getByText('Delete', { exact: true }).last().click();
  return collectToasts(page, await wfFrame(page), 5000);
}

/** New Event Listener ๋ชจ๋‹ฌ ์—ด๊ธฐ โ†’ Frame ๊ณผ ๋ชจ๋‹ฌ locator */
export async function openNewEventListener(page: Page) {
  const fl = await gotoEventListeners(page);
  await fl.getByText('New Event Listener', { exact: true }).first().click();
  const f = await wfFrame(page);
  const m = f.locator('.modal.show');
  await m.getByPlaceholder('Enter Event Listener Name').waitFor();
  return { f, m };
}

/** Workflow ์˜ Jenkins Build ๋ชฉ๋ก(GET /workflow/log/{idx}: buildIdx, buildLog) */
export async function workflowBuilds(page: Page, workflowIdx: number) {
  return ((await wfApi(page, 'GET', `/workflow/log/${workflowIdx}`)).body?.data ?? []) as { buildIdx: number; buildLog: string }[];
}

/**
 * Event Listener Action URL ํ˜ธ์ถœ โ†’ ์ ‘์ˆ˜ ์‘๋‹ต๊ณผ ์ƒˆ Build ์ถ”์ .
 * ๋ฐ˜ํ™˜: ์‘๋‹ต(status, body), ์ƒˆ buildIdx, ์ตœ์ข… ๋กœ๊ทธ(์™„๋ฃŒ ๋˜๋Š” maxWaitMs ๊ฒฝ๊ณผ ์‹œ์ ), ์™„๋ฃŒ ์—ฌ๋ถ€
 */
export async function callEventListener(page: Page, elIdx: number, workflowIdx: number, method: 'GET' | 'POST', params: Record<string, string>, maxWaitMs = 20 * 60_000) {
  const before = await workflowBuilds(page, workflowIdx);
  const lastBefore = Math.max(0, ...before.map((b) => Number(b.buildIdx)));
  const path = `/eventlistener/run/${elIdx}`;
  const res = method === 'GET'
    ? await wfApi(page, 'GET', `${path}?${new URLSearchParams(params).toString()}`)
    : await wfApi(page, 'POST', path, params);
  note('el-call', `${method} ${path} โ†’ HTTP ${res.status} ${JSON.stringify(res.body).slice(0, 200)} (์ง์ „ Build #${lastBefore})`);
  let build: { buildIdx: number; buildLog: string } | undefined;
  const end = Date.now() + maxWaitMs;
  while (Date.now() < end) {
    await page.waitForTimeout(15_000);
    build = (await workflowBuilds(page, workflowIdx)).find((b) => Number(b.buildIdx) > lastBefore);
    if (build && /Finished: \w+/.test(build.buildLog)) break;
  }
  const finished = build?.buildLog.match(/Finished: (\w+)/)?.[1];
  note('el-build', `์ƒˆ Build #${build?.buildIdx ?? '(์—†์Œ)'} / ๊ฒฐ๊ณผ ${finished ?? '(์ง„ํ–‰ ์ค‘)'}`);
  return { res, lastBefore, build, finished };
}

/** ์‹คํŒจํ•œ Build ๋กœ๊ทธ์˜ ์›์ธ ์ค„(ERROR/error/HTTP 4xxยท5xx) */
export const failureLines = (log: string) => log.split(/\r?\n/).filter((l) => /ERROR|error:|Exception|Http_Status_code:[45]\d\d|failed/i.test(l)).slice(-4).map((l) => l.slice(0, 300));

export { gotoWorkflows, workflowRow, expect };

lib/iv4-infra.ts

ํ•ด์‹œ 450851f7 ยท 448์ค„

import { test, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import type { Csp } from './data';
import { infraName, infraNodegroups, WORKSPACE, DEFAULT_PROJECT } from './data';
import { selectWorkspaceProject } from './ui';

// IV4 ์ „์šฉ Infra ํ—ฌํผ. ์…€๋ ‰ํ„ฐ๋Š” 2026-10-02 IV4 ์‚ฌ์ „์ ๊ฒ€(tools/probe-iv4*.mjs)์œผ๋กœ ํ™•์ธํ–ˆ๋‹ค.
//  - ๋ชฉ๋ก: /webconsole/operations/manage/workloads/infraworkloads (๋กœ๊ทธ์ธ ์งํ›„ ์ฒซ ํ™”๋ฉด), Tabulator, "Showing x-y of n rows"
//  - Add Infra ํผ: #mci_name, #mci_deploy_algorithm, #mci_policy_on_partial_failure, '+ NodeGroup' ์นด๋“œ
//  - Node Configuration: #ep_name, #ep_specId(๊ฒ€์ƒ‰ โ†’ #spec-search 'Node Recommendation'), #ep_imageId_input(๊ฒ€์ƒ‰ โ†’ #image-search 'Image Recommendation'), #ep_vm_add_cnt, #express_done_btn
export const INFRA_URL = '/webconsole/operations/manage/workloads/infraworkloads';

/** CSP ๋ณ„ Add Infra ์ž…๋ ฅ๊ฐ’(IV3_S02 ์‚ฌ์ „์ ๊ฒ€ ๊ธฐ์ค€). AWS ์™ธ๋Š” ๋ฏธํ™•์ธ โ†’ ํ˜ธ์ถœ ์ธก์—์„œ SKIP/BLOCKED ํŒ๋‹จ */
const CREATE: Record<string, { provider: string; region: string; spec: string; mem: string; cpu: string; os: string; image?: string }> = {
  AWS: { provider: 'aws', region: 'ap-northeast-2', spec: 't3.micro', mem: '1', cpu: '2', os: 'ubuntu 22.04', image: 'ami-0efae34c528c68a9c' },
};

/** "Workspace Selection Check" ๋“ฑ Confirm ๋ชจ๋‹ฌ. ๋ณด์ด๋Š”๋ฐ ํด๋ฆญ์ด ์•ˆ ๋˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์–ด ๊ฐ•์ œ ํด๋ฆญ/ESC ๋กœ ๋‹ซ๋Š”๋‹ค(2026-10-02 ๊ด€์ฐฐ). */
export async function closeConfirmModal(page: Page) {
  const confirm = page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).first();
  for (let i = 0; i < 3; i++) {
    if (!(await confirm.isVisible({ timeout: 2500 }).catch(() => false))) return;
    await confirm.click({ force: true, timeout: 5000 }).catch(() => page.keyboard.press('Escape'));
    await page.waitForTimeout(800);
  }
}

/** Infra ๋ชฉ๋ก์œผ๋กœ ์ด๋™ํ•ด ws01/default ์„ ํƒ ํ›„ ๋ชฉ๋ก ๋กœ๋”ฉ ์™„๋ฃŒ๊นŒ์ง€ ๋Œ€๊ธฐ */
export async function gotoInfraList(page: Page) {
  if (!page.url().includes('/workloads/infraworkloads')) await page.goto(INFRA_URL);
  await closeConfirmModal(page);
  await selectWorkspaceProject(page, WORKSPACE, DEFAULT_PROJECT, true);
  await closeConfirmModal(page);
  await page.getByText(/Showing \d+-\d+ of \d+ rows|Showing 0 rows/).first().waitFor({ timeout: 30_000 }).catch(() => {});
  await page.waitForTimeout(2000);
  // Infra ๊ฐ€ 5๊ฐœ๋ฅผ ๋„˜์œผ๋ฉด ๊ธฐ๋ณธ page size 5 ์—์„œ ๋Œ€์ƒ(mc9 ๋“ฑ)์ด 2ํŽ˜์ด์ง€๋กœ ๋ฐ€๋ฆฐ๋‹ค โ†’ ๊ฐ€์žฅ ํฐ page size ๋กœ(2026-10-14, lib/infra.ts ์™€ ๊ฐ™์€ ์ฒ˜๋ฆฌ)
  const ps = page.locator('.tabulator-page-size').first();
  if (await ps.count()) {
    const last = await ps.locator('option').evaluateAll((os) => (os[os.length - 1] as HTMLOptionElement).value).catch(() => '');
    if (last) { await ps.selectOption(last).catch(() => {}); await page.waitForTimeout(1000); }
  }
}

/** ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ. page.reload ๋Š” fixtures.netRetry(goto ์žฌ์‹œ๋„) ๋Œ€์ƒ์ด ์•„๋‹ˆ๋ผ ERR_NETWORK_CHANGED ์— ์ทจ์•ฝ โ†’ goto ์‚ฌ์šฉ */
export async function refreshInfraList(page: Page) {
  await page.goto(INFRA_URL);
  await gotoInfraList(page);
}

export const infraRow = (page: Page, name: string) =>
  page.getByRole('row').filter({ has: page.getByRole('gridcell', { name, exact: true }) });

/**
 * ๋ชฉ๋ก์— Infra ํ–‰์ด ๋‚˜ํƒ€๋‚  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ. "Showing 0 rows" ๊ฐ€ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ ์ „์— ๋จผ์ € ๋ณด์ด๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์–ด(2026-10-02, ์‹ค์ œ๋กœ๋Š” ์กด์žฌ)
 * ํ–‰์ด ์—†์œผ๋ฉด ์ƒˆ๋กœ๊ณ ์นจ+์žฌ์„ ํƒ์„ ์ตœ๋Œ€ 3ํšŒ ๋ฐ˜๋ณตํ•œ๋‹ค. ๋๋‚ด ์—†์œผ๋ฉด false.
 */
export async function waitInfraListed(page: Page, name: string) {
  for (let i = 0; i < 4; i++) {
    if (await infraRow(page, name).first().waitFor({ timeout: 15_000 }).then(() => true, () => false)) return true;
    if (i === 3) break;
    test.info().annotations.push({ type: 'list-retry', description: `${name} ํ–‰ ๋ฏธํ‘œ์‹œ โ†’ ์ƒˆ๋กœ๊ณ ์นจ ${i + 1}` });
    await refreshInfraList(page);
  }
  return false;
}

/** ๋ชฉ๋ก ํ–‰ ํ…์ŠคํŠธ(๊ณต๋ฐฑ ์ •๋ฆฌ). ์—†์œผ๋ฉด '' */
export async function infraRowText(page: Page, name: string) {
  const row = infraRow(page, name);
  return (await row.count()) ? (await row.first().innerText()).replace(/\s+/g, ' ').trim() : '';
}

/** ๋ชฉ๋ก์˜ Status ์—ด์ด pattern ์— ๋งž์„ ๋•Œ๊นŒ์ง€ ์ƒˆ๋กœ๊ณ ์นจํ•˜๋ฉฐ ๋Œ€๊ธฐ */
export async function waitInfraStatus(page: Page, name: string, pattern: RegExp, timeoutMs = 15 * 60_000) {
  const end = Date.now() + timeoutMs;
  let last = '';
  while (Date.now() < end) {
    await infraRow(page, name).first().waitFor({ timeout: 15_000 }).catch(() => {});
    last = await infraRowText(page, name);
    if (pattern.test(last)) return last;
    await page.waitForTimeout(15_000);
    await refreshInfraList(page);
  }
  throw new Error(`${name} ์ƒํƒœ๊ฐ€ ${pattern} ์— ๋„๋‹ฌํ•˜์ง€ ์•Š์Œ(๋งˆ์ง€๋ง‰: ${last || 'ํ–‰ ์—†์Œ'})`);
}

/** ๋ชฉ๋ก์—์„œ Infra ํ–‰ ํด๋ฆญ โ†’ ํ•˜๋‹จ Infra Info / Node List ํ‘œ์‹œ๊นŒ์ง€ ๋Œ€๊ธฐ */
export async function openInfraDetail(page: Page, name: string) {
  // ํ–‰ ํด๋ฆญ์ด ์ƒ์„ธ๋ฅผ ์—ด์ง€ ๋ชปํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์Œ(2026-10-05, ์—ฐ์† ์ƒˆ๋กœ๊ณ ์นจ ์ค‘) โ†’ ์ตœ๋Œ€ 3ํšŒ ํด๋ฆญ
  const title = page.getByText(`Infra Info [ ${name} ]`);
  for (let i = 0; i < 3; i++) {
    await infraRow(page, name).first().click();
    if (await title.isVisible({ timeout: 10_000 }).catch(() => false)) break;
  }
  await expect(title).toBeVisible({ timeout: 20_000 });
  await page.locator('#tabs-mci-default .vm-checkbox').first().waitFor({ timeout: 30_000 });
}

/** Infra Info ์˜ Status ๋ฌธ์ž์—ด (์˜ˆ: "Running:1 (R:1/1)") */
export async function infraInfoStatus(page: Page) {
  const t = await page.locator('body').innerText();
  return (t.match(/Infra Info \[[^\]]*\][\s\S]*?Status\s*\n?\s*([^\n]+)/)?.[1] ?? '').trim();
}

/** Default ํƒญ Node List ์˜ node ์ด๋ฆ„ ๋ชฉ๋ก (์˜ˆ: ['g1-1', 'g1-2']) */
export async function nodeNames(page: Page) {
  return page.locator('#tabs-mci-default .vm-checkbox').evaluateAll((els) => els.map((e) => e.id.replace('checkbox_vm_', '')));
}

/**
 * node ํด๋ฆญ โ†’ Node Info [ ng / node ] ์ƒ์„ธ ํ™•์ธ. 2026-10-02 ํ™•์ธํ•œ ํ•ญ๋ชฉ:
 * ํ—ค๋” CSP(#server_info_csp), Region, Zone, Config Name, NodeID, Node Spec(#server_info_vmspec_name), Architecture, Public/Private IP
 * Detail ํƒญ: Node ID, Node Status, Node Spec, Public IP, Region, Zone, Security Group, Private IP, Image ID, Platform, OS, VPC/Subnet, Root Device, Block Device, Keypair, User ID
 */
export async function readNodeInfo(page: Page, ng: string, node: string) {
  // node ์นด๋“œ ํด๋ฆญ์€ ์ฒดํฌ๋ฅผ ํ† ๊ธ€ํ•œ๋‹ค. ๋‹ค๋ฅธ node ๊ฐ€ ์ฒดํฌ๋œ ์ƒํƒœ์—์„œ ํด๋ฆญํ•˜๋ฉด Node Info ๊ฐ€ ๋ฐ”๋€Œ์ง€ ์•Š๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์–ด(2026-10-05)
  // ๋‹ค๋ฅธ node ์ฒดํฌ๋ฅผ ๋ชจ๋‘ ํ•ด์ œํ•˜๊ณ  ๋Œ€์ƒ ์นด๋“œ๋งŒ ์„ ํƒํ•œ๋‹ค. ๋Œ€์ƒ์ด ์ด๋ฏธ ์ฒดํฌ๋ผ ์žˆ์œผ๋ฉด ํด๋ฆญ์ด ํ•ด์ œ๊ฐ€ ๋˜๋ฏ€๋กœ Node Info ๊ฐ€ ๋‚˜์˜ฌ ๋•Œ๊นŒ์ง€ ์ตœ๋Œ€ 3ํšŒ.
  const card = page.locator('#tabs-mci-default');
  for (const id of await card.locator('.vm-checkbox:checked').evaluateAll((els) => els.map((e) => e.id))) {
    if (id !== `checkbox_vm_${node}`) await card.locator(`#${id}`).uncheck();
  }
  const title = page.getByText(new RegExp(`Node Info \\[ ${ng} / ${node} \\]`));
  for (let i = 0; i < 3; i++) {
    await card.locator(`#server_status_icon_${node}`).click();
    if (await title.isVisible({ timeout: 6000 }).catch(() => false)) break;
  }
  await expect(title).toBeVisible({ timeout: 15_000 });
  const box = page.locator('#server_info');
  await expect(box.locator('#server_info_csp')).not.toBeEmpty({ timeout: 20_000 });
  const t = (await box.innerText()).replace(/\r/g, '');
  const field = (label: string) => (t.match(new RegExp(`(?:^|\\n)${label}\\s*\\n\\s*([^\\n]*)`))?.[1] ?? '').trim();
  const info = {
    csp: (await box.locator('#server_info_csp').innerText()).trim(),
    region: (await box.locator('#server_info_region').innerText()).trim(),
    zone: (await box.locator('#server_info_zone').innerText()).trim(),
    spec: (await box.locator('#server_info_vmspec_name').innerText()).trim(),
    status: field('Node Status'),
    publicIp: field('Public IP\\(IPv4\\)') || field('Public IP'),
    privateIp: field('Private IP'),
    os: field('Platform'),
  };
  test.info().annotations.push({ type: `node-info ${node}`, description: Object.entries(info).map(([k, v]) => `${k}=${v || '-'}`).join(', ') });
  return info;
}

/**
 * node ์ƒํƒœ๊ฐ€ pattern ์— ๋งž์„ ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ(๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ โ†’ ์ƒ์„ธ โ†’ node ํด๋ฆญ โ†’ Detail ์˜ Node Status).
 * node ๊ฐ€ Node List ์—์„œ ์‚ฌ๋ผ์ง€๋ฉด '(์—†์Œ)' ์œผ๋กœ ๋ณธ๋‹ค(Delete ํ™•์ธ์šฉ). ๋งˆ์ง€๋ง‰ ์ƒํƒœ๋ฅผ ๋Œ๋ ค์ค€๋‹ค.
 */
export async function waitNodeStatus(page: Page, infra: string, ng: string, node: string, pattern: RegExp, timeoutMs = 10 * 60_000) {
  const start = Date.now();
  const trail: string[] = [];
  let st = '';
  while (Date.now() - start < timeoutMs) {
    await refreshInfraList(page);
    if (await waitInfraListed(page, infra)) {
      await openInfraDetail(page, infra);
      st = (await nodeNames(page)).includes(node) ? (await readNodeInfo(page, ng, node)).status || '(๋นˆ ๊ฐ’)' : '(์—†์Œ)';
      if (trail[trail.length - 1]?.split('@')[0] !== st) trail.push(`${st}@${Math.round((Date.now() - start) / 1000)}s`);
      if (pattern.test(st)) break;
    }
    await page.waitForTimeout(10_000);
  }
  test.info().annotations.push({ type: `node-status-trail ${node}`, description: trail.join(' โ†’ ') });
  return st;
}

/** ๋ชฉ๋ก ํ–‰์˜ ์ˆซ์ž ์—ด(gridcell ๋‹จ์œ„). ์—ด ์ˆœ์„œ: [์ฒดํฌ๋ฐ•์Šค, Status, Id, Name, ProviderImg, Total Nodes, Running, Suspended, Terminated, Failed] */
export async function infraCounts(page: Page, name: string) {
  const cells = (await infraRow(page, name).first().getByRole('gridcell').allInnerTexts()).map((t) => t.trim());
  return { total: Number(cells[5]), running: Number(cells[6]), suspended: Number(cells[7]), terminated: Number(cells[8]), failed: Number(cells[9]), cells: cells.join(' | ') };
}

/**
 * Node List(Default ํƒญ) ์˜ node ์ฒดํฌ โ†’ ํˆด๋ฐ” โŒ„ ๋ฉ”๋‰ด ํ•ญ๋ชฉ โ†’ ํ™•์ธ ๋ชจ๋‹ฌ Confirm.
 * ๋ชฉ๋ก ํˆด๋ฐ” โŒ„ ๋ฉ”๋‰ด(MciReboot ๋“ฑ)๋Š” Infra ์ „์ฒด ๋Œ€์ƒ์ด๋ฏ€๋กœ ์“ฐ์ง€ ์•Š๋Š”๋‹ค. Node List ๋ฉ”๋‰ด๋Š” VmReboot/VmSuspend/VmResume.
 */
export async function nodeMenuAction(page: Page, node: string, item: 'Reboot' | 'Suspend' | 'Resume' | 'Terminate' | 'Delete') {
  const card = page.locator('#tabs-mci-default');
  // node ์นด๋“œ ํด๋ฆญ๋„ ์ฒดํฌ๋ฅผ ํ† ๊ธ€ํ•œ๋‹ค โ†’ ๋Œ€์ƒ ์™ธ node ๊ฐ€ ์ฒดํฌ๋ผ ์žˆ์œผ๋ฉด ํ•จ๊ป˜ ์ œ์–ด๋˜๋ฏ€๋กœ(Terminate/Delete ์œ„ํ—˜) ๋Œ€์ƒ๋งŒ ์ฒดํฌ
  for (const other of await nodeNames(page)) {
    if (other === node) continue;
    const ocb = card.locator(`#checkbox_vm_${other}`);
    if (await ocb.isChecked()) await ocb.uncheck();
    await expect(ocb).not.toBeChecked();
  }
  const cb = card.locator(`#checkbox_vm_${node}`);
  if (!(await cb.isChecked())) await cb.check();
  await expect(cb).toBeChecked();
  const checked = await card.locator('.vm-checkbox:checked').evaluateAll((els) => els.map((e) => e.id));
  expect(checked, `์ฒดํฌ๋œ node ๋Š” ${node} ํ•˜๋‚˜`).toEqual([`checkbox_vm_${node}`]);
  // Node List ์นด๋“œ์— โŒ„ ๋ฉ”๋‰ด๊ฐ€ 2๊ฐœ(Action ์ƒ˜ํ”Œ / ์‹ค์ œ ๋ฉ”๋‰ด) โ†’ Reboot ํ•ญ๋ชฉ์„ ๊ฐ€์ง„ ๋ฉ”๋‰ด์˜ ํ† ๊ธ€์„ ์—ฐ๋‹ค
  const menu = card.locator('.dropdown-menu').filter({ hasText: 'Reboot' }).first();
  await menu.locator('xpath=preceding-sibling::*[@data-bs-toggle="dropdown"][1]').click();
  const menuItem = card.locator('.dropdown-menu.show .dropdown-item', { hasText: new RegExp(`^\\s*${item}\\s*$`) }).first();
  await menuItem.click();
  const modal = page.locator('#commonDefaultModal');
  await expect(modal).toBeVisible();
  const msg = (await modal.locator('#commonDefaultModal-content').innerText()).trim();
  test.info().annotations.push({ type: 'confirm-modal', description: `${(await modal.locator('#commonDefaultModal-title').innerText()).trim()} / ${msg}` });
  expect(msg.toLowerCase()).toContain('node'); // node ๋Œ€์ƒ ํ™•์ธ(Infra ์ „์ฒด ๋Œ€์ƒ์ด๋ฉด 'Infra')
  // ์ฆ๊ฑฐ: Confirm ์ดํ›„ 5์ดˆ๊ฐ„์˜ /api/ ํ˜ธ์ถœ์„ ๊ธฐ๋ก(๋ชฉ๋ก ์กฐํšŒ GetAllInfra ๋“ฑ๋งŒ ์ œ์™ธ). ์ œ์–ด API ์ด๋ฆ„์ด Get* ์ผ ์ˆ˜ ์žˆ์–ด ์ด๋ฆ„์œผ๋กœ ๊ฑฐ๋ฅด์ง€ ์•Š๋Š”๋‹ค
  return withAsyncRequest(page, async () => {
    await modal.locator('#commonDefaultModal-confirm-btn').click();
    await expect(modal).toBeHidden({ timeout: 15_000 });
  });
}

/**
 * ์ œ์–ด ๋™์ž‘(act)์„ ์ˆ˜ํ–‰ํ•˜๊ณ  ๋ฐœ๊ธ‰๋œ ๋น„๋™๊ธฐ ์š”์ฒญ ID ๋ฅผ ๋Œ๋ ค์ค€๋‹ค.
 * ์ œ์–ด๋Š” ๋น„๋™๊ธฐ ์š”์ฒญ์œผ๋กœ ์ฒ˜๋ฆฌ๋œ๋‹ค(2026-10-02 ํ™•์ธ): ์š”์ฒญ ID(mcwc-...)๊ฐ€ GetRequest ์˜ pathParams.reqId ๋กœ ๋ณด์ด๊ณ ,
 * ์ง„ํ–‰/๊ฒฐ๊ณผ๋Š” ์ƒ๋‹จ ์ข… ์•„์ด์ฝ˜(#async-request-notif-toggle) ํŒจ๋„ "Node reboot: g1-1 ยท Completed" ๋กœ ํ™•์ธ๋œ๋‹ค.
 */
export async function withAsyncRequest(page: Page, act: () => Promise<void>) {
  const apiLog: string[] = [];
  let reqId = '';
  const onResp = async (r: import('@playwright/test').Response) => {
    if (!r.url().includes('/api/') || /GetAllInfra|GetAllNs|GetWorkspace|GetProject/i.test(r.url())) return;
    const body = r.request().postData() ?? '';
    reqId ||= body.match(/"reqId"\s*:\s*"(mcwc-[^"]+)"/)?.[1] ?? '';
    apiLog.push(`${r.status()} ${r.url().split('/api/')[1]} req=${body.slice(0, 200)} res=${(await r.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 200)}`);
  };
  page.on('response', onResp);
  await act();
  await page.waitForTimeout(5000);
  page.off('response', onResp);
  const t = (await page.locator('[class*="toast"], [role="alert"]').allInnerTexts().catch(() => [])).map((x) => x.replace(/\s+/g, ' ').trim()).filter(Boolean);
  test.info().annotations.push({ type: 'control-api', description: apiLog.join(' || ').slice(0, 280) || '(API ์‘๋‹ต ์—†์Œ)' });
  test.info().annotations.push({ type: 'async-request-id', description: reqId || '(์š”์ฒญ ID ๋ฏธํ™•์ธ)' });
  if (t.length) test.info().annotations.push({ type: 'toast', description: t.join(' | ') });
  return reqId;
}

/**
 * ๋น„๋™๊ธฐ ์š”์ฒญ ๊ฒฐ๊ณผ ํ™•์ธ: ์ข… ์•„์ด์ฝ˜ ํŒจ๋„์„ ์—ด์–ด ํ•ด๋‹น ์š”์ฒญ ID ํ•ญ๋ชฉ์ด Completed ๊ฐ€ ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ(๋…นํ™”์— ํŒจ๋„์ด ๋ณด์ด๋„๋ก ์—ฐ ์ƒํƒœ๋กœ ํ™•์ธ).
 * ํŒจ๋„ ํ•ญ๋ชฉ ํ˜•์‹: "Node reboot: g1-1 ยท Completed Requested 11:05:24 AM ยท mcwc-... Finished 11:05:27 AM"
 */
export async function waitAsyncRequest(page: Page, reqId: string, timeoutMs = 5 * 60_000) {
  const end = Date.now() + timeoutMs;
  let last = '';
  while (Date.now() < end) {
    // ์ง„ํ–‰ ์ค‘ ํ† ์ŠคํŠธ("Loading... ScaleOut: g1c +1 โ€” in progress...")๊ฐ€ ์ข… ์•„์ด์ฝ˜์„ ๊ฐ€๋ ค ํด๋ฆญ์ด ๋ง‰ํžŒ ๊ฒฝ์šฐ ์žˆ์Œ(2026-10-05) โ†’ ์ž ์‹œ ๋Œ€๊ธฐ ํ›„ ๊ฐ•์ œ ํด๋ฆญ
    await page.getByText(/in progress\.\.\./).first().waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {});
    await page.locator('#async-request-notif-toggle').click({ force: true });
    await page.waitForTimeout(2500);
    const text = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
    const i = text.indexOf(reqId);
    if (i >= 0) {
      // ์š”์ฒญ ID ์•ž์ชฝ "๋ผ๋ฒจ ยท ์ƒํƒœ Requested ์‹œ๊ฐ ยท " ๋ถ€๋ถ„
      last = text.slice(Math.max(0, i - 120), i + reqId.length + 30);
      const seg = last.slice(0, last.indexOf(reqId));
      const st = seg.match(/ยท\s*(\w+)\s*Requested[^ยท]*ยท\s*$/)?.[1] ?? '';
      if (/Completed|Success/i.test(st)) {
        test.info().annotations.push({ type: 'async-request', description: last.trim() });
        await page.waitForTimeout(2000); // ๋…นํ™”์šฉ
        await page.keyboard.press('Escape');
        return last;
      }
      if (/Fail|Error/i.test(st)) {
        test.info().annotations.push({ type: 'async-request', description: last.trim() });
        throw new Error(`๋น„๋™๊ธฐ ์š”์ฒญ ์‹คํŒจ: ${last.trim()}`);
      }
    }
    await page.keyboard.press('Escape');
    await page.waitForTimeout(3000);
  }
  throw new Error(`๋น„๋™๊ธฐ ์š”์ฒญ ${reqId} ์™„๋ฃŒ ํ™•์ธ ๋ชป ํ•จ(๋งˆ์ง€๋ง‰: ${last || 'ํŒจ๋„์— ์—†์Œ'})`);
}

/**
 * Node Info ์˜ Public IP ์˜† 'Terminal' โ†’ 'Would you like to connect?' Confirm โ†’ Remote Terminal ๋ชจ๋‹ฌ(#cmdtestmodal) ์˜ xterm ํ”„๋กฌํ”„ํŠธ ๋Œ€๊ธฐ.
 * (2026-10-05 ํ™•์ธ: Terminal ๋ฒ„ํŠผ์€ Public IP ์˜† 1๊ฐœ๋ฟ, IP ์„ ํƒ ์˜ต์…˜ ์—†์Œ. ๋ชจ๋‹ฌ = Command(xterm) + Transfer a file)
 * readNodeInfo ๋กœ ๋Œ€์ƒ node ๋ฅผ ๋จผ์ € ์„ ํƒํ•œ ์ƒํƒœ์—์„œ ํ˜ธ์ถœํ•œ๋‹ค. ์ฒซ ํ™”๋ฉด ํ…์ŠคํŠธ๋ฅผ ๋Œ๋ ค์ค€๋‹ค.
 */
export async function openNodeTerminal(page: Page) {
  const btn = page.locator('#server_info').locator('a', { hasText: /^\s*Terminal\s*$/ }).first();
  const html = await btn.evaluate((e) => e.parentElement?.innerText.replace(/\s+/g, ' ') ?? '');
  test.info().annotations.push({ type: 'terminal-button', description: `์œ„์น˜: ${html}` });
  await btn.click();
  const modal = page.locator('#commonDefaultModal');
  await expect(modal).toBeVisible();
  test.info().annotations.push({ type: 'confirm-modal', description: (await modal.innerText()).replace(/\s+/g, ' ').trim() });
  await modal.locator('#commonDefaultModal-confirm-btn').click();
  const term = page.locator('#cmdtestmodal');
  await expect(term).toBeVisible({ timeout: 20_000 });
  await expect(term.locator('.xterm-rows')).toContainText('$', { timeout: 60_000 });
  const first = (await term.locator('.xterm-rows').innerText()).replace(/\s+\n/g, '\n').trim();
  test.info().annotations.push({ type: 'terminal-first-screen', description: first.replace(/\s+/g, ' ').slice(0, 200) });
  return first;
}

let termSeq = 0;
/**
 * Remote Terminal Command ์— ๋ช…๋ น ์ž…๋ ฅ โ†’ ๋ ํ‘œ์‹œ(@@En@@)๊ฐ€ ์ถœ๋ ฅ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ โ†’ ๋ช…๋ น ์ถœ๋ ฅ ํ…์ŠคํŠธ.
 * ํ„ฐ๋ฏธ๋„ ํญ์ด ์ข์•„ ๊ธด ๋ช…๋ น์€ ์ค„๋ฐ”๊ฟˆ๋˜๋ฏ€๋กœ ๋ช…๋ น์€ ์งง๊ฒŒ ์“ด๋‹ค(ํ‘œ์‹œ ๋ฌธ์ž์—ด์ด ์ž˜๋ฆฌ๋ฉด ๋์„ ๋ชป ์ฐพ์Œ).
 */
export async function termRun(page: Page, cmd: string, timeoutMs = 60_000) {
  const term = page.locator('#cmdtestmodal');
  const collapse = term.locator('#server-terminal-collapse');
  if (!(await collapse.isVisible())) await term.getByRole('button', { name: 'Command' }).click();
  await expect(collapse).toBeVisible();
  const mark = `@@E${++termSeq}@@`;
  await term.locator('.xterm').click();
  await page.keyboard.type(`${cmd};echo ${mark}`, { delay: 15 });
  await page.keyboard.press('Enter');
  const rows = term.locator('.xterm-rows');
  const flat = async () => (await rows.innerText()).replace(/ /g, ' ');
  // ์ž…๋ ฅ ์ค„์˜ "echo @@En@@" ์™ธ์— ์ถœ๋ ฅ ์ค„์˜ ํ‘œ์‹œ๊ฐ€ ๋‚˜์˜ฌ ๋•Œ๊นŒ์ง€(= 2ํšŒ ์ด์ƒ ๋“ฑ์žฅ)
  await expect.poll(async () => ((await flat()).match(new RegExp(mark, 'g')) ?? []).length, { timeout: timeoutMs }).toBeGreaterThanOrEqual(2);
  const text = await flat();
  const start = text.indexOf(mark) + mark.length;
  const out = text.slice(start, text.lastIndexOf(mark)).trim();
  test.info().annotations.push({ type: 'terminal', description: `$ ${cmd} โ†’ ${out.replace(/\s+/g, ' ').slice(0, 200)}` });
  return out;
}

/**
 * Transfer a file: Target Path ์ง€์ • โ†’ ๋“œ๋กญ์กด(์ˆจ์€ file input)์— ํŒŒ์ผ ์ง€์ • โ†’ Transfer File โ†’ File Transfer Result ๋ชจ๋‹ฌ ํ…์ŠคํŠธ.
 * (๋“œ๋กญ์กด ํ—ˆ์šฉ: .txt .json .yaml .conf .log .sh, Max 10MB)
 */
export async function termTransferFile(page: Page, targetPath: string, file: { name: string; mimeType: string; buffer: Buffer }, node?: string) {
  const term = page.locator('#cmdtestmodal');
  const sec = term.locator('#server-transfer-collapse');
  if (!(await sec.isVisible())) await term.getByRole('button', { name: 'Transfer a file' }).click();
  await expect(sec).toBeVisible();
  await sec.locator('#file-path-input').fill(targetPath);
  // Dropzone ์€ ์ˆจ์€ input(.dz-hidden-input)์„ body ์— ๋ถ™์ธ๋‹ค
  await page.locator('input.dz-hidden-input').last().setInputFiles(file);
  await expect(sec.locator('.dz-preview').first()).toBeVisible({ timeout: 10_000 }).catch(() => {});
  test.info().annotations.push({ type: 'transfer-file', description: `${file.name} (${file.buffer.length} bytes) โ†’ ${targetPath}` });
  await page.waitForTimeout(1000); // ๋…นํ™”์šฉ
  await sec.locator('#show-content-btn').click();
  // 'Confirm File Transfer' ๋ชจ๋‹ฌ: Target: Node <node> / This will run on N node. / Destination path / Files โ†’ [Transfer to N node] (2026-10-05 ํ™•์ธ)
  const confirm = page.locator('.modal.show').filter({ hasText: 'Confirm File Transfer' });
  await expect(confirm).toBeVisible({ timeout: 15_000 });
  const ctext = (await confirm.innerText()).replace(/\s+/g, ' ').trim();
  test.info().annotations.push({ type: 'transfer-confirm', description: ctext.slice(0, 300) });
  expect(ctext, '์ „์†ก ๋Œ€์ƒ 1๊ฐœ node').toMatch(/run on 1 node/i);
  expect(ctext, `๋Œ€์ƒ ๊ฒฝ๋กœ ${targetPath}`).toContain(targetPath);
  expect(ctext, '์ „์†ก ํŒŒ์ผ๋ช…').toContain(file.name);
  if (node) expect(ctext, `์ „์†ก ๋Œ€์ƒ node ${node}`).toMatch(new RegExp(`Target:\\s*Node\\s*${node}\\b`));
  await page.waitForTimeout(1000); // ๋…นํ™”์šฉ
  await confirm.getByRole('button', { name: /Transfer to \d+ node/ }).click();
  const result = page.locator('#transferResultModal');
  await expect(result).toBeVisible({ timeout: 120_000 });
  const text = (await result.innerText()).replace(/\s+/g, ' ').trim();
  test.info().annotations.push({ type: 'transfer-result', description: text.slice(0, 300) });
  await page.waitForTimeout(2000); // ๋…นํ™”์šฉ
  return text;
}

/**
 * ์ƒํƒœ ์ „์ด ๊ด€์ฐฐ: ๋ชฉ๋ก ํ–‰ Status ๋ฅผ ์ฃผ๊ธฐ์ ์œผ๋กœ ์ฝ์–ด ๋ณ€ํ™” ์ด๋ ฅ์„ ๋‚จ๊ธฐ๊ณ , done ํŒจํ„ด(์—ฐ์† 2ํšŒ)์ด๋ฉด ์ข…๋ฃŒ.
 * ์žฌ๋ถ€ํŒ…์ด ๋นจ๋ผ ์ค‘๊ฐ„ ์ƒํƒœ๋ฅผ ๋ชป ๋ณผ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์ „์ด ์ด๋ ฅ์€ ๊ธฐ๋ก์šฉ(ํŒ์ •์€ ์ตœ์ข… ์ƒํƒœ๋กœ).
 */
export async function watchInfraStatus(page: Page, name: string, done: RegExp, timeoutMs = 10 * 60_000, minWaitMs = 30_000) {
  const start = Date.now();
  const trail: string[] = [];
  let hits = 0;
  while (Date.now() - start < timeoutMs) {
    await page.waitForTimeout(Date.now() - start < 60_000 ? 3_000 : 10_000); // ์ฒซ 1๋ถ„์€ ์งง์€ ์ฃผ๊ธฐ(๋น ๋ฅธ ์žฌ๋ถ€ํŒ… ์ „์ด ํฌ์ฐฉ)
    await refreshInfraList(page);
    await infraRow(page, name).first().waitFor({ timeout: 15_000 }).catch(() => {}); // ๋กœ๋”ฉ ์ง€์—ฐ ํก์ˆ˜(์—†์œผ๋ฉด '(ํ–‰ ์—†์Œ)' ๊ธฐ๋ก)
    const row = await infraRowText(page, name);
    const st = row.split(' ')[0] || '(ํ–‰ ์—†์Œ)';
    if (trail[trail.length - 1]?.split('@')[0] !== st) trail.push(`${st}@${Math.round((Date.now() - start) / 1000)}s`);
    hits = done.test(st) && Date.now() - start >= minWaitMs ? hits + 1 : 0;
    if (hits >= 2) break;
  }
  test.info().annotations.push({ type: 'status-trail', description: trail.join(' โ†’ ') });
  return trail;
}

/**
 * ์„ ํ–‰ ์ƒํƒœ ๋ณด์žฅ(๋ฉฑ๋“ฑ): Infra mc{n} ์ด ์—†์œผ๋ฉด Add Infra ๋กœ ์ƒ์„ฑ(IV3_S02 ์™€ ๊ฐ™์€ ๊ตฌ์„ฑ: nodegroup g{n}, size 1)ํ•˜๊ณ  Running ๊นŒ์ง€ ๋Œ€๊ธฐ.
 * ์ด๋ฏธ ์žˆ์œผ๋ฉด ์ƒ์„ฑํ•˜์ง€ ์•Š๋Š”๋‹ค(๋‹ค๋ฅธ ์„ธ์…˜์ด ๋งŒ๋“  ๊ฒฝ์šฐ ํฌํ•จ).
 */
export async function ensureInfra(page: Page, c: Csp) {
  const name = infraName(c);
  await gotoInfraList(page);
  if (await waitInfraListed(page, name)) { // ๋กœ๋”ฉ ์ง€์—ฐ์œผ๋กœ '์—†์Œ' ์˜คํŒ โ†’ ์ค‘๋ณต ์ƒ์„ฑ ๋ฐฉ์ง€
    test.info().annotations.push({ type: 'ensure-infra', description: `${name} ์กด์žฌ โ†’ ์ƒ์„ฑ ๊ฑด๋„ˆ๋œ€: ${await infraRowText(page, name)}` });
  } else {
    const cfg = CREATE[c.code];
    if (!cfg) throw new Error(`${c.name}: Add Infra ์ž…๋ ฅ๊ฐ’ ๋ฏธํ™•์ธ(์‚ฌ์ „์ ๊ฒ€ ํ•„์š”)`);
    test.info().annotations.push({ type: 'ensure-infra', description: `${name} ์—†์Œ โ†’ Add Infra ๋กœ ์ƒ์„ฑ` });
    await page.getByRole('link', { name: 'Add Infra' }).click();
    await page.locator('#mci_name').fill(name);
    await page.locator('#mci_deploy_algorithm').selectOption({ label: 'Express' });
    await page.locator('#mci_policy_on_partial_failure').selectOption({ label: 'Continue' });
    await page.getByText('+ NodeGroup').first().click();
    await page.locator('#ep_name').fill(infraNodegroups(c)[0]);

    // Spec: Node Recommendation(Provider ์ฒดํฌ + ๋ฉ”๋ชจ๋ฆฌ/vCPU ์กฐ๊ฑด โ†’ ๊ฒ€์ƒ‰ โ†’ region+spec ํ–‰ ์„ ํƒ โ†’ Apply)
    await page.locator('#ep_specId').locator('xpath=following::a[contains(@class,"btn")][1]').click();
    const sm = page.locator('#spec-search');
    await expect(sm).toBeVisible();
    await sm.locator(`#spec-provider-${cfg.provider}`).check();
    await sm.locator('#assist_min_memory').fill(cfg.mem);
    await sm.locator('#assist_max_memory').fill(cfg.mem);
    await sm.locator('#assist_min_cpu').fill(cfg.cpu);
    await sm.locator('#assist_max_cpu').fill(cfg.cpu);
    await sm.locator('a.btn-icon').first().click();
    const specRow = sm.locator('.tabulator-row').filter({ hasText: cfg.spec }).filter({ hasText: cfg.region }).first();
    await specRow.waitFor({ timeout: 60_000 });
    await specRow.click();
    await sm.getByRole('button', { name: 'Apply' }).click();
    await expect(page.locator('#ep_specId')).toHaveValue(cfg.spec);

    // Image: Image Recommendation(Spec ์„ ํƒ ํ›„์—๋งŒ ์—ด๋ฆผ, Provider/Region ์ž๋™) โ†’ OS Type ๊ฒ€์ƒ‰ โ†’ ํ–‰ ์„ ํƒ โ†’ Apply
    await page.locator('#ep_imageId_input').locator('xpath=following::a[contains(@class,"btn")][1]').click();
    const im = page.locator('#image-search');
    await expect(im).toBeVisible();
    await im.locator('#assist_os_type').fill(cfg.os);
    await im.locator('a.btn-icon').first().click();
    const rows = im.locator('.tabulator-row');
    await rows.first().waitFor({ timeout: 60_000 });
    const preferred = cfg.image ? rows.filter({ hasText: cfg.image }) : rows;
    const imgRow = (await preferred.count()) ? preferred.first() : rows.first();
    test.info().annotations.push({ type: 'image', description: (await imgRow.innerText()).replace(/\s+/g, ' ').slice(0, 200) });
    await imgRow.click();
    await im.getByRole('button', { name: 'Apply' }).click();
    await expect(page.locator('#ep_imageId_input')).not.toHaveValue('');

    await page.locator('#express_done_btn').click();
    await closeConfirmModal(page); // 'NodeGroup Validation ... Add to the list anyway?' (IV3_S10 ๊ด€์ฐฐ)
    await expect(page.getByText(new RegExp(`^${infraNodegroups(c)[0]}\\s*\\(1\\)`)).first()).toBeVisible({ timeout: 15_000 }).catch(() => {});

    // ์ƒ์„ฑ ์ง์ „ ์žฌํ™•์ธ(๋‹ค๋ฅธ ์„ธ์…˜์ด ๋™์‹œ์— ๋งŒ๋“ค์—ˆ์„ ์ˆ˜ ์žˆ์Œ): API ๋กœ ์ด๋ฆ„ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ
    const dup = await page.evaluate(async (n) => {
      const r = await fetch('/api/mc-infra-manager/GetAllInfra', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pathParams: { nsId: 'default' } }) }).catch(() => null);
      const j = r ? await r.json().catch(() => null) : null;
      return !!j?.responseData?.infra?.some((x: any) => x.name === n || x.id === n);
    }, name);
    if (dup) {
      test.info().annotations.push({ type: 'ensure-infra', description: `${name} ์ด ์ƒ์„ฑ ์ง์ „์— ๋‚˜ํƒ€๋‚จ(๋‹ค๋ฅธ ์„ธ์…˜ ์ƒ์„ฑ) โ†’ Deploy ์ทจ์†Œ` });
    } else {
      await page.getByRole('button', { name: 'Deploy' }).click();
      await closeConfirmModal(page);
    }
    await page.goto(INFRA_URL);
    await gotoInfraList(page);
  }
  const text = await waitInfraStatus(page, name, /Running/i);
  test.info().annotations.push({ type: 'infra-ready', description: text });
  return name;
}

lib/iv4-k8s.ts

ํ•ด์‹œ aac3d6e6 ยท 70์ค„

import { test, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { WORKSPACE } from './data';
import { selectWorkspaceProject } from './ui';
import { closeConfirmModal } from './iv4-infra';

// IV4 ์ „์šฉ K8s Workloads ํ—ฌํผ. 2026-10-05 IV4 ์‚ฌ์ „์ ๊ฒ€(tools/probe-iv4-k8s.mjs)์œผ๋กœ ํ™•์ธ:
//  - ๋ชฉ๋ก ์—ด: [ , ProviderImg, Status, Name, Node Group, VPC, Subnet, Security Group]
//  - ์ƒ์„ธ: Cluster Info(Name, CspName, Version, Status, VPC, Subnet, SG, Cloud Connection, End Point, KubeConfig)
//    'NodeGroup List / Status' ์นด๋“œ: nodegroup ์นด๋“œ #nodeGroup_status_icon_<ng>(ํด๋ฆญ = ์ฒดํฌ ํ† ๊ธ€ + NodeGroup Info ํ‘œ์‹œ), ์ฒดํฌ๋ฐ•์Šค #node_checkbox_<ng>
//    ์นด๋“œ โŒ„ ๋ฉ”๋‰ด Delete(NodeGroupDelete) / Edit Scaling / ExportยทImport JSON. ๋ชฉ๋ก โŒ„ ๋ฉ”๋‰ด Delete ๋Š” ํด๋Ÿฌ์Šคํ„ฐ ์ „์ฒด ์‚ญ์ œ(PmkDelete)
export const K8S_URL = '/webconsole/operations/manage/workloads/k8sworkloads';
export const clusterRow = (page: Page, name: string) => page.getByRole('row').filter({ has: page.getByRole('gridcell', { name, exact: true }) });

/** K8s ๋ชฉ๋ก์œผ๋กœ ์ด๋™ โ†’ ws01 / project ์„ ํƒ โ†’ ํด๋Ÿฌ์Šคํ„ฐ ํ–‰์ด ๋ณด์ผ ๋•Œ๊นŒ์ง€(์ตœ๋Œ€ 4ํšŒ ์žฌ์กฐํšŒ) */
export async function gotoK8sList(page: Page, project: string, cluster: string) {
  for (let i = 0; i < 4; i++) {
    await page.goto(K8S_URL);
    await closeConfirmModal(page);
    await selectWorkspaceProject(page, WORKSPACE, project, true);
    await closeConfirmModal(page);
    if (await clusterRow(page, cluster).first().waitFor({ timeout: 20_000 }).then(() => true, () => false)) return true;
    test.info().annotations.push({ type: 'list-retry', description: `${cluster} ํ–‰ ๋ฏธํ‘œ์‹œ โ†’ ์žฌ์กฐํšŒ ${i + 1}` });
  }
  return false;
}

export async function clusterCells(page: Page, cluster: string) {
  return (await clusterRow(page, cluster).first().getByRole('gridcell').allInnerTexts()).map((t) => t.trim());
}

/** ํ–‰ ํด๋ฆญ โ†’ Cluster Info ์— ์ด๋ฆ„ ํ‘œ์‹œ(์ตœ๋Œ€ 3ํšŒ) */
export async function openCluster(page: Page, cluster: string) {
  for (let i = 0; i < 3; i++) {
    await clusterRow(page, cluster).first().click();
    if (await page.getByText('Cluster Info').first().isVisible({ timeout: 8000 }).catch(() => false)
      && (await page.locator('body').innerText()).includes(cluster)) break;
  }
  await expect(page.getByText('NodeGroup List / Status').first()).toBeVisible({ timeout: 30_000 });
  return page.locator('#cluster_info');
}

/** Cluster Info ์˜ ํ•ญ๋ชฉ ๊ฐ’(์˜ˆ: 'Status', 'Version') */
export async function clusterInfoField(page: Page, label: string) {
  const t = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
  const i = t.indexOf('Cluster Info');
  return (t.slice(i).match(new RegExp(`\\b${label} (\\S+)`))?.[1] ?? '').trim();
}

/** ์ƒ์„ธ NodeGroup ์นด๋“œ ์ด๋ฆ„ ๋ชฉ๋ก */
export async function nodeGroupNames(page: Page) {
  return page.locator('li[id^="nodeGroup_status_icon_"]').evaluateAll((els) => els.map((e) => e.id.replace('nodeGroup_status_icon_', '')));
}

/** NodeGroup ์นด๋“œ ํ…์ŠคํŠธ/์ƒํƒœ ํด๋ž˜์Šค + NodeGroup Info Status. ์นด๋“œ๊ฐ€ ์—†์œผ๋ฉด exists=false */
export async function nodeGroupState(page: Page, ng: string) {
  const card = page.locator(`#nodeGroup_status_icon_${ng}`);
  if (!(await card.count())) return { exists: false, status: '', text: '(์นด๋“œ ์—†์Œ)' };
  const cls = ((await card.getAttribute('class')) ?? '').split(/\s+/).filter((c) => c.startsWith('bg-')).join(',');
  // NodeGroup Info(Status ํฌํ•จ)๋Š” ์นด๋“œ๋ฅผ ์„ ํƒํ•ด์•ผ ํ‘œ์‹œ๋œ๋‹ค. 2026-10-05: ์‚ญ์ œ ์š”์ฒญ ํ›„ ์นด๋“œ bg-info โ†’ bg-secondary, Status 'Deleting'
  if (!(await page.getByText('NodeGroup Info').first().isVisible().catch(() => false))) {
    await card.click();
    await page.getByText('NodeGroup Info').first().waitFor({ timeout: 10_000 }).catch(() => {});
  }
  const body = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
  const i = body.indexOf('NodeGroup Info');
  const status = body.match(/NodeGroup Info .*?Status (\S+)/)?.[1] ?? '';
  return { exists: true, status, text: `card=${(await card.innerText()).trim()}(${cls}) status=${status} / ${body.slice(i, i + 300)}` };
}

lib/iv4-kubectl.ts

ํ•ด์‹œ 9ff3568f ยท 54์ค„

import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { gotoK8s, clusterRow } from './iv3-k8s';

// IV4_S19ยทS20(TC-INFRA-037ยท038) kubectl ํ—ฌํผ โ€” 2026-10-15
// - K8s Workloads ๋ชฉ๋ก์—์„œ ํด๋Ÿฌ์Šคํ„ฐ ํ–‰ ์…€ ํด๋ฆญ โ†’ #cluster_info, #cluster_info_kubeconfig ์˜ 'Copy KubeConfig'(a.btn) โ†’ ํด๋ฆฝ๋ณด๋“œ
//   ํ™”๋ฉด ๋ฐ์ดํ„ฐ: POST /api/mc-infra-manager/GetK8sClusterKubeconfig (responseData.kubeconfig, CSP-native โ€” EKS/GKE ๋Š” exec ํ”Œ๋Ÿฌ๊ทธ์ธ)
// - ๋‹จ๊ธฐ ํ† ํฐ: ์ฝ˜์†” API GetK8sClusterToken(api.yaml: /ns/{nsId}/k8sCluster/{k8sClusterId}/token) โ€” ํ™”๋ฉด ๋ฒ„ํŠผ์€ ์—†์–ด ์ฝ˜์†” ๊ณต์šฉ ํ˜ธ์ถœ(commonAPIPost)๋กœ ํ™•์ธ
// - kubectl: ํ…Œ์ŠคํŠธ PC ์˜ kubectl. exec ์ธ์ฆ ๋Œ€์‹  ๋ฐœ๊ธ‰ ํ† ํฐ์„ ์“ฐ๋„๋ก ์ž„์‹œ kubeconfig ์˜ user ๋ฅผ token ์œผ๋กœ ๋ฐ”๊พผ๋‹ค(๋น„๋ฐ€ ๊ฐ’์€ ๊ธฐ๋กํ•˜์ง€ ์•Š์Œ)
export const K8S_CYCLE_CLUSTER = (n: number) => process.env.CYCLE_CLUSTER ?? `k${n}s`;

/** ์ฝ˜์†” ๊ณต์šฉ API(webconsolejs commonAPIPost, ๋กœ๊ทธ์ธ ์„ธ์…˜ ์‚ฌ์šฉ)๋กœ mc-infra-manager operation ํ˜ธ์ถœ. ๋ฐ˜ํ™˜ responseData */
export async function consoleOp(page: Page, op: string, pathParams: Record<string, string>) {
  return page.evaluate(async ([op, pathParams]) => {
    const r = await (window as any).webconsolejs['common/api/http'].commonAPIPost(`/api/mc-infra-manager/${op}`, { pathParams });
    return { status: r?.status as number, data: (r?.data?.responseData ?? r?.data) as any };
  }, [op, pathParams] as const);
}

/** ๋ชฉ๋ก โ†’ ํด๋Ÿฌ์Šคํ„ฐ ์ƒ์„ธ(Cluster Info) โ†’ Copy KubeConfig โ†’ ํด๋ฆฝ๋ณด๋“œ ๊ฐ’๊ณผ ํ™”๋ฉด์ด ๋ฐ›์€ kubeconfig */
export async function copyKubeconfig(page: Page, project: string, cluster: string) {
  let api = '';
  page.on('response', async (r) => { if (/GetK8sClusterKubeconfig/.test(r.url())) { try { api = (await r.json())?.responseData?.kubeconfig ?? ''; } catch { /* */ } } });
  await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin });
  await gotoK8s(page, project);
  await clusterRow(page, cluster).first().getByRole('gridcell', { name: cluster, exact: true }).click();
  await expect(page.locator('#cluster_info')).toBeVisible({ timeout: 30_000 });
  const btn = page.locator('#cluster_info_kubeconfig a.btn');
  await expect(btn).toHaveText(/Copy KubeConfig/, { timeout: 60_000 });
  await btn.click();
  await page.waitForTimeout(1500);
  const clip = await page.evaluate(() => navigator.clipboard.readText());
  return { clip, api, note: (await page.locator('#cluster_info_kubeconfig .form-text').innerText().catch(() => '')).replace(/\s+/g, ' ') };
}

/** ์ž„์‹œ kubeconfig(token ์ธ์ฆ)๋กœ kubectl ์‹คํ–‰. ๋ฐ˜ํ™˜ stdout. ์ž„์‹œ ํŒŒ์ผ์€ ํ˜ธ์ถœ์ž๊ฐ€ rmDir */
export function writeTokenKubeconfig(kubeconfig: string, token?: string) {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcfg-'));
  const f = path.join(dir, 'config');
  fs.writeFileSync(f, kubeconfig, { mode: 0o600 });
  if (token) {
    const user = execFileSync('kubectl', ['--kubeconfig', f, 'config', 'view', '-o', 'jsonpath={.users[0].name}'], { encoding: 'utf-8' }).trim();
    execFileSync('kubectl', ['--kubeconfig', f, 'config', 'unset', `users.${user}.exec`], { encoding: 'utf-8' });
    execFileSync('kubectl', ['--kubeconfig', f, 'config', 'set-credentials', user, `--token=${token}`], { encoding: 'utf-8' });
  }
  return { dir, f };
}
export const kubectl = (f: string, args: string[], timeout = 120_000) =>
  execFileSync('kubectl', ['--kubeconfig', f, ...args], { encoding: 'utf-8', timeout, stdio: ['pipe', 'pipe', 'pipe'] });

lib/iv4-ssh.ts

ํ•ด์‹œ 53ca0beb ยท 40์ค„

import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { selectWorkspaceProject } from './ui';
import { WORKSPACE } from './data';
import { gotoInfraList, openInfraDetail } from './iv4-infra';

// IV4_S17ยทS18(TC-INFRA-026ยท027) SSH ์ ‘์† ์ •๋ณด ํ—ฌํผ โ€” 2026-10-14 ํƒ์ƒ‰(wsl/probe/infra-k8s-ssh-probe*.spec.ts)
// - Node Info: Public IP(IPv4) ยท 'User ID / Pwd cb-user / ***' ยท Keypair Name
// - Settings > Cloud Resources > SSH Keys โ†’ Keypair ํ–‰ ํด๋ฆญ โ†’ Private Key ๋งˆ์Šคํ‚น + Show / Copy. Show ๋Š” ๋…นํ™” ๋…ธ์ถœ ๋•Œ๋ฌธ์— ์“ฐ์ง€ ์•Š๋Š”๋‹ค
export const SSH_INFRA = process.env.SSH_INFRA ?? 'mc1';
export const SSH_NODE = process.env.SSH_NODE ?? 'g1-1';

/** Node Info ์—์„œ Public IP ยท User ID ยท Keypair Name ์„ ์ฝ๋Š”๋‹ค */
export async function nodeSshInfo(page: Page) {
  await gotoInfraList(page);
  await openInfraDetail(page, SSH_INFRA);
  await page.getByText(SSH_NODE, { exact: true }).first().click();
  await expect(page.getByText(`[ g1 / ${SSH_NODE} ]`).or(page.getByText(new RegExp(`/ ${SSH_NODE} \\]`))).first()).toBeVisible({ timeout: 30_000 });
  const body = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
  return {
    ip: body.match(/Public IP\(IPv4\) (\d+\.\d+\.\d+\.\d+)/)?.[1] ?? '',
    user: body.match(/User ID \/ Pwd (\S+) \//)?.[1] ?? '',
    keypair: body.match(/Keypair Name (\S+)/)?.[1] ?? '',
  };
}

/** SSH Keys ํ™”๋ฉด์—์„œ Keypair ์ƒ์„ธ โ†’ Copy ๋กœ Private Key ๋ฅผ ํด๋ฆฝ๋ณด๋“œ์—์„œ ์ฝ๋Š”๋‹ค(๊ฐ’์€ ๋ฐ˜ํ™˜๋งŒ, ๊ธฐ๋ก ๊ธˆ์ง€) */
export async function copyPrivateKey(page: Page, keypair: string) {
  await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin });
  await page.goto('/webconsole/settings/environment/cloudresources/sshkeys');
  await selectWorkspaceProject(page, WORKSPACE, 'default', true);
  const cell = page.getByRole('gridcell', { name: keypair, exact: true }).first();
  await expect(cell).toBeVisible({ timeout: 60_000 });
  await cell.click();
  await page.getByRole('button', { name: 'Copy' }).first().click();
  await page.waitForTimeout(1000);
  return page.evaluate(() => navigator.clipboard.readText());
}

lib/iv5-app.ts

ํ•ด์‹œ 8046b305 ยท 166์ค„

import { test, expect } from '@playwright/test';
import type { Page, Frame } from '@playwright/test';
import { WORKSPACE, DEFAULT_PROJECT } from './data';
import { selectWorkspaceProject } from './ui';

// IV5 mc-application-manager ํ™”๋ฉด TC(TC-APP-001~012, 015, 017, 018) ๊ณต์šฉ ํ—ฌํผ โ€” 2026-10-14 ํƒ์ƒ‰(wsl/probe/app-explore.spec.ts) + applicationFE ์†Œ์Šค ํ™•์ธ
// - Repository: ์ฝ˜์†” /webconsole/_view/apprepository โ†’ iframe :18084/web/applications/repository (Nexus hosted repo, ์ปฌ๋Ÿผ Name/Format/URL/Type(hosted)/Action)
//   New Repository ๋ชจ๋‹ฌ(#repositoryForm): Name, Format ๋ผ๋””์˜ค(helm/docker), Allow(allow/allow_once/deny), On/Offline โ†’ Create. ์ƒ์„ธ๋Š” ํ–‰์˜ ์…€ ํด๋ฆญ(/repository/{name})
//   ์ƒ์„ธ ์ปดํฌ๋„ŒํŠธ ํ‘œ Name/URL(Path)/Size/Action(Delete, Download). File Upload ๋ฒ„ํŠผ์€ FE ์—์„œ ์ฃผ์„ ์ฒ˜๋ฆฌ(2026-10-14) โ†’ ํ™”๋ฉด ์—…๋กœ๋“œ ๋ถˆ๊ฐ€
// - SW Catalogs: /webconsole/_view/swcatalogs โ†’ iframe :18084/web/applications/swcatalog. ๋ชฉ๋ก #sc-list-group .list-group-item, ์ด๋ฆ„ ํด๋ฆญ = ํŽผ์นจ/์ ‘ํž˜(#accordion_{id})
//   Register โ†’ #modal-wizard "Application Registration" 4๋‹จ๊ณ„. Source: Existing Package / Public Registry(Docker HubยทArtifact Hub ๊ฒ€์ƒ‰ โ†’ Import & Create)
//   ์™ธ๋ถ€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์—…๋กœ๋“œ ์ „์šฉ ํ™”๋ฉด(uploadForm)์€ FE ์—์„œ ์“ฐ์ด์ง€ ์•Š์Œ โ†’ Import & Create ๊ฐ€ ์—…๋กœ๋“œ(Nexus ๋ฐ˜์ž…)๋ฅผ ๊ฒธํ•จ
// - App Status: /webconsole/_view/appstatus โ†’ iframe :18084/web/applications/status. Refresh, ํ–‰ Rating(#rating-modal: ๋ณ„ 5๊ฐœยทCategoryยท์˜๊ฒฌยท์ด๋ฆ„ยท์ด๋ฉ”์ผ โ†’ Submit)
export const REPO_URL = '/webconsole/_view/apprepository';
export const CATALOG_URL = '/webconsole/_view/swcatalogs';
export const STATUS_URL = '/webconsole/_view/appstatus';
export const APP_ORIGIN = (process.env.BASE_URL ?? 'https://<STAGE_HOST>').replace(/:\d+\/?$/, ':18084');

const note = (type: string, d: string) => test.info().annotations.push({ type, description: d.slice(0, 2000) });
export const clean = (s: string) => s.replace(/\s+/g, ' ').trim();

/** ์ฝ˜์†” ๋ฉ”๋‰ด ํ™”๋ฉด์œผ๋กœ ์ด๋™ โ†’ :18084 iframe(Frame) ์ด ํ•ด๋‹น ๊ฒฝ๋กœ๋ฅผ ๋„์šธ ๋•Œ๊นŒ์ง€ */
export async function gotoApp(page: Page, url: string, pathRe: RegExp, project = DEFAULT_PROJECT): Promise<Frame> {
  for (let attempt = 1; ; attempt++) {
    await page.goto(url);
    await selectWorkspaceProject(page, WORKSPACE, project, true);
    const end = Date.now() + 60_000;
    while (Date.now() < end) {
      const f = page.frames().find((x) => x.url().includes(':18084') && pathRe.test(x.url()));
      if (f && (await f.locator('body').innerText().catch(() => '')).trim().length > 20) { await page.waitForTimeout(2500); return f; }
      await page.waitForTimeout(1000);
    }
    if (attempt >= 3) throw new Error(`${url} iframe ์ด 3ํšŒ ๋ชจ๋‘ ๋กœ๋”ฉ๋˜์ง€ ์•Š์Œ`);
    note('app-iframe-retry', `${url} ์‹œ๋„ ${attempt}`);
  }
}

/** :18084 API ๋ฅผ ๋ธŒ๋ผ์šฐ์ € ์ปจํ…์ŠคํŠธ๋กœ ํ˜ธ์ถœ */
export async function appApi(page: Page, method: 'GET' | 'POST' | 'DELETE', path: string, data?: unknown) {
  const res = await page.request.fetch(APP_ORIGIN + path, { method, data, headers: data ? { 'Content-Type': 'application/json' } : undefined });
  const text = await res.text();
  let body: any = text; try { body = JSON.parse(text); } catch { /* ํ…์ŠคํŠธ */ }
  return { status: res.status(), body };
}

/** ํ† ์ŠคํŠธ(vue-toastification) ๋ฌธ๊ตฌ๋ฅผ ์ผ์ • ์‹œ๊ฐ„ ๋ชจ์€๋‹ค */
export async function collectToasts(page: Page, f: Frame, ms = 6000) {
  const seen = new Set<string>();
  const end = Date.now() + ms;
  while (Date.now() < end) {
    for (const t of await f.locator('[class*="toast"]').allInnerTexts().catch(() => [] as string[])) { const c = clean(t).replace(/\s*ร—\s*$/, ''); if (c) seen.add(c); }
    await page.waitForTimeout(300);
  }
  return [...seen];
}
export const toastGone = (f: Frame) => f.locator('[class*="toast"]').first().waitFor({ state: 'detached', timeout: 8000 }).catch(() => {});

/** Tabulator ๋ชฉ๋ก Page Size ๋ฅผ ๊ฐ€์žฅ ํฐ ๊ฐ’์œผ๋กœ */
export async function showAllRows(f: Frame) {
  const sel = f.locator('.tabulator-page-size').first();
  if (await sel.count()) {
    const last = await sel.locator('option').evaluateAll((os) => (os[os.length - 1] as HTMLOptionElement).value);
    await sel.selectOption(last).catch(() => {});
    await f.page().waitForTimeout(800);
  }
}

// ---------------- Repository ----------------
export const repoList = async (page: Page) => ((await appApi(page, 'GET', '/oss/v1/repositories/nexus/list')).body?.data ?? []) as any[];
export const repoRow = (f: Frame, name: string) => f.locator('.tabulator-row').filter({ has: f.locator('.tabulator-cell', { hasText: new RegExp(`^${name}$`) }) });

export async function createRepository(page: Page, name: string, format: 'docker' | 'helm') {
  const f = await gotoApp(page, REPO_URL, /repository/);
  await f.getByRole('button', { name: 'New Repository' }).click();
  const m = f.locator('#repositoryForm');
  await expect(m.getByText('Repository Create')).toBeVisible();
  await m.getByPlaceholder('Enter repository name').fill(name);
  await m.locator(`input[name="format"][value="${format}"]`).check();
  await toastGone(f);
  await m.getByRole('button', { name: 'Create', exact: true }).click();
  return { f, toasts: await collectToasts(page, f, 6000) };
}

export async function deleteRepository(page: Page, name: string) {
  const f = await gotoApp(page, REPO_URL, /repository/);
  await showAllRows(f);
  await repoRow(f, name).locator('#delete-btn').click();
  const m = f.locator('#deleteRepository');
  await expect(m.getByText(`Are you sure you want to delete ${name}?`)).toBeVisible();
  await toastGone(f);
  await m.getByText('Delete', { exact: true }).last().click();
  return { f, toasts: await collectToasts(page, f, 6000) };
}

/** ๋ชฉ๋ก์—์„œ ํ–‰์˜ Name ์…€ ํด๋ฆญ โ†’ ์ƒ์„ธ(/repository/{name}) */
export async function openRepositoryDetail(page: Page, name: string) {
  const f = await gotoApp(page, REPO_URL, /repository/);
  await showAllRows(f);
  await repoRow(f, name).locator('.tabulator-cell').first().click();
  await expect(f.getByText('Repository Detail').first()).toBeVisible({ timeout: 30_000 });
  await page.waitForTimeout(2000);
  return f;
}

// ---------------- SW Catalog ----------------
export const catalogList = async (page: Page) => ((await appApi(page, 'GET', '/catalog/software?name=')).body?.data ?? []) as any[];
export const catalogItem = (f: Frame, title: string) =>
  f.locator('#sc-list-group .list-group-item').filter({ has: f.locator('.col-5', { hasText: new RegExp(`^\\s*${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`) }) }).first();

/** Register ๋งˆ๋ฒ•์‚ฌ ์—ด๊ธฐ */
export async function openRegister(page: Page) {
  const f = await gotoApp(page, CATALOG_URL, /swcatalog/);
  await f.getByRole('button', { name: 'Register', exact: true }).click();
  const m = f.locator('#modal-wizard');
  await expect(m.getByText('Application Registration')).toBeVisible();
  return { f, m };
}

/** Public Registry ๊ฒ€์ƒ‰(Target VM = Docker Hub). ๋ฐ˜ํ™˜: ๊ฒฐ๊ณผ ์ด๋ฆ„ ๋ชฉ๋ก */
export async function publicSearch(page: Page, m: ReturnType<Frame['locator']>, keyword: string) {
  await m.locator('#sourcePublic').check();
  await m.locator('#targetVM').check();
  await m.locator('#registrySearchInput').fill(keyword);
  await m.getByRole('button', { name: 'Search', exact: true }).click();
  await expect(m.locator('.registry-results button.list-group-item').first().or(m.getByText('No matching packages found.'))).toBeVisible({ timeout: 60_000 });
  return (await m.locator('.registry-results button.list-group-item .fw-semibold').allInnerTexts()).map(clean);
}

/** select ์˜ ์˜ต์…˜ ์ค‘ ์ •๊ทœ์‹์— ๋งž๋Š” ์ฒซ ์˜ต์…˜ ์„ ํƒ. ๋ฐ˜ํ™˜: ์„ ํƒํ•œ ์˜ต์…˜ ๊ธ€์ž */
export async function selectByText(sel: ReturnType<Frame['locator']>, re: RegExp) {
  await expect.poll(async () => (await sel.locator('option').allInnerTexts()).some((o) => re.test(o)), { timeout: 60_000 }).toBe(true);
  const label = (await sel.locator('option').allInnerTexts()).find((o) => re.test(o))!;
  await sel.selectOption({ label });
  return clean(label);
}

/** ๋งˆ๋ฒ•์‚ฌ 2~4๋‹จ๊ณ„ ์ž…๋ ฅ ํ›„ ์ œ์ถœ(Create / Import & Create) */
export async function fillWizardRest(page: Page, f: Frame, m: ReturnType<Frame['locator']>, o: { name: string; summary: string; desc: string; ref?: [string, string]; cpu: [number, number]; mem: [number, number]; disk: [number, number]; port: string }, submit: RegExp) {
  await m.getByRole('button', { name: 'Next', exact: true }).click();
  await m.getByPlaceholder('Application name').fill(o.name);
  await m.getByPlaceholder('Application summary').fill(o.summary);
  await m.getByPlaceholder('Application description').fill(o.desc);
  if (o.ref) { await m.locator('select').filter({ has: f.locator('option', { hasText: 'HOMEPAGE' }) }).first().selectOption({ label: o.ref[0] }); await m.getByPlaceholder('Ref Value').first().fill(o.ref[1]); }
  await m.getByRole('button', { name: 'Next', exact: true }).click();
  const nums: [string, number][] = [['1', o.cpu[0]], ['2', o.cpu[1]], ['4', o.mem[0]], ['8', o.mem[1]], ['10', o.disk[0]], ['20', o.disk[1]]];
  for (const [ph, v] of nums) await m.getByPlaceholder(ph, { exact: true }).first().fill(String(v));
  await m.getByRole('button', { name: 'Next', exact: true }).click();
  await m.getByPlaceholder('80', { exact: true }).first().fill(o.port);
  await toastGone(f);
  await m.getByRole('button', { name: submit }).click();
  return collectToasts(page, f, 30_000);
}

/** ์นดํƒˆ๋กœ๊ทธ ํ–‰์˜ ํŽธ์ง‘(์ฒซ ๋ฒˆ์งธ svg)/์‚ญ์ œ(๋‘ ๋ฒˆ์งธ svg) ์•„์ด์ฝ˜ */
export async function catalogAction(f: Frame, title: string, which: 'edit' | 'delete') {
  const item = catalogItem(f, title);
  await item.hover();
  const icons = item.locator('.mouse-hover svg');
  await icons.nth(which === 'edit' ? 0 : 1).click();
}

// ---------------- App Status ----------------
export const statusGroups = async (page: Page, ns = DEFAULT_PROJECT) => ((await appApi(page, 'GET', `/api/applications/status/groups?namespace=${ns}`)).body?.data ?? []) as any[];

export { expect };

lib/iv5-objstore.ts

ํ•ด์‹œ 04dddc19 ยท 238์ค„

import { test, expect } from '@playwright/test';
import type { Page, Frame, Locator } from '@playwright/test';
import { gotoApp, CATALOG_URL, collectToasts, toastGone, clean } from './iv5-app';
import { gotoInfraList, openInfraDetail, readNodeInfo } from './iv4-infra';
import { appAction, waitUninstalled } from './iv5';

// IV5_S24~S29(TC-APP-024~029, mc-application-manager ์‹œํŠธ์—๋งŒ ์žˆ๋˜ Object StorageยทJupyter TC) ๊ณต์šฉ ํ—ฌํผ โ€” 2026-10-15 ํƒ์ƒ‰(wsl/probe/app-jupyter-probe.spec.ts) + applicationFE ์†Œ์Šค
// - SW Catalogs โ†’ Deploy โ†’ Application Installation(#install-form): Target Infra(#infra VM/K8S) ยท Namespace(#vm-namespace) ยท Infra ID(#vm-mci) ยท VM(#vm-name)
//   Application '[JupyterLab for Object Storage] โ€ฆ' ์„ ํƒ ์‹œ 'Object Storage Configuration': Enable(#vmObjectStorageEnabled), ๋ฒ„ํ‚ท ์ฒดํฌ๋ฐ•์Šค(#vm-object-storage-{id}),
//   Refresh(GET /applications/object-storages?namespace=), Allowed Object Prefix(placeholder 'Optional: data/project-a/'), Access Mode(READ_ONLY/READ_WRITE),
//   Jupyter Access Token(placeholder 'At least 12 characters'), Network Exposure('Add restricted direct access' โ†’ CIDR), ๋ฒ„ํŠผ Storage Check ยท Spec Check ยท Deploy
// - Storage Check: POST /applications/{vm|k8s}/object-storage/smoke-check โ†’ data.success ยท ํ† ์ŠคํŠธ 'Object Storage check succeeded' / 'Object Storage check failed'
// - Jupyter ๋…ธํŠธ๋ถ์€ MCMP_OBJECT_STORAGE_GATEWAY_URL / MCMP_OBJECT_STORAGE_TOKEN ์œผ๋กœ gateway(storages / objects / presigned-url)๋ฅผ ์“ด๋‹ค(๊ธฐ๋ณธ notebook sample-data.ipynb)
export const OBJ_NS = process.env.OBJ_NS ?? 'default';
export const OBJ_MCI = process.env.OBJ_MCI ?? 'mc3';
export const OBJ_VM = process.env.OBJ_VM ?? 'g3-1';
export const OBJ_BUCKET = process.env.OBJ_BUCKET ?? 'mcmp-e2e-v07-osam';
export const JUPYTER_TOKEN = process.env.OBJ_JUPYTER_TOKEN ?? 'mcmp-e2e-jupyter-0001';
export const JUPYTER_APP = /JupyterLab for Object Storage/;

/** Deploy ๋ชจ๋‹ฌ โ†’ VM ๋Œ€์ƒ ยท JupyterLab ์„ ํƒ โ†’ Object Storage ์˜์—ญ์ด ๋ณด์ผ ๋•Œ๊นŒ์ง€ */
export async function openJupyterDeploy(page: Page, target: 'VM' | 'K8S' = 'VM', k8s?: { project: string; cluster: string }) {
  const f = await gotoApp(page, CATALOG_URL, /swcatalog/, k8s?.project ?? OBJ_NS);
  await f.getByRole('button', { name: 'Deploy', exact: true }).click();
  const m = f.locator('#install-form');
  await m.waitFor({ state: 'visible', timeout: 20_000 });
  await m.locator('#infra').selectOption({ label: target });
  await page.waitForTimeout(2000);
  if (target === 'VM') {
    await m.locator('#vm-mci').selectOption({ label: OBJ_MCI });
    await expect.poll(async () => (await m.locator('#vm-name option').allInnerTexts()).map(clean).includes(OBJ_VM), { timeout: 30_000 }).toBe(true);
    await m.locator('#vm-target-single').check().catch(() => {});
    await m.locator('#vm-name').selectOption({ label: OBJ_VM });
  } else if (k8s) {
    await expect(m.locator('#k8s-cluster option', { hasText: k8s.cluster })).toHaveCount(1, { timeout: 30_000 });
    await m.locator('#k8s-cluster').selectOption({ label: k8s.cluster });
  }
  const app = m.locator('select').filter({ has: f.locator('option', { hasText: JUPYTER_APP }) }).first();
  const label = (await app.locator('option').allInnerTexts()).find((o) => JUPYTER_APP.test(o))!;
  await app.selectOption({ label: label.trim() });
  await expect(m.getByText('Object Storage Configuration').first()).toBeVisible({ timeout: 20_000 });
  return { f, m };
}

/** Registered Object Storage Refresh โ†’ ์‘๋‹ต(data[]) ๊ณผ ํ™”๋ฉด ์ฒดํฌ๋ฐ•์Šค id ๋ชฉ๋ก */
export async function refreshStorages(page: Page, m: Locator) {
  const resP = page.waitForResponse((r) => /\/applications\/object-storages\?/.test(r.url()), { timeout: 30_000 });
  await m.getByRole('button', { name: /^Refresh$/ }).first().click();
  const res = await resP;
  const data: any[] = ((await res.json().catch(() => ({})))?.data ?? []) as any[];
  const shown = await m.locator('input[id^="vm-object-storage-"], input[id^="k8s-object-storage-"]').evaluateAll((els) => els.map((e) => e.id.replace(/^(vm|k8s)-object-storage-/, '')));
  return { status: res.status(), data, shown };
}

export interface ObjOpts { buckets: string[]; mode: 'READ_ONLY' | 'READ_WRITE'; prefix?: string; token?: string }
/** Object Storage ์˜์—ญ ์ž…๋ ฅ(Enable ์ฒดํฌ โ†’ ๋ฒ„ํ‚ท ์ฒดํฌ โ†’ Prefix โ†’ Access Mode โ†’ Token) */
export async function fillObjectStorage(m: Locator, o: ObjOpts) {
  const en = m.locator('#vmObjectStorageEnabled, #k8sObjectStorageEnabled, #k8sJupyterObjectStorageEnabled').first(); // K8S ๋Š” #k8sJupyterObjectStorageEnabled(๋ฒ„ํ‚ท ์ฒดํฌ๋ฐ•์Šค id ๋Š” K8S ๋„ vm-object-storage-*, 2026-10-15)
  if (await en.isEnabled().catch(() => false)) await en.check();
  const boxes = m.locator('input[id^="vm-object-storage-"], input[id^="k8s-object-storage-"]');
  for (let i = 0; i < (await boxes.count()); i++) {
    const b = boxes.nth(i);
    const id = (await b.getAttribute('id'))!.replace(/^(vm|k8s)-object-storage-/, '');
    await b.setChecked(o.buckets.includes(id));
  }
  await m.getByPlaceholder('Optional: data/project-a/').first().fill(o.prefix ?? '');
  await m.locator('select:has(option[value="READ_WRITE"])').first().selectOption(o.mode);
  await m.getByPlaceholder('At least 12 characters').first().fill(o.token ?? JUPYTER_TOKEN);
}

/** Storage Check ํด๋ฆญ โ†’ smoke-check ์‘๋‹ต(success ๋“ฑ)๊ณผ ํ† ์ŠคํŠธ */
export async function storageCheck(page: Page, f: Frame, m: Locator) {
  await toastGone(f);
  const resP = page.waitForResponse((r) => /object-storage\/smoke-check/.test(r.url()), { timeout: 120_000 }).catch(() => null);
  await m.getByRole('button', { name: /Storage Check/ }).click();
  const res = await resP;
  const body = res ? await res.json().catch(async () => ({ raw: (await res.text()).slice(0, 500) })) : null;
  const toasts = await collectToasts(page, f, 5000);
  return { status: res?.status() ?? 0, body, toasts };
}

/** ํ…Œ์ŠคํŠธ ์‹คํ–‰ PC ์˜ ๊ณต์ธ IPv4(๋ณด์•ˆ๊ทธ๋ฃน restricted direct access CIDR ์šฉ) */
export async function myPublicIp(page: Page) {
  const r = await page.request.get('https://checkip.amazonaws.com/');
  return (await r.text()).trim();
}

/**
 * Jupyter(token) ์˜ ์ปค๋„์—์„œ python ์ฝ”๋“œ๋ฅผ ์‹คํ–‰ํ•˜๊ณ  stdout ์„ ๋Œ๋ ค์ค€๋‹ค.
 * ๋…ธํŠธ๋ถ์˜ helper(storages / list_objects / download / upload)๋Š” ๊ธฐ๋ณธ notebook ์ฒซ ์ฝ”๋“œ ์…€์„ ๊ทธ๋Œ€๋กœ ์‹คํ–‰ํ•ด ์ •์˜ํ•œ๋‹ค.
 */
export async function jupyterExec(page: Page, base: string, token: string, code: string, timeoutMs = 120_000) {
  return page.evaluate(async ({ base, token, code, timeoutMs }) => {
    const q = `token=${encodeURIComponent(token)}`;
    const k = await (await fetch(`${base}/api/kernels?${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })).json();
    const ws = new WebSocket(`${base.replace(/^http/, 'ws')}/api/kernels/${k.id}/channels?${q}`);
    const msgId = `e2e-${Date.now()}`;
    let out = ''; let err = '';
    await new Promise<void>((resolve, reject) => {
      const t = setTimeout(() => { ws.close(); resolve(); }, timeoutMs);
      ws.onopen = () => ws.send(JSON.stringify({ header: { msg_id: msgId, username: 'e2e', session: msgId, msg_type: 'execute_request', version: '5.3' }, parent_header: {}, metadata: {}, content: { code, silent: false, store_history: false, user_expressions: {}, allow_stdin: false }, channel: 'shell' }));
      ws.onmessage = (ev) => {
        const msg = JSON.parse(ev.data);
        if (msg.parent_header?.msg_id !== msgId) return;
        if (msg.msg_type === 'stream') out += msg.content.text;
        if (msg.msg_type === 'error') err += `${msg.content.ename}: ${msg.content.evalue}`;
        if (msg.msg_type === 'status' && msg.content.execution_state === 'idle') { clearTimeout(t); ws.close(); resolve(); }
      };
      ws.onerror = () => { clearTimeout(t); reject(new Error('kernel websocket error')); };
    });
    await fetch(`${base}/api/kernels/${k.id}?${q}`, { method: 'DELETE' }).catch(() => {});
    return { out, err };
  }, { base, token, code, timeoutMs });
}

/** ๊ธฐ๋ณธ notebook ์˜ helper ์ •์˜ ์ฝ”๋“œ ์…€(def storages ํฌํ•จ). ์œ„์น˜๊ฐ€ ๋ฃจํŠธ๊ฐ€ ์•„๋‹ ์ˆ˜ ์žˆ์–ด ๋ฃจํŠธยทํ•œ ๋‹จ๊ณ„ ํ•˜์œ„ ํด๋”์˜ .ipynb ๋ฅผ ์ฐพ๋Š”๋‹ค(2026-10-15) */
export async function notebookHelper(page: Page, base: string, token: string) {
  return page.evaluate(async ({ base, token }) => {
    const q = `token=${encodeURIComponent(token)}`;
    const get = async (p: string) => { const r = await fetch(`${base}/api/contents/${p}?content=1&${q}`); return r.ok ? r.json() : null; };
    const root = await get('');
    const items: any[] = root?.content ?? [];
    const nbs: string[] = items.filter((x) => x.type === 'notebook').map((x) => x.path);
    for (const d of items.filter((x) => x.type === 'directory')) nbs.push(...(((await get(d.path))?.content ?? []) as any[]).filter((x) => x.type === 'notebook').map((x) => x.path));
    for (const p of nbs) {
      const nb = await get(p);
      const cell = (nb?.content?.cells ?? []).find((c: any) => c.cell_type === 'code' && /def storages/.test(Array.isArray(c.source) ? c.source.join('') : String(c.source)));
      if (cell) return Array.isArray(cell.source) ? cell.source.join('') : String(cell.source);
    }
    return `# notebook helper ์—†์Œ โ€” notebooks: ${nbs.join(', ') || '(์—†์Œ)'} / root: ${items.map((x) => x.path).join(', ')}`;
  }, { base, token });
}

/**
 * Jupyter ๋ฐฐํฌ(VM): Object Storage ์ž…๋ ฅ โ†’ Port โ†’ Network Exposure(restricted CIDR) โ†’ Storage Check โ†’ Spec Check(๋‚ฎ์€ ์‚ฌ์–‘ confirm ์€ fixtures ๊ฐ€ ์ˆ˜๋ฝ) โ†’ Deploy โ†’ ์™„๋ฃŒ ๋Œ€๊ธฐ.
 * ๋ฐ˜ํ™˜: ์ œ์ถœ ์‘๋‹ต ยท ์™„๋ฃŒ ๋ฌธ๊ตฌ ยท ํ† ์ŠคํŠธ
 */
export async function deployJupyterVm(page: Page, o: ObjOpts & { port: string; cidr: string }, waitMs = 25 * 60_000) {
  const { f, m } = await openJupyterDeploy(page, 'VM');
  await refreshStorages(page, m);
  await fillObjectStorage(m, o);
  await m.getByPlaceholder('8080').first().fill(o.port);
  await m.locator('select:has(option[value="RESTRICTED"])').first().selectOption('RESTRICTED');
  await m.getByPlaceholder('<PUBLIC_IP>/32').first().fill(o.cidr);
  const sc = await storageCheck(page, f, m);
  expect(sc.toasts.join(' '), 'Storage Check').toMatch(/Object Storage check succeeded/);
  await toastGone(f);
  await m.getByRole('button', { name: /^Spec Check$/ }).click();
  const specToasts = await collectToasts(page, f, 15_000);
  const subP = page.waitForResponse((r) => /deployment-submissions\/VM/.test(r.url()) && r.request().method() === 'POST', { timeout: 120_000 });
  await m.getByRole('button', { name: 'Deploy', exact: true }).click();
  const sub = await subP;
  const subBody = (await sub.text()).slice(0, 600);
  const done = await m.getByText('Deployment completed.').first().waitFor({ timeout: waitMs }).then(() => true, () => false);
  const toasts = await collectToasts(page, f, 4000);
  const status = clean(await m.innerText().catch(() => '')).match(/Deployment (completed|failed)[^.]*\.?/i)?.[0] ?? '';
  return { specToasts, submit: `${sub.status()} ${subBody}`, done, status, toasts };
}

/**
 * Jupyter ๋ฐฐํฌ(K8S, 2026-10-15 ํƒ์ƒ‰ wsl/probe/app-jupyter-k8s-probe): ClusterName(#k8s-cluster) ยท Storage Class(GET /applications/k8s/storage-classes) ยท
 * Notebook volume capacity(GiB) ยท Allowed IPv4 CIDR(#k8sOpenIngress: ์›Œ์ปค Security Group TCP 30880 ํ—ˆ์šฉ) ยท Object Storage โ†’ Storage Check โ†’ Spec Check โ†’ Deploy โ†’ ์™„๋ฃŒ ๋Œ€๊ธฐ.
 * ์ ‘์† ์•ˆ๋‚ด: http://<ingress host>:30880 (Ingress Controller ๊ฐ€ ์žˆ๋Š” node ๊ณต์ธ IP)
 */
export async function deployJupyterK8s(page: Page, o: ObjOpts & { project: string; cluster: string; storageClass?: string; sizeGi: string; cidr: string; ingressHost: string }, waitMs = 25 * 60_000) {
  const { f, m } = await openJupyterDeploy(page, 'K8S', o);
  const sc = m.locator('select').filter({ hasText: 'Select StorageClass' }).first();
  await expect.poll(async () => (await sc.locator('option').allInnerTexts()).length, { timeout: 30_000 }).toBeGreaterThan(1);
  const scOpts = (await sc.locator('option').evaluateAll((els) => els.map((e) => (e as HTMLOptionElement).value))).filter(Boolean);
  // ํ›„๋ณด๊ฐ€ ํ•˜๋‚˜๋ฉด ์ž๋™ ์„ ํƒ๋œ ์ฑ„ ๋น„ํ™œ์„ฑ(disabled) โ€” ๊ทธ๋Œ€๋กœ ๋‘”๋‹ค(2026-10-15 k1s: gp2 ํ•˜๋‚˜)
  if (await sc.isEnabled()) await sc.selectOption(o.storageClass ?? scOpts[0]);
  const scSelected = await sc.inputValue();
  await m.locator('xpath=.//*[contains(text(),"Notebook volume capacity")]/following::input[1]').fill(o.sizeGi);
  await m.locator('#k8sOpenIngress').check();
  await m.getByPlaceholder('<PUBLIC_IP>/32').first().fill(o.cidr);
  // CIDR ํ—ˆ์šฉ(#k8sOpenIngress)์„ ์ผœ๋ฉด Ingress ๊ฐ€ ํ•จ๊ป˜ ์ผœ์ง€๊ณ , Host ๊ฐ€ ๋น„๋ฉด 'Ingress Host must be a DNS name' ์œผ๋กœ Deploy ๊ฐ€ ์ž ๊ธด๋‹ค(2026-10-15)
  if (await m.locator('#ingressEnabled').isChecked()) await m.getByPlaceholder('example.com').first().fill(o.ingressHost);
  await refreshStorages(page, m);
  await fillObjectStorage(m, o);
  const chk = await storageCheck(page, f, m);
  expect(chk.toasts.join(' '), 'Storage Check').toMatch(/Object Storage check succeeded/);
  await toastGone(f);
  await m.getByRole('button', { name: /^Spec Check$/ }).click();
  const specToasts = await collectToasts(page, f, 15_000);
  const deployBtn = m.getByRole('button', { name: 'Deploy', exact: true });
  if (!(await deployBtn.isEnabled({ timeout: 10_000 }).catch(() => false))) {
    // Deploy ๊ฐ€ ๋น„ํ™œ์„ฑ์ด๋ฉด ํ™”๋ฉด์ด ์š”๊ตฌํ•˜๋Š” ์กฐ๊ฑด(์•ˆ๋‚ด ๋ฌธ๊ตฌยท์ž…๋ ฅ๊ฐ’)์„ ๋‚จ๊ธฐ๊ณ  ์‹คํŒจ
    const vals = await m.locator('input, select').evaluateAll((els) => els.filter((e) => (e as HTMLElement).offsetParent).map((e) => `${e.id || (e as HTMLInputElement).placeholder || e.tagName}=${(e as HTMLInputElement).type === 'checkbox' ? (e as HTMLInputElement).checked : (e as HTMLInputElement).value}${(e as HTMLInputElement).disabled ? '(disabled)' : ''}`));
    test.info().annotations.push({ type: 'deploy-disabled', description: `spec: ${specToasts.join(' | ')} / ์ž…๋ ฅ: ${vals.join(', ')} / ํ™”๋ฉด: ${clean(await m.innerText()).slice(-1500)}` });
    throw new Error('Spec Check ํ›„ Deploy ๋น„ํ™œ์„ฑ');
  }
  const subP = page.waitForResponse((r) => /deployment-submissions|\/applications\/k8s\/(install|deploy)/i.test(r.url()) && r.request().method() === 'POST', { timeout: 120_000 }).catch(() => null);
  await m.getByRole('button', { name: 'Deploy', exact: true }).click();
  const sub = await subP;
  const subBody = sub ? `${sub.url().replace(/^https?:\/\/[^/]+/, '')} ${(await sub.text()).slice(0, 600)}` : '(์ œ์ถœ ์‘๋‹ต ๋ชป ์ฐพ์Œ)';
  const done = await m.getByText('Deployment completed.').first().waitFor({ timeout: waitMs }).then(() => true, () => false);
  const toasts = await collectToasts(page, f, 4000);
  const status = clean(await m.innerText().catch(() => '')).match(/Deployment (completed|failed)[^.]*\.?|http:\/\/\S+:30880\S*/gi)?.join(' ') ?? '';
  return { storageClasses: scOpts, scSelected, specToasts, submit: `${sub?.status() ?? 0} ${subBody}`, done, status, toasts };
}

/** IV5_S26 ์—์„œ ๋ฐฐํฌํ•ด S28(Prefix)ยทS29(Uninstall)๊ฐ€ ์ด์–ด ์“ฐ๋Š” Jupyter ๋‘ ๊ฐœ */
export const APPS = { ro: { port: '8888', mode: 'READ_ONLY' as const, prefix: '' }, rw: { port: '8889', mode: 'READ_WRITE' as const, prefix: 'am-test/' } };
/** App Status ํ–‰ ์‹๋ณ„(VM: "VM (default / mc3 / g3-1)") */
export const jupyterTarget = () => ({ key: `/ ${OBJ_MCI} / ${OBJ_VM})`, project: OBJ_NS });

/** ์‹œํ—˜ VM ๊ณต์ธ IP(Infra ์ƒ์„ธ Node ์ •๋ณด) */
export async function jupyterVmIp(page: Page) {
  await gotoInfraList(page);
  await openInfraDetail(page, OBJ_MCI);
  const info: any = await readNodeInfo(page, OBJ_VM.replace(/-\d+$/, ''), OBJ_VM);
  return String(info.publicIp ?? info['Public IP'] ?? '');
}

/** notebook helper ์ •์˜ + ์ฒซ storage alias(STORAGES ์ถœ๋ ฅ)๊นŒ์ง€์˜ python ์ฝ”๋“œ */
export const nbPrep = (helper: string) => `${helper}\nimport json\nst = storages()\nalias = st[0]['alias']\nprint('STORAGES', json.dumps([{k: s.get(k) for k in ('alias','accessMode','prefix','provider')} for s in st]))\n`;

/** Jupyter(app) ์ ‘์† โ†’ helper ๋ฅผ ์ •์˜ํ•œ ๋’ค code ์‹คํ–‰ */
export async function jupyterRun(page: Page, ip: string, port: string, code: string) {
  const base = `http://${ip}:${port}`;
  await page.goto(`${base}/lab?token=${JUPYTER_TOKEN}`);
  const helper = await notebookHelper(page, base, JUPYTER_TOKEN);
  expect(helper, '๊ธฐ๋ณธ notebook helper').toContain('def storages');
  return { helper, ...(await jupyterExec(page, base, JUPYTER_TOKEN, nbPrep(helper) + code)) };
}

export const JUPYTER_APP_NAME = 'JupyterLab for Object Storage';
/**
 * ๊ฐ™์€ VM ์—๋Š” ๊ฐ™์€ ์นดํƒˆ๋กœ๊ทธ๋ฅผ ํ•˜๋‚˜๋งŒ ์„ค์น˜ํ•  ์ˆ˜ ์žˆ๋‹ค โ€” ๋‘ ๋ฒˆ์งธ ๋ฐฐํฌ๋Š” 'Skipping deployment on VMs with existing installations' ๋กœ ์‹คํŒจ(2026-10-15 stage).
 * ๊ทธ๋ž˜์„œ A(Read only) โ†’ B(Read and write) ๋ฅผ ์ˆœ์„œ๋Œ€๋กœ: ์ด๋ฏธ ์„ค์น˜๋ผ ์žˆ์œผ๋ฉด ๊ทธ ํ–‰์„ Uninstall ํ•˜๊ณ  ์ œ๊ฑฐ๋  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆฐ๋‹ค.
 */
export async function uninstallJupyterVm(page: Page) {
  await appAction(page, jupyterTarget(), JUPYTER_APP_NAME, 'Uninstall');
  return waitUninstalled(page, jupyterTarget(), JUPYTER_APP_NAME, 10 * 60_000);
}

export { expect, test };

lib/iv5.ts

ํ•ด์‹œ 7ae5fb5b ยท 280์ค„

import { test, expect } from '@playwright/test';
import type { Page, Frame, Locator } from '@playwright/test';
import type { Csp } from './data';
import { infraName, infraNodegroups, WORKSPACE, DEFAULT_PROJECT } from './data';
import { selectWorkspaceProject } from './ui';

// IV5(SW ์„ค์น˜) ํ™”๋ฉด ์…€๋ ‰ํ„ฐ: 2026-10-05 AWS mc1 ์‚ฌ์ „์ ๊ฒ€(Playwright ํ”„๋กœ๋ธŒ)์œผ๋กœ ํ™•์ธ
// - SW Catalogs: /webconsole/_view/swcatalogs, ๋ณธ๋ฌธ์€ iframe(:18084/web/applications/swcatalog)
// - Deploy โ†’ "Application Installation" ๋ชจ๋‹ฌ: #infra(VM/K8S), #vm-namespace, #vm-mci(Infra ID), #vm-target-single(VM ๋ผ๋””์˜ค),
//   #vm-name(VM), Application select(์˜ต์…˜ "[Nginx] latest"), Port(placeholder 8080), Spec Check / Deploy ๋ฒ„ํŠผ
// - Infra ์ƒ์„ธ Node Info ์˜ "Install SW"(a.btn): 2026-10-05 ์—๋Š” SW Catalog ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ–ˆ์œผ๋‚˜,
//   2026-10-06 ๋ถ€ํ„ฐ #installsw-modal(๋Œ€์ƒ ๊ณ ์ • ์„ค์น˜ ํผ iframe)์„ ์—ฐ๋‹ค โ†’ installFromNode
export const INFRA_URL = '/webconsole/operations/manage/workloads/infraworkloads';
export const CATALOG_URL = '/webconsole/_view/swcatalogs';

/** Infra ์ง์ ‘ ์ƒ์„ฑ ์‹œ ์ฒซ node: g{n}-1 */
export const firstNode = (c: Csp) => `${infraNodegroups(c)[0]}-1`;

const note = (type: string, description: string) => test.info().annotations.push({ type, description });

/** Infra Workloads ๋ชฉ๋ก์—์„œ Infra ๋ฅผ ์„ ํƒํ•˜๊ณ  node ์นด๋“œ๋ฅผ ์—ฐ๋‹ค. node ์˜ Node Status ๋ฅผ ๋Œ๋ ค์ค€๋‹ค. */
export async function openNode(page: Page, c: Csp) {
  const infra = infraName(c);
  const node = firstNode(c);
  await page.goto(INFRA_URL);
  await selectWorkspaceProject(page, WORKSPACE, DEFAULT_PROJECT, true); // ์ฃผ์†Œ ์ด๋™ ์‹œ ์„ ํƒ์ด ์ดˆ๊ธฐํ™”๋จ
  // Infra ๊ฐ€ 5๊ฐœ๋ฅผ ๋„˜์œผ๋ฉด ๊ธฐ๋ณธ page size 5 ์—์„œ ๋Œ€์ƒ์ด 2ํŽ˜์ด์ง€๋กœ ๋ฐ€๋ฆฐ๋‹ค(2026-10-13 NHN mc6) โ†’ ๊ฐ€์žฅ ํฐ page size ๋กœ
  const list = page.locator('.tabulator').filter({ has: page.getByRole('columnheader', { name: 'Total Nodes' }) }).first();
  await page.getByText(/Showing \d+/).first().waitFor({ timeout: 30_000 }).catch(() => {});
  const ps = list.locator('.tabulator-page-size');
  if (await ps.count()) {
    const last = await ps.locator('option').evaluateAll((os) => (os[os.length - 1] as HTMLOptionElement).value);
    await ps.selectOption(last).catch(() => {});
  }
  // ๋ชฉ๋ก์ด ๋Šฆ๊ฒŒ ์ฑ„์›Œ์ง€๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ๋‹ค(2026-10-02 0๊ฑด์œผ๋กœ ๋ณด์˜€์œผ๋‚˜ API ์—๋Š” ์กด์žฌ) โ†’ ์ถฉ๋ถ„ํžˆ ๋Œ€๊ธฐ
  const cell = page.getByRole('gridcell', { name: infra, exact: true }).first();
  await expect(cell, `Infra ${infra} ๊ฐ€ ๋ชฉ๋ก์— ํ‘œ์‹œ(IV3 ์„ ํ–‰)`).toBeVisible({ timeout: 60_000 });
  await cell.click();
  const card = page.getByText(node, { exact: true }).first();
  await expect(card, `node ${node} ์นด๋“œ ํ‘œ์‹œ`).toBeVisible({ timeout: 60_000 });
  await card.click();
  await expect(page.getByText(`[ ${infraNodegroups(c)[0]} / ${node} ]`)).toBeVisible({ timeout: 30_000 });
  const status = (await page.getByText('Node Status', { exact: true }).locator('xpath=following-sibling::*[1]').first().innerText().catch(() => '?')).trim();
  // SW ์„ค์น˜๋Š” SSH ๋กœ Docker ๋ฅผ ์ค€๋น„ํ•˜๋ฏ€๋กœ Public IP ๊ฐ€ ํ•„์š”(2026-10-05: Public IP ์—†์Œ โ†’ ์„ค์น˜ Failed)
  const pip = (await page.getByText('Public IP(IPv4)', { exact: true }).locator('xpath=following-sibling::*[1]').first().innerText().catch(() => '?')).trim();
  note('node-status', `${infra}/${node}: ${status}, Public IP(IPv4)=${pip || '(์—†์Œ)'}`);
  return status;
}

/** SW Catalog iframe ์„ ์ฐพ๋Š”๋‹ค(๋ฉ”๋‰ด ์ด๋™ ๋˜๋Š” Install SW ๋งํฌ ํ›„). */
export async function catalogFrame(page: Page, project?: string): Promise<Frame> {
  // Project ์„ ํƒ ์‹œ iframe ์ด ๋‹ค์‹œ ๋กœ๋“œ๋จ โ†’ ์ด์ „ ํ”„๋ ˆ์ž„์„ ์žก์œผ๋ฉด ๋นˆ ํ™”๋ฉด์—์„œ ๋Œ€๊ธฐ(2026-10-06 p3). ๋งค๋ฒˆ ํ”„๋ ˆ์ž„์„ ๋‹ค์‹œ ์ฐพ๊ณ , ๋๋‚ด ๋น„๋ฉด ์ƒˆ๋กœ๊ณ ์นจ
  for (let i = 0; ; i++) {
    await expect.poll(() => page.frames().some((f) => f.url().includes('/applications/swcatalog')), { timeout: 60_000 }).toBe(true);
    await page.waitForTimeout(3000);
    const f = page.frames().find((x) => x.url().includes('/applications/swcatalog'))!;
    if (await f.getByText('Software Catalog').first().isVisible({ timeout: 20_000 }).catch(() => false)) return f;
    if (i >= 2) throw new Error('SW Catalog iframe ์ด ๋น„์–ด ์žˆ์Œ(์žฌ์‹œ๋„ 3ํšŒ)');
    test.info().annotations.push({ type: 'catalog-retry', description: `์‹œ๋„ ${i + 1}: iframe ๋น„์–ด ์žˆ์Œ โ†’ ์ƒˆ๋กœ๊ณ ์นจ` });
    if (i === 1) { await page.reload(); if (project) await selectWorkspaceProject(page, WORKSPACE, project, true); }
  }
}

export interface DeployOpts { app: string; port: string }

/**
 * Deploy โ†’ Application Installation ๋ชจ๋‹ฌ์—์„œ ๋Œ€์ƒ(VM)ยทSW ๋ฅผ ๊ณ ๋ฅด๊ณ  ์„ค์น˜๋ฅผ ์š”์ฒญํ•œ๋‹ค.
 * ๋ชจ๋‹ฌ ์•ˆ๋‚ด/ํ† ์ŠคํŠธ/์•Œ๋Ÿฟ ๋“ฑ ์š”์ฒญ ๊ฒฐ๊ณผ ๋ฌธ๊ตฌ๋ฅผ annotation ์— ๋‚จ๊ธฐ๊ณ  ๋Œ๋ ค์ค€๋‹ค.
 */
export async function deployToVm(page: Page, f: Frame, c: Csp, o: DeployOpts) {
  const infra = infraName(c);
  const node = firstNode(c);
  await f.getByRole('button', { name: 'Deploy', exact: true }).first().click();
  const modal = f.locator('.modal.show').last();
  await expect(modal.getByText('Application Installation')).toBeVisible({ timeout: 30_000 });
  await modal.locator('#infra').selectOption({ label: 'VM' });
  await expect(modal.locator('#vm-mci option', { hasText: infra })).toHaveCount(1, { timeout: 30_000 });
  await modal.locator('#vm-mci').selectOption({ label: infra });
  await modal.locator('#vm-target-single').check();
  await expect(modal.locator('#vm-name option', { hasText: node })).toHaveCount(1, { timeout: 30_000 });
  await modal.locator('#vm-name').selectOption({ label: node });
  note('deploy-target', `Infra=${infra} VM=${node} (Catalog Deploy ๋ชจ๋‹ฌ์—์„œ ์„ ํƒ)`);
  return fillAndDeploy(page, f, modal, modal, o);
}

/**
 * Infra ์ƒ์„ธ Node Info 'Install SW' โ†’ #installsw-modal(iframe :18084/web/softwareCatalog/install?targetType=VM&mciId=..&vmId=..)
 * 2026-10-06 ํ™•์ธ: ๋Œ€์ƒ(VM / namespace / Infra ID / VM Name)์ด ํ˜ธ์ถœํ•œ Workload ํ™”๋ฉด ๊ธฐ์ค€์œผ๋กœ ๊ณ ์ •ยท๋น„ํ™œ์„ฑ.
 * ๊ณ ์ •๋œ ๋Œ€์ƒ์ด mc{n}/g{n}-1 ์ธ์ง€ ํ™•์ธํ•œ ๋’ค SW ๋ฅผ ๊ณจ๋ผ ์„ค์น˜๋ฅผ ์š”์ฒญํ•œ๋‹ค.
 */
export async function installFromNode(page: Page, c: Csp, o: DeployOpts) {
  const infra = infraName(c);
  const node = firstNode(c);
  await page.getByText('Install SW', { exact: true }).first().click();
  const outer = page.locator('#installsw-modal');
  await expect(outer).toBeVisible({ timeout: 30_000 });
  await expect(outer.locator('#installsw-modal-title')).toContainText(node);
  await expect.poll(() => page.frames().some((x) => x.url().includes('/softwareCatalog/install')), { timeout: 60_000 }).toBe(true);
  const f = page.frames().find((x) => x.url().includes('/softwareCatalog/install'))!;
  const body = f.locator('body');
  await expect(body.getByText('Application Installation')).toBeVisible({ timeout: 30_000 });
  // ๋Œ€์ƒ ๊ณ ์ • ํ™•์ธ
  await expect(f.locator('#vm-mci')).toHaveValue(new RegExp(`^${infra}$`));
  await expect(f.locator('#vm-mci')).toBeDisabled();
  await expect(body.getByText(new RegExp(`fixed by the Workload screen: VM ${node} `))).toBeVisible();
  note('deploy-target', `Install SW ๋ชจ๋‹ฌ: ${new URL(f.url()).search} (๋Œ€์ƒ ๊ณ ์ •: Infra=${infra} VM=${node})`);
  return fillAndDeploy(page, f, body, outer, o);
}

/**
 * ์„ค์น˜ ํผ ๊ณตํ†ต: ApplicationยทPort ์ž…๋ ฅ โ†’ Spec Check โ†’ (Deploy ํ™œ์„ฑํ™” ๋Œ€๊ธฐ) โ†’ Deploy.
 * form: ์ž…๋ ฅ ์š”์†Œ๊ฐ€ ์žˆ๋Š” ๋ฒ”์œ„, closeWatch: ์„ค์น˜ ์š”์ฒญ ํ›„ ๋‹ซํžˆ๋Š”์ง€ ๋ณผ ๋ชจ๋‹ฌ.
 * ๊ฒฐ๊ณผ ๋ฌธ๊ตฌ(ํ† ์ŠคํŠธ/์•Œ๋Ÿฟ/์ƒˆ๋กœ ๋‚˜ํƒ€๋‚œ ๋ฌธ๊ตฌ)๋ฅผ annotation ์— ๋‚จ๊ธฐ๊ณ  ๋Œ๋ ค์ค€๋‹ค.
 */
async function fillAndDeploy(page: Page, f: Frame, form: Locator, closeWatch: Locator, o: DeployOpts) {
  const appSel = form.locator('select').filter({ has: f.locator('option', { hasText: `[${o.app}]` }) }).first();
  const appLabel = (await appSel.locator('option', { hasText: `[${o.app}]` }).first().innerText()).trim();
  await appSel.selectOption({ label: appLabel });
  // Port: VM ํผ placeholder 8080, K8S ํผ placeholder 80 โ†’ ํผ ์•ˆ์˜ ์ˆซ์ž ์ž…๋ ฅ 1๊ฐœ
  await form.locator('input[type=number]').first().fill(o.port);
  note('deploy-input', `App=${appLabel} Port=${o.port}`);

  // Spec Check: VM ์‚ฌ์–‘ ์ ํ•ฉ ์—ฌ๋ถ€ ํ™•์ธ(๊ฒฐ๊ณผ ๋ฌธ๊ตฌ ๊ธฐ๋ก). Install SW ๋ชจ๋‹ฌ์—์„œ๋Š” Spec Check ์ „๊นŒ์ง€ Deploy ๋น„ํ™œ์„ฑ(2026-10-06)
  const before = await form.innerText();
  await form.getByRole('button', { name: 'Spec Check' }).click();
  const deploy = form.getByRole('button', { name: 'Deploy', exact: true });
  await expect(deploy, 'Spec Check ํ›„ Deploy ํ™œ์„ฑํ™”').toBeEnabled({ timeout: 120_000 });
  await page.waitForTimeout(2000);
  const afterCheck = await form.innerText().catch(() => '(๋‹ซํž˜)');
  note('spec-check', diffText(before, afterCheck) || '(๋ณ€ํ™” ์—†์Œ)');

  await deploy.click();
  // ์š”์ฒญ ๊ฒฐ๊ณผ: ๋ชจ๋‹ฌ ๋‹ซํž˜ ๋˜๋Š” ์•ˆ๋‚ด/์˜ค๋ฅ˜ ๋ฌธ๊ตฌ
  const closed = await closeWatch.waitFor({ state: 'hidden', timeout: 60_000 }).then(() => true, () => false);
  const msgs = [
    ...(await f.locator('[class*="toast"], [role="alert"], .alert').allInnerTexts().catch(() => [])),
    ...(await page.locator('[class*="toast"], [role="alert"]').allInnerTexts().catch(() => [])),
  ].map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
  const after = closed ? '' : diffText(afterCheck, await form.innerText().catch(() => ''));
  note('deploy-result', `modalClosed=${closed} msgs=${msgs.join(' / ') || '(์—†์Œ)'} ${after}`.trim());
  return { closed, msgs, after };
}

/** ์ƒˆ๋กœ ๋‚˜ํƒ€๋‚œ ์ค„๋งŒ ์ถ”์ถœ(๋ชจ๋‹ฌ ๋ฌธ๊ตฌ ๋ณ€ํ™” ๊ธฐ๋ก์šฉ). */
function diffText(a: string, b: string) {
  const old = new Set(a.split('\n').map((s) => s.trim()));
  return b.split('\n').map((s) => s.trim()).filter((s) => s && !old.has(s)).join(' / ').slice(0, 400);
}

export const APP_STATUS_URL = '/webconsole/_view/appstatus';
/** ์„ค์น˜๊ฐ€ ๋๋‚œ ์ƒํƒœ(์ด ๊ฐ’์ด ๋‚˜์˜ค๋ฉด ๋Œ€๊ธฐ ์ข…๋ฃŒ). ๊ทธ ์™ธ(Installing/Pending ๋“ฑ)๋Š” ์ง„ํ–‰ ์ค‘์œผ๋กœ ๋ณด๊ณ  ์žฌ์กฐํšŒํ•œ๋‹ค. */
const FINAL = /^(Running|Failed|Error|Stopped|Success|Succeeded|Completed|Installed|Healthy)$/i;
/** App Status ํ–‰์˜ Status ์—ด ๊ฐ’ ํ›„๋ณด(์ง„ํ–‰ ์ค‘ ์ƒํƒœ ํฌํ•จ) */
const STATUS_WORD = /\b(Running|Failed|Error|Stopped|Success|Succeeded|Completed|Installed|Healthy|Pending|Installing|Deploying|Creating|Waiting|Unknown)\b/i;

/** App Status ํ–‰ ์‹๋ณ„: VM ์€ Infra ์—ด์ด "VM (default / mc{n} / g{n}-1)" */
export interface AppTarget { key: string; project: string }
export const vmTarget = (c: Csp): AppTarget => ({ key: `/ ${infraName(c)} / ${firstNode(c)})`, project: DEFAULT_PROJECT });

/**
 * K8s ๋ฐฐํฌ: SW Catalog Deploy โ†’ Application Installation ๋ชจ๋‹ฌ์—์„œ Target Infra=K8S.
 * 2026-10-06 ํ™•์ธ(ws01/p2, Azure k2e): #k8s-namespace(=Project, ๊ณ ์ •), #k8s-cluster, Helm chart select("[Nginx] 0.16.8"),
 * Port(placeholder 80), Resource Type, HPA/Workload Rebalancing/Ingress ์ฒดํฌ๋ฐ•์Šค(๊ธฐ๋ณธ ํ•ด์ œ), Spec Check ํ›„ Deploy ํ™œ์„ฑํ™”.
 */
export async function deployToK8s(page: Page, f: Frame, cluster: string, o: DeployOpts) {
  await f.getByRole('button', { name: 'Deploy', exact: true }).first().click();
  const modal = f.locator('.modal.show').last();
  await expect(modal.getByText('Application Installation')).toBeVisible({ timeout: 30_000 });
  await modal.locator('#infra').selectOption({ label: 'K8S' });
  await expect(modal.locator('#k8s-cluster option', { hasText: cluster }), `ํด๋Ÿฌ์Šคํ„ฐ ${cluster} ์„ ํƒ ๊ฐ€๋Šฅ`).toHaveCount(1, { timeout: 30_000 });
  await modal.locator('#k8s-cluster').selectOption({ label: cluster });
  const ns = await modal.locator('#k8s-namespace').inputValue().catch(() => '?');
  note('deploy-target', `K8S namespace=${ns} cluster=${cluster} (Catalog Deploy ๋ชจ๋‹ฌ์—์„œ ์„ ํƒ)`);
  return fillAndDeploy(page, f, modal, modal, o);
}

/**
 * Applications > App Status(iframe :18084/web/applications/status) ์—์„œ ๋Œ€์ƒ(t.key ๋ฅผ ํฌํ•จํ•˜๋Š” ํ–‰) + SW ํ–‰์˜ Status ๋ฅผ ์ฝ๋Š”๋‹ค.
 * 2026-10-05 ํ™•์ธ: ์ปฌ๋Ÿผ Infra / Application / Status / CheckedAt / Action. ์„ค์น˜ ์‹คํŒจ ์‹œ ์ฆ‰์‹œ Failed ๋กœ ํ‘œ์‹œ๋˜๊ณ ,
 * ํ–‰์˜ Application ์„ ๋ˆ„๋ฅด๋ฉด Application Detail(Deployment Logs ํฌํ•จ)์ด ์—ด๋ฆฐ๋‹ค.
 * ์ตœ์ข… ์ƒํƒœ๊ฐ€ ๋‚˜์˜ฌ ๋•Œ๊นŒ์ง€(์ตœ๋Œ€ waitMs) ์žฌ์กฐํšŒํ•˜๊ณ , Failed ๋ฉด Deployment Logs ๋ฅผ annotation ์— ๋‚จ๊ธด๋‹ค. ์ƒํƒœ ๋ฌธ์ž์—ด์„ ๋Œ๋ ค์ค€๋‹ค.
 */
export async function appStatus(page: Page, t: AppTarget, app: string, waitMs = 10 * 60_000) {
  const key = t.key;
  const deadline = Date.now() + waitMs;
  let status = '';
  let row = '';
  let f: Frame | undefined;
  for (;;) {
    await page.goto(APP_STATUS_URL);
    await selectWorkspaceProject(page, WORKSPACE, t.project, true);
    await expect.poll(() => page.frames().some((x) => x.url().includes('/applications/status')), { timeout: 60_000 }).toBe(true);
    await page.waitForTimeout(5000); // Project ์„ ํƒ ์‹œ iframe ์ด ๋‹ค์‹œ ๋กœ๋“œ๋จ(Frame detached, 2026-10-06)
    f = page.frames().find((x) => x.url().includes('/applications/status'))!;
    await f.getByText(/Showing \d+.*rows|No data/i).first().waitFor({ timeout: 60_000 }).catch(() => {});
    // ๊ฐ™์€ VMยทSW ํ–‰์ด ์—ฌ๋Ÿฌ ๊ฐœ๋ฉด(์žฌ์‹คํ–‰) CheckedAt ์ด ๊ฐ€์žฅ ์ตœ๊ทผ์ธ ํ–‰
    const rows = (await f.locator('tr, [role=row], .tabulator-row').allInnerTexts())
      .map((s) => s.replace(/\s+/g, ' ').trim())
      .filter((s) => s.includes(key) && s.includes(app))
      .sort((a, b) => (b.match(/\d{4}-\d\d-\d\d \d\d:\d\d:\d\d/)?.[0] ?? '').localeCompare(a.match(/\d{4}-\d\d-\d\d \d\d:\d\d:\d\d/)?.[0] ?? ''));
    row = rows[0] ?? '';
    // Application ์—ด ์ด๋ฆ„์ด ์นดํƒˆ๋กœ๊ทธ์™€ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Œ(K8s: "Nginx for Kubernetes", 2026-10-06) โ†’ ์•ฑ ์ด๋ฆ„ ๋’ค์—์„œ ์ƒํƒœ ๋‹จ์–ด๋ฅผ ์ฐพ๋Š”๋‹ค
    status = row.slice(row.indexOf(app) + app.length).match(STATUS_WORD)?.[1] ?? '';
    if ((status && FINAL.test(status)) || Date.now() > deadline) break;
    await page.waitForTimeout(20_000);
  }
  // ํ–‰์„ ๋ชป ์ฐพ์œผ๋ฉด ํ˜•์‹ ํ™•์ธ์šฉ์œผ๋กœ ์ „์ฒด ํ–‰์„ ๋‚จ๊ธด๋‹ค(K8s ํ–‰ ํ˜•์‹์€ ์ฒซ ์‹คํ–‰์—์„œ ํ™•์ธ)
  const all = row ? '' : ` | ์ „์ฒด: ${(await f!.locator('tr, [role=row], .tabulator-row').allInnerTexts().catch(() => [])).map((s) => s.replace(/\s+/g, ' ')).join(' || ').slice(0, 500)}`;
  note('app-status', `${app}@${key}: ${status || '(App Status ์— ํ–‰ ์—†์Œ)'} :: ${row}${all}`);
  if (/failed|error/i.test(status) && f) {
    // ์‹คํŒจ ์›์ธ: Application Detail ์˜ Deployment Logs
    await f.getByText(app, { exact: true }).first().click().catch(() => {});
    const body = (await f.locator('body').innerText().catch(() => '')).replace(/\s+/g, ' ');
    const i = body.indexOf('Deployment Logs');
    note('deployment-logs', i >= 0 ? body.slice(i, i + 600) : '(์ƒ์„ธ ์—†์Œ)');
  }
  return status;
}

let termSeq = 0;
/**
 * Remote Terminal(#cmdtestmodal, lib/iv4-infra openNodeTerminal ๋กœ ์—ฐ ์ƒํƒœ)์—์„œ ๋ช…๋ น ์‹คํ–‰.
 * iv4 termRun ์€ ๋ ํ‘œ์‹œ๊ฐ€ 2๋ฒˆ(์ž…๋ ฅ ์ค„+์ถœ๋ ฅ) ๋ณด์—ฌ์•ผ ๋์œผ๋กœ ๋ณด๋Š”๋ฐ, docker version ์ฒ˜๋Ÿผ ์ถœ๋ ฅ์ด ๊ธธ๋ฉด ์ž…๋ ฅ ์ค„์ด ํ™”๋ฉด ์œ„๋กœ ๋ฐ€๋ ค
 * ๋์„ ๋ชป ์ฐพ๋Š”๋‹ค(2026-10-06). ์—ฌ๊ธฐ์„œ๋Š” "๋งˆ์ง€๋ง‰ ๋ ํ‘œ์‹œ ๋‹ค์Œ ์ค„์ด ํ”„๋กฌํ”„ํŠธ($)" ์ด๋ฉด ๋์œผ๋กœ ๋ณธ๋‹ค.
 * xterm ์€ ๋ณด์ด๋Š” ์ค„๋งŒ DOM ์— ์žˆ์œผ๋ฏ€๋กœ ๊ธด ์ถœ๋ ฅ์€ ์•ž๋ถ€๋ถ„์ด ์ž˜๋ฆด ์ˆ˜ ์žˆ๋‹ค(ํŒ์ •์€ ์ถœ๋ ฅ ๋๋ถ€๋ถ„ ๊ธฐ์ค€์œผ๋กœ ํ•  ๊ฒƒ).
 */
export async function termRunLong(page: Page, cmd: string, timeoutMs = 90_000) {
  const term = page.locator('#cmdtestmodal');
  const collapse = term.locator('#server-terminal-collapse');
  if (!(await collapse.isVisible())) await term.getByRole('button', { name: 'Command' }).click();
  await expect(collapse).toBeVisible();
  const mark = `@@L${++termSeq}@@`;
  await term.locator('.xterm').click();
  await page.keyboard.type(`${cmd};echo ${mark}`, { delay: 15 });
  await page.keyboard.press('Enter');
  const rows = term.locator('.xterm-rows');
  const flat = async () => (await rows.innerText()).replace(/\u00a0/g, ' ').replace(/[ \t]+\n/g, '\n').trimEnd();
  const done = new RegExp(`${mark}\\s*\\n[^\\n]*\\$$`);
  await expect.poll(async () => done.test(await flat()), { timeout: timeoutMs }).toBe(true);
  const text = await flat();
  const end = text.lastIndexOf(mark);
  const inputAt = text.indexOf(`echo ${mark}`);
  const out = text.slice(inputAt >= 0 ? inputAt + `echo ${mark}`.length : 0, end).trim();
  note('terminal', `$ ${cmd} โ†’ ${out.replace(/\s+/g, ' ').slice(0, 300)}${inputAt < 0 ? ' (์•ž๋ถ€๋ถ„ ํ™”๋ฉด ๋ฐ–)' : ''}`);
  return out;
}


/** App Status ์—์„œ ํ•ด๋‹น ํ–‰(๋Œ€์ƒ key + ์•ฑ ์ด๋ฆ„)์˜ Action ๋ฒ„ํŠผ(Start/Uninstall ๋“ฑ)์„ ๋ˆ„๋ฅด๊ณ  iframe ์•ˆ ํ™•์ธ์ฐฝ์ด ์žˆ์œผ๋ฉด ํ™•์ธํ•œ๋‹ค. */
export async function appAction(page: Page, t: AppTarget, app: string, button: string) {
  await page.goto(APP_STATUS_URL);
  await selectWorkspaceProject(page, WORKSPACE, t.project, true);
  await expect.poll(() => page.frames().some((x) => x.url().includes('/applications/status')), { timeout: 60_000 }).toBe(true);
  await page.waitForTimeout(5000);
  const f = page.frames().find((x) => x.url().includes('/applications/status'))!;
  await f.getByText(/Showing \d+.*rows|No data/i).first().waitFor({ timeout: 60_000 }).catch(() => {});
  const row = f.locator('tr, [role=row], .tabulator-row').filter({ hasText: app }).filter({ hasText: t.key }).first();
  await row.getByRole('button', { name: button }).click();
  const dlg = f.getByRole('dialog');
  let confirmText = '';
  if (await dlg.isVisible({ timeout: 4000 }).catch(() => false)) {
    confirmText = (await dlg.innerText().catch(() => '')).replace(/\s+/g, ' ').slice(0, 160);
    await dlg.getByRole('button', { name: /uninstall|start|confirm|ok|yes|submit|delete/i }).last().click();
  }
  note('app-action', `${app}: ${button} ํด๋ฆญ${confirmText ? ` (ํ™•์ธ์ฐฝ: ${confirmText})` : ''}`);
  await page.waitForTimeout(5000);
}

/** App Status ํ–‰์ด ์‚ฌ๋ผ์ง€๊ฑฐ๋‚˜ Uninstalled ๊ฐ€ ๋  ๋•Œ๊นŒ์ง€ ์žฌ์กฐํšŒ. ๊ด€์ฐฐํ•œ ์ƒํƒœ ๋ณ€ํ™”๋ฅผ ๋Œ๋ ค์ค€๋‹ค(ํ–‰ ์—†์Œ='์ œ๊ฑฐ๋จ'). */
export async function waitUninstalled(page: Page, t: AppTarget, app: string, waitMs = 5 * 60_000) {
  const seen: string[] = [];
  const deadline = Date.now() + waitMs;
  for (;;) {
    const s = await appStatus(page, t, app, 1);
    if (seen[seen.length - 1] !== (s || '์ œ๊ฑฐ๋จ')) seen.push(s || '์ œ๊ฑฐ๋จ');
    if (!s || /uninstalled/i.test(s)) return { done: true, seen };
    if (Date.now() > deadline) return { done: false, seen };
    await page.waitForTimeout(15_000);
  }
}

lib/iv7-obs-logs.ts

ํ•ด์‹œ baf23c89 ยท 24์ค„

import type { Page, Frame } from '@playwright/test';
import { test, expect } from './fixtures';
import { collectResponses, clean } from './iv7-obs';

// IV7_S31~S34(TC-OBS-031~034, 'ํ…Œ์ŠคํŠธ ์‹œ๋‚˜๋ฆฌ์˜ค' ์‹œํŠธ์˜ ๋กœ๊ทธ TC โ€” Framework ์‹œํŠธ 026ยท027 ๊ณผ ์ •์˜๊ฐ€ ๋‹ฌ๋ผ ๋ณ„๋„ ๋ฒˆํ˜ธ, ์‚ฌ์šฉ์ž ๊ฒฐ์ • 2026-10-15) ๊ณตํ†ต ๋ณธ๋ฌธ
// Logs ํ™”๋ฉด: Workload(Infra / Cluster) ยท Server ยท Keyword โ†’ Search โ†’ GET /api/o11y/log/query_range?query=LogQL
export async function logSearch(page: Page, f: Frame, workload: string, keyword: string) {
  const q = collectResponses(page, /\/api\/o11y\/log\/query_range/);
  await f.locator('select').first().selectOption({ label: workload });
  await page.waitForTimeout(2000);
  await f.locator('select').nth(1).selectOption({ label: 'All' }).catch(() => {});
  await f.getByPlaceholder(/keyword/i).first().or(f.locator('input[type="text"], input[type="search"]').first()).fill(keyword);
  await f.getByRole('button', { name: 'Search', exact: true }).click();
  await expect.poll(() => q.length, { timeout: 30_000 }).toBeGreaterThan(0);
  await page.waitForTimeout(2000);
  const r = q.at(-1)!;
  const query = decodeURIComponent(r.url.match(/query=([^&]+)/)?.[1] ?? '').replace(/\+/g, ' ');
  const rows: any[] = r.json?.data?.data ?? [];
  const body = clean(await f.locator('body').innerText());
  test.info().annotations.push({ type: 'logql', description: `${query} โ†’ HTTP ${r.status} ${rows.length}๊ฑด` });
  test.info().annotations.push({ type: 'first-row', description: JSON.stringify(rows[0] ?? {}).slice(0, 300) });
  return { r, query, rows, body };
}

lib/iv7-obs.ts

ํ•ด์‹œ ad7aa6e1 ยท 111์ค„

import { test, expect } from '@playwright/test';
import type { Page, Frame, Response } from '@playwright/test';
import { WORKSPACE } from './data';
import { selectWorkspaceProject } from './ui';

// IV7 mc-observability ํ™”๋ฉด TC(TC-OBS-007~030) ๊ณต์šฉ ํ—ฌํผ โ€” 2026-10-14 ํƒ์ƒ‰(wsl/probe/obs-explore*.spec.ts)์œผ๋กœ ํ™•์ธ
// - Monitorings ๋ฉ”๋‰ด 6๊ฐœ โ†’ iframe :18081/embed/{monitoring|logs|config|insight|alerts|trace}/{ns}
//   ์ฝ˜์†” ๋ฉ”๋‰ด: Monitoring(mco11ymonitorings) ยท Logs(mc011ylogs) ยท Config(mc011yconfig) ยท Insight(mc011yinsight) ยท Alert(mc011yalerts) ยท Tracing(mc011ytracing)
// - ์ž„๋ฒ ๋“œ ํ™”๋ฉด์—๋Š” NS ์…€๋ ‰ํ„ฐ๊ฐ€ ์—†๊ณ  ์ฝ˜์†” Project ๊ฐ€ NS(ns_id)๋กœ ์ „๋‹ฌ๋œ๋‹ค
// - node ๋Œ€์‹œ๋ณด๋“œ๋Š” ์นด๋“œ ํด๋ฆญ์œผ๋กœ ์—ด๋ฆฌ์ง€ ์•Š์•„ iframe ์„ /embed/monitoring/{ns}/{infra}/{node} ๋กœ ์ด๋™(์‹œํŠธ ๊ฒฝ๋กœ). ?source=csp ๋Š” API(CSP) ๋ฉ”ํŠธ๋ฆญ
// - ๋Œ€์ƒ(์ฝ๊ธฐ ์ „์šฉ): ํ™˜๊ฒฝ๋ณ€์ˆ˜ OBS_NS/OBS_INFRA/OBS_NODE(๊ธฐ๋ณธ default/mc1/g1-1, IV3ยทIV7 ์ด ๋งŒ๋“  Agent ์„ค์น˜ node โ€” ์ƒํƒœ๋ฅผ ๋ฐ”๊พธ์ง€ ์•Š์Œ)
export const OBS_NS = process.env.OBS_NS ?? 'default';
export const OBS_INFRA = process.env.OBS_INFRA ?? 'mc1';
export const OBS_NODE = process.env.OBS_NODE ?? 'g1-1';
export const OBS_MENU = {
  monitoring: '/webconsole/_view/mco11ymonitorings', logs: '/webconsole/_view/mc011ylogs', config: '/webconsole/_view/mc011yconfig',
  insight: '/webconsole/_view/mc011yinsight', alerts: '/webconsole/_view/mc011yalerts', trace: '/webconsole/_view/mc011ytracing',
} as const;
export type ObsMenu = keyof typeof OBS_MENU;
export const clean = (s: string) => s.replace(/\s+/g, ' ').trim();
const note = (type: string, d: string) => test.info().annotations.push({ type, description: d.slice(0, 2500) });

/** ์ฝ˜์†” ๋ฉ”๋‰ด ์ง„์ž… โ†’ :18081 iframe(Frame). ๋ณธ๋ฌธ์ด ์ฑ„์›Œ์งˆ ๋•Œ๊นŒ์ง€ */
export async function openObs(page: Page, menu: ObsMenu, project = OBS_NS): Promise<Frame> {
  for (let attempt = 1; ; attempt++) {
    await page.goto(OBS_MENU[menu]);
    await selectWorkspaceProject(page, WORKSPACE, project, true);
    const end = Date.now() + 60_000;
    while (Date.now() < end) {
      const f = page.frames().find((x) => x.url().includes(':18081') && x.url().includes(`/embed/${menu === 'trace' ? 'trace' : menu}`));
      if (f && clean(await f.locator('body').innerText().catch(() => '')).length > 30) { await page.waitForTimeout(3000); return f; }
      await page.waitForTimeout(1000);
    }
    if (attempt >= 3) throw new Error(`${menu} iframe ์ด 3ํšŒ ๋ชจ๋‘ ๋กœ๋”ฉ๋˜์ง€ ์•Š์Œ`);
    note('obs-iframe-retry', `${menu} ์‹œ๋„ ${attempt}`);
  }
}

/** iframe ์„ ๊ฐ™์€ ์ถœ์ฒ˜์˜ ๋‹ค๋ฅธ embed ๊ฒฝ๋กœ๋กœ ์ด๋™ */
export async function obsGoto(page: Page, f: Frame, path: string) {
  await f.goto(new URL(f.url()).origin + path);
  await page.waitForTimeout(6000);
  return page.frames().find((x) => x.url().includes(':18081'))!;
}

/** ์กฐ๊ฑด์— ๋งž๋Š” ์‘๋‹ต๋“ค์„ ๋ชจ์€๋‹ค(ํ™”๋ฉด์ด ๋ถ€๋ฅธ API ์˜ ์‘๋‹ต์„ ํŒ์ • ๊ทผ๊ฑฐ๋กœ ์“ฐ๊ธฐ ์œ„ํ•ด) */
export function collectResponses(page: Page, re: RegExp) {
  const list: { url: string; status: number; method: string; json: any }[] = [];
  page.on('response', async (r: Response) => {
    if (!re.test(r.url()) || r.request().method() === 'OPTIONS') return;
    let json: any; try { json = await r.json(); } catch { json = undefined; }
    list.push({ url: r.url().replace(/^https?:\/\/[^/]+/, ''), status: r.status(), method: r.request().method(), json });
  });
  return list;
}

/** select(๋ผ๋ฒจ ํ…์ŠคํŠธ ์˜†) ์„ ํƒ: label ๋ฌธ๊ตฌ๋กœ ์ฐพ์€ ๋‹ค์Œ select */
export async function selectNear(f: Frame, label: string, option: string | RegExp) {
  const sel = f.locator(`xpath=//label[normalize-space()="${label}"]/following::select[1]`).first();
  const opts = (await sel.locator('option').allInnerTexts()).map((o) => o.trim());
  const pick = typeof option === 'string' ? opts.find((o) => o === option) : opts.find((o) => option.test(o));
  if (!pick) throw new Error(`${label} ์˜ต์…˜ ${option} ์—†์Œ: ${opts.join('|')}`);
  await sel.selectOption({ label: pick });
  return pick;
}

// ---- Alert(Trigger Policy) ํ™”๋ฉด โ€” 2026-10-16 ํƒ์ƒ‰(rf-obs2, ์ •์ฑ… ์ƒ์„ฑยท์‚ญ์ œ 1ํšŒ)์œผ๋กœ ํ™•์ธ ----
// - Policies ํƒญ: '+ New Policy' โ†’ ํผ(๋ผ๋ฒจ์€ <div>, ์ž…๋ ฅ๋ž€์€ ๊ฐ™์€ ์ค„์˜ input/select): Title(input[required]) / Description / Resource(select CPU|MEMORY|DISK)
//   / Aggregation(select AVG|MAX|MIN|LAST) / Thresholds InfoยทWarningยทCritical(number, ๊ธฐ๋ณธ 20ยท50ยท80) / Hold Duration(5m) / Repeat Interval(1h)
//   / Alarm targets(select Infra + node ์ฒดํฌ๋ฐ•์Šค + Add) / Notification channels(TeamsยทKakaoยทSlackยทEmailยทSmsยทDiscord ์ฒดํฌ + ์ˆ˜์‹ ์ž ์ž…๋ ฅ + Test) / Create
// - ๋ชฉ๋ก ํ‘œ: ID | Title | Resource | Thresholds(I:20 W:50 C:80) | Targets(๊ฐœ์ˆ˜) | Channels | Actions(Manage, Delete)
// - Delete โ†’ JS confirm 'Delete this policy?'(fixtures dialogs ๊ฐ€ ์ˆ˜๋ฝ) โ†’ DELETE /api/o11y/trigger/policy/{id} 202
// - Manage โ†’ ํ–‰ ์•„๋ž˜ ํŒจ๋„: Close / Delete / โ–ธ Policy settings / Alarm targets(select + ์ฒดํฌ๋ฐ•์Šค + 'Add targets') / Notification channels(โ€ฆ 'Save channels')
/** ํผ์˜ ํ•œ ์ค„(๋ผ๋ฒจ <div> ํ…์ŠคํŠธ)์— ์žˆ๋Š” input/select */
export const policyField = (f: Frame, label: string) =>
  f.locator(`xpath=//div[normalize-space()="${label}"]/ancestor::div[contains(@class,"items-start")][1]//*[self::input or self::select]`).first();
export const policyRow = (f: Frame, title: string) => f.locator('tr').filter({ has: f.locator('td', { hasText: new RegExp(`^${title}$`) }) }).first();

/** + New Policy โ†’ ๊ฐ’ ์ž…๋ ฅ โ†’ Create. POST ์‘๋‹ต(id) ์„ ๋Œ๋ ค์ค€๋‹ค */
export async function createPolicy(page: Page, f: Frame, p: { title: string; description?: string; resource?: string; aggregation?: string; info?: string; warning?: string; critical?: string; hold?: string; repeat?: string }) {
  const api = collectResponses(page, /\/api\/o11y\/trigger\/policy$/);
  await f.getByRole('button', { name: '+ New Policy' }).click();
  await expect(policyField(f, 'Title')).toBeVisible();
  await policyField(f, 'Title').fill(p.title);
  if (p.description !== undefined) await policyField(f, 'Description').fill(p.description);
  if (p.resource) await policyField(f, 'Resource').selectOption({ label: p.resource });
  if (p.aggregation) await policyField(f, 'Aggregation').selectOption({ label: p.aggregation });
  for (const [lb, v] of [['Info', p.info], ['Warning', p.warning], ['Critical', p.critical], ['Hold Duration', p.hold], ['Repeat Interval', p.repeat]] as const) if (v !== undefined) await policyField(f, lb).fill(v);
  await f.getByRole('button', { name: 'Create', exact: true }).click();
  await expect.poll(() => api.find((a) => a.method === 'POST')?.status ?? 0, { message: '์ •์ฑ… ์ƒ์„ฑ POST', timeout: 30_000 }).toBeGreaterThan(0);
  const post = api.find((a) => a.method === 'POST')!;
  note('policy-create', `${post.status} POST ${post.url} โ†’ ${JSON.stringify(post.json?.data ?? post.json)}`);
  await expect(policyRow(f, p.title)).toBeVisible({ timeout: 30_000 });
  return { status: post.status, id: post.json?.data?.id as number | undefined };
}

/** ๋ชฉ๋ก์—์„œ ์ •์ฑ… Delete(ํ™•์ธ์ฐฝ ์ˆ˜๋ฝ) โ†’ DELETE ์‘๋‹ต ์ƒํƒœ */
export async function deletePolicy(page: Page, f: Frame, title: string) {
  const api = collectResponses(page, /\/api\/o11y\/trigger\/policy\/\d+$/);
  const row = policyRow(f, title);
  if (!(await row.isVisible().catch(() => false))) return 0;
  await row.getByRole('button', { name: 'Delete', exact: true }).click();
  await expect.poll(() => api.find((a) => a.method === 'DELETE')?.status ?? 0, { message: '์ •์ฑ… ์‚ญ์ œ DELETE', timeout: 30_000 }).toBeGreaterThan(0);
  const d = api.find((a) => a.method === 'DELETE')!;
  note('policy-delete', `${d.status} DELETE ${d.url}`);
  await expect(policyRow(f, title)).toHaveCount(0, { timeout: 30_000 });
  return d.status;
}

export { expect };

lib/iv8-nrdb.ts

ํ•ด์‹œ d4d2856a ยท 225์ค„

import { test, expect } from '@playwright/test';
import type { Page, Frame } from '@playwright/test';
import type { Csp } from './data';
import { openDm, selectCred } from './iv8-os';
import { rdbRowText, pickClass, refreshHostIfNeeded, PENDING, RDB_PW } from './iv8-rdb';
import type { RdbSpec } from './iv8-rdb';

// IV8 No-SQL(NRDBMS) ๊ณตํ†ต ๋™์ž‘. Data Migrations(iframe :3300) > Generate > No-SQL ํƒญ. (2026-10-14 DOM ์ ๊ฒ€)
//   GCP(Firestore): + Instance #openCreateBtn โ†’ Create Instance ํŒจ๋„์€ Instance ID(#dbi-instanceId) ํ•˜๋‚˜ โ†’ #dbi-createBtn
//   AWS(DynamoDB): serverless ๋ผ Instance Listยท+ Instance ๊ฐ€ ์—†๋‹ค(Restore ๋Œ€์ƒ์€ credentialยทRegion ๋งŒ ์ง€์ •)
//   ๋ชฉ๋ก ์—ด: ID / Name / Status / Version / Host / Port (SQL Database ์™€ ๊ฐ™์€ table ๊ตฌ์กฐ๋ผ ํ–‰ ์ฐพ๊ธฐ๋Š” iv8-rdb ์˜ rdbRowText ๋ฅผ ์“ด๋‹ค)

/** Drive mc-data-manager ์‹œํŠธ TC-DATA-020 ๊ธฐ์ค€(2026-10-14) */
export const NRDB_SRC = 'mcmp-e2e-v07-nrdb1gcp'; // GenerateยทMigration ์›๋ณธยทBackup ์›๋ณธ
export const NRDB_MIG = 'mcmp-e2e-v07-nrdb2gcp'; // Migration ๋Œ€์ƒ
export const NRDB_IDS = [NRDB_SRC, NRDB_MIG];
export const NRDB_BACKUP = 'mcmp-e2e-v07-bk3';

/**
 * 2026-10-15 ์‚ฌ์šฉ์ž ๋Œ€์ƒํ‘œ(NRDB = AWSยทGCPยทAlibabaยทNCP) โ€” ์ธ์Šคํ„ด์Šค ์ƒ์„ฑยท์‚ญ์ œ(TC-DATA-020/021)๋Š” ์ธ์Šคํ„ด์Šค๊ฐ€ ์žˆ๋Š” GCPยทAlibabaยทNCP.
 * AWS(DynamoDB)๋Š” ํ™”๋ฉด์— Instance Listยท+ Instance ๊ฐ€ ์—†์–ด 020/021 ๋น„๋Œ€์ƒ.
 * AlibabaยทNCP ๋Š” MongoDB โ€” ์ƒ์„ฑ ํผ์— Version / Instance Class / (Alibaba ๋งŒ Storage) / Username / Password ๊ฐ€ ์žˆ๋‹ค. ์ตœ์†Œ ์‚ฌ์–‘(์‚ฌ์šฉ์ž ์ง€์‹œ):
 *   Alibaba 7.0 / mdb.shard.4x.large.d (๊ธฐ๋ณธ๊ฐ’์€ mdb.shard.2x.16xlarge.s2.d ๋ผ ๋ฐ˜๋“œ์‹œ ๋ฐ”๊ฟ”์•ผ ํ•จ), ๊ณ„์ • root(Alibaba MongoDB ๋Š” root ๊ณ„์ •)
 *   NCP 7.0.28 / SVR.VMGDB.AMD.MNGOD.HICPU.C002.M004.NET.SSD.B050.G003(๊ธฐ๋ณธ๊ฐ’), ๊ณ„์ • mcmp, Instance ID 8์ž ์ดํ•˜(NCP SQL ๊ณผ ๊ฐ™์€ ์ œํ•œ โ€” mcmpndb1ยท2)
 */
export interface NrdbSpec { id: string; version?: string; family?: string; size?: string; classText?: string; user?: string }
export const NRDBS: Record<string, NrdbSpec[]> = {
  GCP: [{ id: NRDB_SRC }, { id: NRDB_MIG }],
  Alibaba: [
    { id: 'mcmp-e2e-v07-nrdb1ali', version: '7.0', family: 'mdb.shard', size: '4x.large.d', classText: 'mdb.shard.4x.large.d', user: 'root' },
    { id: 'mcmp-e2e-v07-nrdb2ali', version: '7.0', family: 'mdb.shard', size: '4x.large.d', classText: 'mdb.shard.4x.large.d', user: 'root' },
  ],
  NCP: [
    { id: 'mcmpndb1', version: '7.0.28', family: 'SVR.VMGDB.AMD.MNGOD', size: 'HICPU.C002.M004.NET.SSD.B050.G003', classText: 'SVR.VMGDB.AMD.MNGOD.HICPU.C002.M004.NET.SSD.B050.G003', user: 'mcmp' },
    { id: 'mcmpndb2', version: '7.0.28', family: 'SVR.VMGDB.AMD.MNGOD', size: 'HICPU.C002.M004.NET.SSD.B050.G003', classText: 'SVR.VMGDB.AMD.MNGOD.HICPU.C002.M004.NET.SSD.B050.G003', user: 'mcmp' },
  ],
};
export const nrdbsOf = (c: Csp) => NRDBS[c.code];
/** CSP ๋‹จ๋… ํ๋ฆ„: src ์— ์ƒ˜ํ”Œ โ†’ srcโ†’mig ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ โ†’ src ๋ฐฑ์—… โ†’ ๊ฐ™์€ CSP ์˜ rst(= mig) ๋กœ ๋ณต๊ตฌ */
export interface NrdbFlow { src: NrdbSpec; mig: NrdbSpec; rst: NrdbSpec; backup: string }
export const NRDB_FLOW: Record<string, NrdbFlow> = {
  GCP: { src: NRDBS.GCP[0], mig: NRDBS.GCP[1], rst: NRDBS.GCP[1], backup: NRDB_BACKUP },
  Alibaba: { src: NRDBS.Alibaba[0], mig: NRDBS.Alibaba[1], rst: NRDBS.Alibaba[1], backup: 'mcmp-e2e-v07-bk3ali' },
  NCP: { src: NRDBS.NCP[0], mig: NRDBS.NCP[1], rst: NRDBS.NCP[1], backup: 'mcmp-e2e-v07-bk3ncp' },
};
export const nrdbFlowOf = (c: Csp) => NRDB_FLOW[c.code];
/** ์ธ์Šคํ„ด์Šค ์ƒ์„ฑยท์‚ญ์ œ ๋Œ€์ƒ CSP(NRDB) */
export const NRDB_CSPS = ['GCP', 'Alibaba', 'NCP'];
/** ์ƒ˜ํ”Œยท๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ยท๋ฐฑ์—…ยท๋ณต๊ตฌ(TC-DATA-012~015) ๋Œ€์ƒ CSP โ€” AWS(DynamoDB)๋Š” ์ธ์Šคํ„ด์Šค ์—†์ด credentialยทRegion ๋งŒ์œผ๋กœ ์ง„ํ–‰ */
export const NRDB_FLOW_CSPS = ['AWS', 'GCP', 'Alibaba', 'NCP'];
NRDB_FLOW.AWS = { src: { id: '' }, mig: { id: '' }, rst: { id: '' }, backup: 'mcmp-e2e-v07-bk3aws' };
/** ํ™”๋ฉด ์ข…๋ฅ˜: AWS DynamoDB / GCP Firestore / AlibabaยทNCP MongoDB (2026-10-15 ํƒ์ƒ‰) */
export const nrdbKind = (c: Csp) => (c.code === 'AWS' ? 'dynamo' : c.code === 'GCP' ? 'firestore' : 'mongo');
/** MongoDB Sample Data ํŒจ๋„์˜ Database Name(#nsp-database) โ€” ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ยท๋ฐฑ์—…์—์„œ ์ด DB ๋ฅผ ๊ณ ๋ฅธ๋‹ค */
export const NRDB_DB = 'mcmpe2e';

// โ”€โ”€ MongoDB(AlibabaยทNCP) ํ™”๋ฉด (2026-10-15 ํƒ์ƒ‰, Alibaba nrdb1ali ์„ ํƒ ์ƒํƒœ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//   Generate: ํ–‰ ํด๋ฆญ โ†’ 'Sample Data'(#openCreateBtn) โ†’ ํŒจ๋„ #nsp-username(Alibaba ๋Š” root ๋งŒ) / #nsp-password / #nsp-database โ†’ #nsp-submitBtn
//   Migration ์›๋ณธ: ํ–‰ ํด๋ฆญ โ†’ #mig-source-mongodb-hostยทport ์ž๋™ โ†’ #mig-source-mongodb-usernameยทpassword โ†’ Load Databases(#src-nrdb-load-db-btn)
//   Back Up ์›๋ณธยทRestore ๋Œ€์ƒ: ํ–‰ ํด๋ฆญ โ†’ #nosql-srcHostยทPort ์ž๋™ โ†’ #nosql-srcUsernameยทPassword โ†’ Load Databases(#nrdb-load-db-btn)
//   AWS DynamoDB: ์ธ์Šคํ„ด์Šค ๋ชฉ๋ก ์—†์ด Generate '#nrdb-aws-sampleBtn', Migration '#src-nrdb-aws-sampleBtn'/'#tgt-โ€ฆ', Back Up '#bak-โ€ฆ', Restore '#res-โ€ฆ'

/** ๋ณด์ด๋Š” select ์ค‘ option ์— name ์ด ์žˆ๋Š” ๊ฒƒ์„ ๊ณจ๋ผ ์„ ํƒ. ๊ณ ๋ฅธ select id ๋ฅผ ๋Œ๋ ค์ค€๋‹ค(์—†์œผ๋ฉด ''). */
export async function selectDbOption(page: Page, f: Frame, name: string, scope = 'body') {
  let id = '';
  await expect.poll(async () => {
    id = await f.locator(scope).first().evaluate((root, n) => {
      const s = [...root.querySelectorAll('select')].find((e) => (e as HTMLElement).offsetParent && [...(e as HTMLSelectElement).options].some((o) => o.value === n || (o.textContent || '').trim() === n));
      return s ? (s.id || '(id ์—†์Œ)') : '';
    }, name);
    return id;
  }, { timeout: 60_000, intervals: [2000, 3000] }).not.toBe('').catch(() => {});
  if (id && id !== '(id ์—†์Œ)') await f.locator(`select#${id}`).selectOption(name);
  note('db-select', id ? `select#${id} = ${name}` : `${name} ์„ ๊ฐ€์ง„ select ์—†์Œ`);
  return id;
}

/** MongoDB ์ ‘์†์ •๋ณด(UsernameยทPassword) ์ž…๋ ฅ โ†’ Load Databases โ†’ DB ์„ ํƒ */
export async function mongoLoadDb(page: Page, f: Frame, s: NrdbSpec, prefix: string, btn: string, api: () => string, scope = 'body') {
  await f.locator(`#${prefix}username, #${prefix}Username`).first().fill(s.user!);
  await f.locator(`#${prefix}password, #${prefix}Password`).first().fill(RDB_PW);
  await f.locator(btn).click();
  const id = await selectDbOption(page, f, NRDB_DB, scope);
  note('load-db-api', api() || '(API ๊ธฐ๋ก ์—†์Œ)');
  return id;
}

/** MongoDB Sample Data ํŒจ๋„: UsernameยทPasswordยทDatabase Name โ†’ Submit โ†’ Result */
export async function createMongoSample(page: Page, f: Frame, s: NrdbSpec, api: () => string) {
  const { resultText, waitResult } = await import('./iv8-rdb');
  await expect(f.locator('#openCreateBtn')).toHaveText(/Sample Data/i, { timeout: 15_000 });
  await f.locator('#openCreateBtn').click();
  await expect(f.locator('#nsp-database')).toBeVisible({ timeout: 15_000 });
  note('sample-panel', (await f.locator('.offcanvas.show').innerText().catch(() => '')).replace(/\s+/g, ' ').slice(0, 300));
  await f.locator('#nsp-username').fill(s.user!);
  await f.locator('#nsp-password').fill(RDB_PW);
  await f.locator('#nsp-database').fill(NRDB_DB);
  const before = await resultText(page, f);
  await f.locator('#nsp-submitBtn').click();
  const txt = await waitResult(page, f, before, 15 * 60_000);
  note('sample-api', api() || '(API ๊ธฐ๋ก ์—†์Œ)');
  return txt;
}

/** AWS DynamoDB 'Sample Data' ๋ฒ„ํŠผ(#{prefix}-aws-sampleBtn) โ†’ ์—ด๋ฆฐ ํŒจ๋„ ๊ธฐ๋ก โ†’ Submit(์žˆ์œผ๋ฉด) โ†’ Result */
export async function createDynamoSample(page: Page, f: Frame, btn: string, api: () => string) {
  const { resultText, waitResult } = await import('./iv8-rdb');
  await f.locator(btn).click();
  await page.waitForTimeout(2500);
  note('sample-panel', (await f.locator('.offcanvas.show, .modal.show').first().innerText().catch(() => '(ํŒจ๋„ ์—†์Œ)')).replace(/\s+/g, ' ').slice(0, 400));
  const before = await resultText(page, f);
  const submit = f.locator('.offcanvas.show button, .modal.show button').filter({ hasText: /submit|create|confirm/i }).first();
  if (await submit.isVisible().catch(() => false)) await submit.click();
  const txt = await waitResult(page, f, before, 15 * 60_000);
  note('sample-api', api() || '(API ๊ธฐ๋ก ์—†์Œ)');
  return txt;
}

const note = (type: string, d: string) => test.info().annotations.push({ type, description: d.slice(0, 1500) });

/** Data Migrations > {item} > No-SQL ํƒญ์—์„œ credential(+Region) ์„ ํƒ๊นŒ์ง€ */
export async function openNosql(page: Page, c: Csp, item: 'Generate' | 'Migration' | 'Back Up' | 'Restore' = 'Generate', selectId = 'targetCredentialSelect'): Promise<Frame> {
  const f = await openDm(page, item);
  await f.getByText(/No.?SQL/i).first().click();
  await expect(f.locator(`select#${selectId}`)).toBeVisible({ timeout: 30_000 });
  await selectCred(page, f, c, selectId);
  await page.waitForTimeout(3000); // Instance List ์กฐํšŒ ๋Œ€๊ธฐ
  return f;
}

/** ํ™”๋ฉด์„ ์ƒˆ๋กœ ์—ด์–ด ํ–‰ ๋ฌธ๊ตฌ๋ฅผ ์ฝ๋Š”๋‹ค(ํ–‰์ด ๋๋‚ด ์—†์œผ๋ฉด '') */
export async function refreshNrdb(page: Page, c: Csp, id: string): Promise<string> {
  const f = await openNosql(page, c);
  for (let i = 0; i < 4; i++) {
    const t = await rdbRowText(f, id);
    if (t) return t;
    await page.waitForTimeout(3000);
  }
  return '';
}

/**
 * + Instance ๋กœ ์ƒ์„ฑ. ์ด๋ฏธ ์žˆ์œผ๋ฉด ๊ฑด๋„ˆ๋›ด๋‹ค(๋ฉฑ๋“ฑ).
 * GCP(Firestore)๋Š” Instance ID ๋งŒ, MongoDB(AlibabaยทNCP)๋Š” VersionยทInstance ClassยทUsernameยทPassword ๋„ ์ž…๋ ฅํ•˜๊ณ 
 * Create ์ง์ „์— ์‹ค์ œ ์ „์†ก๊ฐ’(์ˆจ์€ #dbi-instanceClass)์ด ์ง€์ • Class ์ธ์ง€ ํ™•์ธํ•œ๋‹ค(๋น„์šฉ ์•ˆ์ „์žฅ์น˜).
 */
export async function ensureNrdb(page: Page, f: Frame, spec: string | NrdbSpec, api: () => string) {
  const s: NrdbSpec = typeof spec === 'string' ? { id: spec } : spec;
  const id = s.id;
  const before = await rdbRowText(f, id);
  if (before) { note('nrdb-exists', `${id} ์ด๋ฏธ ์žˆ์Œ โ†’ ์ƒ์„ฑ ๊ฑด๋„ˆ๋œ€: ${before}`); return before; }
  await f.locator('#openCreateBtn').click();
  await expect(f.locator('#dbi-instanceId')).toBeVisible({ timeout: 15_000 });
  if (s.version) {
    await expect.poll(() => f.locator('select#dbi-engineVersion option').allTextContents().then((o) => o.map((x) => x.trim())), { timeout: 30_000 }).toContain(s.version);
    await f.locator('select#dbi-engineVersion').selectOption(s.version);
    await page.waitForTimeout(4000); // ๋ฒ„์ „ ๋ณ€๊ฒฝ ํ›„ Class ๊ธฐ๋ณธ๊ฐ’์ด ๋‹ค์‹œ ์ฑ„์›Œ์งˆ ๋•Œ๊นŒ์ง€
    await pickClass(f, s as RdbSpec);
  }
  await f.locator('#dbi-instanceId').fill(id);
  if (s.user) {
    await f.locator('#dbi-username').fill(s.user);
    await f.locator('#dbi-password').fill(RDB_PW);
    note('nrdb-username-help', await f.locator('#dbi-username-help').innerText().catch(() => ''));
  }
  if (s.classText) {
    await page.waitForTimeout(1500);
    const sent = await f.locator('#dbi-instanceClass').inputValue();
    note('nrdb-class-hidden', sent);
    expect(sent, `Create ์ „ Instance Class ๊ฐ’(์ง€์ • ${s.classText})`).toBe(s.classText);
    note('nrdb-form', `version=${s.version} class=${s.classText} id=${id} user=${s.user} storage=${await f.locator('#dbi-storage').inputValue().catch(() => '(์—†์Œ)')}`);
  }
  await f.locator('#dbi-createBtn').click();
  try {
    await expect.poll(() => rdbRowText(f, id), { timeout: 180_000, intervals: [3000, 5000] }).not.toBe('');
  } finally {
    note('nrdb-create-api', api() || '(API ๊ธฐ๋ก ์—†์Œ)');
  }
  const row = await rdbRowText(f, id);
  note('nrdb-row', row);
  return row;
}

// โ”€โ”€ ์—ฐ์‡„ ์‹œ๋‚˜๋ฆฌ์˜ค(TC-DATA-012~015) ๊ณตํ†ต (2026-10-14 DOM ์ ๊ฒ€) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//   ํ–‰ ํด๋ฆญ โ†’ ์ƒ๋‹จ 'Sample Data'(#openCreateBtn) โ†’ ํŒจ๋„ 'Create Sample Data': Database ID(= Instance ID, #nsp-databaseId ์ž๋™ ์ž…๋ ฅ) โ†’ #nsp-submitBtn
//   Back Up: ํ–‰ ํด๋ฆญ โ†’ #nrdbms-gcp-instanceIdยท#nrdbms-gcp-databaseId ์ž๋™ ์ž…๋ ฅ / Migration ์›๋ณธ: #src-nrdb-gcp-instanceIdยทdatabaseId

/** Instance List ์—์„œ ํ–‰ ์„ ํƒ(ํ–‰ ํด๋ฆญ) โ€” ์ด๋ฆ„ ์นธ ํด๋ฆญ */
export async function pickNrdbRow(page: Page, f: Frame, id: string, filledSel?: string) {
  const row = f.locator('table tbody tr').filter({ has: f.locator('td', { hasText: new RegExp(`^\\s*${id}\\s*$`) }) }).first();
  await expect(row, `${id} Instance List ํ–‰`).toBeVisible({ timeout: 60_000 });
  await refreshHostIfNeeded(page, f, id); // NCP Public Domain(์ฝ˜์†”์—์„œ ํ• ๋‹น) ํ›„ Host ๊ฐฑ์‹ 
  await row.locator('td').nth(2).click();
  await page.waitForTimeout(1500);
  if (filledSel) await expect(f.locator(filledSel), `${id} ์„ ํƒ ํ›„ ์ž๋™ ์ž…๋ ฅ`).not.toHaveValue('', { timeout: 15_000 });
  note('nrdb-picked', `${id}${filledSel ? ` ${filledSel}=${await f.locator(filledSel).inputValue()}` : ''}`);
}

/** Sample Data ํŒจ๋„(Database ID ์ž๋™ ์ž…๋ ฅ) โ†’ Submit โ†’ Result */
export async function createNosqlSample(page: Page, f: Frame, api: () => string) {
  const { resultText, waitResult } = await import('./iv8-rdb');
  await expect(f.locator('#openCreateBtn')).toHaveText(/Sample Data/i, { timeout: 15_000 });
  await f.locator('#openCreateBtn').click();
  await expect(f.locator('#nsp-databaseId')).toBeVisible({ timeout: 15_000 });
  note('sample-panel', `${(await f.locator('.offcanvas.show').innerText().catch(() => '')).replace(/\s+/g, ' ').slice(0, 200)} | databaseId=${await f.locator('#nsp-databaseId').inputValue()}`);
  const before = await resultText(page, f);
  await f.locator('#nsp-submitBtn').click();
  const txt = await waitResult(page, f, before, 15 * 60_000);
  note('sample-api', api() || '(API ๊ธฐ๋ก ์—†์Œ)');
  return txt;
}

/** ์ƒ์„ฑ ์ง„ํ–‰ ์ƒํƒœ๊ฐ€ ๋๋‚  ๋•Œ๊นŒ์ง€ ๋ชฉ๋ก์„ ๊ฐฑ์‹ ํ•˜๋ฉฐ ๋Œ€๊ธฐ โ†’ id ๋ณ„ ์ตœ์ข… ํ–‰ */
export async function waitNrdbReady(page: Page, c: Csp, ids: string[], maxMs = 15 * 60_000) {
  const cur: Record<string, string> = {};
  const t0 = Date.now();
  const pending = () => ids.filter((id) => !cur[id] || PENDING.test(cur[id]));
  while (pending().length && Date.now() - t0 < maxMs) {
    for (const id of pending()) cur[id] = (await refreshNrdb(page, c, id)) || cur[id] || '';
    if (pending().length) await page.waitForTimeout(20_000);
  }
  for (const id of ids) note('nrdb-final', `${id} โ€” ${Math.round((Date.now() - t0) / 1000)}s: ${cur[id]}`);
  return cur;
}

lib/iv8-os.ts

ํ•ด์‹œ 93c3f995 ยท 245์ค„

import { test, expect } from '@playwright/test';
import type { Page, Frame } from '@playwright/test';
import type { Csp } from './data';

// IV8 Object Storage(Data Migrations iframe :3300) ๊ณตํ†ต ๋™์ž‘. IV8 ์„ธ์…˜ ์ „์šฉ(2026-10-12).
// ํ™”๋ฉด id(2026-10-12 DOM ์ ๊ฒ€): credential select #targetCredentialSelect / #sourceCredentialSelect, + Bucket #openCreateBtn,
//   ์ƒ์„ฑ #bkt-create-input + #bkt-create-confirm, ์‚ญ์ œ ๋ชจ๋‹ฌ #bktDeleteModal(#bkt-delete-input + #bkt-delete-confirm), ์„ ํƒ ์‚ญ์ œ #deleteInstanceBtn

/** Data Migrations credential select ์˜ option value. Azure ๋Š” ๋ชฉ๋ก์— ์—†์Œ(2026-10-12). */
const DM_CRED: Record<string, string | null> = {
  AWS: 'aws', Azure: null, Alibaba: 'alibaba', GCP: 'gcp', Tencent: 'tencent', NHNCloud: 'nhn', NCP: 'ncp', IBM: 'ibm', KT: 'kt',
};
export const dmCred = (c: Csp) => DM_CRED[c.code] ?? null;
/**
 * Region ์ง€์ •๊ฐ’. credential ๋งŒ ๊ณ ๋ฅด๋ฉด Region ์ด ๋ชฉ๋ก ์ฒซ ๊ฐ’์œผ๋กœ ์ž๋™ ์„ ํƒ๋œ๋‹ค(2026-10-12: AWS โ†’ af-south-1).
 * ์‚ฌ์šฉ์ž ์ง€์‹œ(2026-10-12): ๋‹ค๋ฅธ ์‹œ๋‚˜๋ฆฌ์˜ค์˜ ์—ฐ๊ฒฐ ๋ฆฌ์ „๊ณผ ๋งž์ถ˜๋‹ค(2026-10-12 9๊ฐœ CSP ์‹คํ–‰์œผ๋กœ ํ™•์ธ, Azure ๋Š” credential ๋ชฉ๋ก์— ์—†์Œ). ๋นˆ ๊ฐ’์ด๋ฉด ์ž๋™ ์„ ํƒ ์œ ์ง€.
 */
const DM_REGION: Record<string, string> = { AWS: 'ap-northeast-2', Alibaba: 'ap-northeast-2', GCP: 'asia-northeast3', Tencent: 'ap-seoul', NHNCloud: 'kr1', NCP: 'kr', IBM: 'jp-tok', KT: 'kr1' };
export const dmRegion = (c: Csp) => DM_REGION[c.code] ?? '';
export const dmBlocker = (c: Csp) => (dmCred(c) ? '' : `${c.name}: Data Migrations credential ๋ชฉ๋ก์— ์—†์Œ`);
/** bucket ์ด๋ฆ„: mcmp-e2e-v07-os{n}{suffix}. MCMP ํ‘œ์‹œ ์ด๋ฆ„์ด๊ณ  ์‹ค์ œ CSP bucket ์ด๋ฆ„์€ MCMP uid(tbโ€ฆ)๋ผ ์ „์—ญ ๊ณ ์œ ์ผ ํ•„์š”๋Š” ์—†๋‹ค(2026-10-12 ํ™•์ธ) */
export const bucketName = (c: Csp | number, suffix = '') => `mcmp-e2e-v07-os${typeof c === 'number' ? c : c.n}${suffix}`;

const note = (type: string, d: string) => test.info().annotations.push({ type, description: d.slice(0, 1500) });

/** iframe ์ด ๋ถ€๋ฅด๋Š” API ์‘๋‹ต ๊ธฐ๋ก(์ •์  ํŒŒ์ผ ์ œ์™ธ). ๋ฐ˜ํ™˜ ํ•จ์ˆ˜๋กœ ๊บผ๋‚ด๋ฉด์„œ ๋น„์šด๋‹ค. */
export function apiRecorder(page: Page) {
  let buf: string[] = [];
  page.on('response', async (r) => {
    const u = r.url();
    if (!/:3300\//.test(u) || /\.(js|css|png|svg|woff2?|ico|map)(\?|$)/.test(u) || r.request().resourceType() === 'document') return;
    let body = '';
    try {
      body = (await r.text()).replace(/\s+/g, ' ');
      if (body.length > 1000) body = `${body.slice(0, 200)} โ€ฆ ${body.slice(-700)}`; // ๊ธด ๋กœ๊ทธ๋Š” ๋(์„ฑ๊ณต/์‹คํŒจ ์ค„)์ด ์ค‘์š”
    } catch { /* ๋ณธ๋ฌธ ์—†์Œ */ }
    buf.push(`${r.request().method()} ${r.status()} ${u.replace(/^https?:\/\/[^/]+/, '')} :: ${body}`);
  });
  return () => { const out = buf.join('\n'); buf = []; return out; };
}

/** ์‚ฌ์ด๋“œ ๋ฉ”๋‰ด Data Migrations > {item} ๋กœ ์ด๋™ํ•˜๊ณ  iframe Frame ์„ ๋Œ๋ ค์ค€๋‹ค. ๊ทธ๋ฃน ํด๋ฆญ์€ ํ† ๊ธ€์ด๋ผ ๋งํฌ๊ฐ€ ์•ˆ ๋ณด์ผ ๋•Œ๋งŒ ๋ˆ„๋ฅธ๋‹ค. */
export async function openDm(page: Page, item: 'Generate' | 'Migration' | 'Back Up' | 'Restore' | 'Log Management'): Promise<Frame> {
  const link = page.getByRole('link', { name: item, exact: true }).first();
  if (!(await link.isVisible().catch(() => false))) await page.getByText('Data Migrations', { exact: true }).first().click();
  await link.click();
  await page.locator('iframe').first().waitFor({ state: 'visible', timeout: 60_000 });
  let f: Frame | undefined;
  await expect.poll(() => (f = page.frames().find((x) => /:3300\//.test(x.url()))) ? f.url() : '', { timeout: 60_000 }).not.toBe('');
  await f!.locator('body').waitFor({ timeout: 60_000 });
  return f!;
}

/** Bucket List ์‘๋‹ต ์š”์•ฝ: bucket ๋ณ„ id / status / systemMessage / CSP ๋ฆฌ์†Œ์Šค id (์ „์ฒด JSON ์€ ๊ธธ์–ด์„œ ์ž˜๋ฆผ) */
export function summarizeBuckets(body: string) {
  try {
    const list: any[] = JSON.parse(body).objectStorage ?? [];
    if (!list.length) return '[] (bucket ์—†์Œ)';
    const pick = (o: any) => Object.fromEntries(Object.entries(o).filter(([k, v]) => /status|message|msg|error|reason|cspResource|region|created/i.test(k) && typeof v !== 'object'));
    return list.map((o) => `${o.id}: ${JSON.stringify(pick(o))}`).join(' | ');
  } catch { return body.slice(0, 300); }
}

/** credential ์„ ํƒ(Region ์ž๋™) โ†’ Bucket List ์กฐํšŒ ์‘๋‹ต๊นŒ์ง€ ๋Œ€๊ธฐ */
export async function selectCred(page: Page, f: Frame, c: Csp, selectId = 'targetCredentialSelect') {
  const v = dmCred(c)!;
  const resp = page.waitForResponse((r) => /\/objectstorage\/buckets\?/.test(r.url()) && r.url().includes(`filterVal=${v}`), { timeout: 60_000 }).catch(() => null);
  await f.locator(`select#${selectId}`).selectOption(v);
  const r = await resp;
  note('bucket-list', r ? `${r.status()} ${summarizeBuckets(await r.text().catch(() => ''))}` : '(Bucket List ์‘๋‹ต ์—†์Œ)');
  const regionSel = f.locator(`select#${selectId.replace('Credential', 'Region')}`);
  const current = () => regionSel.evaluate((s: HTMLSelectElement) => s.selectedOptions[0]?.value ?? '').catch(() => '');
  const auto = await current();
  const want = process.env.DM_REGION_AUTO ? '' : dmRegion(c); // DM_REGION_AUTO=1: ์ž๋™ ์„ ํƒ ์œ ์ง€(์˜ˆ: af-south-1 ์— ์ž˜๋ชป ๋งŒ๋“  bucket ์ •๋ฆฌ)
  if (want && auto !== want) {
    await regionSel.selectOption(want);
    await page.waitForTimeout(2000);
  }
  const region = await current();
  note('credential', `${v} / region ${region || '(๋ฏธ์„ ํƒ)'}${want ? ` (์ง€์ • ${want}, ์ž๋™ ์„ ํƒ๊ฐ’ ${auto || '-'})` : ' (์ž๋™ ์„ ํƒ)'}`);
  if (want) expect(region, 'Region ์ง€์ •').toBe(want);
}

/** Bucket List ์˜ ํ–‰(์ด๋ฆ„ ์…€ ์ •ํ™•ํžˆ ์ผ์น˜) */
export const bucketRow = (f: Frame, name: string) =>
  f.locator('table tbody tr').filter({ has: f.locator('td', { hasText: new RegExp(`^\\s*${name}\\s*$`) }) }).first();

export async function bucketRowText(f: Frame, name: string) {
  const row = bucketRow(f, name);
  return (await row.count()) ? (await row.innerText()).replace(/\s+/g, ' ').trim() : '';
}

/** + Bucket ์œผ๋กœ ์ƒ์„ฑ. ์ด๋ฏธ ์žˆ์œผ๋ฉด ์ƒ์„ฑํ•˜์ง€ ์•Š๊ณ  ๊ธฐ์กด ํ–‰์„ ๋Œ๋ ค์ค€๋‹ค(๋ฉฑ๋“ฑ). */
export async function ensureBucket(page: Page, f: Frame, name: string, api: () => string, createBtn = '#openCreateBtn') {
  // createBtn: Generate ๋Š” #openCreateBtn, Migration ์˜ Target ์€ #tgt-bkt-createBtn (์ƒ์„ฑ ๋ชจ๋‹ฌ์€ ๊ณต์šฉ #bkt-create-*)
  const before = await bucketRowText(f, name);
  if (before) { note('bucket-exists', `${name} ์ด๋ฏธ ์žˆ์Œ โ†’ ์ƒ์„ฑ ๊ฑด๋„ˆ๋œ€: ${before}`); return before; }
  await expect(f.locator(createBtn)).toBeEnabled({ timeout: 30_000 });
  await f.locator(createBtn).click();
  const input = f.locator('#bkt-create-input');
  await input.waitFor({ state: 'visible', timeout: 15_000 });
  await input.fill(name);
  await f.locator('#bkt-create-confirm').click();
  try {
    await expect.poll(() => bucketRowText(f, name), { timeout: 120_000, intervals: [2000, 5000] }).not.toBe('');
  } finally {
    note('bucket-create-api', api() || '(API ๊ธฐ๋ก ์—†์Œ)'); // ์‹คํŒจํ•ด๋„ ์‘๋‹ต(์˜ค๋ฅ˜ ์‚ฌ์œ )์„ ๋‚จ๊ธด๋‹ค
  }
  const row = await bucketRowText(f, name);
  note('bucket-row', row);
  return row;
}

/** Bucket ์‚ญ์ œ(ํ™•์ธ ํŒ์—…์— ์ด๋ฆ„ ์ž…๋ ฅ). ์—†์œผ๋ฉด false. */
export async function deleteBucket(page: Page, f: Frame, name: string, api: () => string) {
  const row = bucketRow(f, name);
  if (!(await row.count())) { note('bucket-missing', `${name} ๋ชฉ๋ก์— ์—†์Œ`); return false; }
  note('bucket-before-delete', await bucketRowText(f, name));
  // ํ–‰ ์•ˆ ์‚ญ์ œ ๋ฒ„ํŠผ์ด ์žˆ์œผ๋ฉด ๊ทธ๊ฒƒ์„, ์—†์œผ๋ฉด ์ฒดํฌ๋ฐ•์Šค ์„ ํƒ ํ›„ ์ƒ๋‹จ Delete
  const rowBtn = row.locator('button, a').filter({ hasText: /delete/i }).or(row.locator('button.btn-danger, button:has(i[class*="trash"]), button:has(svg)'));
  if (await rowBtn.count()) await rowBtn.first().click();
  else {
    // ์ฒดํฌ๋ฐ•์Šค ์นธ(td)์ด ํด๋ฆญ์„ ๊ฐ€๋กœ์ฑ„๋ฏ€๋กœ ์นธ์„ ๋ˆŒ๋Ÿฌ ์„ ํƒํ•˜๊ณ , ์„ ํƒ๋๋Š”์ง€ ํ™•์ธ ํ›„ ์ƒ๋‹จ Delete
    const cb = row.locator('input.row-select, input[type=checkbox]').first();
    await cb.locator('xpath=ancestor::td[1]').click();
    if (!(await cb.isChecked())) await cb.check({ force: true });
    await expect(cb).toBeChecked();
    await f.locator('#deleteInstanceBtn').click();
  }
  const modal = f.locator('#bktDeleteModal');
  await modal.waitFor({ state: 'visible', timeout: 15_000 });
  note('delete-modal', (await modal.innerText()).replace(/\s+/g, ' ').trim());
  await f.locator('#bkt-delete-input').fill(name);
  await f.locator('#bkt-delete-confirm').click();
  await expect.poll(() => bucketRowText(f, name), { timeout: 180_000, intervals: [2000, 5000] }).toBe('');
  note('bucket-delete-api', api() || '(API ๊ธฐ๋ก ์—†์Œ)');
  return true;
}

/** Target/Source Bucket ๋“œ๋กญ๋‹ค์šด์—์„œ ์ด๋ฆ„์ด ํฌํ•จ๋œ option ์„ ํƒ */
export async function selectBucket(f: Frame, name: string, selectId = 'newBucket') {
  const sel = f.locator(`select#${selectId}`);
  await expect.poll(() => sel.locator('option').allTextContents().then((o) => o.join(',')), { timeout: 30_000 }).toContain(name);
  const value = await sel.evaluate((s: HTMLSelectElement, n) => [...s.options].find((o) => (o.textContent ?? '').includes(n))!.value, name);
  await sel.selectOption(value);
  note('bucket-selected', `${selectId}=${value}`);
}

/**
 * bucket ์˜ ๊ฐ์ฒด ๋ชฉ๋ก(Objects ํ‘œ: Key / Size / Last Modified). Bucket List ํ–‰์˜ 'Browse objects'(.bkt-browse-btn) ๋กœ ์—ฐ๋‹ค.
 * ํŒจ๋„์ด ์•ˆ ์—ด๋ฆฌ๋ฉด null(ํ™”๋ฉด ๊ตฌ์กฐ ํ™•์ธ์šฉ ๊ธฐ๋ก๋งŒ).
 */
export async function readObjects(page: Page, f: Frame, name: string) {
  await f.locator(`button.bkt-browse-btn[data-name="${name}"]`).first().click();
  const table = f.locator('table').filter({ has: f.locator('thead', { hasText: /Key\s*Size\s*Last Modified/ }) }).first();
  const ok = await table.waitFor({ state: 'visible', timeout: 15_000 }).then(() => true, () => false);
  if (!ok) { note('objects', 'Objects ํŒจ๋„์ด ์—ด๋ฆฌ์ง€ ์•Š์Œ'); return null; }
  const refresh = f.locator('#bkt-object-refresh');
  if (await refresh.isVisible().catch(() => false)) { await refresh.click(); await page.waitForTimeout(3000); }
  const panel = (await table.locator('xpath=ancestor::*[contains(@class,"modal") or contains(@class,"offcanvas") or contains(@class,"card")][1]').innerText().catch(() => '')).replace(/\s+/g, ' ').trim();
  const rows = (await table.locator('tbody tr').allInnerTexts()).map((t) => t.replace(/\s+/g, ' ').trim()).filter((t) => t && t !== '-');
  note('objects', `${rows.length}ํ–‰(ํ‘œ์‹œ ๊ธฐ์ค€) | ${panel.slice(0, 300)} | ์˜ˆ: ${rows.slice(0, 5).join(' / ')}`);
  // ํŒจ๋„ ๋‹ซ๊ธฐ(๋ชจ๋‹ฌ์ด๋ฉด Esc)
  await page.keyboard.press('Escape').catch(() => {});
  return rows;
}

/** ๋ฐฑ์—… ์ด๋ฆ„: mcmp-e2e-v07-bk{n} */
export const backupName = (c: Csp) => `mcmp-e2e-v07-bk${c.n}`;

/** Backup List(Back UpยทRestore ํ™”๋ฉด) ์—์„œ ์ด๋ฆ„์ด ๋“ค์–ด๊ฐ„ ํ–‰ ํ…์ŠคํŠธ(์—†์œผ๋ฉด ๋นˆ ๊ฐ’) */
export async function backupRowText(f: Frame, name: string) {
  const row = f.locator('table tbody tr').filter({ hasText: name }).filter({ hasNot: f.locator('.bkt-browse-btn') }).first();
  return (await row.count()) ? (await row.innerText()).replace(/\s+/g, ' ').trim() : '';
}

/**
 * ๋ฐฑ์—… ์‚ญ์ œ(Back UpยทRestore ํ™”๋ฉด Backup List): ํ–‰ ์ฒดํฌ โ†’ #backup-deleteBtn โ†’ #backupDeleteModal ์— ์ด๋ฆ„ ์ž…๋ ฅ โ†’ #backup-delete-confirm.
 * ์—†์œผ๋ฉด false.
 */
export async function deleteBackup(page: Page, f: Frame, name: string, api: () => string) {
  const row = f.locator(`tr.backup-row[data-name="${name}"]`).first();
  if (!(await row.count())) { note('backup-missing', `${name} Backup List ์— ์—†์Œ`); return false; }
  note('backup-before-delete', await backupRowText(f, name));
  const cb = row.locator('input[type=checkbox]').first();
  await cb.locator('xpath=ancestor::td[1]').click(); // ์ฒดํฌ ์นธ(td)์ด ํด๋ฆญ์„ ๋ฐ›๋Š”๋‹ค(bucket ๋ชฉ๋ก๊ณผ ๊ฐ™์€ ๊ตฌ์กฐ๋กœ ๊ฐ€์ •)
  if (!(await cb.isChecked())) await cb.check({ force: true });
  await expect(cb).toBeChecked();
  await expect(f.locator('#backup-deleteBtn')).toBeEnabled({ timeout: 10_000 });
  await f.locator('#backup-deleteBtn').click();
  const modal = f.locator('#backupDeleteModal');
  await modal.waitFor({ state: 'visible', timeout: 15_000 });
  note('backup-delete-modal', (await modal.innerText()).replace(/\s+/g, ' ').trim());
  await f.locator('#backup-delete-input').fill(name);
  await f.locator('#backup-delete-confirm').click();
  await expect.poll(() => backupRowText(f, name), { timeout: 180_000, intervals: [2000, 5000] }).toBe('');
  note('backup-delete-api', api() || '(API ๊ธฐ๋ก ์—†์Œ)');
  return true;
}

/** ๋‹จ๊ณ„ํ˜•(accordion) ํ™”๋ฉด์˜ Next: data-current ๊ฐ€ ํ•ด๋‹น ๋‹จ๊ณ„์ธ ๋ฒ„ํŠผ */
export async function nextStep(page: Page, f: Frame, current: '#collapseOne' | '#collapseTwo' | '#collapseThree') {
  await f.locator(`button[data-current="${current}"]`).click();
  await page.waitForTimeout(1500);
}

/** Diagnose ๋ฒ„ํŠผ โ†’ Duration / Object / Object Count ๊ฐ’์ด ์ฑ„์›Œ์งˆ ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ ํ›„ ๋ฐ˜ํ™˜ */
export async function diagnose(page: Page, f: Frame, side: 'src' | 'tgt') {
  await f.locator(`#${side}-diag-btn`).click();
  const val = (k: string) => f.locator(`#${side}-diag-${k}`).inputValue().catch(() => '');
  await expect.poll(() => val('count'), { timeout: 10 * 60_000, intervals: [2000, 5000] }).not.toBe('');
  const out = { duration: await val('duration'), object: await val('object'), count: await val('count') };
  note(`diag-${side}`, JSON.stringify(out));
  return out;
}

/** bucket ์˜ ํ•˜์œ„ ํด๋” ๋ชฉ๋ก: Browse objects โ†’ Objects ํ‘œ์—์„œ path ์˜ ํด๋”๋ฅผ ์ฐจ๋ก€๋กœ ๋ˆŒ๋Ÿฌ ๋“ค์–ด๊ฐ„ ๋’ค ํ–‰ ํ…์ŠคํŠธ */
export async function listFolder(page: Page, f: Frame, name: string, path: string) {
  await f.locator(`button.bkt-browse-btn[data-name="${name}"]`).first().click();
  const table = f.locator('table').filter({ has: f.locator('thead', { hasText: /Key\s*Size\s*Last Modified/ }) }).first();
  await table.waitFor({ state: 'visible', timeout: 15_000 });
  await page.waitForTimeout(2000);
  for (const seg of path.split('/').filter(Boolean)) {
    await table.locator('tbody tr td:first-child', { hasText: seg }).first().click();
    await page.waitForTimeout(3000);
  }
  const rows = (await table.locator('tbody tr').allInnerTexts()).map((t) => t.replace(/\s+/g, ' ').trim()).filter((t) => t && t !== '-');
  note(`objects ${name}/${path}`, rows.slice(0, 15).join(' / '));
  await page.keyboard.press('Escape').catch(() => {});
  return rows;
}

/** Log Management ์—์„œ ์„œ๋น„์Šค/์ž‘์—… ํ•„ํ„ฐ๋กœ ์ตœ๊ทผ ์ด๋ ฅ ์กฐํšŒ โ†’ ํ–‰ ํ…์ŠคํŠธ(์ตœ์‹  ์ˆœ ํ‘œ์‹œ ๊ธฐ์ค€) */
export async function logHistory(page: Page, service: 'Object Storage' | 'SQL Database' | 'No-SQL', op: string) {
  const f = await openDm(page, 'Log Management');
  await f.locator('select#filter-service-type').selectOption({ label: service });
  await f.locator('select#filter-operation').selectOption({ label: op });
  await f.locator('#log-search-btn').click();
  await page.waitForTimeout(4000);
  // ํ–‰: # / Service Type / Operation / Provider / Region / Resource / Duration / Result / Finished (Finished ๋‚ด๋ฆผ์ฐจ์ˆœ)
  const rows = (await f.locator('#log-table-body tr').allInnerTexts()).map((t) => t.replace(/\s+/g, ' ').trim()).filter(Boolean);
  note(`log-${op}`, rows.slice(0, 5).join(' / ') || '(ํ–‰ ์—†์Œ)');
  return rows;
}

lib/iv8-rdb.ts

ํ•ด์‹œ 0da9732e ยท 327์ค„

import { test, expect } from '@playwright/test';
import type { Page, Frame } from '@playwright/test';
import type { Csp } from './data';
import { openDm, selectCred } from './iv8-os';

// IV8 SQL Database(RDBMS) ๊ณตํ†ต ๋™์ž‘. Data Migrations(iframe :3300) > Generate > SQL Database ํƒญ. (2026-10-13 DOM ์ ๊ฒ€)
//   + Instance #openCreateBtn โ†’ #dbi-engine, #dbi-engineVersion, Instance Class ์„ ํƒ๊ธฐ(#dbi-icTrigger โ†’ #dbi-icFamilyList โ†’ #dbi-icSizeList),
//   #dbi-instanceId, #dbi-storage, #dbi-username, #dbi-password, ์ƒ์„ฑ #dbi-createBtn / ์‚ญ์ œ #deleteInstanceBtn โ†’ ๋ชจ๋‹ฌ #dbi-delete-input + #dbi-delete-confirm
//   credential ๋ชฉ๋ก์— ์žˆ๋Š” CSP: alibaba, aws, gcp, ncp (2026-10-15 ์‚ฌ์šฉ์ž ๋Œ€์ƒํ‘œ: 4๊ฐœ ๋ชจ๋‘ ๋Œ€์ƒ โ€” RDB_CSPS)

export interface RdbSpec { engine: string; version: string; family: string; size: string; id: string; user: string; classText: string }
/**
 * CSP ๋ณ„ ์ž…๋ ฅ๊ฐ’ โ€” Drive mc-data-manager ์‹œํŠธ TC-DATA-018 ๊ธฐ์ค€(2026-10-14).
 * AWS: mysql 8.4.11 / db.m5.large / mcmp โ€” rdb1aws(GenerateยทBackup ์›๋ณธ) + rdb3aws(Migration ๋Œ€์ƒ) 2๊ฐœ.
 * GCP: MYSQL 8_4 / Class 'db.g1-small'(ํ™”๋ฉด ํ‘œ๊ธฐ db-g1-small) / Username ์€ 'root' ๋งŒ ํ—ˆ์šฉ('Only root is allowed') โ€” rdb2gpc(Restore ๋Œ€์ƒ).
 */
export const RDBS: Record<string, RdbSpec[]> = {
  AWS: [
    { engine: 'mysql', version: '8.4.11', family: 'db.m5', size: 'large', id: 'mcmp-e2e-v07-rdb1aws', user: 'mcmp', classText: 'db.m5.large' },
    { engine: 'mysql', version: '8.4.11', family: 'db.m5', size: 'large', id: 'mcmp-e2e-v07-rdb3aws', user: 'mcmp', classText: 'db.m5.large' },
  ],
  GCP: [
    { engine: 'MYSQL', version: '8_4', family: 'db-g1', size: 'small', id: 'mcmp-e2e-v07-rdb2gpc', user: 'root', classText: 'db-g1-small' },
    // 2026-10-15 ์‚ฌ์šฉ์ž ๋Œ€์ƒํ‘œ(RDB = AWSยทGCPยทAlibabaยทNCP ์ „๋ถ€) โ€” GCP ๋‹จ๋… ํ๋ฆ„์˜ MigrationยทRestore ๋Œ€์ƒ
    { engine: 'MYSQL', version: '8_4', family: 'db-g1', size: 'small', id: 'mcmp-e2e-v07-rdb4gcp', user: 'root', classText: 'db-g1-small' },
  ],
  // 2026-10-15 ์‚ฌ์šฉ์ž ์ง€์‹œ: ์ตœ์†Œ ์‚ฌ์–‘. Alibaba MySQL 8.4(์‚ฌ์šฉ์ž ์ง€์ •) / mysql.n2.small.1 โ€” ๋ชฉ๋ก ์ตœ์†Œ์ธ mysql.n1.micro.1 ์€ ์ƒ์„ฑ ์‹œ
  //   'The specified instance type is no longer provided' ๋กœ ๊ฑฐ๋ถ€๋ผ(2026-10-15) ๊ทธ ๋‹ค์Œ ์ตœ์†Œ(1C2G ๊ธฐ๋ณธํ˜•)๋กœ ๋ณ€๊ฒฝ. NCP mysql 8.4.11 / HICPU C002.M004.
  //   mysql.n2.small.1 ๋„ ๊ฐ™์€ ์˜ค๋ฅ˜๋กœ ๊ฑฐ๋ถ€ โ†’ ์‚ฌ์šฉ์ž ์ง€์ • mysql.n2.medium.1(family mysql.n2 โ†’ size medium.1). 8.4 ๋ชฉ๋ก ๊ธฐ๋ณธ๊ฐ’์€ myduck.n2.2xlarge.1(๋Œ€ํ˜•) โ€” ๋ฐ˜๋“œ์‹œ ๋ฐ”๊ฟ”์•ผ ํ•จ
  //   NCP Instance ID ๋Š” 8์ž ์ดํ•˜('For NCP, use 8 characters or fewer (a unique suffix is added on creation)') โ†’ mcmprdb1ยทmcmprdb2
  Alibaba: [
    { engine: 'MySQL', version: '8.4', family: 'mysql.n2', size: 'medium.1', id: 'mcmp-e2e-v07-rdb1ali', user: 'mcmp', classText: 'mysql.n2.medium.1' },
    { engine: 'MySQL', version: '8.4', family: 'mysql.n2', size: 'medium.1', id: 'mcmp-e2e-v07-rdb2ali', user: 'mcmp', classText: 'mysql.n2.medium.1' },
  ],
  NCP: [
    { engine: 'mysql', version: '8.4.11', family: 'SVR.VDBAS.AMD.HICPU', size: 'C002.M004.NET.SSD.B050.G003', id: 'mcmprdb1', user: 'mcmp', classText: 'SVR.VDBAS.AMD.HICPU.C002.M004.NET.SSD.B050.G003' },
    { engine: 'mysql', version: '8.4.11', family: 'SVR.VDBAS.AMD.HICPU', size: 'C002.M004.NET.SSD.B050.G003', id: 'mcmprdb2', user: 'mcmp', classText: 'SVR.VDBAS.AMD.HICPU.C002.M004.NET.SSD.B050.G003' },
  ],
};
export const RDB_PW = process.env.RDB_PW ?? '<RDB_PW>';
export const rdbsOf = (c: Csp) => RDBS[c.code];
/** ์—ฐ์‡„ ์‹œ๋‚˜๋ฆฌ์˜ค(TC-DATA-008~011)์—์„œ ์“ฐ๋Š” ์—ญํ• ๋ณ„ ์ธ์Šคํ„ด์Šค */
export const RDB_SRC = RDBS.AWS[0];  // GenerateยทMigration ์›๋ณธยทBackup ์›๋ณธ
export const RDB_MIG = RDBS.AWS[1];  // Migration ๋Œ€์ƒ
export const RDB_RST = RDBS.GCP[0];  // Restore ๋Œ€์ƒ
export const RDB_DB = 'LibraryManagement_0'; // ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ๋กœ ์ƒ์„ฑ๋˜๋Š” DB
/** DB ์ด๋ฆ„์€ ๋Œ€์†Œ๋ฌธ์ž ๋ฌด์‹œ๋กœ ๋น„๊ต โ€” Alibaba MySQL ์€ ์†Œ๋ฌธ์ž๋กœ ๋งŒ๋“ ๋‹ค(librarymanagement_0, 2026-10-15) */
export const hasRdbDb = (names: string[]) => names.some((n) => n.toLowerCase() === RDB_DB.toLowerCase());
/** select ์—์„œ RDB_DB ์™€ ๋Œ€์†Œ๋ฌธ์ž ๋ฌด์‹œ๋กœ ๊ฐ™์€ option ์„ ๊ณจ๋ผ ์„ ํƒํ•˜๊ณ  ๊ทธ ๊ฐ’์„ ๋Œ๋ ค์ค€๋‹ค */
export async function selectRdbDb(f: Frame, sel: string) {
  const v = await f.locator(`${sel} option`).evaluateAll((os, d) => (os as HTMLOptionElement[]).map((o) => o.value).find((x) => x.toLowerCase() === d.toLowerCase()) || '', RDB_DB);
  await f.locator(sel).selectOption(v || RDB_DB);
  return v || RDB_DB;
}
export const RDB_BACKUP = 'mcmp-e2e-v07-bk2';
/**
 * CSP ๋‹จ๋… ํ๋ฆ„(2026-10-15 ์‚ฌ์šฉ์ž ๋Œ€์ƒํ‘œ): ์›๋ณธ src ์— ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ โ†’ srcโ†’mig ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ โ†’ src ๋ฐฑ์—… โ†’ ๊ฐ™์€ CSP ์˜ rst ๋กœ ๋ณต๊ตฌ.
 * AWS ๋Š” ๊ธฐ์กด ์ด๋ฆ„(rdb1awsโ†’rdb3aws, bk2)์„ ๊ทธ๋Œ€๋กœ ์“ฐ๊ณ  ๋ณต๊ตฌ ๋Œ€์ƒ๋งŒ rdb3aws. (2026-10-14 ๊นŒ์ง€์˜ GCP ๋ณต๊ตฌ๋Š” AWS ๋ฐฑ์—… โ†’ rdb2gpc)
 */
export interface RdbFlow { src: RdbSpec; mig: RdbSpec; rst: RdbSpec; backup: string }
export const RDB_FLOW: Record<string, RdbFlow> = {
  // AWS ๋ฐฑ์—…: 2026-10-14 ์˜ 'mcmp-e2e-v07-bk2' ๊ฐ€ ๋‚จ์•„ ์žˆ์–ด(S13 ์‹คํŒจ) ํ๋ฆ„์šฉ์€ bk2aws ๋กœ ๋ถ„๋ฆฌ. ์˜› bk2 ๋Š” S10 AWS ์—์„œ ํ•จ๊ป˜ ์ •๋ฆฌ
  AWS: { src: RDBS.AWS[0], mig: RDBS.AWS[1], rst: RDBS.AWS[1], backup: 'mcmp-e2e-v07-bk2aws' },
  GCP: { src: RDBS.GCP[0], mig: RDBS.GCP[1], rst: RDBS.GCP[1], backup: 'mcmp-e2e-v07-bk2gcp' },
  Alibaba: { src: RDBS.Alibaba[0], mig: RDBS.Alibaba[1], rst: RDBS.Alibaba[1], backup: 'mcmp-e2e-v07-bk2ali' },
  NCP: { src: RDBS.NCP[0], mig: RDBS.NCP[1], rst: RDBS.NCP[1], backup: 'mcmp-e2e-v07-bk2ncp' },
};
export const rdbFlowOf = (c: Csp) => RDB_FLOW[c.code];
/** ๋Œ€์ƒํ‘œ CSP(RDB) */
export const RDB_CSPS = ['AWS', 'GCP', 'Alibaba', 'NCP'];

const note = (type: string, d: string) => test.info().annotations.push({ type, description: d.slice(0, 1500) });

/** Data Migrations > Generate > SQL Database ํƒญ์—์„œ credential(+Region) ์„ ํƒ๊นŒ์ง€ */
export async function openSql(page: Page, c: Csp): Promise<Frame> {
  const f = await openDm(page, 'Generate');
  await f.getByText(/SQL Database/i).first().click();
  await expect(f.locator('#openCreateBtn')).toBeVisible({ timeout: 30_000 });
  await selectCred(page, f, c, 'targetCredentialSelect');
  await page.waitForTimeout(3000); // Instance List ์กฐํšŒ ๋Œ€๊ธฐ
  return f;
}

/** Instance List ์˜ ํ–‰(์ด๋ฆ„ ์…€ ์ •ํ™•ํžˆ ์ผ์น˜). ๋ชฉ๋ก์ด ๋น„๋ฉด 'No instances found' ํ–‰๋งŒ ์žˆ๋‹ค. */
export const rdbRow = (f: Frame, id: string) =>
  f.locator('table tbody tr').filter({ has: f.locator('td', { hasText: new RegExp(`^\\s*${id}\\s*$`) }) }).first();
export async function rdbRowText(f: Frame, id: string) {
  const row = rdbRow(f, id);
  return (await row.count()) ? (await row.innerText()).replace(/\s+/g, ' ').trim() : '';
}
/**
 * ํ™”๋ฉด์„ ์ƒˆ๋กœ ์—ด์–ด(๋ชฉ๋ก ์žฌ์กฐํšŒ) ํ–‰ ๋ฌธ๊ตฌ๋ฅผ ๋Œ๋ ค์ค€๋‹ค. ์ƒˆ Frame ๋„ ํ•จ๊ป˜ ๋ฐ˜ํ™˜ โ€” ์ด์ „ Frame ์€ ์“ฐ์ง€ ์•Š๋Š”๋‹ค.
 * credential ์„ noneโ†’์žฌ์„ ํƒํ•˜๋Š” ๋ฐฉ์‹์€ ์ด์ „ ๋ชฉ๋ก์ด DOM ์— ๋‚จ์•„ ๋‚ก์€ ๊ฐ’(backing-up)์„ ์ฝ์—ˆ๋‹ค(2026-10-13 AWS 25๋ถ„ ๋Œ€๊ธฐ ํ›„ ์‹คํŒจ).
 * ํ–‰์ด ๋๋‚ด ์•ˆ ๋ณด์ด๋ฉด text '' (์‚ญ์ œ ์™„๋ฃŒ ๋˜๋Š” ์กฐํšŒ ์‹คํŒจ).
 */
export async function refreshRow(page: Page, c: Csp, id: string): Promise<{ f: Frame; text: string }> {
  const f = await openSql(page, c);
  for (let i = 0; i < 4; i++) {
    const t = await rdbRowText(f, id);
    if (t) return { f, text: t };
    await page.waitForTimeout(3000);
  }
  return { f, text: '' };
}

/** Instance Class ์„ ํƒ๊ธฐ์—์„œ family โ†’ size ์„ ํƒ ํ›„ ํ‘œ์‹œ ๋ฌธ๊ตฌ ํ™•์ธ */
export async function pickClass(f: Frame, r: RdbSpec) {
  await f.locator('#dbi-icTrigger').click();
  // family ์ด๋ฆ„์ด ์ •ํ™•ํžˆ ๊ฐ™์€ ํ•ญ๋ชฉ์ด ์—†์œผ๋ฉด(CSP ๋งˆ๋‹ค family/size ๋‚˜๋ˆ„๋Š” ๋ฐฉ์‹์ด ๋‹ค๋ฆ„ โ€” ์˜ˆ Alibaba mysql.n1.micro.1)
  // classText ์•ž๋ถ€๋ถ„๊ณผ ์ผ์น˜ํ•˜๋Š” family ๋ฅผ ๊ณ ๋ฅด๊ณ , ๋‚˜๋จธ์ง€๋ฅผ size ๋กœ ๋ณธ๋‹ค
  const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  let fam = f.locator('#dbi-icFamilyList > *').filter({ hasText: new RegExp(`^\\s*${esc(r.family)}\\s*$`) }).first();
  let size = r.size;
  if (!(await fam.count())) {
    const names = (await f.locator('#dbi-icFamilyList > *').allInnerTexts()).map((s) => s.trim());
    const hit = names.filter((n) => r.classText.startsWith(`${n}.`)).sort((a, b) => b.length - a.length)[0];
    note('rdb-class-family', `์ง€์ • family ${r.family} ์—†์Œ โ†’ ๋ชฉ๋ก [${names.join(', ')}] ์ค‘ ${hit ?? '(์—†์Œ)'}`);
    expect(hit, `Instance Class family(${r.classText})`).toBeTruthy();
    fam = f.locator('#dbi-icFamilyList > *').filter({ hasText: new RegExp(`^\\s*${esc(hit!)}\\s*$`) }).first();
    size = r.classText.slice(hit!.length + 1);
  }
  await fam.click();
  await f.locator('#dbi-icSizeList > *').filter({ hasText: new RegExp(`^\\s*${esc(size)}\\s*$`) }).first().click();
  await expect(f.locator('#dbi-icTriggerText'), 'Instance Class ํ‘œ์‹œ').toHaveText(r.classText);
}

// NCP ๋Š” ์ƒ์„ฑ ์งํ›„ 'creating' โ†’ 'settingup' โ†’ 'running' (2026-10-15: settingup ์„ ์™„๋ฃŒ๋กœ ๋ณด๊ณ  ๋‹ค์Œ ๋‹จ๊ณ„๊ฐ€ ์‹คํŒจ)
export const PENDING = /creat|pending|provision|processing|starting|modif|configur|backing|setting/i;

/** + Instance ๋กœ ์ƒ์„ฑ. ์ด๋ฏธ ์žˆ์œผ๋ฉด ๊ฑด๋„ˆ๋›ด๋‹ค(๋ฉฑ๋“ฑ). ์ƒ์„ฑ ์š”์ฒญ ํ›„ ํ–‰์ด ๋ณด์ผ ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ */
export async function ensureRdb(page: Page, f: Frame, c: Csp, r: RdbSpec, api: () => string) {
  const before = await rdbRowText(f, r.id);
  if (before) { note('rdb-exists', `${r.id} ์ด๋ฏธ ์žˆ์Œ โ†’ ์ƒ์„ฑ ๊ฑด๋„ˆ๋œ€: ${before}`); return before; }
  await f.locator('#openCreateBtn').click();
  await expect(f.locator('select#dbi-engine')).toBeVisible({ timeout: 15_000 });
  await f.locator('select#dbi-engine').selectOption(r.engine);
  // Alibaba: ์—”์ง„ ์„ ํƒ ๋•Œ 5.7 ๋ชฉ๋ก ํ˜ธ์ถœ ์งํ›„ ๋ฒ„์ „์„ ๋ฐ”๊พธ๋ฉด Class ๋ชฉ๋ก ํ˜ธ์ถœ์ด Throttling.User ๋กœ ๋ง‰ํ˜€ 5.7 ๋ชฉ๋ก์ด ๋‚จ๋Š”๋‹ค โ†’ 60์ดˆ ๋Œ€๊ธฐ(2026-10-15)
  if (c.code === 'Alibaba') await page.waitForTimeout(60_000);
  await expect.poll(() => f.locator('select#dbi-engineVersion option').allTextContents().then((o) => o.map((s) => s.trim())), { timeout: 30_000 }).toContain(r.version);
  await f.locator('select#dbi-engineVersion').selectOption(r.version); // ์ž๋™ ์„ ํƒ๊ฐ’(์˜ˆ: AWS 5.7.44)์€ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค
  // ๋ฒ„์ „์„ ๋ฐ”๊พธ๋ฉด Instance Class ๊ฐ€ ๋น„๋™๊ธฐ๋กœ ๊ธฐ๋ณธ๊ฐ’(AWS db.m5.12xlarge)์œผ๋กœ ๋‹ค์‹œ ์ฑ„์›Œ์ง„๋‹ค(2026-10-13: ๋จผ์ € ๊ณจ๋ผ ๋‘๋ฉด ๋ฎ์–ด์จ์ ธ 12xlarge ๋กœ ์ƒ์„ฑ๋จ).
  // ๊ทธ๋ž˜์„œ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๋‹ค์‹œ ์ฑ„์›Œ์งˆ ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆฐ ๋’ค์— ์„ ํƒํ•œ๋‹ค.
  await page.waitForTimeout(4000);
  await pickClass(f, r);
  await f.locator('#dbi-instanceId').fill(r.id);
  await f.locator('#dbi-username').fill(r.user);
  await f.locator('#dbi-password').fill(RDB_PW);
  // ๋น„์šฉ ์•ˆ์ „์žฅ์น˜: Create ์ง์ „์— ์‹ค์ œ ์ „์†ก๊ฐ’(์ˆจ์€ #dbi-instanceClass)์ด ์ง€์ •ํ•œ Class ์ธ์ง€ ํ™•์ธํ•˜๊ณ  ์•„๋‹ˆ๋ฉด ์ค‘๋‹จ
  await page.waitForTimeout(1500);
  const sent = await f.locator('#dbi-instanceClass').inputValue();
  note('rdb-class-hidden', sent);
  expect(sent, `Create ์ „ Instance Class ๊ฐ’(์ง€์ • ${r.classText})`).toBe(r.classText);
  note('rdb-form', `engine=${r.engine} version=${r.version} class=${r.classText} id=${r.id} user=${r.user} storage=${await f.locator('#dbi-storage').inputValue()}`);
  await f.locator('#dbi-createBtn').click();
  try {
    await expect.poll(() => rdbRowText(f, r.id), { timeout: 180_000, intervals: [3000, 5000] }).not.toBe('');
  } finally {
    note('rdb-create-api', api() || '(API ๊ธฐ๋ก ์—†์Œ)');
  }
  const row = await rdbRowText(f, r.id);
  note('rdb-row', row);
  return row;
}

// โ”€โ”€ ์—ฐ์‡„ ์‹œ๋‚˜๋ฆฌ์˜ค(TC-DATA-008~011) ๊ณตํ†ต (2026-10-14 DOM ์ ๊ฒ€) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//   ์ธ์Šคํ„ด์Šค ํ–‰์„ ํด๋ฆญํ•˜๋ฉด ์ƒ๋‹จ ๋ฒ„ํŠผ์ด 'Sample Data'(#openCreateBtn, Back Up ์€ #bak-rdbms-createBtn, Migration ์›๋ณธ์€ #src-rdbms-createBtn)๋กœ ๋ฐ”๋€๋‹ค
//   Sample Data ํŒจ๋„: 'Create Sample Data' โ€” #nsp-username(GCP ๋Š” root ๋งŒ) / #nsp-password / #nsp-submitBtn
//   Back UpยทMigration ์›๋ณธ: ํ–‰ ํด๋ฆญ โ†’ #mysql-srcHostยท#mysql-srcPort ์ž๋™ ์ž…๋ ฅ โ†’ #mysql-srcUsernameยท#mysql-srcPassword โ†’ Load Databases

/**
 * NCP ๋Š” Public Domain ์„ NCP ์ฝ˜์†”(DB ๊ด€๋ฆฌ > Public ๋„๋ฉ”์ธ ๊ด€๋ฆฌ)์—์„œ ๋”ฐ๋กœ ํ• ๋‹นํ•ด์•ผ Host ๊ฐ€ ์ƒ๊ธด๋‹ค(์›น ์ฝ˜์†” ์•ˆ๋‚ด 'NCP Public Domain โ€ฆ click Refresh Host').
 * ํ–‰์˜ Host ์นธ์ด '-' ์ด๊ณ  'Refresh Host' ๋ฒ„ํŠผ์ด ๋ณด์ด๋ฉด ๋ˆŒ๋Ÿฌ์„œ ์ตœ์‹  Host ๋ฅผ ๋ถˆ๋Ÿฌ์˜จ๋‹ค. (2026-10-15, ํ• ๋‹น์€ ์‚ฌ์šฉ์ž ์ˆ˜ํ–‰)
 */
export async function refreshHostIfNeeded(page: Page, f: Frame, id: string) {
  const row = rdbRow(f, id);
  const text = (await row.innerText().catch(() => '')).replace(/\s+/g, ' ');
  if (!/\s-\s/.test(` ${text} `)) return;
  const btn = f.getByRole('button', { name: /refresh host/i }).first();
  const visible = await btn.isVisible().catch(() => false);
  note('host-missing', `${id} Host '-' โ€” Refresh Host ๋ฒ„ํŠผ ${visible ? 'ํด๋ฆญ' : '์—†์Œ'}: ${text}`);
  if (!visible) return;
  await btn.click();
  await expect.poll(async () => (await row.innerText().catch(() => '')).replace(/\s+/g, ' '), { timeout: 60_000, intervals: [3000] }).not.toBe(text).catch(() => {});
  note('host-refreshed', (await row.innerText().catch(() => '')).replace(/\s+/g, ' '));
}

/** Instance List ์—์„œ ํ–‰ ์„ ํƒ(ํ–‰ ํด๋ฆญ). ์„ ํƒ ํ›„ Host ์ž…๋ ฅ๋ž€์ด ์ฑ„์›Œ์ง€๋Š” ํ™”๋ฉด์€ ๊ทธ ๊ฐ’๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆฐ๋‹ค. */
export async function pickRdbRow(page: Page, f: Frame, id: string, hostSel?: string) {
  await expect(rdbRow(f, id), `${id} Instance List ํ–‰`).toBeVisible({ timeout: 60_000 });
  await refreshHostIfNeeded(page, f, id);
  await rdbRow(f, id).locator('td').nth(2).click();
  await page.waitForTimeout(1500);
  if (hostSel) await expect(f.locator(hostSel), `${id} ์„ ํƒ ํ›„ Host ์ž๋™ ์ž…๋ ฅ`).not.toHaveValue('', { timeout: 15_000 });
  note('rdb-picked', `${id} ${hostSel ? `host=${await f.locator(hostSel).inputValue()}` : ''}`);
}

/** ํ•˜๋‹จ Result ์˜์—ญ ๋ฌธ๊ตฌ(์ ‘ํ˜€ ์žˆ์œผ๋ฉด ํŽผ์นœ๋‹ค). ๋กœ๊ทธ๋Š” textarea ๊ฐ’์ด๋ผ innerText ๋กœ๋Š” ๋น„์–ด ๋ณด์ธ๋‹ค. */
export async function resultText(page: Page, f: Frame) {
  const box = f.locator('#resultCollapse');
  if (!(await box.isVisible().catch(() => false))) await f.locator('[data-bs-target="#resultCollapse"]').click().catch(() => {});
  await page.waitForTimeout(800);
  const ta = box.locator('textarea').first();
  const v = (await ta.count()) ? await ta.inputValue().catch(() => '') : await box.innerText().catch(() => '');
  return v.replace(/\s+/g, ' ').trim();
}

/** Result ์˜์—ญ์— ์„ฑ๊ณต/์‹คํŒจ ๋ฌธ๊ตฌ๊ฐ€ ๋‚˜์˜ฌ ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ(์‹คํ–‰ ์ „ ๋ฌธ๊ตฌ์™€ ๋‹ฌ๋ผ์งˆ ๋•Œ๊นŒ์ง€) */
export async function waitResult(page: Page, f: Frame, before: string, maxMs: number) {
  let txt = before;
  await expect.poll(async () => (txt = await resultText(page, f)) !== before && /success|complete|fail|error/i.test(txt), { timeout: maxMs, intervals: [3000, 5000, 10_000] })
    .toBe(true).catch(() => {});
  note('result', txt || '(Result ๋น„์–ด ์žˆ์Œ)');
  return txt;
}

/** Sample Data ํŒจ๋„ ์ž…๋ ฅ โ†’ Submit โ†’ Result */
export async function createSampleData(page: Page, f: Frame, r: RdbSpec, api: () => string, openBtn = '#openCreateBtn') {
  await expect(f.locator(openBtn)).toHaveText(/Sample Data/i, { timeout: 15_000 });
  await f.locator(openBtn).click();
  await expect(f.locator('#nsp-username')).toBeVisible({ timeout: 15_000 });
  note('sample-panel', (await f.locator('.offcanvas.show').innerText().catch(() => '')).replace(/\s+/g, ' ').slice(0, 300));
  await f.locator('#nsp-username').fill(r.user);
  await f.locator('#nsp-password').fill(RDB_PW);
  const before = await resultText(page, f);
  await f.locator('#nsp-submitBtn').click();
  const txt = await waitResult(page, f, before, 15 * 60_000);
  note('sample-api', api() || '(API ๊ธฐ๋ก ์—†์Œ)');
  return txt;
}

/** ์›๋ณธ ์ ‘์†์ •๋ณด ์ž…๋ ฅ โ†’ Load Databases โ†’ ํ™”๋ฉด์— ํ‘œ์‹œ๋œ DB ์ด๋ฆ„ ๋ชฉ๋ก(select option ๋˜๋Š” ์ฒดํฌ ๋ชฉ๋ก) */
export async function loadDatabases(page: Page, f: Frame, r: RdbSpec, btn: string, api: () => string, prefix = 'mysql-src') {
  await f.locator(`#${prefix}Username`).fill(r.user);
  await f.locator(`#${prefix}Password`).fill(RDB_PW);
  await f.locator(btn).click();
  let names: string[] = [];
  await expect.poll(async () => {
    names = await f.evaluate(() => {
      const vis = (e: Element) => !!(e as HTMLElement).offsetParent;
      const opts = [...document.querySelectorAll('select option')].map((o) => (o.textContent || '').trim());
      const labels = [...document.querySelectorAll('label, .form-check-label, li, td')].filter(vis).map((e) => (e.textContent || '').trim());
      return [...new Set([...opts, ...labels])].filter((t) => /^[A-Za-z0-9_]+$/.test(t));
    });
    return names.join(',').toLowerCase();
  }, { timeout: 60_000, intervals: [2000, 3000] }).toContain(RDB_DB.toLowerCase()).catch(() => {});
  note('databases', names.filter((n) => !/^\d+$/.test(n)).slice(0, 30).join(', ') || '(DB ๋ชฉ๋ก ์—†์Œ)');
  note('load-db-api', api() || '(API ๊ธฐ๋ก ์—†์Œ)');
  return names;
}

/** ๋‹จ๊ณ„(accordion) ํŒจ๋„ ์•ˆ์˜ ๋ณด์ด๋Š” ๋ฒ„ํŠผยท์ž…๋ ฅ ์š”์•ฝ(๊ตฌ์„ฑ ๊ธฐ๋ก์šฉ) */
export async function stepSummary(f: Frame, panel: string) {
  return f.locator(panel).evaluate((p) => {
    const vis = (e: Element) => !!(e as HTMLElement).offsetParent;
    const btn = [...p.querySelectorAll('button')].filter(vis).map((e) => `${(e.textContent || '').trim()}#${e.id}`);
    const inp = [...p.querySelectorAll('input, select, textarea')].filter(vis).map((e) => `${e.tagName}#${e.id}=${(e as HTMLInputElement).type === 'password' ? '***' : (e as HTMLInputElement).value}`);
    return `buttons: ${btn.join(', ')} | inputs: ${inp.join(', ')}`.slice(0, 1200);
  }).catch((e) => `(ํŒจ๋„ ์—†์Œ: ${String(e).slice(0, 100)})`);
}

/** ํŒจ๋„ ์•ˆ ๋ฒ„ํŠผ(ํ…์ŠคํŠธ) ํด๋ฆญ */
export const clickIn = (f: Frame, panel: string, name: RegExp) => f.locator(panel).getByRole('button', { name }).first().click();

/**
 * ํŒจ๋„ ์•ˆ Diagnose ์‹คํ–‰ โ†’ ๊ฒฐ๊ณผ ํ‘œ์˜ ๊ฐ’์ด ์ฑ„์›Œ์งˆ ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ โ†’ ํŒจ๋„ ํ…์ŠคํŠธ ๋ฐ˜ํ™˜.
 * ์ง„๋‹จ์€ Duration(๊ธฐ๋ณธ 5์ดˆ) ๋™์•ˆ ๋Œ๊ณ  ์•ฝ 10์ดˆ ๋’ค ํ‘œ๊ฐ€ ์ฑ„์›Œ์ง„๋‹ค(2026-10-14 ํ™•์ธ). 'Total Time' ์— ์ˆซ์ž๊ฐ€ ๋‚˜์˜ค๋ฉด ์™„๋ฃŒ๋กœ ๋ณธ๋‹ค.
 * ํ‘œ์— 'Ignored errors' ํ•ญ๋ชฉ๋ช…์ด ์žˆ์–ด ์˜ค๋ฅ˜ ํŒ์ •์€ DIAG_ERR ๋กœ ํ•œ๋‹ค.
 */
export const DIAG_ERR = /fail|exception|error(?!s\b)/i;
export async function diagnoseIn(page: Page, f: Frame, panel: string, api: () => string) {
  const text = async () => (await f.locator(panel).innerText().catch(() => '')).replace(/\s+/g, ' ');
  const before = await text();
  await clickIn(f, panel, /diagnose/i);
  await page.waitForTimeout(12_000);
  // ์ง„๋‹จ ์ „๊ณผ ๋‹ฌ๋ผ์ง€๊ณ , 5์ดˆ ๊ฐ„๊ฒฉ ๋‘ ๋ฒˆ ์ฝ์€ ๊ฐ’์ด ๊ฐ™์•„์งˆ ๋•Œ(ํ‘œ ๊ฐฑ์‹  ๋) ์™„๋ฃŒ๋กœ ๋ณธ๋‹ค(Target ํ‘œ๋Š” ์ง„๋‹จ ์ „์—๋„ 0 ๊ฐ’์ด ๋ณด์ธ๋‹ค)
  let prev = '';
  await expect.poll(async () => { const t = await text(); const done = t !== before && t === prev; prev = t; return done; },
    { timeout: 10 * 60_000, intervals: [5000] }).toBe(true).catch(() => {});
  const after = await text();
  note(`diagnose ${panel}`, after.slice(0, 900));
  note(`diagnose-api ${panel}`, api() || '(API ๊ธฐ๋ก ์—†์Œ)');
  return after;
}

/**
 * Back Up ํ™”๋ฉด: Backup Name ์ž…๋ ฅ โ†’ Submit โ†’ Result โ†’ Backup List ์— ์ด๋ฆ„์ด ๋‚˜์˜ค๊ณ  ์ง„ํ–‰ ์ƒํƒœ๊ฐ€ ๋๋‚  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ.
 * Backup List ์—ด: Name / Provider / Region / Instance ID / Instance Name / Database Name / Status / Created
 */
export async function submitBackup(page: Page, f: Frame, name: string, api: () => string) {
  const { backupRowText } = await import('./iv8-os');
  await f.locator('#backup-name').fill(name);
  const before = await resultText(page, f);
  await f.locator('#submitBtn').click();
  const result = await waitResult(page, f, before, 30 * 60_000);
  note('backup-submit-api', api() || '(API ๊ธฐ๋ก ์—†์Œ)');
  let row = '';
  const t0 = Date.now();
  await expect.poll(async () => {
    row = await backupRowText(f, name);
    return !!row && !PENDING.test(row) && !/running|progress|wait/i.test(row);
  }, { timeout: 20 * 60_000, intervals: [5000, 10_000] }).toBe(true).catch(() => {});
  note('backup-row', `${Math.round((Date.now() - t0) / 1000)}s: ${row || '(Backup List ์— ์—†์Œ)'}`);
  return { result, row };
}

/** Instance ์‚ญ์ œ(์ฒดํฌ โ†’ Delete โ†’ ํ™•์ธ ํŒ์—…์— ์ด๋ฆ„ ์ž…๋ ฅ). ์—†์œผ๋ฉด false. */
export async function deleteRdb(page: Page, f: Frame, r: RdbSpec, api: () => string) {
  const row = rdbRow(f, r.id);
  if (!(await row.count())) { note('rdb-missing', `${r.id} ๋ชฉ๋ก์— ์—†์Œ`); return false; }
  note('rdb-before-delete', await rdbRowText(f, r.id));
  // ๋ชฉ๋ก์ด ์ฃผ๊ธฐ์ ์œผ๋กœ ๋‹ค์‹œ ๊ทธ๋ ค์ ธ(์ƒํƒœ ๊ฐฑ์‹ ) ํ–‰ยท์ฒดํฌ๋ฐ•์Šค๊ฐ€ ๊ต์ฒด๋˜๋ฏ€๋กœ, ์ฒดํฌ ํ›„ Delete ๋ฒ„ํŠผ์ด ํ™œ์„ฑํ™”๋  ๋•Œ๊นŒ์ง€ ๋‹ค์‹œ ์‹œ๋„ํ•œ๋‹ค
  const del = f.locator('#deleteInstanceBtn');
  for (let i = 0; i < 8 && !(await del.isEnabled()); i++) {
    await rdbRow(f, r.id).locator('input.row-select').first().check({ force: true, timeout: 5_000 }).catch(() => {});
    await page.waitForTimeout(700);
  }
  await expect(del, 'Delete ๋ฒ„ํŠผ ํ™œ์„ฑํ™”(ํ–‰ ์„ ํƒ)').toBeEnabled();
  await del.click();
  const input = f.locator('#dbi-delete-input');
  await input.waitFor({ state: 'visible', timeout: 15_000 });
  note('delete-modal', (await f.locator('#dbi-delete-instruction').innerText().catch(() => '')).replace(/\s+/g, ' '));
  await input.fill(r.id);
  await f.locator('#dbi-delete-confirm').click();
  note('rdb-delete-api', api() || '(API ๊ธฐ๋ก ์—†์Œ)');
  return true;
}

lib/k8s.ts

ํ•ด์‹œ 30879d5b ยท 75์ค„

import { test, expect } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { loginAsAdmin } from './ui';

// K8s Workloads ํ™”๋ฉด ํ—ฌํผ. 2026-10-05 Playwright ์‚ฌ์ „์ ๊ฒ€ + ์‚ฌ์šฉ์ž ์•ˆ๋‚ด ๊ธฐ์ค€:
//  - ๋ชฉ๋ก(Tabulator)์˜ ํ–‰๋งˆ๋‹ค action item ์ด ์žˆ๊ณ  ๊ทธ ๋ฉ”๋‰ด์˜ 'Delete' ๋กœ ํด๋Ÿฌ์Šคํ„ฐ๋ฅผ ์‚ญ์ œํ•œ๋‹ค.
//  - ๋ชฉ๋ก ์ปฌ๋Ÿผ: Provider, Status, Name, Node Group, VPC, Subnet, Security Group
export const K8S_LABEL = 'K8s Workloads';

/** admin ๋กœ๊ทธ์ธ(ws01/project ์„ ํƒ) ํ›„ ์‚ฌ์ด๋“œ ๋ฉ”๋‰ด๋กœ K8s Workloads ๋ชฉ๋ก๊นŒ์ง€ ์ด๋™. ๋ฉ”๋‰ด ์ด๋™์€ ์„ ํƒ๊ฐ’์„ ์œ ์ง€ํ•œ๋‹ค. */
export async function openK8sList(page: Page, project: string) {
  await loginAsAdmin(page, 'ws01', project);
  await page.getByRole('link', { name: K8S_LABEL }).first().click();
  await waitList(page);
}

export async function waitList(page: Page) {
  await page.getByText(/Showing \d+-\d+ of \d+ rows|No Data/).first().waitFor({ timeout: 30_000 }).catch(() => {});
  await page.waitForTimeout(1500);
}

export const clusterRow = (page: Page, name: string): Locator =>
  page.getByRole('row').filter({ has: page.getByRole('gridcell', { name, exact: true }) });

const squash = (s: string) => s.replace(/\s+/g, ' ').trim();

/**
 * ๋ชฉ๋ก ํ–‰์˜ action item โ†’ Delete โ†’ ํ™•์ธ.
 * ๋ฐ˜ํ™˜: ์š”์ฒญ ์งํ›„ ํ–‰ ์ƒํƒœ ๋ฌธ๊ตฌ(์—†์œผ๋ฉด ํ–‰์ด ์ด๋ฏธ ์‚ฌ๋ผ์ง).
 * action item ์˜ ์ •ํ™•ํ•œ ์…€๋ ‰ํ„ฐ๋Š” ๋ฏธํ™•์ธ์ด๋ผ ํ›„๋ณด๋ฅผ ์ฐจ๋ก€๋กœ ์‹œ๋„ํ•˜๊ณ , ๋ชป ์ฐพ์œผ๋ฉด ํ–‰ HTML ์„ ์–ด๋…ธํ…Œ์ด์…˜์— ๋‚จ๊ธฐ๊ณ  ์‹คํŒจ์‹œํ‚จ๋‹ค.
 */
export async function deleteClusterFromList(page: Page, name: string): Promise<string> {
  const row = clusterRow(page, name);
  await expect(row, `${name} ๊ฐ€ ๋ชฉ๋ก์— ์žˆ์–ด์•ผ ํ•œ๋‹ค`).toHaveCount(1, { timeout: 30_000 });
  const before = squash(await row.innerText());
  test.info().annotations.push({ type: 'k8s-row-before', description: before });

  // ํ–‰ ์•ˆ์—๋Š” ๋ฉ”๋‰ด ๋ฒ„ํŠผ์ด ์—†๊ณ  ์„ ํƒ ์ฒดํฌ๋ฐ•์Šค(Select Row)๋งŒ ์žˆ๋‹ค(2026-10-05 ํ™•์ธ).
  // โ†’ ํ–‰์„ ์ฒดํฌํ•œ ๋’ค ๋ชฉ๋ก ์นด๋“œ ํ—ค๋”(List of Cluster)์˜ action item ๋ฉ”๋‰ด์—์„œ Delete ๋ฅผ ๊ณ ๋ฅธ๋‹ค.
  await row.getByRole('checkbox', { name: 'Select Row' }).check();
  const header = page.locator('.card-header, [class*="card-header"], .card-title').filter({ hasText: /List of Cluster/ }).first();
  const candidates = [
    header.locator('[title*="ction" i], [aria-label*="ction" i]'),
    header.locator('.dropdown-toggle, [data-bs-toggle="dropdown"], [data-toggle="dropdown"]'),
    header.locator('button, a').filter({ has: page.locator('svg, i') }),
    header.getByRole('button'),
  ];
  let opened = false;
  for (const c of candidates) {
    const n = await c.count();
    // ํ—ค๋” ์•„์ด์ฝ˜์€ ๋ณดํ†ต [filter, sort, action(โŒ„), refresh] ์ˆœ์„œ โ†’ ๋’ค์—์„œ๋ถ€ํ„ฐ ์‹œ๋„(refresh ๋Š” ๊ฑด๋„ˆ๋›ฐ๊ณ  โŒ„ ๊ฐ€ ๊ฑธ๋ฆฌ๋„๋ก ๋ชจ๋‘ ์‹œ๋„)
    for (let i = n - 1; i >= 0 && !opened; i--) {
      await c.nth(i).click({ timeout: 3_000 }).catch(() => {});
      if (await page.getByText('Delete', { exact: true }).first().isVisible().catch(() => false)) opened = true;
    }
    if (opened) break;
  }
  if (!opened) {
    test.info().annotations.push({ type: 'k8s-header-html', description: (await header.evaluate((e) => e.outerHTML).catch(() => '(ํ—ค๋” ๋ชป ์ฐพ์Œ)')).slice(0, 1800) });
    throw new Error('๋ชฉ๋ก ํ—ค๋”์˜ action item ๋ฉ”๋‰ด(Delete)๋ฅผ ์—ด์ง€ ๋ชปํ•จ');
  }
  await page.getByText('Delete', { exact: true }).first().click();

  // ํ™•์ธ: ๋„ค์ดํ‹ฐ๋ธŒ confirm ์€ fixtures ๊ฐ€ ์ž๋™ ์ˆ˜๋ฝ. ๋ชจ๋‹ฌ์ด๋ฉด ํ™•์ธ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ๋‹ค.
  const dlg = page.getByRole('dialog');
  if (await dlg.isVisible({ timeout: 3_000 }).catch(() => false)) {
    test.info().annotations.push({ type: 'k8s-confirm-dialog', description: squash(await dlg.innerText()).slice(0, 300) });
    await dlg.getByRole('button', { name: /Delete|Confirm|OK|Yes/i }).last().click();
  }
  await page.waitForTimeout(3_000);
  const after = (await row.count()) ? squash(await row.first().innerText()) : '(ํ–‰ ์—†์Œ)';
  test.info().annotations.push({ type: 'k8s-row-after-request', description: after });
  return after;
}

lib/menu-access.ts

ํ•ด์‹œ 1aab1499 ยท 128์ค„

๋ฉ”๋‰ด URL ์ง์ ‘ ์ ‘๊ทผ ์ ๊ฒ€ (IV1_S13S16 / TC-IAM-044047). ๋กœ๊ทธ์ธํ•œ ์„ธ์…˜์œผ๋กœ ์‚ฌ์šฉ์ž ๋ฉ”๋‰ด(GetAllAvailableMenus)ยท์ „์ฒด ๋ฉ”๋‰ด(Getmenuresources)๋ฅผ ์กฐํšŒํ•ด ์‚ฌ์ด๋“œ๋ฐ”์™€ ๊ฐ™์€ ๊ทœ์น™์œผ๋กœ ํ™”๋ฉด URL ์„ ๊ณ„์‚ฐํ•˜๊ณ , ๋กœ๊ทธ์ธ ์งํ›„ ํ™”๋ฉด๊ณผ ์ „์ฒด ํ™”๋ฉด ๋ฉ”๋‰ด์˜ ์ง์ ‘ ์ ‘๊ทผ ๊ฒฐ๊ณผ(์—ด๋ฆผ / /auth/unauthorized#menuNotAllowed / ์ฒซ ๋ฉ”๋‰ด ์ด๋™)๋ฅผ ํŒ์ •ํ•œ๋‹ค. ๊ธฐ๋Œ€๊ฐ’์„ ํ•˜๋“œ์ฝ”๋”ฉํ•˜์ง€ ์•Š์•„ ์—ญํ•  ๋ฉ”๋‰ด๊ฐ€ ๋ฐ”๋€Œ์–ด๋„ ๊ทธ๋Œ€๋กœ ์“ด๋‹ค.

import type { Page } from '@playwright/test';
import { test, expect } from './fixtures';
import { loginAndSettle } from './ui';
import { USERS, pwOf } from './data';

/**
 * ๋ฉ”๋‰ด URL ์ง์ ‘ ์ ‘๊ทผ (IV1_S13~S16 / TC-IAM-044~047, WEB-BUG-126 ์ˆ˜์ • ๊ธฐ์ค€ 2026-10-15).
 * ๊ธฐ์ค€: ์‚ฌ์šฉ์ž ๋ฉ”๋‰ด(GetAllAvailableMenus)์— ์žˆ๋Š” ํ™”๋ฉด์€ URL ์ง์ ‘ ์ž…๋ ฅ์œผ๋กœ ์—ด๋ฆฌ๊ณ ,
 *       ์ „์ฒด ๋ฉ”๋‰ด(Getmenuresources)์—๋Š” ์žˆ์ง€๋งŒ ์‚ฌ์šฉ์ž ๋ฉ”๋‰ด์— ์—†๋Š” ํ™”๋ฉด์€ /auth/unauthorized#menuNotAllowed ๋กœ ์ด๋™ํ•œ๋‹ค.
 *       ๋กœ๊ทธ์ธ ์งํ›„ ๊ธฐ๋ณธ ํ™”๋ฉด(Infra Workloads)์ด ์‚ฌ์šฉ์ž ๋ฉ”๋‰ด์— ์—†์œผ๋ฉด ์‚ฌ์ด๋“œ๋ฐ” ์ˆœ์„œ์ƒ ์ฒซ ๋ฉ”๋‰ด ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•œ๋‹ค.
 * ๊ธฐ๋Œ€๊ฐ’์€ ํ•˜๋“œ์ฝ”๋”ฉํ•˜์ง€ ์•Š๊ณ  ๋กœ๊ทธ์ธํ•œ ์„ธ์…˜์œผ๋กœ ๋ฉ”๋‰ด๋ฅผ ์กฐํšŒํ•ด ๊ณ„์‚ฐํ•œ๋‹ค(์—ญํ•  ๋ฉ”๋‰ด๊ฐ€ ๋ฐ”๋€Œ์–ด๋„ ๊ทธ๋Œ€๋กœ ์“ธ ์ˆ˜ ์žˆ๋‹ค).
 * ํ™”๋ฉด ์•ˆ ๋ฒ„ํŠผยทIAM API ๊ถŒํ•œ(์˜ˆ: operator ์˜ GetRoleList ์กฐํšŒ)์€ ์ด ์‹œ๋‚˜๋ฆฌ์˜ค ๋ฒ”์œ„๊ฐ€ ์•„๋‹ˆ๋‹ค.
 */
export const MENU_ACCESS_USERS: Record<string, { id: string; tc: string }> = {
  IV1_S13: { id: 'workflow01', tc: 'TC-IAM-044' },
  IV1_S14: { id: 'monitor01', tc: 'TC-IAM-045' },
  IV1_S15: { id: 'cost01', tc: 'TC-IAM-046' },
  IV1_S16: { id: 'cost02', tc: 'TC-IAM-047' },
};

export const DEFAULT_PAGE = '/webconsole/operations/manage/workloads/infraworkloads';
export const DENIED_PAGE = '/auth/unauthorized';

interface IamMenu {
  id: string; parentId?: string; displayName?: string; path?: string; viewType?: string;
  isAction?: boolean | string; priority?: number; menuNumber?: number;
}
export interface MenuPage { id: string; name: string; href: string; trail: string }

async function iamMenus(page: Page, operationId: string): Promise<IamMenu[]> {
  const r = await page.request.post(`/api/mc-iam-manager/${operationId}`, { data: {} });
  expect(r.ok(), `${operationId} HTTP ${r.status()}`).toBe(true);
  return (await r.json()).responseData ?? [];
}

/** ์‚ฌ์ด๋“œ๋ฐ”(sidebar.js resolveMenuHref)์™€ ๊ฐ™์€ ๊ทœ์น™์œผ๋กœ ํ™”๋ฉด ๋ฉ”๋‰ด์˜ URL ์„ ์‚ฌ์ด๋“œ๋ฐ” ์ˆœ์„œ๋Œ€๋กœ ๋งŒ๋“ ๋‹ค. popupยท๊ทธ๋ฃน ๋ฉ”๋‰ด๋Š” ์ œ์™ธ */
export function menuPages(menus: IamMenu[]): MenuPage[] {
  const byId = new Map(menus.map((m) => [m.id, m]));
  const parentOf = (m: IamMenu) => (m.parentId && m.parentId !== 'home' && byId.has(m.parentId) ? m.parentId : '');
  const chain = (m: IamMenu) => {
    const ids = [m.id];
    for (let p = parentOf(m); p && !ids.includes(p); p = parentOf(byId.get(p)!)) ids.unshift(p);
    return ids;
  };
  const hrefOf = (m: IamMenu) => {
    const view = (m.viewType || 'local').toLowerCase();
    if (view === 'popup') return '';
    if (view === 'iframe') return `/webconsole/_view/${m.id}`;
    return m.path ? `/webconsole/${m.path.replace(/^\/+|\/+$/g, '')}` : `/webconsole/${chain(m).join('/')}`;
  };
  const children = new Map<string, IamMenu[]>();
  for (const m of menus) children.set(parentOf(m), [...(children.get(parentOf(m)) ?? []), m]);
  const pages: MenuPage[] = [];
  const walk = (parent: string) => {
    const list = [...(children.get(parent) ?? [])].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0) || (a.menuNumber ?? 0) - (b.menuNumber ?? 0));
    for (const m of list) {
      const href = hrefOf(m);
      if (href && String(m.isAction).toLowerCase() === 'true') {
        pages.push({ id: m.id, name: m.displayName ?? m.id, href, trail: chain(m).map((id) => byId.get(id)?.displayName ?? id).join(' > ') });
      }
      walk(m.id);
    }
  };
  walk('');
  return pages;
}

/** URL ์„ ์ง์ ‘ ์ž…๋ ฅํ–ˆ์„ ๋•Œ ๋„์ฐฉํ•œ ๊ณณ: 'open'(๊ทธ๋Œ€๋กœ) / 'blocked'(Unauthorized) / ๊ทธ ๋ฐ–์˜ ๋„์ฐฉ ๊ฒฝ๋กœ */
async function visit(page: Page, href: string): Promise<string> {
  await page.goto(href, { waitUntil: 'domcontentloaded' });
  const u = new URL(page.url());
  if (u.pathname.replace(/\/$/, '') === href) return 'open';
  if (u.pathname.startsWith(DENIED_PAGE)) return u.hash === '#menuNotAllowed' ? 'blocked' : `unauthorized${u.hash}`;
  return u.pathname;
}

export function menuAccessTest(scn: keyof typeof MENU_ACCESS_USERS) {
  const { id, tc } = MENU_ACCESS_USERS[scn];
  const role = USERS.find((u) => u.id === id)?.role ?? '';
  test(`${scn} ${tc} ์—ญํ•  ${role}: ${id} ๋ฉ”๋‰ด URL ์ง์ ‘ ์ ‘๊ทผ โ€” ๋ฉ”๋‰ด์— ์žˆ๋Š” ํ™”๋ฉด๋งŒ ์—ด๋ฆผ`, async ({ page }) => {
    test.setTimeout(10 * 60_000);
    let pages: MenuPage[] = [];
    let mine = new Set<string>();

    await test.step(`1. ${id} ๋กœ๊ทธ์ธ`, () => loginAndSettle(page, id, pwOf(id)));

    await test.step('2. ์‚ฌ์šฉ์ž ๋ฉ”๋‰ด(GetAllAvailableMenus)ยท์ „์ฒด ๋ฉ”๋‰ด(Getmenuresources) ์กฐํšŒ โ†’ ํ™”๋ฉด URL ๊ณ„์‚ฐ', async () => {
      mine = new Set((await iamMenus(page, 'GetAllAvailableMenus')).map((m) => m.id));
      pages = menuPages(await iamMenus(page, 'Getmenuresources'));
      expect(pages.length, 'ํ™”๋ฉด ๋ฉ”๋‰ด').toBeGreaterThan(0);
      const allowed = pages.filter((p) => mine.has(p.id)).length;
      test.info().annotations.push({ type: 'menus', description: `ํ™”๋ฉด ๋ฉ”๋‰ด ${pages.length}๊ฐœ ์ค‘ ์‚ฌ์šฉ์ž ๋ฉ”๋‰ด ${allowed}๊ฐœ (์‚ฌ์šฉ์ž ๋ฉ”๋‰ด ํ•ญ๋ชฉ ${mine.size}๊ฐœ)` });
    });

    // Infra Workloads(๊ธฐ๋ณธ ํ™”๋ฉด)๊ฐ€ ๋ฉ”๋‰ด์— ์—†๋Š” ์‚ฌ์šฉ์ž๋Š” Unauthorized ๋Œ€์‹  ์‚ฌ์ด๋“œ๋ฐ” ์ฒซ ๋ฉ”๋‰ด ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•œ๋‹ค
    let firstPage = '';

    await test.step('3. ๋กœ๊ทธ์ธ ์งํ›„ ํ™”๋ฉด: Infra Workloads, ๋ฉ”๋‰ด์— ์—†์œผ๋ฉด ์‚ฌ์ด๋“œ๋ฐ” ์ฒซ ๋ฉ”๋‰ด ํ™”๋ฉด (Unauthorized ์•„๋‹˜)', async () => {
      firstPage = pages.find((p) => mine.has(p.id))?.href ?? '';
      const expected = mine.has('infraworkloads') ? DEFAULT_PAGE : firstPage;
      await page.goto('/', { waitUntil: 'domcontentloaded' });
      const landed = new URL(page.url()).pathname;
      test.info().annotations.push({ type: 'landing', description: `${landed} (๊ธฐ๋Œ€ ${expected})` });
      await page.screenshot({ path: test.info().outputPath(`${scn}-landing.png`) });
      expect(landed).toBe(expected);
    });

    await test.step('4. ํ™”๋ฉด ๋ฉ”๋‰ด URL ์„ ํ•˜๋‚˜์”ฉ ์ง์ ‘ ์ž…๋ ฅ โ†’ ๋ฉ”๋‰ด์— ์žˆ์œผ๋ฉด ์—ด๋ฆผ, ์—†์œผ๋ฉด /auth/unauthorized#menuNotAllowed (๊ธฐ๋ณธ ํ™”๋ฉด์€ ์ฒซ ๋ฉ”๋‰ด๋กœ ์ด๋™)', async () => {
      const rows: string[] = [];
      for (const p of pages) {
        const expected = mine.has(p.id) ? 'open' : p.href === DEFAULT_PAGE ? firstPage : 'blocked';
        const actual = await visit(page, p.href);
        const label = expected === 'open' ? '์—ด๋ฆผ' : expected === 'blocked' ? '๋ง‰ํž˜' : '์ฒซ ๋ฉ”๋‰ด';
        rows.push(`${actual === expected ? 'OK' : 'NG'} ${label} ${p.trail} ${p.href}${actual === expected ? '' : ` โ†’ ${actual}`}`);
        expect.soft(actual, `${p.trail} (${p.href})`).toBe(expected);
        if (expected === 'blocked' && actual === 'blocked' && !rows.some((r) => r.startsWith('SHOT'))) {
          await page.screenshot({ path: test.info().outputPath(`${scn}-blocked.png`) });
          rows.push(`SHOT ${p.href}`);
        }
      }
      const result = rows.filter((r) => !r.startsWith('SHOT'));
      const count = (label: string) => result.filter((r) => r.includes(` ${label} `)).length;
      test.info().annotations.push({ type: 'access', description: `์—ด๋ฆผ ${count('์—ด๋ฆผ')} ยท ๋ง‰ํž˜ ${count('๋ง‰ํž˜')} ยท ์ฒซ ๋ฉ”๋‰ด ์ด๋™ ${count('์ฒซ ๋ฉ”๋‰ด')} ยท ๋ถˆ์ผ์น˜ ${result.filter((r) => r.startsWith('NG')).length}` });
      await test.info().attach(`${scn}-access.txt`, { body: result.join('\n'), contentType: 'text/plain' });
    });
  });
}

lib/projects.ts

ํ•ด์‹œ 92fbb0b8 ยท 52์ค„

import { test, expect } from './fixtures';
import type { Page, Locator } from '@playwright/test';
import { loginAsAdmin } from './ui';

// Workspaces > Projects ํ™”๋ฉด ํ—ฌํผ (2026-10-08 ํ”„๋กœ๋ธŒยทp6 ์ •๋ฆฌ๋กœ ํ™•์ธ)
//  - ๋ชฉ๋ก(Tabulator): ID, Name, Description, NsId, Workspaces, Created. ํ–‰ ํด๋ฆญ โ†’ ํ•˜๋‹จ ์ƒ์„ธ(Assigned Workspaces 'ws01 (id: 1) Unassign', Edit/Delete, Assign)
//  - Add Project ํŒ์—… #modal-project-add: #add-project-name, #add-project-description, #add-project-initial-workspace
//  - ์ œํ’ˆ ๊ทœ์น™: Workspace ์— ํ• ๋‹น๋œ Project ๋Š” ์‚ญ์ œํ•  ์ˆ˜ ์—†๋‹ค โ†’ 'Cannot Delete Project / This project is assigned to: ws01 / Unassign it from all workspaces first. [Close][Go to Assignments]'
//  - ์ž„์‹œ Project ์ด๋ฆ„(ํ…Œ์ŠคํŠธ ์ „์šฉ). default / p1 / system ๋ฐ p{n} ์€ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š๋Š”๋‹ค.
export const PROJECTS_URL = '/webconsole/operations/manage/workspaces/projects';
export const TEMP_PROJECT = 'ptmp';
export const PROTECTED = ['default', 'p1', 'system'];

export const projectRow = (page: Page, name: string): Locator =>
  page.locator('.tabulator-row').filter({ has: page.locator('[tabulator-field="name"]', { hasText: new RegExp(`^\\s*${name}\\s*$`) }) });

export async function openProjects(page: Page) {
  await loginAsAdmin(page);
  await page.goto(PROJECTS_URL);
  await page.getByRole('gridcell', { name: 'default', exact: true }).first().waitFor({ timeout: 60_000 });
  await page.waitForTimeout(2000);
}

/** ์ž„์‹œ Project ๊ฐ€ ์—†์œผ๋ฉด Workspace ws01 ์— ํ• ๋‹น๋œ ์ฑ„๋กœ ์ƒ์„ฑํ•œ๋‹ค(Add Project โ†’ Initial Workspace=ws01). */
export async function ensureTempProject(page: Page, name = TEMP_PROJECT) {
  expect(PROTECTED).not.toContain(name);
  if (await projectRow(page, name).count()) return false;
  await page.getByRole('button', { name: /Add Project/ }).or(page.getByRole('link', { name: /Add Project/ })).first().click();
  const m = page.locator('#modal-project-add');
  await expect(m).toBeVisible();
  await m.locator('#add-project-name').fill(name);
  await m.locator('#add-project-description').fill('์ž„์‹œ Project (IV3_S26~S28 ํ…Œ์ŠคํŠธ์šฉ)');
  await m.getByRole('button', { name: 'Save' }).click();
  await expect(m).toBeHidden({ timeout: 30_000 });
  await page.goto(PROJECTS_URL);
  await page.getByRole('gridcell', { name: 'default', exact: true }).first().waitFor({ timeout: 60_000 });
  await expect(projectRow(page, name), `๋ชฉ๋ก์— ${name}`).toHaveCount(1, { timeout: 60_000 });
  test.info().annotations.push({ type: 'created', description: `์ž„์‹œ Project ${name} ์ƒ์„ฑ(ws01 ํ• ๋‹น)` });
  return true;
}

export const rowWorkspaceCount = async (page: Page, name: string) => Number((await projectRow(page, name).locator('[tabulator-field="workspaceCount"]').innerText()).trim());

export async function selectProject(page: Page, name: string) {
  await projectRow(page, name).locator('[tabulator-field="name"]').click();
  await page.waitForTimeout(2000);
}

export async function protectedStillThere(page: Page) {
  for (const keep of PROTECTED) expect(await projectRow(page, keep).count(), `${keep} ์œ ์ง€`).toBe(1);
}

lib/reco.ts

ํ•ด์‹œ fb58a69b ยท 76์ค„

import { test, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import type { Csp } from './data';
import { INFRA_INPUT, gotoInfraList } from './infra';

// Add Infra > + NodeGroup ์˜ Node Recommendation / Image Recommendation ํŒ์—… ํ™•์ธ์šฉ ํ—ฌํผ (IV3_S23, IV3_S24).
// ์ƒ์„ฑํ•˜์ง€ ์•Š๋Š”๋‹ค: ํŒ์—…์—์„œ ์กฐํšŒ/์„ ํƒ๋งŒ ํ•˜๊ณ  Add Infra ๋Š” ์ €์žฅ ์—†์ด ๋๋‚ธ๋‹ค.
// ์…€๋ ‰ํ„ฐ ๊ทผ๊ฑฐ: lib/infra.ts addNodeGroup (2026-10-02/10-06 ํ”„๋กœ๋ธŒ) + 2026-10-06 Node Recommendation ํŒ์—… ํ”„๋กœ๋ธŒ
//   #assistRecommendSpecConnectionName(Priority Option/Limit(location): seoul/london/newyork), #spec-provider-{id},
//   #assist_min/max_memory, #assist_min/max_cpu, a[onclick*=getRecommendVmInfo], #assist_os_type, a[onclick*=getRecommendImageInfo]

/** Cloud Provider ์ฒดํฌ๋ฐ•์Šค id (#spec-provider-{id}) */
export const PROVIDER_ID: Record<string, string> = { AWS: 'aws', Azure: 'azure', Alibaba: 'alibaba', GCP: 'gcp', Tencent: 'tencent', 'NHN Cloud': 'nhn', NCP: 'ncp', IBM: 'ibm', KT: 'kt' };

/** Add Infra โ†’ + NodeGroup ํผ๊นŒ์ง€ ์—ฐ๋‹ค(Infra ์ด๋ฆ„์€ ๋น„์›Œ ๋‘”๋‹ค). */
export async function openNodeForm(page: Page) {
  await gotoInfraList(page);
  await page.getByText('Add Infra', { exact: true }).first().click();
  await page.getByText('+ NodeGroup').first().click();
  await page.locator('#ep_name').waitFor();
  await page.locator('#ep_name').fill('reco');
}

/** Node Recommendation ํŒ์—…์„ ์—ด๊ณ  ์กฐ๊ฑด์„ ๋„ฃ์–ด ๊ฒ€์ƒ‰. ๊ฒฐ๊ณผ ํ–‰ ์ •๋ณด๋ฅผ ๋Œ๋ ค์ค€๋‹ค. */
export async function searchSpec(page: Page, c: Csp) {
  const pv = PROVIDER_ID[c.name];
  const inp = INFRA_INPUT[c.code];
  await page.locator('a[data-bs-target="#spec-search"]:visible').first().click();
  const sm = page.locator('#spec-search');
  await sm.waitFor();
  // ์กฐ๊ฑด ์ˆœ์„œ: Priority Option(location)=Seoul โ†’ Cloud Provider ์ฒดํฌ โ†’ Spec Config(Memory/vCPU) โ†’ ๊ฒ€์ƒ‰
  await sm.locator('#assistRecommendSpecConnectionName').selectOption(inp?.priority ?? 'seoul');
  await sm.locator(`#spec-provider-${pv}`).check();
  // ์†Œ์ˆ˜ ๋ฉ”๋ชจ๋ฆฌ(์˜ˆ: GCP n1-standard-1 3.75GB)๋Š” ์ •ํ™•ํžˆ ๊ฐ™์€ ๊ฐ’์œผ๋กœ ๊ฒ€์ƒ‰ํ•˜๋ฉด 0๊ฑด์ด ๋  ์ˆ˜ ์žˆ์–ด floor~ceil ๋ฒ”์œ„๋กœ ๋„ฃ๋Š”๋‹ค
  const [minMem, maxMem, minCpu, maxCpu] = inp ? [Math.floor(inp.mem), Math.ceil(inp.mem), Math.floor(inp.cpu), Math.ceil(inp.cpu)] : [2, 8, 1, 4];
  await sm.locator('#assist_min_memory').fill(String(minMem));
  await sm.locator('#assist_max_memory').fill(String(maxMem));
  await sm.locator('#assist_min_cpu').fill(String(minCpu));
  await sm.locator('#assist_max_cpu').fill(String(maxCpu));
  await sm.locator('a[onclick*="getRecommendVmInfo"]').click();
  const rows = sm.locator('.tabulator-row');
  await rows.first().waitFor({ timeout: 60_000 }).catch(() => {});
  const count = await rows.count();
  const conns = await sm.locator('.tabulator-row [tabulator-field="connectionName"]').allInnerTexts();
  const showing = (await sm.innerText()).match(/Showing [^\n]*/)?.[0] ?? '';
  test.info().annotations.push({ type: 'spec-search', description: `provider=${pv}, priority=${inp?.priority ?? 'seoul'}, mem=${minMem}~${maxMem}, vCPU=${minCpu}~${maxCpu} โ†’ ${showing} / ์—ฐ๊ฒฐ: ${[...new Set(conns.map((s) => s.trim()))].join(', ') || '(์—†์Œ)'}` });
  return { sm, rows, count, conns: conns.map((s) => s.trim()), pv, inp };
}

/** ์ž…๋ ฅ spec(INFRA_INPUT)์ด ์žˆ์œผ๋ฉด ๊ทธ ํ–‰, ์—†์œผ๋ฉด ์ฒซ ํ–‰ */
export function specTargetRow(page: Page, sm: ReturnType<Page['locator']>, c: Csp) {
  const inp = INFRA_INPUT[c.code];
  if (!inp) return sm.locator('.tabulator-row').first();
  return sm.locator('.tabulator-row')
    .filter({ has: page.locator('[tabulator-field="connectionName"]', { hasText: `${inp.provider}-${inp.region}` }) })
    .filter({ hasText: inp.spec }).first();
}

/** Spec ์„ ํƒ โ†’ Apply ํ›„ Image Recommendation ํŒ์—…์—์„œ OS Type ์œผ๋กœ ๊ฒ€์ƒ‰. ๊ฒฐ๊ณผ ํ–‰ ๋ฐ˜ํ™˜. */
export async function searchImage(page: Page, os: string) {
  await page.locator('a[onclick*="validateAndOpenImageModal"]:visible').first().click();
  const im = page.locator('#image-search');
  await im.waitFor();
  await im.locator('#assist_os_type').fill(os);
  await im.locator('a[onclick*="getRecommendImageInfo"]').click();
  const avail = im.locator('.tabulator-row').filter({ has: page.locator('[tabulator-field="imageStatus"]', { hasText: 'Available' }) });
  await avail.first().waitFor({ timeout: 60_000 }).catch(() => {});
  const total = await im.locator('.tabulator-row').count();
  const showing = (await im.innerText()).match(/Showing [^\n]*/)?.[0] ?? '';
  const sample = (await im.locator('.tabulator-row').evaluateAll((els) => els.slice(0, 3).map((e) => (e as HTMLElement).innerText.replace(/\s+/g, ' ').slice(0, 140))));
  test.info().annotations.push({ type: 'image-search', description: `os='${os}' โ†’ ${showing} / ํ–‰ ${total}๊ฐœ, Available ${await avail.count()}๊ฐœ / ์˜ˆ: ${sample.join(' || ')}` });
  return { im, avail, total };
}

export { expect };

lib/resources.ts

ํ•ด์‹œ c011a892 ยท 229์ค„

import { test, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import type { Csp } from './data';
import { vnetName, sgName, keyName, diskName, WORKSPACE, DEFAULT_PROJECT } from './data';
import { loginAsAdmin, selectWorkspaceProject } from './ui';

// Cloud Resources ์ƒ์„ฑ ํ™”๋ฉด ์…€๋ ‰ํ„ฐ: 2026-10-01 Playwright ํ”„๋กœ๋ธŒ๋กœ ํ™•์ธ (id ๊ธฐ๋ฐ˜)
const BASE = '/webconsole/settings/environment/cloudresources';
export const RES = {
  vnet: { url: `${BASE}/networks`, create: 'Create VPC' },
  sg: { url: `${BASE}/securitygroups`, create: 'Create SG' },
  key: { url: `${BASE}/sshkeys`, create: 'Create SSH Key' },
  disk: { url: `${BASE}/disks`, create: 'Create Data Disk' },
} as const;
export type ResKind = keyof typeof RES;

/**
 * IV2 ์ž์› ์ƒ์„ฑ์— ์“ฐ๋Š” Connection. ๊ธฐ๋ณธ์€ Infra ์—ฐ๊ฒฐ(data.ts)๊ณผ ๊ฐ™๋‹ค.
 * IBM ์€ Infra ์—ฐ๊ฒฐ์ด ๋ฏธ๊ฒ€์ฆ์ด์ง€๋งŒ ibm-jp-tok ์ด Connections ์—์„œ Verified(2026-10-02 ํ™•์ธ) โ†’ IV2 ๋Š” ์ด ์—ฐ๊ฒฐ๋กœ ์ง„ํ–‰(์‚ฌ์šฉ์ž ๊ฒฐ์ •).
 */
export const connOf = (c: Csp) => (c.code === 'IBM' ? 'ibm-jp-tok' : c.infra.conn);

/** CSP ๋ณ„ ์ƒ์„ฑ ๋ถˆ๊ฐ€/๋ฏธ๊ฒ€์ฆ ์‚ฌ์œ . ์žˆ์œผ๋ฉด SKIP ์ฒ˜๋ฆฌํ•˜๊ณ  ์‚ฌ์œ ๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. */
export function resourceBlocker(c: Csp, kind: ResKind): string | undefined {
  if (connOf(c).startsWith('(')) return `์—ฐ๊ฒฐ ์ •๋ณด ๋ฏธ๊ฒ€์ฆ(${c.name}) โ†’ ๊ฒฐ๊ณผ ๊ทธ๋Œ€๋กœ ๊ธฐ๋ก ๋Œ€์ƒ์ด๋‚˜ Connection ๋ฏธ์ง€์ •์œผ๋กœ SKIP`;
  return undefined;
}

/**
 * vNet(VPC) 2๊ฐœ์งธ๋ถ€ํ„ฐ ๊ณผ๊ธˆ๋˜๋Š” CSP(์‚ฌ์šฉ์ž ์ง€์‹œ 2026-10-08: 1๊ฐœ๋Š” ๋ฌด๋ฃŒ, 2๊ฐœ๋ถ€ํ„ฐ ๊ณผ๊ธˆ). ์ด CSP ๋Š” VPC ๋ฅผ ์ƒˆ๋กœ ๋งŒ๋“ค์ง€ ์•Š๊ณ  ๊ธฐ์กด VPC ๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค.
 * ๊ธฐ์กด VPC ๊ฐ€ ์—†๊ฑฐ๋‚˜ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์œผ๋ฉด ํ•ด๋‹น ์‹œ๋‚˜๋ฆฌ์˜ค๋Š” SKIP. (ํ˜„์žฌ NHN Cloud)
 */
export const VNET_BILLED = new Set(['NHNCloud']);

/**
 * VPC ์‹ ๊ทœ ์ƒ์„ฑ์ด ๋ถˆ๊ฐ€๋Šฅํ•œ CSP(2026-10-08 NCP: PostVNet 500 returnCode 1015009 'You can no longer create a product').
 * IV2_S07 ์˜ Resource Sync ๋กœ ๊ฐ€์ ธ์˜จ ๊ธฐ์กด VPC / SG ๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค(์‹ ๊ทœ ์ƒ์„ฑ ์•ˆ ํ•จ, ์—†์œผ๋ฉด SKIP).
 */
export const SYNCED_ONLY = new Set(['NCP']);

async function gotoList(page: Page, kind: ResKind) {
  await page.goto(RES[kind].url);
  await selectWorkspaceProject(page, WORKSPACE, DEFAULT_PROJECT, true); // ์ฃผ์†Œ ์ด๋™ ์‹œ ์„ ํƒ์ด ์ดˆ๊ธฐํ™”๋จ
  // ๋ชฉ๋ก์ด ์™„์ „ํžˆ ์ฑ„์›Œ์งˆ ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ: "Showing x-y of n rows" ๋˜๋Š” ๋นˆ ๋ชฉ๋ก ๋ฌธ๊ตฌ
  await page.getByText(/Showing \d+-\d+ of \d+ rows|No registered/).first().waitFor({ timeout: 30_000 }).catch(() => {});
  await page.waitForTimeout(1500);
  // ๋ชฉ๋ก์€ ๊ธฐ๋ณธ 10ํ–‰์”ฉ ๋ณด์—ฌ CSP ๊ฐ€ ๋Š˜๋ฉด ๋’ค์ชฝ ์ž์›์ด ์ฒซ ํŽ˜์ด์ง€์— ์—†๋‹ค(2026-10-08 sg6/disk7/disk8 'not found' ์˜คํŒ) โ†’ Page Size ๋ฅผ 50 ์œผ๋กœ
  const size = page.locator('select').filter({ has: page.locator('option', { hasText: /^50$/ }) }).last();
  if (await size.count()) { await size.selectOption({ label: '50' }).catch(() => {}); await page.waitForTimeout(1500); }
}

const rowOf = (page: Page, name: string, provider?: string) =>
  page.getByRole('row').filter({ has: page.getByRole('gridcell', { name, exact: true }) }).filter(provider ? { hasText: provider } : {});

// ๊ทœ์น™ ํ–‰์€ #create-sg-rule-list > .sg-rule-row (2026-10-02 probe ํ™•์ธ).
// placeholder 'e.g. 0' ๋Š” ๋ถ€๋ถ„ ์ผ์น˜๋กœ CIDR ์นธ('CIDR (e.g. 0.0.0.0/0)')๊นŒ์ง€ ์žกํžˆ๋ฏ€๋กœ ํด๋ž˜์Šค๋กœ ์ฐพ๋Š”๋‹ค.
async function fillRule(page: Page, i: number, port: string) {
  const dlg = page.getByRole('dialog');
  const rows = dlg.locator('#create-sg-rule-list .sg-rule-row');
  while ((await rows.count()) <= i) {
    await dlg.getByRole('button', { name: '+ Add Rule' }).click();
  }
  const row = rows.nth(i);
  await row.locator('.rule-from-port').fill(port);
  await row.locator('.rule-to-port').fill(port);
  await row.locator('.rule-cidr').fill('0.0.0.0/0');
}

/** ์ƒ์„ฑ ๋Œ€์ƒ ์ด๋ฆ„(CSP ๋ฒˆํ˜ธ ๊ธฐ๋ฐ˜). */
export const nameOf = (kind: ResKind, c: Csp) => ({ vnet: vnetName, sg: sgName, key: keyName, disk: diskName })[kind](c);

/**
 * ๋ฆฌ์†Œ์Šค ์ƒ์„ฑ โ†’ ๋ชฉ๋ก ํ‘œ์‹œ โ†’ CSP ํ•„ํ„ฐ ํ™•์ธ (IV2_S01~S04 ๊ณตํ†ต).
 * ์ด๋ฏธ ๊ฐ™์€ ์ด๋ฆ„์ด ์žˆ์œผ๋ฉด(์žฌ์‹คํ–‰/์ด์ „ ์‚ฌ์ „์ ๊ฒ€ ์ž”์กด) ์ƒ์„ฑ์€ ๊ฑด๋„ˆ๋›ฐ๊ณ  ๋ชฉ๋กยทํ•„ํ„ฐ๋งŒ ๊ฒ€์ฆํ•œ๋‹ค(annotation: preexisting).
 */
export async function createListFilter(page: Page, c: Csp, kind: ResKind) {
  let name = nameOf(kind, c);
  const conn = connOf(c);
  const reuseVpc = VNET_BILLED.has(c.code) || SYNCED_ONLY.has(c.code);
  await loginAsAdmin(page);
  await gotoList(page, kind);

  // vNet ๊ณผ๊ธˆ CSP: ์ƒˆ๋กœ ๋งŒ๋“ค์ง€ ์•Š๊ณ  ๋ชฉ๋ก์˜ ๊ธฐ์กด VPC ๋กœ ๋ชฉ๋กยทํ•„ํ„ฐ๋งŒ ๊ฒ€์ฆ(๊ธฐ์กด VPC ๊ฐ€ ์—†์œผ๋ฉด SKIP)
  let reused = false;
  // SYNCED_ONLY(NCP) ๋Š” SG ๋„ ์‹ ๊ทœ ์ƒ์„ฑ ์—†์ด Resource Sync ๋กœ ๊ฐ€์ ธ์˜จ ๊ธฐ์กด SG ๋กœ ๋ชฉ๋กยทํ•„ํ„ฐ๋ฅผ ๊ฒ€์ฆํ•œ๋‹ค
  if ((kind === 'vnet' && reuseVpc) || (kind === 'sg' && SYNCED_ONLY.has(c.code))) {
    const existing = page.locator('.tabulator-row').filter({ hasText: new RegExp(`\\b${providerKey(c)}\\b`, 'i') }).first().locator('[tabulator-field="name"]');
    const has = (await existing.count()) > 0;
    test.skip(!has, `${c.name}: ์‹ ๊ทœ ์ƒ์„ฑ ์•ˆ ํ•จ(${SYNCED_ONLY.has(c.code) ? 'VPC ์ƒ์„ฑ ๋ถˆ๊ฐ€ โ€” Resource Sync ๋กœ ๊ฐ€์ ธ์˜จ ์ž์› ์‚ฌ์šฉ' : 'vNet 2๊ฐœ์งธ๋ถ€ํ„ฐ ๊ณผ๊ธˆ'}), ์‚ฌ์šฉํ•  ๊ธฐ์กด ${kind === 'sg' ? 'SG' : 'VPC'} ์—†์Œ - SKIP`);
    name = ((await existing.innerText()) || '').trim();
    reused = true;
    test.info().annotations.push({ type: 'existing-vpc', description: `${c.name}: vNet ๊ณผ๊ธˆ CSP โ†’ ์‹ ๊ทœ ์ƒ์„ฑ ์—†์ด ๊ธฐ์กด VPC ${name} ๋กœ ๋ชฉ๋กยทํ•„ํ„ฐ ๊ฒ€์ฆ` });
  }

  if (reused || await rowOf(page, name).count()) {
    test.info().annotations.push({ type: 'preexisting', description: `${name} ์ด๋ฏธ ์กด์žฌ(๊ธฐ์กด ์ž์›) โ†’ ์ƒ์„ฑ ๊ฑด๋„ˆ๋›ฐ๊ณ  ๋ชฉ๋กยทํ•„ํ„ฐ ๊ฒ€์ฆ, PASS ๋กœ ๊ธฐ๋ก(์‚ฌ์šฉ์ž ๊ฒฐ์ • 2026-10-02)` });
  } else {
    await page.getByRole('button', { name: RES[kind].create }).click();
    const dlg = page.getByRole('dialog');
    await expect(dlg).toBeVisible();
    // Connection ๋ชฉ๋ก์— ๋Œ€์ƒ ์—ฐ๊ฒฐ์ด ์—†์œผ๋ฉด(์˜ˆ: NHN nhn-kr1 ๋ฏธ๊ฒ€์ฆ, 2026-10-02) ํ™˜๊ฒฝ ๋ฌธ์ œ๋กœ ๋ช…ํ™•ํžˆ ์‹คํŒจ์‹œํ‚จ๋‹ค
    if (kind !== 'sg') {
      const sel = dlg.locator(`#create-${{ vnet: 'vpc', key: 'sshkey', disk: 'disk' }[kind]}-connection`);
      await expect(sel.locator('option').nth(1)).toBeAttached({ timeout: 15_000 });
      const has = await sel.locator('option').evaluateAll((os, cn) => os.some((o) => (o as HTMLOptionElement).label === cn), conn);
      if (!has) {
        test.info().annotations.push({ type: 'blocked', description: `Connection ๋ชฉ๋ก์— ${conn} ์—†์Œ(Cloud SPs > Connections ์—๋„ Verified ๋ชฉ๋ก์— ์—†์Œ)` });
        throw new Error(`BLOCKED: Connection ๋ชฉ๋ก์— ${conn} ์—†์Œ`);
      }
    }
    if (kind === 'vnet') {
      await dlg.locator('#create-vpc-connection').selectOption({ label: conn });
      await dlg.locator('#create-vpc-name').fill(name);
      await dlg.locator('#create-vpc-cidr').fill(`10.${c.n}.0.0/16`);
      await dlg.getByPlaceholder('Subnet name (e.g. subnet-1)').first().fill(`s${c.n}-1`);
      await dlg.getByPlaceholder('CIDR (e.g. 10.0.0.0/24)').first().fill(`10.${c.n}.1.0/24`);
    } else if (kind === 'sg') {
      // VPC ์˜ต์…˜์€ ๋น„๋™๊ธฐ๋กœ ์ฑ„์›Œ์ง„๋‹ค(Tencent ์—์„œ selectOption timeout 1ํšŒ, 2026-10-02) โ†’ ์˜ต์…˜์ด ๋ถ™์„ ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ
      // vNet ๊ณผ๊ธˆ CSP ๋Š” ์‹ ๊ทœ VPC ๊ฐ€ ์—†์œผ๋ฏ€๋กœ ๊ฐ™์€ ์—ฐ๊ฒฐ์˜ ๊ธฐ์กด VPC ์ค‘ ์ฒซ ๋ฒˆ์งธ๋ฅผ ์‚ฌ์šฉ(์—†์œผ๋ฉด SKIP)
      let vpcLabel = `${vnetName(c)} (${conn})`;
      if (reuseVpc) {
        await dlg.locator('#create-sg-vnet-select option').nth(1).waitFor({ state: 'attached', timeout: 30_000 }).catch(() => {});
        const labels = await dlg.locator('#create-sg-vnet-select option').evaluateAll((os) => os.map((o) => (o as HTMLOptionElement).label));
        const own = labels.find((l) => l.endsWith(`(${conn})`));
        test.skip(!own, `${c.name}: vNet 2๊ฐœ์งธ๋ถ€ํ„ฐ ๊ณผ๊ธˆ โ†’ ์‹ ๊ทœ VPC ์ƒ์„ฑ ์•ˆ ํ•จ, ์„ ํƒํ•  ๊ธฐ์กด VPC(${conn}) ์—†์Œ - SKIP`);
        vpcLabel = own!;
        test.info().annotations.push({ type: 'existing-vpc', description: `SG ๋Š” ๊ธฐ์กด VPC ${vpcLabel} ์‚ฌ์šฉ` });
      }
      const vpcOpt = dlg.locator('#create-sg-vnet-select option', { hasText: vpcLabel });
      if (!(await vpcOpt.waitFor({ state: 'attached', timeout: 30_000 }).then(() => true, () => false))) {
        test.info().annotations.push({ type: 'blocked', description: `VPC ์„ ํƒ ๋ชฉ๋ก์— ${vpcLabel} ์—†์Œ(์„ ํ–‰ IV2_S01 vNet ๋ฏธ์ƒ์„ฑ)` });
        throw new Error(`BLOCKED: VPC ์„ ํƒ ๋ชฉ๋ก์— ${vpcLabel} ์—†์Œ(์„ ํ–‰ IV2_S01 ์‹คํŒจ)`);
      }
      await dlg.locator('#create-sg-vnet-select').selectOption({ label: vpcLabel });
      await dlg.locator('#create-sg-name').fill(name);
      await fillRule(page, 0, '22');
      await fillRule(page, 1, '80');
    } else if (kind === 'key') {
      await dlg.locator('#create-sshkey-connection').selectOption({ label: conn });
      await dlg.locator('#create-sshkey-name').fill(name);
    } else {
      await dlg.locator('#create-disk-connection').selectOption({ label: conn });
      await dlg.locator('#create-disk-name').fill(name);
      // Disk Type ์˜ต์…˜ 'type|์ตœ์†Œ|์ตœ๋Œ€|๋‹จ์œ„' ์—์„œ ์ตœ์†Œ ํฌ๊ธฐ ํ™•์ธ: 10GB ๋ฏธ๋งŒ ํ—ˆ์šฉ ํƒ€์ž…์ด ์—†์œผ๋ฉด ๊ทธ ์ตœ์†Œ๊ฐ’ ์‚ฌ์šฉ
      // (Alibaba 10GB โ†’ InvalidDiskSize.NotSupported, 2026-10-02)
      await page.waitForTimeout(1500);
      const types = await dlg.locator('#create-disk-type option').evaluateAll((os) => os.map((o) => (o as HTMLOptionElement).label));
      const mins = types.map((t) => Number(t.split('|')[1])).filter((n) => n > 0);
      // Disk Type ์€ ๊ธฐ๋ณธ(๋ฏธ์„ ํƒ)์œผ๋กœ ๋‘๋ฏ€๋กœ ์„œ๋ฒ„ ๊ธฐ๋ณธ ํƒ€์ž…์˜ ์ตœ์†Œ๊ฐ’์„ ํ™”๋ฉด์—์„œ ์•Œ ์ˆ˜ ์—†๋‹ค. ํ™•์ธ๋œ CSP ์ตœ์†Œ๊ฐ’(2026-10-02):
      //   NCP 100GB('SIZE range [<ACCOUNT_ID>..]', ์˜ต์…˜์— ๋ฒ”์œ„ ์—†์Œ), Alibaba 40GB(10GB โ†’ InvalidDiskSize.NotSupported, ์˜ต์…˜ ์ตœ์†Œ ์ค‘ ์ตœ๋Œ€ cloud_essd_PL0 40)
      const cspMin = ({ NCP: 100, Alibaba: 40 } as Record<string, number>)[c.code] ?? 10;
      const size = Math.max(cspMin, mins.length ? Math.min(...mins) : 10);
      test.info().annotations.push({ type: 'disk-types', description: `${types.join(', ')} โ†’ Size ${size}GB` });
      await dlg.locator('#create-disk-size').fill(String(size));
    }
    // ํ™”๋ฉด ์˜ค๋ฅ˜ ๋ฌธ๊ตฌ(ํ† ์ŠคํŠธ/์•Œ๋Ÿฟ) ์ˆ˜์ง‘: ์ƒ์„ฑ ์‹คํŒจ ์‹œ ์ œํ’ˆ ์˜ค๋ฅ˜์ธ์ง€ ํŒ๋‹จ ๊ทผ๊ฑฐ๋กœ ๋‚จ๊ธด๋‹ค
    const seen = new Set<string>();
    const grab = async () => {
      for (const t of await page.locator('[class*="toast"], [role="alert"], .invalid-feedback:visible, .alert:visible').allInnerTexts().catch(() => [] as string[])) {
        const s = t.replace(/\s+/g, ' ').trim();
        if (s) seen.add(s.slice(0, 300));
      }
    };
    // ์‹คํŒจํ•œ API ์‘๋‹ต ๋ณธ๋ฌธ ๊ธฐ๋ก(ํ™”๋ฉด์—๋Š” 'status code 500' ๋งŒ ๋ณด์—ฌ ์›์ธ ํŒ๋‹จ ๋ถˆ๊ฐ€, 2026-10-02 NCP vNet)
    const apiErrors: string[] = [];
    page.on('response', async (r) => {
      if (r.status() >= 400 && r.request().method() !== 'GET') {
        const body = await r.text().catch(() => '');
        apiErrors.push(`${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, '')} ${body.replace(/\s+/g, ' ').slice(0, 400)}`);
      }
    });
    await dlg.getByRole('button', { name: /^Create/ }).last().click();
    const closed = await (async () => {
      for (let i = 0; i < 90; i++) {
        await grab();
        if (!(await dlg.isVisible())) return true;
        await page.waitForTimeout(1000);
      }
      return false;
    })();
    if (seen.size) test.info().annotations.push({ type: 'create-messages', description: [...seen].join(' | ') });
    if (apiErrors.length) test.info().annotations.push({ type: 'api-errors', description: apiErrors.join(' || ') });
    expect(closed, `Create ํ›„ ๋‹ค์ด์–ผ๋กœ๊ทธ๊ฐ€ ๋‹ซํ˜€์•ผ ํ•จ(ํ™”๋ฉด ๋ฉ”์‹œ์ง€: ${[...seen].join(' | ') || '์—†์Œ'})`).toBe(true);
    await gotoList(page, kind);
  }

  // ๋ชฉ๋ก ํ‘œ์‹œ
  const prov = providerKey(c);
  const row = rowOf(page, name);
  await expect(row, `${name} ๊ฐ€ ๋ชฉ๋ก์— ํ‘œ์‹œ`).toHaveCount(1, { timeout: 60_000 });
  const rowText = (await row.innerText()).replace(/\s+/g, ' ');
  test.info().annotations.push({ type: 'list-row', description: rowText });
  expect(rowText.toLowerCase()).toContain(prov);

  await filterByProvider(page, prov, name);
}

/** ๋ชฉ๋ก Provider ๊ฐ’(์†Œ๋ฌธ์ž): AWSโ†’aws, NHNCloudโ†’nhn ... */
export const providerKey = (c: Csp) => (c.code === 'NHNCloud' ? 'nhn' : c.code.toLowerCase());

/**
 * CSP ํ•„ํ„ฐ (2026-10-02 probe ํ™•์ธ): ๋ชฉ๋ก ์šฐ์ƒ๋‹จ ๊น”๋•Œ๊ธฐ([data-bs-target="#filter-body"]) โ†’ #filter-provider ์„ ํƒ.
 * ์„ ํƒํ•œ Provider ํ–‰๋งŒ ๋‚จ๊ณ  ๋Œ€์ƒ ์ž์›์ด ํฌํ•จ๋˜๋Š”์ง€ ํ™•์ธํ•œ ๋’ค Clear ๋กœ ์›๋ณตํ•œ๋‹ค.
 * Provider ์˜ต์…˜์€ ๋ชฉ๋ก์— ์žˆ๋Š” ๊ฐ’๋งŒ ํ‘œ์‹œ๋จ(ํ…Œ์ŠคํŠธ_๋ฉ”๋ชจ #4 ๊ฐœ์„  ์‚ฌํ•ญ).
 */
export async function filterByProvider(page: Page, prov: string, name: string) {
  const sel = page.locator('#filter-provider');
  if (!(await sel.isVisible())) await page.locator('[data-bs-target="#filter-body"]').first().click();
  await expect(sel).toBeVisible();
  const options = await sel.locator('option').evaluateAll((os) => os.map((o) => (o as HTMLOptionElement).value).filter(Boolean));
  test.info().annotations.push({ type: 'filter-provider-options', description: options.join(', ') });
  await sel.selectOption(prov);
  await page.waitForTimeout(1500);
  const status = await page.getByText(/Showing \d+-\d+ of \d+ rows/).first().innerText().catch(() => '-');
  // ๋ฐ์ดํ„ฐ ํ–‰์˜ Provider ์—ด ๊ฐ’์ด ๋ชจ๋‘ ์„ ํƒํ•œ Provider ์ธ์ง€ ํ™•์ธ (์—ด ์œ„์น˜๋Š” ํ—ค๋” ์ œ๋ชฉ์œผ๋กœ ์ฐพ๋Š”๋‹ค)
  const provs = await page.locator('.tabulator').first().evaluate((t) => {
    const cols = [...t.querySelectorAll('.tabulator-header .tabulator-col')].map((c) => c.getAttribute('tabulator-field') ?? '');
    const titles = [...t.querySelectorAll('.tabulator-header .tabulator-col')].map((c) => (c.textContent ?? '').trim());
    const field = cols[titles.indexOf('Provider')];
    return [...t.querySelectorAll('.tabulator-row')].map((r) => (r.querySelector(`.tabulator-cell[tabulator-field="${field}"]`)?.textContent ?? '').trim().toLowerCase());
  });
  test.info().annotations.push({ type: 'filter-result', description: `Provider=${prov} โ†’ ${status} / ํ–‰ Provider: ${[...new Set(provs)].join(',')}` });
  expect(provs.length, `Provider=${prov} ํ•„ํ„ฐ ํ›„ ํ–‰์ด ์žˆ์–ด์•ผ ํ•จ`).toBeGreaterThan(0);
  expect([...new Set(provs)], `Provider=${prov} ํ•„ํ„ฐ ํ›„ ๋‹ค๋ฅธ CSP ํ–‰์ด ์—†์–ด์•ผ ํ•จ`).toEqual([prov]);
  await expect(rowOf(page, name), `ํ•„ํ„ฐ ํ›„ ${name} ํ‘œ์‹œ`).toHaveCount(1);
  await page.locator('#filter-clear').click().catch(() => {});
  await sel.selectOption('');
}

lib/roles.ts

ํ•ด์‹œ 9c919115 ยท 94์ค„

import fs from 'node:fs';
import path from 'node:path';
import type { Page } from '@playwright/test';
import { test, expect } from './fixtures';
import { loginAndSettle, menuSnapshot, selectWorkspaceProject, toasts } from './ui';
import { USERS, pwOf } from './data';
import { URL_GROUPS, URL_USERS, URL_ROLES } from './forms';

/**
 * ์—ญํ• (role)๋ณ„ ๋ฉ”๋‰ด ๋…ธ์ถœ / ํƒ€ ๋ฉ”๋‰ด ์ง์ ‘ ์ ‘๊ทผ (IV1_S05~S09, IV1_S13~S16 ์ด ๊ณต์œ ).
 * 2026-10-01 ์‚ฌ์ „์ ๊ฒ€ ๊ฒฐ๊ณผ ๋ฉ”๋‰ด๋Š” ํŒ€์ด ์•„๋‹ˆ๋ผ "์—ญํ• " ๊ธฐ์ค€์ด๋ฏ€๋กœ ์—ญํ• ๋ณ„ ๋Œ€ํ‘œ ์‚ฌ์šฉ์ž 1๋ช…์œผ๋กœ ๊ฒ€์ฆํ•œ๋‹ค.
 * ๋ฉ”๋‰ด ๊ธฐ๋Œ€๊ฐ’์€ tests/baseline/menus.{user}.json ์— ์ €์žฅํ•ด ๋น„๊ตํ•œ๋‹ค(์—†์œผ๋ฉด ์ฒซ ์‹คํ–‰์—์„œ ์ƒ์„ฑ โ†’ ์‚ฌ๋žŒ์ด ๊ฒ€ํ† ).
 */
const BASELINE_DIR = path.join(__dirname, '..', 'tests', 'baseline');
const user = (id: string) => USERS.find((u) => u.id === id)!;

/** ์‹œ๋‚˜๋ฆฌ์˜ค ๋ฒˆํ˜ธ -> ๋Œ€ํ‘œ ์‚ฌ์šฉ์ž */
export const MENU_USERS: Record<string, string> = {
  IV1_S05: 'iam01', IV1_S06: 'workflow01', IV1_S07: 'monitor01', IV1_S08: 'cost01', IV1_S09: 'cost02',
};
export const ACCESS_USERS: Record<string, string> = {
  IV1_S13: 'workflow01', IV1_S14: 'monitor01', IV1_S15: 'cost01', IV1_S16: 'cost02',
};

export function menuTest(scn: keyof typeof MENU_USERS) {
  const u = user(MENU_USERS[scn]);
  test(`${scn} ์—ญํ•  ${u.role}: ${u.id} ๋กœ๊ทธ์ธ -> ๋…ธ์ถœ ๋ฉ”๋‰ด ํ™•์ธ`, async ({ page }) => {
    await loginAndSettle(page, u.id, pwOf(u.id));
    await selectWorkspaceProject(page, 'ws01', 'default');
    const menus = await menuSnapshot(page);
    expect(menus.length).toBeGreaterThan(0);

    const file = path.join(BASELINE_DIR, `menus.${u.id}.json`);
    if (!fs.existsSync(file)) {
      fs.mkdirSync(BASELINE_DIR, { recursive: true });
      fs.writeFileSync(file, JSON.stringify(menus, null, 2));
      test.info().annotations.push({ type: 'baseline', description: `์ƒ์„ฑ๋จ: ${file} (๊ฒ€ํ†  ํ•„์š”)` });
    } else {
      expect(menus).toEqual(JSON.parse(fs.readFileSync(file, 'utf-8')));
    }
  });
}

/** ์ง์ ‘ URL ์ ‘๊ทผ ์ ๊ฒ€ ๋Œ€์ƒ ํ™”๋ฉด๊ณผ, ๊ทธ ํ™”๋ฉด์ด ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” API (mc-iam-manager) */
const ACCESS_TARGETS: Record<string, { url: string; api: string }> = {
  Groups: { url: URL_GROUPS, api: 'Getorganizations' },
  Users: { url: URL_USERS, api: 'Listusers' },
  Roles: { url: URL_ROLES, api: 'GetRoleList' },
};

/**
 * ์ง์ ‘ ์ด๋™ ํ›„ ์ ‘๊ทผ์ด ๋ง‰ํ˜”๋Š”์ง€: ๋‹ค๋ฅธ ์ฃผ์†Œ๋กœ ํŠ•๊ธฐ๊ฑฐ๋‚˜, ํ™”๋ฉด์˜ ๋ชฉ๋ก API ๊ฐ€ 401/403 ์ด๋ฉด ์ฐจ๋‹จ์œผ๋กœ ๋ณธ๋‹ค.
 * ํ† ์ŠคํŠธ๋งŒ์œผ๋กœ ํŒ๋‹จํ•˜๋ฉด API ์š”์ฒญ์ด ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๋กœ ๋Š๊ธด ๊ฒฝ์šฐ(2026-10-02 Groups, status -1)๋ฅผ '์—ด๋ฆผ'์œผ๋กœ ์˜คํŒํ•œ๋‹ค
 * โ†’ API ์‘๋‹ต ์ฝ”๋“œ๋กœ ํŒ์ •ํ•˜๊ณ , ์‘๋‹ต์ด ์—†์œผ๋ฉด ํ™˜๊ฒฝ ๋ฌธ์ œ๋กœ ๋ณด๊ณ  ๋‹ค์‹œ ์ด๋™ํ•œ๋‹ค.
 */
async function accessDenied(page: Page, url: string, api: string): Promise<{ denied: boolean; evidence: string }> {
  for (let attempt = 1; attempt <= 3; attempt++) {
    const resp = page.waitForResponse((r) => r.url().includes(`/api/mc-iam-manager/${api}`), { timeout: 20_000 }).catch(() => null);
    await page.goto(url);
    const r = await resp;
    const path = new URL(page.url()).pathname;
    if (!path.startsWith(url)) return { denied: true, evidence: `redirect โ†’ ${path}` };
    if (r) {
      await page.waitForTimeout(1500); // ํ† ์ŠคํŠธ ํ‘œ์‹œ ๋Œ€๊ธฐ(์ฆ๊ฑฐ ๊ธฐ๋ก์šฉ)
      const msg = (await toasts(page)).join(' | ').replace(/\s+/g, ' ').slice(0, 120);
      return { denied: [401, 403].includes(r.status()), evidence: `${api} ${r.status()}${msg ? `, toast: ${msg}` : ''}` };
    }
    test.info().annotations.push({ type: 'api-no-response', description: `${api} ์‘๋‹ต ์—†์Œ(์‹œ๋„ ${attempt}) โ†’ ๋‹ค์‹œ ์ด๋™` });
  }
  throw new Error(`${api} ์‘๋‹ต์„ ๋ฐ›์ง€ ๋ชปํ•จ(๋„คํŠธ์›Œํฌ/ํ™˜๊ฒฝ ๋ฌธ์ œ) โ€” ํŒ์ • ๋ถˆ๊ฐ€`);
}

/**
 * ๊ธฐ์ค€(์‚ฌ์šฉ์ž ์ง€์‹œ, 2026-10-02): ์ขŒ์ธก ๋ฉ”๋‰ด์— ์žˆ๋Š” ํ™”๋ฉด์€ ์ง์ ‘ URL ๋กœ๋„ ์—ด๋ ค์•ผ ํ•˜๊ณ , ๋ฉ”๋‰ด์— ์—†๋Š” ํ™”๋ฉด์€ ์ง์ ‘ ์ ‘๊ทผ์ด ๋ง‰ํ˜€์•ผ ํ•œ๋‹ค.
 * ๋ฉ”๋‰ด ์—ฌ๋ถ€๋Š” IV1_S05~S09 ๊ฐ€ ๋งŒ๋“  tests/baseline/menus.{id}.json ์œผ๋กœ ํŒ๋‹จํ•œ๋‹ค.
 * ํ™”๋ฉด ์•ˆ ๋ฒ„ํŠผ(Add Role ๋“ฑ)์˜ ๋™์ž‘์€ ๋ณ„๋„ policy ๋กœ ์ œ์–ดํ•  ๊ธฐ๋Šฅ(๋ฏธ๊ตฌํ˜„)์ด๋ฏ€๋กœ ์—ฌ๊ธฐ์„œ ๊ฒ€์ฆํ•˜์ง€ ์•Š๋Š”๋‹ค.
 */
export function accessTest(scn: keyof typeof ACCESS_USERS) {
  const u = user(ACCESS_USERS[scn]);
  test(`${scn} ์—ญํ•  ${u.role}: ${u.id} ํƒ€ ๋ฉ”๋‰ด URL ์ง์ ‘ ์ ‘๊ทผ (์˜ˆ์ƒ์‹คํŒจ)`, async ({ page }) => {
    const file = path.join(BASELINE_DIR, `menus.${u.id}.json`);
    if (!fs.existsSync(file)) throw new Error(`๋ฉ”๋‰ด ๊ธฐ์ค€ ์—†์Œ: ${file} (IV1_S05~S09 ๋ฅผ ๋จผ์ € ์‹คํ–‰)`);
    const menuHrefs = new Set((JSON.parse(fs.readFileSync(file, 'utf-8')) as string[]).map((m) => m.split(' -> ')[1]));

    await loginAndSettle(page, u.id, pwOf(u.id));
    for (const [name, { url, api }] of Object.entries(ACCESS_TARGETS)) {
      const inMenu = menuHrefs.has(url);
      const r = await accessDenied(page, url, api);
      test.info().annotations.push({ type: `access-${name}`, description: `๋ฉ”๋‰ด ${inMenu ? '์žˆ์Œ' : '์—†์Œ'} โ†’ ${r.denied ? '์ฐจ๋‹จ' : '์—ด๋ฆผ'} (${r.evidence})` });
      expect.soft(r.denied, `${name}: ๋ฉ”๋‰ด ${inMenu ? '์žˆ์Œ โ†’ ์—ด๋ ค์•ผ ํ•จ' : '์—†์Œ โ†’ ์ง์ ‘ ์ ‘๊ทผ์ด ๋ง‰ํ˜€์•ผ ํ•จ'}`).toBe(!inMenu);
    }
  });
}

lib/runtag.ts

ํ•ด์‹œ d6bac09c ยท 7์ค„

// ์‹คํ–‰ ํšŒ์ฐจ ํƒœ๊ทธ. ๋…นํ™” ํŒŒ์ผ๋ช… prefix(`${RUN_TAG}_`)์™€ ๊ฒฐ๊ณผ ํด๋”(results/${RUN_TAG}/)์— ์“ด๋‹ค.
// ๊ธฐ๋ณธ๊ฐ’์„ ์ฝ”๋“œ์— ๋‘” ์ด์œ : ์—ฌ๋Ÿฌ ์„ธ์…˜์ด WSL ์—์„œ ์‹คํ–‰ํ•˜๋ฉฐ PowerShell ํ™˜๊ฒฝ๋ณ€์ˆ˜๋Š” WSL ๋กœ ์ „๋‹ฌ๋˜์ง€ ์•Š๋Š”๋‹ค.
// ํšŒ์ฐจ๋ฅผ ๋ฐ”๊ฟ€ ๋•Œ๋Š” ์ด ๊ธฐ๋ณธ๊ฐ’์„ ๊ณ ์น˜๊ฑฐ๋‚˜ RUN_TAG=<ํƒœ๊ทธ> ๋ฅผ ์ง€์ •ํ•œ๋‹ค. RUN_TAG= (๋นˆ ๊ฐ’)์ด๋ฉด prefix/ํด๋” ๋ถ„๋ฆฌ๋ฅผ ํ•˜์ง€ ์•Š๋Š”๋‹ค.
export const RUN_TAG = process.env.RUN_TAG ?? 'v07';
export const RUN_DIR = RUN_TAG ? `results/${RUN_TAG}` : 'results';
export const REC_PREFIX = RUN_TAG ? `${RUN_TAG}_` : '';

lib/sync.ts

ํ•ด์‹œ f0046219 ยท 118์ค„

import { test, expect } from './fixtures';
import type { Page } from '@playwright/test';
import { selectWorkspaceProject, toasts } from './ui';

// Cloud Resources > Resource Sync ํ™”๋ฉด ํ—ฌํผ (2026-10-08 AWS/NHN/NCP ํ”„๋กœ๋ธŒ๋กœ ํ™•์ธ โ€” ์กฐ๊ฑด ์ž…๋ ฅ์€ IV2_S06 NHN ๊ธฐ์ค€๊ณผ ๊ฐ™๋‹ค)
//  - ์กฐ๊ฑด: radio name=query-provider-radio / query-restype-radio, ์ฒดํฌ๋ฐ•์Šค value=<provider>(์ฒซ์งธ=Provider, ๋‘˜์งธ=Connection ๊ทธ๋ฃน), <connection>, vNet/securityGroup/...
//  - 'CSP Resource Sync Status' ํ‘œ: 'Fetching resource status...' ํ›„ ์—ฐ๊ฒฐ๋ณ„ ํ–‰, ํ–‰ ์ฒดํฌ๋ฐ•์Šค๋กœ ์—ฐ๊ฒฐ ์„ ํƒ(ํ–‰ ํด๋ฆญ์€ Connection Detail ๋งŒ)
//  - Run Sync: #sync-ns, radio name=provider-radio / restype-radio, ์—ฐ๊ฒฐ์„ ๊ณ ๋ฅด๋ฉด Target Provider ๊ฐ€ ์ž๋™ ์ฒดํฌ, ์‹คํ–‰ ์‹œ ๋ธŒ๋ผ์šฐ์ € ํ™•์ธ์ฐฝ
export const SYNC_URL = '/webconsole/settings/environment/cloudresources/resourcesync';
export type SyncType = 'vNet' | 'securityGroup' | 'sshKey' | 'node' | 'dataDisk' | 'customImage';

export async function runResourceSync(page: Page, o: { provider: string; conns: string[]; types: SyncType[]; ns?: string }) {
  await page.goto(SYNC_URL);
  await selectWorkspaceProject(page, 'ws01', 'default', true);
  await expect(page.locator('input[name="query-provider-radio"][value="partial"]')).toBeVisible({ timeout: 60_000 });
  await page.waitForTimeout(3000);

  // Search Conditions
  await page.locator('input[name="query-provider-radio"][value="partial"]').check();
  await page.locator(`input[value="${o.provider}"]`).first().check();
  await expect(page.locator(`input[value="${o.conns[0]}"]`).first(), `Provider ${o.provider} ์ฒดํฌ ํ›„ Connection ๋ชฉ๋ก ํ‘œ์‹œ`).toBeVisible({ timeout: 15_000 });
  await page.locator(`input[value="${o.provider}"]`).nth(1).check().catch(() => {}); // Connection ๊ทธ๋ฃน ์ฒดํฌ(์—ฐ๊ฒฐ์ด ํ•˜์œ„์— ๋ชจ๋‘ ์ฒดํฌ๋จ)
  // ๊ทธ๋ฃน ์ฒดํฌ๋Š” ํ•˜์œ„ ์—ฐ๊ฒฐ์„ ๋ชจ๋‘ ์ฒดํฌํ•˜๋ฏ€๋กœ, ์ง€์ •ํ•œ ์—ฐ๊ฒฐ๋งŒ ๋‚จ๊ธฐ๊ณ  ๊ฐ™์€ Provider ์˜ ๋‹ค๋ฅธ ์—ฐ๊ฒฐ์€ ํ•ด์ œํ•œ๋‹ค(์˜ˆ: NCP ๋Š” ncp-kr ๋งŒ, ncp-jpnยทncp-sgn ์ œ์™ธ)
  for (const v of await page.locator(`input[type=checkbox][value^="${o.provider}-"]`).evaluateAll((es) => es.map((e) => (e as HTMLInputElement).value))) {
    const box = page.locator(`input[value="${v}"]`).first();
    if (!o.conns.includes(v) && (await box.isChecked())) await box.uncheck();
  }
  for (const cn of o.conns) {
    const box = page.locator(`input[value="${cn}"]`).first();
    if (!(await box.isChecked())) await box.check();
    await expect(box, `${cn} ์ฒดํฌ`).toBeChecked();
  }
  await page.locator('input[name="query-restype-radio"][value="partial"]').check();
  for (const t of o.types) {
    const box = page.locator(`input[value="${t}"]`).first();
    if (!(await box.isChecked())) await box.check();
  }
  await page.getByRole('button', { name: 'Query', exact: true }).click();

  // CSP Resource Sync Status: 'Fetching resource status...' ๊ฐ€ ๋๋‚˜๋ฉด ์—ฐ๊ฒฐ๋ณ„ ํ–‰์ด ๋‚˜์˜จ๋‹ค โ†’ ํ–‰ ์ฒดํฌ๋ฐ•์Šค๋กœ ์—ฐ๊ฒฐ ์„ ํƒ
  await expect(page.getByText('Fetching resource status')).toBeHidden({ timeout: 180_000 });
  await page.waitForTimeout(1500);
  const q = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
  const status = q.slice(q.indexOf('CSP Resource Sync Status'), q.indexOf('CSP Resource Sync Status') + 420);
  test.info().annotations.push({ type: 'sync-status-before', description: status });
  for (const cn of o.conns) {
    const row = page.getByRole('row').filter({ has: page.getByRole('gridcell', { name: cn, exact: true }) }).first();
    await expect(row, `Sync Status ์— ${cn} ํ–‰`).toBeVisible({ timeout: 30_000 });
    await row.getByRole('checkbox').first().check();
  }
  const t1 = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
  test.info().annotations.push({ type: 'target-connection', description: t1.slice(t1.indexOf('Target Connection'), t1.indexOf('Target Connection') + 120) });

  // Run Sync: Namespace, Target Provider(Partial+provider), Resource Type(Partial+types)
  await page.locator('#sync-ns').selectOption({ label: o.ns ?? 'default' });
  await page.locator('input[name="provider-radio"][value="partial"]').check();
  await page.waitForTimeout(800);
  const runProv = page.locator('input[name="provider-radio"][value="partial"]').locator(`xpath=following::input[@value="${o.provider}"][1]`);
  if (!(await runProv.isChecked())) await runProv.check();
  await page.locator('input[name="restype-radio"][value="partial"]').check();
  await page.waitForTimeout(800);
  for (const t of o.types) await page.locator('input[name="restype-radio"][value="partial"]').locator(`xpath=following::input[@value="${t}"][1]`).check();
  await page.getByRole('button', { name: 'Run Sync', exact: true }).click(); // ๋ธŒ๋ผ์šฐ์ € ํ™•์ธ์ฐฝ์€ fixture ๊ฐ€ ์ˆ˜๋ฝํ•˜๊ณ  ๋ฌธ๊ตฌ๋Š” 'dialogs' ๋กœ ๊ธฐ๋ก๋จ
  const msgs = new Set<string>();
  for (let i = 0; i < 24; i++) {
    for (const m of await page.locator('.modal.show').all()) { const t = (await m.innerText().catch(() => '')).replace(/\s+/g, ' ').trim(); if (t) msgs.add(`modal: ${t.slice(0, 300)}`); }
    for (const t of await toasts(page)) { const s = t.replace(/\s+/g, ' ').trim(); if (s) msgs.add(`toast: ${s.slice(0, 300)}`); }
    await page.waitForTimeout(5000);
  }
  test.info().annotations.push({ type: 'run-sync-messages', description: [...msgs].join(' || ').slice(0, 800) || '(ํ™”๋ฉด ๋ฉ”์‹œ์ง€ ์—†์Œ)' });
}

/**
 * ์กฐ๊ฑด ์ž…๋ ฅ + Query ๋งŒ ํ•˜๊ณ (Run Sync ์•ˆ ํ•จ) CSP Resource Sync Status ํ‘œ๋ฅผ ์ฝ๋Š”๋‹ค.
 * ๋ฐ˜ํ™˜: ์—ฐ๊ฒฐ โ†’ { ์—ด ์ด๋ฆ„: '๋“ฑ๋ก์ˆ˜/์ „์ฒด์ˆ˜' } (์—ด์€ ์„ ํƒํ•œ Resource Type ์ด๋ฆ„: vNet, Security Group ...)
 */
export async function queryResourceStatus(page: Page, o: { provider: string; conns: string[]; types: Array<{ value: SyncType; label: string }> }) {
  await page.goto(SYNC_URL);
  await selectWorkspaceProject(page, 'ws01', 'default', true);
  await expect(page.locator('input[name="query-provider-radio"][value="partial"]')).toBeVisible({ timeout: 60_000 });
  await page.waitForTimeout(3000);
  await page.locator('input[name="query-provider-radio"][value="partial"]').check();
  await page.locator(`input[value="${o.provider}"]`).first().check();
  await expect(page.locator(`input[value="${o.conns[0]}"]`).first()).toBeVisible({ timeout: 15_000 });
  await page.locator(`input[value="${o.provider}"]`).nth(1).check().catch(() => {});
  for (const v of await page.locator(`input[type=checkbox][value^="${o.provider}-"]`).evaluateAll((es) => es.map((e) => (e as HTMLInputElement).value))) {
    const box = page.locator(`input[value="${v}"]`).first();
    if (!o.conns.includes(v) && (await box.isChecked())) await box.uncheck();
  }
  for (const cn of o.conns) { const box = page.locator(`input[value="${cn}"]`).first(); if (!(await box.isChecked())) await box.check(); }
  await page.locator('input[name="query-restype-radio"][value="partial"]').check();
  for (const t of o.types) { const box = page.locator(`input[value="${t.value}"]`).first(); if (!(await box.isChecked())) await box.check(); }
  await page.getByRole('button', { name: 'Query', exact: true }).click();
  await expect(page.getByText('Fetching resource status')).toBeHidden({ timeout: 180_000 });
  await page.waitForTimeout(1500);
  const out: Record<string, Record<string, string>> = {};
  for (const cn of o.conns) {
    const row = page.getByRole('row').filter({ has: page.getByRole('gridcell', { name: cn, exact: true }) }).first();
    await expect(row, `Sync Status ์— ${cn} ํ–‰`).toBeVisible({ timeout: 30_000 });
    const cells = (await row.getByRole('gridcell').allInnerTexts()).map((s) => s.trim());
    const rec: Record<string, string> = {};
    for (const t of o.types) rec[t.label] = cells.filter((s) => /^\d+\/\d+$/.test(s))[o.types.indexOf(t)] ?? '(์—†์Œ)';
    out[cn] = rec;
  }
  return out;
}

/** ๋ชฉ๋ก ํ™”๋ฉด์—์„œ ํ•ด๋‹น provider(์†Œ๋ฌธ์ž) ํ–‰ ์ด๋ฆ„๋“ค์„ ์ฝ๋Š”๋‹ค(Page Size 50). */
export async function listProviderRows(page: Page, url: string, prov: string) {
  await page.goto(url);
  await selectWorkspaceProject(page, 'ws01', 'default', true);
  await page.getByText(/Showing \d+-\d+ of \d+ rows|No registered/).first().waitFor({ timeout: 30_000 }).catch(() => {});
  await page.waitForTimeout(1500);
  const size = page.locator('select').filter({ has: page.locator('option', { hasText: /^50$/ }) }).last();
  if (await size.count()) { await size.selectOption({ label: '50' }).catch(() => {}); await page.waitForTimeout(1500); }
  return (await page.locator('.tabulator-row').allInnerTexts()).map((s) => s.replace(/\s+/g, ' ')).filter((s) => new RegExp(`\\b${prov}\\b`, 'i').test(s));
}

lib/ui.ts

ํ•ด์‹œ bab45566 ยท 137์ค„

import { test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { ADMIN, WORKSPACE, DEFAULT_PROJECT } from './data';

// ์•„๋ž˜ ์…€๋ ‰ํ„ฐ๋Š” 2026-10-01 IV1 ์‚ฌ์ „์ ๊ฒ€์—์„œ ํ™•์ธํ•œ ํ™”๋ฉด ๊ธฐ์ค€์ด๋‹ค.
// ํ™•์ธ ๋ชป ํ•œ ํ™”๋ฉด(IV2 ์ดํ›„)์˜ ์…€๋ ‰ํ„ฐ๋Š” ํ•ด๋‹น spec ์— TODO ๋กœ ํ‘œ์‹œํ–ˆ๋‹ค.

/** ๋กœ๊ทธ์ธ. ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ˜ธ์ถœํ•˜๋Š” ์ชฝ์—์„œ {id}_password ๊ทœ์น™์œผ๋กœ ์ „๋‹ฌํ•œ๋‹ค. */
export async function login(page: Page, id: string, pw: string) {
  await page.goto('/auth/login');
  await page.getByPlaceholder('Your ID').fill(id);
  await page.getByPlaceholder('Your password').fill(pw);
  await page.getByRole('button', { name: 'Login' }).click();
}

/** ๋กœ๊ทธ์ธ ํ›„ ์ฒซ ํ™”๋ฉด์˜ "Workspace Selection Check" ๋ชจ๋‹ฌ์ด ๋œจ๋ฉด ๋‹ซ๋Š”๋‹ค. */
export async function dismissWorkspaceCheck(page: Page) {
  const confirm = page.getByRole('dialog').getByRole('button', { name: 'Confirm' });
  if (!(await confirm.isVisible({ timeout: 4000 }).catch(() => false))) return;
  // ๋ชจ๋‹ฌ์ด ๋ณด์ด๋Š”๋ฐ ์• ๋‹ˆ๋ฉ”์ด์…˜/๊ฒน์นจ์œผ๋กœ ํด๋ฆญ์ด 20์ดˆ ๋Œ€๊ธฐ ํ›„ ์‹คํŒจํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์Œ(2026-10-02~05 IV3ยทIV4 ๊ด€์ฐฐ) โ†’ ์งง๊ฒŒ ์‹œ๋„ ํ›„ ๊ฐ•์ œ ํด๋ฆญ, ๊ทธ๋ž˜๋„ ์•ˆ ๋˜๋ฉด ESC
  await confirm.click({ timeout: 5000 })
    .catch(() => confirm.click({ force: true, timeout: 3000 }))
    .catch(() => page.keyboard.press('Escape'));
}

export async function loginAndSettle(page: Page, id: string, pw: string) {
  // stage ๋Š” ๊ฐ„ํ—์ ์œผ๋กœ ๋„คํŠธ์›Œํฌ ๋ณ€๊ฒฝ/์ง€์—ฐ(ERR_NETWORK_CHANGED, ๋กœ๊ทธ์ธ ์‘๋‹ต ์ง€์—ฐ)์ด ๋ฐœ์ƒ โ†’ 1ํšŒ ์žฌ์‹œ๋„
  for (let attempt = 1; ; attempt++) {
    try {
      await login(page, id, pw);
      // ๋กœ๊ทธ์ธ ์‘๋‹ต์ด 20์ดˆ๋ฅผ ๋„˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žฆ์Œ(2026-10-05 IV1 ์žฌ๋…นํ™” ์ค‘ ์—ฐ์† ๋ฐœ์ƒ) โ†’ 45์ดˆ
      await page.waitForURL((u) => !u.pathname.startsWith('/auth/'), { timeout: 45_000 });
      break;
    } catch (e) {
      if (attempt >= 2) throw e;
      test.info().annotations.push({ type: 'login-retry', description: String(e).split('\n')[0].slice(0, 120) });
    }
  }
  await dismissWorkspaceCheck(page);
}

export async function loginAsAdmin(page: Page, workspace = WORKSPACE, project: string | null = DEFAULT_PROJECT) {
  await loginAndSettle(page, ADMIN.id, ADMIN.pw);
  // admin ์€ ws01 / default ๊ฐ€ ๊ธฐ๋ณธ ์กด์žฌ โ†’ ๋“œ๋กญ๋‹ค์šด์ด ๋น„์–ด ์žˆ์œผ๋ฉด ๋น„์ •์ƒ(strict): ์ƒˆ๋กœ๊ณ ์นจ ํ›„ ์žฌ์‹œ๋„, ๋๋‚ด ์—†์œผ๋ฉด ์‹คํŒจ
  await selectWorkspaceProject(page, workspace, project, true);
}

/** ๋กœ๊ทธ์•„์›ƒ: ์„ธ์…˜ ์ฟ ํ‚ค ์‚ญ์ œ ํ›„ ๋กœ๊ทธ์ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ (๋กœ๊ทธ์•„์›ƒ ๋ฉ”๋‰ด ์œ„์น˜๋Š” ๋ฏธํ™•์ธ). */
export async function logout(page: Page) {
  await page.context().clearCookies();
  await page.goto('/auth/login');
}

/** ์ƒ๋‹จ Workspace / Project ์„ ํƒ. ์ฃผ์†Œ ์ง์ ‘ ์ด๋™ ์‹œ ์„ ํƒ์ด ์ดˆ๊ธฐํ™”๋˜๋ฏ€๋กœ ์ด๋™ ํ›„ ๋‹ค์‹œ ํ˜ธ์ถœํ•œ๋‹ค. */
export async function selectWorkspaceProject(page: Page, workspace: string, project: string | null, strict = false) {
  if (strict) {
    // ๋กœ๊ทธ์ธ ์งํ›„ Workspace/Project ๋ฏธ์„ ํƒ ๊ฒฝ๊ณ ์ฐฝ์ด ๋œฐ ์ˆ˜ ์žˆ๋‹ค โ†’ ๋‹ซ๊ณ , ๋ชฉ๋ก์ด ์ฑ„์›Œ์ง€์ง€ ์•Š์œผ๋ฉด ์ƒˆ๋กœ๊ณ ์นจ ์žฌ์‹œ๋„
    for (let attempt = 1; attempt <= 3; attempt++) {
      await dismissWorkspaceCheck(page);
      // <option> ์€ visible ์ด ๋  ์ˆ˜ ์—†์œผ๋ฏ€๋กœ state:'attached' ๋กœ ๋Œ€๊ธฐ
      const wsOk = await page.locator('#select-current-workspace option', { hasText: workspace }).first().waitFor({ state: 'attached', timeout: 8000 }).then(() => true, () => false);
      // Project ๋ชฉ๋ก์€ Workspace ๋ฅผ ์„ ํƒํ•ด์•ผ ์ฑ„์›Œ์ง„๋‹ค(2026-10-02 ํ™•์ธ) โ†’ ๋จผ์ € Workspace ์„ ํƒ ํ›„ ํ™•์ธ
      if (wsOk) await page.locator('#select-current-workspace').selectOption({ label: workspace });
      const prjOk = !project || (wsOk && (await page.locator('#select-current-project option', { hasText: project }).first().waitFor({ state: 'attached', timeout: 10_000 }).then(() => true, () => false)));
      if (wsOk && prjOk) break;
      if (attempt === 3) throw new Error(`Workspace/Project ๋“œ๋กญ๋‹ค์šด์— ${workspace}/${project} ์—†์Œ(๊ธฐ๋ณธ๊ฐ’์ด๋ฏ€๋กœ ๋น„์ •์ƒ)`);
      test.info().annotations.push({ type: 'dropdown-empty-retry', description: `์‹œ๋„ ${attempt}: ๋“œ๋กญ๋‹ค์šด ๋น„์–ด ์žˆ์Œ โ†’ ์ƒˆ๋กœ๊ณ ์นจ` });
      await page.reload();
    }
  }
  const wsBox = page.locator('#select-current-workspace');
  const prjBox = page.locator('#select-current-project');
  // ์„ ํƒ ๋ชฉ๋ก์€ ๋น„๋™๊ธฐ๋กœ ์ฑ„์›Œ์ง„๋‹ค โ†’ ์ž ๊น ๋Œ€๊ธฐ. ์—ญํ• ์— ๋”ฐ๋ผ ๋ชฉ๋ก์ด ๋น„์–ด ์žˆ์„ ์ˆ˜ ์žˆ๋‹ค(2026-10-01 ๊ด€์ฐฐ):
  //   billadmin(cost02): Workspace ๋ชฉ๋ก ๋น„์–ด ์žˆ์Œ / viewer(monitor01): Project ๋ชฉ๋ก ๋น„์–ด ์žˆ์Œ
  const has = async (box: ReturnType<Page['locator']>, label: string) => {
    for (let i = 0; i < 8; i++) {
      if ((await box.locator('option', { hasText: label }).count()) > 0) return true;
      await page.waitForTimeout(500);
    }
    return false;
  };
  if (await has(wsBox, workspace)) await wsBox.selectOption({ label: workspace });
  else test.info().annotations.push({ type: 'no-workspace-option', description: `${workspace} ๋ฅผ ์„ ํƒํ•  ์ˆ˜ ์—†์Œ(๋ชฉ๋ก์— ์—†์Œ)` });
  if (project) {
    if (await has(prjBox, project)) await prjBox.selectOption({ label: project });
    else test.info().annotations.push({ type: 'no-project-option', description: `${project} ๋ฅผ ์„ ํƒํ•  ์ˆ˜ ์—†์Œ(๋ชฉ๋ก์— ์—†์Œ)` });
  }
  await dismissWorkspaceCheck(page);
}

/** ์‚ฌ์ด๋“œ ๋ฉ”๋‰ด ์ด๋™. group ๋งŒ ์ฃผ๋ฉด ๊ทธ๋ฃน, item ๊นŒ์ง€ ์ฃผ๋ฉด ํ•˜์œ„ ๋งํฌ ํด๋ฆญ. */
export async function clickMenu(page: Page, group: string, item?: string) {
  await page.getByText(group, { exact: true }).first().click();
  if (item) await page.getByRole('link', { name: item, exact: true }).first().click();
}

/** ์‚ฌ์ด๋“œ ๋ฉ”๋‰ด์— ๋…ธ์ถœ๋œ ๋งํฌ ๋ชฉ๋ก (ํ…์ŠคํŠธ -> href). ์—ญํ• ๋ณ„ ๋ฉ”๋‰ด ๋น„๊ต์šฉ. */
export async function menuSnapshot(page: Page): Promise<string[]> {
  const items = await page.evaluate(() =>
    [...document.querySelectorAll('a[href^="/webconsole"]')].map(
      (a) => `${(a.textContent ?? '').trim()} -> ${a.getAttribute('href')}`,
    ),
  );
  return [...new Set(items)].sort();
}

/** ํ™”๋ฉด์— ๋–  ์žˆ๋Š” ํ† ์ŠคํŠธ(๋นจ๊ฐ„ ์˜ค๋ฅ˜ ๋“ฑ) ํ…์ŠคํŠธ. */
export async function toasts(page: Page): Promise<string[]> {
  return page.locator('[class*="toast"], [role="alert"]').allInnerTexts();
}

/**
 * ํ† ์ŠคํŠธ ๊ธฐ๋ก ์‹œ์ž‘ โ€” ์ดํ›„ ๋‚˜ํƒ€๋‚œ ํ† ์ŠคํŠธ๋ฅผ ์‚ฌ๋ผ์ ธ๋„(autohide 5์ดˆ) ๋ชจ๋‘ ๋ชจ์€๋‹ค. ๋ฐ˜ํ™˜ ํ•จ์ˆ˜๋กœ ์ฝ๋Š”๋‹ค(์‹œ๊ฐ msยทํ…์ŠคํŠธ).
 * ํ™”๋ฉด ์ด๋™(ํŽ˜์ด์ง€ ๋กœ๋“œ)์ด ์žˆ์œผ๋ฉด ๊ธฐ๋ก์ด ๋Š๊ธฐ๋ฏ€๋กœ ๊ฐ™์€ ํ™”๋ฉด ์•ˆ์˜ ๋™์ž‘์—๋งŒ ์“ด๋‹ค(2026-10-14, IV3_S31).
 */
export async function watchToasts(page: Page): Promise<() => Promise<string[]>> {
  await page.evaluate(() => {
    const w = window as any; const t0 = Date.now();
    w.__toastLog = [] as string[];
    const sel = '[class*="toast"], [role="alert"]';
    // ๊ธฐ๋ก ์‹œ์ž‘ ์‹œ ์ด๋ฏธ ๋–  ์žˆ๋˜ ํ† ์ŠคํŠธ๋Š” ์ œ์™ธ, ์ดํ›„ ์ƒˆ ํ…์ŠคํŠธ๋งŒ(๊ฐ™์€ ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์žฌ์‚ฌ์šฉํ•ด๋„ ํ…์ŠคํŠธ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ๊ธฐ๋ก)
    const seen = new Set<string>();
    const texts = () => [...document.querySelectorAll(sel)].map((e) => (e as HTMLElement).innerText?.replace(/\s+/g, ' ').trim()).filter(Boolean) as string[];
    texts().forEach((s) => seen.add(s));
    const grab = () => texts().forEach((s) => {
      if (!seen.has(s)) { seen.add(s); w.__toastLog.push(`+${((Date.now() - t0) / 1000).toFixed(1)}s ${s}`); }
    });
    w.__toastObs?.disconnect();
    w.__toastObs = new MutationObserver(grab);
    w.__toastObs.observe(document.body, { childList: true, subtree: true, characterData: true });
  });
  return () => page.evaluate(() => [...((window as any).__toastLog ?? [])]).catch(() => []);
}

/** ์•Œ๋Ÿฟ์ด ๋ฐœ์ƒํ•  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐํ•˜์ง€ ์•Š๊ณ , fixtures ์˜ dialogs ๋กœ๊ทธ๋ฅผ ๊ฒ€์‚ฌํ•œ๋‹ค. */
export const lastDialog = (dialogs: string[]) => dialogs[dialogs.length - 1] ?? '';

lib/workflow.ts

ํ•ด์‹œ 426003f4 ยท 164์ค„

import { test, expect } from '@playwright/test';
import type { Page, FrameLocator } from '@playwright/test';
import { WORKSPACE, DEFAULT_PROJECT } from './data';
import { selectWorkspaceProject } from './ui';

// Workflows ํ™”๋ฉด(iframe :18083) ์…€๋ ‰ํ„ฐ: 2026-10-02 IV3 ํ”„๋กœ๋ธŒ๋กœ ํ™•์ธ
export const WORKFLOWS_URL = '/webconsole/_view/mcworkflows';

/** Workflows ํ™”๋ฉด ์ง„์ž… โ†’ iframe ๋ฐ˜ํ™˜ */
export async function gotoWorkflows(page: Page): Promise<FrameLocator> {
  // iframe ๋ชฉ๋ก์ด 60์ดˆ ๋„˜๊ฒŒ ๋น„์–ด ์žˆ๋Š” ๊ฒฝ์šฐ๊ฐ€ ๊ฐ„ํ—์ ์œผ๋กœ ์žˆ์Œ(2026-10-05 2ํšŒ) โ†’ ๋‹ค์‹œ ์ง„์ž…ํ•˜๋ฉฐ ์ตœ๋Œ€ 3ํšŒ
  for (let attempt = 1; ; attempt++) {
    await page.goto(WORKFLOWS_URL);
    await selectWorkspaceProject(page, WORKSPACE, DEFAULT_PROJECT, true);
    const f = page.frameLocator('iframe').first();
    const ok = await f.getByText(/Showing \d+-\d+ of \d+ rows/).first().waitFor({ timeout: 60_000 }).then(() => true, () => false);
    if (ok) return f;
    if (attempt >= 3) throw new Error('Workflows iframe ๋ชฉ๋ก์ด 3ํšŒ ๋ชจ๋‘ ๋กœ๋”ฉ๋˜์ง€ ์•Š์Œ');
    test.info().annotations.push({ type: 'wf-iframe-retry', description: `์‹œ๋„ ${attempt}: iframe ๋ชฉ๋ก ๋ฏธ๋กœ๋”ฉ โ†’ ์žฌ์ง„์ž…` });
  }
}

export const workflowRow = (f: FrameLocator, name: string) =>
  f.locator('.tabulator-row').filter({ has: f.locator('[tabulator-field="workflowInfo.workflowName"]', { hasText: new RegExp(`^${name}$`) }) });

/** RUN ํด๋ฆญ โ†’ Run Workflow ํŒ์—…์˜ ํŒŒ๋ผ๋ฏธํ„ฐ ์ž…๋ ฅ๋ž€์ด ์ฑ„์›Œ์งˆ ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ */
export async function openRun(f: FrameLocator, name: string) {
  await workflowRow(f, name).locator('#run-btn').click();
  await f.getByText('Tumblebug Parameter Selection').waitFor();
  await expect(f.locator('input.param-value-input').first()).toBeVisible({ timeout: 60_000 });
}

/** ํŒŒ๋ผ๋ฏธํ„ฐ ํ‘œ์—์„œ ์ฝ”๋“œ(์˜ˆ: INFRA_ID)๋กœ ํ–‰์„ ์ฐพ์•„ ๊ฐ’ ์ž…๋ ฅ(๊ธฐ์กด ๊ฐ’์€ ์ง€์šฐ๊ณ  ์ž…๋ ฅ) */
export async function setParam(f: FrameLocator, code: string, value: string) {
  const input = f.locator('.param-row').filter({ has: f.locator('code', { hasText: new RegExp(`^${code}$`) }) }).locator('input.param-value-input');
  await input.fill(value);
  await expect(input).toHaveValue(value);
}

export async function getParam(f: FrameLocator, code: string) {
  return f.locator('.param-row').filter({ has: f.locator('code', { hasText: new RegExp(`^${code}$`) }) }).locator('input.param-value-input').inputValue();
}

/** ์ƒ๋‹จ Tumblebug Parameter Selection ์˜ ๊ฒ€์ƒ‰ํ˜• ์„ ํƒ(placeholder ๊ธฐ์ค€): ๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ ํ›„ ์˜ต์…˜ ํด๋ฆญ */
export async function pickSearchable(f: FrameLocator, placeholder: string, query: string, option: string | RegExp) {
  const input = f.getByPlaceholder(placeholder, { exact: true });
  // ์‚ฌ์šฉ์ž ์•ˆ๋‚ด(2026-10-07): ์นธ์„ ์ง€์šฐ๊ณ  spec ์ด๋ฆ„์„ ์ง์ ‘ ์ž…๋ ฅํ•œ ๋’ค ์นธ์„ ํด๋ฆญํ•˜๋ฉด ๋ชฉ๋ก์— ๋‚˜ํƒ€๋‚œ๋‹ค.
  // fill() ์€ ํ‚ค ์ž…๋ ฅ ์ด๋ฒคํŠธ๊ฐ€ ์—†์–ด ๋ชฉ๋ก์ด ๊ฐฑ์‹ ๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์–ด(v07 ์—์„œ t3.micro ์˜ต์…˜ ๋ชป ์ฐพ์Œ) ์ง€์šฐ๊ธฐ โ†’ ํ‚ค ์ž…๋ ฅ โ†’ ํด๋ฆญ ์ˆœ์„œ๋กœ ํ•œ๋‹ค.
  // ์นธ์—๋Š” ๊ธฐ๋ณธ๊ฐ’(์˜ˆ: m8i.large, ubuntu-noble ์ด๋ฏธ์ง€)์ด ๋“ค์–ด ์žˆ๊ณ  Ctrl+A/Backspace ๋กœ๋Š” ์•ˆ์ •์ ์œผ๋กœ ์ง€์›Œ์ง€์ง€ ์•Š์•„ ๊ฒ€์ƒ‰์–ด๊ฐ€ ์ค‘๊ฐ„์— ๋ผ์–ด๋“ ๋‹ค(2026-10-07 ๊ด€์ฐฐ)
  // โ†’ value ๋ฅผ ์ง์ ‘ ๋น„์šฐ๊ณ  input ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ ๋’ค ํ•œ ๊ธ€์ž์”ฉ ์ž…๋ ฅํ•˜๊ณ , ์ž…๋ ฅ๊ฐ’์ด ๊ฒ€์ƒ‰์–ด์™€ ๊ฐ™์€์ง€ ํ™•์ธํ•œ๋‹ค(๋‹ค๋ฅด๋ฉด ์žฌ์‹œ๋„).
  const opt = f.locator('.searchable-select-option').filter({ hasText: option }).first();
  for (let attempt = 1; attempt <= 4; attempt++) {
    await input.click();
    await input.evaluate((el) => { (el as HTMLInputElement).value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); });
    await input.pressSequentially(query, { delay: 60 });
    if ((await input.inputValue()) !== query) continue; // ๊ธฐ๋ณธ๊ฐ’์ด ๋ผ์–ด๋“  ๊ฒฝ์šฐ
    if (!(await opt.isVisible({ timeout: 8_000 }).catch(() => false))) await input.click(); // ๋ชฉ๋ก์ด ๋‹ซํ˜€ ์žˆ์œผ๋ฉด ๋‹ค์‹œ ํด๋ฆญํ•ด ์—ฐ๋‹ค
    if (await opt.isVisible({ timeout: 10_000 }).catch(() => false)) { await opt.click(); return; }
  }
  await expect(opt, `${placeholder} ์˜ต์…˜ ${option}`).toBeVisible({ timeout: 5_000 }); // ์‹คํŒจ ๋ฉ”์‹œ์ง€์šฉ
}

/** CSP ๋ณ„ multi-csp-vm-deploy Run ์ž…๋ ฅ๊ฐ’(ํ™•์ธ๋œ CSP ๋งŒ). spec/image ๋Š” Tumblebug Parameter Selection ๊ฒ€์ƒ‰ํ˜• ์„ ํƒ */
export interface VmDeployInput { csp: string; specQuery: string; specOption: string; imageQuery: string }
export const VM_DEPLOY_INPUT: Record<string, VmDeployInput | undefined> = {
  // Image: v07(clean ์„ค์น˜) ์นดํƒˆ๋กœ๊ทธ์—์„œ ami-0efae34c528c68a9c ๋Š” ubuntu-noble-24.04 minimal(Image ์นธ ๊ธฐ๋ณธ๊ฐ’). Ubuntu 22.04 server ๋Š” ami-012a353bb3afb92ee (IV3_S24 ์—์„œ ์ถ”์ฒœ ๋ชฉ๋ก์œผ๋กœ ํ™•์ธ, 2026-10-07)
  AWS: { csp: 'aws', specQuery: 't3.micro', specOption: 'aws+ap-northeast-2+t3.micro', imageQuery: process.env.WF_AWS_IMAGE ?? 'ami-012a353bb3afb92ee' },
  // 2026-10-16 9 CSP ํ™•์žฅ(IV3_S01): RUN ๋ชจ๋‹ฌ ์ƒ๋‹จ CSP ๋ฅผ ๋จผ์ € ๋ฐ”๊ฟ”์•ผ Spec/Image ๊ฒ€์ƒ‰ ๋ชฉ๋ก์ด ๊ทธ CSP ๋กœ ๋ฐ”๋€๋‹ค(runVmDeploy).
  //   ๊ฐ’์€ 2026-10-16 ํ™”๋ฉด ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ(Ubuntu 22.04 ๊ณ„์—ด). AWS ๋Š” Image ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ custom image img1(ubuntu-jammy-22.04 server 20260731 ๊ธฐ๋ฐ˜) 1๊ฑด๋ฟ โ†’ WF_AWS_IMAGE=img1
  //   KT: Ubuntu 22.04 ์—†์Œ, Image ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ img9(os-ubuntu24.04-image-20260929) 1๊ฑด๋ฟ. Spec ์€ Infra ์ƒ์„ฑ ์ด๋ ฅ 1x1.itl
  Azure: { csp: 'azure', specQuery: 'Standard_B1s', specOption: 'azure+koreacentral+Standard_B1s', imageQuery: 'Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:22.04.202608060' },
  Alibaba: { csp: 'alibaba', specQuery: 'ecs.t6-c1m2.large', specOption: 'alibaba+ap-northeast-2+ecs.t6-c1m2.large', imageQuery: 'ubuntu_22_04_x64_20G_alibase_20260810.vhd' },
  GCP: { csp: 'gcp', specQuery: 'n1-standard-1', specOption: 'gcp+asia-northeast3+n1-standard-1 /', imageQuery: 'https://www.googleapis.com/compute/v1/projects/ubuntu-os-cloud/global/images/ubuntu-2204-jammy-v20260820' },
  Tencent: { csp: 'tencent', specQuery: 'S5.MEDIUM2', specOption: 'tencent+ap-seoul+S5.MEDIUM2', imageQuery: 'img-487zeit5' },
  NHNCloud: { csp: 'nhn', specQuery: 'm2.c1m2', specOption: 'nhn+kr1+m2.c1m2', imageQuery: '0f07c795-2a46-44fc-a61b-fa0d96763ce2' },
  NCP: { csp: 'ncp', specQuery: 'ci2-g3', specOption: 'ncp+kr+ci2-g3', imageQuery: '23214590' },
  IBM: { csp: 'ibm', specQuery: 'bx2-2x8', specOption: 'ibm+jp-tok+bx2-2x8', imageQuery: 'r022-f1f5d21f-f498-45c2-8bb6-ce5cf0709bd0' },
  KT: { csp: 'kt', specQuery: '1x1.itl', specOption: 'kt+kr1+1x1.itl /', imageQuery: 'img9' },
};

/**
 * Workflows > multi-csp-vm-deploy RUN ํŒ์—… ์ž…๋ ฅ ํ›„ Run. ๋‹จ์ผ CSP: CSP_LIST ๋ฅผ ํ•ด๋‹น CSP ๋กœ ์ค„์ธ๋‹ค.
 * ๋ฐ˜ํ™˜: Run ์งํ›„ ํ† ์ŠคํŠธ ๋ชฉ๋ก(annotation run-params / run-toasts ๊ธฐ๋ก)
 */
export async function runVmDeploy(page: Page, inp: VmDeployInput, infraId: string, ngPrefix: string, size = '1') {
  const f = await gotoWorkflows(page);
  await openRun(f, 'multi-csp-vm-deploy');
  // ์ƒ๋‹จ CSP ๊ธฐ๋ณธ๊ฐ’์€ aws. ๋‹ค๋ฅธ CSP ๋Š” ๋จผ์ € CSP ๋ฅผ ๋ฐ”๊ฟ”์•ผ Spec/Image ๊ฒ€์ƒ‰์ด ๊ทธ CSP ๋ชฉ๋ก์œผ๋กœ ๋ฐ”๋€๋‹ค(2026-10-16)
  if (inp.csp !== 'aws') { await pickSearchable(f, 'CSP', inp.csp, new RegExp(`^\\s*${inp.csp}\\s*$`)); await page.waitForTimeout(4000); }
  await pickSearchable(f, 'VM/K8s Spec', inp.specQuery, inp.specOption);
  // Image ๋„ ์ƒ๋‹จ ๊ฒ€์ƒ‰ ์„ ํƒ์œผ๋กœ ๊ณ ๋ฅธ๋‹ค. ํŒŒ๋ผ๋ฏธํ„ฐ ํ‘œ์˜ {CSP}_IMAGE_ID ๋ฅผ ์ง์ ‘ ๋ฐ”๊พธ๋ฉด ๊ฐ’์€ ๋ณด์ด์ง€๋งŒ Run ์‹œ ์ƒ๋‹จ ์„ ํƒ๊ฐ’(๊ธฐ๋ณธ ubuntu-noble)์œผ๋กœ ์‹คํ–‰๋จ(2026-10-07 IV3_S01)
  await pickSearchable(f, 'Image', inp.imageQuery, inp.imageQuery);
  await setParam(f, 'CSP_LIST', inp.csp); // ์ค„์ด์ง€ ์•Š์œผ๋ฉด ๋‹ค๋ฅธ CSP IMAGE/SPEC ์žฌ์„ ํƒ ์˜ค๋ฅ˜(2026-10-01)
  await setParam(f, 'INFRA_ID', infraId);
  await setParam(f, 'INFRA_NODEGROUP_PREFIX', ngPrefix);
  await setParam(f, 'INFRA_NODEGROUP_SIZE', size);
  const P = inp.csp.toUpperCase();
  const keys = ['NAMESPACE', 'INFRA_ID', 'INFRA_PREFIX', 'INFRA_NODEGROUP_PREFIX', 'INFRA_NODEGROUP_SIZE', 'PROVIDER', 'CSP', 'CSP_LIST',
    `${P}_REGION`, `${P}_CONNECTION_NAME`, `${P}_ZONE`, `${P}_SPEC_ID`, `${P}_IMAGE_ID`];
  const vals: string[] = [];
  for (const k of keys) vals.push(`${k}=${await getParam(f, k).catch(() => '?')}`);
  test.info().annotations.push({ type: 'run-params', description: vals.join(', ') });
  expect(await getParam(f, `${P}_IMAGE_ID`), `${P}_IMAGE_ID ์„ ํƒ๋จ`).toBe(inp.imageQuery);
  return { f, toasts: await runAndCollectToasts(page, f) };
}

/** CSP ๋ณ„ multi-csp-k8s-cluster-deploy Run ์ž…๋ ฅ๊ฐ’(ํ™•์ธ๋œ CSP ๋งŒ). 2026-10-05 ํŒŒ๋ผ๋ฏธํ„ฐ ํ™•์ธ: CLUSTER_PREFIX, K8S_NODEGROUP_PREFIX, K8S_DESIRED/MIN/MAX_NODE_SIZE, K8S_VERSION, CSP_LIST, {CSP}_SPEC_ID/IMAGE_ID/K8S_VERSION */
export interface K8sDeployInput { csp: string; specQuery: string; specOption: string; imageQuery?: string }
export const K8S_DEPLOY_INPUT: Record<string, K8sDeployInput | undefined> = {
  // ๊ณ„ํš์„œ t3.medium(๊ธฐ๋ณธ๊ฐ’ c8i.xlarge ๋Œ€์‹ ). Spec ์„ ๋ฐ”๊พธ๋ฉด 'Please reselect AWS_IMAGE_ID for the current Region/Connection.' โ†’ K8s Image ๋ฅผ ๋‹ค์‹œ ์„ ํƒ(x86 spec ์ด๋ผ AL2023_x86_64_STANDARD, 2026-10-07)
  AWS: { csp: 'aws', specQuery: 't3.medium', specOption: 'aws+ap-northeast-2+t3.medium', imageQuery: 'AL2023_x86_64_STANDARD' },
};

/** Workflows > multi-csp-k8s-cluster-deploy RUN. namespace = Project(์˜ˆ p1) */
export async function runK8sDeploy(page: Page, inp: K8sDeployInput, ns: string, clusterPrefix: string, ngPrefix: string, size = '1') {
  const f = await gotoWorkflows(page);
  await openRun(f, 'multi-csp-k8s-cluster-deploy');
  await pickSearchable(f, 'Namespace', ns, new RegExp(`^\\s*${ns}\\b`)).catch(() => {}); // ์ƒ๋‹จ ์„ ํƒ(๋ชฉ๋ก์— ์—†์œผ๋ฉด ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ๋งŒ ์ง€์ •)
  await pickSearchable(f, 'VM/K8s Spec', inp.specQuery, inp.specOption);
  if (inp.imageQuery) await pickSearchable(f, 'K8s Image', inp.imageQuery, inp.imageQuery); // Spec ๋ณ€๊ฒฝ ํ›„ K8s Image ์žฌ์„ ํƒ ํ•„์š”
  await setParam(f, 'NAMESPACE', ns);
  await setParam(f, 'CSP_LIST', inp.csp);
  await setParam(f, 'CLUSTER_PREFIX', clusterPrefix);
  await setParam(f, 'K8S_NODEGROUP_PREFIX', ngPrefix);
  await setParam(f, 'K8S_DESIRED_NODE_SIZE', size);
  await setParam(f, 'K8S_MIN_NODE_SIZE', size);
  await setParam(f, 'K8S_MAX_NODE_SIZE', size);
  const U = inp.csp.toUpperCase();
  const keys = ['NAMESPACE', 'PROVIDER', 'CSP', 'CLUSTER_PREFIX', 'K8S_NODEGROUP_PREFIX', 'K8S_VERSION', 'K8S_DESIRED_NODE_SIZE', 'K8S_MIN_NODE_SIZE', 'K8S_MAX_NODE_SIZE', 'K8S_CREATE_OPTION', 'CSP_LIST',
    `${U}_REGION`, `${U}_CONNECTION_NAME`, `${U}_ZONE`, `${U}_SPEC_ID`, `${U}_IMAGE_ID`, `${U}_K8S_VERSION`];
  const vals: string[] = [];
  for (const k of keys) vals.push(`${k}=${await getParam(f, k).catch(() => '?')}`);
  test.info().annotations.push({ type: 'run-params', description: vals.join(', ') });
  expect(await getParam(f, `${U}_SPEC_ID`), `${U}_SPEC_ID`).toContain(inp.specQuery);
  return { f, toasts: await runAndCollectToasts(page, f) };
}

/** Workflows ๋ชฉ๋ก์˜ ์›Œํฌํ”Œ๋กœ์šฐ ํ–‰ ์ƒํƒœยท๋งˆ์ง€๋ง‰ ์‹คํ–‰์ผ */
export async function workflowStatus(page: Page, name: string) {
  const f = await gotoWorkflows(page);
  const row = workflowRow(f, name).first();
  const st = (await row.locator('[tabulator-field="workflowInfo.status"]').innerText().catch(() => '')).replace(/\s+/g, ' ').trim();
  const run = (await row.locator('[tabulator-field="runDate"]').innerText().catch(() => '')).replace(/\s+/g, ' ').trim();
  return { status: st, runDate: run };
}

/** Run ํด๋ฆญ ํ›„ ์ผ์ • ์‹œ๊ฐ„ ๋™์•ˆ ๋œจ๋Š” ํ† ์ŠคํŠธ/์•Œ๋ฆผ ๋ฌธ๊ตฌ๋ฅผ ๋ชจ์€๋‹ค(์˜ค๋ฅ˜ ํ† ์ŠคํŠธ๊ฐ€ ์ˆ˜ ์ดˆ ๋งŒ์— ์‚ฌ๋ผ์ง€๋ฏ€๋กœ ํด๋ง). */
export async function runAndCollectToasts(page: Page, f: FrameLocator, ms = 20_000) {
  await f.locator('.modal.show').getByRole('button', { name: 'Run', exact: true }).click();
  const seen = new Set<string>();
  const sel = '[class*="toast"], [role="alert"], .alert, .notification';
  const end = Date.now() + ms;
  while (Date.now() < end) {
    for (const t of [...(await f.locator(sel).allInnerTexts().catch(() => [])), ...(await page.locator(sel).allInnerTexts().catch(() => []))]) {
      const s = t.replace(/\s+/g, ' ').trim();
      if (s) seen.add(s.slice(0, 300));
    }
    await page.waitForTimeout(500);
  }
  const list = [...seen];
  test.info().annotations.push({ type: 'run-toasts', description: list.join(' | ') || '(์—†์Œ)' });
  return list;
}

tests/iv7/monitoring.ts

ํ•ด์‹œ 5f35b1cd ยท 168์ค„

import type { Page, FrameLocator } from '@playwright/test';
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, selectWorkspaceProject, clickMenu, toasts } from '../../lib/ui';
import { WORKSPACE } from '../../lib/data';

// IV7 ๊ณตํ†ต: Monitorings > Monitoring ํ™”๋ฉด (2026-10-02 ์‚ฌ์ „์ ๊ฒ€)
// - ๋ฉ”๋‰ด ์ฃผ์†Œ /webconsole/_view/mco11ymonitorings, ๋ณธ๋ฌธ์€ iframe https://<host>:18081/embed/monitoring/{namespace}
// - iframe ์ƒ๋‹จ "Namespace โ€” {ns}", ๋ฒ„ํŠผ Node / K8s (K8s ์„ ํƒ ์‹œ Agent API ๋ฒ„ํŠผ ์ถ”๊ฐ€)
// - ๋Œ€์ƒ์ด ์—†์œผ๋ฉด "0 Nodes / 0 Infras  No nodes in this namespace", "0 Nodes / 0 Clusters  No K8s clusters in this namespace"
// - ๋Œ€์ƒ์ด ์žˆ์„ ๋•Œ์˜ ๋ชฉ๋ก/์„ค์น˜ ๋ฒ„ํŠผ์€ ์•„์ง ํ™•์ธ ๋ชป ํ•จ(2026-10-02 stage ์— InfraยทK8s 0๊ฐœ) โ†’ ์„ค์น˜ ๋‹จ๊ณ„ ์…€๋ ‰ํ„ฐ๋Š” ์ถ”์ •(TODO)

export const monFrame = (page: Page): FrameLocator => page.frameLocator('iframe[src*="/embed/monitoring"]').first();

/** admin ๋กœ๊ทธ์ธ โ†’ ws01/{project} ์„ ํƒ โ†’ Monitoring ์ง„์ž…. iframe ์˜ Namespace ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. */
export async function openMonitoring(page: Page, project: string) {
  await loginAsAdmin(page, WORKSPACE, project);
  await clickMenu(page, 'Monitorings', 'Monitoring');
  await page.waitForURL(/mco11ymonitorings/);
  // ๋ฉ”๋‰ด ์ด๋™ ์‹œ ์„ ํƒ์ด ์ดˆ๊ธฐํ™”๋  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ๋‹ค์‹œ ์„ ํƒ
  await selectWorkspaceProject(page, WORKSPACE, project);
  const fr = monFrame(page);
  await expect(fr.getByText(/Namespace/).first()).toBeVisible({ timeout: 30_000 });
  const ns = ((await fr.getByText(/Namespace\s*โ€”/).first().innerText().catch(() => '')) || '').replace(/.*โ€”\s*/, '').trim();
  test.info().annotations.push({ type: 'namespace', description: `iframe Namespace=${ns} (์„ ํƒ Project=${project})` });
  return fr;
}

/** Node / K8s ํƒญ ์ „ํ™˜ ํ›„ ์š”์•ฝ ๋ฌธ๊ตฌ("0 Nodes / 0 Infras" ๋“ฑ)๋ฅผ ๊ธฐ๋กํ•˜๊ณ , ๋Œ€์ƒ ์ด๋ฆ„์ด ๋ณด์ด๋Š”์ง€ ๋ฐ˜ํ™˜ํ•œ๋‹ค. */
export async function findTarget(page: Page, fr: FrameLocator, tab: 'Node' | 'K8s', target: string) {
  await fr.getByRole('button', { name: tab, exact: true }).click();
  await page.waitForTimeout(3000);
  const body = (await fr.locator('body').innerText()).replace(/\s+/g, ' ');
  const summary = body.match(/\d+ Nodes \/ \d+ (Infras|Clusters)/)?.[0] ?? '';
  test.info().annotations.push({ type: 'monitoring-summary', description: `${tab}: ${summary} | ${body.slice(0, 200)}` });
  const found = await fr.getByText(target, { exact: true }).first().isVisible({ timeout: 5000 }).catch(() => false);
  return { found, body };
}

// ---- Config(Monitor Setting) ํ™”๋ฉด โ€” 2026-10-07 Playwright ํ”„๋กœ๋ธŒ๋กœ ํ™•์ธ ----
// - ์‚ฌ์ด๋“œ ๋ฉ”๋‰ด Monitorings > Config (/webconsole/_view/mc011yconfig), ๋ณธ๋ฌธ์€ iframe(/embed/โ€ฆ)
// - Monitoring ํ™”๋ฉด์˜ node ์˜† 'Go to Config to install agent' ๋ฒ„ํŠผ๋„ ์ด ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•œ๋‹ค. ์—์ด์ „ํŠธ๋Š” ์ด ํ™”๋ฉด์—์„œ ์„ค์น˜ํ•œ๋‹ค.
// - Project=default: 'Workload: {infra}' ์•„๋ž˜ node ๋ชฉ๋ก(Name / Node ID / Status / Monitoring Agent / Log Agent), ๊ฐ node ๋งˆ๋‹ค 'Not installed [Install]' 2๊ฐœ(Monitoring, Log)
// - Project=p1: 'Namespace: p1' ์•„๋ž˜ 'K8s {cluster} Active n Nodes', node(์˜ˆ i-0278โ€ฆ) ์˜ Monitoring Agent / Log Agent 'Install'
const cfgFrame = (page: Page): FrameLocator => page.frameLocator('iframe[src*="/embed/"]').first();

/** admin ๋กœ๊ทธ์ธ โ†’ ws01/{project} ์„ ํƒ โ†’ Monitorings > Config ์ง„์ž…. iframe ๋ฐ˜ํ™˜. */
export async function openConfig(page: Page, project: string) {
  await loginAsAdmin(page, WORKSPACE, project);
  await page.getByText('Monitorings', { exact: true }).first().click(); // ๊ทธ๋ฃน ํŽผ์นจ(๋กœ๊ทธ์ธ ์งํ›„๋Š” ์ ‘ํžŒ ์ƒํƒœ)
  await page.locator('a[href*="/mc011yconfig"]').first().click();
  await page.waitForURL(/mc011yconfig/);
  await selectWorkspaceProject(page, WORKSPACE, project); // ๋ฉ”๋‰ด ์ด๋™ ์‹œ ์„ ํƒ์ด ์ดˆ๊ธฐํ™”๋  ์ˆ˜ ์žˆ์Œ
  const fr = cfgFrame(page);
  await expect(fr.getByText('Monitor Setting')).toBeVisible({ timeout: 30_000 });
  await page.waitForTimeout(4000);
  const text = (await fr.locator('body').innerText()).replace(/\s+/g, ' ');
  test.info().annotations.push({ type: 'config-screen', description: text.slice(0, 400) });
  return { fr, text };
}

/**
 * Config ํ™”๋ฉด์—์„œ ์ฒซ ๋ฒˆ์งธ ๋Œ€์ƒ(node)์˜ Monitoring Agent 'Install' ์„ ๋ˆŒ๋Ÿฌ ์„ค์น˜๋ฅผ ์š”์ฒญํ•œ๋‹ค.
 * ๊ฒฐ๊ณผ๋Š” (1) ์•Œ๋ฆผ/ํ† ์ŠคํŠธ (2) ํ™”๋ฉด์˜ 'Not installed' ๊ฐœ์ˆ˜ ๋ณ€ํ™”๋กœ ํŒ๋‹จํ•˜๊ณ  ๋ชจ๋‘ annotation ์— ๋‚จ๊ธด๋‹ค.
 */
export async function installFirstAgent(page: Page, fr: FrameLocator, dialogs: string[]) {
  const installs = fr.getByRole('button', { name: 'Install', exact: true });
  await expect(installs.first(), 'Install ๋ฒ„ํŠผ').toBeVisible({ timeout: 30_000 });
  const read = async () => (await fr.locator('body').innerText()).replace(/\s+/g, ' ');
  const before = await read();
  const notBefore = (before.match(/Not installed/g) ?? []).length;
  await installs.first().click();
  // ํ™•์ธ ๋ชจ๋‹ฌ์ด ์žˆ์œผ๋ฉด(iframe ์•ˆ) ํ™•์ธ
  const dlg = fr.getByRole('dialog');
  if (await dlg.isVisible({ timeout: 4000 }).catch(() => false)) {
    test.info().annotations.push({ type: 'install-modal', description: (await dlg.innerText()).replace(/\s+/g, ' ').slice(0, 300) });
    await dlg.getByRole('button', { name: /install|confirm|ok|yes|submit/i }).last().click();
  }
  let after = before, notAfter = notBefore;
  for (let i = 0; i < 18 && notAfter >= notBefore; i++) { // ์ตœ๋Œ€ ~90์ดˆ ํ™”๋ฉด ๋ณ€ํ™” ํ™•์ธ
    await page.waitForTimeout(5000);
    after = await read();
    notAfter = (after.match(/Not installed/g) ?? []).length;
  }
  const msgs = [...dialogs, ...(await toasts(page)), ...(await fr.locator('[class*="toast"], [role="alert"], [role="status"]').allInnerTexts().catch(() => []))].map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
  test.info().annotations.push({ type: 'install-result', description: `์•Œ๋ฆผ: ${msgs.join(' | ') || '(์—†์Œ)'} / Not installed ${notBefore} โ†’ ${notAfter} / ํ™”๋ฉด: ${after.slice(0, 350)}` });
  expect(msgs.join(' ') + ' ' + (notAfter < notBefore ? 'state-changed' : ''), '์„ค์น˜ ์š”์ฒญ ์ ‘์ˆ˜(์•Œ๋ฆผ ๋˜๋Š” ์ƒํƒœ ๋ณ€ํ™”)').toMatch(/success|request|install|์ ‘์ˆ˜|accepted|state-changed/i);
  return { before, after, msgs };
}

/** ๋Œ€์ƒ ์„ ํƒ โ†’ ์„ค์น˜(์—์ด์ „ํŠธ) ์š”์ฒญ. (๊ตฌ๋ฒ„์ „: Monitoring ํ™”๋ฉด ๊ธฐ์ค€ ์ถ”์ • ์…€๋ ‰ํ„ฐ โ€” ์‹ค์ œ ์„ค์น˜๋Š” openConfig/installFirstAgent ์‚ฌ์šฉ) */
export async function requestInstall(page: Page, fr: FrameLocator, target: string, dialogs: string[]) {
  await fr.getByText(target, { exact: true }).first().click();
  const install = fr.getByRole('button', { name: /install/i }).first(); // TODO: ์‹ค์ œ ๋ฒ„ํŠผ ์ด๋ฆ„ ํ™•์ธ
  await expect(install, '์„ค์น˜ ๋ฒ„ํŠผ').toBeVisible();
  await install.click();
  // ํŒ์—…์ด ๋œจ๋ฉด ํ™•์ธ/์„ค์น˜ ๋ฒ„ํŠผ์œผ๋กœ ์ œ์ถœ (TODO: ํŒ์—… ๊ตฌ์„ฑ ํ™•์ธ)
  const submit = fr.getByRole('dialog').getByRole('button', { name: /install|confirm|ok|submit/i }).first();
  if (await submit.isVisible({ timeout: 5000 }).catch(() => false)) await submit.click();
  await page.waitForTimeout(5000);
  const msg = [...dialogs, ...(await toasts(page)), ...(await fr.locator('[class*="toast"], [role="alert"], [role="status"]').allInnerTexts().catch(() => []))].join(' | ');
  test.info().annotations.push({ type: 'install-result', description: msg || '(์•Œ๋ฆผ ์—†์Œ)' });
  expect(msg, '์„ค์น˜ ์š”์ฒญ ์ ‘์ˆ˜ ์•Œ๋ฆผ').toMatch(/success|request|install|์ ‘์ˆ˜|accepted/i);
}


// ---- node ๋‹จ์œ„ ์—์ด์ „ํŠธ ์„ค์น˜ (IV7_S03~S06, 2026-10-08) ----
// Config ํ™”๋ฉด node ํ–‰(<tr>): Infra = Name | Node ID | Status | Monitoring Agent | Log Agent, K8s = Name(node id, Running ํฌํ•จ) | Monitoring Agent | Log Agent.
// ์—์ด์ „ํŠธ ์นธ์€ ๋งˆ์ง€๋ง‰ ๋‘ <td>(๋’ค์—์„œ ๋‘ ๋ฒˆ์งธ = Monitoring, ๋งˆ์ง€๋ง‰ = Log). ์ƒํƒœ ๋ฌธ๊ตฌ: 'Not installed'+Install / 'INSTALLING Installingโ€ฆ' / 'Installed'(SUCCESS)+Uninstall / SERVICE_INACTIVE ๋“ฑ
export type AgentKind = 'Monitoring' | 'Log';
export const nodeRow = (fr: FrameLocator, node: string) => fr.locator('tr').filter({ hasText: node }).filter({ has: fr.locator('td') }).first();
export const agentCell = (fr: FrameLocator, node: string, kind: AgentKind) => nodeRow(fr, node).locator('td').nth(kind === 'Monitoring' ? -2 : -1);
export const cellText = async (fr: FrameLocator, node: string, kind: AgentKind) => ((await agentCell(fr, node, kind).innerText().catch(() => '')) || '').replace(/\s+/g, ' ').trim();
export const rowStatus = async (fr: FrameLocator, node: string) => ((await nodeRow(fr, node).innerText().catch(() => '')) || '').replace(/\s+/g, ' ').match(/Running|Suspended|Resuming|Suspending|Terminated|Failed/)?.[0] ?? '';

/** node ๊ฐ€ Running ์ด ๋  ๋•Œ๊นŒ์ง€ Config ํ™”๋ฉด์„ ๋‹ค์‹œ ์—ด์–ด ํ™•์ธ(Resume ์งํ›„ 'Resuming'). */
export async function waitNodeRunning(page: Page, project: string, node: string, timeoutMs = 10 * 60_000) {
  const deadline = Date.now() + timeoutMs;
  for (;;) {
    const { fr } = await openConfig(page, project);
    const st = await rowStatus(fr, node);
    if (st === 'Running') return fr;
    if (Date.now() > deadline) throw new Error(`node ${node} ๊ฐ€ Running ์ด ๋˜์ง€ ์•Š์Œ(ํ˜„์žฌ ${st || 'ํ–‰ ์—†์Œ'})`);
    await page.waitForTimeout(30_000);
  }
}

/** ์—์ด์ „ํŠธ ์นธ ๋ฌธ๊ตฌ โ†’ ์ƒํƒœ. Infra: 'SUCCESS Uninstall' / 'SERVICE_INACTIVE Uninstall' / 'FAILED Uninstall' / 'INSTALLING Installingโ€ฆ' / 'Not installed Install', K8s: 'Installed Uninstall' */
export type AgentState = 'not-installed' | 'installing' | 'uninstalling' | 'failed' | 'inactive' | 'installed' | 'unknown';
export const classify = (s: string): AgentState =>
  /Uninstalling/i.test(s) ? 'uninstalling' : /INSTALLING|Installing/.test(s) ? 'installing' : /FAILED/i.test(s) ? 'failed' : /Not installed/.test(s) ? 'not-installed'
  : /SERVICE_INACTIVE/.test(s) ? 'inactive' : /SUCCESS|Installed/.test(s) ? 'installed' : 'unknown';

/** ์ง„ํ–‰ ์ค‘(installing/uninstalling)์ด๋ฉด ๋๋‚  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐํ•˜๊ณ  ์ตœ์ข… ์ƒํƒœ ๋ฌธ๊ตฌ๋ฅผ ๋Œ๋ ค์ค€๋‹ค. */
export async function settleAgent(page: Page, fr: FrameLocator, node: string, kind: AgentKind, timeoutMs = 8 * 60_000) {
  const deadline = Date.now() + timeoutMs;
  let s = await cellText(fr, node, kind);
  while (/installing|uninstalling/.test(classify(s)) && Date.now() < deadline) { await page.waitForTimeout(10_000); s = await cellText(fr, node, kind); }
  return s;
}

/** node ์˜ ์—์ด์ „ํŠธ Install/Uninstall ํด๋ฆญ โ†’ ํ™•์ธ์ฐฝ ํ™•์ธ โ†’ ์ƒํƒœ๊ฐ€ ๋๋‚  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ. ๊ด€์ฐฐํ•œ ์ƒํƒœ ๋ณ€ํ™”๋ฅผ ๋Œ๋ ค์ค€๋‹ค. */
export async function agentAction(page: Page, fr: FrameLocator, dialogs: string[], node: string, kind: AgentKind, action: 'Install' | 'Uninstall', timeoutMs = 8 * 60_000) {
  const cell = agentCell(fr, node, kind);
  const before = await cellText(fr, node, kind);
  await cell.getByRole('button', { name: action, exact: true }).click();
  const dlg = fr.getByRole('dialog');
  if (await dlg.isVisible({ timeout: 4000 }).catch(() => false)) {
    test.info().annotations.push({ type: `${kind}-${action}-confirm`, description: (await dlg.innerText()).replace(/\s+/g, ' ').slice(0, 200) });
    await dlg.getByRole('button', { name: /install|uninstall|confirm|ok|yes|submit/i }).last().click();
  }
  const seen: string[] = [before];
  const deadline = Date.now() + timeoutMs;
  let state = before;
  let started = false; // ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ๋’ค ์ƒํƒœ๊ฐ€ ํ•œ ๋ฒˆ์ด๋ผ๋„ ๋ฐ”๋€Œ์—ˆ๋Š”์ง€
  for (;;) {
    await page.waitForTimeout(8_000);
    state = await cellText(fr, node, kind);
    if (seen[seen.length - 1] !== state) { seen.push(state); started = true; }
    const cls = classify(state);
    const finished = action === 'Install' ? ['installed', 'inactive', 'failed'].includes(cls) : cls === 'not-installed';
    if (started && finished) break;
    if (Date.now() > deadline) break;
  }
  const msgs = [...dialogs, ...(await toasts(page))].map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
  test.info().annotations.push({ type: `${kind}-${action}`, description: `${node}: ${seen.join(' โ†’ ')}${msgs.length ? ` / ์•Œ๋ฆผ: ${msgs.join(' | ').slice(0, 200)}` : ''}` });
  const cls = classify(state);
  return { before, state, seen, cls, done: action === 'Install' ? ['installed', 'inactive'].includes(cls) : cls === 'not-installed' };
}

playwright.config.ts

ํ•ด์‹œ 4d3a401d ยท 33์ค„

import { defineConfig } from '@playwright/test';
import { RUN_DIR } from './lib/runtag'; // ์‹คํ–‰ ํšŒ์ฐจ ํƒœ๊ทธ(v07): ๊ฒฐ๊ณผ๋Š” results/v07/ ์•„๋ž˜

// 1ํ…Œ์ŠคํŠธ = 1๋…นํ™”. ํ•ด์ƒ๋„ 1920x1080.
// ๋…นํ™” ํŒŒ์ผ์€ lib/fixtures.ts ๊ฐ€ results/recordings/{๋…นํ™”ํŒŒ์ผ๋ช…}.webm ์œผ๋กœ ๋ณต์‚ฌํ•œ๋‹ค.

// ์ž‘์—… PC ์˜ DNS ๊ฐ€ stage ์ฃผ์†Œ ์กฐํšŒ์— ์ž์ฃผ ์‹คํŒจํ•œ๋‹ค(2026-10-05: 5ํšŒ ์ค‘ 4ํšŒ 'DNS server failure' โ†’ ๋กœ๊ทธ์ธ ์ง€์—ฐยทNetwork Error).
// ํ…Œ์ŠคํŠธ ๋ธŒ๋ผ์šฐ์ €(Chromium)์—์„œ๋งŒ stage ํ˜ธ์ŠคํŠธ๋ฅผ ๊ณ ์ • IP ๋กœ ์—ฐ๊ฒฐํ•œ๋‹ค(PC ์„ค์ • ๋ณ€๊ฒฝ ์—†์Œ, ์‚ฌ์šฉ์ž ๊ฒฐ์ • 2026-10-05).
//   STAGE_IP=<ip> ๋กœ ๋ฐ”๊พธ๊ณ , STAGE_IP=off ๋กœ ๋ˆ๋‹ค. Node ์ชฝ ์š”์ฒญ(APIRequestContext)์—๋Š” ์ ์šฉ๋˜์ง€ ์•Š๋Š”๋‹ค.
const STAGE_HOST = '<STAGE_HOST>';
const STAGE_IP = process.env.STAGE_IP ?? '<STAGE_IP>';
const hostRules = STAGE_IP && STAGE_IP !== 'off' ? [`--host-resolver-rules=MAP ${STAGE_HOST} ${STAGE_IP}`] : [];
export default defineConfig({
  testDir: './tests',
  workers: 1,
  fullyParallel: false,
  retries: 0,
  timeout: 40 * 60 * 1000, // Infra/K8s ์ƒ์„ฑ ๋Œ€๊ธฐ(์ตœ๋Œ€ ์•ฝ 30๋ถ„ ์ด์ƒ) ๊ณ ๋ ค
  expect: { timeout: 15_000 },
  reporter: [['list'], ['html', { open: 'never', outputFolder: `${RUN_DIR}/html` }], ['json', { outputFile: `${RUN_DIR}/results.json` }]],
  outputDir: `${RUN_DIR}/artifacts`,
  use: {
    baseURL: process.env.BASE_URL ?? 'https://<STAGE_HOST>',
    viewport: { width: 1920, height: 1080 },
    video: { mode: 'on', size: { width: 1920, height: 1080 } },
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
    ignoreHTTPSErrors: true,
    actionTimeout: 20_000,
    launchOptions: { slowMo: Number(process.env.SLOWMO ?? 300), args: hostRules }, // slowMo: ๋…นํ™” ๊ฐ€๋…์„ฑ์„ ์œ„ํ•ด ๋™์ž‘ ๊ฐ„ ์ง€์—ฐ
  },
});
โš ๏ธ **GitHub.com Fallback** โš ๏ธ