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

Test Specs — IV3 Infra / K8s 생성

생성: 2026-10-16 · 기준: v07 설치 후 통합 검증(IV) · 원본: 여정별 계획서(Excel) ← Test Scenarios · IV 허브 · 기능 TC

Playwright(TypeScript) spec 소스입니다. 시나리오마다 ## <시나리오번호> 섹션이 있고 앵커는 #<시나리오번호 소문자> 입니다. 공용 헬퍼는 Test-Specs-Common. 비밀번호 등은 마스킹했습니다.

시나리오 파일 줄 해시
IV3_S01 tests/iv3/IV3_S01.spec.ts 76 3f84b370
IV3_S02 tests/iv3/IV3_S02.spec.ts 47 0bdd9421
IV3_S03 tests/iv3/IV3_S03.spec.ts 114 6dc2fb54
IV3_S04 tests/iv3/IV3_S04.spec.ts 39 8963fc65
IV3_S05 tests/iv3/IV3_S05.spec.ts 68 4716cc61
IV3_S06 tests/iv3/IV3_S06.spec.ts 61 bd1a1b79
IV3_S07 tests/iv3/IV3_S07.spec.ts 92 a384e36c
IV3_S08 tests/iv3/IV3_S08.spec.ts 75 3ff7df20
IV3_S09 tests/iv3/IV3_S09.spec.ts 50 cd4aef28
IV3_S10 tests/iv3/IV3_S10.spec.ts 59 78dd8aaf
IV3_S11 tests/iv3/IV3_S11.spec.ts 142 153bb4b6
IV3_S12 tests/iv3/IV3_S12.spec.ts 49 3c3db986
IV3_S13 tests/iv3/IV3_S13.spec.ts 66 1af3342f
IV3_S14 tests/iv3/IV3_S14.spec.ts 22 c06a830c
IV3_S15 tests/iv3/IV3_S15.spec.ts 36 64ad8fcf
IV3_S16 tests/iv3/IV3_S16.spec.ts 37 1a6c14fd
IV3_S17 tests/iv3/IV3_S17.spec.ts 38 47efaf32
IV3_S18 tests/iv3/IV3_S18.spec.ts 89 868a6f71
IV3_S19 tests/iv3/IV3_S19.spec.ts 63 dcbc63f5
IV3_S20 tests/iv3/IV3_S20.spec.ts 65 b0c34936
IV3_S21 tests/iv3/IV3_S21.spec.ts 55 395ed504
IV3_S22 tests/iv3/IV3_S22.spec.ts 49 98db18d2
IV3_S23 tests/iv3/IV3_S23.spec.ts 26 a78a0309
IV3_S24 tests/iv3/IV3_S24.spec.ts 38 05eff554
IV3_S25 tests/iv3/IV3_S25.spec.ts 40 1e0b4b83
IV3_S26 tests/iv3/IV3_S26.spec.ts 28 534d4261
IV3_S27 tests/iv3/IV3_S27.spec.ts 42 c785f6cf
IV3_S28 tests/iv3/IV3_S28.spec.ts 29 b83116eb
IV3_S29 tests/iv3/IV3_S29.spec.ts 75 f51fe929
IV3_S30 tests/iv3/IV3_S30.spec.ts 54 ea5e9d7f
IV3_S31 tests/iv3/IV3_S31.spec.ts 70 44a868e3
IV3_S32 tests/iv3/IV3_S32.spec.ts 48 eac94caf
IV3_S33 tests/iv3/IV3_S33.spec.ts 44 1ee4b36d
IV3_S34 tests/iv3/IV3_S34.spec.ts 82 a0673427
IV3_S35 tests/iv3/IV3_S35.spec.ts 53 94eeefec
IV3_S36 tests/iv3/IV3_S36.spec.ts 55 617aaaff
IV3_S37 tests/iv3/IV3_S37.spec.ts 83 ed0116f5
IV3_S38 tests/iv3/IV3_S38.spec.ts 68 bbccbe96
IV3_S39 tests/iv3/IV3_S39.spec.ts 43 3aae00a0
IV3_S40 tests/iv3/IV3_S40.spec.ts 65 23dece22
IV3_S41 tests/iv3/IV3_S41.spec.ts 52 c6a5b759
IV3_S42 tests/iv3/IV3_S42.spec.ts 45 6970297e
IV3_S43 tests/iv3/IV3_S43.spec.ts 43 8a0f9a50
IV3_S44 tests/iv3/IV3_S44.spec.ts 62 37b5dd5e
IV3_S45 tests/iv3/IV3_S45.spec.ts 62 67c732ba
IV3_S46 tests/iv3/IV3_S46.spec.ts 110 0b90e9d2
IV3_S47 tests/iv3/IV3_S47.spec.ts 95 1a6ba850
IV3_S48 tests/iv3/IV3_S48.spec.ts 52 9c5cc10c
IV3_S49 tests/iv3/IV3_S49.spec.ts 60 333eb26c

IV3_S01

tests/iv3/IV3_S01.spec.ts · 76줄 · 해시 3f84b370 · 시나리오 보기

// IV3_S01: Workflow로 단일 CSP Infra 생성 [정상, CSP별]
// 화면: Workflows(iframe) → multi-csp-vm-deploy RUN 팝업(Run Workflow, 파라미터 입력) → 실행 상태
// 시작 상태: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default 선택된 상태에서 시작
// 절차 (예: AWS 기준): 1.Workflows 진입(iframe) 2.multi-csp-vm-deploy RUN: Spec=t3.micro, Image=Ubuntu 22.04(ami-0efae34c528c68a9c),
//   INFRA_ID=mw1, CSP_LIST=aws, INFRA_NODEGROUP_PREFIX=g1, INFRA_NODEGROUP_SIZE=1 3.Run 4.Infra Workloads 에서 mw1 Running 도달까지 대기
//   5.mw1 선택 → Infra Info > Node List 에서 NodeGroup g1-aws 의 node g1-aws-1 선택 → Node Info 상세 확인
// 기대결과: Infra mw1 Running, Node Info [ g1-aws / g1-aws-1 ] 에 CSP=aws, Node Status=Running, Spec=t3.micro, Image ID, Connection, Public/Private IP 표시
// 멱등: mw1 이 이미 있으면 Run 은 건너뛰고(annotation preexisting) 상태만 검증한다(이름 중복 Run 은 IV3_S09 대상).
// 2026-10-16 수정(9 CSP 실행): lib/workflow VM_DEPLOY_INPUT 에 Azure~KT 입력값 추가, runVmDeploy 가 상단 CSP 를 먼저 선택.
//   기존 Infra 가 Suspended 면 Resume → 검증 → 다시 Suspend(사용자 지시 2026-10-16, lib/iv3-mc10 withResumed). 기존 Infra 는 Image ID 값만 기록. 새 이름은 WF_INFRA_NAME. 테스트 시간 40분
//   만든 mw{n} 은 이후 TC(INFRA-028 Terminal·032 Scale Policy·052 전체 중지)용으로 삭제하지 않는다

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, wfInfraName } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { gotoInfraList, infraRow, nodeField, openInfraDetail, openNodeInfo, rowCells, statusOf, waitInfraRunning } from '../../lib/infra';
import { VM_DEPLOY_INPUT as WF_INPUT, runVmDeploy } from '../../lib/workflow';
import { withResumed } from '../../lib/iv3-mc10';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S01", c)} ` + "Workflow로 단일 CSP Infra 생성", async ({ page }) => {
    const inp = WF_INPUT[c.code];
    test.skip(!inp, `${c.name}: Workflow Run 입력값 미확정(사전점검 필요) → AWS 확정 후 확장`);
    test.setTimeout(40 * 60_000);
    // WF_INFRA_NAME: 기존 mw{n} 을 다른 세션이 중지(Suspended)해 둔 경우 등 새 이름으로 생성할 때(2026-10-16, AWS mw1 Suspended → mw1b)
    const name = process.env.WF_INFRA_NAME || wfInfraName(c);
    const ng = `g${c.n}`;
    await loginAsAdmin(page);
    await gotoInfraList(page);
    if (await infraRow(page, name).count()) {
      const pre = await rowCells(infraRow(page, name).first());
      test.info().annotations.push({ type: 'preexisting', description: `${name} 이미 존재 → Run 건너뜀: ${JSON.stringify(pre)}` });
      // 2026-10-16 사용자 지시: "Test에 필요한 infra가 suspended 상태면 resume 후 테스트 한다. 테스트 완료 후 다시 suspended 한다." → withResumed
      // 목록 status 는 'Stopped' 로 보이고 statusCount.countSuspended 가 node 수(2026-10-16 mw1) → Suspended 로 본다
      if (/suspend/i.test(statusOf(pre)) || (Number(pre['statusCount.countSuspended'] ?? 0) > 0 && Number(pre['statusCount.countSuspended']) === Number(pre['statusCount.countTotal']))) {
        await withResumed(page, { infra: name, ng: '', node: '', why: `기존 ${name} Suspended → Resume`, resume: true, origStatus: statusOf(pre) }, () => verify(true));
        return;
      }
      test.skip(/terminat|fail/i.test(statusOf(pre)), `${name} 이 ${statusOf(pre)} 상태 — 검증 불가`);
      await verify(true);
      return;
    } else {
      const { toasts } = await runVmDeploy(page, inp!, name, ng);
      const err = toasts.find((t) => /fail|error|invalid|not found/i.test(t));
      expect(err, `Run 직후 오류 토스트 없음`).toBeUndefined();
    }
    await verify(false);

    async function verify(preexisting: boolean) {
    const cells = await waitInfraRunning(page, name, 25 * 60_000);
    test.info().annotations.push({ type: 'list-row', description: JSON.stringify(cells) });
    await openInfraDetail(page, name);
    const body = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
    const i = body.indexOf('Infra Info');
    test.info().annotations.push({ type: 'detail', description: body.slice(i, i + 400) });

    // NodeGroup 의 node 선택 → Node Info 상세 확인. nodegroup 이름은 {INFRA_NODEGROUP_PREFIX}-{csp} (예: g1-aws)
    const wfNg = `${ng}-${inp!.csp}`;
    const node = `${wfNg}-1`;
    const info = await openNodeInfo(page, wfNg, node);
    test.info().annotations.push({ type: 'node-info', description: info.slice(0, 900) });
    expect(nodeField(info, 'CSP'), 'CSP').toBe(inp!.csp);
    expect(nodeField(info, 'Node Status'), 'Node Status').toBe('Running');
    expect(nodeField(info, 'Node Spec'), 'Node Spec').toBe(inp!.specQuery);
    // 기존 Infra(이전 실행에서 생성)는 생성 당시 이미지라 현재 입력값과 다를 수 있음 → 값만 기록
    if (preexisting) {
      test.info().annotations.push({ type: 'preexisting-image', description: `Image ID ${nodeField(info, 'Image ID')} (현재 입력값 ${inp!.imageQuery})` });
      expect(nodeField(info, 'Image ID'), 'Image ID').not.toBe('');
    } else expect(nodeField(info, 'Image ID'), 'Image ID').toBe(inp!.imageQuery);
    expect(nodeField(info, 'Config Name'), 'Connection').toBe(c.infra.conn);
    expect(nodeField(info, 'Public IP\\(IPv4\\)'), 'Public IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
    expect(nodeField(info, 'Private IP'), 'Private IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
    }
  });
}

IV3_S02

tests/iv3/IV3_S02.spec.ts · 47줄 · 해시 0bdd9421 · 시나리오 보기

// IV3_S02: 직접 Infra 생성 (Add Infra) [정상, CSP별]
// 화면: Workloads > Infra Workloads > Add Infra (Create Infra 폼: Name, Description, Deployment Algorithm, Policy on Partial Failure, Labels, + NodeGroup → Deploy)
// 시작 상태: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default 선택된 상태에서 시작
// 절차 (예: AWS 기준): 1.Infra Workloads 진입 2.Add Infra: Name=mc1, Deployment Algorithm=Express, Policy on Partial Failure=Continue 3.+ NodeGroup: g1 (연결=aws-ap-northeast-2, Spec=t3.micro, Image=Ubuntu 22.04 기본 이미지, node 1) → Done 4.Deploy 5.Running 도달까지 대기
//   6.mc1 선택 → Infra Info > Node List 에서 NodeGroup g1 의 node g1-1 선택 → Node Info 상세 확인 7.Group 탭에 g1(1)
// 기대결과: Infra mc1 Running (nodegroup g1, node 1), Node Info [ g1 / g1-1 ] 에 CSP, Node Status=Running, Spec, Image ID, Connection, Public/Private IP 표시
// 멱등: 같은 이름 Infra 가 이미 있으면 생성은 건너뛰고(annotation preexisting) 상태만 검증한다(이름 중복 생성은 IV3_S10 대상).

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { INFRA_INPUT, createInfra, gotoInfraList, infraInputBlocker, infraRow, nodeField, openInfraDetail, openNodeInfo, rowCells, waitInfraRunning } from '../../lib/infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S02", c)} ` + "직접 Infra 생성 (Add Infra)", async ({ page }) => {
    const why = infraInputBlocker(c);
    test.skip(!!why, why);
    const name = infraName(c);
    const ng = infraNodegroups(c)[0];
    await loginAsAdmin(page);
    await gotoInfraList(page);
    if (await infraRow(page, name).count()) {
      test.info().annotations.push({ type: 'preexisting', description: `${name} 이미 존재 → 생성 건너뜀: ${JSON.stringify(await rowCells(infraRow(page, name).first()))}` });
    } else {
      await createInfra(page, c, name, ng);
    }
    const cells = await waitInfraRunning(page, name);
    test.info().annotations.push({ type: 'list-row', description: JSON.stringify(cells) });

    // 상세: NodeGroup {ng} 의 node {ng}-1 선택 → Node Info 상세 확인
    await openInfraDetail(page, name);
    const inp = INFRA_INPUT[c.code]!;
    const info = await openNodeInfo(page, ng, `${ng}-1`);
    test.info().annotations.push({ type: 'node-info', description: info.slice(0, 900) });
    expect(nodeField(info, 'CSP'), 'CSP').toBe(inp.provider);
    expect(nodeField(info, 'Node Status'), 'Node Status').toBe('Running');
    expect(nodeField(info, 'Node Spec'), 'Node Spec').toBe(inp.spec);
    expect(nodeField(info, 'Config Name'), 'Connection').toBe(c.infra.conn);
    expect(nodeField(info, 'Image ID'), 'Image ID').not.toBe('');
    expect(nodeField(info, 'Public IP\\(IPv4\\)'), 'Public IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
    expect(nodeField(info, 'Private IP'), 'Private IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
    // Group 탭 NodeGroup List 에 {ng}(1)
    await page.getByText('Group', { exact: true }).first().click();
    await expect(page.getByText(`${ng}(1)`).first(), `nodegroup ${ng}(1) 표시`).toBeVisible({ timeout: 30_000 });
  });
}

IV3_S03

tests/iv3/IV3_S03.spec.ts · 114줄 · 해시 6dc2fb54 · 시나리오 보기

// IV3_S03: 생성한 Infra에 nodegroup 추가 [정상, CSP별]
// 화면: Workloads > Infra Workloads > Infra 선택 > 상세 Node List 'Add server'(⌄ 메뉴 'New' 와 같은 화면, #addserver) → '+ Node' → Node Configuration → Done → Deploy
// 시작 상태: admin(mcmp) 로그인, ws01/default. mc{n} Running(nodegroup g{n})
// 절차(AWS): 1.Infra 목록에서 mc1 선택 2.NodeGroup g1b 추가(Spec t3.micro, Image Ubuntu 22.04 ami-0efae34c528c68a9c — 계획서 ami-0afe1fd15675c3f15 는 카탈로그에 없음, node 1)
//   3.Deploy → mc1 노드 2개 Running 대기 4.상세 Group 탭에 g1, g1b 표시 확인 5.Default 탭 Node List 에서 g1b-1 선택 → Node Info 상세(CSP, Running, Spec, Image ID, Connection, IP)
// 기대결과: mc1 에 nodegroup 2개(g1, g1b), 신규 node g1b-1 Running, Node Info 상세 표시
// 멱등: g1b 가 이미 있으면 추가는 건너뛰고(preexisting) 확인만 한다.

import { test, expect } from '../../lib/fixtures';
import { CSPS, activeCsps, tid, infraName, infraNodegroups } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { INFRA_INPUT, addNodeGroupToInfra, gotoInfraList, infraInputBlocker, infraRow, nodeField, openInfraDetail, openNodeInfo, rowCells, waitInfraNodes } from '../../lib/infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S03", c)} ` + "생성한 Infra에 nodegroup 추가", async ({ page }) => {
    const why = infraInputBlocker(c);
    test.skip(!!why, why);
    const inp = INFRA_INPUT[c.code]!;
    const name = infraName(c);
    const [ng, ngB] = infraNodegroups(c);
    await loginAsAdmin(page);
    await gotoInfraList(page);
    test.skip(!(await infraRow(page, name).count()), `선행 IV3_S02 의 ${name} 없음`);
    const before = await rowCells(infraRow(page, name).first());
    test.info().annotations.push({ type: 'before', description: JSON.stringify(before) });

    // g1b 존재 여부: 상세 Node List 의 node 카드 #server_status_icon_{ngB}-1
    await openInfraDetail(page, name);
    let image = inp.image ?? '';
    let expectNodes = Number(before['statusCount.countTotal']);
    if (await page.locator(`[id^="server_status_icon_${ngB}-"]`).count()) {
      test.info().annotations.push({ type: 'preexisting', description: `${ngB} 이미 존재 → 추가 건너뜀` });
    } else {
      ({ image } = await addNodeGroupToInfra(page, c, name, ngB, 1));
      expectNodes += 1;
    }
    const cells = await waitInfraNodes(page, name, expectNodes);
    test.info().annotations.push({ type: 'after', description: JSON.stringify(cells) });

    // Group 탭: g1, g1b
    await openInfraDetail(page, name);
    await page.getByText('Group', { exact: true }).first().click();
    await expect(page.getByText(new RegExp(`^${ng}\\(\\d+\\)$`)).first(), `nodegroup ${ng} 표시`).toBeVisible({ timeout: 30_000 });
    await expect(page.getByText(new RegExp(`^${ngB}\\(\\d+\\)$`)).first(), `nodegroup ${ngB} 표시`).toBeVisible({ timeout: 30_000 });

    // Default 탭: g1b-1 Node Info
    await page.getByText('Default', { exact: true }).first().click();
    // 추가한 NodeGroup 의 node id 는 {ng}-1 이 아닐 수 있다(KT: Infra 전체 번호를 이어서 g9b-2) → 카드 id 로 찾는다
    const cardB = page.locator(`[id^="server_status_icon_${ngB}-"]`).first();
    await cardB.waitFor({ state: 'visible', timeout: 60_000 });
    const nodeB = ((await cardB.getAttribute('id')) ?? '').replace('server_status_icon_', '');
    test.info().annotations.push({ type: 'node-id', description: `${ngB} 의 node: ${nodeB}` });
    const info = await openNodeInfo(page, ngB, nodeB);
    test.info().annotations.push({ type: 'node-info', description: info.slice(0, 900) });
    expect(nodeField(info, 'CSP'), 'CSP').toBe(inp.provider);
    expect(nodeField(info, 'Node Status'), 'Node Status').toBe('Running');
    expect(nodeField(info, 'Node Spec'), 'Node Spec').toBe(inp.spec);
    expect(nodeField(info, 'Image ID'), 'Image ID').toBe(image);
    expect(nodeField(info, 'Config Name'), 'Connection').toBe(c.infra.conn);
    expect(nodeField(info, 'Public IP\\(IPv4\\)'), 'Public IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
    expect(nodeField(info, 'Private IP'), 'Private IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
  });
}

// IV3_S03_AWS-GCP: 같은 시나리오로 AWS Infra mc1 에 provider=gcp nodegroup g1c(node 2개) 추가 → 멀티 CSP Infra (사용자 지시 2026-10-05)
// 절차: mc1 선택 → Add server → + Node: g1c(GCP e2-small, Ubuntu 22.04, size 2) → Done → Deploy → mc1 노드 +2 Running 대기
//   → Group 탭 g1, g1b, g1c 표시 → g1c-1, g1c-2 Node Info(CSP gcp, Running, Spec, Image ID, Connection, IP)
if (activeCsps(['AWS']).length) {
  test('IV3_S03_AWS-GCP 생성한 Infra에 다른 CSP(GCP) nodegroup 추가', async ({ page }) => {
    const aws = CSPS.find((x) => x.code === 'AWS')!;
    const gcp = CSPS.find((x) => x.code === 'GCP')!;
    const inp = INFRA_INPUT.GCP!;
    const name = infraName(aws);
    const ngC = `g${aws.n}c`;
    const size = 2;
    await loginAsAdmin(page);
    await gotoInfraList(page);
    test.skip(!(await infraRow(page, name).count()), `선행 ${name} 없음`);
    const before = await rowCells(infraRow(page, name).first());
    test.info().annotations.push({ type: 'before', description: JSON.stringify(before) });

    await openInfraDetail(page, name);
    let expectNodes = Number(before['statusCount.countTotal']);
    if (await page.locator(`#server_status_icon_${ngC}-1`).count()) {
      test.info().annotations.push({ type: 'preexisting', description: `${ngC} 이미 존재 → 추가 건너뜀` });
    } else {
      await addNodeGroupToInfra(page, gcp, name, ngC, size);
      expectNodes += size;
    }
    const cells = await waitInfraNodes(page, name, expectNodes, 25 * 60_000);
    test.info().annotations.push({ type: 'after', description: JSON.stringify(cells) });

    await openInfraDetail(page, name);
    await page.getByText('Group', { exact: true }).first().click();
    for (const g of infraNodegroups(aws).concat(ngC)) {
      await expect(page.getByText(new RegExp(`^${g}\\(\\d+\\)$`)).first(), `nodegroup ${g} 표시`).toBeVisible({ timeout: 30_000 });
    }
    await expect(page.getByText(`${ngC}(${size})`).first(), `${ngC} 노드 ${size}개`).toBeVisible();

    await page.getByText('Default', { exact: true }).first().click();
    for (let k = 1; k <= size; k++) {
      const info = await openNodeInfo(page, ngC, `${ngC}-${k}`);
      test.info().annotations.push({ type: `node-info:${ngC}-${k}`, description: info.slice(0, 900) });
      expect(nodeField(info, 'CSP'), 'CSP').toBe(inp.provider);
      expect(nodeField(info, 'Node Status'), 'Node Status').toBe('Running');
      expect(nodeField(info, 'Node Spec'), 'Node Spec').toBe(inp.spec);
      expect(nodeField(info, 'Image ID'), 'Image ID').not.toBe('');
      expect(nodeField(info, 'Config Name'), 'Connection').toBe(gcp.infra.conn);
      expect(nodeField(info, 'Public IP\\(IPv4\\)'), 'Public IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
      expect(nodeField(info, 'Private IP'), 'Private IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
    }
  });
}

IV3_S04

tests/iv3/IV3_S04.spec.ts · 39줄 · 해시 8963fc65 · 시나리오 보기

// IV3_S04: Disk attach/detach — Infra 상세(Node Info)에서 [정상, CSP별] (생성→attach→detach 통합. 개별 동작은 IV3_S14~S16)
// 화면: Cloud Resources > Disks(Create Data Disk) → Infra Workloads > Infra 선택 > Node List 에서 node 선택 > Node Info > Block Device '+ Attach Disk' / 'Detach'
// 절차(AWS): 1.Disks 에서 disk1c 생성(aws-ap-northeast-2, 10GB) 2.mc1 → Running node(g1b-2) → Node Info 3.+ Attach Disk → Existing Disk disk1c → Attach
//   4.Disks 목록 Attached / Attached To 'mc1 / g1b-2', Block Device 에 disk1c 5.Block Device disk1c Detach → Confirm 6.Disks 목록 Available, Block Device 에서 사라짐
// 기대결과: disk1c 가 node 에 attach 되고 detach 후 해제(Available)

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { DISK_TARGET, attachExistingDisk, blockDevice, createDisk, detachDisk, diskTargetBlocker, openInfraDetail, openNodeInfo, pickRunningNode, waitDiskState } from '../../lib/infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S04", c)} ` + "Disk attach/detach: Infra 상세에서", async ({ page }) => {
    const why = diskTargetBlocker(c);
    test.skip(!!why, why);
    const tg = DISK_TARGET[c.code]!;
    await loginAsAdmin(page);
    await createDisk(page, c.infra.conn, tg.disk, tg.size, tg.diskType);
    await waitDiskState(page, tg.disk, 'available');

    const node = await pickRunningNode(page, tg.infra, tg.node);
    const ng = node.replace(/-\d+$/, '');
    let info = await openNodeInfo(page, ng, node);
    expect(blockDevice(info), '시작 시 disk 미연결').not.toContain(tg.disk);
    const target = await attachExistingDisk(page, tg.disk);
    expect(target, '대상 노드 표시').toContain(node);
    await waitDiskState(page, tg.disk, 'attached', node);
    await openInfraDetail(page, tg.infra);
    info = await openNodeInfo(page, ng, node);
    expect(blockDevice(info), 'Block Device 에 disk 표시').toContain(tg.disk);

    await detachDisk(page, tg.disk);
    await waitDiskState(page, tg.disk, 'available');
    await openInfraDetail(page, tg.infra);
    info = await openNodeInfo(page, ng, node);
    expect(blockDevice(info), 'Block Device 에서 disk 해제').not.toContain(tg.disk);
  });
}

IV3_S05

tests/iv3/IV3_S05.spec.ts · 68줄 · 해시 4716cc61 · 시나리오 보기

// IV3_S05: Disk attach/detach: Disks 화면 action item에서 [정상, CSP별] — Disk 테스트 (Infra 상세에서 하는 S04 와 구분)
// 화면: Cloud Resources > Disks (Create Data Disk 팝업) → Disk 행 선택 → 목록 상단 'Attach selected Data Disk' → attach, 행 선택 시 열리는 Data Disk Detail 의 Detach
// 전제: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default. IV3_S02 mc1 Running
// 절차(AWS): 1.Disks 진입 2.Create Data Disk: Connection=aws-ap-northeast-2, Disk Name=disk1b, 10GB 3.disk1b 행 선택 → Attach selected Data Disk: Infra mc1 / NodeGroup / Node 선택
//   4.attach 확인(Disks 목록 Attached, Attached To) 5.행 선택 → Data Disk Detail 의 Detach → Confirm 6.detach 확인(Available, Attached To '-')
// 기대결과: disk1b 가 mc1 의 node 에 attach/detach
// 화면 확인(2026-10-08 AWS 프로브): Attach Data Disk 모달(#attach-disk-mci Infra / #attach-disk-nodegroup / #attach-disk-vm / #attach-disk-execute-btn),
//   Detach 버튼 #detail-detach-btn. disk 의 AZ 가 node 와 다르면 attach 가 거부될 수 있다(IV3_S15 참고) → 결과 그대로 기록
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin, toasts } from '../../lib/ui';
import { DISK_TARGET, createDisk, diskCells, diskRow, diskTargetBlocker, gotoDisks, nodeField, openNodeInfo, pickRunningNode, waitDiskState } from '../../lib/infra';

const DISK_B = process.env.TARGET_DISK_B ?? 'disk1b'; // node 와 같은 AZ 의 disk 로 바꿀 때 지정(2026-10-12)
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S05", c)} ` + "Disk attach/detach: Disks 화면 action item에서", async ({ page }) => {
    const why = diskTargetBlocker(c);
    test.skip(!!why, why);
    const tg = DISK_TARGET[c.code]!;
    await loginAsAdmin(page);
    await createDisk(page, c.infra.conn, DISK_B, tg.size, tg.diskType);
    const node = await pickRunningNode(page, tg.infra, tg.node);
    const ng = node.replace(/-\d+$/, '');
    const info = await openNodeInfo(page, ng, node);
    test.info().annotations.push({ type: 'node-zone', description: `${node}: Zone ${nodeField(info, 'Zone')}` });

    // 3. Disks 목록에서 행 선택 → Attach selected Data Disk
    await gotoDisks(page);
    await diskRow(page, DISK_B).first().getByRole('checkbox').check();
    await page.locator('[title="Attach selected Data Disk"], [data-bs-original-title="Attach selected Data Disk"]').first().click();
    const m = page.locator('.modal.show').last();
    await expect(m).toBeVisible();
    await m.locator('#attach-disk-mci').selectOption({ label: tg.infra });
    await m.locator('#attach-disk-nodegroup').selectOption({ label: ng });
    // node 목록은 NodeGroup 선택 후 채워지고 표시는 'g1-1 (Running)'
    await expect.poll(async () => (await m.locator('#attach-disk-vm option').allInnerTexts()).length, { timeout: 30_000 }).toBeGreaterThan(1);
    const vmLabel = (await m.locator('#attach-disk-vm option').allInnerTexts()).find((s) => s.trim().startsWith(node))!;
    await m.locator('#attach-disk-vm').selectOption({ label: vmLabel });
    await m.locator('#attach-disk-execute-btn').click();
    // 거부되면(AZ 불일치 등) 모달이 닫히지 않고 오류 표시도 없다(IV3_S15 와 같은 증상) → 닫히지 않으면 기록 후 취소하고 Disks 상태로 판정
    const closed = await m.waitFor({ state: 'hidden', timeout: 90_000 }).then(() => true).catch(() => false);
    if (!closed) {
      test.info().annotations.push({ type: 'attach-modal-open', description: `Attach 후 90초 넘게 모달이 닫히지 않음: ${(await m.innerText()).replace(/\s+/g, ' ').slice(0, 120)}` });
      await m.getByRole('button', { name: 'Cancel' }).click();
      const st = await diskCells(page, DISK_B);
      test.info().annotations.push({ type: 'disk-after-attach', description: JSON.stringify(st) });
      expect(st.status, `attach 실패(disk1b 기본 AZ 와 ${node}(${nodeField(info, 'Zone')}) 불일치 추정, IV3_S15 참고)`).toMatch(/attached/i);
    }
    await page.waitForTimeout(3000);
    const t1 = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
    test.info().annotations.push({ type: 'attach', description: `target=${tg.infra} / ${node} disk=${DISK_B} / ${t1.join(' | ').slice(0, 300) || '(토스트 없음)'}` });
    await waitDiskState(page, DISK_B, 'attached');
    test.info().annotations.push({ type: 'disk-attached', description: JSON.stringify(await diskCells(page, DISK_B)) });

    // 5. detach: 행 선택 → Data Disk Detail 의 Detach → Confirm
    await diskRow(page, DISK_B).first().click();
    const det = page.locator('#detail-detach-btn');
    await expect(det, 'Detach 버튼').toBeVisible({ timeout: 15_000 });
    await det.click();
    const m2 = page.locator('.modal.show').last();
    await expect(m2).toBeVisible();
    test.info().annotations.push({ type: 'detach-confirm', description: (await m2.innerText()).replace(/\s+/g, ' ').slice(0, 160) });
    await m2.getByRole('button', { name: /Confirm|Detach|OK/ }).last().click();
    await waitDiskState(page, DISK_B, 'available');
    test.info().annotations.push({ type: 'disk-detached', description: JSON.stringify(await diskCells(page, DISK_B)) });
  });
}

IV3_S06

tests/iv3/IV3_S06.spec.ts · 61줄 · 해시 bd1a1b79 · 시나리오 보기

// IV3_S06: Infra에서 Custom Image 생성 → MyImages 목록 표시 [정상, CSP별]
// 화면: Workloads > Infra Workloads > Infra 선택 > Default 탭 Node List 에서 node 선택 > 툴바 ⌄ 메뉴 'Create MyImage'(모달: MyImage Name #node-myimage-name / Description / Create #node-myimage-submit-btn) → Cloud Resources > MyImages
// 전제: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default. IV3_S02 mc1 Running
// 절차(AWS): 1.mc1 선택 → Running node 선택 2.툴바 ⌄ > Create MyImage: 이름 img1 → Create 3.생성 완료 대기 4.Cloud Resources > MyImages 목록에 img1 표시 확인 5.Provider(CSP) 열 확인
// 기대결과: img1 생성, MyImages 목록에 표시
// 생성 이미지는 이후 삭제 테스트(IV2 계열/CL)용으로 남긴다. 이미 img1 이 있으면 생성 생략(preexisting)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin, toasts } from '../../lib/ui';
import { DISK_TARGET, openNodeInfo, pickRunningNode, rowCells } from '../../lib/infra';

// MyImage 이름은 CSP 번호 기준(img{n}): AWS img1, KT img9 — AWS 의 img1 이 이미 있어도 KT 는 별도로 만든다(2026-10-12)
const imgOf = (c: { n: number }) => `img${c.n}`;
async function openMyImages(page: import('@playwright/test').Page) {
  const link = page.locator('a#sidebar_cloudresources_myimages');
  if (!(await link.isVisible().catch(() => false))) await page.getByText('Cloud Resources', { exact: true }).first().click(); // 그룹 펼침
  await link.click();
  await page.waitForURL(/myimages/i);
  await page.getByText(/Showing \d+-\d+ of \d+ rows|No registered|No data/i).first().waitFor({ timeout: 30_000 }).catch(() => {});
  await page.waitForTimeout(1500);
}
const imgRowOf = (page: import('@playwright/test').Page, name: string) => page.getByRole('row').filter({ has: page.getByRole('gridcell', { name, exact: true }) });

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S06", c)} ` + "Infra에서 Custom Image 생성 → MyImages 목록 표시", async ({ page }) => {
    const IMG = imgOf(c);
    const imgRow = (page: import('@playwright/test').Page) => imgRowOf(page, IMG);
    const tg = DISK_TARGET[c.code];
    test.skip(!tg, `${c.name}: 대상 Infra/node 미지정(사전점검 필요)`);
    await loginAsAdmin(page);
    await openMyImages(page);
    if (await imgRow(page).count()) {
      test.info().annotations.push({ type: 'preexisting', description: `${IMG} 이미 존재 → 생성 건너뜀` });
    } else {
      const node = await pickRunningNode(page, tg!.infra, tg!.node); // 대상 node: DISK_TARGET.node (KT g9-1, 사용자 지시 2026-10-12)
      const ng = node.replace(/-\d+$/, '');
      await openNodeInfo(page, ng, node);
      test.info().annotations.push({ type: 'target', description: `${tg!.infra} / ${node}` });
      const item = page.locator('a[onclick*="openCreateNodeMyImageModal"]').first();
      await item.locator('xpath=ancestor::*[contains(@class,"dropdown-menu")][1]/preceding-sibling::*[1]').click(); // 툴바 ⌄
      await item.click();
      const m = page.locator('.modal.show').last();
      await expect(m).toBeVisible();
      await m.locator('#node-myimage-name').fill(IMG);
      await m.locator('#node-myimage-submit-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: 'myimage-create', description: t.join(' | ').slice(0, 300) || '(토스트 없음)' });
      await openMyImages(page);
    }
    // 생성 완료 대기(스냅샷 → 이미지 등록): 목록 재조회 최대 15분
    await expect.poll(async () => { await openMyImages(page); return await imgRow(page).count(); }, { timeout: 15 * 60_000, intervals: [30_000] }).toBeGreaterThan(0);
    const cells = await rowCells(imgRow(page).first());
    test.info().annotations.push({ type: 'myimage-row', description: JSON.stringify(cells) });
    // Provider 열은 driver 기준 소문자(nhn 등) — CSP 코드(NHNCloud)와 다를 수 있어 매핑(2026-10-16 NHN 오판 수정)
    const providerKey = ({ NHNCloud: 'nhn' } as Record<string, string>)[c.code] ?? c.code.toLowerCase();
    expect(JSON.stringify(cells).toLowerCase(), `Provider ${c.code}`).toContain(providerKey);
  });
}

IV3_S07

tests/iv3/IV3_S07.spec.ts · 92줄 · 해시 a384e36c · 시나리오 보기

// IV3_S07: NLB 생성 → 같은 Infra 의 NodeGroup node 할당 [정상, CSP별]
// 2026-10-12 사용자 결정: 기대결과를 '여러 Infra 할당' → '같은 Infra NodeGroup 할당'으로 변경(NLB 는 Infra+NodeGroup 단위, 다른 Infra node 는 할당 대상 아님)
// 화면: Cloud Resources > NLB (Create NLB 팝업) → NLB 목록 → NLB Detail(Assigned Nodes) → Edit(assign / unassign nodes)
// 전제: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default. mc1 의 g1 node(g1-1, g1-2) Running
// 화면 확인(2026-10-08 AWS 프로브): 사이드바 Cloud Resources > NLB(/cloudresources/nlbs). Create NLB 모달: #create-nlb-infra(Infra), #create-nlb-nodegroup(Target NodeGroup),
//   #create-nlb-listener-port(80), #create-nlb-target-port(80), #create-nlb-protocol(TCP/UDP), Type/Scope(PUBLIC / REGION), Health Checker(interval/timeout/threshold, 0=provider default), #create-nlb-execute-btn.
//   "NLB is created per target NodeGroup of the selected Infra. The NLB id follows the NodeGroup id."
// 절차(AWS): 1.NLB 진입 2.Create NLB: Infra=mc1, Target NodeGroup=g1, Listener Port=80, Target Port=80, Protocol=TCP, Health Checker 기본값 3.Create 4.목록에 NLB 표시
//   5.NLB Detail 의 Assigned Nodes 에 g1-1, g1-2 확인 6.Edit 에서 Assigned/Available Nodes 가 같은 Infra(mc1) node 로 구성되는지 확인(변경 없이 Cancel)
// 기대결과: NLB 생성·목록 표시, 같은 Infra 의 Target NodeGroup node(g1-1, g1-2)가 NLB 에 할당
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups } from '../../lib/data';
import { loginAsAdmin, selectWorkspaceProject, toasts } from '../../lib/ui';
import { infraInputBlocker, openInfraDetail } from '../../lib/infra';

const NLB_URL = '/webconsole/settings/environment/cloudresources/nlbs';
async function openNlb(page: import('@playwright/test').Page) {
  await page.goto(NLB_URL);
  await selectWorkspaceProject(page, 'ws01', 'default', true);
  await page.getByText(/Showing \d+(-\d+)? (of \d+ )?rows|No NLBs/i).first().waitFor({ timeout: 30_000 }).catch(() => {});
  await page.waitForTimeout(1500);
}

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S07", c)} ` + "NLB 생성 → 실행 중인 여러 Infra 할당", async ({ page }) => {
    test.setTimeout(20 * 60_000);
    const why = infraInputBlocker(c);
    test.skip(!!why, why);
    const infra = infraName(c);
    const ng = infraNodegroups(c)[0];
    await loginAsAdmin(page);
    await openNlb(page);
    const before = (await page.locator('.tabulator-row').allInnerTexts()).map((s) => s.replace(/\s+/g, ' '));
    test.info().annotations.push({ type: 'nlb-before', description: before.join(' || ') || '(NLB 없음)' });

    const exists = before.some((s) => s.includes(infra) && s.includes(ng));
    if (exists) test.info().annotations.push({ type: 'preexisting', description: `NLB(${infra}/${ng}) 이미 존재 → 생성 건너뜀` });
    if (!exists) {
    await page.getByRole('button', { name: 'Create NLB' }).click();
    const m = page.locator('.modal.show').last();
    await expect(m).toBeVisible();
    await m.locator('#create-nlb-infra').selectOption({ label: infra });
    await expect.poll(async () => (await m.locator('#create-nlb-nodegroup option').allInnerTexts()).length, { timeout: 30_000 }).toBeGreaterThan(1);
    await m.locator('#create-nlb-nodegroup').selectOption({ label: ng });
    await m.locator('#create-nlb-listener-port').fill('80');
    await m.locator('#create-nlb-target-port').fill('80');
    await m.locator('#create-nlb-protocol').selectOption('TCP');
    await m.locator('#create-nlb-execute-btn').click();
    await expect(m).toBeHidden({ timeout: 300_000 });
    await page.waitForTimeout(3000);
    const t = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
    test.info().annotations.push({ type: 'nlb-create', description: t.join(' | ').slice(0, 300) || '(토스트 없음)' });
    }

    await openNlb(page);
    const rows = (await page.locator('.tabulator-row').allInnerTexts()).map((s) => s.replace(/\s+/g, ' '));
    test.info().annotations.push({ type: 'nlb-after', description: rows.join(' || ') });
    expect(rows.join(' '), `NLB 목록에 ${infra} / ${ng}`).toMatch(new RegExp(`${infra}[^|]*${ng}|${ng}[^|]*${infra}`));

    // 4-1. 상세: 생성 직후 같은 NodeGroup 의 node 가 자동 할당되어 있어야 한다(Assigned Nodes)
    const row = page.locator('.tabulator-row').filter({ hasText: infra }).first();
    await row.click();
    await page.waitForTimeout(2000);
    const body = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
    const detail = body.slice(body.indexOf('NLB Detail'), body.indexOf('NLB Detail') + 700);
    test.info().annotations.push({ type: 'nlb-detail', description: detail });
    // 2026-10-12 사용자 지시: AWS mc1 의 g1-1, g1-2 가 모두 할당되는지 확인(NLB_NODES 로 지정, 기본 {ng}-1)
    for (const n of (process.env.NLB_NODES ?? `${ng}-1`).split(',')) expect.soft(detail, `Assigned Nodes 에 ${n}`).toContain(n);

    // 5. Edit(assign / unassign nodes): 할당 대상은 같은 Infra 의 NodeGroup node 로 한정(2026-10-12 사용자 결정으로 기대결과 변경 —
    //    NLB 는 Infra+NodeGroup 단위라 다른 Infra(mw1) node 는 표시되지 않는 것이 사양)
    await openInfraDetail(page, infra);
    const sameNodes = (await page.locator('[id^="server_status_icon_"]').evaluateAll((es) => es.map((e) => e.id.replace('server_status_icon_', ''))));
    test.info().annotations.push({ type: 'same-infra-nodes', description: `${infra}: ${sameNodes.join(', ')}` });
    await openNlb(page);
    await page.locator('.tabulator-row').filter({ hasText: infra }).first().getByRole('checkbox').check();
    await page.locator('[title="Edit selected (assign / unassign nodes)"], [data-bs-original-title="Edit selected (assign / unassign nodes)"]').first().click();
    const em = page.locator('.modal.show').last();
    await expect(em).toBeVisible();
    await page.waitForTimeout(2000);
    const editText = (await em.innerText()).replace(/\s+/g, ' ');
    test.info().annotations.push({ type: 'nlb-edit-modal', description: editText.slice(0, 500) });
    await em.getByRole('button', { name: 'Cancel' }).click(); // 변경하지 않는다
    // Edit 의 Assigned + Available Nodes 가 모두 같은 Infra 의 node 인지 확인(Assigned 에 지정 node, Available 에 같은 Infra 의 나머지 node)
    const avail = editText.slice(editText.indexOf('Available Nodes'));
    const shown = sameNodes.filter((n) => editText.includes(n));
    test.info().annotations.push({ type: 'edit-nodes', description: `같은 Infra node 중 Edit 에 표시: ${shown.join(', ')} / Available 영역: ${avail.slice(0, 200)}` });
    for (const n of (process.env.NLB_NODES ?? `${ng}-1`).split(',')) expect(editText, `Edit Assigned Nodes 에 ${n}`).toContain(n);
    expect(shown.length, `Edit 에 같은 Infra(${infra}) 의 NodeGroup node 표시`).toBe(sameNodes.length);
  });
}

IV3_S08

tests/iv3/IV3_S08.spec.ts · 75줄 · 해시 3ff7df20 · 시나리오 보기

// IV3_S08: Workflow 필수 파라미터 누락 RUN [예상실패, 공통] — TC-WF-009
// 화면: Workflows(iframe) → multi-csp-vm-deploy RUN 팝업(Run Workflow) → Run → 토스트 / Infra Workloads
// 시작 상태: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default 선택된 상태에서 시작. TC-WF-001 완료(seed Workflow 존재)
// 절차: 1.Workflows 진입 → multi-csp-vm-deploy RUN 2.정상 입력값 설정: CSP_LIST=aws, Spec=t3.micro(상단 VM/K8s Spec), INFRA_ID=mcmp-e2e-v07-wf009,
//   INFRA_NODEGROUP_PREFIX=g1, INFRA_NODEGROUP_SIZE=1 3.AWS_IMAGE_ID 만 공란(상단 Image 미선택 + 파라미터 공란) 4.Run → 토스트 기록
//   5.약 2분 동안 Jenkins Build 목록(GET /workflow/log/{idx} — Workflows LOG 화면과 같은 출처)과 Infra 목록 관찰
// 입력값(Drive): CSP_LIST aws / 누락 항목 AWS_SPEC_ID 또는 AWS_IMAGE_ID 공란(이 spec 은 AWS_IMAGE_ID) / 나머지 유효한 값. INFRA_ID 공란은 기본 이름을 쓰므로 대상 아님
// 기대결과: 누락 값 검증 오류로 실행 요청 차단, 신규 Jenkins Build 와 자원(Infra mcmp-e2e-v07-wf009) 미생성
// 판정: 오류 안내가 있고 Build 수·Infra 변화가 없으면 PASS. Build 가 새로 생기거나 Infra 가 생기면 FAIL(검증 없이 실행)
// 비고: 2026-10-16 구현(stub 대체). 잘못 생성되면(결함) Infra mcmp-e2e-v07-wf009 는 정리 대상으로 결과 문서에 기록

import { test, expect } from '../../lib/fixtures';
import { tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { gotoInfraList, infraRow } from '../../lib/infra';
import { VM_DEPLOY_INPUT, pickSearchable, setParam, getParam, runAndCollectToasts } from '../../lib/workflow';
import { openRunRetry } from '../../lib/iv3-mc10';
import { workflowDetailByName, workflowBuilds } from '../../lib/iv3-wf';

const INFRA_ID = process.env.WF009_INFRA ?? 'mcmp-e2e-v07-wf009';

test(`${tid('IV3_S08')} Workflow 필수 파라미터 누락 RUN`, async ({ page }) => {
  test.setTimeout(15 * 60_000);
  const inp = VM_DEPLOY_INPUT.AWS!;
  await loginAsAdmin(page);
  await gotoInfraList(page);
  expect(await infraRow(page, INFRA_ID).count(), `${INFRA_ID} 미존재(시작 상태)`).toBe(0);

  const wf = await workflowDetailByName(page, 'multi-csp-vm-deploy');
  expect(wf, 'seed Workflow multi-csp-vm-deploy 존재(TC-WF-001)').toBeTruthy();
  const idx = Number(wf!.workflowInfo.workflowIdx);
  const before = await workflowBuilds(page, idx);
  const lastBefore = Math.max(0, ...before.map((b) => Number(b.buildIdx)));
  test.info().annotations.push({ type: 'builds-before', description: `workflowIdx ${idx} / Build ${before.length}건, 마지막 #${lastBefore}` });

  // 1~3. RUN 팝업: 정상값 + AWS_IMAGE_ID 공란
  const f = await openRunRetry(page, 'multi-csp-vm-deploy');
  await pickSearchable(f, 'VM/K8s Spec', inp.specQuery, inp.specOption);
  await setParam(f, 'CSP_LIST', inp.csp);
  await setParam(f, 'INFRA_ID', INFRA_ID);
  await setParam(f, 'INFRA_NODEGROUP_PREFIX', 'g1');
  await setParam(f, 'INFRA_NODEGROUP_SIZE', '1');
  await setParam(f, 'AWS_IMAGE_ID', '');
  const keys = ['NAMESPACE', 'INFRA_ID', 'INFRA_NODEGROUP_PREFIX', 'INFRA_NODEGROUP_SIZE', 'CSP_LIST', 'AWS_REGION', 'AWS_CONNECTION_NAME', 'AWS_ZONE', 'AWS_SPEC_ID', 'AWS_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(', ') });
  test.info().annotations.push({ type: 'top-image', description: `상단 Image='${await f.getByPlaceholder('Image', { exact: true }).inputValue()}'` });
  expect(await getParam(f, 'AWS_IMAGE_ID'), 'AWS_IMAGE_ID 공란').toBe('');
  expect(await getParam(f, 'AWS_SPEC_ID'), 'AWS_SPEC_ID 정상값').toContain(inp.specQuery);

  // 4. Run
  const toasts = await runAndCollectToasts(page, f, 20_000);
  const modalOpen = await f.locator('.modal.show').isVisible().catch(() => false);
  const modalErr = modalOpen ? (await f.locator('.modal.show .invalid-feedback:visible, .modal.show .text-danger:visible, .modal.show .is-invalid').allInnerTexts().catch(() => [])).join(' | ') : '';
  test.info().annotations.push({ type: 'after-run', description: `팝업 ${modalOpen ? '열림(실행 안 됨)' : '닫힘'}${modalErr ? ` / 표시 오류: ${modalErr}` : ''}` });
  await page.waitForTimeout(3000); // 녹화용

  // 5. 관찰: 신규 Build·Infra
  let newBuild: { buildIdx: number; buildLog: string } | undefined;
  for (let i = 0; i < 4; i++) {
    await page.waitForTimeout(30_000);
    newBuild = (await workflowBuilds(page, idx)).find((b) => Number(b.buildIdx) > lastBefore);
    if (newBuild) break;
  }
  await gotoInfraList(page);
  const created = await infraRow(page, INFRA_ID).count();
  const tail = newBuild ? newBuild.buildLog.split(/\r?\n/).filter((l) => /ERROR|error|Finished|IMAGE/i.test(l)).slice(-5).join(' / ').slice(0, 600) : '';
  test.info().annotations.push({ type: 'result', description: `오류 토스트: ${toasts.join(' | ') || '(없음)'} / 신규 Build: ${newBuild ? `#${newBuild.buildIdx} ${tail}` : '없음'} / Infra ${INFRA_ID}: ${created ? '생성됨' : '없음'}` });

  expect(toasts.join(' '), '누락 값 검증 오류 안내').toMatch(/IMAGE|image|required|empty|missing|invalid|fail|error|select/i);
  expect(newBuild, '신규 Jenkins Build 미생성').toBeUndefined();
  expect(created, `Infra ${INFRA_ID} 미생성`).toBe(0);
});

IV3_S09

tests/iv3/IV3_S09.spec.ts · 50줄 · 해시 cd4aef28 · 시나리오 보기

// IV3_S09: Workflow Infra 이름 중복 RUN [예상실패, AWS]
// 화면: Workflows(iframe) → multi-csp-vm-deploy RUN 팝업 → Run
// 시작 상태: admin(mcmp) 로그인, ws01/default. IV3_S01 로 만든 mw1 이 Running
// 절차: 1.Infra Workloads 에서 mw1 의 노드 수·전체 Infra 수 기록 2.Workflows → multi-csp-vm-deploy RUN: INFRA_ID=mw1(기존과 동일), 나머지는 IV3_S01 과 같음
//   3.Run → 토스트 기록 4.일정 시간(최대 6분) Workflow 상태와 Infra 목록을 관찰
// 기대결과: 중복 에러 또는 실행 거부, 추가 자원 미생성(mw1 노드 수·Infra 수 변화 없음)
// 판정: 오류 토스트/Workflow 실패가 있고 자원이 늘지 않으면 PASS. 자원이 늘면 FAIL(중복 허용).

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, wfInfraName } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { gotoInfraList, infraRow, rowCells } from '../../lib/infra';
import { VM_DEPLOY_INPUT, runVmDeploy, workflowStatus } from '../../lib/workflow';

const nodesOf = (cells: Record<string, string>) => Number(cells['statusCount.countTotal'] ?? 'NaN');

for (const c of activeCsps(["AWS"])) {
  test(`${tid("IV3_S09", c)} ` + "Workflow Infra 이름 중복 RUN", async ({ page }) => {
    const inp = VM_DEPLOY_INPUT[c.code]!;
    const name = wfInfraName(c);
    await loginAsAdmin(page);
    await gotoInfraList(page);
    test.skip(!(await infraRow(page, name).count()), `선행 IV3_S01 의 ${name} 없음`);
    const before = await rowCells(infraRow(page, name).first());
    const infraCountBefore = await page.locator('.tabulator-row').filter({ has: page.locator('[tabulator-field="statusCount.countTotal"]') }).count();
    const wfBefore = await workflowStatus(page, 'multi-csp-vm-deploy');
    test.info().annotations.push({ type: 'before', description: `${name}: ${JSON.stringify(before)} / Infra 수 ${infraCountBefore} / workflow ${JSON.stringify(wfBefore)}` });

    const { toasts } = await runVmDeploy(page, inp, name, `g${c.n}`);
    const errToast = toasts.find((t) => /fail|error|exist|duplicat|invalid/i.test(t));

    // 관찰: Workflow 상태와 mw1 노드 수(최대 6분)
    let after = before; let wf = wfBefore; let infraCountAfter = infraCountBefore;
    for (let i = 0; i < 6; i++) {
      await page.waitForTimeout(45_000);
      wf = await workflowStatus(page, 'multi-csp-vm-deploy');
      await gotoInfraList(page);
      after = await rowCells(infraRow(page, name).first());
      infraCountAfter = await page.locator('.tabulator-row').filter({ has: page.locator('[tabulator-field="statusCount.countTotal"]') }).count();
      test.info().annotations.push({ type: `observe-${i}`, description: `workflow ${JSON.stringify(wf)} / ${name} ${after.status} nodes ${nodesOf(after)} / Infra 수 ${infraCountAfter}` });
      if (/fail|error|success|complete|done/i.test(wf.status) && i >= 1) break;
    }
    test.info().annotations.push({ type: 'result', description: `오류 토스트: ${errToast ?? '(없음)'} / workflow 최종 ${JSON.stringify(wf)} / 노드 ${nodesOf(before)} → ${nodesOf(after)} / Infra 수 ${infraCountBefore} → ${infraCountAfter}` });

    expect(nodesOf(after), `${name} 노드 수 변화 없음(추가 자원 미생성)`).toBe(nodesOf(before));
    expect(infraCountAfter, 'Infra 수 변화 없음').toBe(infraCountBefore);
    expect(errToast || /fail|error/i.test(wf.status) ? 'rejected' : 'accepted', '중복 에러 또는 실행 거부').toBe('rejected');
  });
}

IV3_S10

tests/iv3/IV3_S10.spec.ts · 59줄 · 해시 78dd8aaf · 시나리오 보기

// IV3_S10: 직접 Infra 생성 이름 중복 (예상실패) [예상실패, CSP별]
// 화면: Workloads > Infra Workloads > Add Infra (Create Infra 폼 → + NodeGroup → Done → NodeGroup Validation 확인 팝업 → Deploy → Infra Deployment Warning 팝업)
// 전제: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default. IV3_S02_AWS 완료(mc1 존재)
// 절차(AWS): 1.Add Infra: Name=mc1(기존과 동일), nodegroup 1개(g1dup) 2.Deploy → 경고 팝업은 계속 진행(Confirm)
// 기대결과: 이름 중복 시 Infra 가 생성되지 않음: 경고(already exists) 표시, 계속 진행해도 중복 오류로 생성 실패, 추가 자원 미생성
// 판정: 경고/오류 문구 기록 + 기존 mc1 의 Total Nodes·목록 행 수가 시작 시와 같으면 PASS(기대한 실패). 늘어나면 FAIL(중복 생성됨 → 즉시 정리 필요)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName } from '../../lib/data';
import { loginAsAdmin, toasts } from '../../lib/ui';
import { addNodeGroup, gotoInfraList, infraInputBlocker, infraRow, rowCells } from '../../lib/infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S10", c)} ` + "직접 Infra 생성 이름 중복 (예상실패)", async ({ page }) => {
    const why = infraInputBlocker(c);
    test.skip(!!why, why);
    const name = infraName(c);
    await loginAsAdmin(page);
    await gotoInfraList(page);
    test.skip(!(await infraRow(page, name).count()), `선행 IV3_S02: ${name} 없음`);
    const rowsBefore = await page.locator('[role=row][class*="tabulator-row"], .tabulator-row').count();
    const before = await rowCells(infraRow(page, name).first());
    test.info().annotations.push({ type: 'before', description: `${name}: Total=${before['statusCount.countTotal']} Running=${before['statusCount.countRunning']} / 목록 ${rowsBefore}행` });

    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('express');
    await page.locator('#mci_policy_on_partial_failure').selectOption('continue');
    await addNodeGroup(page, c, 'g1dup', 1);
    await page.getByRole('button', { name: 'Deploy', exact: true }).click();

    // 경고 팝업(Infra Deployment Warning 등)은 계속 진행. 나타나는 팝업 문구를 모두 기록(최대 3회)
    const popups: string[] = [];
    for (let i = 0; i < 3; i++) {
      const m = page.locator('.modal.show').last();
      if (!(await m.waitFor({ state: 'visible', timeout: 10_000 }).then(() => true, () => false))) break;
      popups.push((await m.innerText()).replace(/\s+/g, ' ').slice(0, 300));
      const btn = m.getByRole('button', { name: /Confirm|Continue|Deploy|OK|Yes/i }).last();
      if (!(await btn.count())) break;
      await btn.click();
      await page.waitForTimeout(2000);
    }
    test.info().annotations.push({ type: 'popups', description: popups.join(' || ') || '(팝업 없음)' });
    await page.waitForTimeout(5000);
    const t = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
    test.info().annotations.push({ type: 'toast', description: t.join(' | ').slice(0, 400) || '(토스트 없음)' });

    // 추가 자원 미생성 확인: 일정 시간 뒤 기존 mc1 노드 수·목록 행 수가 그대로
    await page.waitForTimeout(60_000);
    await gotoInfraList(page);
    const after = await rowCells(infraRow(page, name).first());
    const rowsAfter = await page.locator('[role=row][class*="tabulator-row"], .tabulator-row').count();
    test.info().annotations.push({ type: 'after', description: `${name}: Total=${after['statusCount.countTotal']} Running=${after['statusCount.countRunning']} / 목록 ${rowsAfter}행` });
    expect(after['statusCount.countTotal'], '기존 Infra 노드 수 변화 없음').toBe(before['statusCount.countTotal']);
    expect(rowsAfter, 'Infra 목록 행 수 변화 없음').toBe(rowsBefore);
    expect(popups.join(' ') + t.join(' '), '중복 경고/오류 문구(already exists)').toMatch(/already exist|duplicate|exists|중복/i);
  });
}

IV3_S11

tests/iv3/IV3_S11.spec.ts · 142줄 · 해시 153bb4b6 · 시나리오 보기

// IV3_S11: Workflow로 멀티 CSP Infra mc10 생성 (CSP별 nodegroup 1개, node 1개) [정상, 공통] — TC-WF-008
// 화면: Workflows(iframe) → multi-csp-vm-deploy RUN 팝업(Tumblebug Parameter Selection + Parameters) → Run → Infra Workloads(mc10 목록·상세 Node List·Node Info)
// 시작 상태: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default 선택된 상태에서 시작. TC-WF-001 완료, 같은 Namespace(default)에 mc10 없음
// 절차: 1.Workflows 진입 → multi-csp-vm-deploy RUN 2.상단 CSP=aws 에서 Spec=t3.micro(IV3_S01 과 같음)·Image=목록의 Ubuntu 22.04 server(IV3_S01 값 ami-012a353bb3afb92ee 가 목록에 없으면 목록 값 — 2026-10-16 후보는 img1 1개) 선택
//   3.파라미터: NAMESPACE=default, INFRA_ID=mc10, INFRA_NODEGROUP_PREFIX=g10, INFRA_NODEGROUP_SIZE=1, CSP_LIST=alibaba,aws,azure,gcp,ibm,ncp,nhn,tencent(KT 제외)
//      나머지 CSP 의 <CSP>_REGION/CONNECTION_NAME/ZONE/SPEC_ID/IMAGE_ID 는 팝업이 현재 카탈로그 목록에서 채운 값 그대로(run-params 에 전부 기록)
//   4.Run → 토스트 기록 5.Jenkins Build(Workflows LOG 와 같은 GET /workflow/log/{idx}) 완료와 Infra 목록 mc10 상태를 최대 45분 관찰
//   6.mc10 상세 Node List 에서 CSP별 NodeGroup g10-<csp> 의 node g10-<csp>-1 을 하나씩 선택해 Node Info 의 CSP·Node Status 확인
// 입력값(Drive): Workspace ws01 / Project default / NAMESPACE default / INFRA_ID mc10 / CSP_LIST alibaba,aws,azure,gcp,ibm,ncp,nhn,tencent /
//   INFRA_NODEGROUP_PREFIX g10 / INFRA_NODEGROUP_SIZE 1 / <CSP>_REGION·CONNECTION_NAME·ZONE·SPEC_ID·IMAGE_ID = 현재 목록에서 선택한 값
// 기대결과: Jenkins SUCCESS 및 mc10 Running, CSP별 g10-<csp> NodeGroup 1개·Node 1개 Running
// 멱등: mc10 이 이미 있으면 Run 은 건너뛰고(annotation preexisting) 상태만 검증
// 과금: CSP 8개 VM 각 1대(테스트 후 CL_S02 로 삭제 — 사용자 지시 시 실행). IV4_S03(TC-INFRA-049)·IV4_S06·S08 대상으로도 사용
// 비고: 2026-10-16 구현(stub 대체)

import { test, expect } from '../../lib/fixtures';
import type { Page, FrameLocator } from '@playwright/test';
import { MC10, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { VM_DEPLOY_INPUT, pickSearchable, setParam, getParam, runAndCollectToasts } from '../../lib/workflow';
import { openRunRetry } from '../../lib/iv3-mc10';
import { workflowDetailByName, workflowBuilds, failureLines } from '../../lib/iv3-wf';
import { gotoInfraList, refreshInfraList, infraRow, infraRowText, waitInfraListed, openInfraDetail, nodeNames, readNodeInfo } from '../../lib/iv4-infra';

import { MC10_CSPS } from '../../lib/iv3-mc10'; // Drive 입력값 순서(KT 제외)
/** 상단 Image 검색형 목록에서 선택: preferId 가 있으면 그것, 없으면 fallback 에 맞는 첫 옵션. 반환: 선택된 Image ID(옵션 'ID / 이름' 의 ID) */
async function pickImageFromList(page: Page, f: FrameLocator, preferId: string, fallback: RegExp) {
  const input = f.getByPlaceholder('Image', { exact: true });
  await input.click({ force: true });
  await input.evaluate((el) => { (el as HTMLInputElement).value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); });
  await page.waitForTimeout(3000);
  const opts = f.locator('.searchable-select-option');
  if (!(await opts.count())) { await input.click({ force: true }); await page.waitForTimeout(3000); }
  const texts = (await opts.allInnerTexts()).map((t) => t.replace(/\s+/g, ' ').trim());
  test.info().annotations.push({ type: 'aws-image-options', description: texts.slice(0, 20).join(' | ') || '(없음)' });
  let i = texts.findIndex((t) => t.startsWith(`${preferId} `) || t === preferId);
  if (i < 0) i = texts.findIndex((t) => fallback.test(t));
  expect(i, `Image 목록에 ${preferId} 또는 ${fallback}`).toBeGreaterThanOrEqual(0);
  await opts.nth(i).click();
  const id = texts[i].split(' / ')[0].trim();
  test.info().annotations.push({ type: 'aws-image', description: `선택: ${texts[i]}${id !== preferId ? ` (${preferId} 는 목록에 없음)` : ''}` });
  return id;
}

const NAME = MC10.name;
const PREFIX = 'g10';

test(`${tid('IV3_S11')} Workflow로 멀티 CSP Infra mc10 생성 (CSP별 nodegroup 1개, node 1개)`, async ({ page }) => {
  test.setTimeout(70 * 60_000);
  const aws = VM_DEPLOY_INPUT.AWS!;
  await loginAsAdmin(page);
  await gotoInfraList(page);
  const exists = await infraRow(page, NAME).first().waitFor({ timeout: 15_000 }).then(() => true, () => false);
  const wf = await workflowDetailByName(page, 'multi-csp-vm-deploy');
  expect(wf, 'seed Workflow multi-csp-vm-deploy 존재(TC-WF-001)').toBeTruthy();
  const idx = Number(wf!.workflowInfo.workflowIdx);
  const lastBefore = Math.max(0, ...(await workflowBuilds(page, idx)).map((b) => Number(b.buildIdx)));

  let finished = '';
  if (exists) {
    test.info().annotations.push({ type: 'preexisting', description: `${NAME} 이미 존재 → Run 건너뜀: ${await infraRowText(page, NAME)}` });
  } else {
    // 1~3. RUN 팝업
    const f = await openRunRetry(page, 'multi-csp-vm-deploy');
    await pickSearchable(f, 'VM/K8s Spec', aws.specQuery, aws.specOption);
    // Image: 현재 목록에서 선택(Drive 입력값 '<선택 Spec과 호환되는 Image ID>'). IV3_S01 값(ami-012a353bb3afb92ee)이 없으면 목록의 Ubuntu 22.04 server 이미지
    // (2026-10-16 t3.micro 의 후보는 'img1 / ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-20260731' 1개뿐 — ami 는 No matches)
    const awsImage = await pickImageFromList(page, f, aws.imageQuery, /ubuntu-jammy-22\.04-amd64-server/);
    await setParam(f, 'NAMESPACE', 'default');
    await setParam(f, 'INFRA_ID', NAME);
    await setParam(f, 'INFRA_NODEGROUP_PREFIX', PREFIX);
    await setParam(f, 'INFRA_NODEGROUP_SIZE', '1');
    await setParam(f, 'CSP_LIST', MC10_CSPS.join(','));
    const keys = ['NAMESPACE', 'INFRA_ID', 'INFRA_PREFIX', 'INFRA_NODEGROUP_PREFIX', 'INFRA_NODEGROUP_SIZE', 'PROVIDER', 'CSP', 'CSP_LIST',
      ...MC10_CSPS.flatMap((c) => ['REGION', 'CONNECTION_NAME', 'ZONE', 'SPEC_ID', 'IMAGE_ID'].map((k) => `${c.toUpperCase()}_${k}`))];
    const vals: Record<string, string> = {};
    for (const k of keys) vals[k] = await getParam(f, k).catch(() => '?');
    test.info().annotations.push({ type: 'run-params', description: Object.entries(vals).map(([k, v]) => `${k}=${v}`).join(', ') });
    for (const c of MC10_CSPS) {
      expect(vals[`${c.toUpperCase()}_SPEC_ID`], `${c} SPEC_ID 값`).toMatch(new RegExp(`^${c}\\+`));
      expect(vals[`${c.toUpperCase()}_IMAGE_ID`], `${c} IMAGE_ID 값`).not.toBe('');
    }
    expect(vals.AWS_IMAGE_ID, 'AWS_IMAGE_ID 선택됨').toBe(awsImage);
    await page.waitForTimeout(2000); // 녹화용
    // 4. Run
    const toasts = await runAndCollectToasts(page, f, 30_000);
    const err = toasts.find((t) => /fail|error|invalid|not found|reselect/i.test(t));
    expect(err, 'Run 직후 오류 토스트 없음').toBeUndefined();
  }

  // 5. Build 완료 + mc10 상태 관찰(최대 45분)
  const trail: string[] = [];
  const start = Date.now();
  let row = '';
  // Build 는 같은 Workflow 를 다른 세션도 실행하므로(2026-10-16 IV3_S01 mw* 동시 실행) payload 의 "name":"mc10" 으로 찾는다(1차 실행은 다른 세션 Build 를 잡음)
  const myBuild = async () => (await workflowBuilds(page, idx))
    .filter((x) => x.buildLog.includes(`payload: {"name":"${NAME}"`) && (exists || Number(x.buildIdx) > lastBefore))
    .sort((a, b) => Number(b.buildIdx) - Number(a.buildIdx))[0];
  while (Date.now() - start < 45 * 60_000) {
    if (!finished) {
      const b = await myBuild();
      finished = b?.buildLog.match(/Finished: (\w+)/)?.[1] ?? '';
      if (finished) {
        const resp = b!.buildLog.split(/\r?\n/).find((l) => l.startsWith('{"resourceType":"infra"')) ?? '';
        const sys = resp.match(/"systemMessage":(\[[^\]]*\]|null)/)?.[1] ?? '';
        test.info().annotations.push({ type: 'build', description: `#${b!.buildIdx} Finished: ${finished}${finished !== 'SUCCESS' ? ' / ' + failureLines(b!.buildLog).join(' / ') : ''}` });
        test.info().annotations.push({ type: 'build-systemMessage', description: sys.slice(0, 1500) || '(없음)' });
      }
    }
    await refreshInfraList(page);
    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`);
    const done = /^Running/i.test(st) && !/partial/i.test(row);
    if (finished && (done || /Failed|Partial/i.test(st))) break;
    await page.waitForTimeout(30_000);
  }
  test.info().annotations.push({ type: 'status-trail', description: trail.join(' → ') });
  test.info().annotations.push({ type: 'list-row', description: row });

  // 6. CSP별 NodeGroup·node 확인
  expect(await waitInfraListed(page, NAME), `${NAME} 목록 표시`).toBeTruthy();
  await openInfraDetail(page, NAME);
  const nodes = await nodeNames(page);
  test.info().annotations.push({ type: 'nodes', description: nodes.join(', ') });
  const bad: string[] = [];
  for (const c of MC10_CSPS) {
    const ng = `${PREFIX}-${c}`;
    const node = `${ng}-1`;
    if (!nodes.includes(node)) { bad.push(`${node} 없음`); continue; }
    const info = await readNodeInfo(page, ng, node);
    if (!info.csp.toLowerCase().includes(c)) bad.push(`${node} CSP=${info.csp}`);
    if (!/^Running/i.test(info.status)) bad.push(`${node} ${info.status || '(상태 빈 값)'}`);
  }
  const extra = nodes.filter((n) => !MC10_CSPS.some((c) => n === `${PREFIX}-${c}-1`));
  if (extra.length) bad.push(`예상 외 node: ${extra.join(',')}`);
  test.info().annotations.push({ type: 'per-csp', description: bad.length ? bad.join(' / ') : `8개 CSP 모두 g10-<csp>-1 Running` });
  expect(finished, 'Jenkins Build SUCCESS').toBe('SUCCESS');
  expect(bad, 'CSP별 NodeGroup 1개·Node 1개 Running').toEqual([]);
  expect(row, `${NAME} Running`).toMatch(/^Running/i);
});

IV3_S12

tests/iv3/IV3_S12.spec.ts · 49줄 · 해시 3c3db986 · 시나리오 보기

// IV3_S12: Project 생성 → Workspace(ws01) 할당 [정상, 공통 — CSP 무관 기능]
// 화면: Workspaces > Projects(/webconsole/operations/manage/workspaces/projects) → 목록(ID, Name, Description, NsId, Workspaces, Created), Add Project 팝업(#modal-project-add:
//   #add-project-name, #add-project-description, #add-project-initial-workspace(ws01)), 행 클릭 → 상세(Assigned Workspaces 'ws01 (id: 1) Unassign', 'Assign to Workspace...' + Assign)
// 절차: 1.Projects 진입 2.p1 이 없으면 Add Project(Name=p1, Initial Workspace=ws01) → Save 3.목록에 p1(Workspaces 1) 4.행 클릭 → Assigned Workspaces 에 ws01 5.상단 Project 선택 목록에 p1
// 2026-10-12 사용자 결정: 프로젝트는 CSP 와 무관한 공통 기능 → CSP 별 9행을 공통 1행으로 합침(녹화번호 IV3_S12).
// p1: 사전점검(2026-10-01)으로 이미 존재 → 사용자 결정(2026-10-05) '존재 확인으로 대체'(생성 생략, 특화명 IV3_S12_Projects_ViewProject-CheckWorkspace)

import { test, expect } from '../../lib/fixtures';
import { WORKSPACE } from '../../lib/data';
import { loginAsAdmin, selectWorkspaceProject } from '../../lib/ui';

const URL_PROJECTS = '/webconsole/operations/manage/workspaces/projects';
const PRJ = 'p1';

test('IV3_S12 Project 생성 → Workspace(ws01) 할당', async ({ page, dialogs }) => {
  await loginAsAdmin(page);
  await page.goto(URL_PROJECTS);
  await page.getByRole('gridcell', { name: 'default', exact: true }).first().waitFor({ timeout: 60_000 });
  const cell = page.getByRole('gridcell', { name: PRJ, exact: true });
  if (await cell.count()) {
    test.info().annotations.push({ type: 'preexisting', description: `${PRJ} 이미 존재 → 생성 생략(존재 확인으로 대체)` });
  } else {
    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(PRJ);
    await m.locator('#add-project-description').fill('IV3_S12 공통');
    await m.getByRole('button', { name: 'Save' }).click();
    await expect(m).toBeHidden({ timeout: 30_000 });
    const t = (await page.locator('[class*="toast"], [role="alert"]').allInnerTexts()).map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
    test.info().annotations.push({ type: 'create', description: [...t, ...dialogs].join(' | ').slice(0, 300) || '(메시지 없음)' });
    await page.goto(URL_PROJECTS);
    await expect(cell.first(), `목록에 ${PRJ}`).toBeVisible({ timeout: 60_000 });
  }
  // 목록 행: Workspaces 수
  const row = page.getByRole('row').filter({ has: cell.first() }).first();
  const ws = (await row.locator('[tabulator-field="workspaceCount"]').innerText()).trim();
  test.info().annotations.push({ type: 'row', description: (await row.innerText()).replace(/\s+/g, ' ') });
  expect(Number(ws), 'Workspaces 1 이상').toBeGreaterThanOrEqual(1);
  // 상세: Assigned Workspaces 에 ws01
  await cell.first().click();
  await expect(page.getByText(new RegExp(`${WORKSPACE} \\(id: \\d+\\)`)).first(), `Assigned Workspaces 에 ${WORKSPACE}`).toBeVisible({ timeout: 30_000 });
  // 상단 Project 선택 목록에 표시(새 프로젝트는 즉시 반영 안 될 수 있어 Workspace 재선택 후 확인)
  await selectWorkspaceProject(page, WORKSPACE, null, true);
  await expect(page.locator('#select-current-project option', { hasText: new RegExp(`^${PRJ}$`) }), `상단 Project 목록에 ${PRJ}`).toHaveCount(1, { timeout: 30_000 });
  await page.locator('#select-current-project').selectOption({ label: PRJ });
  test.info().annotations.push({ type: 'header', description: `Project 선택: ${PRJ}` });
});

IV3_S13

tests/iv3/IV3_S13.spec.ts · 66줄 · 해시 1af3342f · 시나리오 보기

// IV3_S13: Workflow로 K8s 클러스터 생성 (nodegroup 1개) [정상, CSP별]
// 화면: Workflows(iframe) → multi-csp-k8s-cluster-deploy RUN → Workflows Status / LOG → Workloads > K8s Workloads(Project p{n})
// 시작 상태: admin(mcmp) 로그인, ws01. Project p{n} 존재(IV3_S12)
// 절차(AWS): 1.Workflows → multi-csp-k8s-cluster-deploy RUN: NAMESPACE=p1, CSP_LIST=aws, Spec t3.medium, CLUSTER_PREFIX=k1, K8S_NODEGROUP_PREFIX=g1, node 1(min/max 1)
//   2.Run → Workflow Status(SUCCESS/FAILURE) 대기 3.K8s Workloads(p1) 목록에 클러스터 표시·상태 확인
// 기대결과: K8s 클러스터 Ready(Active), nodegroup 1개(node 1)
// 클러스터 이름은 {CLUSTER_PREFIX}-{csp} 형식일 수 있음(Infra 워크플로우 nodegroup 규칙과 같다면 k1-aws) → 실행 결과로 확인

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sName, k8sNodegroup, k8sProject, WORKSPACE } from '../../lib/data';
import { loginAsAdmin, selectWorkspaceProject } from '../../lib/ui';
import { K8S_DEPLOY_INPUT, runK8sDeploy, workflowStatus } from '../../lib/workflow';

const K8S_URL = '/webconsole/operations/manage/workloads/k8sworkloads';

async function k8sList(page: import('@playwright/test').Page, prj: string) {
  await page.goto(K8S_URL);
  await selectWorkspaceProject(page, WORKSPACE, prj, true);
  await page.getByText(/Showing \d+/).first().waitFor({ timeout: 30_000 }).catch(() => {});
  await page.waitForTimeout(2000);
  return (await page.getByRole('row').allInnerTexts()).map((s) => s.replace(/\s+/g, ' '));
}

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S13", c)} ` + "Workflow로 K8s 클러스터 생성 (nodegroup 1개)", async ({ page }) => {
    const inp = K8S_DEPLOY_INPUT[c.code];
    test.skip(!inp, `${c.name}: K8s Workflow 입력값 미확정(사전점검 필요)`);
    const prj = k8sProject(c);
    const prefix = k8sName(c);
    await loginAsAdmin(page);
    const before = await k8sList(page, prj);
    test.info().annotations.push({ type: 'k8s-before', description: before.join(' || ').slice(0, 500) });
    const exists = before.some((r) => new RegExp(`\\b${prefix}(-${inp!.csp})?\\b`).test(r));
    if (exists) {
      test.info().annotations.push({ type: 'preexisting', description: `${prefix} 클러스터 이미 존재 → Run 생략` });
    } else {
      const { toasts } = await runK8sDeploy(page, inp!, prj, prefix, k8sNodegroup(c), '1');
      expect(toasts.find((t) => /fail|error|invalid/i.test(t)), 'Run 직후 오류 토스트 없음').toBeUndefined();
      // Workflow 완료 대기(최대 40분)
      let wf = { status: '', runDate: '' };
      for (let i = 0; i < 80; i++) {
        await page.waitForTimeout(30_000);
        wf = await workflowStatus(page, 'multi-csp-k8s-cluster-deploy');
        if (i % 4 === 0) test.info().annotations.push({ type: `wf-${i}`, description: JSON.stringify(wf) });
        if (/success|failure|fail|abort/i.test(wf.status)) break;
      }
      test.info().annotations.push({ type: 'wf-final', description: JSON.stringify(wf) });
      expect(wf.status, 'Workflow SUCCESS').toMatch(/success/i);
    }
    // K8s Workloads 목록: 클러스터 표시·상태
    let rows: string[] = [];
    await expect.poll(async () => { rows = await k8sList(page, prj); return rows.some((r) => r.includes(prefix)); },
      { message: `K8s Workloads(${prj}) 에 ${prefix}*`, timeout: 10 * 60_000, intervals: [30_000] }).toBe(true);
    const row = rows.find((r) => r.includes(prefix))!;
    test.info().annotations.push({ type: 'k8s-row', description: row });
    expect(row, '클러스터 Active/Running/Ready').toMatch(/active|running|ready/i);
    // 기대결과 'nodegroup 1개(node 1)' — 목록 Node Group 칸(개수)으로 확인(2026-10-15 보완: 이전엔 Active 만 봐서 NodeGroup 0개도 PASS 로 판정됨)
    // AWS 는 tumblebug nodeGroupsOnCreation=false(assets/k8sclusterinfo.yaml) → 클러스터 생성 후 NodeGroup 을 따로 추가해야 하는데 workflow 에 그 단계가 없어 0개(2026-10-15 AWS FAIL)
    // 행 텍스트 예: 'Active k1-aws 0 vpc-…'(셀 사이 공백이 없을 수 있음) → 클러스터 이름 바로 뒤 숫자가 Node Group 개수
    const cname = row.match(new RegExp(`${prefix}(?:-${inp!.csp})?`))?.[0] ?? prefix;
    const ngCount = Number(row.match(new RegExp(`${cname}\\s*(\\d+)\\s*(?:vpc-|tb|N/A)`))?.[1] ?? NaN);
    test.info().annotations.push({ type: 'nodegroup-count', description: String(ngCount) });
    expect(ngCount, 'NodeGroup 1개').toBeGreaterThanOrEqual(1);
  });
}

IV3_S14

tests/iv3/IV3_S14.spec.ts · 22줄 · 해시 c06a830c · 시나리오 보기

// IV3_S14: Data Disk 생성 [정상, CSP별] — Disk 테스트 3분리(CreateDisk / AttachDisk / DetachDisk) 중 1 (사용자 지시 2026-10-05)
// 화면: Cloud Resources > Disks > Create Data Disk (Connection, Disk Name, Disk Type(optional), Disk Size, Description)
// 절차(AWS): 1.Disks 진입 2.Create Data Disk: Connection aws-ap-northeast-2, Name disk1d, Size 10GB → Create 3.목록에 disk1d Available 확인
// 기대결과: 토스트 'Data Disk "disk1d" created successfully', 목록에 Available(Attached To '-')
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';

import { DISK_TARGET, createDisk, diskTargetBlocker, waitDiskState } from '../../lib/infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S14", c)} ` + "Data Disk 생성 (CreateDisk)", async ({ page }) => {
    const why = diskTargetBlocker(c);
    test.skip(!!why, why);
    const tg = DISK_TARGET[c.code]!;
    await loginAsAdmin(page);
    await createDisk(page, c.infra.conn, tg.diskSplit, tg.size, tg.diskType);
    const cells = await waitDiskState(page, tg.diskSplit, 'available');
    expect(cells._attachedTo ?? '-', '미연결').toBe('-');
  });
}

IV3_S15

tests/iv3/IV3_S15.spec.ts · 36줄 · 해시 64ad8fcf · 시나리오 보기

// IV3_S15: Data Disk attach — Infra 상세(Node Info)에서 [정상, CSP별] — Disk 테스트 3분리 중 2 (선행 IV3_S14)
// 화면: Infra Workloads > Infra 선택 > Node List 에서 node 선택 > Node Info > Block Device '+ Attach Disk' (Attach Data Disk: Target Node 고정, Existing Disk / Create New)
// 절차(AWS): 1.mc1 선택 → Running node(g1b-2) 선택 → Node Info 2.+ Attach Disk → Existing Disk: disk1d → Attach 3.Block Device 에 disk1d, Disks 목록 Attached / Attached To 'mc1 / {node}' 확인
// 기대결과: disk1d 가 node 에 attach
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';

import { DISK_TARGET, attachExistingDisk, blockDevice, diskCells, diskTargetBlocker, openInfraDetail, openNodeInfo, pickRunningNode, waitDiskState } from '../../lib/infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S15", c)} ` + "Data Disk attach (AttachDisk)", async ({ page }) => {
    const why = diskTargetBlocker(c);
    test.skip(!!why, why);
    const tg = DISK_TARGET[c.code]!;
    await loginAsAdmin(page);
    const before = await diskCells(page, tg.diskSplit);
    test.skip(!before.name, `선행 IV3_S14 의 ${tg.diskSplit} 없음`);
    test.info().annotations.push({ type: 'disk-before', description: JSON.stringify(before) });
    const node = await pickRunningNode(page, tg.infra, tg.node);
    const ng = node.replace(/-\d+$/, '');
    let info = await openNodeInfo(page, ng, node);
    if (/attached/i.test(before.status ?? '')) {
      test.info().annotations.push({ type: 'preexisting', description: `${tg.diskSplit} 이미 attach: ${before._attachedTo}` });
    } else {
      const target = await attachExistingDisk(page, tg.diskSplit);
      expect(target, '대상 노드 표시').toContain(node);
    }
    await waitDiskState(page, tg.diskSplit, 'attached');
    await openInfraDetail(page, tg.infra);
    info = await openNodeInfo(page, ng, node);
    test.info().annotations.push({ type: 'block', description: blockDevice(info) });
    expect(blockDevice(info), 'Block Device 에 disk 표시').toContain(tg.diskSplit);
  });
}

IV3_S16

tests/iv3/IV3_S16.spec.ts · 37줄 · 해시 1a6c14fd · 시나리오 보기

// IV3_S16: Data Disk detach — Infra 상세(Node Info)에서 [정상, CSP별] — Disk 테스트 3분리 중 3 (선행 IV3_S15)
// 화면: Infra Workloads > Infra 선택 > Node List 에서 node 선택 > Node Info > Block Device 의 disk 'Detach' → 'Detach disk "{disk}"?' Confirm
// 절차(AWS): 1.Disks 목록에서 disk1d 가 attach 된 node 확인 2.mc1 → 해당 node 선택 → Node Info 3.Block Device disk1d Detach → Confirm 4.Disks 목록 Available, Block Device 에서 사라짐 확인
// 기대결과: disk1d 가 node 에서 해제되어 Available
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';

import { DISK_TARGET, blockDevice, detachDisk, diskCells, diskTargetBlocker, openInfraDetail, openNodeInfo, waitDiskState } from '../../lib/infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S16", c)} ` + "Data Disk detach (DetachDisk)", async ({ page }) => {
    const why = diskTargetBlocker(c);
    test.skip(!!why, why);
    const tg = DISK_TARGET[c.code]!;
    await loginAsAdmin(page);
    // disk1d(S15) 가 AZ 불일치로 attach 되지 않은 경우(v07 AWS) S25 로 attach 된 disk1e 를 대상으로 한다
    let disk = tg.diskSplit;
    let before = await diskCells(page, disk);
    if (!/attached/i.test(before.status ?? '') && tg.diskNew) { disk = tg.diskNew; before = await diskCells(page, disk); }
    test.info().annotations.push({ type: 'disk-before', description: `${disk}: ${JSON.stringify(before)}` });
    test.skip(!/attached/i.test(before.status ?? ''), `선행 IV3_S15/S25: attach 된 disk 없음`);
    // Attached To 'mc1 / g1b-2' 에서 node 추출
    const node = (before._attachedTo ?? '').split('/').pop()!.trim();
    const ng = node.replace(/-\d+$/, '');
    await openInfraDetail(page, tg.infra);
    let info = await openNodeInfo(page, ng, node);
    expect(blockDevice(info), '시작 시 Block Device 에 disk').toContain(disk);
    await detachDisk(page, disk);
    await waitDiskState(page, disk, 'available');
    await openInfraDetail(page, tg.infra);
    info = await openNodeInfo(page, ng, node);
    test.info().annotations.push({ type: 'block', description: blockDevice(info) });
    expect(blockDevice(info), 'Block Device 에서 disk 해제').not.toContain(disk);
  });
}

IV3_S17

tests/iv3/IV3_S17.spec.ts · 38줄 · 해시 47efaf32 · 시나리오 보기

// IV3_S17: K8s 직접 생성 타입 A: Cluster 생성 요청 → 목록에서 Creating 확인 (K8s Workloads) [정상, CSP별]
// 화면: Workloads > K8s Workloads(Project p{n}) > 'Add cluster' 링크(#createcluster) → Create Cluster 기본 폼(Cluster Name, Provider, Region, Connection, Description, Version) → Deploy → 목록
// 절차(AWS): 1.K8s Workloads 진입(ws01 → p1) 2.Add cluster: Cluster Name=k1d, Provider=AWS, Region=[AWS] aws-ap-northeast-2, Connection=aws-ap-northeast-2, Version 비움 3.Deploy
//   4.목록에 k1d 가 Creating 으로 표시되는지 확인 후 종료(Active 대기는 녹화하지 않음)
// AWS 는 기본 폼에 NodeGroup 입력란이 없는 타입 A(2026-10-05 프로브·사용자 확인). Deploy 는 실제 과금 → 실행 전 사용자 확인(K8S_DEPLOY_OK=1 일 때만 Deploy)

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { clusterCells, deployCluster, fillCreateCluster, gotoK8s, waitClusterStatus } from '../../lib/iv3-k8s';

const INPUT: Record<string, { name: string; provider: string; conn: string } | undefined> = {
  AWS: { name: 'k1d', provider: 'AWS', conn: 'aws-ap-northeast-2' },
  // Azure(2026-10-06): 기본 폼에 NodeGroup 입력란이 나타나는 타입 B — 입력란을 비우고 Cluster 만 요청해 허용/거부를 기록(사용자 지시)
  Azure: { name: 'k2d', provider: 'AZURE', conn: 'azure-koreacentral' },
};

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S17", c)} ` + "K8s 직접 생성 타입 A: Cluster 생성 요청 → Creating 확인", async ({ page }) => {
    const inp = INPUT[c.code];
    test.skip(!inp, `${c.name}: 입력값 미지정(AWS 완료 후 별도 지시)`);
    test.skip(process.env.K8S_DEPLOY_OK !== '1', 'Deploy 전 사용자 확인 필요(K8S_DEPLOY_OK=1)');
    const prj = k8sProject(c);
    await loginAsAdmin(page);
    await gotoK8s(page, prj);
    const before = await clusterCells(page, inp!.name);
    test.skip(!!before.name || Object.keys(before).length > 0, `${inp!.name} 이미 존재: ${JSON.stringify(before)}`);
    await fillCreateCluster(page, { ...inp!, desc: 'IV3_S17 타입 A' });
    // 타입 A 확인: 기본 폼에 NodeGroup 입력란 없음
    const ngField = await page.locator('input[id*="nodegroup" i]:visible, input[id*="ng_" i]:visible').count();
    test.info().annotations.push({ type: 'nodegroup-field', description: ngField ? `NodeGroup 입력란 ${ngField}개 있음` : '없음(타입 A)' });
    const toasts = await deployCluster(page);
    expect(toasts.find((t) => /fail|error|invalid/i.test(t)), 'Deploy 직후 오류 토스트 없음').toBeUndefined();
    const cells = await waitClusterStatus(page, prj, inp!.name, /creat|provision|pending|progress/i, 5 * 60_000);
    test.info().annotations.push({ type: 'result', description: `${inp!.name} 목록 상태: ${cells.status}` });
  });
}

IV3_S18

tests/iv3/IV3_S18.spec.ts · 89줄 · 해시 868a6f71 · 시나리오 보기

// IV3_S18: K8s 직접 생성 타입 B: Cluster 와 NodeGroup 한 번에 생성 요청 → 목록에서 Creating 확인 (K8s Workloads) [정상, CSP별]
// 화면: Workloads > K8s Workloads > Add cluster (Create Cluster: Provider 선택 시 나타나는 NodeGroup 입력란 포함) → Deploy → 목록
// 절차(Azure, p2): 1.K8s Workloads 진입(ws01 → p2) 2.Add cluster: Cluster Name=k2e, Provider=AZURE, Region=[AZURE] azure-koreacentral, Connection=azure-koreacentral
//   + NodeGroup g2e(Spec 검색 vCPU 4 / Memory 16 → Standard_B4ms, Image Use Default, node 1) 3.Deploy 4.목록에 k2e 가 Creating 으로 표시되는지 확인 후 종료(Active 대기 녹화 안 함)
// Spec: Standard_B2s(2 vCPU)는 stage 카탈로그에 없음(azure-koreacentral 819개, 최소 4 vCPU — 2026-10-06 확인) → 사용자 지시로 B4ms → 실패 시 B4as_v2 → D4s_v5 (env AZ_SPEC 로 지정)
// Deploy 는 실제 과금 → K8S_DEPLOY_OK=1 일 때만 Deploy. K8S_DRY=1 이면 폼 입력까지만 확인
// AWS 는 타입 A(기본 폼에 NodeGroup 입력란 없음) → SKIP 기록 완료
// 2026-10-16 수정(9 CSP 실행): NHN·NCP·IBM 입력값 추가, Azure 기본 Spec Standard_E4as_v6, 폼에 NodeGroup 입력란이 없으면(타입 A: AWS·Alibaba·Tencent) Deploy 없이 SKIP,
//   KT 는 SKIP_REASON(Project p9 없음). 정리: wsl/probe/k8s-cycle.spec.ts K8S_ACTION=delete CYCLE_CLUSTER=k{n}e TC_VARIANT=<csp>
//   테스트 시간: GKE·IBM 등 N/A 재조회 포함 최대 40분 → test.setTimeout 지정

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { clusterCells, deployCluster, fillCreateCluster, fillDynamicNodeGroup, gotoK8s, openCluster, waitClusterStatus } from '../../lib/iv3-k8s';

const SPECS: Record<string, { mem: number; cpu: number }> = {
  Standard_B4ms: { mem: 16, cpu: 4 }, Standard_B4as_v2: { mem: 16, cpu: 4 }, Standard_D4s_v5: { mem: 16, cpu: 4 },
  // 2026-10-13 IV5: 16GB 급은 검색 버그로 불가 → 사용자 지시로 Standard_E4as_v6 사용(약 6분 Active). 2026-10-16 Azure 기본값으로 지정
  Standard_E4as_v6: { mem: 0, cpu: 4 }, 'r2.c4m16': { mem: 0, cpu: 4 }, 's4-g3': { mem: 0, cpu: 4 }, 'bx2-4x16': { mem: 0, cpu: 4 },
  // GCP: stage 카탈로그(asia-northeast3)에 e2-standard 4vCPU 이하 없음 · 메모리 정확 일치 검색 0건 → vCPU=4 로 검색(mem 0 = 조건 없음, 2026-10-15)
  'e2-highmem-4': { mem: 0, cpu: 4 },
};

const INPUT: Record<string, { name: string; provider: string; conn: string; ng: string; spec: string } | undefined> = {
  Azure: { name: process.env.K8S_CLUSTER || 'k2e', provider: 'AZURE', conn: 'azure-koreacentral', ng: process.env.K8S_NG || 'g2e', spec: process.env.AZ_SPEC || 'Standard_E4as_v6' },
  // GCP(2026-10-15 K8s CSP 사이클): p4 에 k4e(NodeGroup g4e, e2-highmem-4). Creating 확인 후 사이클 유틸로 삭제
  GCP: { name: process.env.K8S_CLUSTER || 'k4e', provider: 'GCP', conn: 'gcp-asia-northeast3', ng: process.env.K8S_NG || 'g4e', spec: 'e2-highmem-4' },
  // 2026-10-16 9 CSP 확장(미실행 TC 실행): 2026-10-13 IV5 K8s 확장 기록 기준 타입 B 후보 NHN·NCP·IBM 입력값 추가.
  //   Spec 검색 팝업 메모리 하한 버그(16 고정·근사값) 때문에 vCPU=4 로만 검색하고 이름으로 고른다(mem 0)
  NHNCloud: { name: process.env.K8S_CLUSTER || 'k6e', provider: 'NHN', conn: 'nhn-kr1', ng: process.env.K8S_NG || 'g6e', spec: 'r2.c4m16' },
  NCP: { name: process.env.K8S_CLUSTER || 'k7e', provider: 'NCP', conn: 'ncp-kr', ng: process.env.K8S_NG || 'g7e', spec: 's4-g3' }, // c2-g3 는 검색 불가(2026-10-13), NG 이름 3자 이상
  IBM: { name: process.env.K8S_CLUSTER || 'k8e', provider: 'IBM', conn: 'ibm-jp-tok', ng: process.env.K8S_NG || 'g8e', spec: 'bx2-4x16' },
  // 타입 A 확인용(폼에 NodeGroup 입력란이 없으면 SKIP — Deploy 안 함): AWS·Alibaba·Tencent (2026-10-05·10-13 기록)
  AWS: { name: 'k1e', provider: 'AWS', conn: 'aws-ap-northeast-2', ng: 'g1e', spec: 't3.medium' },
  Alibaba: { name: process.env.K8S_CLUSTER || 'k3e', provider: 'ALIBABA', conn: 'alibaba-ap-northeast-2', ng: process.env.K8S_NG || 'g3e', spec: 'ecs.e-c1m4.xlarge' },
  Tencent: { name: process.env.K8S_CLUSTER || 'k5e', provider: 'TENCENT', conn: 'tencent-ap-seoul', ng: process.env.K8S_NG || 'g5e', spec: 'S8.LARGE16' },
};
// KT: K8s 는 AWS 에 한해 진행(lib/data.ts KT k8s.note), ws01 에 Project p9 없음(2026-10-16 확인) → SKIP
const SKIP_REASON: Record<string, string> = { KT: 'KT: K8s 진행 대상 아님(lib/data KT k8s.note "K8s 는 AWS 에 한해 진행"), ws01 에 Project p9 없음(GetAllK8sCluster nsId=p9 → 404, 2026-10-16)' };

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S18", c)} ` + "K8s 직접 생성 타입 B: Cluster 와 NodeGroup 한 번에 생성 요청 → 목록에서 Creating 확인 (K8s Workloads)", async ({ page }) => {
    test.setTimeout(45 * 60_000);
    test.skip(!!SKIP_REASON[c.code], SKIP_REASON[c.code]);
    const inp = INPUT[c.code];
    test.skip(!inp, `${c.name}: 입력값 미지정(별도 지시)`);
    const dry = process.env.K8S_DRY === '1';
    test.skip(!dry && process.env.K8S_DEPLOY_OK !== '1', 'Deploy 전 사용자 확인 필요(K8S_DEPLOY_OK=1)');
    // K8S_PROJECT: CSP 기본 Project(p{n})의 VPC 쿼터·한도 초과 시 다른 Project 로 실행(2026-10-16 사용자 지시: NHN·NCP 는 default)
    const prj = process.env.K8S_PROJECT || k8sProject(c);
    await loginAsAdmin(page);
    await gotoK8s(page, prj);
    const before = await clusterCells(page, inp!.name);
    test.skip(process.env.K8S_RECHECK !== '1' && Object.keys(before).length > 0, `${inp!.name} 이미 존재: ${JSON.stringify(before)}`);
    // K8S_RECHECK=1: 이미 요청한 클러스터(같은 이름)를 Deploy 없이 상태만 재확인(2026-10-15 GKE: 생성 중 Status N/A)
    if (!(process.env.K8S_RECHECK === '1' && Object.keys(before).length > 0)) {
          await fillCreateCluster(page, { name: inp!.name, provider: inp!.provider, conn: inp!.conn, desc: 'IV3_S18 타입 B' });
      // 2026-10-16: 기본 폼에 NodeGroup 입력란이 없으면 타입 A → 이 TC(타입 B) 비대상으로 SKIP(Deploy 안 함)
      const typeB = await page.locator('#nodegroup_name_dynamic').waitFor({ state: 'visible', timeout: 15_000 }).then(() => true, () => false);
      test.info().annotations.push({ type: 'form-type', description: typeB ? '타입 B(NodeGroup 입력란 있음)' : '타입 A(NodeGroup 입력란 없음)' });
      test.skip(!typeB, `${c.name}: 기본 폼에 NodeGroup 입력란 없음(타입 A) — 타입 B TC 비대상`);
      const found = await fillDynamicNodeGroup(page, { name: inp!.ng, spec: inp!.spec, ...SPECS[inp!.spec], size: 1 });
      expect(found, `Spec 검색 결과에 ${inp!.spec}`).toBe(true);
      if (dry) { test.info().annotations.push({ type: 'dry', description: 'K8S_DRY=1: Deploy 안 함' }); return; }
      const toasts = await deployCluster(page);
      expect(toasts.find((t) => /fail|error|invalid|warning/i.test(t)), 'Deploy 직후 오류/경고 토스트 없음').toBeUndefined();
    }
    // Azure: 목록 Status 는 생성 중 'N/A'(2026-10-06), 상세 Cluster Info Status 가 Creating → 목록에 행이 생기면 상세로 판정
    const cells = await waitClusterStatus(page, prj, inp!.name, /creat|provision|pending|progress|active|fail|N\/A/i, 8 * 60_000);
    const info = await openCluster(page, inp!.name);
    const st = info.match(/Status (\S+)/)?.[1] ?? '?';
    test.info().annotations.push({ type: 'result', description: `${inp!.name} 목록 Status: ${cells.status}, Node Group: ${cells.nodegroup} / 상세 Status: ${st}, NodeGroup ${inp!.ng} ${info.includes(inp!.ng) ? '표시' : '없음'}` });
    // GKE 는 생성 중 목록·상세 Status 가 'N/A'(2026-10-15, 결함 후보) → Active/Failed 로 바뀔 때까지 최대 25분 재조회해 생성 결과로 판정
    // 2026-10-16: 재조회한 상세(info)로 판정(이전엔 첫 N/A 상세로 NodeGroup 을 검사해 오판 — Azure k2e). 상세 NodeGroup List 는 클러스터 생성 중엔 비어 있을 수 있어
    //   NodeGroup 이 보이거나 Active/Failed 가 될 때까지 최대 30분 재조회
    let final = st;
    let last = info;
    const end = Date.now() + 30 * 60_000;
    while (Date.now() < end && (/^N\/A$|^\?$/.test(final) || (!last.includes(inp!.ng) && !/active|fail/i.test(final)))) {
      await page.waitForTimeout(60_000); await gotoK8s(page, prj);
      last = await openCluster(page, inp!.name); final = last.match(/Status (\S+)/)?.[1] ?? '?';
    }
    if (final !== st || last !== info) test.info().annotations.push({ type: 'recheck', description: `첫 상세 Status ${st} → 재조회 Status ${final}, NodeGroup ${inp!.ng} ${last.includes(inp!.ng) ? '표시' : '없음'}: ${last.slice(0, 400)}` });
    expect(final, '상세 Status Creating 계열(또는 생성 완료 Active)').toMatch(/creat|provision|pending|progress|active/i);
    expect(last, `NodeGroup ${inp!.ng} 표시`).toContain(inp!.ng);
  });
}

IV3_S19

tests/iv3/IV3_S19.spec.ts · 63줄 · 해시 dcbc63f5 · 시나리오 보기

// IV3_S19: K8s 직접 생성 Expert 모드: VPC/Subnet/Security Group 선택 생성 요청 → 목록에서 Creating 확인 (K8s Workloads) [정상, CSP별]
// 화면: K8s Workloads(p{n}) > Add cluster > Expert Creation(Cluster Name, Description, Provider/Region, Connection, VPC, Subnet, Security Group, Version) → Deploy → 목록
// AWS(2026-10-05): 지시서의 v1/s1-1/sg1 은 Project default 자원이라 p1 Expert 폼에 표시되지 않음(VPC 선택지는 p1-shared-aws-ap-northeast-2 뿐)
//   → 사용자 결정으로 p1 에 vNet v1k(10.11.0.0/16, subnet s1k-1 10.11.1.0/24)·SG sg1k(TCP 22/80) 를 만들어 사용
// 절차(AWS): 1.K8s Workloads(p1) → Add cluster → Expert Creation 2.Cluster Name=k1f, Provider=AWS, Region/Connection=aws-ap-northeast-2 3.VPC=v1k(최대 12초 대기, 없으면 SKIP) → Subnet=s1k-1 → SG=sg1k
//   4.Deploy 5.목록에 k1f Creating, VPC/Subnet/SG 열이 선택값과 일치하는지 확인 후 종료
// Deploy 는 실제 과금 → 실행 전 사용자 확인(K8S_DEPLOY_OK=1 일 때만 Deploy)

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { addNodeGroupForm, clusterCells, deployCluster, fillExpertCluster, gotoK8s, openCluster, waitClusterStatus } from '../../lib/iv3-k8s';
import type { NodeGroupInput } from '../../lib/iv3-k8s';

const INPUT: Record<string, { name: string; provider: string; conn: string; vpc: string; subnet: string; sg: string; version?: string; ng?: NodeGroupInput } | undefined> = {
  // Expert 는 Version 필수 → k1d 와 같은 1.35
  // 2026-10-15: p1 의 v1k 가 없어 10-07 SKIP → p1 공용 vNet(p1-shared-aws-ap-northeast-2, subnet 2a·2b 다중 선택, SG …-sg-k8s) 사용
  AWS: { name: process.env.K8S_CLUSTER || 'k1f', provider: 'AWS', conn: 'aws-ap-northeast-2', vpc: 'p1-shared-aws-ap-northeast-2', subnet: 'p1-shared-aws-ap-northeast-2', sg: 'p1-shared-aws-ap-northeast-2-sg-k8s', version: '1.35' },
  // Azure(2026-10-06, 사용자 지시): p2 에 vNet v2k(10.12.0.0/16, subnet s2k-1 10.12.1.0/24)·SG sg2k(TCP 22/80) 생성 후 사용. Version 은 env K8S_VERSION(없으면 첫 옵션)
  Azure: { name: 'k2f', provider: 'AZURE', conn: 'azure-koreacentral', vpc: 'v2k', subnet: 's2k-1', sg: 'sg2k', version: process.env.K8S_VERSION,
    ng: { name: 'g2f', spec: 'Standard_B4ms', mem: 16, cpu: 4, size: 1 } }, // S18_Azure 와 같은 Spec(B2s 는 카탈로그에 없음)
  // GCP(2026-10-15): p4 공용 vNet(subnet 단일 선택), Version 1.35/1.34, NodeGroup 필수 → g4f e2-highmem-4(vCPU=4 검색)
  GCP: { name: process.env.K8S_CLUSTER || 'k4f', provider: 'GCP', conn: 'gcp-asia-northeast3', vpc: 'p4-shared-gcp-asia-northeast3', subnet: 'p4-shared-gcp-asia-northeast3', sg: 'p4-shared-gcp-asia-northeast3-sg-k8s', version: '1.35',
    ng: { name: 'g4f', spec: 'e2-highmem-4', mem: 0, cpu: 4, size: 1 } },
};

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S19", c)} ` + "K8s 직접 생성 Expert 모드 → Creating 확인", async ({ page }) => {
    const inp = INPUT[c.code];
    test.skip(!inp, `${c.name}: 입력값 미지정(AWS 완료 후 별도 지시)`);
    const dry = process.env.K8S_DRY === '1';
    test.skip(!dry && process.env.K8S_DEPLOY_OK !== '1', 'Deploy 전 사용자 확인 필요(K8S_DEPLOY_OK=1)');
    const prj = k8sProject(c);
    await loginAsAdmin(page);
    await gotoK8s(page, prj);
    const before = await clusterCells(page, inp!.name);
    test.skip(process.env.K8S_RECHECK !== '1' && Object.keys(before).length > 0, `${inp!.name} 이미 존재: ${JSON.stringify(before)}`);
    // K8S_RECHECK=1: 이미 요청한 클러스터(같은 이름)를 Deploy 없이 상태만 재확인(2026-10-15 GKE: 생성 중 Status N/A)
    if (!(process.env.K8S_RECHECK === '1' && Object.keys(before).length > 0)) {
          const form = await fillExpertCluster(page, { ...inp!, desc: 'IV3_S19 Expert' });
      test.skip(!form, `VPC 선택지에 ${inp!.vpc} 없음(12초 대기)`);
      // Azure: Expert 도 NodeGroup 필수(Deploy 시 'NodeGroup Required' 팝업, 2026-10-06) → 폼 하단 '+ nodeGroup' 으로 추가
      if (inp!.ng) await addNodeGroupForm(page, inp!.ng, { expert: true });
      if (dry) { test.info().annotations.push({ type: 'dry', description: 'K8S_DRY=1: Deploy 안 함' }); return; }
      const toasts = await deployCluster(page);
      expect(toasts.find((t) => /fail|error|invalid|warning/i.test(t)), 'Deploy 직후 오류/경고 토스트 없음').toBeUndefined();
    }
    // Azure: 생성 중 목록 Status 'N/A'(상세는 Creating, 2026-10-06) → 목록에 행이 생기면 상세 Status 로 판정
    const cells = await waitClusterStatus(page, prj, inp!.name, /creat|provision|pending|progress|active|fail|N\/A/i, 8 * 60_000);
    const info = await openCluster(page, inp!.name);
    const st = info.match(/Status (\S+)/)?.[1] ?? '?';
    test.info().annotations.push({ type: 'result', description: `${inp!.name}: ${JSON.stringify(cells)} / 상세 Status: ${st} / ${info.slice(0, 600)}` });
    // GKE 는 생성 중 목록·상세 Status 가 'N/A'(2026-10-15, 결함 후보) → Active/Failed 로 바뀔 때까지 최대 25분 재조회해 생성 결과로 판정
    let final = st;
    if (/^N\/A$|^\?$/.test(st)) {
      const end = Date.now() + 25 * 60_000;
      while (Date.now() < end && /^N\/A$|^\?$/.test(final)) { await page.waitForTimeout(60_000); await gotoK8s(page, prj); final = (await openCluster(page, inp!.name)).match(/Status (\S+)/)?.[1] ?? '?'; }
      test.info().annotations.push({ type: 'status-na', description: `생성 중 Status N/A 표시 → 재조회 결과 ${final}` });
    }
    expect(final, '상세 Status Creating 계열(또는 생성 완료 Active)').toMatch(/creat|provision|pending|progress|active/i);
  });
}

IV3_S20

tests/iv3/IV3_S20.spec.ts · 65줄 · 해시 b0c34936 · 시나리오 보기

// IV3_S20: K8s NodeGroup 추가 요청 → Creating 확인 (타입 A, Cluster Active 상태에서 시작) [정상, CSP별]
// 화면: K8s Workloads(p{n}) → 클러스터 행 선택 → 상세 'Add NodeGroup' → Add node(#addnode, 클러스터 정보 고정) → '+ NodeGroup' → NodeGroup Configuration → Done → Deploy
// 절차(AWS): 1.목록에서 k1d Active 확인 2.k1d 선택 → Add NodeGroup → + NodeGroup: Node Name g1d, Spec c6g.2xlarge(Spec Recommendation 메모리 16 / vCPU 8 검색, 사용자 지시 2026-10-05 — t3.medium 대신),
//   Image 검색 팝업에서 AL2023_ARM_64_STANDARD(OS Architecture arm64 자동), SSH Key(기본 선택지), Desired Node Count 1 → Done 3.Deploy 4.상세 NodeGroup List / 목록에서 g1d Creating 확인 후 종료(Ready 대기는 녹화하지 않음)
// EKS 안내: 'EKS node groups are always backed by an Auto Scaling group…' — autoscaling 끌 수 없고 min/max = desired

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { addNodeGroupForm, clusterCells, deployNodeGroup, gotoK8s, openCluster, selectNodeGroup } from '../../lib/iv3-k8s';

// AWS: c6g.2xlarge(arm64) 는 'Use Default' 이미지(AL2023_x86_64_STANDARD)와 맞지 않아 실패(1차) → 이미지 검색에서 ARM 이미지 선택(사용자 지시 2026-10-05)
const INPUT: Record<string, { cluster: string; ng: { name: string; spec: string; mem: number; cpu: number; size: number; image?: string } } | undefined> = {
  // K8S_CLUSTER / K8S_NG 로 대상 변경 가능(2026-10-06 localhost:3009 Expert 클러스터 k1f / g1f)
  AWS: { cluster: process.env.K8S_CLUSTER ?? 'k1d', ng: { name: process.env.K8S_NG ?? 'g1d', spec: 'c6g.2xlarge', mem: 16, cpu: 8, size: 1, image: 'AL2023_ARM_64_STANDARD' } },
  // GCP: K8s CSP 사이클 k4s(p4). stage 카탈로그에 e2-standard-2/4 없음 → vCPU=4 최저가 e2-highmem-4, 메모리 조건 없이 검색(2026-10-15)
  GCP: { cluster: process.env.K8S_CLUSTER ?? 'k4s', ng: { name: process.env.K8S_NG ?? 'g4t', spec: 'e2-highmem-4', mem: 0, cpu: 4, size: 1 } },
};

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S20", c)} ` + "K8s NodeGroup 추가 요청 → Creating 확인", async ({ page }) => {
    const inp = INPUT[c.code];
    test.skip(!inp, `${c.name}: 입력값 미지정`);
    test.skip(process.env.K8S_DEPLOY_OK !== '1', 'Deploy 전 사용자 확인 필요(K8S_DEPLOY_OK=1)');
    const prj = k8sProject(c);
    await loginAsAdmin(page);
    await gotoK8s(page, prj);
    const cells = await clusterCells(page, inp!.cluster);
    test.info().annotations.push({ type: 'cluster', description: JSON.stringify(cells) });
    expect(cells.status, `${inp!.cluster} Active`).toMatch(/active/i);
    const before = await openCluster(page, inp!.cluster);
    test.info().annotations.push({ type: 'detail-before', description: before.slice(0, 600) });
    test.skip(new RegExp(`\\b${inp!.ng.name}\\b`).test(before.slice(before.indexOf('NodeGroup List'))), `${inp!.ng.name} 이미 존재`);

    await addNodeGroupForm(page, inp!.ng);
    const toasts = await deployNodeGroup(page);
    expect(toasts.find((t) => /fail|error|invalid/i.test(t)), 'Deploy 직후 오류 토스트 없음').toBeUndefined();

    // 상세 NodeGroup List 에서 g1d 상태(Creating) 확인.
    // v07(2026-10-07) 보완: (1) 폴링 안에서 예외(클러스터 행 지연 등)가 나도 계속 재시도 (2) AWS NodeGroup 은 몇 분 안에 Active 가 되어
    // 20초 폴링 사이에 Creating 이 지나갈 수 있음 → 요청 접수 토스트(queued / in progress)가 있었으면 Active 로 바뀐 것도 '요청 처리됨'으로 인정하고 annotation 에 남긴다.
    const accepted = toasts.some((t) => /queued|in progress/i.test(t));
    // 2026-10-15 보완: 상세 NodeGroup List 카드는 이름만 표시(상태 문구 없음) — 이전 PASS 는 토스트 'K8s NG create: …' 의 create 가 우연히 걸린 것.
    // → NodeGroup 카드를 열어 NodeGroup Info 의 Status 로 판정(최대 15분). AWS 는 spider 가 Active 까지 기다린 뒤 응답해 Creating 이 안 보일 수 있음
    let detail = '';
    let ngStatus = '';
    await expect.poll(async () => {
      try {
        await gotoK8s(page, prj);
        const st = await selectNodeGroup(page, inp!.cluster, inp!.ng.name);
        detail = st.info;
        ngStatus = st.info.match(/Status (\S+)/)?.[1] ?? '';
      } catch (e) {
        return `재시도: ${String(e).split('\n')[0].slice(0, 80)}`;
      }
      if (/creat|pending|provision|progress|updat/i.test(ngStatus)) return 'creating';
      if (accepted && /active/i.test(ngStatus)) return 'active-after-accepted';
      return `status=${ngStatus || '(없음)'}`;
    }, { message: `${inp!.ng.name} Creating`, timeout: 15 * 60_000, intervals: [15_000] }).toMatch(/^(creating|active-after-accepted)$/);
    test.info().annotations.push({ type: 'nodegroup-status', description: `${inp!.ng.name}: ${ngStatus}${accepted ? ' (요청 접수 토스트 확인)' : ''}` });
    test.info().annotations.push({ type: 'detail-after', description: detail.slice(0, 600) });
    test.info().annotations.push({ type: 'list-after', description: JSON.stringify(await clusterCells(page, inp!.cluster)) });
  });
}

IV3_S21

tests/iv3/IV3_S21.spec.ts · 55줄 · 해시 395ed504 · 시나리오 보기

// IV3_S21: K8s 목록에서 Cluster 선택 → NodeGroup 선택 → node 확인 [정상, CSP별]
// 화면: K8s Workloads(p{n}) → 클러스터 행 선택 → Cluster Info + NodeGroup List / Status → NodeGroup 카드(#nodeGroup_status_icon_{ng}) 클릭
//   → NodeGroup Info(Name, Id, Status, Image, Spec, Key Pair, Desired/Min/Max Node Size, Auto Scaling, Root Disk) + Node List / Status(node 카드 #node_status_icon_{instance-id}) → node 선택 → 상세
// 절차(AWS): 1.목록에서 k1d 선택(Cluster Info Status Active) 2.NodeGroup g1d 선택 → NodeGroup Info Status Active(NodeGroup 상태값은 Running 이 아니라 Active — 사용자 확인 2026-10-05), Spec c6g.2xlarge
//   3.Node List 의 node(1개) 선택 → 표시되는 상태·정보 기록
// 기대결과: g1d(Active) 의 node 1개가 Node List 에 표시되고 node 상세 정보 확인

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { gotoK8s, selectNodeGroup } from '../../lib/iv3-k8s';

const INPUT: Record<string, { cluster: string; ng: string; spec: string } | undefined> = {
  AWS: { cluster: process.env.K8S_CLUSTER ?? 'k1d', ng: process.env.K8S_NG ?? 'g1d', spec: 'c6g.2xlarge' }, // K8S_CLUSTER/K8S_NG: K8s CSP 사이클(k1s/g1s, 2026-10-15)
  Azure: { cluster: 'k2e', ng: 'g2e', spec: 'Standard_B4ms' }, // IV3_S18_Azure 로 생성(타입 B, p2)
  GCP: { cluster: process.env.K8S_CLUSTER ?? 'k4s', ng: process.env.K8S_NG ?? 'g4s', spec: 'e2-highmem-4' }, // K8s CSP 사이클(타입 B, p4, 2026-10-15)
};

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S21", c)} ` + "K8s Cluster 선택 → NodeGroup 선택 → node 확인", async ({ page }) => {
    const inp = INPUT[c.code];
    test.skip(!inp, `${c.name}: 입력값 미지정`);
    const prj = k8sProject(c);
    await loginAsAdmin(page);
    await gotoK8s(page, prj);
    // Cluster → NodeGroup 선택
    const ng = await selectNodeGroup(page, inp!.cluster, inp!.ng);
    const body0 = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
    const ci = body0.slice(body0.indexOf('Cluster Info'), body0.indexOf('Cluster Info') + 500);
    test.info().annotations.push({ type: 'cluster-info', description: ci });
    expect(ci, 'Cluster Status Active').toMatch(/Status Active/i);
    test.info().annotations.push({ type: 'nodegroup-info', description: ng.info.slice(0, 600) });
    expect(ng.info, 'NodeGroup Status Active').toMatch(/Status Active/i);
    expect(ng.info, `Spec ${inp!.spec}`).toContain(inp!.spec);
    expect(ng.nodes.length, 'Node List 에 node 1개 이상').toBeGreaterThan(0);

    // node 선택 → 상세
    const nodeId = ng.nodes[0];
    // Azure node id 는 '/subscriptions/…/virtualMachines/0' 형식 → #id 대신 속성 선택자
    const card = page.locator(`li[id="node_status_icon_${nodeId}"]`);
    test.info().annotations.push({ type: 'node-card', description: `${nodeId} class=${await card.getAttribute('class')}` });
    await card.click();
    await page.waitForTimeout(5000);
    const b = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
    // 카드 표시 이름(AWS 는 id 그대로, Azure 는 다를 수 있음)으로 상세 위치를 찾는다
    const key = (await card.innerText()).trim() || nodeId;
    const nl = b.indexOf('Node List');
    const i = b.indexOf(key, nl) >= 0 ? b.indexOf(key, nl) : nl;
    const nodeInfo = b.slice(i, i + 1500);
    test.info().annotations.push({ type: 'node-info', description: `card='${key}' ${nodeInfo}` });
    expect(nodeInfo, 'node 상세 표시').toContain(key);
    await page.screenshot({ path: `results/${tid('IV3_S21', c)}-node.png`, fullPage: true });
  });
}

IV3_S22

tests/iv3/IV3_S22.spec.ts · 49줄 · 해시 98db18d2 · 시나리오 보기

// IV3_S22: K8s Expert 생성 — Subnet 1개(단일 AZ) 선택 시 오류 (예상실패) [예상실패, CSP별]
// 화면: Workloads > K8s Workloads(p1) > Add cluster > Expert Creation(Cluster Name, Provider/Region, Connection, VPC, Subnet(단일 선택), Security Group, Version(필수)) → Deploy → 토스트
// 시작 상태: admin 로그인, ws01 / p1. p1 공용 vNet p1-shared-aws-ap-northeast-2(subnet 2a·2b)·SG …-sg-k8s 존재(2026-10-15: 지시서의 v1k 는 p1 에 없음)
// 절차 (AWS): 1.K8s Workloads(p1) → Add cluster → Expert Creation 2.Cluster Name=k1g, Provider=AWS, Region/Connection=aws-ap-northeast-2, VPC=p1-shared-aws-ap-northeast-2,
//   Subnet=p1-shared-aws-ap-northeast-2(2a 하나만), SG=p1-shared-aws-ap-northeast-2-sg-k8s, Version=1.35 3.Deploy 4.오류 안내(화면 검증 또는 생성 거부)와 목록에 k1g 가 Active 로 생기지 않음을 확인
// 기대결과: AWS EKS 는 서로 다른 AZ 의 Subnet 2개 이상이 필요하므로 생성 거부('Select at least 2 subnets in different availability zones' 또는
//   'InvalidParameterException: Subnets specified must be in at least two different AZs'), 클러스터 미생성
// 비고: 2026-10-15 구현(stub 대체). 화면이 Deploy 시 'Subnet Selection' 모달로 사전 차단(Confirm 을 눌러도 생성 요청 안 감) → 과금 없음. 잘못 생성되면(결함) 사이클 유틸로 삭제
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { clusterCells, deployCluster, fillExpertCluster, gotoK8s } from '../../lib/iv3-k8s';

const INPUT: Record<string, { name: string; provider: string; conn: string; vpc: string; subnet: string; sg: string; version: string } | undefined> = {
  AWS: { name: process.env.K8S_CLUSTER || 'k1g', provider: 'AWS', conn: 'aws-ap-northeast-2', vpc: 'p1-shared-aws-ap-northeast-2', subnet: 'p1-shared-aws-ap-northeast-2', sg: 'p1-shared-aws-ap-northeast-2-sg-k8s', version: '1.35' },
};

for (const c of activeCsps(['AWS'])) { // 엑셀에 있는 CSP: AWS
  test(`${tid('IV3_S22', c)} K8s Expert 생성 — Subnet 1개(단일 AZ) 선택 시 오류 (예상실패)`, async ({ page }) => {
    test.setTimeout(15 * 60_000);
    const inp = INPUT[c.code]!;
    const prj = k8sProject(c);
    const dialogs: string[] = [];
    page.on('dialog', (d) => { dialogs.push(d.message()); });
    await loginAsAdmin(page);
    await gotoK8s(page, prj);
    expect(Object.keys(await clusterCells(page, inp.name)).length, `${inp.name} 미존재(시작 상태)`).toBe(0);
    const form = await fillExpertCluster(page, { ...inp, desc: 'IV3_S22 single AZ' });
    expect(form, `VPC ${inp.vpc} 선택 가능`).toBeTruthy();
    // 다중 선택 Subnet 을 2a 하나만 남긴다
    await page.locator('#cluster_subnet').selectOption([inp.subnet]);
    const picked = await page.locator('#cluster_subnet').evaluate((s) => [...(s as HTMLSelectElement).selectedOptions].map((o) => o.value));
    test.info().annotations.push({ type: 'subnet-selected', description: picked.join(', ') });
    expect(picked).toHaveLength(1);
    const toasts = await deployCluster(page);
    await page.waitForTimeout(5000);
    // 화면 사전 차단은 확인 모달(deployCluster 가 'deploy-modal' 로 기록, 2026-10-15: 'Subnet Selection — AWS requires subnets in at least 2 different availability zones')
    const modalText = test.info().annotations.filter((a) => a.type === 'deploy-modal').map((a) => a.description ?? '');
    const msgs = [...modalText, ...toasts, ...dialogs, ...(await page.locator('.invalid-feedback:visible, .text-danger:visible, [role="alert"]').allInnerTexts())].map((t) => t.replace(/\s+/g, ' ').trim()).filter(Boolean);
    test.info().annotations.push({ type: 'messages', description: msgs.join(' | ').slice(0, 1200) || '(없음)' });
    // 2분 동안 목록에 Active/Creating 으로 생기지 않는지 확인(생겼다면 결함 — 상태 기록)
    let cells: Record<string, string> = {};
    for (let i = 0; i < 4; i++) { await page.waitForTimeout(30_000); await gotoK8s(page, prj); cells = await clusterCells(page, inp.name); if (cells.status) break; }
    test.info().annotations.push({ type: 'list-after', description: JSON.stringify(cells) || '(행 없음)' });
    expect(msgs.join(' '), '단일 AZ 오류 안내').toMatch(/two different|availability zone|at least 2|AZs|subnet/i);
    expect(cells.status ?? '', '클러스터 미생성(또는 Failed)').not.toMatch(/active|creat|provision|pending/i);
  });
}

IV3_S23

tests/iv3/IV3_S23.spec.ts · 26줄 · 해시 a78a0309 · 시나리오 보기

// IV3_S23: Infra Node Recommendation 확인 (Provider 별) [정상, CSP별]
// 화면: Workloads > Infra Workloads > Add Infra > + NodeGroup > Spec 검색 → Node Recommendation 팝업
// 절차: 1.Add Infra → + NodeGroup 2.Spec 검색 → Node Recommendation 3.Priority Option(location)=Seoul, Cloud Provider={CSP} 체크
//   4.Spec Config(Memory/vCPU) → 검색 5.추천 목록이 해당 Provider 의 spec 만 표시하는지 확인(입력 spec 이 있으면 그 행도 확인) 6.Cancel(생성하지 않음)
// 기대결과: 선택한 Provider 의 spec 만 추천 목록에 표시. Infra 를 생성하지 않는다.
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { INFRA_INPUT } from '../../lib/infra';
import { openNodeForm, searchSpec, specTargetRow } from '../../lib/reco';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S23", c)} ` + "Infra Node Recommendation 확인 (Provider 별)", async ({ page }) => {
    await loginAsAdmin(page);
    await openNodeForm(page);
    const { sm, count, conns, pv } = await searchSpec(page, c);
    expect(count, `${c.name} 추천 결과 행 수`).toBeGreaterThan(0);
    // 선택한 Provider 의 연결(예: aws-ap-northeast-2)만 보여야 한다
    const other = conns.filter((s) => !s.toLowerCase().startsWith(`${pv}-`));
    expect(other, `다른 Provider 의 spec 이 섞이면 안 됨: ${other.join(', ')}`).toEqual([]);
    const inp = INFRA_INPUT[c.code];
    if (inp) await expect(specTargetRow(page, sm, c), `${inp.spec}(${inp.region}) 행`).toBeVisible();
    await sm.getByRole('button', { name: 'Cancel' }).click(); // 저장/생성 없이 종료
  });
}

IV3_S24

tests/iv3/IV3_S24.spec.ts · 38줄 · 해시 05eff554 · 시나리오 보기

// IV3_S24: Infra Image Recommendation 확인 (Spec 조회 선행) [정상, CSP별]
// 화면: Workloads > Infra Workloads > Add Infra > + NodeGroup > Spec 검색(Node Recommendation) → Apply → Image 검색 → Image Recommendation 팝업
// 절차: 1.(선행) Node Recommendation 에서 Provider 로 Spec 조회 → 행 선택 → Apply 2.Image 검색 → Image Recommendation(Provider/Region 은 Spec 에서 자동 입력)
//   3.OS Type='ubuntu 22.04' 입력 → 검색 4.Available 이미지 표시 확인 5.이미지 선택 → Apply 로 Image 입력 확인 6.저장 없이 종료
// 기대결과: Spec 선택 후 해당 Provider/Region 의 Image 추천 목록이 표시(Available), Apply 시 Image 입력. Infra 를 생성하지 않는다.
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { INFRA_INPUT } from '../../lib/infra';
import { openNodeForm, searchSpec, searchImage, specTargetRow } from '../../lib/reco';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S24", c)} ` + "Infra Image Recommendation 확인 (Spec 조회 선행)", async ({ page }) => {
    await loginAsAdmin(page);
    await openNodeForm(page);
    // 1. 선행: Spec 조회 → 선택 → Apply
    const { sm, count } = await searchSpec(page, c);
    expect(count, `${c.name} Spec 추천 결과 행 수(선행 조건)`).toBeGreaterThan(0);
    await specTargetRow(page, sm, c).click();
    await sm.getByRole('button', { name: 'Apply' }).click();
    const spec = await page.locator('#ep_specId').inputValue();
    expect(spec, 'Spec 선택(Apply) 후 Spec 입력란').not.toBe('');
    const conn = await page.locator('#ep_connectionName').inputValue();
    test.info().annotations.push({ type: 'spec-applied', description: `spec=${spec}, connection=${conn}` });
    const inp = INFRA_INPUT[c.code];
    if (inp) expect(conn, 'Spec 선택으로 Connection 자동 지정').toBe(c.infra.conn);

    // 2~4. Image Recommendation
    const { im, avail } = await searchImage(page, inp?.os ?? 'ubuntu 22.04');
    expect(await avail.count(), `${c.name} Available 이미지 수`).toBeGreaterThan(0);
    // 5. 이미지 선택 → Apply
    await avail.first().click();
    await im.getByRole('button', { name: 'Apply' }).click();
    expect(await page.locator('#ep_imageId').inputValue(), 'Image 선택(Apply) 후 Image 입력란').not.toBe('');
    // 6. 저장 없이 종료(Done/Deploy 클릭 안 함)
  });
}

IV3_S25

tests/iv3/IV3_S25.spec.ts · 40줄 · 해시 1e0b4b83 · 시나리오 보기

// IV3_S25: Data Disk attach — Create New (node 와 같은 AZ 에 새 disk 를 만들어 attach) [정상, CSP별]
// 배경: Existing Disk 는 disk 의 AZ 가 node 와 다르면 attach 가 거부된다(IV3_S15, AWS 2a/2d). 같은 AZ 에 없으면 Create New 로 만든다(사용자 지시 2026-10-07).
// 화면: Infra Workloads > Infra 선택 > Node List 에서 node 선택 > Node Info > Block Device '+ Attach Disk' > Create New
// 절차(AWS): 1.mc1 선택 → Running node 선택 → Node Info(Zone 기록) 2.+ Attach Disk → Create New: Disk Name=disk1e, Size 10GB → Attach
//   3.Block Device 에 disk1e, Disks 목록 Attached / Attached To 'mc1 / {node}' 확인
// 기대결과: disk1e 가 node 와 같은 AZ 에 생성되어 attach 됨
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { DISK_TARGET, attachNewDisk, blockDevice, diskCells, diskTargetBlocker, nodeField, openInfraDetail, openNodeInfo, pickRunningNode, waitDiskState } from '../../lib/infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV3_S25", c)} ` + "Data Disk attach — Create New (node 와 같은 AZ)", async ({ page }) => {
    const why = diskTargetBlocker(c);
    test.skip(!!why, why);
    const tg = DISK_TARGET[c.code]!;
    test.skip(!tg.diskNew, `${c.name}: Create New 대상 disk 이름 미지정`);
    const disk = tg.diskNew!;
    await loginAsAdmin(page);
    const before = await diskCells(page, disk);
    const node = await pickRunningNode(page, tg.infra, tg.node);
    const ng = node.replace(/-\d+$/, '');
    let info = await openNodeInfo(page, ng, node);
    test.info().annotations.push({ type: 'node-zone', description: `${node}: Zone ${nodeField(info, 'Zone')}` });
    if (/attached/i.test(before.status ?? '')) {
      test.info().annotations.push({ type: 'preexisting', description: `${disk} 이미 attach: ${before._attachedTo}` });
    } else {
      const target = await attachNewDisk(page, disk, tg.size, tg.diskType);
      expect(target, '대상 노드 표시').toContain(node);
    }
    await waitDiskState(page, disk, 'attached');
    const after = await diskCells(page, disk);
    test.info().annotations.push({ type: 'disk-after', description: JSON.stringify(after) });
    await openInfraDetail(page, tg.infra);
    info = await openNodeInfo(page, ng, node);
    test.info().annotations.push({ type: 'block', description: blockDevice(info) });
    expect(blockDevice(info), 'Block Device 에 disk 표시').toContain(disk);
  });
}

IV3_S26

tests/iv3/IV3_S26.spec.ts · 28줄 · 해시 534d4261 · 시나리오 보기

// IV3_S26: Project 삭제 거부: Workspace 에 할당된 Project 는 삭제할 수 없다 (예상실패) [공통]  (사용자 지시 2026-10-08)
// 화면: Workspaces > Projects → 목록 행 선택 → 상세 Delete → 안내 팝업
// 절차: 1.Projects 진입 2.임시 ptmp(ws01 할당, 없으면 생성) 행 선택 3.상세 Delete 4.안내 팝업 확인 5.Close 후 ptmp 가 그대로 남아 있는지 확인
// 기대결과: 삭제가 거부되고 안내 팝업(Cannot Delete Project / assigned to: ws01 / Unassign it from all workspaces first)이 표시되며 ptmp 는 남는다
import { test, expect } from '../../lib/fixtures';
import { openProjects, ensureTempProject, selectProject, projectRow, rowWorkspaceCount, protectedStillThere, TEMP_PROJECT } from '../../lib/projects';

test('IV3_S26 Project 삭제 거부: Workspace 에 할당된 Project 는 삭제할 수 없다 (예상실패)', async ({ page }) => {
  await openProjects(page);
  await ensureTempProject(page);
  const ws = await rowWorkspaceCount(page, TEMP_PROJECT);
  test.info().annotations.push({ type: 'before', description: `${TEMP_PROJECT}: Workspaces ${ws}` });
  expect(ws, '시작 상태: ptmp 가 Workspace 에 할당됨').toBeGreaterThanOrEqual(1);
  await selectProject(page, TEMP_PROJECT);
  await page.getByRole('button', { name: 'Delete', exact: true }).click();
  const m = page.locator('.modal.show').last();
  await expect(m).toBeVisible();
  const msg = (await m.innerText()).replace(/\s+/g, ' ').trim();
  test.info().annotations.push({ type: 'delete-popup', description: msg });
  expect(msg, '삭제 거부 안내').toMatch(/Cannot Delete Project/i);
  expect(msg, '할당된 Workspace 표시').toMatch(/ws01/);
  expect(msg, '해제 안내').toMatch(/Unassign/i);
  await page.waitForTimeout(2500); // 녹화에 팝업이 보이도록
  await m.locator('.modal-footer').getByRole('button', { name: 'Close', exact: true }).click(); // 헤더 X(Close)와 구분해 푸터의 Close 버튼
  await expect(projectRow(page, TEMP_PROJECT), '삭제되지 않고 남음').toHaveCount(1);
  await protectedStillThere(page);
});

IV3_S27

tests/iv3/IV3_S27.spec.ts · 42줄 · 해시 c785f6cf · 시나리오 보기

// IV3_S27: Project 할당 해제: Workspace 에서 Project 를 Unassign [공통]  (사용자 지시 2026-10-08)
// 화면: Workspaces > Projects → 목록 행 선택 → 상세 Assigned Workspaces 의 Unassign
// 절차: 1.Projects 진입 2.임시 ptmp 행 선택 3.상세 Assigned Workspaces 에서 ws01 Unassign(확인창이 있으면 확인) 4.상세에서 ws01 이 사라지고 목록 Workspaces 수가 0 인지 확인
//   5.상단 Workspace=ws01 의 Project 선택 목록에서 ptmp 가 사라졌는지 확인
// 기대결과: ptmp 가 ws01 에서 해제되어 Assigned Workspaces 가 비고 ws01 의 Project 선택 목록에서 사라진다(Project 자체는 목록에 남음)
import { test, expect } from '../../lib/fixtures';
import { WORKSPACE } from '../../lib/data';
import { selectWorkspaceProject } from '../../lib/ui';
import { openProjects, ensureTempProject, selectProject, projectRow, rowWorkspaceCount, protectedStillThere, PROJECTS_URL, TEMP_PROJECT } from '../../lib/projects';

test('IV3_S27 Project 할당 해제: Workspace 에서 Project 를 Unassign', async ({ page }) => {
  await openProjects(page);
  await ensureTempProject(page);
  await selectProject(page, TEMP_PROJECT);
  const detail0 = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
  test.info().annotations.push({ type: 'before', description: `Workspaces ${await rowWorkspaceCount(page, TEMP_PROJECT)} / ${detail0.slice(detail0.indexOf('Assigned Workspaces'), detail0.indexOf('Assigned Workspaces') + 100)}` });
  const unassign = page.getByRole('button', { name: 'Unassign', exact: true });
  expect(await unassign.count(), 'Unassign 버튼(할당된 Workspace)').toBeGreaterThan(0);
  for (let i = 0; i < 5 && (await unassign.count()); i++) {
    await unassign.first().click();
    const um = page.locator('.modal.show').last();
    if (await um.isVisible({ timeout: 3000 }).catch(() => false)) {
      test.info().annotations.push({ type: 'unassign-confirm', description: (await um.innerText()).replace(/\s+/g, ' ').slice(0, 200) });
      await um.getByRole('button', { name: /Confirm|Unassign|OK|Yes/i }).last().click();
    }
    await page.waitForTimeout(2500);
  }
  // 목록/상세 갱신 확인
  await page.goto(PROJECTS_URL);
  await page.getByRole('gridcell', { name: 'default', exact: true }).first().waitFor({ timeout: 60_000 });
  await expect(projectRow(page, TEMP_PROJECT), 'Project 자체는 목록에 남음').toHaveCount(1, { timeout: 30_000 });
  expect(await rowWorkspaceCount(page, TEMP_PROJECT), '목록 Workspaces 수').toBe(0);
  await selectProject(page, TEMP_PROJECT);
  const detail = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
  test.info().annotations.push({ type: 'after', description: detail.slice(detail.indexOf('Assigned Workspaces'), detail.indexOf('Assigned Workspaces') + 100) });
  expect(await page.getByText(new RegExp(`${WORKSPACE} \\(id: \\d+\\)`)).count(), `Assigned Workspaces 에서 ${WORKSPACE} 사라짐`).toBe(0);
  // 상단 Project 선택 목록
  await selectWorkspaceProject(page, WORKSPACE, null, true);
  await expect(page.locator('#select-current-project option', { hasText: new RegExp(`^${TEMP_PROJECT}$`) }), `상단 Project 목록에서 ${TEMP_PROJECT} 사라짐`).toHaveCount(0, { timeout: 30_000 });
  await protectedStillThere(page);
});

IV3_S28

tests/iv3/IV3_S28.spec.ts · 29줄 · 해시 b83116eb · 시나리오 보기

// IV3_S28: Project 삭제: 할당 해제된 Project 삭제 [공통]  (사용자 지시 2026-10-08)
// 화면: Workspaces > Projects → 목록 행 선택 → 상세 Delete → 확인 팝업
// 전제: 임시 ptmp 가 어떤 Workspace 에도 할당되지 않은 상태(IV3_S27 완료)
// 절차: 1.Projects 진입 2.ptmp 행 선택 3.상세 Delete → 확인 팝업(대상 ptmp) Confirm 4.목록에서 ptmp 가 사라지고 default / p1 / system 은 그대로인지 확인
// 기대결과: 삭제가 성공하고 ptmp 만 목록에서 제거된다
import { test, expect } from '../../lib/fixtures';
import { openProjects, selectProject, projectRow, rowWorkspaceCount, protectedStillThere, PROJECTS_URL, TEMP_PROJECT } from '../../lib/projects';

test('IV3_S28 Project 삭제: 할당 해제된 Project 삭제', async ({ page }) => {
  await openProjects(page);
  test.skip((await projectRow(page, TEMP_PROJECT).count()) === 0, `선행 IV3_S26/S27: 임시 ${TEMP_PROJECT} 없음`);
  const ws = await rowWorkspaceCount(page, TEMP_PROJECT);
  test.skip(ws > 0, `BLOCKED: ${TEMP_PROJECT} 가 아직 Workspace ${ws}개에 할당됨(IV3_S27 먼저)`);
  await selectProject(page, TEMP_PROJECT);
  await page.getByRole('button', { name: 'Delete', exact: true }).click();
  const m = page.locator('.modal.show').last();
  await expect(m).toBeVisible();
  const msg = (await m.innerText()).replace(/\s+/g, ' ').trim();
  test.info().annotations.push({ type: 'confirm', description: msg });
  expect(msg, '삭제 거부 팝업이 아님').not.toMatch(/Cannot Delete Project/i);
  expect(msg, `확인 대상이 ${TEMP_PROJECT}`).toContain(TEMP_PROJECT);
  await m.getByRole('button', { name: /Confirm|Delete|OK|Yes/i }).last().click();
  await page.waitForTimeout(4000);
  await page.goto(PROJECTS_URL);
  await page.getByRole('gridcell', { name: 'default', exact: true }).first().waitFor({ timeout: 60_000 });
  await expect(projectRow(page, TEMP_PROJECT), `${TEMP_PROJECT} 삭제됨`).toHaveCount(0, { timeout: 30_000 });
  await protectedStillThere(page);
});

IV3_S29

tests/iv3/IV3_S29.spec.ts · 75줄 · 해시 f51fe929 · 시나리오 보기

// IV3_S29: NLB Edit: NLB 대상 node 할당 해제 / 재할당 [정상, AWS]  (사용자 지시 2026-10-08)
// 화면: Cloud Resources > NLB (/cloudresources/nlbs) → 행 체크 → 상단 'Edit selected (assign / unassign nodes)' → Edit NLB 모달(Assigned Nodes 체크=해제 / Available Nodes 체크=할당, 저장 #nlb-edit-execute-btn)
// 절차: 1.NLB 진입 2.NLB g1(mc1) 행 선택 → Edit 3.Assigned Nodes 의 g1-2 체크(해제) → Save 4.상세 Assigned Nodes 가 g1-1 만 남았는지 확인 5.다시 Edit → Available Nodes 의 g1-2 체크(할당) → Save 6.상세가 g1-1, g1-2 로 돌아왔는지 확인
// 기대결과: node 할당 해제와 재할당이 반영되어 Assigned Nodes 가 변경된다
// 주의: 마지막 node 는 해제하지 않는다(일부 CSP 는 전부 해제를 거부). 다른 CSP(GCP) node 를 AWS NLB 에 할당하지 않는다. 시작 시 g1-2 가 이미 해제 상태면 재할당부터 시작한다.
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups } from '../../lib/data';
import { loginAsAdmin, selectWorkspaceProject, toasts } from '../../lib/ui';

const NLB_URL = '/webconsole/settings/environment/cloudresources/nlbs';
const EDIT = '[title="Edit selected (assign / unassign nodes)"], [data-bs-original-title="Edit selected (assign / unassign nodes)"]';

for (const c of activeCsps(['AWS'])) {
  test(`${tid('IV3_S29', c)} NLB Edit: NLB 대상 node 할당 해제 / 재할당`, async ({ page }) => {
    test.setTimeout(15 * 60_000);
    const infra = infraName(c);
    const ng = infraNodegroups(c)[0];
    const keep = `${ng}-1`, toggle = `${ng}-2`;
    const row = () => page.locator('.tabulator-row').filter({ hasText: infra }).filter({ hasText: ng }).first();
    const open = async () => {
      await page.goto(NLB_URL);
      await selectWorkspaceProject(page, 'ws01', 'default', true);
      await page.getByText(/Showing \d+(-\d+)? (of \d+ )?rows|No NLBs/i).first().waitFor({ timeout: 30_000 }).catch(() => {});
      await page.waitForTimeout(1500);
    };
    const assigned = async () => {
      await open();
      await row().locator('[tabulator-field="id"], [tabulator-field="name"]').first().click().catch(() => row().click());
      await page.waitForTimeout(2000);
      const b = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
      const i = b.indexOf('Assigned Nodes', b.indexOf('NLB Detail'));
      return b.slice(i, i + 60);
    };
    const edit = async (which: 'assigned' | 'available') => {
      await open();
      await row().getByRole('checkbox').first().check();
      await page.locator(EDIT).first().click();
      const m = page.locator('.modal.show').last();
      await expect(m).toBeVisible();
      const before = (await m.innerText()).replace(/\s+/g, ' ').slice(0, 260);
      test.info().annotations.push({ type: `edit-modal-${which}`, description: before });
      const box = m.locator(`input[type=checkbox][value="${toggle}"]`).first();
      await expect(box, `${which} 목록에 ${toggle}`).toBeVisible({ timeout: 10_000 });
      await box.check();
      await m.locator('#nlb-edit-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: `edit-result-${which}`, description: t.join(' | ').slice(0, 250) || '(토스트 없음)' });
    };

    await loginAsAdmin(page);
    let cur = await assigned();
    test.info().annotations.push({ type: 'before', description: cur });
    expect(cur, `시작 상태에 ${keep} 가 할당됨`).toContain(keep);
    // 1) 해제 (g1-2 가 할당돼 있으면)
    if (cur.includes(toggle)) {
      await edit('assigned');
      await expect.poll(async () => await assigned(), { timeout: 120_000, intervals: [10_000], message: `${toggle} 해제 반영` }).not.toContain(toggle);
      cur = await assigned();
      test.info().annotations.push({ type: 'after-unassign', description: cur });
      expect(cur, `${keep} 는 유지`).toContain(keep);
    } else {
      test.info().annotations.push({ type: 'preexisting', description: `${toggle} 이미 해제 상태 → 재할당부터` });
    }
    // 2) 재할당
    await edit('available');
    await expect.poll(async () => await assigned(), { timeout: 120_000, intervals: [10_000], message: `${toggle} 재할당 반영` }).toContain(toggle);
    cur = await assigned();
    test.info().annotations.push({ type: 'after-assign', description: cur });
    expect(cur).toContain(keep);
    expect(cur).toContain(toggle);
  });
}

IV3_S30

tests/iv3/IV3_S30.spec.ts · 54줄 · 해시 ea5e9d7f · 시나리오 보기

// IV3_S30: Disks 화면에서 Data Disk Detach (Data Disk Detail 의 Detach) [정상, AWS]  (사용자 지시 2026-10-08)
// 화면: Cloud Resources > Disks → 행 선택 → 하단 Data Disk Detail(Attached To) → Detach(#detail-detach-btn) → 확인
// 절차: 1.Disks 진입 2.Attached 상태 disk(disk1e) 행 선택 3.Attached To 확인 4.Detach → 확인 5.목록 Status Available, Attached To '-' 확인
// 기대결과: disk 가 node 에서 분리되어 Available 이 된다
// 선행: node 와 같은 AZ 에 attach 된 disk(disk1e). Available 이면 Node Info 의 + Attach Disk(Existing Disk)로 mc1 node 에 먼저 attach(전제 준비), disk1e 가 없으면 IV3_S25 로 만든다
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin, toasts } from '../../lib/ui';
import { DISK_TARGET, attachExistingDisk, attachNewDisk, diskCells, diskRow, diskTargetBlocker, gotoDisks, openNodeInfo, pickRunningNode, waitDiskState } from '../../lib/infra';

for (const c of activeCsps(['AWS'])) {
  test(`${tid('IV3_S30', c)} Disks 화면에서 Data Disk Detach (Data Disk Detail 의 Detach)`, async ({ page }) => {
    test.setTimeout(20 * 60_000);
    const why = diskTargetBlocker(c);
    test.skip(!!why, why);
    const tg = DISK_TARGET[c.code]!;
    const disk = tg.diskNew!;
    await loginAsAdmin(page);
    let cells = await diskCells(page, disk);
    // 전제 준비: attach 상태가 아니면 attach
    if (!/attached/i.test(cells.status ?? '')) {
      const node = await pickRunningNode(page, tg.infra, 'g1-1');
      const ng = node.replace(/-\d+$/, '');
      await openNodeInfo(page, ng, node);
      if (cells.name) await attachExistingDisk(page, disk); else await attachNewDisk(page, disk, tg.size);
      await waitDiskState(page, disk, 'attached');
      test.info().annotations.push({ type: 'precondition', description: `${disk} 를 ${tg.infra} / ${node} 에 attach(전제 준비)` });
      cells = await diskCells(page, disk);
    }
    test.info().annotations.push({ type: 'before', description: JSON.stringify(cells) });
    expect(cells.status, `${disk} Attached`).toMatch(/attached/i);
    // Disks 화면에서 행 선택 → 상세 Detach
    await gotoDisks(page);
    await diskRow(page, disk).first().locator('[tabulator-field="name"]').click();
    await page.waitForTimeout(2000);
    const detail = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
    test.info().annotations.push({ type: 'detail', description: detail.slice(detail.indexOf('Data Disk Detail'), detail.indexOf('Data Disk Detail') + 220) });
    const det = page.locator('#detail-detach-btn');
    await expect(det, 'Detach 버튼(Attached 상태에서만 표시)').toBeVisible({ timeout: 15_000 });
    await det.click();
    const m = page.locator('.modal.show').last();
    await expect(m).toBeVisible();
    test.info().annotations.push({ type: 'detach-confirm', description: (await m.innerText()).replace(/\s+/g, ' ').slice(0, 200) });
    await m.getByRole('button', { name: /Confirm|Detach|OK|Yes/i }).last().click();
    await page.waitForTimeout(3000);
    const t = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
    test.info().annotations.push({ type: 'detach-result', description: t.join(' | ').slice(0, 250) || '(토스트 없음)' });
    await waitDiskState(page, disk, 'available');
    const after = await diskCells(page, disk);
    test.info().annotations.push({ type: 'after', description: JSON.stringify(after) });
    expect(after.status).toMatch(/available/i);
  });
}

IV3_S31

tests/iv3/IV3_S31.spec.ts · 70줄 · 해시 44a868e3 · 시나리오 보기

// IV3_S31: Data Disk attach — disk 와 node 의 AZ 가 다를 때 거부 (예상실패) [AWS] — 사용자 지시 2026-10-12
// 화면: Infra Workloads > mt1 > Node List g1-1 > Node Info > Block Device '+ Attach Disk'(Existing Disk) / Cloud Resources > Disks
// 절차: 1.Disks 에서 disk1c(ap-northeast-2a, Available) 확인 2.mt1 → g1-1(ap-northeast-2d) Node Info 3.+ Attach Disk → Existing Disk disk1c → Attach
//   4.오류 표시/팝업 상태 기록(90초) → 팝업이 남아 있으면 Cancel 5.Disks 목록 disk1c Available·Attached To '-', Block Device 에 없음 확인
// 기대결과: attach 거부 + AZ 불일치 원인을 알리는 오류 표시. disk 는 Available 유지
// 참고(2026-10-12 S04/S05/S15 실행): 거부 시 팝업이 닫히지 않고 오류 표시 없음. Disks 화면·상세에는 Zone 이 표시되지 않음(API GetDataDisk 에는 zone 있음)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin, toasts, watchToasts } from '../../lib/ui';
import { blockDevice, diskCells, nodeField, openInfraDetail, openNodeInfo } from '../../lib/infra';

const INPUT: Record<string, { infra: string; ng: string; node: string; disk: string } | undefined> = {
  AWS: { infra: 'mt1', ng: 'g1', node: 'g1-1', disk: process.env.TARGET_DISK ?? 'disk1c' },
};

for (const c of activeCsps(["AWS"])) {
  test(`${tid("IV3_S31", c)} ` + "Data Disk attach — disk 와 node 의 AZ 가 다를 때 거부", async ({ page }) => {
    const inp = INPUT[c.code];
    test.skip(!inp, `${c.name}: 대상 미지정`);
    let diskZone = '';
    page.on('response', async (r) => {
      if (/GetAllDataDisk|GetDataDisk/.test(r.url())) {
        const t = await r.text().catch(() => ''); const i = t.indexOf(`"${inp!.disk}"`);
        const z = i >= 0 ? t.slice(i, i + 3000).match(/"zone"\s*:\s*"([^"]*)"/) : null; if (z) diskZone = z[1];
      }
    });
    await loginAsAdmin(page);
    const before = await diskCells(page, inp!.disk);
    test.info().annotations.push({ type: 'disk-before', description: `${JSON.stringify(before)} zone(API)=${diskZone}` });
    expect(before.status, `${inp!.disk} Available`).toMatch(/available/i);
    await openInfraDetail(page, inp!.infra);
    let info = await openNodeInfo(page, inp!.ng, inp!.node);
    const nodeZone = nodeField(info, 'Zone');
    test.info().annotations.push({ type: 'zones', description: `node ${inp!.node}=${nodeZone} (Node Status ${nodeField(info, 'Node Status')}) / disk ${inp!.disk}=${diskZone}` });
    // Attach 요청 응답 기록(2026-10-14): 화면에 오류가 없어도 서버가 무엇을 돌려주는지 남긴다
    const api: string[] = [];
    page.on('response', async (r) => {
      if (r.request().method() === 'POST' && /attach|datadisk|disk/i.test(r.url()) && !/GetAllDataDisk|GetDataDisk/.test(r.url())) {
        const t = (await r.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 300);
        api.push(`${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, '')} ${t}`);
      }
    });
    expect(diskZone && nodeZone && diskZone !== nodeZone, 'disk 와 node 의 AZ 가 다름(전제)').toBeTruthy();

    await page.getByText('+ Attach Disk').first().click();
    const m = page.locator('#node-attach-disk-modal');
    await expect(m).toBeVisible();
    await m.locator('#node-attach-mode-existing').check();
    await m.locator('#node-attach-disk-select').selectOption({ label: inp!.disk });
    // 오류 토스트는 autohide 5초 → 팝업 대기(최대 90초) 뒤에 읽으면 놓친다. Attach 전에 기록을 걸어 둔다(2026-10-14)
    const readToasts = await watchToasts(page);
    await m.locator('#node-attach-disk-execute-btn').click();
    const closed = await m.waitFor({ state: 'hidden', timeout: 90_000 }).then(() => true, () => false);
    const msgs = [...await readToasts(), ...(await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean)];
    const modalText = closed ? '' : (await m.innerText()).replace(/\s+/g, ' ').slice(0, 300);
    test.info().annotations.push({ type: 'attach-result', description: `팝업 ${closed ? '닫힘' : '90초 후에도 열림'} / 토스트: ${msgs.join(' | ').slice(0, 400) || '(없음)'} / 팝업: ${modalText}` });
    test.info().annotations.push({ type: 'attach-api', description: api.join(' | ').slice(0, 600) || '(요청 없음)' });
    if (!closed) await m.getByRole('button', { name: 'Cancel' }).click();

    const after = await diskCells(page, inp!.disk);
    test.info().annotations.push({ type: 'disk-after', description: JSON.stringify(after) });
    expect(after.status, 'attach 거부 → disk Available 유지').toMatch(/available/i);
    await openInfraDetail(page, inp!.infra);
    info = await openNodeInfo(page, inp!.ng, inp!.node);
    expect(blockDevice(info), 'Block Device 에 disk 없음').not.toContain(inp!.disk);
    // 기대: 사용자에게 오류 표시(AZ 불일치). 표시가 없으면 FAIL(제품 개선 필요)
    expect(msgs.join(' ') + modalText, '거부 사유(오류) 표시').toMatch(/fail|error|zone|availability|AZ|오류|실패/i);
  });
}

IV3_S32

tests/iv3/IV3_S32.spec.ts · 48줄 · 해시 eac94caf · 시나리오 보기

// IV3_S32: NLB 삭제 [정상, AWS] — 사용자 지시 2026-10-12(NLB 삭제 테스트 추가 후 IV3_S07 재시도)
// 화면: Cloud Resources > NLB(/cloudresources/nlbs) → 목록 행 체크 → 상단 'Delete selected'(confirmBulkDelete) → 확인 팝업 Confirm → 목록
// 절차: 1.NLB 진입 2.NLB g1(Infra mc1 / NodeGroup g1) 행 체크 3.Delete selected → 확인 팝업 문구 기록 → Confirm 4.목록에서 g1 이 사라지는지 확인(최대 10분)
// 기대결과: 삭제 요청이 접수되고 NLB 목록에서 mc1/g1 NLB 가 제거됨
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin, selectWorkspaceProject, toasts } from '../../lib/ui';

const NLB_URL = '/webconsole/settings/environment/cloudresources/nlbs';
const INPUT: Record<string, { infra: string; ng: string } | undefined> = { AWS: { infra: 'mc1', ng: 'g1' } };

async function nlbRows(page: import('@playwright/test').Page) {
  await page.goto(NLB_URL);
  await selectWorkspaceProject(page, 'ws01', 'default', true);
  await page.getByText(/Showing \d+(-\d+)? (of \d+ )?rows|No NLBs|No data/i).first().waitFor({ timeout: 30_000 }).catch(() => {});
  await page.waitForTimeout(2000);
  return (await page.locator('.tabulator-row').allInnerTexts()).map((s) => s.replace(/\s+/g, ' '));
}

const nlbRow = (page: import('@playwright/test').Page, infra: string, ng: string) => page.locator('.tabulator-row')
  .filter({ has: page.getByRole('gridcell', { name: ng, exact: true }) }).filter({ has: page.getByRole('gridcell', { name: infra, exact: true }) });

for (const c of activeCsps(["AWS"])) {
  test(`${tid("IV3_S32", c)} ` + "NLB 삭제", async ({ page }) => {
    test.setTimeout(20 * 60_000);
    const inp = INPUT[c.code];
    test.skip(!inp, `${c.name}: 대상 미지정`);
    await loginAsAdmin(page);
    const before = await nlbRows(page);
    test.info().annotations.push({ type: 'nlb-before', description: before.join(' || ') || '(없음)' });
    // 행 텍스트는 셀이 붙어 나옴('g1mc1aws…') → 셀 단위로 NLB 이름(g1)·Infra(mc1) 일치 확인
    const row = nlbRow(page, inp!.infra, inp!.ng).first();
    await expect(row, `NLB ${inp!.infra}/${inp!.ng} 존재(선행 IV3_S07)`).toBeVisible();
    await row.getByRole('checkbox').check();
    await page.locator('[title="Delete selected"], [data-bs-original-title="Delete selected"]').first().click();
    const m = page.locator('.modal.show').last();
    await expect(m).toBeVisible();
    test.info().annotations.push({ type: 'confirm-modal', description: (await m.innerText()).replace(/\s+/g, ' ').slice(0, 200) });
    await m.getByRole('button', { name: /Confirm|Delete|OK|Yes/i }).last().click();
    await page.waitForTimeout(4000);
    test.info().annotations.push({ type: 'delete-toast', description: (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean).join(' | ').slice(0, 300) || '(토스트 없음)' });
    let rows: string[] = [];
    await expect.poll(async () => { rows = await nlbRows(page); return nlbRow(page, inp!.infra, inp!.ng).count(); },
      { message: `NLB ${inp!.infra}/${inp!.ng} 목록에서 제거`, timeout: 10 * 60_000, intervals: [20_000] }).toBe(0);
    test.info().annotations.push({ type: 'nlb-after', description: rows.join(' || ') || '(없음)' });
  });
}

IV3_S33

tests/iv3/IV3_S33.spec.ts · 44줄 · 해시 1ee4b36d · 시나리오 보기

// IV3_S33: TC-WF-004 Workflow 목록 조회 [정상, 공통]
// v0.6 TC-ID: TC-WF-FLOW-02 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/TC-WORKFLOW-01-list-workflows.spec.ts, wf-list.spec.ts, deploy/tc/workflow/TC-WF-FLOW-02.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Workflows (iframe :18083/web/workflow/list)
// 시작 상태: admin(mcmp) 로그인, Workspace=ws01 / Project=default. 기본 Workflow seed 등록
// 절차: 1.Workspace/Project 선택 후 Workflows 목록 조회 2.기본 Workflow 10종과 컬럼(Status, Params Count, Last Run Date)·Action 버튼 확인
//   3.화면 목록 = 화면이 부르는 GET /eventlistener/workflowList/N 응답(이름·수)
// 기대결과: 기본 Workflow 10종 모두 포함, Status·Params Count·Last Run Date 표시, Detail/DELETE/RUN/LOG 버튼 표시
// 비고: 사용자 Workflow 가 있으면 전체 목록 수는 10개를 초과할 수 있음(시트 비고)

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoWorkflows, workflowRows, wfApi, SEED_WORKFLOWS } from '../../lib/iv3-wf';

test('IV3_S33 TC-WF-004 Workflow 목록 조회', async ({ page }) => {
  let rows: Awaited<ReturnType<typeof workflowRows>> = {};

  await test.step('1. admin 로그인 → Workflows 진입(ws01/default)', async () => {
    await loginAsAdmin(page);
    const f = await gotoWorkflows(page);
    await expect(f.getByText('Workflow Name', { exact: true }).first()).toBeVisible();
    for (const h of ['Purpose', 'Status', 'Params Count', 'Last Run Date', 'Action']) await expect(f.getByText(h, { exact: true }).first()).toBeVisible();
    rows = await workflowRows(f);
    test.info().annotations.push({ type: 'rows', description: Object.entries(rows).map(([n, r]) => `${n}(${r.purpose}/${r.status || '-'}/${r.params}/${r.runDate || '-'})`).join(', ') });
  });

  await test.step('2. 기본 Workflow 10종 포함 · 행마다 Params Count 숫자 · Detail/DELETE/RUN/LOG 버튼', async () => {
    const missing = SEED_WORKFLOWS.filter((n) => !rows[n]);
    expect(missing, '목록에 없는 기본 Workflow').toEqual([]);
    for (const n of SEED_WORKFLOWS) {
      expect(rows[n].params, `${n} Params Count`).toMatch(/^\d+$/);
      expect(rows[n].buttons, `${n} Action 버튼`).toEqual(expect.arrayContaining(['Detail', 'DELETE', 'RUN', 'LOG']));
    }
  });

  await test.step('3. 화면 목록 = GET /eventlistener/workflowList/N 응답', async () => {
    const api = await wfApi(page, 'GET', '/eventlistener/workflowList/N');
    const list: any[] = api.body?.data ?? [];
    const apiNames = list.map((w) => w.workflowInfo?.workflowName).sort();
    test.info().annotations.push({ type: 'api', description: `HTTP ${api.status}, ${apiNames.length}건: ${apiNames.join(', ')}` });
    expect(api.status).toBe(200);
    expect(Object.keys(rows).sort()).toEqual(apiNames);
  });
});

IV3_S34

tests/iv3/IV3_S34.spec.ts · 82줄 · 해시 a0673427 · 시나리오 보기

// IV3_S34: TC-WF-001 Workflow Engine(Jenkins) 수정 및 연동 확인 [정상, 공통]
// v0.6 TC-ID: TC-WF-FLOW-01 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-jenkins.spec.ts, deploy/tc/workflow/TC-WF-FLOW-01.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: mc-workflow-manager OSS 목록(:18083/web/oss/list) → SampleOss EDIT → Edit OSS 모달
//   (OSS 관리는 콘솔 메뉴에 없어 시트 입력값의 Server URL 화면으로 직접 진입, 2026-10-14 확인)
// 시작 상태: admin(mcmp) 로그인. Jenkins 서버 구동
// 절차: 1.SampleOss 의 EDIT 에서 Jenkins URL/ID/인증값 입력 2.Connection Check 후 Edit 로 저장 3.재조회한 접속 정보와 연결 결과 확인
// 입력값: 현재 SampleOss 접속 정보를 그대로 다시 입력(공용 설정이므로 값은 바꾸지 않음 — 사용자 결정 2026-10-14). 시트 값: ID admin / PW(시트 참고)
//   비밀번호는 코드에 두지 않고 WF_OSS_PW 환경변수, 없으면 현재 저장값(목록 API 의 base64 값)을 디코딩해 쓴다
// 기대결과: Jenkins 연결 확인 성공, 수정한 접속 정보 저장 및 재조회 일치

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { clean, WF_ORIGIN } from '../../lib/iv3-wf';

const OSS_LIST = `${WF_ORIGIN}/web/oss/list`;
const OSS_API = `${WF_ORIGIN}/oss/list`;

test('IV3_S34 TC-WF-001 Workflow Engine(Jenkins) 수정 및 연동 확인', async ({ page, dialogs }) => {
  const apiCalls: string[] = [];
  page.on('response', async (r) => {
    const u = r.url();
    if (u.includes(':18083') && r.request().method() !== 'GET' && r.request().method() !== 'OPTIONS') apiCalls.push(`${r.status()} ${r.request().method()} ${u.replace(/^https?:\/\/[^/]+/, '')} :: ${clean(await r.text().catch(() => '')).slice(0, 200)}`);
  });

  const before = ((await (await page.request.get(OSS_API)).json()).data ?? []).find((o: any) => o.ossName === 'SampleOss');
  test.info().annotations.push({ type: 'before', description: JSON.stringify({ ...before, ossPassword: '***' }) });
  const OSS_PW = process.env.WF_OSS_PW ?? Buffer.from(String(before?.ossPassword ?? ''), 'base64').toString('utf-8');
  expect(before, 'SampleOss 등록되어 있음').toBeTruthy();

  const modal = page.locator('.modal.show');
  await test.step('1. OSS 목록 → SampleOss EDIT → URL/ID/PW 입력(현재 값 그대로)', async () => {
    await loginAsAdmin(page);
    await page.goto(OSS_LIST);
    const row = page.locator('.tabulator-row').filter({ hasText: 'SampleOss' }).first();
    await row.locator('#edit-btn').click();
    await expect(modal.getByText('Edit OSS')).toBeVisible();
    await modal.getByPlaceholder('Enter the Server URL').fill(before.ossUrl);
    await modal.getByPlaceholder('Enter the OSS ID').fill(before.ossUsername);
    await modal.getByPlaceholder('Enter the OSS Password').fill(OSS_PW);
  });

  let checkMsg = '';
  await test.step('2. Duplicate Check · Connection Check → Edit 저장', async () => {
    const n0 = dialogs.length;
    const consoleMsgs: string[] = [];
    page.on('console', (m) => { if (m.type() === 'error' || m.type() === 'warning') consoleMsgs.push(m.text().slice(0, 200)); });
    const toasts = async () => clean((await page.locator('.toast, [class*="toast"], [role="alert"], .invalid-feedback:visible, .text-danger:visible').allInnerTexts().catch(() => [])).join(' | '));
    await modal.getByRole('button', { name: 'Duplicate Check' }).click();
    await page.waitForTimeout(2000);
    test.info().annotations.push({ type: 'duplicate-check', description: `${dialogs.slice(n0).join(' | ')} ${await toasts()}` || '(알림 없음)' });
    await modal.getByRole('button', { name: 'Connection Check' }).click();
    await page.waitForTimeout(5000);
    checkMsg = dialogs.slice(n0).join(' | ') + ' ' + clean(await page.locator('[class*="toast"], [role="alert"]').allInnerTexts().then((a) => a.join(' ')).catch(() => ''));
    test.info().annotations.push({ type: 'connection-check', description: checkMsg || '(알림 없음)' });
    expect(checkMsg, 'Connection Check 실패 문구 없음').not.toMatch(/fail|error|invalid|unable/i);
    const n1 = dialogs.length;
    // Edit 저장(PATCH /oss/{idx})은 응답이 늦을 수 있다(2026-10-14: 3초 안에 응답 없음 → 토스트·모달 닫힘 없음) → 응답까지 최대 3분 대기
    const t0 = Date.now();
    const [patch] = await Promise.all([
      page.waitForResponse((r) => /\/oss\/\d+$/.test(r.url()) && r.request().method() === 'PATCH', { timeout: 180_000 }),
      modal.getByRole('button', { name: 'Edit', exact: true }).click(),
    ]);
    test.info().annotations.push({ type: 'patch', description: `HTTP ${patch.status()} ${Math.round((Date.now() - t0) / 1000)}s :: ${clean(await patch.text().catch(() => '')).slice(0, 200)}` });
    await page.waitForTimeout(1500);
    test.info().annotations.push({ type: 'after-edit-msgs', description: `toast: ${await toasts()} / console: ${consoleMsgs.join(' | ')}` });
    test.info().annotations.push({ type: 'save', description: dialogs.slice(n1).join(' | ') || '(알림 없음)' });
    test.info().annotations.push({ type: 'api', description: apiCalls.join(' || ') || '(기록 없음)' });
    test.info().annotations.push({ type: 'modal-after-edit', description: (await modal.count()) ? clean(await modal.innerText()).slice(0, 200) : '(모달 닫힘)' });
    expect(apiCalls.some((c) => /^200 (PATCH|PUT|POST) \/oss(\/\d+)?(\s|$)/.test(c)), 'Edit 저장 요청 200').toBe(true);
  });

  await test.step('3. 재조회: 접속 정보 일치 · 연결 결과', async () => {
    await page.goto(OSS_LIST);
    await expect(page.locator('.tabulator-row').filter({ hasText: 'SampleOss' }).first()).toContainText(before.ossUrl);
    const after = ((await (await page.request.get(OSS_API)).json()).data ?? []).find((o: any) => o.ossName === 'SampleOss');
    test.info().annotations.push({ type: 'after', description: JSON.stringify({ ...after, ossPassword: '***' }) });
    expect({ url: after.ossUrl, id: after.ossUsername, pwSame: after.ossPassword === before.ossPassword })
      .toEqual({ url: before.ossUrl, id: before.ossUsername, pwSame: true });
    expect(apiCalls.some((c) => /connection/i.test(c) && /^200/.test(c)) || /success|connected|true/i.test(checkMsg), '연결 확인 성공(응답 200 또는 성공 알림)').toBe(true);
  });
});

IV3_S35

tests/iv3/IV3_S35.spec.ts · 53줄 · 해시 94eeefec · 시나리오 보기

// IV3_S35: TC-WF-002 신규 Workflow 생성 [정상, 공통]
// v0.6 TC-ID: TC-WF-FLOW-03 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/TC-WORKFLOW-02-create-workflow.spec.ts, wf-flow.spec.ts, deploy/tc/workflow/TC-WF-FLOW-03.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Workflows → New Workflow (iframe /web/workflows/workflow/new)
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-001(IV3_S34) 완료, Workflow Stage 기본 데이터 등록
// 절차: 1.이름 입력 후 Duplicate Check 2.Purpose 선택 및 Create Script 후 Stage/Param 구성 3.Regist 후 목록과 저장 내용 확인
// 입력값(시트): Workflow Name workflow-create-test / Purpose For Deployment / Stage namespace-ensure → infra-create / Parameter 선택 Stage 의 기본값 / Tumblebug Selector 사용 안 함
// 기대결과: Workflow 목록 등록(Jenkins Job 생성), Stage 순서와 Parameter 설정 일치, Params Count = 공백 키 제외·대문자 정규화 후 저장 키 수
// 비고: Jenkins Job 은 mc-web-console 화면에서 직접 볼 수 없어 등록 응답(Registered successfully)·목록·상세(저장된 Stage/Parameter)로 판정한다(지침 4-0)
//   이미 같은 이름이 있으면(재실행) 먼저 삭제하고 진행한다(멱등)

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { createWorkflow, deleteWorkflow, gotoWorkflows, workflowRows, workflowDetailByName, normalizedKeyCount } from '../../lib/iv3-wf';

const NAME = 'workflow-create-test';
const STAGES: [string, string][] = [['Utility', 'namespace-ensure'], ['Infra', 'infra-create']];

test('IV3_S35 TC-WF-002 신규 Workflow 생성', async ({ page }) => {
  await loginAsAdmin(page);
  if (await workflowDetailByName(page, NAME)) {
    test.info().annotations.push({ type: 'pre-clean', description: `기존 ${NAME} 삭제: ${(await deleteWorkflow(page, NAME)).join(' | ')}` });
  }

  let r!: Awaited<ReturnType<typeof createWorkflow>>;
  await test.step(`1~3. New Workflow: ${NAME} → Duplicate Check → For Deployment → Create Script → ${STAGES.map((s) => s[1]).join(' → ')} → Regist`, async () => {
    r = await createWorkflow(page, NAME, 'For Deployment', STAGES);
    test.info().annotations.push({ type: 'duplicate-check', description: r.dupToasts.join(' | ') });
    test.info().annotations.push({ type: 'stages-form', description: r.shownStages.join(' → ') });
    test.info().annotations.push({ type: 'params-form', description: r.params.map((p) => `${p.key}=${p.value}`).join(', ') });
    test.info().annotations.push({ type: 'regist', description: r.registToasts.join(' | ') || '(토스트 없음)' });
    expect(r.dupToasts.join(' ')).toMatch(/Name is available/);
    expect(r.shownStages).toEqual(STAGES.map((s) => s[1]));
    expect(r.registToasts.join(' ')).toMatch(/Registered successfully/);
  });

  await test.step('4. 목록 등록 · 저장된 Stage 순서와 Parameter · Params Count 규칙', async () => {
    const fl = await gotoWorkflows(page);
    const rows = await workflowRows(fl);
    expect(rows[NAME], '목록에 등록').toBeTruthy();
    const d = await workflowDetailByName(page, NAME);
    const savedStages = (d?.workflowStageMappings ?? []).filter((m: any) => !m.isDefaultScript && m.workflowStageName).sort((a: any, b: any) => a.stageOrder - b.stageOrder).map((m: any) => m.workflowStageName);
    const savedKeys = (d?.workflowParams ?? []).map((p: any) => String(p.paramKey ?? ''));
    test.info().annotations.push({ type: 'saved', description: `purpose ${d?.workflowInfo?.workflowPurpose} / stages ${savedStages.join(' → ')} / params(${savedKeys.length}) ${savedKeys.join(', ')} / 목록 Params Count ${rows[NAME].params}` });
    expect(d?.workflowInfo?.workflowPurpose).toBe('For Deployment');
    expect(savedStages, '저장된 Stage 순서').toEqual(STAGES.map((s) => s[1]));
    // 화면에 보이는 키는 일부(TUMBLEBUG·USER·USERPASS 등은 Advanced/Show Secret 아래 접혀 있음, 2026-10-14) → 보인 키가 모두 저장됐는지 확인
    const formKeys = r.params.map((p) => p.key.trim().toUpperCase()).filter(Boolean);
    const savedUpper = savedKeys.map((k: string) => k.trim().toUpperCase());
    expect(formKeys.filter((k) => !savedUpper.includes(k)), '화면에서 구성한 키 중 저장되지 않은 키').toEqual([]);
    expect(Number(rows[NAME].params), 'Params Count = 정규화한 저장 키 수').toBe(normalizedKeyCount(savedKeys));
  });
});

IV3_S36

tests/iv3/IV3_S36.spec.ts · 55줄 · 해시 617aaaff · 시나리오 보기

// IV3_S36: TC-WF-003 Workflow 삭제 [정상, 공통]
// v0.6 TC-ID: TC-WF-FLOW-05 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/TC-WORKFLOW-04-delete-workflow.spec.ts, wf-flow.spec.ts, deploy/tc/workflow/TC-WF-FLOW-05.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Workflows → 대상 행 DELETE → Delete Workflow 확인 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-002 절차로 삭제 전용 Workflow 생성(실행 이력·연결 Event Listener 없음), Jenkins 정상 연결
// 절차: 1.삭제 대상의 DELETE → Delete 실행 2.Workflow 목록에서 제거 확인 3.동일 이름으로 다시 등록 가능한지 확인
// 입력값(시트): Workflow workflow-delete-test
// 기대결과: Workflow 목록(및 Jenkins Job)에서 삭제, 동일 Workflow Name 재사용 가능
// 비고: Jenkins Job 은 화면에서 볼 수 없어 목록·상세 API(404/빈 응답)·재등록 성공으로 판정. 재등록한 것은 끝에 다시 삭제(정리)

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { createWorkflow, deleteWorkflow, gotoWorkflows, workflowRows, workflowDetailByName, wfApi } from '../../lib/iv3-wf';

const NAME = 'workflow-delete-test';
const STAGES: [string, string][] = [['Utility', 'namespace-ensure']];

test('IV3_S36 TC-WF-003 Workflow 삭제', async ({ page }) => {
  await loginAsAdmin(page);

  await test.step(`0. 삭제 전용 Workflow ${NAME} 생성(TC-WF-002 절차) · 실행 이력/연결 Event Listener 없음 확인`, async () => {
    if (!(await workflowDetailByName(page, NAME))) {
      const r = await createWorkflow(page, NAME, 'For Testing', STAGES);
      test.info().annotations.push({ type: 'pre-create', description: r.registToasts.join(' | ') });
      expect(r.registToasts.join(' ')).toMatch(/Registered successfully/);
    }
    const d = await workflowDetailByName(page, NAME);
    const idx = d?.workflowInfo?.workflowIdx;
    const el = await wfApi(page, 'GET', `/workflow/existEventListener/${idx}`);
    const hist = await wfApi(page, 'GET', `/workflow/runHistory/${idx}`);
    test.info().annotations.push({ type: 'pre-state', description: `idx ${idx} / existEventListener ${JSON.stringify(el.body?.data)} / runHistory ${JSON.stringify(hist.body?.data ?? []).slice(0, 200)}` });
    expect(el.body?.data, '연결된 Event Listener 없음').toBeFalsy();
  });

  await test.step('1. DELETE → Delete 실행', async () => {
    const toasts = await deleteWorkflow(page, NAME);
    test.info().annotations.push({ type: 'delete', description: toasts.join(' | ') || '(토스트 없음)' });
  });

  await test.step('2. 목록·상세에서 제거', async () => {
    const rows = await workflowRows(await gotoWorkflows(page));
    expect(rows[NAME], '목록에서 제거').toBeUndefined();
    expect(await workflowDetailByName(page, NAME), '상세 조회 없음').toBeUndefined();
  });

  await test.step('3. 같은 이름으로 다시 등록 가능(Duplicate Check 통과 + Regist 성공) → 정리 삭제', async () => {
    const r = await createWorkflow(page, NAME, 'For Testing', STAGES);
    test.info().annotations.push({ type: 're-regist', description: `${r.dupToasts.join(' | ')} / ${r.registToasts.join(' | ')}` });
    expect(r.dupToasts.join(' ')).toMatch(/Name is available/);
    expect(r.registToasts.join(' ')).toMatch(/Registered successfully/);
    expect(await workflowDetailByName(page, NAME)).toBeTruthy();
    test.info().annotations.push({ type: 'cleanup', description: (await deleteWorkflow(page, NAME)).join(' | ') });
    expect(await workflowDetailByName(page, NAME)).toBeUndefined();
  });
});

IV3_S37

tests/iv3/IV3_S37.spec.ts · 83줄 · 해시 ed0116f5 · 시나리오 보기

// IV3_S37: TC-WF-005 Workflow 상세 수정 [정상, 공통]
// v0.6 TC-ID: TC-WF-FLOW-04 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-flow.spec.ts, deploy/tc/workflow/TC-WF-FLOW-04.spec.ts, deploy/scenarios/C3-svc-wf/C3-02-wf-modify.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Workflows → vm-mariadb-backup-import-data-init Detail (iframe /web/workflows/workflow/detail/{idx})
// 시작 상태: admin(mcmp) 로그인, ws01/default. 수정 대상 Workflow 존재, TC-WF-001(IV3_S34) 완료
// 절차: 1.Detail 에서 Parameter 변경 + Stage 추가 2.Edit 로 저장한 뒤 재조회 3.저장값과 Pipeline(script)/Parameter 비교
//   4.(정리) 원본으로 되돌리고 같은지 확인 — seed Workflow 는 다른 테스트도 쓰므로 반드시 원복(사용자 결정 2026-10-14)
//     Detail 화면에서 저장하면 바꾸지 않은 값도 바뀐다(2026-10-14 관찰: NAMESPACE 가 상단 선택 Project 로 덮임 ns01→default, IMAGE/IMAGE_ID 가 빈 값,
//     전 Stage 의 기본 파라미터 15개 추가 25→40, Stage 를 지워도 그 파라미터는 남음) → 화면으로는 원복 불가. 백업한 원본을 화면과 같은 저장 API(PATCH /workflow/{idx})로 저장
// 입력값(시트): Workflow vm-mariadb-backup-import-data-init / DB_NAME testdb_updated / Stage 변경: 추가(Database > db-data-verify, 맨 뒤)
// 기대결과: 변경한 Parameter 와 Stage 구성 유지, 재생성된 Pipeline(script)에 반영, Params Count = 저장된 Parameter 키 수
// 비고: Script 는 Stage 구성으로 재생성(직접 본문 편집은 제외, 시트 비고). Jenkins 설정은 화면에서 볼 수 없어 저장된 script 로 판정(지침 4-0)
//   원본 상세는 results/v07/iv3_s37-original.json 에 백업한다

import fs from 'node:fs';
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { RUN_DIR } from '../../lib/runtag';
import { wfApi, openWorkflowDetail, setFormParam, addStage, pipelineStages, collectToasts, workflowDetailByName, detailSummary, gotoWorkflows, workflowRows, normalizedKeyCount } from '../../lib/iv3-wf';

const NAME = 'vm-mariadb-backup-import-data-init';
const NEW_DB = 'testdb_updated';
const ADD: [string, string] = ['Database', 'db-data-verify'];

async function saveEdit(page: import('@playwright/test').Page, f: import('@playwright/test').Frame) {
  await f.getByRole('button', { name: 'Edit', exact: true }).click();
  return collectToasts(page, f, 6000);
}

test('IV3_S37 TC-WF-005 Workflow 상세 수정', async ({ page }) => {
  await loginAsAdmin(page);
  const orig = await workflowDetailByName(page, NAME);
  expect(orig, `${NAME} 존재`).toBeTruthy();
  const o = detailSummary(orig);
  fs.mkdirSync(RUN_DIR, { recursive: true });
  fs.writeFileSync(`${RUN_DIR}/iv3_s37-original.json`, JSON.stringify(orig, null, 2));
  test.info().annotations.push({ type: 'original', description: `stages ${o.stages.join(' → ')} / DB_NAME=${o.params.DB_NAME} / params ${Object.keys(o.params).length}` });
  expect(o.stages, '원본에 추가할 Stage 가 이미 있으면 안 됨').not.toContain(ADD[1]);

  let modified = false;
  try {
    await test.step(`1. Detail → DB_NAME=${NEW_DB} · Stage 추가(${ADD.join(' > ')})`, async () => {
      const f = await openWorkflowDetail(page, NAME);
      expect(await setFormParam(f, 'DB_NAME', NEW_DB), 'DB_NAME 입력란(화면에 보이는 행)').toBe(true);
      await addStage(page, f, ADD[0], ADD[1]);
      const shown = await pipelineStages(f);
      test.info().annotations.push({ type: 'stages-form', description: shown.join(' → ') });
      expect(shown).toEqual([...o.stages, ADD[1]]);
      const toasts = await saveEdit(page, f);
      modified = true;
      test.info().annotations.push({ type: 'edit', description: toasts.join(' | ') || '(토스트 없음)' });
      expect(toasts.join(' ')).toMatch(/Updated successfully/);
    });

    await test.step('2~3. 재조회: Parameter·Stage 유지 · script 에 추가 Stage 반영 · Params Count', async () => {
      const m = detailSummary(await workflowDetailByName(page, NAME));
      const rows = await workflowRows(await gotoWorkflows(page));
      test.info().annotations.push({ type: 'modified', description: `stages ${m.stages.join(' → ')} / DB_NAME=${m.params.DB_NAME} / script에 stage("${ADD[1]}") ${m.script.includes(`stage("${ADD[1]}")`)} / 목록 Params Count ${rows[NAME]?.params}` });
      const side = Object.keys({ ...o.params, ...m.params }).filter((k) => k !== 'DB_NAME' && o.params[k] !== m.params[k]);
      test.info().annotations.push({ type: 'side-effect', description: `수정하지 않았는데 바뀐 파라미터: ${side.map((k) => `${k} ${JSON.stringify(o.params[k])}→${JSON.stringify(m.params[k])}`).join(', ') || '없음'}` });
      expect(m.params.DB_NAME).toBe(NEW_DB);
      expect(m.stages).toEqual([...o.stages, ADD[1]]);
      expect(m.script, '재생성된 Pipeline script 에 추가 Stage').toContain(`stage("${ADD[1]}")`);
      expect(Number(rows[NAME]?.params), 'Params Count = 정규화한 저장 키 수').toBe(normalizedKeyCount(Object.keys(m.params)));
      // 다시 Detail 을 열었을 때도 화면에 같은 값
      const f = await openWorkflowDetail(page, NAME);
      expect(await pipelineStages(f)).toEqual([...o.stages, ADD[1]]);
    });
  } finally {
    if (modified) {
      await test.step('4. (정리) 원래 값으로 되돌려 저장 → 원본과 비교', async () => {
        const res = await wfApi(page, 'PATCH', `/workflow/${orig!.workflowInfo.workflowIdx}`, { workflowInfo: orig!.workflowInfo, workflowParams: orig!.workflowParams, workflowStageMappings: orig!.workflowStageMappings });
        const toasts = [`PATCH ${res.status} ${JSON.stringify(res.body?.data)}`];
        const r = detailSummary(await workflowDetailByName(page, NAME));
        const diffKeys = Object.keys({ ...o.params, ...r.params }).filter((k) => o.params[k] !== r.params[k]);
        test.info().annotations.push({ type: 'restore', description: `${toasts.join(' | ')} / stages ${r.stages.join(' → ')} / DB_NAME=${r.params.DB_NAME} / 다른 파라미터 ${diffKeys.join(', ') || '없음'} / script 동일 ${r.script === o.script}` });
        expect(r.stages, '원복: Stage').toEqual(o.stages);
        expect(diffKeys, '원복: Parameter').toEqual([]);
        expect(r.script === o.script, '원복: script').toBe(true);
      });
    }
  }
});

IV3_S38

tests/iv3/IV3_S38.spec.ts · 68줄 · 해시 bbccbe96 · 시나리오 보기

// IV3_S38: TC-WF-006 Workflow 로그 모달 표시 [정상, 공통]
// v0.6 TC-ID: TC-WF-FLOW-07 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-flow.spec.ts, TC-WORKFLOW-03-run-workflow.spec.ts, deploy/tc/workflow/TC-WF-FLOW-07.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Workflows → 실행 이력이 있는 Workflow 의 LOG → Workflow Log 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. 확인 대상 Workflow 의 Build 이력 존재(IV3 실행으로 생긴 multi-csp-k8s-cluster-deploy 등), Jenkins 정상 연결
// 절차: 1.실행 완료한 Workflow(Status SUCCESS/FAILURE)의 LOG 클릭 2.Build 선택(최신 Build) 3.해당 Build 의 Stage 로그·실패 원인 확인
// 기대결과: 선택한 Build 번호와 Console Log 일치, 대상 Workflow 의 Stage 로그 표시, 실패한 경우 원인 표시
// 비고: 로그 조회를 위해 Workflow 를 다시 실행하지 않음(시트 비고)

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoWorkflows, workflowRows, workflowRow, wfApi, clean } from '../../lib/iv3-wf';

test('IV3_S38 TC-WF-006 Workflow 로그 모달 표시', async ({ page }) => {
  await loginAsAdmin(page);
  const f = await gotoWorkflows(page);
  const rows = await workflowRows(f);
  // 실행 이력이 있는 Workflow(Status 가 SUCCESS/FAILURE) 중 하나
  const target = ['multi-csp-k8s-cluster-deploy', 'multi-csp-vm-deploy', ...Object.keys(rows)].find((n) => /SUCCESS|FAILURE/.test(rows[n]?.status ?? ''));
  test.skip(!target, '실행 이력(Status SUCCESS/FAILURE)이 있는 Workflow 없음');
  const status = rows[target!].status;
  test.info().annotations.push({ type: 'target', description: `${target} (Status ${status})` });

  // Stage 이름: Workflow 정의 스크립트의 stage("...") 목록
  const wf = (await wfApi(page, 'GET', '/eventlistener/workflowList/N')).body?.data?.find((w: any) => w.workflowInfo?.workflowName === target);
  const stages: string[] = [...String(wf?.workflowInfo?.script ?? '').matchAll(/stage\("([^"]+)"\)/g)].map((m) => m[1]);
  test.info().annotations.push({ type: 'stages', description: stages.join(' → ') });

  const modal = f.locator('.modal.show');
  await test.step(`1. ${target} 의 LOG 클릭 → Workflow Log 모달`, async () => {
    await workflowRow(f, target!).locator('#log-btn').click();
    await expect(modal.getByText('Workflow Log')).toBeVisible();
  });

  // Build 별 카드(card-header 의 h3.card-title = Build 번호, card-body textarea = Console Log). 화면 데이터는 GET /workflow/log/{workflowIdx}
  const logs: any[] = (await wfApi(page, 'GET', `/workflow/log/${wf?.workflowInfo?.workflowIdx}`)).body?.data ?? [];
  const latest = logs.reduce((a, b) => (Number(b.buildIdx) > Number(a?.buildIdx ?? -1) ? b : a), undefined as any);
  test.info().annotations.push({ type: 'builds', description: `API Build ${logs.map((l) => l.buildIdx).join(', ')} / 최신 ${latest?.buildIdx}` });

  let build = '';
  await test.step('2. 최신 Build 선택(Build 카드 헤더)', async () => {
    const titles = (await modal.locator('.card-title').allInnerTexts()).map(clean);
    test.info().annotations.push({ type: 'cards', description: titles.join(', ') });
    build = String(latest?.buildIdx ?? '');
    expect(titles, '최신 Build 카드 표시').toContain(build);
    const card = modal.locator('.card').filter({ has: f.locator('.card-title', { hasText: new RegExp(`^${build}$`) }) }).first();
    if (!(await card.locator('textarea').isVisible().catch(() => false))) await card.locator('.card-header').click();
    await expect(card.locator('textarea')).toBeVisible();
  });

  await test.step('3. Console Log: 선택 Build 와 일치 · Stage 로그 표시 · 실패면 원인', async () => {
    const card = modal.locator('.card').filter({ has: f.locator('.card-title', { hasText: new RegExp(`^${build}$`) }) }).first();
    const log = await card.locator('textarea').inputValue();
    test.info().annotations.push({ type: 'log-head', description: log.slice(0, 500) });
    test.info().annotations.push({ type: 'log-tail', description: log.slice(-800) });
    expect(clean(log), `Build ${build} 화면 로그 = API buildLog`).toBe(clean(String(latest.buildLog)));
    const shown = stages.filter((st) => log.includes(`(${st})`) || log.includes(`STAGE: ${st}`));
    test.info().annotations.push({ type: 'stage-in-log', description: `${shown.length}/${stages.length}: ${shown.join(', ')}` });
    expect(shown.length, 'Workflow 의 Stage 로그가 Console Log 에 표시').toBeGreaterThan(0);
    const finished = log.match(/Finished: (\w+)/)?.[1] ?? '(진행 중/표기 없음)';
    test.info().annotations.push({ type: 'finished', description: finished });
    if (finished === 'FAILURE') {
      const cause = log.split(/\r?\n/).filter((l) => /ERROR|error:|Exception|failed/i.test(l)).slice(-3).join(' / ');
      test.info().annotations.push({ type: 'failure-cause', description: cause || '(원인 줄 없음)' });
      expect(cause, '실패 원인 표시').not.toBe('');
    }
  });
});

IV3_S39

tests/iv3/IV3_S39.spec.ts · 43줄 · 해시 3aae00a0 · 시나리오 보기

// IV3_S39: TC-WF-014 Event Listener 목록 조회 [정상, 공통]
// v0.6 TC-ID: TC-WF-EL-01 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-el.spec.ts, deploy/tc/workflow/TC-WF-EL-01.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Event Listeners (iframe :18083/web/workflows/eventListener/list)
// 시작 상태: admin(mcmp) 로그인, Workspace=ws01 / Project=default. Workflow 메뉴 접근 가능
// 절차: 1.Workspace/Project 선택 후 Event Listener 목록 조회 2.등록 항목의 연결 정보·Action URL·버튼 확인
// 기대결과: Listener 이름·연결 Workflow·Description·Action URL 표시, Edit/DELETE 버튼 표시. 미등록 상태에서는 빈 목록(Showing 0 rows)
// 판정: 화면 목록 = 화면이 부르는 GET /eventlistener/list 응답(이름·수)

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoEventListeners, wfApi, clean } from '../../lib/iv3-wf';

test('IV3_S39 TC-WF-014 Event Listener 목록 조회', async ({ page }) => {
  await loginAsAdmin(page);
  const f = await gotoEventListeners(page);

  await test.step('1. 컬럼: Event Listener Name / Connect Workflow Name / Event Listener Desc / Action URL / Action', async () => {
    for (const h of ['Event Listener Name', 'Connect Workflow Name', 'Event Listener Desc', 'Action URL', 'Action']) await expect(f.getByText(h, { exact: true }).first()).toBeVisible();
  });

  await test.step('2. 화면 목록 = GET /eventlistener/list · 행마다 Action URL 과 Edit/DELETE 버튼', async () => {
    const api = await wfApi(page, 'GET', '/eventlistener/list');
    const list: any[] = api.body?.data ?? [];
    const rows = f.locator('.tabulator-row');
    const n = await rows.count();
    const shown: string[] = [];
    for (let i = 0; i < n; i++) {
      const r = rows.nth(i);
      const name = clean(await r.locator('[tabulator-field="eventListenerName"]').innerText());
      const url = clean(await r.locator('[tabulator-field="eventListenerUrl"]').innerText());
      const btns = (await r.locator('button').allInnerTexts()).map(clean);
      shown.push(`${name}(${clean(await r.locator('[tabulator-field="workflowName"]').innerText())}) ${url} [${btns.join('/')}]`);
      expect(url, `${name} Action URL`).toMatch(/eventlistener\/run\/\d+|https?:\/\//i);
      expect(btns.map((b) => b.toUpperCase()), `${name} 버튼`).toEqual(expect.arrayContaining(['EDIT', 'DELETE']));
    }
    test.info().annotations.push({ type: 'rows', description: shown.join(' || ') || '(빈 목록)' });
    test.info().annotations.push({ type: 'api', description: `HTTP ${api.status}, ${list.length}건: ${list.map((x) => x.eventListenerName).join(', ')}` });
    expect(api.status).toBe(200);
    expect(n).toBe(list.length);
    if (n === 0) await expect(f.getByText(/Showing 0 rows/).first()).toBeVisible();
  });
});

IV3_S40

tests/iv3/IV3_S40.spec.ts · 65줄 · 해시 23dece22 · 시나리오 보기

// IV3_S40: TC-WF-015 신규 Event Listener 생성 [정상, 공통]
// v0.6 TC-ID: TC-WF-EL-02 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-el.spec.ts, deploy/tc/workflow/TC-WF-EL-02.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Event Listeners → New Event Listener 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-014(IV3_S39) 완료, 연결 Workflow 존재, Jenkins 정상 연결
// 절차: 1.New Event Listener 에서 이름 입력 및 Duplicate Check 2.Description / Connect Workflow / 기본 Parameter 입력 3.Create 후 목록과 저장값·Action URL 확인
// 입력값(시트): Name el-vm-mariadb-backup-import-data-init / Description VM MariaDB data init webhook / Workflow vm-mariadb-backup-import-data-init
//   Parameter NAMESPACE ns01, INFRA_ID vm-mariadb-data-init, DB_NAME testdb, DB_USER mariadb_user, DB_PASSWORD mariadb_pass
// 기대결과: 신규 Listener 목록 등록 및 Action URL 생성, 연결 Workflow 와 기본 Parameter 저장
// 비고: Workflow 를 고르면 파라미터가 그 Workflow 값으로 채워지고 NAMESPACE 는 상단 선택 Project(default)로 바뀐다 → 시트 값으로 다시 입력. 이미 있으면(재실행) 삭제 후 진행

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openNewEventListener, setFormParam, collectToasts, gotoEventListeners, elRow, elByName, deleteEventListener, clean } from '../../lib/iv3-wf';

export const EL_NAME = 'el-vm-mariadb-backup-import-data-init';
export const EL_WF = 'vm-mariadb-backup-import-data-init';
const DESC = 'VM MariaDB data init webhook';
const PARAMS: Record<string, string> = { NAMESPACE: 'ns01', INFRA_ID: 'vm-mariadb-data-init', DB_NAME: 'testdb', DB_USER: 'mariadb_user', DB_PASSWORD: process.env.WF_DB_PASSWORD ?? 'mariadb_pass' };

test('IV3_S40 TC-WF-015 신규 Event Listener 생성', async ({ page }) => {
  await loginAsAdmin(page);
  if (await elByName(page, EL_NAME)) test.info().annotations.push({ type: 'pre-clean', description: (await deleteEventListener(page, EL_NAME)).join(' | ') });

  await test.step(`1. New Event Listener → ${EL_NAME} → Duplicate Check`, async () => {
    const { f, m } = await openNewEventListener(page);
    await m.getByPlaceholder('Enter Event Listener Name').fill(EL_NAME);
    await m.getByRole('button', { name: 'Duplicate Check' }).click();
    const t = await collectToasts(page, f, 2500);
    test.info().annotations.push({ type: 'duplicate-check', description: t.join(' | ') });
    expect(t.join(' ')).toMatch(/Name is available/);

    await test.step(`2. Description · Workflow ${EL_WF} · Parameter 입력`, async () => {
      await m.getByPlaceholder('Enter Event Listener Description').fill(DESC);
      await m.locator('select').first().selectOption({ label: EL_WF });
      await page.waitForTimeout(2000);
      const missing: string[] = [];
      for (const [k, v] of Object.entries(PARAMS)) if (!(await setFormParam(f, k, v))) missing.push(k);
      test.info().annotations.push({ type: 'missing', description: missing.join(', ') || '없음' });
      expect(missing, '입력란을 찾지 못한 Parameter').toEqual([]);
    });

    await test.step('3. Create', async () => {
      await m.getByRole('button', { name: 'Create', exact: true }).click();
      const t2 = await collectToasts(page, f, 5000);
      test.info().annotations.push({ type: 'create', description: t2.join(' | ') || '(토스트 없음)' });
      expect(t2.join(' ')).toMatch(/Registered successfully/);
    });
  });

  await test.step('4. 목록 등록 · Action URL · 저장된 연결 Workflow/Parameter', async () => {
    const fl = await gotoEventListeners(page);
    const row = elRow(fl, EL_NAME);
    await expect(row).toHaveCount(1);
    const url = clean(await row.locator('[tabulator-field="eventListenerUrl"]').innerText());
    const d = await elByName(page, EL_NAME);
    const saved: Record<string, string> = {};
    for (const p of d?.workflowParams ?? []) saved[p.paramKey] = p.paramValue;
    test.info().annotations.push({ type: 'saved', description: `idx ${d?.eventListenerIdx} / workflow ${d?.workflowName ?? d?.workflowIdx} / desc ${d?.eventListenerDesc} / callUrl ${d?.eventListenerCallUrl} / 화면 URL ${url} / ${Object.keys(PARAMS).map((k) => `${k}=${k === 'DB_PASSWORD' ? '***' : saved[k]}`).join(', ')}` });
    expect(url, '화면 Action URL').toContain(`/eventlistener/run/${d?.eventListenerIdx}`);
    expect(d?.eventListenerDesc).toBe(DESC);
    expect(clean(await row.locator('[tabulator-field="workflowName"]').innerText())).toBe(EL_WF);
    for (const [k, v] of Object.entries(PARAMS)) expect(saved[k], `저장된 ${k}`).toBe(v);
  });
});

IV3_S41

tests/iv3/IV3_S41.spec.ts · 52줄 · 해시 c6a5b759 · 시나리오 보기

// IV3_S41: TC-WF-016 Event Listener 이름 중복 검사 [예외, 공통]
// v0.6 TC-ID: TC-WF-EL-03 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-el.spec.ts, deploy/tc/workflow/TC-WF-EL-03.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Event Listeners → New Event Listener 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-015(IV3_S40) 완료 → 기존 Listener 존재
// 절차: 1.기존 이름으로 Duplicate Check 2.미사용 이름으로 바꿔 다시 Duplicate Check 3.이름을 다시 바꾼 뒤 검증 없이 Create 클릭
// 입력값(시트): 기존 el-vm-mariadb-backup-import-data-init / 미사용 …-02 / 재변경 …-03
// 기대결과: 기존 이름 중복 안내, 미사용 이름 통과, 재변경 후 미검증 Create 시 오류 안내 및 차단(Listener 미생성)

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openNewEventListener, collectToasts, elByName, wfApi } from '../../lib/iv3-wf';

const EXIST = 'el-vm-mariadb-backup-import-data-init';

test('IV3_S41 TC-WF-016 Event Listener 이름 중복 검사', async ({ page }) => {
  await loginAsAdmin(page);
  test.skip(!(await elByName(page, EXIST)), `선행 IV3_S40 의 ${EXIST} 없음`);
  const before = ((await wfApi(page, 'GET', '/eventlistener/list')).body?.data ?? []).length;
  const { f, m } = await openNewEventListener(page);
  const name = m.getByPlaceholder('Enter Event Listener Name');
  const toastGone = () => f.locator('[class*="toast"]').first().waitFor({ state: 'detached', timeout: 8000 }).catch(() => {}); // 이전 단계 토스트가 섞이지 않게
  const check = async () => { await toastGone(); await m.getByRole('button', { name: 'Duplicate Check' }).click(); return (await collectToasts(page, f, 2500)).join(' | '); };

  await test.step(`1. 기존 이름 ${EXIST} → Duplicate Check → 중복 안내`, async () => {
    await name.fill(EXIST);
    const t = await check();
    test.info().annotations.push({ type: 'exist', description: t });
    expect(t).toMatch(/already in use/i);
  });

  await test.step(`2. 미사용 이름 ${EXIST}-02 → Duplicate Check 통과`, async () => {
    await name.fill(`${EXIST}-02`);
    const t = await check();
    test.info().annotations.push({ type: 'unused', description: t });
    expect(t).toMatch(/Name is available/);
  });

  await test.step(`3. 이름을 ${EXIST}-03 으로 바꾸고 검증 없이 Create → 오류 안내 · 미생성`, async () => {
    await name.fill(`${EXIST}-03`);
    await m.getByPlaceholder('Enter Event Listener Description').fill('duplicate check test');
    await toastGone();
    await m.getByRole('button', { name: 'Create', exact: true }).click();
    const t = (await collectToasts(page, f, 4000)).join(' | ');
    test.info().annotations.push({ type: 'create-unchecked', description: t || '(토스트 없음)' });
    expect(t).toMatch(/perform duplicate check/i);
    await expect(m, '모달 유지(저장 차단)').toBeVisible();
    const after = ((await wfApi(page, 'GET', '/eventlistener/list')).body?.data ?? []) as any[];
    expect(after.length, 'Listener 수 변화 없음').toBe(before);
    expect(after.map((x) => x.eventListenerName)).not.toContain(`${EXIST}-03`);
  });
});

IV3_S42

tests/iv3/IV3_S42.spec.ts · 45줄 · 해시 6970297e · 시나리오 보기

// IV3_S42: TC-WF-012 Event Listener 를 통한 Workflow 실행 (GET) [정상, AWS]
// v0.6 TC-ID: TC-WF-EL-06 — 기존 spec(mcmp-e2e deploy/tc/workflow/TC-WF-EL-06.spec.ts, deploy/scenarios/C3-svc-wf/C3-07-wf-el-run.spec.ts, mc-web-console/specs/workflow/wf-el.spec.ts)은 절차만 참고. 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Event Listeners 의 Action URL(목록 표시값)을 외부에서 GET 호출 → Workflows 목록 Status · LOG
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-015(IV3_S40) 완료, 연결 Workflow 실행 가능, Jenkins 정상 연결
// 절차: 1.Listener 의 Action URL 에 Query 를 붙여 GET 호출 2.접수 응답과 신규 Build 확인 3.Build LOG 에서 전달값 확인
// 입력값(시트): GET /eventlistener/run/{eventListenerIdx}?NAMESPACE=ns01&INFRA_ID=vm-mariadb-data-init&REGION=ap-northeast-2&CONNECTION_NAME=aws-ap-northeast-2&DB_NAME=testdb_get
// 기대결과: 응답 JSON code=200, data=true(요청 접수), 연결 Workflow 의 신규 Build 생성·상태 갱신, Query Parameter 가 해당 Build 에 반영
// 비고: 접수 응답과 최종 Build 결과는 별도 판정(시트). 최종 결과·실패 원인은 annotation 으로 남긴다(최대 20분 관찰)
//   💰 연결 Workflow 는 infra-create 로 AWS VM 을 만든다 → 테스트 후 vm-mariadb-data-init-cleanup 으로 정리(사용자 결정 2026-10-14)

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { elByName, callEventListener, failureLines, gotoWorkflows, workflowRows } from '../../lib/iv3-wf';

const EL_NAME = 'el-vm-mariadb-backup-import-data-init';
const QUERY = { NAMESPACE: 'ns01', INFRA_ID: 'vm-mariadb-data-init', REGION: 'ap-northeast-2', CONNECTION_NAME: 'aws-ap-northeast-2', DB_NAME: 'testdb_get' };

test('IV3_S42_AWS TC-WF-012 Event Listener 를 통한 Workflow 실행 (GET)', async ({ page }) => {
  test.setTimeout(30 * 60_000);
  await loginAsAdmin(page);
  const el = await elByName(page, EL_NAME);
  test.skip(!el, `선행 IV3_S40 의 ${EL_NAME} 없음`);
  const wfIdx = Number(el.workflowIdx);

  let r!: Awaited<ReturnType<typeof callEventListener>>;
  await test.step(`1~2. GET ${el.eventListenerCallUrl}?… → 접수 응답 · 신규 Build`, async () => {
    r = await callEventListener(page, el.eventListenerIdx, wfIdx, 'GET', QUERY);
    expect(r.res.status).toBe(200);
    expect({ code: r.res.body?.code, data: r.res.body?.data }, '접수 응답').toEqual({ code: 200, data: true });
    expect(r.build, '연결 Workflow 의 신규 Build').toBeTruthy();
    const rows = await workflowRows(await gotoWorkflows(page));
    test.info().annotations.push({ type: 'wf-status', description: `${el.workflowName}: ${rows[el.workflowName]?.status} / Last Run ${rows[el.workflowName]?.runDate}` });
  });

  await test.step('3. Build LOG: 전달값 반영 · 최종 결과/실패 원인', async () => {
    const log = r.build!.buildLog;
    const reflected = Object.entries(QUERY).filter(([, v]) => log.includes(v)).map(([k]) => k);
    test.info().annotations.push({ type: 'reflected', description: `로그에 나타난 Query 값: ${reflected.join(', ') || '없음'} (DB_NAME 은 mariadb-install 이후 Stage 에서만 사용)` });
    if (r.finished === 'FAILURE') test.info().annotations.push({ type: 'failure-cause', description: failureLines(log).join(' / ') });
    test.info().annotations.push({ type: 'log-tail', description: log.slice(-1200) });
    expect(reflected, 'NAMESPACE 반영(namespace-ensure 로그)').toContain('NAMESPACE');
    // 시트: 접수 응답과 최종 Build 결과는 별도 판정 → 최종 결과는 판정에 넣지 않는다
  });
});

IV3_S43

tests/iv3/IV3_S43.spec.ts · 43줄 · 해시 8a0f9a50 · 시나리오 보기

// IV3_S43: TC-WF-013 Event Listener 를 통한 Workflow 실행 (POST) [정상, AWS]
// v0.6 TC-ID: TC-WF-EL-07 — 기존 spec(mcmp-e2e deploy/tc/workflow/TC-WF-EL-07.spec.ts, deploy/scenarios/C3-svc-wf/C3-07-wf-el-run.spec.ts, mc-web-console/specs/workflow/wf-el.spec.ts)은 절차만 참고. 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Event Listeners 의 Action URL 을 외부에서 POST 호출 → Workflows 목록 Status · LOG
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-015(IV3_S40) 완료, 연결 Workflow 실행 가능, Jenkins 정상 연결
// 절차: 1.Listener 의 Action URL 에 JSON Body 를 POST 2.접수 응답과 신규 Build 확인 3.Build 의 전달값과 최종 결과/실패 원인 확인
// 입력값(시트): POST /eventlistener/run/{eventListenerIdx}, Content-Type application/json,
//   Body {"NAMESPACE":"ns01","INFRA_ID":"vm-mariadb-data-init","REGION":"ap-northeast-2","CONNECTION_NAME":"aws-ap-northeast-2","DB_NAME":"testdb_post","INSERT_SQL":"INSERT INTO sample_data (id, name) VALUES (2, 'event row');"}
// 기대결과: 응답 JSON code=200, data=true(요청 접수), Request Body 가 해당 Build 에 반영, 최종 실행 결과 또는 실패 원인 확인 가능
// 비고: 접수 응답과 최종 Build 결과는 별도 판정(시트). 💰 연결 Workflow 는 AWS VM 을 만든다 → 테스트 후 정리

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { elByName, callEventListener, failureLines } from '../../lib/iv3-wf';

const EL_NAME = 'el-vm-mariadb-backup-import-data-init';
const BODY = { NAMESPACE: 'ns01', INFRA_ID: 'vm-mariadb-data-init', REGION: 'ap-northeast-2', CONNECTION_NAME: 'aws-ap-northeast-2', DB_NAME: 'testdb_post', INSERT_SQL: "INSERT INTO sample_data (id, name) VALUES (2, 'event row');" };

test('IV3_S43_AWS TC-WF-013 Event Listener 를 통한 Workflow 실행 (POST)', async ({ page }) => {
  test.setTimeout(30 * 60_000);
  await loginAsAdmin(page);
  const el = await elByName(page, EL_NAME);
  test.skip(!el, `선행 IV3_S40 의 ${EL_NAME} 없음`);

  let r!: Awaited<ReturnType<typeof callEventListener>>;
  await test.step(`1~2. POST ${el.eventListenerCallUrl} (JSON Body) → 접수 응답 · 신규 Build`, async () => {
    r = await callEventListener(page, el.eventListenerIdx, Number(el.workflowIdx), 'POST', BODY);
    expect(r.res.status).toBe(200);
    expect({ code: r.res.body?.code, data: r.res.body?.data }, '접수 응답').toEqual({ code: 200, data: true });
    expect(r.build, '연결 Workflow 의 신규 Build').toBeTruthy();
  });

  await test.step('3. Build LOG: Body 값 반영 · 최종 결과 또는 실패 원인', async () => {
    const log = r.build!.buildLog;
    const reflected = Object.entries(BODY).filter(([, v]) => log.includes(v)).map(([k]) => k);
    const cause = r.finished === 'FAILURE' ? failureLines(log) : [];
    test.info().annotations.push({ type: 'reflected', description: `로그에 나타난 Body 값: ${reflected.join(', ') || '없음'} (DB_NAME·INSERT_SQL 은 DB Stage 에서만 사용)` });
    test.info().annotations.push({ type: 'final', description: `${r.finished ?? '(진행 중)'}${cause.length ? ' / 원인: ' + cause.join(' / ') : ''}` });
    test.info().annotations.push({ type: 'log-tail', description: log.slice(-1200) });
    expect(reflected, 'NAMESPACE 반영').toContain('NAMESPACE');
    expect(r.finished === 'SUCCESS' || cause.length > 0, '최종 결과 SUCCESS 또는 실패 원인 확인 가능').toBe(true);
  });
});

IV3_S44

tests/iv3/IV3_S44.spec.ts · 62줄 · 해시 37b5dd5e · 시나리오 보기

// IV3_S44: TC-WF-017 Event Listener 상세 수정 [정상, AWS]
// v0.6 TC-ID: TC-WF-EL-04 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-el.spec.ts, deploy/tc/workflow/TC-WF-EL-04.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Event Listeners → 대상 행 Edit → Event Listener Edit 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-015(IV3_S40) 완료, 연결 Workflow 실행 가능, Jenkins 정상 연결
// 절차: 1.Edit 에서 Description/Parameter 변경 후 저장 2.재조회하여 저장값 확인 3.Action URL 을 호출하여 수정값 반영 확인
// 입력값(시트): Event Listener el-vm-mariadb-backup-import-data-init / Description updated webhook desc / DB_NAME testdb_updated
//   INSERT_SQL INSERT INTO sample_data (id, name) VALUES (3, 'updated row');
// 기대결과: 수정한 Description/Parameter 및 연결 Workflow 유지, 호출 Build 에 수정된 Parameter 반영
// 비고: 3 의 호출은 Query/Body 없이 GET(저장된 Parameter 만으로 실행). 💰 연결 Workflow 는 AWS VM 을 만든다 → 테스트 후 정리

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoEventListeners, elRow, elByName, wfFrame, setFormParam, collectToasts, callEventListener, failureLines } from '../../lib/iv3-wf';

const EL_NAME = 'el-vm-mariadb-backup-import-data-init';
const DESC = 'updated webhook desc';
const PARAMS: Record<string, string> = { DB_NAME: 'testdb_updated', INSERT_SQL: "INSERT INTO sample_data (id, name) VALUES (3, 'updated row');" };

test('IV3_S44_AWS TC-WF-017 Event Listener 상세 수정', async ({ page }) => {
  test.setTimeout(30 * 60_000);
  await loginAsAdmin(page);
  const before = await elByName(page, EL_NAME);
  test.skip(!before, `선행 IV3_S40 의 ${EL_NAME} 없음`);

  await test.step(`1. Edit → Description "${DESC}" · DB_NAME · INSERT_SQL 변경 → Edit 저장`, async () => {
    const fl = await gotoEventListeners(page);
    await elRow(fl, EL_NAME).locator('#edit-btn').click();
    const f = await wfFrame(page);
    const m = f.locator('.modal.show');
    await expect(m.getByText('Event Listener Edit')).toBeVisible();
    await page.waitForTimeout(2000);
    await m.getByPlaceholder('Enter Event Listener Description').fill(DESC);
    const missing: string[] = [];
    for (const [k, v] of Object.entries(PARAMS)) if (!(await setFormParam(f, k, v))) missing.push(k);
    expect(missing, '입력란을 찾지 못한 Parameter').toEqual([]);
    await m.getByRole('button', { name: 'Edit', exact: true }).click();
    const t = await collectToasts(page, f, 5000);
    test.info().annotations.push({ type: 'edit', description: t.join(' | ') || '(토스트 없음)' });
    expect(t.join(' ')).toMatch(/Updated successfully/);
  });

  await test.step('2. 재조회: Description · Parameter · 연결 Workflow 유지', async () => {
    const d = await elByName(page, EL_NAME);
    const saved: Record<string, string> = {};
    for (const p of d?.workflowParams ?? []) saved[p.paramKey] = p.paramValue;
    test.info().annotations.push({ type: 'saved', description: `desc ${d?.eventListenerDesc} / workflow ${d?.workflowName} (${d?.workflowIdx}) / DB_NAME=${saved.DB_NAME} / INSERT_SQL=${saved.INSERT_SQL} / NAMESPACE=${saved.NAMESPACE}` });
    expect(d?.eventListenerDesc).toBe(DESC);
    expect(String(d?.workflowIdx)).toBe(String(before.workflowIdx));
    for (const [k, v] of Object.entries(PARAMS)) expect(saved[k], `저장된 ${k}`).toBe(v);
  });

  await test.step('3. Action URL 호출(GET, Query 없음) → 신규 Build · 수정값 반영', async () => {
    const r = await callEventListener(page, before.eventListenerIdx, Number(before.workflowIdx), 'GET', {});
    expect({ code: r.res.body?.code, data: r.res.body?.data }, '접수 응답').toEqual({ code: 200, data: true });
    expect(r.build, '신규 Build').toBeTruthy();
    const log = r.build!.buildLog;
    const reflected = Object.entries(PARAMS).filter(([, v]) => log.includes(v)).map(([k]) => k);
    test.info().annotations.push({ type: 'reflected', description: `로그에 나타난 수정값: ${reflected.join(', ') || '없음'} / 결과 ${r.finished ?? '(진행 중)'}${r.finished === 'FAILURE' ? ' / 원인: ' + failureLines(log).join(' / ') : ''}` });
    test.info().annotations.push({ type: 'log-tail', description: log.slice(-1200) });
  });
});

IV3_S45

tests/iv3/IV3_S45.spec.ts · 62줄 · 해시 67c732ba · 시나리오 보기

// IV3_S45: TC-WF-018 Event Listener 삭제 [정상, 공통]
// v0.6 TC-ID: TC-WF-EL-05 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-el.spec.ts, deploy/tc/workflow/TC-WF-EL-05.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Event Listeners → 대상 행 Delete → Delete Event Listener 확인 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-015 절차로 삭제용 Listener 생성, 연결 Workflow 존재
// 절차: 1.삭제용 Listener 의 Delete → Delete 실행 2.목록에서 제거된 뒤 기존 Action URL 호출 3.응답과 신규 Build 미생성 확인
// 입력값(시트): Event Listener el-delete-test
// 기대결과: Listener 목록에서 삭제, 삭제 URL 응답 data=false 및 신규 Build 미생성
// 비고: 삭제용 Listener 의 연결 Workflow 는 IV3_S35 의 workflow-create-test(없으면 S35 선행 필요). 이 Workflow 는 Image 가 비어 있어 잘못 실행돼도 infra-create 에서 실패한다(자원 미생성)

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openNewEventListener, collectToasts, elByName, deleteEventListener, gotoEventListeners, elRow, workflowBuilds, workflowDetailByName, wfApi } from '../../lib/iv3-wf';

const EL_NAME = 'el-delete-test';
const TARGET_WF = 'workflow-create-test';

test('IV3_S45 TC-WF-018 Event Listener 삭제', async ({ page }) => {
  await loginAsAdmin(page);
  const wf = await workflowDetailByName(page, TARGET_WF);
  test.skip(!wf, `연결할 Workflow ${TARGET_WF} 없음(IV3_S35 선행)`);
  const wfIdx = Number(wf!.workflowInfo.workflowIdx);

  await test.step(`0. 삭제용 Listener ${EL_NAME} 생성(→ ${TARGET_WF})`, async () => {
    if (await elByName(page, EL_NAME)) return;
    const { f, m } = await openNewEventListener(page);
    await m.getByPlaceholder('Enter Event Listener Name').fill(EL_NAME);
    await m.getByRole('button', { name: 'Duplicate Check' }).click();
    await page.waitForTimeout(1500);
    await m.getByPlaceholder('Enter Event Listener Description').fill('delete test');
    await m.locator('select').first().selectOption({ label: TARGET_WF });
    await page.waitForTimeout(1500);
    await m.getByRole('button', { name: 'Create', exact: true }).click();
    const t = await collectToasts(page, f, 5000);
    test.info().annotations.push({ type: 'pre-create', description: t.join(' | ') });
    expect(t.join(' ')).toMatch(/Registered successfully/);
  });
  const el = await elByName(page, EL_NAME);
  const buildsBefore = (await workflowBuilds(page, wfIdx)).length;

  await test.step('1. Delete → 확인 모달 Delete', async () => {
    const t = await deleteEventListener(page, EL_NAME);
    test.info().annotations.push({ type: 'delete', description: t.join(' | ') || '(토스트 없음)' });
    expect(t.join(' ')).toMatch(/Deleted successfully/);
  });

  await test.step('2. 목록에서 제거', async () => {
    const fl = await gotoEventListeners(page);
    await expect(elRow(fl, EL_NAME)).toHaveCount(0);
    expect(await elByName(page, EL_NAME)).toBeUndefined();
  });

  await test.step(`3. 삭제된 URL(${el.eventListenerCallUrl}) GET 호출 → data=false · 신규 Build 없음`, async () => {
    const res = await wfApi(page, 'GET', el.eventListenerCallUrl);
    test.info().annotations.push({ type: 'call-deleted', description: `HTTP ${res.status} ${JSON.stringify(res.body).slice(0, 200)}` });
    expect(res.body?.data, '삭제 URL 응답 data').toBe(false);
    await page.waitForTimeout(30_000);
    const after = (await workflowBuilds(page, wfIdx)).length;
    test.info().annotations.push({ type: 'builds', description: `${TARGET_WF} Build 수 ${buildsBefore} → ${after}` });
    expect(after, '신규 Build 미생성').toBe(buildsBefore);
  });
});

IV3_S46

tests/iv3/IV3_S46.spec.ts · 110줄 · 해시 0b90e9d2 · 시나리오 보기

// IV3_S46: TC-WF-019 Workflow 로 오브젝트 스토리지 활용 [정상, AWS]
// v0.6 TC-ID: 없음(mc-workflow-manager 시트에만 있는 TC, 2026-10-14 추가)
// 화면: Workflows > Workflows → vm-object-storage-data-lab-init RUN → Run Workflow 팝업 → Workflows Status/LOG · Infra Workloads
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-001(IV3_S34) 완료, AWS 연결 및 VM/Object Storage 생성 권한, Infra ID·Bucket 논리 ID 미사용
// 절차: 1.RUN 모달에 VM/Object Storage 값을 설정하고 Run 2.Jenkins 결과와 VM/Bucket 생성·연결 확인
//   (시트 3.CSP 콘솔 S3 업로드 · 4.JupyterLab 접속은 mc-web-console 밖 작업이라 수행하지 않음 — 사용자 결정 2026-10-14, 지침 4-0)
//   5.(정리) vm-object-storage-data-lab-cleanup RUN 으로 VM·Bucket 삭제
// 입력값(시트): NAMESPACE <선택 Project 의 ns_id>=default / INFRA_ID wf-object-storage-test / CSP aws / REGION ap-northeast-2 / CONNECTION_NAME aws-ap-northeast-2
//   ZONE 기본값 / SPEC_ID·IMAGE_ID 현재 목록에서 선택(t3.medium, Ubuntu 22.04 ami-012a353bb3afb92ee — IV3_S24 추천 확인값)
//   OBJECT_STORAGE_PROVIDER aws / REGION ap-northeast-2 / NAMESPACE default / BUCKET bucket-aws-01 / JUPYTER_PORT 8888 / JUPYTER_ALLOWED_CIDR 0.0.0.0/0
// 기대결과: Jenkins SUCCESS 및 VM Running, 지정 Bucket 생성·연결(시트의 적재·JupyterLab 항목은 화면 밖이라 판정 제외)
// 비고: RUN 모달의 Image 칸은 기본이 빈 값(2026-10-14) → 상단 Image 검색 선택으로 지정. 💰 VM·Bucket 과금 → 5 에서 정리

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoWorkflows, openRun, setParam, getParam, pickSearchable, runAndCollectToasts } from '../../lib/workflow';
import { gotoInfraList, infraRow, rowCells, statusOf } from '../../lib/infra';
import { showAllRows, workflowDetailByName, workflowBuilds, failureLines } from '../../lib/iv3-wf';

const INIT = 'vm-object-storage-data-lab-init';
const CLEANUP = 'vm-object-storage-data-lab-cleanup';
const INFRA_ID = 'wf-object-storage-test';
const BUCKET = 'bucket-aws-01';
const NS = 'default';
// 2026-10-16: ami-012a353bb3afb92ee 가 Image 검색에서 'No matches'(1차 실행 실패). 같은 날 AWS Image 검색 결과는 custom image img1(ubuntu-jammy-22.04 server 20260731 기반) 1건뿐
//   → WF_AWS_IMAGE 로 화면에 있는 이미지 지정(실행: WF_AWS_IMAGE=img1)
const IMAGE = process.env.WF_AWS_IMAGE ?? 'ami-012a353bb3afb92ee';

async function waitBuild(page: import('@playwright/test').Page, wfIdx: number, after: number, maxMs: number) {
  const end = Date.now() + maxMs;
  let b: { buildIdx: number; buildLog: string } | undefined;
  while (Date.now() < end) {
    await page.waitForTimeout(30_000);
    b = (await workflowBuilds(page, wfIdx)).find((x) => Number(x.buildIdx) > after);
    if (b && /Finished: \w+/.test(b.buildLog)) break;
  }
  return { build: b, finished: b?.buildLog.match(/Finished: (\w+)/)?.[1] };
}

test('IV3_S46_AWS TC-WF-019 Workflow 로 오브젝트 스토리지 활용', async ({ page }) => {
  test.setTimeout(80 * 60_000);
  await loginAsAdmin(page);
  const init = await workflowDetailByName(page, INIT);
  const cleanup = await workflowDetailByName(page, CLEANUP);
  expect(init && cleanup, 'seed Workflow 2종 존재').toBeTruthy();
  const initIdx = Number(init!.workflowInfo.workflowIdx);
  const cleanIdx = Number(cleanup!.workflowInfo.workflowIdx);
  const lastInit = Math.max(0, ...(await workflowBuilds(page, initIdx)).map((b) => Number(b.buildIdx)));
  let ran = false;

  try {
    await test.step(`1. ${INIT} RUN → 입력값 설정 → Run`, async () => {
      const f = await gotoWorkflows(page);
      await showAllRows(f);
      await openRun(f, INIT);
      await pickSearchable(f, 'VM/K8s Spec', 't3.medium', 'aws+ap-northeast-2+t3.medium');
      await pickSearchable(f, 'Image', IMAGE, IMAGE);
      const vals: Record<string, string> = {
        NAMESPACE: NS, INFRA_ID, CSP: 'aws', REGION: 'ap-northeast-2', CONNECTION_NAME: 'aws-ap-northeast-2',
        OBJECT_STORAGE_PROVIDER: 'aws', OBJECT_STORAGE_REGION: 'ap-northeast-2', OBJECT_STORAGE_NAMESPACE: NS, OBJECT_STORAGE_BUCKET: BUCKET,
        JUPYTER_PORT: '8888', JUPYTER_ALLOWED_CIDR: '0.0.0.0/0',
      };
      for (const [k, v] of Object.entries(vals)) await setParam(f, k, v);
      const keys = [...Object.keys(vals), 'ZONE', 'SPEC_ID', 'IMAGE_ID'];
      const shown: string[] = [];
      for (const k of keys) shown.push(`${k}=${await getParam(f, k).catch(() => '?')}`);
      test.info().annotations.push({ type: 'run-params', description: shown.join(', ') });
      expect(await getParam(f, 'IMAGE_ID')).toBe(IMAGE);
      expect(await getParam(f, 'SPEC_ID')).toContain('t3.medium');
      await runAndCollectToasts(page, f);
      ran = true;
    });

    await test.step('2. Jenkins 결과 SUCCESS · VM Running · Bucket 생성/연결', async () => {
      const { build, finished } = await waitBuild(page, initIdx, lastInit, 45 * 60_000);
      const log = build?.buildLog ?? '';
      test.info().annotations.push({ type: 'build', description: `#${build?.buildIdx ?? '(없음)'} ${finished ?? '(진행 중)'}${finished === 'FAILURE' ? ' / 원인: ' + failureLines(log).join(' / ') : ''}` });
      const bucketLines = log.split(/\r?\n/).filter((l) => /object storage|bucket/i.test(l) && !/curl /.test(l)).slice(0, 6).map((l) => l.slice(0, 200));
      test.info().annotations.push({ type: 'bucket-log', description: bucketLines.join(' / ') || '(없음)' });
      const jupyter = log.split(/\r?\n/).filter((l) => /jupyter|:8888/i.test(l) && /http/i.test(l)).slice(-2).map((l) => l.replace(/token=\w+/g, 'token=***').slice(0, 200));
      test.info().annotations.push({ type: 'jupyter-url', description: `${jupyter.join(' / ') || '(로그에 없음)'} — 접속 확인은 화면 밖 작업이라 수행하지 않음` });
      test.info().annotations.push({ type: 'log-tail', description: log.slice(-1000).replace(/token=\w+/g, 'token=***').replace(/(jupyter token\s*:\s*)\w+/gi, '$1***') }); // 2026-10-16: 'jupyter token : …' 줄도 마스킹
      expect(finished, 'Jenkins 결과').toBe('SUCCESS');
      await gotoInfraList(page);
      const cells = await rowCells(infraRow(page, INFRA_ID).first());
      test.info().annotations.push({ type: 'infra', description: JSON.stringify(cells) });
      expect(statusOf(cells), `${INFRA_ID} 상태`).toMatch(/Running/i);
      expect(log, `Bucket ${BUCKET} 생성/재사용 로그`).toContain(BUCKET);
    });
  } finally {
    if (ran) {
      await test.step(`5. (정리) ${CLEANUP} RUN → VM·Bucket 삭제`, async () => {
        const lastClean = Math.max(0, ...(await workflowBuilds(page, cleanIdx)).map((b) => Number(b.buildIdx)));
        const f = await gotoWorkflows(page);
        await showAllRows(f);
        await openRun(f, CLEANUP).catch(async () => { /* Tumblebug 선택 없는 Workflow 는 상단 선택 영역이 없을 수 있음 */ });
        for (const [k, v] of Object.entries({ NAMESPACE: NS, INFRA_ID, OBJECT_STORAGE_BUCKET: BUCKET, OBJECT_STORAGE_NAMESPACE: NS, OBJECT_STORAGE_PROVIDER: 'aws', OBJECT_STORAGE_REGION: 'ap-northeast-2', OBJECT_STORAGE_DELETE_ENABLED: 'true' })) {
          await setParam(f, k, v).catch(() => test.info().annotations.push({ type: 'cleanup-param-missing', description: k }));
        }
        await runAndCollectToasts(page, f);
        const { build, finished } = await waitBuild(page, cleanIdx, lastClean, 25 * 60_000);
        test.info().annotations.push({ type: 'cleanup', description: `#${build?.buildIdx ?? '(없음)'} ${finished ?? '(진행 중)'}${finished === 'FAILURE' ? ' / ' + failureLines(build!.buildLog).join(' / ') : ''}` });
        await gotoInfraList(page);
        const left = await infraRow(page, INFRA_ID).count();
        test.info().annotations.push({ type: 'cleanup-check', description: `${INFRA_ID} 목록 잔존 ${left}건` });
      });
    }
  }
});

IV3_S47

tests/iv3/IV3_S47.spec.ts · 95줄 · 해시 1a6ba850 · 시나리오 보기

// IV3_S47: Disks 화면 action item 에서 Data Disk attach — disk 와 node 의 AZ 가 다를 때 거부 (예상실패) [AWS] — 사용자 지시 2026-10-14
// 화면: Cloud Resources > Disks 목록 → disk 행 체크 → 'Attach selected Data Disk'(Infra / NodeGroup / Node 선택) / Infra Workloads > mt1 > Node List(Resume·Suspend)
// 시작 상태: admin 로그인, ws01 / default. Infra mt1, node g1-1(Zone ap-northeast-2d). Disks 에 node 와 다른 AZ 의 disk1c(ap-northeast-2a, Available)
// 절차: 0.Attach 모달의 Node 목록은 Running node 만 표시 → g1-1 이 Suspended 면 Resume(사용자 지시, 끝나면 다시 Suspend)
//   1.Disks 에서 disk1c Available 확인 2.disk1c 행 체크 → Attach selected Data Disk → Infra mt1 / NodeGroup g1 / Node g1-1 → Attach
//   3.오류 토스트(autohide 5초 → Attach 전부터 기록)·attach API 응답·팝업 상태 기록 → 팝업이 남아 있으면 Cancel 4.disk1c Available·Attached To '-' 확인
// 기대결과: AZ 가 달라 attach 가 거부되고 원인(AZ 불일치)을 알리는 오류가 표시됨. disk 는 Available 유지(거부 후 팝업이 남는 것은 정상 — 다른 disk 선택용)
// 관련: IV3_S31(같은 거부를 Node Info '+ Attach Disk' 경로로 확인), IV3_S05(같은 화면의 정상 attach)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin, toasts, watchToasts } from '../../lib/ui';
import { diskCells, diskRow, gotoDisks } from '../../lib/infra';
import { gotoInfraList, waitInfraListed, openInfraDetail, readNodeInfo, nodeMenuAction, waitAsyncRequest, waitNodeStatus } from '../../lib/iv4-infra';

const INPUT: Record<string, { infra: string; ng: string; node: string; disk: string } | undefined> = {
  AWS: { infra: 'mt1', ng: 'g1', node: 'g1-1', disk: process.env.TARGET_DISK ?? 'disk1c' },
};

for (const c of activeCsps(["AWS"])) {
  test(`${tid("IV3_S47", c)} ` + "Disks 화면 Data Disk attach — disk 와 node 의 AZ 가 다를 때 거부", async ({ page }) => {
    test.setTimeout(40 * 60_000);
    const inp = INPUT[c.code];
    test.skip(!inp, `${c.name}: 대상 미지정`);
    const { infra, ng, node, disk } = inp!;
    let diskZone = '';
    const api: string[] = [];
    page.on('response', async (r) => {
      if (/GetAllDataDisk|GetDataDisk/.test(r.url())) {
        const t = await r.text().catch(() => ''); const i = t.indexOf(`"${disk}"`);
        const z = i >= 0 ? t.slice(i, i + 3000).match(/"zone"\s*:\s*"([^"]*)"/) : null; if (z) diskZone = z[1];
      } else if (r.request().method() === 'POST' && /attach|datadisk|disk/i.test(r.url())) {
        api.push(`${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, '')} ${(await r.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 300)}`);
      }
    });
    await loginAsAdmin(page);

    // 0. 대상 node 를 Running 으로(Attach 모달 Node 목록은 Running node 만 표시)
    await gotoInfraList(page);
    expect(await waitInfraListed(page, infra), `Infra ${infra} 존재`).toBeTruthy();
    await openInfraDetail(page, infra);
    const n0 = await readNodeInfo(page, ng, node);
    test.info().annotations.push({ type: 'node-before', description: `${node}: ${n0.status} / Zone ${n0.zone ?? ''}` });
    let resumed = false;
    if (!/^Running/i.test(n0.status)) {
      const r = await nodeMenuAction(page, node, 'Resume');
      await waitAsyncRequest(page, r, 15 * 60_000);
      expect(await waitNodeStatus(page, infra, ng, node, /^Running/i), `${node} Running`).toMatch(/^Running/i);
      resumed = true;
    }

    try {
      // 1~2. Disks → disk 체크 → Attach selected Data Disk → 다른 AZ 의 node
      const before = await diskCells(page, disk);
      test.info().annotations.push({ type: 'disk-before', description: `${JSON.stringify(before)} zone(API)=${diskZone}` });
      expect(before.status, `${disk} Available`).toMatch(/available/i);
      await gotoDisks(page);
      await diskRow(page, disk).first().getByRole('checkbox').check();
      await page.locator('[title="Attach selected Data Disk"], [data-bs-original-title="Attach selected Data Disk"]').first().click();
      const m = page.locator('.modal.show').last();
      await expect(m).toBeVisible();
      await m.locator('#attach-disk-mci').selectOption({ label: infra });
      await m.locator('#attach-disk-nodegroup').selectOption({ label: ng });
      const vmOpts = async () => (await m.locator('#attach-disk-vm option').allInnerTexts()).map((s) => s.trim());
      await expect.poll(async () => (await vmOpts()).some((s) => s.startsWith(node)), { message: `Node 목록에 ${node}(Running)`, timeout: 30_000 }).toBe(true);
      const vmLabel = (await vmOpts()).find((s) => s.startsWith(node))!;
      test.info().annotations.push({ type: 'target', description: `${infra} / ${ng} / ${vmLabel} ← ${disk}(zone ${diskZone}) / node Zone ${n0.zone ?? ''}` });
      await m.locator('#attach-disk-vm').selectOption({ label: vmLabel });

      // 3. Attach → 오류 토스트(autohide 5초)는 클릭 전부터 기록
      const readToasts = await watchToasts(page);
      await m.locator('#attach-disk-execute-btn').click();
      const closed = await m.waitFor({ state: 'hidden', timeout: 30_000 }).then(() => true, () => false);
      const msgs = [...await readToasts(), ...(await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean)];
      test.info().annotations.push({ type: 'attach-result', description: `팝업 ${closed ? '닫힘' : '30초 후에도 열림(거부 후 남는 것은 정상)'} / 토스트: ${msgs.join(' | ').slice(0, 400) || '(없음)'}` });
      test.info().annotations.push({ type: 'attach-api', description: api.join(' | ').slice(0, 600) || '(요청 없음)' });
      if (!closed) await m.getByRole('button', { name: 'Cancel' }).click();

      // 4. disk 는 Available 유지 + 거부 사유 표시
      const after = await diskCells(page, disk);
      test.info().annotations.push({ type: 'disk-after', description: JSON.stringify(after) });
      expect(after.status, 'attach 거부 → disk Available 유지').toMatch(/available/i);
      expect(msgs.join(' '), '거부 사유(오류) 표시').toMatch(/fail|error|zone|availability|오류|실패/i);
    } finally {
      // 0 에서 Resume 했으면 원래대로 Suspend
      if (resumed) {
        await gotoInfraList(page);
        await openInfraDetail(page, infra);
        const r = await nodeMenuAction(page, node, 'Suspend');
        await waitAsyncRequest(page, r, 15 * 60_000);
        test.info().annotations.push({ type: 'node-after', description: `${node}: ${await waitNodeStatus(page, infra, ng, node, /^Suspended/i)}(테스트 후 Suspend)` });
      }
    }
  });
}

IV3_S48

tests/iv3/IV3_S48.spec.ts · 52줄 · 해시 9c5cc10c · 시나리오 보기

// IV3_S48: TC-INFRA-020 CSP별 K8s 지원 현황 확인 [정상, 공통] (기능목록 제외 TC)
// v0.6 TC-ID: TC-INFRA-K8S-00 — 기존 정의(mcmp-e2e definitions/01-unit-tests/infra.md, TODO)는 절차만 참고(사용자 결정 2026-10-14)
// 화면: Workloads > K8s Workloads → Add cluster(기본 폼) → Cloud Provider / Region
// 시작 상태: admin(mcmp) 로그인, Workspace=ws01 / Project=p1. CSP Connection 등록
// 절차: 1.Add cluster 2.Provider 별로 한국(또는 기본) Region 선택 3.화면이 부르는 지원 버전 조회(GetAvailableK8sVersion) 응답과 Cluster Version 후보 기록
//   4.노드 이미지 지정 필요 여부: NodeGroup 이미지 칸(Use Default / 검색) 표시 기록 5.Deploy 는 누르지 않음
// 기대결과: Provider 별 지원 K8s 버전 목록 조회(2xx), 이미지 지정 방식 표시
// 비고: 레거시 정의의 k8sClusterInfo · checkK8sNodeImageDesignation 은 2026-10-14 화면에서 호출되지 않음(호출 여부를 기록). KT 는 K8s 미구현(IV4 K8s SKIP 결정)

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoK8s } from '../../lib/iv3-k8s';

const CONN: Record<string, string> = { AWS: 'aws-ap-northeast-2', AZURE: 'azure-koreacentral', GCP: 'gcp-asia-northeast3', ALIBABA: 'alibaba-ap-northeast-2', TENCENT: 'tencent-ap-seoul', NHN: 'nhn-kr1', NCP: 'ncp-kr', IBM: 'ibm-jp-tok', KT: 'kt-kr1' };

test('IV3_S48 TC-INFRA-020 CSP별 K8s 지원 현황 확인', async ({ page }) => {
  test.setTimeout(15 * 60_000);
  const calls: { url: string; req: string; status: number; body: string }[] = [];
  page.on('response', async (r) => {
    if (!/AvailableK8sVersion|K8sClusterInfo|K8sNodeImageDesignation/i.test(r.url())) return;
    calls.push({ url: r.url().replace(/^https?:\/\/[^/]+/, ''), req: r.request().postData() ?? '', status: r.status(), body: (await r.text().catch(() => '')).slice(0, 600) });
  });
  await loginAsAdmin(page);
  await gotoK8s(page, 'p1');
  await page.getByText('Add cluster').first().click();
  await page.locator('#cluster_name_dynamic').waitFor({ timeout: 30_000 });
  const results: Record<string, string> = {};
  for (const [p, conn] of Object.entries(CONN)) {
    await test.step(`${p}: Region [${p}] ${conn} → 지원 버전`, async () => {
      const n0 = calls.length;
      await page.locator('#cluster_provider_dynamic').selectOption(p);
      const region = `[${p}] ${conn}`;
      const has = await expect.poll(() => page.locator('#cluster_region_dynamic').evaluate((s, v) => [...(s as HTMLSelectElement).options].some((o) => o.value === v), region), { timeout: 30_000 }).toBe(true).then(() => true, () => false);
      if (!has) { results[p] = `Region ${region} 없음`; return; }
      await page.locator('#cluster_region_dynamic').selectOption(region);
      await expect.poll(() => calls.slice(n0).some((c) => /AvailableK8sVersion/.test(c.url)), { timeout: 30_000 }).toBe(true).catch(() => {});
      const c = calls.slice(n0).filter((x) => /AvailableK8sVersion/.test(x.url)).at(-1);
      let versions: string[] = [];
      try { versions = (JSON.parse(c?.body ?? '{}').responseData ?? []).map((v: any) => v.name || v.id).filter(Boolean); } catch { /* */ }
      const img = await page.locator('#nodegroup_image_dynamic').isVisible().catch(() => false);
      results[p] = `HTTP ${c?.status ?? '-'} 버전 ${versions.length ? versions.join(',') : '(없음)'} / NodeGroup 이미지 칸 ${img ? '표시(Use Default·검색)' : '없음(클러스터만 생성)'}`;
    });
  }
  test.info().annotations.push({ type: 'support', description: Object.entries(results).map(([k, v]) => `${k}: ${v}`).join(' || ') });
  test.info().annotations.push({ type: 'other-apis', description: calls.filter((c) => !/AvailableK8sVersion/.test(c.url)).map((c) => `${c.status} ${c.url}`).join(', ') || 'k8sClusterInfo · checkK8sNodeImageDesignation 호출 없음' });
  const failed = calls.filter((c) => c.status >= 400);
  expect(failed.map((c) => `${c.status} ${c.req}`), '지원 버전 조회 오류 없음').toEqual([]);
  const supported = Object.entries(results).filter(([k, v]) => k !== 'KT' && /버전 (?!\(없음\))/.test(v)).map(([k]) => k);
  test.info().annotations.push({ type: 'supported', description: supported.join(', ') });
  expect(supported.length, 'K8s 지원 버전이 조회된 CSP').toBeGreaterThan(0);
});

IV3_S49

tests/iv3/IV3_S49.spec.ts · 60줄 · 해시 333eb26c · 시나리오 보기

// IV3_S49: TC-INFRA-021 K8s 이미지 선택 (이미지 지정 필요 CSP) [정상, NHN Cloud]
// v0.6 TC-ID: TC-INFRA-K8S-02 — 기존 spec(mcmp-e2e mc-web-console/specs/web-bug-092-k8s-image-selection.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Workloads > K8s Workloads → Add cluster → NodeGroup 의 Image 칸 검색(돋보기) → 이미지 검색 모달 → 이미지 선택
// 시작 상태: admin(mcmp) 로그인, Workspace=ws01 / Project=p1. NHN Cloud Connection(nhn-kr1)
// 절차: 1.Add cluster → Provider NHN · Region nhn-kr1 2.NodeGroup Spec 검색 → r2.c4m16 적용 3.Image 검색 모달 → 검색 → 이미지 목록 4.이미지 행 선택 → 적용 → Image 칸에 반영 5.Deploy 는 누르지 않음(과금 없음)
// 입력값: provider NHN, region nhn-kr1, Spec r2.c4m16(4 vCPU / 16 GB, lib/data NHN K8s)
// 기대결과: 이미지 검색 2xx, 선택 가능한 이미지 목록 표시, 선택한 이미지가 NodeGroup Image 에 반영
// 비고: NHN 은 노드 이미지 지정이 필요한 CSP(WEB-BUG-092 이력). 이미지 모달은 Spec 선택 후 열린다(validateAndOpenImageModalPmk)

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoK8s, pickSpecInModal } from '../../lib/iv3-k8s';

test('IV3_S49_NHNCloud TC-INFRA-021 K8s 이미지 선택 (이미지 지정 필요 CSP)', async ({ page }) => {
  test.setTimeout(15 * 60_000);
  const img: { status: number; url: string; n: number }[] = [];
  page.on('response', async (r) => {
    if (!/Image/i.test(r.url()) || !/mc-infra-manager/.test(r.url())) return;
    let n = -1; try { const j = await r.json(); n = (j?.responseData?.imageList ?? j?.responseData ?? []).length; } catch { /* */ }
    img.push({ status: r.status(), url: r.url().replace(/^https?:\/\/[^/]+/, ''), n });
  });
  await loginAsAdmin(page);
  await gotoK8s(page, 'p1');
  await test.step('1. Add cluster → NHN · nhn-kr1', async () => {
    await page.getByText('Add cluster').first().click();
    await page.locator('#cluster_provider_dynamic').selectOption('NHN');
    await expect.poll(() => page.locator('#cluster_region_dynamic').evaluate((s) => [...(s as HTMLSelectElement).options].some((o) => o.value === '[NHN] nhn-kr1')), { timeout: 30_000 }).toBe(true);
    await page.locator('#cluster_region_dynamic').selectOption('[NHN] nhn-kr1');
    await page.waitForTimeout(3000);
  });
  await test.step('2. NodeGroup Spec r2.c4m16 적용', async () => {
    await page.locator('#nodegroup_spec_dynamic').locator('xpath=../..').locator('a[data-bs-target="#spec-search-pmk"]').click();
    expect(await pickSpecInModal(page, { spec: 'r2.c4m16', mem: 16, cpu: 4 }), 'Spec r2.c4m16 검색').toBe(true);
    await page.locator('#spec-search-pmk button[onclick*="applySpecInfoPmk"]').click();
    await expect(page.locator('#nodegroup_spec_dynamic')).toHaveValue(/r2\.c4m16/, { timeout: 10_000 });
  });
  await test.step('3. Image 검색 모달 → 검색 → 목록', async () => {
    await page.locator('a[onclick*="validateAndOpenImageModalPmk"]').first().click();
    await page.locator('#image-search-pmk.show').waitFor({ state: 'visible', timeout: 20_000 });
    await page.locator('#image-search-pmk a[onclick*="getRecommendImageInfoPmk"]').first().click();
    const rows = page.locator('#image-table-pmk .tabulator-tableholder .tabulator-row');
    await expect(rows.first()).toBeVisible({ timeout: 120_000 });
    test.info().annotations.push({ type: 'images', description: `API ${img.map((i) => `${i.status} ${i.url.split('/').pop()} ${i.n}건`).join(', ')} / 화면 ${(await page.locator('#image-table-pmk .tabulator-page-counter').textContent().catch(() => '')) ?? ''} / 첫 행 ${(await rows.first().innerText()).replace(/\s+/g, ' ').slice(0, 200)}` });
    expect(img.some((i) => i.status < 300)).toBe(true);
  });
  await test.step('4. 이미지 행 선택 → Apply → Image 칸 반영', async () => {
    const row = page.locator('#image-table-pmk .tabulator-tableholder .tabulator-row').first();
    const rowText = (await row.innerText()).replace(/\s+/g, ' ');
    const imageId = rowText.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? '';
    await row.click();
    // 모달 하단 버튼은 Use Default(applyDefaultImagePmk) · Apply(applyImageInfoPmk) — Apply 를 정확히 지정(2026-10-14)
    await page.locator('#image-search-pmk button[onclick*="applyImageInfoPmk"]').click();
    await expect(page.locator('#nodegroup_image_dynamic')).not.toHaveValue('', { timeout: 15_000 });
    const v = await page.locator('#nodegroup_image_dynamic').inputValue();
    test.info().annotations.push({ type: 'selected', description: `행 ${rowText.slice(0, 120)} → Image 칸 ${v}` });
    expect(v, '선택한 이미지(Use Default 아님)').not.toBe('default');
    if (imageId) expect(v).toContain(imageId);
  });
});
⚠️ **GitHub.com Fallback** ⚠️