Repository navigation
Test Specs Common
생성: 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 01workflow01@mcmp.testEnabledYYN"
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: 녹화 가독성을 위해 동작 간 지연
},
});