Test Specs Common - MZC-CSC/mc-web-console GitHub Wiki
์์ฑ: 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 |
ํด์ 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(() => {});
}ํด์ 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' };ํด์ 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);ํด์ 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);ํด์ 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);
}ํด์ 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);ํด์ 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 ๋ฏธ์ง์ (์ฌ์ ์ ๊ฒ ํ์)`);ํด์ 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;
}ํด์ 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);
}
}
}ํด์ 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 };ํด์ 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;
}ํด์ 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)}` };
}ํด์ 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'] });ํด์ 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());
}ํด์ 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 };ํด์ 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 };ํด์ 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);
}
}ํด์ 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 };
}ํด์ 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 };ํด์ 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;
}ํด์ 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;
}ํด์ 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;
}ํด์ 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;
}ํด์ 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' });
});
});
}ํด์ 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);
}ํด์ 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 };ํด์ 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('');
}ํด์ 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);
}
});
}ํด์ 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}_` : '';ํด์ 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));
}ํด์ 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] ?? '';ํด์ 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;
}ํด์ 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' };
}ํด์ 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: ๋
นํ ๊ฐ๋
์ฑ์ ์ํด ๋์ ๊ฐ ์ง์ฐ
},
});