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

Test Specs โ€” IV4 Infra / K8s ์ œ์–ด

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

Playwright(TypeScript) spec ์†Œ์Šค์ž…๋‹ˆ๋‹ค. ์‹œ๋‚˜๋ฆฌ์˜ค๋งˆ๋‹ค ## <์‹œ๋‚˜๋ฆฌ์˜ค๋ฒˆํ˜ธ> ์„น์…˜์ด ์žˆ๊ณ  ์•ต์ปค๋Š” #<์‹œ๋‚˜๋ฆฌ์˜ค๋ฒˆํ˜ธ ์†Œ๋ฌธ์ž> ์ž…๋‹ˆ๋‹ค. ๊ณต์šฉ ํ—ฌํผ๋Š” Test-Specs-Common. ๋น„๋ฐ€๋ฒˆํ˜ธ ๋“ฑ์€ ๋งˆ์Šคํ‚นํ–ˆ์Šต๋‹ˆ๋‹ค.

์‹œ๋‚˜๋ฆฌ์˜ค ํŒŒ์ผ ์ค„ ํ•ด์‹œ
IV4_S01 tests/iv4/IV4_S01.spec.ts 79 7a101d88
IV4_S02 tests/iv4/IV4_S02.spec.ts 51 738a1434
IV4_S03 tests/iv4/IV4_S03.spec.ts 74 c2dd181d
IV4_S04 tests/iv4/IV4_S04.spec.ts 51 c607d05b
IV4_S05 tests/iv4/IV4_S05.spec.ts 52 5c97b822
IV4_S06 tests/iv4/IV4_S06.spec.ts 116 969c710b
IV4_S07 tests/iv4/IV4_S07.spec.ts 133 d31bf558
IV4_S08 tests/iv4/IV4_S08.spec.ts 181 ed079740
IV4_S09 tests/iv4/IV4_S09.spec.ts 63 a296c449
IV4_S10 tests/iv4/IV4_S10.spec.ts 63 f1ab3590
IV4_S11 tests/iv4/IV4_S11.spec.ts 154 843bc4e6
IV4_S12 tests/iv4/IV4_S12.spec.ts 85 e7a5e620
IV4_S13 tests/iv4/IV4_S13.spec.ts 91 e725873d
IV4_S14 tests/iv4/IV4_S14.spec.ts 54 afbc3978
IV4_S15 tests/iv4/IV4_S15.spec.ts 47 72fbae64
IV4_S16 tests/iv4/IV4_S16.spec.ts 49 5a2517b7
IV4_S17 tests/iv4/IV4_S17.spec.ts 33 70952fab
IV4_S18 tests/iv4/IV4_S18.spec.ts 45 07d046aa
IV4_S19 tests/iv4/IV4_S19.spec.ts 40 99e3f989
IV4_S20 tests/iv4/IV4_S20.spec.ts 51 e057a45c

IV4_S01

tests/iv4/IV4_S01.spec.ts ยท 79์ค„ ยท ํ•ด์‹œ 7a101d88 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S01: Infra Node ๋ผ์ดํ”„์‚ฌ์ดํด โ‘ : ์ƒ์„ธ(nodegroupยทnode) ํ™•์ธ โ†’ Node Reboot [์ •์ƒ, CSP๋ณ„]
// ํ™”๋ฉด: Infra ๋ชฉ๋ก โ†’ Infra ์„ ํƒ โ†’ Infra Info / Group ํƒญ / Default ํƒญ Node List โ†’ node ์„ ํƒ(Node Info Detail) โ†’ Node List โŒ„ ๋ฉ”๋‰ด 'Reboot' โ†’ ํ™•์ธ ํŒ์—… โ†’ ์ข… ์•„์ด์ฝ˜ Requests ํŒจ๋„
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ ์ƒํƒœ, Workspace=ws01 / Project=default ์„ ํƒ๋œ ์ƒํƒœ์—์„œ ์‹œ์ž‘. IV3 Infra Running
// ์ ˆ์ฐจ (์˜ˆ: AWS ๊ธฐ์ค€): 1.Infra ๋ชฉ๋ก์—์„œ mc1 ์„ ํƒ 2.Infra Info(Name, Status, Provider) ํ™•์ธ 3.nodegroup ํ™•์ธ(g1, nodegroup ์ถ”๊ฐ€ ํ›„ g1b)
//   4.Default ํƒญ Node List ์—์„œ node g1-1 ์„ ํƒ โ†’ Node Info(CSP, Region, Zone, Node Spec, OS, Public/Private IP) ๋ฐ Detail ํƒญ Node Status=Running ํ™•์ธ
//   5.node ์ฒดํฌ โ†’ Node List โŒ„ ๋ฉ”๋‰ด Reboot โ†’ ํ™•์ธ ํŒ์—…('reboot the node') Confirm 6.Requests ํŒจ๋„ 'Node reboot: g1-1' Completed ํ™•์ธ 7.Node Status Running ํ™•์ธ
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: Infraยทnodegroupยทnode ์ƒ์„ธ ์ •๋ณด ํ‘œ์‹œ, Reboot ์š”์ฒญ Completed ํ›„ Running (AWS ๋Š” reboot ์ค‘์—๋„ Running ์œผ๋กœ ํ‘œ์‹œ๋  ์ˆ˜ ์žˆ์Œ)
//
// ๋ผ์ดํ”„์‚ฌ์ดํด ์‹œ๋ฆฌ์ฆˆ: โ‘ Reboot(S01) โ†’ โ‘กSuspend/Resume(S04) โ†’ โ‘ขTerminate(S09) โ†’ โ‘ฃTerminated Node Delete(S10)
// ๊ตฌํ˜„ ๋ฉ”๋ชจ(2026-10-02 ์‚ฌ์ „์ ๊ฒ€): Node List ์นด๋“œ(#tabs-mci-default)์˜ โŒ„ ๋ฉ”๋‰ด Reboot = node ๋Œ€์ƒ(VmReboot, "reboot the node").
// ๋ชฉ๋ก ํˆด๋ฐ” โŒ„ ๋ฉ”๋‰ด Reboot ์€ Infra ์ „์ฒด ๋Œ€์ƒ(MciReboot)์ด๋ผ ์“ฐ์ง€ ์•Š๋Š”๋‹ค. ํ™•์ธ ๋ชจ๋‹ฌ #commonDefaultModal โ†’ Confirm.
// ์ œ์–ด๋Š” ๋น„๋™๊ธฐ ์š”์ฒญ: ์š”์ฒญ ID(mcwc-...) โ†’ ์ข… ์•„์ด์ฝ˜ ํŒจ๋„ "Node reboot: g1-1 ยท Completed" ๋กœ ๊ฒฐ๊ณผ ํ™•์ธ.
// ์„ ํ–‰ Infra ๊ฐ€ ์—†์œผ๋ฉด ์„ ํ–‰ ๋ฏธ์ถฉ์กฑ์œผ๋กœ ์‹คํŒจ ์ฒ˜๋ฆฌ(ensure ๋Š” tools/iv4-probe/ensure-mc1.spec.ts ์—์„œ ๋ณ„๋„ ์ˆ˜ํ–‰).

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups, ADMIN } from '../../lib/data';
import { loginAndSettle } from '../../lib/ui';
import { gotoInfraList, infraRow, infraRowText, waitInfraListed, openInfraDetail, infraInfoStatus, nodeNames, readNodeInfo, nodeMenuAction, waitAsyncRequest, watchInfraStatus } from '../../lib/iv4-infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) { // ์—‘์…€์— ์žˆ๋Š” CSP: AWS, Azure, Alibaba, GCP, Tencent, NHN Cloud, NCP, IBM, KT
  test(`${tid("IV4_S01", c)} ` + "Infra Node ๋ผ์ดํ”„์‚ฌ์ดํด โ‘ : ์ƒ์„ธ(nodegroupยทnode) ํ™•์ธ โ†’ Node Reboot", async ({ page }) => {
    test.setTimeout(20 * 60_000);
    const name = infraName(c);
    const [ng, ngB] = infraNodegroups(c);

    await loginAndSettle(page, ADMIN.id, ADMIN.pw);
    await gotoInfraList(page);

    // 1. ๋ชฉ๋ก์—์„œ Infra ์„ ํƒ(์„ ํ–‰: Running)
    expect(await waitInfraListed(page, name), `์„ ํ–‰ Infra ${name} ์กด์žฌ`).toBeTruthy();
    await expect(infraRow(page, name)).toHaveCount(1);
    const before = await infraRowText(page, name);
    test.info().annotations.push({ type: 'list-before', description: before });
    expect(before, `์„ ํ–‰ Infra ${name} Running`).toMatch(/^Running/);

    // 2. Infra Info
    await openInfraDetail(page, name);
    const infraStatus = await infraInfoStatus(page);
    test.info().annotations.push({ type: 'infra-info', description: `Infra Info [ ${name} ] Status: ${infraStatus}` });
    expect(infraStatus).toMatch(/^Running/);

    // 3. nodegroup: Group ํƒญ ์นด๋“œ g{n}(k) ํ™•์ธ (g{n}b ๋Š” nodegroup ์ถ”๊ฐ€ ์‹œ๋‚˜๋ฆฌ์˜ค ์ดํ›„ ์กด์žฌ)
    await page.getByRole('tab', { name: 'Group' }).click();
    const groups = (await page.locator('#tabs-mci-group li[id^="serverGroup_status_icon_"]').allInnerTexts()).map((t) => t.trim());
    test.info().annotations.push({ type: 'nodegroups', description: groups.join(', ') || '(์—†์Œ)' });
    expect(groups.some((g) => g.startsWith(`${ng}(`)), `nodegroup ${ng} ํ‘œ์‹œ`).toBeTruthy();
    if (!groups.some((g) => g.startsWith(`${ngB}(`))) {
      test.info().annotations.push({ type: 'nodegroup-missing', description: `${ngB} ์—†์Œ (nodegroup ์ถ”๊ฐ€ ์‹œ๋‚˜๋ฆฌ์˜ค ๋ฏธ์ง„ํ–‰ ์ƒํƒœ) โ†’ ${ng} ๋กœ ์ง„ํ–‰` });
    }
    await page.getByRole('tab', { name: 'Default' }).click();

    // 4. node ์ƒ์„ธ: g{n}-1 (์—†์œผ๋ฉด g{n} ์˜ ์ฒซ node)
    const nodes = await nodeNames(page);
    test.info().annotations.push({ type: 'nodes', description: nodes.join(', ') });
    const node = nodes.includes(`${ng}-1`) ? `${ng}-1` : nodes.find((n) => n.startsWith(`${ng}-`))!;
    expect(node, `nodegroup ${ng} ์˜ node`).toBeTruthy();
    const info = await readNodeInfo(page, ng, node);
    expect(info.csp.toLowerCase(), 'Node Info CSP').toContain(c.code.toLowerCase().replace('nhncloud', 'nhn'));
    expect(info.region, 'Node Info Region').not.toBe('');
    expect(info.spec, 'Node Info Node Spec').not.toBe('');
    expect(info.status, 'Detail Node Status').toMatch(/^Running/i);

    // 5~6. Node reboot โ†’ ์š”์ฒญ Completed
    const reqId = await nodeMenuAction(page, node, 'Reboot');
    expect(reqId, '๋น„๋™๊ธฐ ์š”์ฒญ ID(mcwc-...) ๋ฐœ๊ธ‰').toMatch(/^mcwc-/);
    await waitAsyncRequest(page, reqId);

    // 7. Running ํ™•์ธ. AWS EC2 reboot ์€ ์ธ์Šคํ„ด์Šค ์ƒํƒœ๊ฐ€ running ์œผ๋กœ ์œ ์ง€๋˜์–ด ์ „์ด๊ฐ€ ๋ณด์ด์ง€ ์•Š์„ ์ˆ˜ ์žˆ๋‹ค(๊ธฐ๋ก๋งŒ)
    const trail = await watchInfraStatus(page, name, /^Running/);
    const after = await infraRowText(page, name);
    test.info().annotations.push({ type: 'list-after', description: after });
    if (trail.length <= 1) test.info().annotations.push({ type: 'no-transition-seen', description: '3~10์ดˆ ์ฃผ๊ธฐ ๊ด€์ฐฐ์—์„œ Running ์™ธ ์ƒํƒœ ์—†์Œ(AWS reboot ์€ running ์œ ์ง€๊ฐ€ ์ •์ƒ ๋™์ž‘์ผ ์ˆ˜ ์žˆ์Œ)' });
    expect(after).toMatch(/^Running/);
    await openInfraDetail(page, name);
    expect((await readNodeInfo(page, ng, node)).status, 'reboot ํ›„ Node Status').toMatch(/^Running/i);
    await page.waitForTimeout(3000); // ๋…นํ™” ๋งˆ๋ฌด๋ฆฌ
  });
}

IV4_S02

tests/iv4/IV4_S02.spec.ts ยท 51์ค„ ยท ํ•ด์‹œ 738a1434 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S02: [K8s] K8s NodeGroup(K8s node) Scale out (1โ†’2) [์ •์ƒ, CSP๋ณ„]  (Scale in ์€ IV4_S16 ์œผ๋กœ ๋ถ„๋ฆฌ, ์‚ฌ์šฉ์ž ์ง€์‹œ 2026-10-07)
// ๋Œ€์ƒ: K8s NodeGroup / K8s node (Infra ์•„๋‹˜ โ€” Infra NodeGroup scale out ์€ IV4_S07)
// ์‚ฌ์šฉ์ž ์ง€์‹œ(2026-10-05): IV3 K8s ์ง์ ‘ ์ƒ์„ฑ์œผ๋กœ ๋งŒ๋“  k1d / g1d ๋Œ€์ƒ. 2026-10-07 ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ Scale out(S02) / Scale in(S16)์œผ๋กœ ๋ถ„๋ฆฌ โ€” Scale out ์ด ์„ฑ๊ณตํ•˜๋ฉด Scale in ์„ ์ง„ํ–‰ํ•œ๋‹ค.
// ํ™”๋ฉด: K8s Workloads(p1) โ†’ k1d ์„ ํƒ โ†’ NodeGroup List ์—์„œ g1d ์„ ํƒ โ†’ โŒ„ Edit Scaling(#nodegroup-scaling-modal: Desired Node Count -/+, Enable autoscaling, min/max, Apply)
//   โ†’ NodeGroup Info(Desired Node Size, Min/Max) / Node List(i-โ€ฆ node ์นด๋“œ) ๋กœ ํ™•์ธ
// ๋Œ€์ƒ ๋ณ€๊ฒฝ: K8S_CLUSTER / K8S_NG ํ™˜๊ฒฝ๋ณ€์ˆ˜(g1d ๊ฐ€ ์‚ญ์ œ๋œ ๋’ค ์žฌ๊ฒ€์ฆ์€ ๋‹ค๋ฅธ NodeGroup ์œผ๋กœ)

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

const INPUT: Record<string, { cluster: string; ng: string } | undefined> = {
  AWS: { cluster: process.env.K8S_CLUSTER ?? 'k1d', ng: process.env.K8S_NG ?? 'g1d' },
  GCP: { cluster: process.env.K8S_CLUSTER ?? 'k4s', ng: process.env.K8S_NG ?? 'g4s' }, // K8s CSP ์‚ฌ์ดํด(p4, 2026-10-15)
};

async function scaleTo(page: import('@playwright/test').Page, prj: string, cluster: string, ng: string, target: number) {
  await loginAsAdmin(page);
  await gotoK8s(page, prj);
  const before = await selectNodeGroup(page, cluster, ng);
  test.info().annotations.push({ type: 'before', description: `${before.info.slice(0, 400)} / nodes=${before.nodes.join(',')}` });
  expect(before.info, 'NodeGroup Active').toMatch(/Status Active/i);
  // ํŒ๋‹จ์€ Node List ์˜ ์‹ค์ œ node ์ˆ˜ ๊ธฐ์ค€ โ€” scale ํ›„ Desired/Min/Max ํ‘œ์‹œ๊ฐ’์ด ํ•œ์ฐธ ๋’ค์— ๊ฐฑ์‹ ๋จ(2026-10-07: ํ‘œ์‹œ 1 / ์‹ค์ œ 2, ์•ฝ 30๋ถ„ ๋’ค 2/2)
  if (before.nodes.length === target) {
    test.info().annotations.push({ type: 'preexisting', description: `node ์ด๋ฏธ ${target}๊ฐœ(Desired ํ‘œ์‹œ ${before.desired}) โ†’ Apply ์ƒ๋žต, ์ƒํƒœ ํ™•์ธ๋งŒ` });
  } else {
    await editScaling(page, target);
  }
  // node ์ˆ˜๊ฐ€ target ์ด ๋  ๋•Œ๊นŒ์ง€ ํ™•์ธ(์ตœ๋Œ€ 15๋ถ„). Desired ํ‘œ์‹œ๊ฐ’์€ ๊ธฐ๋ก๋งŒ
  let st = before;
  await expect.poll(async () => { await gotoK8s(page, prj); st = await selectNodeGroup(page, cluster, ng); return st.nodes.length; },
    { message: `${ng} node ์ˆ˜ ${target}`, timeout: 15 * 60_000, intervals: [30_000] }).toBe(target);
  test.info().annotations.push({ type: 'desired-display', description: `Desired Node Size ํ‘œ์‹œ ${st.desired} / node ${st.nodes.length}๊ฐœ` });
  test.info().annotations.push({ type: 'after', description: `${st.info.slice(0, 400)} / nodes=${st.nodes.join(',')}` });
  // GKE ๋Š” node ์ˆ˜๊ฐ€ ๋ฐ”๋€ ๋’ค์—๋„ NodeGroup ์ด ํ•œ๋™์•ˆ Updating(2026-10-15) โ€” Active ๋ณต๊ท€๊นŒ์ง€ ์ตœ๋Œ€ 20๋ถ„ ์žฌ์กฐํšŒ
    await expect.poll(async () => { if (/Status Active/i.test(st.info)) return 'Active'; await page.waitForTimeout(30_000); await gotoK8s(page, prj); st = await selectNodeGroup(page, cluster, ng); return st.info.match(/Status (\S+)/)?.[1] ?? '?'; },
      { message: 'NodeGroup Active ๋ณต๊ท€', timeout: 20 * 60_000, intervals: [1000] }).toBe('Active');
}

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  const inp = INPUT[c.code];
  const prj = k8sProject(c);
  // ์ œ๋ชฉ ์ฒซ ๋‹จ์–ด = ๋…นํ™” ํŒŒ์ผ๋ช… (๋Œ€์ƒ ๊ตฌ๋ถ„: [K8s] K8s NodeGroup(K8s node) โ€” Infra NodeGroup scale ์€ IV4_S07)
  test(`IV4_S02_K8sWorkloads_EditScaling-ScaleOut_${c.code} [K8s] K8s NodeGroup scale out 1โ†’2 (K8s node)`, async ({ page }) => {
    test.skip(!inp, `${c.name}: ๋Œ€์ƒ ๋ฏธ์ง€์ •`);
    test.info().annotations.push({ type: 'target', description: `K8s NodeGroup (Infra ์•„๋‹˜): cluster=${inp!.cluster}, nodegroup=${inp!.ng}` });
    await scaleTo(page, prj, inp!.cluster, inp!.ng, 2);
  });
}

IV4_S03

tests/iv4/IV4_S03.spec.ts ยท 74์ค„ ยท ํ•ด์‹œ c2dd181d ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S03: ๋ฉ€ํ‹ฐ CSP Infra ์ œ์–ด: nodegroup๋ณ„ CSP ํ™•์ธ โ†’ Node reboot [์ •์ƒ, ๊ณตํ†ต] โ€” TC-INFRA-049
// ํ™”๋ฉด: Infra ๋ชฉ๋ก โ†’ mc10 ์„ ํƒ โ†’ Infra Info / Group ํƒญ(NodeGroup ์นด๋“œ) / Default ํƒญ Node List โ†’ node ์„ ํƒ(Node Info CSP) โ†’ Node List โŒ„ 'Reboot' โ†’ ํ™•์ธ ํŒ์—… โ†’ ์ข… ์•„์ด์ฝ˜ Requests ํŒจ๋„
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ ์ƒํƒœ, Workspace=ws01 / Project=default ์„ ํƒ๋œ ์ƒํƒœ์—์„œ ์‹œ์ž‘. IV3_S11(TC-WF-008) ์™„๋ฃŒ โ€” mc10 Running
// ์ ˆ์ฐจ: 1.Infra ๋ชฉ๋ก์—์„œ mc10 ์„ ํƒ(Running) 2.Group ํƒญ์—์„œ NodeGroup 8๊ฐœ(g10-<csp>) ํ™•์ธ 3.Default ํƒญ์—์„œ CSP๋ณ„ node g10-<csp>-1 ์„ ํ•˜๋‚˜์”ฉ ์„ ํƒํ•ด Node Info CSP ๊ฐ€ NodeGroup ์ด๋ฆ„์˜ CSP ์™€ ๊ฐ™์€์ง€ ํ™•์ธ
//   4.NodeGroup 1๊ฐœ(๊ธฐ๋ณธ g10-aws, ํ™˜๊ฒฝ๋ณ€์ˆ˜ IV4_S03_NG)์˜ node ๋ฅผ ์ฒดํฌ โ†’ Node List โŒ„ Reboot โ†’ Confirm('reboot the node') 5.Requests ํŒจ๋„ 'Node reboot' Completed 6.nodeยทInfra Running ๋ณต๊ท€ ํ™•์ธ
// ์ž…๋ ฅ๊ฐ’: Infra=mc10 / Workspace=ws01 / Project=default
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: nodegroup๋ณ„ CSP ํ‘œ์‹œ, ์ƒํƒœ ์ „์ด ํ›„ Running ๋ณต๊ท€
// ๋น„๊ณ : 2026-10-16 ๊ตฌํ˜„(stub ๋Œ€์ฒด). Drive ์ ˆ์ฐจ์˜ nodegroup ์ด๋ฆ„ g10a~g10h ๋Š” ์ง์ ‘ ์ƒ์„ฑ ๊ณ„ํš ์ด๋ฆ„ โ€” Workflow(IV3_S11) ์ƒ์„ฑ ๊ฒฐ๊ณผ๋Š” g10-<csp>(์˜ˆ g10-aws)
//   reboot ๋Œ€์ƒ์€ Node List โŒ„ ๋ฉ”๋‰ด(node ๋Œ€์ƒ VmReboot). ๋ชฉ๋ก ํˆด๋ฐ” โŒ„ Reboot ์€ Infra ์ „์ฒด ๋Œ€์ƒ์ด๋ผ ์“ฐ์ง€ ์•Š์Œ(IV4_S01 ๊ณผ ๊ฐ™์Œ)

import { test, expect } from '../../lib/fixtures';
import { tid, ADMIN } from '../../lib/data';
import { loginAndSettle } from '../../lib/ui';
import { gotoInfraList, infraRowText, waitInfraListed, openInfraDetail, infraInfoStatus, nodeNames, readNodeInfo, nodeMenuAction, waitAsyncRequest, watchInfraStatus } from '../../lib/iv4-infra';
import { MC10_NAME, MC10_CSPS, mc10Ng, mc10Node } from '../../lib/iv3-mc10';

test(`${tid('IV4_S03')} ๋ฉ€ํ‹ฐ CSP Infra ์ œ์–ด: nodegroup๋ณ„ CSP ํ™•์ธ โ†’ Node reboot`, async ({ page }) => {
  test.setTimeout(30 * 60_000);
  const name = MC10_NAME;
  const rebootCsp = process.env.IV4_S03_NG?.replace(/^g10-/, '') ?? 'aws';
  await loginAndSettle(page, ADMIN.id, ADMIN.pw);
  await gotoInfraList(page);

  // 1. mc10 (์„ ํ–‰ IV3_S11)
  expect(await waitInfraListed(page, name), `์„ ํ–‰ IV3_S11 ์˜ ${name} ์กด์žฌ`).toBeTruthy();
  const before = await infraRowText(page, name);
  test.info().annotations.push({ type: 'list-before', description: before });
  expect(before, `${name} Running`).toMatch(/^Running/);
  await openInfraDetail(page, name);
  test.info().annotations.push({ type: 'infra-info', description: `Infra Info [ ${name} ] Status: ${await infraInfoStatus(page)}` });

  // 2. Group ํƒญ NodeGroup ์นด๋“œ
  await page.getByRole('tab', { name: 'Group' }).click();
  await page.waitForTimeout(1500);
  const groups = (await page.locator('#tabs-mci-group li[id^="serverGroup_status_icon_"]').allInnerTexts()).map((t) => t.trim());
  test.info().annotations.push({ type: 'nodegroups', description: groups.join(', ') || '(์—†์Œ)' });
  // IV3_S11 ์—์„œ ์ƒ์„ฑ๋˜์ง€ ์•Š์€ NodeGroup(2026-10-16: g10-ncpยทg10-nhn โ€” CSP VPC ์ฟผํ„ฐ)์€ TC-WF-008 ๊ฒฐ๊ณผ๋กœ ๊ธฐ๋กํ•˜๊ณ , ์ด TC ๋Š” ์žˆ๋Š” NodeGroup ์œผ๋กœ ํŒ์ •
  const present = MC10_CSPS.filter((c) => groups.some((g) => g.startsWith(`${mc10Ng(c)}(`)));
  const missing = MC10_CSPS.filter((c) => !present.includes(c));
  test.info().annotations.push({ type: 'nodegroups-missing', description: missing.length ? `${missing.map(mc10Ng).join(', ')} ์—†์Œ(IV3_S11 ๋ฏธ์ƒ์„ฑ)` : '์—†์Œ(8๊ฐœ ๋ชจ๋‘ ์กด์žฌ)' });
  expect(present.length, 'NodeGroup 2๊ฐœ ์ด์ƒ(๋ฉ€ํ‹ฐ CSP)').toBeGreaterThanOrEqual(2);
  await page.getByRole('tab', { name: 'Default' }).click();

  // 3. NodeGroup๋ณ„ CSP
  const nodes = await nodeNames(page);
  test.info().annotations.push({ type: 'nodes', description: nodes.join(', ') });
  const map: string[] = [];
  for (const c of present) {
    const info = await readNodeInfo(page, mc10Ng(c), mc10Node(c));
    map.push(`${mc10Ng(c)} โ†’ CSP ${info.csp} / ${info.region} / ${info.status}`);
    expect(info.csp.toLowerCase(), `${mc10Ng(c)} ์˜ CSP`).toContain(c);
  }
  test.info().annotations.push({ type: 'nodegroup-csp', description: map.join(' | ') });

  // 4~5. NodeGroup 1๊ฐœ์˜ node reboot
  const ng = mc10Ng(rebootCsp);
  const node = mc10Node(rebootCsp);
  const info = await readNodeInfo(page, ng, node);
  expect(info.status, `${node} Running`).toMatch(/^Running/i);
  const reqId = await nodeMenuAction(page, node, 'Reboot');
  expect(reqId, '๋น„๋™๊ธฐ ์š”์ฒญ ID(mcwc-...) ๋ฐœ๊ธ‰').toMatch(/^mcwc-/);
  await waitAsyncRequest(page, reqId);

  // 6. Running ๋ณต๊ท€
  const trail = await watchInfraStatus(page, name, /^Running/);
  const after = await infraRowText(page, name);
  test.info().annotations.push({ type: 'list-after', description: after });
  if (trail.length <= 1) test.info().annotations.push({ type: 'no-transition-seen', description: '๊ด€์ฐฐ ์ฃผ๊ธฐ ์•ˆ์— Running ์™ธ ์ƒํƒœ ์—†์Œ(CSP ์— ๋”ฐ๋ผ reboot ์ค‘์—๋„ Running ์œ ์ง€)' });
  expect(after).toMatch(/^Running/);
  await openInfraDetail(page, name);
  expect((await readNodeInfo(page, ng, node)).status, 'reboot ํ›„ Node Status').toMatch(/^Running/i);
  await page.waitForTimeout(3000); // ๋…นํ™” ๋งˆ๋ฌด๋ฆฌ
});

IV4_S04

tests/iv4/IV4_S04.spec.ts ยท 51์ค„ ยท ํ•ด์‹œ c607d05b ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S04: Infra Node ๋ผ์ดํ”„์‚ฌ์ดํด โ‘ก-1: Suspend โ†’ Suspended ํ™•์ธ [์ •์ƒ, CSP๋ณ„]  (Resume ์€ IV4_S15 ๋กœ ๋ถ„๋ฆฌ, ์‚ฌ์šฉ์ž ์Šน์ธ 2026-10-07)
// ๋Œ€์ƒ: Infra node (K8s node ์•„๋‹˜)
// ํ™”๋ฉด: Infra ๋ชฉ๋ก โ†’ Infra ์„ ํƒ โ†’ Default ํƒญ Node List โ†’ node ์„ ํƒ(Node Info) โ†’ Node List โŒ„ ๋ฉ”๋‰ด 'Suspend' โ†’ ํ™•์ธ ํŒ์—… โ†’ ์ข… ์•„์ด์ฝ˜ Requests ํŒจ๋„
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ ์ƒํƒœ, Workspace=ws01 / Project=default ์„ ํƒ๋œ ์ƒํƒœ์—์„œ ์‹œ์ž‘. IV3 Infra Running
// ์ ˆ์ฐจ (์˜ˆ: AWS ๊ธฐ์ค€): 1.Infra ๋ชฉ๋ก์—์„œ mc1 ์„ ํƒ โ†’ Default ํƒญ Node List ์—์„œ node g1-1 ์„ ํƒ(Node Info ํ™•์ธ) 2.node ์ฒดํฌ โ†’ Node List โŒ„ ๋ฉ”๋‰ด Suspend โ†’ ํ™•์ธ ํŒ์—… Confirm
//   3.Requests ํŒจ๋„ Completed ํ™•์ธ 4.Node Status=Suspended ํ™•์ธ(Node Info) 5.๋ชฉ๋ก Suspended ์—ด +1 ํ™•์ธ(๋ชฉ๋ก ๊ฐฑ์‹ ์ด node ์ƒํƒœ๋ณด๋‹ค ๋Šฆ์œผ๋ฏ€๋กœ ๊ฐฑ์‹ ๋  ๋•Œ๊นŒ์ง€ ์žฌํ™•์ธ)
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: Suspend ํ›„ Suspended ๋กœ ์ „์ด, ๋ชฉ๋ก Suspended ์—ด๊ณผ Node Info Node Status ๊ฐฑ์‹ . **ํ…Œ์ŠคํŠธ๋Š” Suspended ์ƒํƒœ๋กœ ๋๋‚œ๋‹ค**(Resume ์€ IV4_S15, ๋˜๋Š” ๋‹ค์Œ ํ…Œ์ŠคํŠธ ์‹œ์ž‘ ์‹œ Resume)
//
// ๊ตฌํ˜„ ๋ฉ”๋ชจ: Node List โŒ„ ๋ฉ”๋‰ด Suspend/Resume = node ๋Œ€์ƒ(VmSuspend/VmResume, "suspend/resume the node").
//   v07(2026-10-07): ๋ชฉ๋ก์˜ Suspended ์—ด์ด node ์ƒํƒœ(Suspended, 23์ดˆ)๋ณด๋‹ค 1๋ถ„ ์ด์ƒ ๋Šฆ๊ฒŒ ๊ฐฑ์‹ ๋จ(S07 ์‹œ์ ์— ๋ฐ˜์˜) โ†’ ์ฆ‰์‹œ ๋‹จ์ •ํ•˜์ง€ ์•Š๊ณ  ์žฌํ™•์ธ.

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups, ADMIN } from '../../lib/data';
import { loginAndSettle } from '../../lib/ui';
import { gotoInfraList, infraRowText, waitInfraListed, openInfraDetail, nodeNames, readNodeInfo, nodeMenuAction, waitAsyncRequest, waitNodeStatus, infraCounts, refreshInfraList } from '../../lib/iv4-infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) { // ์—‘์…€์— ์žˆ๋Š” CSP: AWS, Azure, Alibaba, GCP, Tencent, NHN Cloud, NCP, IBM, KT
  test(`${tid("IV4_S04", c)} ` + "[Infra] Infra Node ๋ผ์ดํ”„์‚ฌ์ดํด โ‘ก-1: Suspend โ†’ Suspended ํ™•์ธ", async ({ page }) => {
    test.setTimeout(40 * 60_000);
    const name = infraName(c);
    const ng = infraNodegroups(c)[0];
    test.info().annotations.push({ type: 'target', description: `Infra node (K8s ์•„๋‹˜): infra=${name}, nodegroup=${ng}` });

    await loginAndSettle(page, ADMIN.id, ADMIN.pw);
    await gotoInfraList(page);
    expect(await waitInfraListed(page, name), `์„ ํ–‰ Infra ${name} ์กด์žฌ`).toBeTruthy();
    const c0 = await infraCounts(page, name);
    test.info().annotations.push({ type: 'list-before', description: `${await infraRowText(page, name)} / ${c0.cells}` });

    // 1. node ์„ ํƒ โ†’ Node Info (Running)
    await openInfraDetail(page, name);
    const nodes = await nodeNames(page);
    const node = nodes.includes(`${ng}-1`) ? `${ng}-1` : nodes.find((n) => n.startsWith(`${ng}-`))!;
    expect(node, `nodegroup ${ng} ์˜ node`).toBeTruthy();
    expect((await readNodeInfo(page, ng, node)).status, '์‹œ์ž‘ Node Status(์„ ํ–‰: Running โ€” Suspended ์ด๋ฉด IV4_S15 ๋กœ ๋จผ์ € Resume)').toMatch(/^Running/i);

    // 2~4. Suspend โ†’ Completed โ†’ Suspended
    const r1 = await nodeMenuAction(page, node, 'Suspend');
    expect(r1, 'Suspend ๋น„๋™๊ธฐ ์š”์ฒญ ID').toMatch(/^mcwc-/);
    await waitAsyncRequest(page, r1, 15 * 60_000);
    expect(await waitNodeStatus(page, name, ng, node, /^Suspended/i), `${node} Suspended`).toMatch(/^Suspended/i);

    // 5. ๋ชฉ๋ก Suspended ์—ด +1 (๊ฐฑ์‹  ์ง€์—ฐ ๋Œ€๋น„ ์ตœ๋Œ€ 5๋ถ„ ์žฌํ™•์ธ)
    let c1 = await infraCounts(page, name);
    await expect.poll(async () => { await refreshInfraList(page); c1 = await infraCounts(page, name); return c1.suspended; },
      { message: '๋ชฉ๋ก Suspended ์—ด +1', timeout: 5 * 60_000, intervals: [15_000] }).toBe(c0.suspended + 1);
    test.info().annotations.push({ type: 'list-suspended', description: c1.cells });
    await page.waitForTimeout(3000); // ๋…นํ™” ๋งˆ๋ฌด๋ฆฌ
  });
}

IV4_S05

tests/iv4/IV4_S05.spec.ts ยท 52์ค„ ยท ํ•ด์‹œ 5c97b822 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S05: CSP ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ: Infra ์ „์ฒด ์ค‘์ง€ (๋น„์šฉ ๋ฐฉ์ง€, ๋…นํ™” ์„ ํƒ) [์ •์ƒ, CSP๋ณ„] โ€” TC-INFRA-052
// ํ™”๋ฉด: Infra Workloads ๋ชฉ๋ก โ†’ Infra ํ–‰ ์„ ํƒ(Infra Info) โ†’ ๋ชฉ๋ก ์นด๋“œ 'List of Infra' โŒ„ ๋ฉ”๋‰ด Suspend โ†’ ํ™•์ธ ํŒ์—…('Would you like to suspend Infra ?') Confirm โ†’ ๋ชฉ๋ก Status
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ ์ƒํƒœ, Workspace=ws01 / Project=default ์„ ํƒ๋œ ์ƒํƒœ์—์„œ ์‹œ์ž‘. ํ•ด๋‹น CSP ์˜ IV2~IV7 ์™„๋ฃŒ
//
// โ–  '์ „์ฒด ์ค‘์ง€' ๋Œ€์ƒ ๋ฒ”์œ„(2026-10-16 ํ™•์ •, ๊ฒฐ๊ณผ ๋ฌธ์„œ docs/drive-sync/๊ฒฐ๊ณผ_๋ฏธ์‹คํ–‰_์Šคํ…_20261016.md ์™€ ๊ฐ™์Œ)
//   - Workspace ws01 / Project default (namespace default) ์˜ Infra ๋งŒ. ๋‹ค๋ฅธ Project(p1~p8ยทns01ยทsystem)์™€ K8s ํด๋Ÿฌ์Šคํ„ฐ๋Š” ๋Œ€์ƒ ์•„๋‹˜
//   - CSP n ๋ณ„ ๋Œ€์ƒ Infra: mc{n}(IV3_S02 ์ง์ ‘ ์ƒ์„ฑ) ๊ณผ mw{n}(IV3_S01 Workflow ์ƒ์„ฑ) โ€” Drive ์ž…๋ ฅ๊ฐ’ 'Infra=mc1, mw1'(AWS ๊ธฐ์ค€)์˜ CSP๋ณ„ ํ™•์žฅ
//     AWS=mc1ยทmw1, Azure=mc2ยทmw2, Alibaba=mc3ยทmw3, GCP=mc4ยทmw4, Tencent=mc5ยทmw5, NHN=mc6ยทmw6, NCP=mc7ยทmw7, IBM=mc8ยทmw8, KT=mc9ยทmw9
//   - ๋ฉ€ํ‹ฐ CSP Infra mc10 ์€ ๋Œ€์ƒ ์•„๋‹˜(CL_S02 ๋กœ ์‚ญ์ œ). ๊ทธ ๋ฐ–์˜ Infra(mt1 ๋“ฑ)๋„ ๋Œ€์ƒ ์•„๋‹˜. ํ™˜๊ฒฝ๋ณ€์ˆ˜ IV4_S05_INFRAS(์‰ผํ‘œ ๊ตฌ๋ถ„)๋กœ ๋ฐ”๊ฟ€ ์ˆ˜ ์žˆ์Œ
//   - ์ด๋ฏธ Suspended ์ธ Infra ๋Š” ์ œ์–ดํ•˜์ง€ ์•Š๊ณ  ์ƒํƒœ๋งŒ ๊ธฐ๋ก, ๋ชฉ๋ก์— ์—†๋Š” Infra ๋Š” '์—†์Œ' ๊ธฐ๋ก. ๋Œ€์ƒ์ด ํ•˜๋‚˜๋„ ์—†์œผ๋ฉด SKIP
//   - ์‹คํ–‰์€ ์‚ฌ์šฉ์ž/๋ฉ”์ธ ์„ธ์…˜ ์ง€์‹œ๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ(๋‹ค๋ฅธ ์„ธ์…˜์ด ๋งŒ๋“  mc*ยทmw* ๋ฅผ ์ค‘์ง€ํ•˜๋ฏ€๋กœ). 2026-10-16 ์ž‘์„ฑ ์‹œ์ ์—๋Š” ์‹คํ–‰ํ•˜์ง€ ์•Š์Œ
// ์ ˆ์ฐจ (์˜ˆ: AWS ๊ธฐ์ค€): 1.Infra ๋ชฉ๋ก์—์„œ mc1 ์„ ํƒ โ†’ โŒ„ Suspend โ†’ Confirm 2.mw1 ์„ ํƒ โ†’ โŒ„ Suspend โ†’ Confirm 3.๋‘˜ ๋‹ค ๋ชฉ๋ก Status Suspended ํ™•์ธ
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: ๋‹ค๋ฅธ CSP ํ…Œ์ŠคํŠธ ์ง„ํ–‰ ์ค‘ ํ•ด๋‹น CSP VM ์ด ๊ณผ๊ธˆ๋˜์ง€ ์•Š๋„๋ก ์ค‘์ง€(๋ชฉ๋ก Status Suspended, Running ์—ด 0)
// ๋น„๊ณ : 2026-10-16 ๊ตฌํ˜„(stub ๋Œ€์ฒด). Infra ๋‹จ์œ„ Suspend ๋Š” lib/iv3-mc10.ts infraListAction(ํ–‰ ํด๋ฆญ์œผ๋กœ ๋Œ€์ƒ ์ง€์ • + ์ œ์–ด ์š”์ฒญ ๋ณธ๋ฌธ์— Infra ์ด๋ฆ„ ํ™•์ธ)

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, wfInfraName } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { gotoInfraList, waitInfraListed, infraRowText, waitInfraStatus } from '../../lib/iv4-infra';
import { infraListAction } from '../../lib/iv3-mc10';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) { // ์—‘์…€์— ์žˆ๋Š” CSP: AWS, Azure, Alibaba, GCP, Tencent, NHN Cloud, NCP, IBM, KT
  test(`${tid("IV4_S05", c)} ` + "CSP ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ: Infra ์ „์ฒด ์ค‘์ง€ (๋น„์šฉ ๋ฐฉ์ง€, ๋…นํ™” ์„ ํƒ)", async ({ page }) => {
    test.setTimeout(30 * 60_000);
    const targets = (process.env.IV4_S05_INFRAS ?? `${infraName(c)},${wfInfraName(c)}`).split(',').map((s) => s.trim()).filter(Boolean);
    test.info().annotations.push({ type: 'scope', description: `ws01 / default(namespace default) / Infra=${targets.join(',')} (mc10ยทK8sยท๋‹ค๋ฅธ Project ์ œ์™ธ)` });
    await loginAsAdmin(page);
    await gotoInfraList(page);
    const result: string[] = [];
    let handled = 0;
    for (const name of targets) {
      if (!(await waitInfraListed(page, name))) { result.push(`${name}: ๋ชฉ๋ก์— ์—†์Œ`); continue; }
      handled++;
      const before = await infraRowText(page, name);
      // ๋ชฉ๋ก Status ๋Š” Suspend ์™„๋ฃŒ๋ฅผ 'Stopped' ๋กœ ํ‘œ์‹œ(2026-10-16 ํ™•์ธ) โ†’ Suspended/Stopped ๋ฅผ ์ค‘์ง€๋กœ ๋ณธ๋‹ค
      if (/^(Suspended|Stopped)/i.test(before)) { result.push(`${name}: ์ด๋ฏธ ์ค‘์ง€ (${before})`); continue; }
      await infraListAction(page, name, 'Suspend');
      await gotoInfraList(page);
      const after = await waitInfraStatus(page, name, /^(Suspended|Stopped)/i, 15 * 60_000);
      result.push(`${name}: ${before.split(' ')[0]} โ†’ ${after}`);
    }
    test.info().annotations.push({ type: 'result', description: result.join(' | ') });
    test.skip(handled === 0, `๋Œ€์ƒ Infra ์—†์Œ: ${result.join(' | ')}`);
    await gotoInfraList(page);
    for (const name of targets) {
      const row = await infraRowText(page, name);
      if (row) expect(row, `${name} Suspended(Stopped)`).toMatch(/^(Suspended|Stopped)/i);
    }
    await page.waitForTimeout(3000); // ๋…นํ™” ๋งˆ๋ฌด๋ฆฌ
  });
}

IV4_S06

tests/iv4/IV4_S06.spec.ts ยท 116์ค„ ยท ํ•ด์‹œ 969c710b ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S06: Infra Terminal ์ ‘์† (Infra/Node) [์ •์ƒ, CSP๋ณ„] โ€” TC-INFRA-028
// ํ™”๋ฉด: Infra Workloads > Infra ์„ ํƒ > Node Info Public IP ์˜† 'Terminal'(Remote Terminal xterm) / Infra Info ์šฐ์ƒ๋‹จ 'Terminal'(Infra ์ „์ฒด Execute Commands)
//   / Group ํƒญ NodeGroup ์„ ํƒ โ†’ 'Terminal'(NodeGroup ์ „์ฒด Execute Commands)
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ ์ƒํƒœ, Workspace=ws01 / Project=default ์„ ํƒ๋œ ์ƒํƒœ์—์„œ ์‹œ์ž‘. ๋Œ€์ƒ CSP node Running
// ๋Œ€์ƒ Infra(2026-10-16 ์‚ฌ์šฉ์ž ์ง€์‹œ: ๋‹ค๋ฅธ ์„ธ์…˜์ด ๋งŒ๋“  mc1~mc9 ๋Š” ์“ฐ์ง€ ์•Š์Œ, lib/iv3-mc10.ts pickNodeTarget):
//   1) IV3_S01 Workflow ๊ฒฐ๊ณผ mw{n}(ํ•ด๋‹น CSP node Running) 2) ์ด ์„ธ์…˜ IV3_S11 ๊ฒฐ๊ณผ mc10(g10-<csp>-1 Running)
//   3) Running ์ด ์—†์œผ๋ฉด Suspended ์ธ mw{n} โ†’ mc{n} ์„ ํ™”๋ฉด์—์„œ Resume ํ›„ ํ…Œ์ŠคํŠธํ•˜๊ณ  ๋๋‚˜๋ฉด(์‹คํŒจํ•ด๋„) ๋‹ค์‹œ Suspend(์‚ฌ์šฉ์ž ์ง€์‹œ 2026-10-16, lib withResumed)
//   4) ์—†์œผ๋ฉด SKIP(์‚ฌ์ „ ์กฐ๊ฑด ์—†์Œ)
//   Drive ์ž…๋ ฅ๊ฐ’ Infra=mc1 / Node=g1-1 ์€ ๋‹ค๋ฅธ ์„ธ์…˜ Infra ๋ผ ์œ„ ๋Œ€์ƒ์œผ๋กœ ๋Œ€์ฒด(๊ฒฐ๊ณผ ๋ฌธ์„œ ๋น„๊ณ )
// ์ ˆ์ฐจ (์˜ˆ: AWS ๊ธฐ์ค€): 1.Infra ๋ชฉ๋ก์—์„œ ๋Œ€์ƒ Infra ์„ ํƒ 2.Node List ์—์„œ ๋Œ€์ƒ node ์„ ํƒ โ†’ Node Info ์˜ Public IP ์˜† 'Terminal' โ†’ Confirm
//   3.ํ„ฐ๋ฏธ๋„์—์„œ whoami(cb-user)ยทhostname ์‹คํ–‰ 4.Infra Info ์šฐ์ƒ๋‹จ 'Terminal'(Infra ์ „์ฒด ๋Œ€์ƒ) โ†’ Execute Commands ์— hostname โ†’ Execute โ†’ ๊ฒฐ๊ณผ ํ™•์ธ
//   5.Group ํƒญ์—์„œ ๋Œ€์ƒ NodeGroup ์„ ํƒ โ†’ 'Terminal'(NodeGroup ๋Œ€์ƒ) โ†’ hostname โ†’ Execute โ†’ ๊ฒฐ๊ณผ ํ™•์ธ
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: ํ„ฐ๋ฏธ๋„์ด ์—ด๋ฆฌ๊ณ  SSH ์ ‘์† ์„ฑ๊ณต(cb-user), ๋ช…๋ น ์‘๋‹ต ํ‘œ์‹œ(InfraยทNodeGroup ์ผ๊ด„ ์‹คํ–‰ ๊ฒฐ๊ณผ ํฌํ•จ)
// ๋น„๊ณ : 2026-10-16 ๊ตฌํ˜„(stub ๋Œ€์ฒด). Node Terminal ํ—ฌํผ๋Š” IV4_S12 ์™€ ๊ฐ™์€ lib/iv4-infra.ts openNodeTerminal/termRun

import { test, expect } from '../../lib/fixtures';
import type { Page } from '@playwright/test';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { gotoInfraList, openInfraDetail, nodeNames, readNodeInfo, openNodeTerminal, termRun } from '../../lib/iv4-infra';
import { pickNodeTarget, withResumed } from '../../lib/iv3-mc10';

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

/** Infra/NodeGroup ์ผ๊ด„ ๋ช…๋ น: Terminal ๋งํฌ โ†’ Confirm โ†’ #<kind>-cmdtestmodal Execute Commands โ†’ ๊ฒฐ๊ณผ ํ…์ŠคํŠธ */
async function batchCommand(page: Page, kind: 'mci' | 'nodegroup', link: string, cmd: string) {
  await page.locator(`a[onclick*="${link}"]:visible`).first().click();
  const confirm = page.locator('#commonDefaultModal');
  await expect(confirm).toBeVisible();
  note(`${kind}-confirm`, clean(await confirm.innerText()));
  await confirm.locator('#commonDefaultModal-confirm-btn').click();
  const modal = page.locator(`#${kind}-cmdtestmodal`);
  await expect(modal).toBeVisible({ timeout: 20_000 });
  await modal.locator(`#${kind}-command-input`).fill(cmd);
  await page.waitForTimeout(800); // ๋…นํ™”์šฉ
  await modal.locator(`#${kind}-execute-command-btn`).click();
  // ์‹คํ–‰ ํ™•์ธ ๋ชจ๋‹ฌ(confirmCommandExecution)์ด ๋œจ๋ฉด ํ™•์ธ
  // 'Confirm Remote Command'(Target โ€ฆ This will run on N node(s). / Run on N node(s)) โ€” 2026-10-16 ํ™•์ธ
  const run = page.getByRole('button', { name: /^Run on \d+ nodes?$/ }).last();
  await expect(run, `${kind} ์‹คํ–‰ ํ™•์ธ ํŒ์—…`).toBeVisible({ timeout: 15_000 });
  const ask = page.locator('.modal.show').filter({ hasText: 'Confirm Remote Command' }).last();
  note(`${kind}-exec-confirm`, clean(await ask.innerText().catch(() => '')).slice(0, 300));
  await page.waitForTimeout(800); // ๋…นํ™”์šฉ
  await run.click();
  // ๊ฒฐ๊ณผ๋Š” 'Remote Terminal โ€” Execution Result/Output' ๋ชจ๋‹ฌ(Execution Result Summary n/n ยท NODE ID/NODE IP/STATUS/RESULT ํ‘œ, 5ํ–‰ ๋‹จ์œ„ ํŽ˜์ด์ง€)๋กœ ํ‘œ์‹œ(2026-10-16 ํ™•์ธ)
  const res = page.locator('.modal.show').filter({ hasText: 'Execution Result Summary' }).last();
  await expect(res, `${kind} ์‹คํ–‰ ๊ฒฐ๊ณผ ํ‘œ์‹œ`).toBeVisible({ timeout: 180_000 });
  await expect(res, `${kind} ์‹คํ–‰ ์™„๋ฃŒ`).toContainText(/Execution Complete|Complete/i, { timeout: 180_000 });
  await page.waitForTimeout(3000);
  let out = clean(await res.innerText());
  // 2ํŽ˜์ด์ง€ ์ดํ›„ ๊ฒฐ๊ณผ๋„ ์ฝ๋Š”๋‹ค(Next)
  for (let i = 0; i < 3; i++) {
    const next = res.getByText('Next', { exact: true });
    if (!(await next.isVisible().catch(() => false)) || !(await res.getByText(String(i + 2), { exact: true }).isVisible().catch(() => false))) break;
    await next.click(); await page.waitForTimeout(1000);
    out += ' || ' + clean(await res.locator('table').first().innerText().catch(() => ''));
  }
  note(`${kind}-result`, out.slice(0, 1500));
  await page.waitForTimeout(1500); // ๋…นํ™”์šฉ
  await res.getByRole('button', { name: 'Close' }).last().click().catch(() => page.keyboard.press('Escape'));
  await expect(res).toBeHidden({ timeout: 10_000 }).catch(() => {});
  if (await modal.isVisible().catch(() => false)) await modal.getByRole('button', { name: 'Close' }).last().click().catch(() => page.keyboard.press('Escape'));
  return out;
}

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) { // ์—‘์…€์— ์žˆ๋Š” CSP: AWS, Azure, Alibaba, GCP, Tencent, NHN Cloud, NCP, IBM, KT
  test(`${tid("IV4_S06", c)} ` + "Infra Terminal ์ ‘์† (Infra/Node)", async ({ page }) => {
    test.setTimeout(20 * 60_000);
    await loginAsAdmin(page);
    await gotoInfraList(page);
    const { target, seen } = await pickNodeTarget(page, c.n, c.code);
    note('target-candidates', seen);
    test.skip(!target, `์‚ฌ์ „ ์กฐ๊ฑด ์—†์Œ: ${c.name} node ๊ฐ€ Running ์ธ ๋Œ€์ƒ Infra(mw${c.n} ๋˜๋Š” mc10) ์—†์Œ โ€” ${seen}`);
    const t = target!;
    note('target', `${t.infra} / ${t.ng} / ${t.node} (${t.why})`);
    // Suspended ๋Œ€์ƒ์ด๋ฉด Resume โ†’ ํ…Œ์ŠคํŠธ โ†’ ๋‹ค์‹œ Suspend(์‚ฌ์šฉ์ž ์ง€์‹œ 2026-10-16)
    await withResumed(page, t, async () => {
    // 1~2. ๋Œ€์ƒ node โ†’ Node Info Public IP ์˜† Terminal
    await openInfraDetail(page, t.infra);
    expect(await nodeNames(page), `${t.node} ์กด์žฌ`).toContain(t.node);
    const info = await readNodeInfo(page, t.ng, t.node);
    expect(info.status, `${t.node} Running`).toMatch(/^Running/i);
    await openNodeTerminal(page);
    // 3. ์ ‘์† ๊ณ„์ •ยท๋ช…๋ น ์‘๋‹ต
    const who = await termRun(page, 'whoami');
    expect(who, '์ ‘์† ๊ณ„์ • cb-user').toContain('cb-user');
    const host = await termRun(page, 'hostname');
    expect(host.trim(), 'hostname ์‘๋‹ต').not.toBe('');
    const term = page.locator('#cmdtestmodal');
    await term.getByRole('button', { name: 'Close' }).last().click().catch(() => page.keyboard.press('Escape'));
    await expect(term).toBeHidden({ timeout: 10_000 }).catch(() => {});

    // 4. Infra Info ์šฐ์ƒ๋‹จ Terminal(Infra ์ „์ฒด)
    const infraOut = await batchCommand(page, 'mci', 'initMciRemoteCmdModal', 'hostname');
    expect(infraOut, 'Infra ์ผ๊ด„ ์‹คํ–‰ ๊ฒฐ๊ณผ์— ์˜ค๋ฅ˜ ์—†์Œ').not.toMatch(/\bfail(ed)?\b|error/i);
    expect(infraOut.toLowerCase(), `Infra ์ผ๊ด„ ๊ฒฐ๊ณผ์— ๋Œ€์ƒ node(${t.node}) ๋˜๋Š” hostname(${host.trim()})`).toMatch(new RegExp(`${t.node}|${host.trim().split(/\s+/)[0]}`, 'i'));

    // 5. Group ํƒญ โ†’ NodeGroup ์„ ํƒ โ†’ Terminal
    // 'Group' ํƒญ ๋งํฌ๊ฐ€ ์ˆจ์€ ์˜์—ญ์—๋„ ์žˆ์–ด ๋ณด์ด๋Š” ์ƒ์„ธ ํƒญ(href=#tabs-mci-group)์„ ๋ˆ„๋ฅธ๋‹ค(2026-10-16 1์ฐจ ์‹คํŒจ)
    // ๊ฒฐ๊ณผ ๋ชจ๋‹ฌ์„ ๋‹ซ์œผ๋ฉด ์ƒ์„ธ(Infra Info)๊ฐ€ ๋‹ซํžˆ๊ณ  ๋ชฉ๋ก๋งŒ ๋‚จ์Œ โ†’ ๋‹ค์‹œ ์—ฐ๋‹ค
    if (await page.locator('.modal.show').count()) await page.keyboard.press('Escape');
    await openInfraDetail(page, t.infra);
    await page.locator('a[href="#tabs-mci-group"]:visible').first().click();
    await page.waitForTimeout(2000);
    const ngcb = page.locator(`#checkbox_vmGroup_${t.ng}`);
    if (!(await ngcb.isChecked())) await page.locator(`#serverGroup_status_icon_${t.ng}`).click();
    await expect(ngcb, `NodeGroup ${t.ng} ์„ ํƒ`).toBeChecked();
    await page.waitForTimeout(1500);
    const ngOut = await batchCommand(page, 'nodegroup', 'initNodeGroupRemoteCmdModal', 'hostname');
    expect(ngOut, 'NodeGroup ์ผ๊ด„ ์‹คํ–‰ ๊ฒฐ๊ณผ์— ์˜ค๋ฅ˜ ์—†์Œ').not.toMatch(/\bfail(ed)?\b|error/i);
    expect(ngOut.toLowerCase(), `NodeGroup ๊ฒฐ๊ณผ์— ${t.node} ๋˜๋Š” hostname`).toMatch(new RegExp(`${t.node}|${host.trim().split(/\s+/)[0]}`, 'i'));
    });
  });
}

IV4_S07

tests/iv4/IV4_S07.spec.ts ยท 133์ค„ ยท ํ•ด์‹œ d31bf558 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S07: Infra NodeGroup Scale (Scale Group Size) out [์ •์ƒ, CSP๋ณ„]
// ํ™”๋ฉด: Infra Workloads > Infra ์„ ํƒ > ์ƒ์„ธ Group ํƒญ > NodeGroup ์นด๋“œ ์„ ํƒ > Scale Group Size
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ ์ƒํƒœ, Workspace=ws01 / Project=default ์„ ํƒ๋œ ์ƒํƒœ์—์„œ ์‹œ์ž‘. IV3 Infra Running
// ์ ˆ์ฐจ (์˜ˆ: AWS ๊ธฐ์ค€): 1.Infra ๋ชฉ๋ก์—์„œ mc1 ์„ ํƒ 2.Group ํƒญ โ†’ NodeGroup g1 ์„ ํƒ โ†’ Scale Group Size: node 1๊ฐœ ์ถ”๊ฐ€ (scale out) 3.Default ํƒญ Node List ์—์„œ ์‹ ๊ทœ node Running ํ™•์ธ
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: scale out ์‹œ node ์ถ”๊ฐ€ ํ›„ Running, ๋ชฉ๋ก Total Nodes ๊ฐฑ์‹ 
//
// ๊ตฌํ˜„ ๋ฉ”๋ชจ(2026-10-02): Infra ์—๋Š” scale in ๊ธฐ๋Šฅ์ด ์—†๋‹ค(์‚ฌ์šฉ์ž ํ™•์ธ, scale out ๋งŒ). 'Scale Group Size'(#scaleGroupToggle)๋Š” ๋ชจ๋‹ฌ์ด ์•„๋‹ˆ๋ผ
// Group ํƒญ ์•ˆ์— ์ธ๋ผ์ธ ํŒจ๋„(#scaleGroupFormList: โ€“ / ์ฝ๊ธฐ์ „์šฉ ์ˆซ์ž(๊ธฐ๋ณธ 1) / + / Apply, "(Nodes to add)")์„ ์—ฐ๋‹ค โ†’ ๊ฐ’์€ "์ถ”๊ฐ€ํ•  node ์ˆ˜".
// ์‹คํ–‰ํ•  ๋•Œ๋งˆ๋‹ค node ๊ฐ€ ๋Š˜์–ด๋‚œ๋‹ค(๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Œ, ๊ณผ๊ธˆ).
//
// ๋Œ€์ƒ/ํฌ๊ธฐ ์ง€์ •(2026-10-05 ์‚ฌ์šฉ์ž ์š”์ฒญ: mc1 ์˜ GCP nodegroup g1c ๋ฅผ 3 ์œผ๋กœ scale out):
//   TC_VARIANT=<nodegroup ์˜ CSP>  IV4_SCALE_INFRA=<Infra>(๊ธฐ๋ณธ mc{n})  IV4_SCALE_NG=<nodegroup>(๊ธฐ๋ณธ g{n})  IV4_SCALE_TO=<๋ชฉํ‘œ node ์ˆ˜>(๊ธฐ๋ณธ ํ˜„์žฌ+1)
//   ์˜ˆ) TC_VARIANT=gcp IV4_SCALE_INFRA=mc1 IV4_SCALE_NG=g1c IV4_SCALE_TO=3 โ†’ ๋…นํ™” IV4_S07_GCP_scaleout_g1c.webm
//   nodegroup ์˜ ์‹ค์ œ CSP ๊ฐ€ TC_VARIANT ์™€ ๋‹ค๋ฅด๋ฉด ์‹คํŒจํ•œ๋‹ค. ์ถ”๊ฐ€ ์ˆ˜ = IV4_SCALE_ADD, ์—†์œผ๋ฉด ๋ชฉํ‘œ - ํ˜„์žฌ
//   ์˜ˆ) TC_VARIANT=aws IV4_SCALE_INFRA=mc1 IV4_SCALE_NG=g1b IV4_SCALE_ADD=2 โ†’ g1b ์— 2๊ฐœ ์ถ”๊ฐ€
//   ๋‹ค๋ฅธ nodegroup ์ด Suspended ์—ฌ๋„ ์ง„ํ–‰ํ•œ๋‹ค(์„ ํ–‰: ๋Œ€์ƒ nodegroup ์— Running node ์กด์žฌ). ๊ธฐ๋Œ€: ์ถ”๊ฐ€๋œ node ๋งŒํผ Total/Running ์ฆ๊ฐ€.

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups, ADMIN } from '../../lib/data';
import { loginAndSettle } from '../../lib/ui';
import { gotoInfraList, waitInfraListed, openInfraDetail, infraInfoStatus, nodeNames, readNodeInfo, infraCounts, withAsyncRequest, waitAsyncRequest, refreshInfraList } from '../../lib/iv4-infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) { // ์—‘์…€์— ์žˆ๋Š” CSP: AWS, Azure, Alibaba, GCP, Tencent, NHN Cloud, NCP, IBM, KT
  // ๋…นํ™” ํŒŒ์ผ๋ช… = ์ œ๋ชฉ ์ฒซ ๋‹จ์–ด. ๋Œ€์ƒ nodegroup ์„ ์ง€์ •ํ•œ ์‹คํ–‰์€ ์•ก์…˜์„ ๋ถ™์—ฌ ๊ธฐ๋ณธ ๋…นํ™”์™€ ๊ตฌ๋ถ„ํ•œ๋‹ค(์‚ฌ์šฉ์ž ์ง€์‹œ 2026-10-05)
  //   ์˜ˆ) IV4_SCALE_NG=g1c โ†’ results/recordings/IV4_S07_AWS_scaleout_g1c.webm
  const recId = process.env.IV4_SCALE_NG ? `${tid("IV4_S07", c)}_scaleout_${process.env.IV4_SCALE_NG}` : tid("IV4_S07", c);
  test(`${recId} ` + "Infra NodeGroup Scale (Scale Group Size) out", async ({ page }) => {
    test.setTimeout(40 * 60_000);
    const name = process.env.IV4_SCALE_INFRA || infraName(c);
    const ng = process.env.IV4_SCALE_NG || infraNodegroups(c)[0];

    await loginAndSettle(page, ADMIN.id, ADMIN.pw);
    await gotoInfraList(page);

    // 1. ๋ชฉ๋ก์—์„œ Infra ์„ ํƒ
    expect(await waitInfraListed(page, name), `์„ ํ–‰ Infra ${name} ์กด์žฌ`).toBeTruthy();
    const c0 = await infraCounts(page, name);
    test.info().annotations.push({ type: 'list-before', description: c0.cells });
    expect(Number.isFinite(c0.total), `Total Nodes ์—ด ์ฝ๊ธฐ(${c0.cells})`).toBeTruthy();
    await openInfraDetail(page, name);
    test.info().annotations.push({ type: 'infra-info-before', description: await infraInfoStatus(page) });
    const nodesBefore = await nodeNames(page);
    const ngBefore = nodesBefore.filter((n) => n.startsWith(`${ng}-`));
    test.info().annotations.push({ type: 'nodes-before', description: nodesBefore.join(', ') });
    // ์„ ํ–‰: ๋Œ€์ƒ nodegroup ์— Running node ์กด์žฌ
    const ngStatus: string[] = [];
    for (const n of ngBefore) {
      const info = await readNodeInfo(page, ng, n);
      // ๋…นํ™”๋ฒˆํ˜ธ์˜ CSP(TC_VARIANT) ์™€ nodegroup ์‹ค์ œ CSP ๊ฐ€ ๊ฐ™์•„์•ผ ํ•œ๋‹ค(2026-10-05: mc1 ์˜ g1c ๋Š” GCP)
      expect(info.csp.toLowerCase(), `${ng} ์˜ CSP ๊ฐ€ ${c.code} ์—ฌ์•ผ ํ•จ โ†’ TC_VARIANT ๋ฅผ nodegroup CSP ๋กœ ์ง€์ •`).toContain(c.code.toLowerCase().replace('nhncloud', 'nhn'));
      ngStatus.push(`${n}=${info.status}`);
    }
    test.info().annotations.push({ type: 'nodegroup-before', description: `${ng}: ${ngStatus.join(', ')}` });
    // ๊ธฐ์กด node ๊ฐ€ Suspended ์—ฌ๋„ scale out ์€ ์ง„ํ–‰ํ•œ๋‹ค(2026-10-05 ์‚ฌ์šฉ์ž ์š”์ฒญ: g1b-1 Suspended ์ƒํƒœ์—์„œ +2). ์ƒํƒœ๋Š” ๊ธฐ๋ก๋งŒ
    if (!ngStatus.some((s) => /=Running/i.test(s))) test.info().annotations.push({ type: 'nodegroup-no-running', description: `${ng} ์— Running node ์—†์Œ โ†’ ๊ธฐ๋ก๋งŒ ํ•˜๊ณ  ์ง„ํ–‰` });

    // ์ถ”๊ฐ€ ์ˆ˜: IV4_SCALE_ADD(์ถ”๊ฐ€ํ•  node ์ˆ˜) ์šฐ์„ , ์—†์œผ๋ฉด IV4_SCALE_TO(๋ชฉํ‘œ node ์ˆ˜) - ํ˜„์žฌ, ๋‘˜ ๋‹ค ์—†์œผ๋ฉด 1
    const add = process.env.IV4_SCALE_ADD ? Number(process.env.IV4_SCALE_ADD) : Number(process.env.IV4_SCALE_TO || ngBefore.length + 1) - ngBefore.length;
    const target = ngBefore.length + add;
    test.info().annotations.push({ type: 'scale-plan', description: `${ng}: ${ngBefore.length} โ†’ ${target} (์ถ”๊ฐ€ ${add})` });
    expect(add, `์ถ”๊ฐ€ node ์ˆ˜(๋ชฉํ‘œ ${target} - ํ˜„์žฌ ${ngBefore.length})`).toBeGreaterThan(0);

    // 2. Group ํƒญ โ†’ ๋Œ€์ƒ NodeGroup ๋งŒ ์ฒดํฌ โ†’ Scale Group Size: add ๊ฐœ ์ถ”๊ฐ€
    await page.getByRole('tab', { name: 'Group' }).click();
    const grp = page.locator('#tabs-mci-group');
    const card = grp.locator(`#serverGroup_status_icon_${ng}`);
    await expect(card).toBeVisible({ timeout: 20_000 });
    test.info().annotations.push({ type: 'nodegroup-card', description: (await card.innerText()).trim() });
    for (const other of await grp.locator('.vmgroup-checkbox').evaluateAll((els) => els.map((e) => e.id))) {
      if (other === `checkbox_vmGroup_${ng}`) continue;
      const ocb = grp.locator(`#${other}`);
      if (await ocb.isChecked()) await ocb.uncheck();
    }
    const cb = grp.locator(`#checkbox_vmGroup_${ng}`);
    if (!(await cb.isChecked())) await card.click();
    await expect(cb).toBeChecked();
    expect(await grp.locator('.vmgroup-checkbox:checked').evaluateAll((els) => els.map((e) => e.id)), `์ฒดํฌ๋œ nodegroup ์€ ${ng} ํ•˜๋‚˜`).toEqual([`checkbox_vmGroup_${ng}`]);
    await grp.locator('#scaleGroupToggle').click();
    // ํŒจ๋„: #scaleGroupFormList > li [โ€“] [input readonly(๊ธฐ๋ณธ 1)] [+] [Apply] (Nodes to add)  โ†’ ๊ฐ’์€ โ€“/+ ๋ฒ„ํŠผ์œผ๋กœ๋งŒ ์กฐ์ •
    const panel = page.locator('#scaleGroupFormList li').first();
    await expect(panel).toBeVisible();
    const addInput = panel.locator('input');
    for (let i = 0; i < 20 && Number(await addInput.inputValue() || 0) > add; i++) await panel.getByRole('button', { name: 'โ€“' }).click();
    for (let i = 0; i < 20 && Number(await addInput.inputValue() || 0) < add; i++) await panel.getByRole('button', { name: '+' }).click();
    await expect(addInput).toHaveValue(String(add));
    await page.waitForTimeout(1000); // ๋…นํ™”์šฉ
    const reqId = await withAsyncRequest(page, async () => {
      await panel.getByRole('button', { name: 'Apply' }).click();
      await closeAnyConfirm(page);
    });
    if (reqId) await waitAsyncRequest(page, reqId, 20 * 60_000);
    else test.info().annotations.push({ type: 'no-async-request', description: '๋น„๋™๊ธฐ ์š”์ฒญ ID ๋ฏธํ™•์ธ โ†’ node ์ˆ˜๋กœ๋งŒ ํŒ์ •' });

    // 3. ๋ชฉ๋ก Total/Running ์ด add ๋งŒํผ ์ฆ๊ฐ€ํ•  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ
    const wantTotal = c0.total + add;
    const wantRunning = c0.running + add;
    const end = Date.now() + 15 * 60_000;
    let n = { total: NaN, running: NaN, cells: '' } as { total: number; running: number; cells: string };
    while (Date.now() < end) {
      await refreshInfraList(page);
      if (await waitInfraListed(page, name)) {
        n = await infraCounts(page, name);
        if (n.total >= wantTotal && n.running >= wantRunning) break;
      }
      await page.waitForTimeout(15_000);
    }
    test.info().annotations.push({ type: 'list-after', description: n.cells });
    expect(n.total, `Total Nodes ${wantTotal}`).toBe(wantTotal);
    expect(n.running, `Running ${wantRunning}`).toBe(wantRunning);

    // 4. ์ƒ์„ธ: nodegroup ์นด๋“œ ng(target), ์‹ ๊ทœ node Running
    await openInfraDetail(page, name);
    test.info().annotations.push({ type: 'infra-info-after', description: await infraInfoStatus(page) });
    const nodesAfter = await nodeNames(page);
    const added = nodesAfter.filter((x) => x.startsWith(`${ng}-`) && !ngBefore.includes(x));
    test.info().annotations.push({ type: 'nodes-after', description: `${nodesAfter.join(', ')} / ์ถ”๊ฐ€: ${added.join(', ') || '(์—†์Œ)'}` });
    expect(added.length, `${ng} ์— ์‹ ๊ทœ node ${add}๊ฐœ`).toBe(add);
    for (const a of added) expect((await readNodeInfo(page, ng, a)).status, `${a} Running`).toMatch(/^Running/i);
    await page.getByRole('tab', { name: 'Group' }).click();
    await expect(grp.locator(`#serverGroup_status_icon_${ng}`)).toContainText(`${ng}(${target})`);
    await page.waitForTimeout(3000); // ๋…นํ™” ๋งˆ๋ฌด๋ฆฌ
  });
}

/** Apply ๋’ค ํ™•์ธ ๋ชจ๋‹ฌ์ด ๋œจ๋ฉด Confirm (์—†์œผ๋ฉด ํ†ต๊ณผ). ๋„ค์ดํ‹ฐ๋ธŒ confirm ์€ fixtures ๊ฐ€ ์ˆ˜๋ฝ */
async function closeAnyConfirm(page: import('@playwright/test').Page) {
  const btn = page.locator('#commonDefaultModal-confirm-btn');
  if (await btn.isVisible({ timeout: 3000 }).catch(() => false)) {
    test.info().annotations.push({ type: 'confirm-modal', description: (await page.locator('#commonDefaultModal').innerText()).replace(/\s+/g, ' ').trim() });
    await btn.click();
  }
}

IV4_S08

tests/iv4/IV4_S08.spec.ts ยท 181์ค„ ยท ํ•ด์‹œ ed079740 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S08: Scale Policy ๋“ฑ๋ก โ†’ ํ™•์ธ [์ •์ƒ, CSP๋ณ„] โ€” TC-INFRA-032
// ํ™”๋ฉด: Infra Workloads > Infra ์„ ํƒ > ์ƒ์„ธ Policy ํƒญ(Scale Policy List ยท Add Policy โ†’ Create Policy ํผ ยท โŒ„ Delete) / Group ํƒญ(Check Scale Policy)
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ ์ƒํƒœ, Workspace=ws01 / Project=default ์„ ํƒ๋œ ์ƒํƒœ์—์„œ ์‹œ์ž‘. ๋Œ€์ƒ CSP node ๊ฐ€ Running ์ธ Infra
// ๋Œ€์ƒ Infra(2026-10-16 ์‚ฌ์šฉ์ž ์ง€์‹œ: ๋‹ค๋ฅธ ์„ธ์…˜์ด ๋งŒ๋“  mc1~mc9 ๋Š” ์“ฐ์ง€ ์•Š์Œ, lib/iv3-mc10.ts pickNodeTarget):
//   1) IV3_S01 Workflow ๊ฒฐ๊ณผ mw{n}(ํ•ด๋‹น CSP node Running) 2) ์ด ์„ธ์…˜ IV3_S11 ๊ฒฐ๊ณผ mc10(g10-<csp>-1 Running)
//   3) Running ์ด ์—†์œผ๋ฉด Suspended ์ธ mw{n} โ†’ mc{n} ์„ ํ™”๋ฉด์—์„œ Resume ํ›„ ํ…Œ์ŠคํŠธํ•˜๊ณ  ๋๋‚˜๋ฉด(์‹คํŒจํ•ด๋„) ๋‹ค์‹œ Suspend(์‚ฌ์šฉ์ž ์ง€์‹œ 2026-10-16, lib withResumed)
//   4) ์—†์œผ๋ฉด SKIP(์‚ฌ์ „ ์กฐ๊ฑด ์—†์Œ)
//   Drive ์ž…๋ ฅ๊ฐ’ Infra=mc1 / NG=g1 ์€ ๋‹ค๋ฅธ ์„ธ์…˜ Infra ๋ผ ์œ„ ๋Œ€์ƒ์œผ๋กœ ๋Œ€์ฒด(๊ฒฐ๊ณผ ๋ฌธ์„œ ๋น„๊ณ )
// ์ ˆ์ฐจ (์˜ˆ: AWS ๊ธฐ์ค€): 1.Infra ๋ชฉ๋ก์—์„œ ๋Œ€์ƒ Infra ์„ ํƒ โ†’ Policy ํƒญ(๊ธฐ์กด Policy ์—†์Œ ํ™•์ธ)
//   2.'Add Policy' โ†’ Create Policy: Policy name=sp{n}, Metric=CPU Usage, Operator='>', Operand=99, Node Name=sp{n},
//     Spec/Image = ๊ฒ€์ƒ‰ ํŒ์—…(Node/Image Recommendation)์œผ๋กœ CSP ๋ณ„ Add Infra ์™€ ๊ฐ™์€ ๊ฐ’(lib/infra.ts INFRA_INPUT), NodeGroup Size=1 โ†’ Deploy
//   3.Scale Policy List(Infra Name, Infra ID, NodeGroup Size, Condition, Period(s), Action, Status)์— ๋Œ€์ƒ Infra ํ–‰ ํ‘œ์‹œ ํ™•์ธ
//   4.Group ํƒญ โ†’ NodeGroup ์„ ํƒ โ†’ 'Check Scale Policy' โ†’ Policy ํƒญ์œผ๋กœ ์ด๋™ํ•ด ๊ฐ™์€ ์ •์ฑ… ํ‘œ์‹œ ํ™•์ธ
//   5.์ •์ฑ… ํ–‰ ์„ ํƒ โ†’ โŒ„ Delete โ†’ Confirm โ†’ ๋ชฉ๋ก์—์„œ ์ œ๊ฑฐ ํ™•์ธ
// ์ž…๋ ฅ๊ฐ’: ์กฐ๊ฑด cpu > 99(์‹ค์ œ Scale Out ์ด ์ผ์–ด๋‚˜์ง€ ์•Š๊ฒŒ โ€” ๊ณผ๊ธˆ ๋ฐฉ์ง€), ์ฃผ๊ธฐ ๊ธฐ๋ณธ๊ฐ’(10์ดˆ, ํผ์— ์ž…๋ ฅ๋ž€ ์—†์Œ), ๋™์ž‘ ScaleOut(๊ณ ์ •)
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: ์ •์ฑ…์ด ๋ชฉ๋ก์— ๋“ฑ๋กยทํ‘œ์‹œ๋˜๊ณ  Check Scale Policy ๋กœ ํ™•์ธ, ์‚ญ์ œ ์‹œ ๋ชฉ๋ก์—์„œ ์ œ๊ฑฐ
// ๋น„๊ณ : 2026-10-16 ๊ตฌํ˜„(stub ๋Œ€์ฒด). ํ™”๋ฉด์— NodeGroup ์ง€์ •๋ž€์ด ์—†์Œ โ€” Scale Policy ๋Š” Infra ๋‹จ์œ„(Tumblebug Infra Policy), Node Name ์€ Scale Out ๋•Œ ๋งŒ๋“ค NodeGroup ์ด๋ฆ„

import { test, expect } from '../../lib/fixtures';
import type { Page } from '@playwright/test';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { INFRA_INPUT } from '../../lib/infra';
import { gotoInfraList, openInfraDetail } from '../../lib/iv4-infra';
import { pickNodeTarget, withResumed } from '../../lib/iv3-mc10';

// mc10 ์€ CSP ๊ฐ€ ๊ฐ™์€ Infra ๋ฅผ ๊ณต์œ (Infra ๋‹น Policy 1๊ฐœ) โ†’ ์ˆœ์ฐจ ์‹คํ–‰ ์ „์ œ(playwright.config workers: 1). ๊ฐ CSP ํ…Œ์ŠคํŠธ๊ฐ€ ๋์— Policy ๋ฅผ ์ง€์šด๋‹ค

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

async function policyRows(page: Page, infra: string) {
  const rows = page.locator('#policy-table .tabulator-row').filter({ has: page.locator('[tabulator-field="mciId"]', { hasText: new RegExp(`^\\s*${infra}\\s*$`) }) });
  const out: Record<string, string>[] = [];
  for (let i = 0; i < (await rows.count()); i++) {
    out.push(await rows.nth(i).locator('[tabulator-field]').evaluateAll((els) => Object.fromEntries(els.map((e) => [e.getAttribute('tabulator-field') ?? '', (e.textContent ?? '').trim()]))));
  }
  return { rows, out };
}

async function showPolicyTab(page: Page) {
  await page.locator('a[href="#tabs-mci-policy"]:visible').first().click();
  await page.locator('#policy-table').waitFor({ timeout: 20_000 });
  await page.waitForTimeout(3000); // GetAllInfraPolicy ์‘๋‹ต
}

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) { // ์—‘์…€์— ์žˆ๋Š” CSP: AWS, Azure, Alibaba, GCP, Tencent, NHN Cloud, NCP, IBM, KT
  test(`${tid("IV4_S08", c)} ` + "Scale Policy ๋“ฑ๋ก โ†’ ํ™•์ธ", async ({ page, dialogs }) => {
    test.setTimeout(20 * 60_000);
    const inp = INFRA_INPUT[c.code];
    test.skip(!inp, `${c.name}: Spec/Image ๊ฒ€์ƒ‰ ์ž…๋ ฅ๊ฐ’ ๋ฏธํ™•์ •`);
    await loginAsAdmin(page);
    await gotoInfraList(page);
    const { target, seen } = await pickNodeTarget(page, c.n, c.code);
    note('target-candidates', seen);
    test.skip(!target, `์‚ฌ์ „ ์กฐ๊ฑด ์—†์Œ: ${c.name} node ๊ฐ€ Running ์ธ ๋Œ€์ƒ Infra(mw${c.n} ๋˜๋Š” mc10) ์—†์Œ โ€” ${seen}`);
    const t = target!;
    note('target', `${t.infra} / ${t.ng} / ${t.node} (${t.why})`);
    const name = `sp${c.n}`;
    // Suspended ๋Œ€์ƒ์ด๋ฉด Resume โ†’ ํ…Œ์ŠคํŠธ โ†’ ๋‹ค์‹œ Suspend(์‚ฌ์šฉ์ž ์ง€์‹œ 2026-10-16)
    await withResumed(page, t, async () => {
    // 1. ๋Œ€์ƒ Infra โ†’ Policy ํƒญ
    await openInfraDetail(page, t.infra);
    await showPolicyTab(page);
    const before = await policyRows(page, t.infra);
    note('policy-before', JSON.stringify(before.out));
    test.skip(before.out.length > 0, `${t.infra} ์— ๊ธฐ์กด Policy ์žˆ์Œ(๋‹ค๋ฅธ ์‹คํ–‰ ์ž”์—ฌ ๊ฐ€๋Šฅ) โ†’ ์†๋Œ€์ง€ ์•Š์Œ: ${JSON.stringify(before.out)}`);

    // 2. Add Policy โ†’ Create Policy
    await page.locator('#tabs-mci-policy a[href="#addpolicy"]').first().click();
    const form = page.locator('#addpolicy');
    await expect(form.locator('#policy_name')).toBeVisible({ timeout: 15_000 });
    await form.locator('#policy_name').fill(name);
    await form.locator('#policy_desc').fill(`IV4_S08 ${c.code} e2e`);
    await form.locator('#policy_ep_metric').selectOption('cpu');
    await form.locator('#policy_ep_operator').selectOption('>');
    await form.locator('#policy_ep_operand').fill('99');
    await form.locator('#policy_ep_name').fill(name);
    // Spec: Node Recommendation(#spec-search) โ€” Add Infra ์™€ ๊ฐ™์€ ํŒ์—…, Apply ์‹œ policy_ep_* ์— ์ฑ„์›Œ์ง(2026-10-16 ํƒ์ƒ‰)
    await form.locator('#policy_ep_specId').locator('xpath=following::a[1]').click();
    const sm = page.locator('#spec-search');
    await expect(sm).toBeVisible();
    if (inp!.priority) await sm.locator('#assistRecommendSpecConnectionName').selectOption(inp!.priority);
    await sm.locator(`#spec-provider-${inp!.provider}`).check();
    await sm.locator('#assist_min_memory').fill(String(Math.floor(inp!.mem)));
    await sm.locator('#assist_max_memory').fill(String(Math.ceil(inp!.mem)));
    await sm.locator('#assist_min_cpu').fill(String(Math.floor(inp!.cpu)));
    await sm.locator('#assist_max_cpu').fill(String(Math.ceil(inp!.cpu)));
    await sm.locator('a[onclick*="getRecommendVmInfo"]').click();
    const specRow = sm.locator('.tabulator-row')
      .filter({ has: page.locator('[tabulator-field="connectionName"]', { hasText: `${inp!.provider}-${inp!.region}` }) })
      .filter({ hasText: inp!.spec }).first();
    await expect(specRow, `Spec ์ถ”์ฒœ ๊ฒฐ๊ณผ์— ${inp!.spec}(${inp!.region})`).toBeVisible({ timeout: 60_000 });
    await specRow.click();
    await sm.getByRole('button', { name: 'Apply' }).click();
    await expect(form.locator('#policy_ep_specId')).toHaveValue(inp!.spec);
    // Image: Image Recommendation(#image-search)
    await form.locator('a[onclick*="validateAndOpenImageModal"]').first().click();
    const im = page.locator('#image-search');
    await expect(im).toBeVisible();
    await im.locator('#assist_os_type').fill(inp!.os);
    await im.locator('a[onclick*="getRecommendImageInfo"]').click();
    await im.locator('.tabulator-row').first().waitFor({ timeout: 60_000 });
    const wanted = inp!.image ? im.locator('.tabulator-row').filter({ hasText: inp!.image }) : im.locator('.tabulator-row').filter({ hasNot: page.locator('[tabulator-field="isGPUImage"]', { hasText: 'โœ“' }) });
    const imgRow = (await wanted.count()) ? wanted.first() : im.locator('.tabulator-row').first();
    note('image-row', clean(await imgRow.innerText()).slice(0, 200));
    await imgRow.click();
    const cb = imgRow.locator('input[type="checkbox"]').first();
    if ((await cb.count()) && !(await cb.isChecked())) await cb.check();
    await im.getByRole('button', { name: 'Apply' }).click();
    await expect(form.locator('#policy_ep_imageId_input')).not.toHaveValue('', { timeout: 10_000 });
    const vals = await form.locator('input:not([type=hidden]), select, input[type=hidden]').evaluateAll((els) => els.filter((e) => e.id).map((e) => `${e.id}=${(e as HTMLInputElement).value}`));
    note('policy-form', vals.join(', '));
    await page.waitForTimeout(1500); // ๋…นํ™”์šฉ

    // Deploy โ†’ CreateInfraPolicy ์‘๋‹ต ๊ธฐ๋ก
    const api: string[] = [];
    const onResp = async (r: import('@playwright/test').Response) => { if (/Policy/i.test(r.url())) api.push(`${r.status()} ${r.url().split('/api/')[1]} ${(await r.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 300)}`); };
    page.on('response', onResp);
    await form.locator('button[onclick*="deployPolicy"]').click();
    await expect.poll(() => dialogs.join(' | '), { timeout: 60_000, message: 'Policy ์ƒ์„ฑ ๊ฒฐ๊ณผ ์•Œ๋ฆผ' }).toMatch(/Policy creation|Error|required|Failed/i);
    await page.waitForTimeout(4000);
    note('deploy', `์•Œ๋ฆผ: ${dialogs.join(' | ')} / API: ${api.join(' || ')}`);
    expect(dialogs.join(' '), 'Policy creation completed.').toMatch(/Policy creation completed/i);

    // 3. Scale Policy List
    await showPolicyTab(page);
    let after = await policyRows(page, t.infra);
    for (let i = 0; i < 3 && !after.out.length; i++) { await openInfraDetail(page, t.infra); await showPolicyTab(page); after = await policyRows(page, t.infra); }
    note('policy-list', JSON.stringify(after.out));
    expect(after.out.length, `Scale Policy List ์— ${t.infra} ํ–‰`).toBeGreaterThan(0);
    const row = after.out[0];
    expect(row.mciName || row.mciId, 'Infra Name/ID').toContain(t.infra);
    expect(row.condition ?? '', 'Condition(cpu > 99)').toMatch(/cpu.*>.*99/i);
    expect(row.actionType ?? '', 'Action ScaleOut').toMatch(/ScaleOut/i);

    // 4. Group ํƒญ โ†’ NodeGroup ์„ ํƒ โ†’ Check Scale Policy
    await page.locator('a[href="#tabs-mci-group"]:visible').first().click();
    await page.waitForTimeout(2000);
    const ngcb = page.locator(`#checkbox_vmGroup_${t.ng}`);
    if (await ngcb.count()) { if (!(await ngcb.isChecked())) await page.locator(`#serverGroup_status_icon_${t.ng}`).click(); }
    note('group-tab', clean(await page.locator('#tabs-mci-group .card-header').first().innerText()).slice(0, 200) + ` / NodeGroup ${t.ng} ์„ ํƒ=${await ngcb.isChecked().catch(() => false)}`);
    await page.waitForTimeout(1500);
    await page.locator('#checkScalePolicy').click();
    await expect(page.locator('#tabs-mci-policy')).toBeVisible({ timeout: 10_000 });
    await page.waitForTimeout(3000);
    const viaCheck = await policyRows(page, t.infra);
    note('check-scale-policy', `Policy ํƒญ์œผ๋กœ ์ด๋™, ${t.infra} ํ–‰ ${viaCheck.out.length}๊ฐœ: ${JSON.stringify(viaCheck.out)}`);
    expect(viaCheck.out.length, 'Check Scale Policy ๊ฒฐ๊ณผ์— ์ •์ฑ… ํ‘œ์‹œ').toBeGreaterThan(0);
    // ํ–‰ ํด๋ฆญ โ†’ ์ƒ์„ธ(policy_info)
    await viaCheck.rows.first().locator('[tabulator-field="mciId"]').click();
    await page.waitForTimeout(2000);
    const info = page.locator('#policy_info');
    if (await info.isVisible().catch(() => false)) note('policy-detail', clean(await info.innerText()).slice(0, 600));

    // 5. ์‚ญ์ œ: ํ–‰ ์„ ํƒ โ†’ โŒ„ Delete โ†’ Confirm
    await showPolicyTab(page);
    const del = await policyRows(page, t.infra);
    const sel = del.rows.first().locator('.tabulator-cell').first();
    await sel.click();
    await page.waitForTimeout(800);
    const n0 = dialogs.length;
    await page.locator('#tabs-mci-policy .dropdown').filter({ hasText: 'Delete' }).locator('[data-bs-toggle="dropdown"]').first().click();
    await page.locator('#tabs-mci-policy .dropdown-menu.show .dropdown-item', { hasText: 'Delete' }).first().click();
    const modal = page.locator('#commonDefaultModal');
    await expect(modal).toBeVisible();
    note('delete-modal', clean(await modal.innerText()).slice(0, 200));
    await modal.locator('#commonDefaultModal-confirm-btn').click();
    await expect.poll(() => dialogs.slice(n0).join(' | '), { timeout: 60_000, message: 'Policy ์‚ญ์ œ ๊ฒฐ๊ณผ ์•Œ๋ฆผ' }).toMatch(/deletion|delete|select/i);
    note('delete', `์•Œ๋ฆผ: ${dialogs.slice(n0).join(' | ')}`);
    expect(dialogs.slice(n0).join(' '), 'Policy deletion completed.').toMatch(/Policy deletion completed/i);
    await openInfraDetail(page, t.infra);
    await showPolicyTab(page);
    const gone = await policyRows(page, t.infra);
    note('policy-after-delete', JSON.stringify(gone.out));
    expect(gone.out.length, '์‚ญ์ œ ํ›„ ๋ชฉ๋ก์—์„œ ์ œ๊ฑฐ').toBe(0);
    page.off('response', onResp);
    });
  });
}

IV4_S09

tests/iv4/IV4_S09.spec.ts ยท 63์ค„ ยท ํ•ด์‹œ a296c449 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S09: Infra Node ๋ผ์ดํ”„์‚ฌ์ดํด โ‘ข: Node Terminate [์ •์ƒ, CSP๋ณ„]
// ํ™”๋ฉด: Infra ๋ชฉ๋ก โ†’ Infra ์„ ํƒ โ†’ Default ํƒญ Node List โ†’ node ์„ ํƒ(Node Info) โ†’ Node List โŒ„ ๋ฉ”๋‰ด 'Terminate' โ†’ ํ™•์ธ ํŒ์—… โ†’ ์ข… ์•„์ด์ฝ˜ Requests ํŒจ๋„
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ ์ƒํƒœ, Workspace=ws01 / Project=default ์„ ํƒ๋œ ์ƒํƒœ์—์„œ ์‹œ์ž‘. IV4_S07 ์™„๋ฃŒ(scale out ์œผ๋กœ ์ถ”๊ฐ€๋œ node g1-2 Running)
// ์ ˆ์ฐจ (์˜ˆ: AWS ๊ธฐ์ค€): 1.Infra ๋ชฉ๋ก์—์„œ mc1 ์„ ํƒ 2.Default ํƒญ Node List ์—์„œ IV4_S07 ๋กœ ์ถ”๊ฐ€๋œ node g1-2 ์„ ํƒ โ†’ Node Info(CSP, Region, Node Spec, Node Status=Running) ํ™•์ธ
//   3.node ์ฒดํฌ โ†’ Node List โŒ„ ๋ฉ”๋‰ด Terminate โ†’ ํ™•์ธ ํŒ์—…('terminate the node') Confirm 4.Requests ํŒจ๋„ Completed ํ™•์ธ
//   5.g1-2 Node Status=Terminated, ๋ชฉ๋ก Terminated ์—ด +1 ํ™•์ธ 6.g1-1 ์€ Running ์œ ์ง€ ํ™•์ธ
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: ๋Œ€์ƒ node ๋งŒ Terminated ๋กœ ์ „์ด, ๋‚˜๋จธ์ง€ node ๋Š” Running ์œ ์ง€
//
// ์•ˆ์ „: ๋Œ€์ƒ์€ g{n}-1 ์ด ์•„๋‹Œ node(IV4_S07 ์ถ”๊ฐ€๋ถ„)๋งŒ. ์—†์œผ๋ฉด ์„ ํ–‰ ๋ฏธ์ถฉ์กฑ์œผ๋กœ ์‹คํŒจ(g{n}-1 ์„ ๋Œ€์‹  ์ง€์šฐ์ง€ ์•Š๋Š”๋‹ค).
// nodeMenuAction ์ด ๋Œ€์ƒ ์™ธ ์ฒดํฌ๋ฅผ ๋ชจ๋‘ ํ•ด์ œํ•˜๊ณ  "์ฒดํฌ๋œ node ๊ฐ€ ๋Œ€์ƒ ํ•˜๋‚˜"์ž„์„ ํ™•์ธํ•œ ๋’ค ์‹คํ–‰ํ•œ๋‹ค.
// ๋ชฉ๋ก(List of Infra) โŒ„ ๋ฉ”๋‰ด Terminate ๋Š” Infra ์ „์ฒด ๋Œ€์ƒ(MciTerminate) โ†’ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค. 2026-10-02 ๊ธฐ์ค€ ๋ฏธ์‹คํ–‰.

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups, ADMIN } from '../../lib/data';
import { loginAndSettle } from '../../lib/ui';
import { gotoInfraList, waitInfraListed, openInfraDetail, nodeNames, readNodeInfo, nodeMenuAction, waitAsyncRequest, waitNodeStatus, infraCounts } from '../../lib/iv4-infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) { // ์—‘์…€์— ์žˆ๋Š” CSP: AWS, Azure, Alibaba, GCP, Tencent, NHN Cloud, NCP, IBM, KT
  test(`${tid("IV4_S09", c)} ` + "Infra Node ๋ผ์ดํ”„์‚ฌ์ดํด โ‘ข: Node Terminate", async ({ page }) => {
    test.setTimeout(30 * 60_000);
    const name = infraName(c);
    const ng = infraNodegroups(c)[0];
    const keep = `${ng}-1`;

    await loginAndSettle(page, ADMIN.id, ADMIN.pw);
    await gotoInfraList(page);
    expect(await waitInfraListed(page, name), `์„ ํ–‰ Infra ${name} ์กด์žฌ`).toBeTruthy();
    const c0 = await infraCounts(page, name);
    test.info().annotations.push({ type: 'list-before', description: c0.cells });

    // 1~2. ๋Œ€์ƒ node: IV4_S07 ๋กœ ์ถ”๊ฐ€๋œ g{n}-k (kโ‰ฅ2) ์ค‘ Running ์ธ ๋งˆ์ง€๋ง‰ ๊ฒƒ
    await openInfraDetail(page, name);
    const nodes = await nodeNames(page);
    test.info().annotations.push({ type: 'nodes', description: nodes.join(', ') });
    const candidates = nodes.filter((n) => n.startsWith(`${ng}-`) && n !== keep);
    expect(candidates.length, `์„ ํ–‰: IV4_S07 ๋กœ ์ถ”๊ฐ€๋œ node ์กด์žฌ (${ng}-1 ์™ธ)`).toBeGreaterThan(0);
    const target = candidates[candidates.length - 1];
    // ๋Œ€์ƒ ์™ธ node ๋Š” '์˜ํ–ฅ ์—†์Œ' = Terminate ์ „ํ›„ ์ƒํƒœ ๋™์ผ(Running ์ด์–ด์•ผ ํ•œ๋‹ค๋Š” ๊ฐ€์ •์€ ํ•˜์ง€ ์•Š๋Š”๋‹ค: ์•ž ์‹œ๋‚˜๋ฆฌ์˜ค์—์„œ Suspended ์ผ ์ˆ˜ ์žˆ์Œ, 2026-10-07)
    const keepBefore = nodes.includes(keep) ? (await readNodeInfo(page, ng, keep)).status : '';
    const info = await readNodeInfo(page, ng, target);
    expect(info.status, `${target} ์‹œ์ž‘ Node Status`).toMatch(/^Running/i);

    // 3~4. Terminate โ†’ Completed
    const reqId = await nodeMenuAction(page, target, 'Terminate');
    expect(reqId, 'Terminate ๋น„๋™๊ธฐ ์š”์ฒญ ID').toMatch(/^mcwc-/);
    await waitAsyncRequest(page, reqId, 15 * 60_000);

    // 5. Terminated
    expect(await waitNodeStatus(page, name, ng, target, /^Terminated/i), `${target} Terminated`).toMatch(/^Terminated/i);
    const c1 = await infraCounts(page, name);
    test.info().annotations.push({ type: 'list-after', description: c1.cells });
    expect(c1.terminated, '๋ชฉ๋ก Terminated ์—ด +1').toBe(c0.terminated + 1);

    // 6. ๋‚˜๋จธ์ง€ node ์˜ํ–ฅ ์—†์Œ
    if (nodes.includes(keep)) {
      const keepAfter = (await readNodeInfo(page, ng, keep)).status;
      test.info().annotations.push({ type: 'non-target', description: `${keep}: ${keepBefore} โ†’ ${keepAfter} (๋Œ€์ƒ ์™ธ node ์ƒํƒœ ๋ณ€ํ™” ์—†์–ด์•ผ ํ•จ)` });
      expect(keepAfter, `${keep} ์ƒํƒœ ๋ณ€ํ™” ์—†์Œ(Terminate ๋Œ€์ƒ ${target} ์™ธ)`).toBe(keepBefore);
    }
    await page.waitForTimeout(3000); // ๋…นํ™” ๋งˆ๋ฌด๋ฆฌ
  });
}

IV4_S10

tests/iv4/IV4_S10.spec.ts ยท 63์ค„ ยท ํ•ด์‹œ f1ab3590 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S10: Infra Node ๋ผ์ดํ”„์‚ฌ์ดํด โ‘ฃ: Terminated Node Delete [์ •์ƒ, CSP๋ณ„]
// ํ™”๋ฉด: Infra ๋ชฉ๋ก โ†’ Infra ์„ ํƒ โ†’ Default ํƒญ Node List โ†’ Terminated node ์„ ํƒ(Node Info) โ†’ Node List โŒ„ ๋ฉ”๋‰ด 'Delete' โ†’ ํ™•์ธ ํŒ์—… โ†’ ์ข… ์•„์ด์ฝ˜ Requests ํŒจ๋„
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ ์ƒํƒœ, Workspace=ws01 / Project=default ์„ ํƒ๋œ ์ƒํƒœ์—์„œ ์‹œ์ž‘. IV4_S09 ์™„๋ฃŒ(node g1-2 Terminated)
// ์ ˆ์ฐจ (์˜ˆ: AWS ๊ธฐ์ค€): 1.Infra ๋ชฉ๋ก์—์„œ mc1 ์„ ํƒ 2.Node List ์—์„œ Terminated ์ƒํƒœ node g1-2 ์„ ํƒ โ†’ Node Info ์˜ Node Status=Terminated ํ™•์ธ
//   3.node ์ฒดํฌ โ†’ Node List โŒ„ ๋ฉ”๋‰ด Delete โ†’ ํ™•์ธ ํŒ์—…('delete Node') Confirm 4.Requests ํŒจ๋„ Completed ํ™•์ธ
//   5.Node List ์—์„œ g1-2 ์‚ฌ๋ผ์ง, ๋ชฉ๋ก Total Nodes -1 ยท Terminated 0 ํ™•์ธ 6.g1-1 Running ์œ ์ง€ ํ™•์ธ
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: Terminated node ๊ฐ€ Infra ์—์„œ ์‚ญ์ œ๋˜๊ณ  Total Nodes ๊ฐ์†Œ, ๋‚˜๋จธ์ง€ node ์˜ํ–ฅ ์—†์Œ
//
// ์•ˆ์ „: ๋Œ€์ƒ์€ Node Status=Terminated ์ธ node ๋งŒ(Running node ๋Š” ์ง€์šฐ์ง€ ์•Š๋Š”๋‹ค). ์—†์œผ๋ฉด ์„ ํ–‰ ๋ฏธ์ถฉ์กฑ์œผ๋กœ ์‹คํŒจ.
// ๋ชฉ๋ก(List of Infra) โŒ„ ๋ฉ”๋‰ด Delete ๋Š” Infra ์ „์ฒด ์‚ญ์ œ(MciDelete) โ†’ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค(Infra ์‚ญ์ œ๋Š” CL_S01). 2026-10-02 ๊ธฐ์ค€ ๋ฏธ์‹คํ–‰.

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups, ADMIN } from '../../lib/data';
import { loginAndSettle } from '../../lib/ui';
import { gotoInfraList, waitInfraListed, openInfraDetail, nodeNames, readNodeInfo, nodeMenuAction, waitAsyncRequest, waitNodeStatus, infraCounts } from '../../lib/iv4-infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) { // ์—‘์…€์— ์žˆ๋Š” CSP: AWS, Azure, Alibaba, GCP, Tencent, NHN Cloud, NCP, IBM, KT
  test(`${tid("IV4_S10", c)} ` + "Infra Node ๋ผ์ดํ”„์‚ฌ์ดํด โ‘ฃ: Terminated Node Delete", async ({ page }) => {
    test.setTimeout(30 * 60_000);
    const name = infraName(c);
    const ng = infraNodegroups(c)[0];
    const keep = `${ng}-1`;

    await loginAndSettle(page, ADMIN.id, ADMIN.pw);
    await gotoInfraList(page);
    expect(await waitInfraListed(page, name), `์„ ํ–‰ Infra ${name} ์กด์žฌ`).toBeTruthy();
    const c0 = await infraCounts(page, name);
    test.info().annotations.push({ type: 'list-before', description: c0.cells });

    // 1~2. Terminated node ์ฐพ๊ธฐ (g{n}-1 ์ œ์™ธ)
    await openInfraDetail(page, name);
    const nodes = await nodeNames(page);
    test.info().annotations.push({ type: 'nodes', description: nodes.join(', ') });
    let target = '';
    for (const n of nodes.filter((x) => x.startsWith(`${ng}-`) && x !== keep).reverse()) {
      if (/^Terminated/i.test((await readNodeInfo(page, ng, n)).status)) { target = n; break; }
    }
    expect(target, '์„ ํ–‰: IV4_S09 ๋กœ Terminated ๋œ node ์กด์žฌ').not.toBe('');
    // ๋Œ€์ƒ ์™ธ node ๋Š” '์˜ํ–ฅ ์—†์Œ' = Delete ์ „ํ›„ ์ƒํƒœ ๋™์ผ(Running ์ด์–ด์•ผ ํ•œ๋‹ค๋Š” ๊ฐ€์ •์€ ํ•˜์ง€ ์•Š๋Š”๋‹ค, 2026-10-07)
    const keepBefore = nodes.includes(keep) ? (await readNodeInfo(page, ng, keep)).status : '';

    // 3~4. Delete โ†’ Completed
    const reqId = await nodeMenuAction(page, target, 'Delete');
    expect(reqId, 'Delete ๋น„๋™๊ธฐ ์š”์ฒญ ID').toMatch(/^mcwc-/);
    await waitAsyncRequest(page, reqId, 15 * 60_000);

    // 5. Node List ์—์„œ ์‚ฌ๋ผ์ง, Total Nodes -1, Terminated ๊ฐ์†Œ
    expect(await waitNodeStatus(page, name, ng, target, /^\(์—†์Œ\)$/), `${target} Node List ์—์„œ ์‚ญ์ œ`).toBe('(์—†์Œ)');
    const c1 = await infraCounts(page, name);
    test.info().annotations.push({ type: 'list-after', description: c1.cells });
    expect(c1.total, '๋ชฉ๋ก Total Nodes -1').toBe(c0.total - 1);
    expect(c1.terminated, '๋ชฉ๋ก Terminated ์—ด -1').toBe(c0.terminated - 1);

    // 6. ๋‚˜๋จธ์ง€ node ์˜ํ–ฅ ์—†์Œ
    if ((await nodeNames(page)).includes(keep)) {
      const keepAfter = (await readNodeInfo(page, ng, keep)).status;
      test.info().annotations.push({ type: 'non-target', description: `${keep}: ${keepBefore} โ†’ ${keepAfter} (๋Œ€์ƒ ์™ธ node ์ƒํƒœ ๋ณ€ํ™” ์—†์–ด์•ผ ํ•จ)` });
      expect(keepAfter, `${keep} ์ƒํƒœ ๋ณ€ํ™” ์—†์Œ(Delete ๋Œ€์ƒ ${target} ์™ธ)`).toBe(keepBefore);
    }
    await page.waitForTimeout(3000); // ๋…นํ™” ๋งˆ๋ฌด๋ฆฌ
  });
}

IV4_S11

tests/iv4/IV4_S11.spec.ts ยท 154์ค„ ยท ํ•ด์‹œ 843bc4e6 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S11: NodeGroup ์‚ญ์ œ (nodegroup ๋‚ด node ์ „์ฒด ์‚ญ์ œ) [์ •์ƒ, CSP๋ณ„]
// ํ™”๋ฉด: Infra ์„ ํƒ โ†’ Group ํƒญ โ†’ NodeGroup ์นด๋“œ ์„ ํƒ(ํ•˜๋‹จ Node List ๋กœ ๋Œ€์ƒ node ํ™•์ธ) โ†’ Default ํƒญ Node List ์—์„œ node ๋ณ„ โŒ„ ๋ฉ”๋‰ด 'Delete'(๊ฑฐ๋ถ€ ์‹œ Terminate ํ›„ Delete) โ†’ ํ™•์ธ ํŒ์—… โ†’ ์ข… ์•„์ด์ฝ˜ Requests ํŒจ๋„
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ ์ƒํƒœ, Workspace=ws01 / Project=default ์„ ํƒ๋œ ์ƒํƒœ์—์„œ ์‹œ์ž‘. ์‚ญ์ œ ๋Œ€์ƒ nodegroup g{n}b(nodegroup ์ถ”๊ฐ€๋กœ ์ƒ์„ฑ) ์กด์žฌ
// ์ ˆ์ฐจ: 1.Infra ๋ชฉ๋ก์—์„œ mc{n} ์„ ํƒ 2.Group ํƒญ์—์„œ ๋Œ€์ƒ nodegroup ๋งŒ ์„ ํƒ โ†’ ํ•˜๋‹จ Node List ์— ํ•ด๋‹น node ํ‘œ์‹œ ํ™•์ธ 3.node ์ „์ฒด ์ฒดํฌ โ†’ โŒ„ ๋ฉ”๋‰ด Delete โ†’ Confirm
//   (Delete ๊ฐ€ ๊ฑฐ๋ถ€๋˜๋ฉด Terminate โ†’ Terminated ํ™•์ธ โ†’ Delete) 4.Requests ํŒจ๋„ Completed ํ™•์ธ 5.Group ํƒญ์—์„œ nodegroup ์นด๋“œ ์‚ฌ๋ผ์ง, Total Nodes ๊ฐ์†Œ 6.๋‹ค๋ฅธ nodegroup ์˜ํ–ฅ ์—†์Œ
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: nodegroup ์˜ node ๊ฐ€ ๋ชจ๋‘ ์‚ญ์ œ๋˜๊ณ  nodegroup ์ด Group ํƒญ์—์„œ ์ œ๊ฑฐ๋จ. ๋‹ค๋ฅธ nodegroup ์˜ํ–ฅ ์—†์Œ
//
// ์‚ฌ์ „์ ๊ฒ€(2026-10-05): ์ฝ˜์†”์— nodegroup ์‚ญ์ œ ๊ธฐ๋Šฅ์ด ์—†๋‹ค(๊ฐœ์„  ์‚ฌํ•ญ). Group ํƒญ ํ•˜๋‹จ Node List(#nodegroup_vm) โŒ„ ๋ฉ”๋‰ด์˜ Delete ๋Š” node ๋‹จ์œ„(VmDelete).
// ๋Œ€์ƒ ์ง€์ •: TC_VARIANT=<nodegroup CSP> IV4_NG_INFRA=<Infra>(๊ธฐ๋ณธ mc{n}) IV4_NG=<nodegroup>(๊ธฐ๋ณธ g{n}b) โ†’ ๋…นํ™” {ID}_ngdelete_{ng}
//   ์˜ˆ) TC_VARIANT=gcp IV4_NG_INFRA=mc1 IV4_NG=g1c โ†’ IV4_S11_GCP_ngdelete_g1c.webm. nodegroup ์˜ ์‹ค์ œ CSP ๊ฐ€ TC_VARIANT ์™€ ๋‹ค๋ฅด๋ฉด ์‹คํŒจ.

import { test, expect } from '../../lib/fixtures';
import type { Page } from '@playwright/test';
import { activeCsps, tid, infraName, infraNodegroups, ADMIN } from '../../lib/data';
import { loginAndSettle } from '../../lib/ui';
import { gotoInfraList, waitInfraListed, openInfraDetail, infraInfoStatus, nodeNames, readNodeInfo, infraCounts, waitAsyncRequest, refreshInfraList, nodeMenuAction } from '../../lib/iv4-infra';

/** Group ํƒญ์—์„œ nodegroup ng ํ•˜๋‚˜๋งŒ ์„ ํƒํ•˜๊ณ , ํ•˜๋‹จ Node List(#nodegroup_vm)์˜ node ์ด๋ฆ„์„ ๋Œ๋ ค์ค€๋‹ค */
async function selectGroupOnly(page: Page, ng: string) {
  await page.getByRole('tab', { name: 'Group' }).click();
  const grp = page.locator('#tabs-mci-group');
  await expect(grp.locator(`#serverGroup_status_icon_${ng}`)).toBeVisible({ timeout: 20_000 });
  for (const id of await grp.locator('.vmgroup-checkbox').evaluateAll((els) => els.map((e) => e.id))) {
    if (id !== `checkbox_vmGroup_${ng}` && (await grp.locator(`#${id}`).isChecked())) await grp.locator(`#${id}`).uncheck();
  }
  if (!(await grp.locator(`#checkbox_vmGroup_${ng}`).isChecked())) await grp.locator(`#serverGroup_status_icon_${ng}`).click();
  expect(await grp.locator('.vmgroup-checkbox:checked').evaluateAll((els) => els.map((e) => e.id)), `์ฒดํฌ๋œ nodegroup ์€ ${ng} ํ•˜๋‚˜`).toEqual([`checkbox_vmGroup_${ng}`]);
  await page.locator('#nodegroup_vm li[id^="nodegroup_vm_status_icon_"]').first().waitFor({ timeout: 20_000 });
  return page.locator('#nodegroup_vm li[id^="nodegroup_vm_status_icon_"]').evaluateAll((els) => els.map((e) => e.id.replace('nodegroup_vm_status_icon_', '')));
}

/**
 * node ๋ฅผ ํ•˜๋‚˜์”ฉ Default ํƒญ Node List โŒ„ ๋ฉ”๋‰ด๋กœ ์ œ์–ด(item โ†’ Confirm). ๋ฐœ๊ธ‰๋œ ๋น„๋™๊ธฐ ์š”์ฒญ ID ์ „๋ถ€๋ฅผ ๋Œ๋ ค์ค€๋‹ค.
 * ์ œํ’ˆ ๋ฒ„๊ทธ(2026-10-05): Group ํƒญ ํ•˜๋‹จ Node List(#nodegroup_vm) ์˜ โŒ„ ๋ฉ”๋‰ด Delete ๋Š” ์„ ํƒํ•œ node ๊ฐ€ ์š”์ฒญ์— ๋“ค์–ด๊ฐ€์ง€ ์•Š์•„
 *   'Node delete: โ€” Not Found'(DelInfraNode, ๋Œ€์ƒ ์ด๋ฆ„ ๋นˆ ๊ฐ’)๋กœ ์‹คํŒจํ•œ๋‹ค. ๋˜ ๊ทธ ๋ชฉ๋ก์€ ํ•œ ๋ฒˆ์— node ํ•˜๋‚˜๋งŒ ์ฒดํฌ๋œ๋‹ค.
 *   โ†’ Group ํƒญ์€ ๋Œ€์ƒ ํ™•์ธ์šฉ์œผ๋กœ๋งŒ ์“ฐ๊ณ , ์ œ์–ด๋Š” S10 ์—์„œ ์ •์ƒ ๋™์ž‘ํ•œ Default ํƒญ Node List ๋กœ ํ•œ๋‹ค(nodeMenuAction ์ด ๋Œ€์ƒ ํ•˜๋‚˜๋งŒ ์ฒดํฌ๋๋Š”์ง€ ํ™•์ธ).
 */
async function groupNodesAction(page: Page, infra: string, _ng: string, nodes: string[], item: 'Delete' | 'Terminate') {
  const ids: string[] = [];
  for (const node of nodes) {
    // ์ง์ „ ์ œ์–ด ๊ฒฐ๊ณผ๊ฐ€ ํ™”๋ฉด์— ๋ฐ˜์˜๋˜๋„๋ก ๋งค๋ฒˆ ๋ชฉ๋กยท์ƒ์„ธ๋ฅผ ๋‹ค์‹œ ์—ฐ๋‹ค
    await refreshInfraList(page);
    expect(await waitInfraListed(page, infra)).toBeTruthy();
    await openInfraDetail(page, infra);
    if (!(await nodeNames(page)).includes(node)) {
      test.info().annotations.push({ type: `skip ${item} ${node}`, description: 'Node List ์— ์—†์Œ(์ด๋ฏธ ์‚ญ์ œ๋จ)' });
      continue;
    }
    const id = await nodeMenuAction(page, node, item);
    if (id) ids.push(id);
  }
  return ids;
}

/** ์š”์ฒญ๋“ค์˜ ๊ฒฐ๊ณผ: ๋ชจ๋‘ Completed ๋ฉด true, ํ•˜๋‚˜๋ผ๋„ ์‹คํŒจ๋ฉด false(์‚ฌ์œ  ๊ธฐ๋ก) */
async function allCompleted(page: Page, ids: string[]) {
  let ok = ids.length > 0;
  for (const id of ids) {
    try { await waitAsyncRequest(page, id, 15 * 60_000); } catch (e) {
      ok = false;
      test.info().annotations.push({ type: 'async-request-failed', description: String(e).split('\n')[0].slice(0, 300) });
    }
  }
  return ok;
}

/** ์ƒ์„ธ๋ฅผ ๋‹ค์‹œ ์—ด์–ด nodegroup ng ์˜ node ๋ชฉ๋ก(Default ํƒญ ๊ธฐ์ค€) */
async function groupNodesNow(page: Page, infra: string, ng: string) {
  await refreshInfraList(page);
  expect(await waitInfraListed(page, infra)).toBeTruthy();
  await openInfraDetail(page, infra);
  return (await nodeNames(page)).filter((n) => n.startsWith(`${ng}-`));
}

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) { // ์—‘์…€์— ์žˆ๋Š” CSP: AWS, Azure, Alibaba, GCP, Tencent, NHN Cloud, NCP, IBM, KT
  const recId = process.env.IV4_NG ? `${tid("IV4_S11", c)}_ngdelete_${process.env.IV4_NG}` : tid("IV4_S11", c);
  test(`${recId} ` + "NodeGroup ์‚ญ์ œ (nodegroup ๋‚ด node ์ „์ฒด ์‚ญ์ œ)", async ({ page }) => {
    test.setTimeout(50 * 60_000);
    const name = process.env.IV4_NG_INFRA || infraName(c);
    const ng = process.env.IV4_NG || infraNodegroups(c)[1];

    await loginAndSettle(page, ADMIN.id, ADMIN.pw);
    await gotoInfraList(page);
    expect(await waitInfraListed(page, name), `์„ ํ–‰ Infra ${name} ์กด์žฌ`).toBeTruthy();
    const c0 = await infraCounts(page, name);
    test.info().annotations.push({ type: 'list-before', description: c0.cells });

    // 1. ์ƒ์„ธ: ๋Œ€์ƒ/๋‹ค๋ฅธ nodegroup ์˜ node ์™€ ์ƒํƒœ
    await openInfraDetail(page, name);
    test.info().annotations.push({ type: 'infra-info-before', description: await infraInfoStatus(page) });
    const all = await nodeNames(page);
    const targets = all.filter((n) => n.startsWith(`${ng}-`));
    const others = all.filter((n) => !n.startsWith(`${ng}-`));
    expect(targets.length, `์„ ํ–‰: nodegroup ${ng} ์˜ node ์กด์žฌ`).toBeGreaterThan(0);
    for (const n of targets) {
      const info = await readNodeInfo(page, ng, n);
      expect(info.csp.toLowerCase(), `${ng} ์˜ CSP ๊ฐ€ ${c.code} ์—ฌ์•ผ ํ•จ โ†’ TC_VARIANT ๋ฅผ nodegroup CSP ๋กœ ์ง€์ •`).toContain(c.code.toLowerCase().replace('nhncloud', 'nhn'));
    }
    await page.getByRole('tab', { name: 'Group' }).click();
    const groupsBefore = (await page.locator('#tabs-mci-group li[id^="serverGroup_status_icon_"]').allInnerTexts()).map((t) => t.trim());
    test.info().annotations.push({ type: 'nodegroups-before', description: groupsBefore.join(', ') });

    // 2. Group ํƒญ: ๋Œ€์ƒ nodegroup ๋งŒ ์„ ํƒ โ†’ ํ•˜๋‹จ Node List = ๋Œ€์ƒ node
    const listed = await selectGroupOnly(page, ng);
    expect(listed.sort(), `Group ํƒญ Node List ์— ${ng} node ๋งŒ ํ‘œ์‹œ`).toEqual([...targets].sort());

    // 3~4. Delete (๊ฑฐ๋ถ€/์‹คํŒจ ์‹œ Terminate โ†’ Delete)
    let ok = await allCompleted(page, await groupNodesAction(page, name, ng, targets, 'Delete'));
    let remain = await groupNodesNow(page, name, ng);
    if (!ok || remain.length) {
      test.info().annotations.push({ type: 'fallback', description: `Delete ํ›„ ๋‚จ์€ node: ${remain.join(', ') || '(์—†์Œ)'} / ์š”์ฒญ ์„ฑ๊ณต: ${ok} โ†’ Terminate ํ›„ Delete` });
      if (remain.length) {
        expect(await allCompleted(page, await groupNodesAction(page, name, ng, remain, 'Terminate')), 'Terminate ์š”์ฒญ Completed').toBeTruthy();
        const end = Date.now() + 10 * 60_000;
        let sts: string[] = [];
        while (Date.now() < end) {
          await groupNodesNow(page, name, ng);
          sts = [];
          for (const n of remain) sts.push((await readNodeInfo(page, ng, n)).status);
          if (sts.every((s) => /^Terminated/i.test(s))) break;
          await page.waitForTimeout(10_000);
        }
        test.info().annotations.push({ type: 'terminated', description: remain.map((n, i) => `${n}=${sts[i]}`).join(', ') });
        ok = await allCompleted(page, await groupNodesAction(page, name, ng, remain, 'Delete'));
      }
    }

    // 5. node ์ „๋ถ€ ์‚ฌ๋ผ์ง + nodegroup ์นด๋“œ ์ œ๊ฑฐ ๋Œ€๊ธฐ
    const end = Date.now() + 15 * 60_000;
    let cardLeft = true;
    while (Date.now() < end) {
      remain = await groupNodesNow(page, name, ng);
      await page.getByRole('tab', { name: 'Group' }).click();
      cardLeft = await page.locator(`#serverGroup_status_icon_${ng}`).isVisible({ timeout: 3000 }).catch(() => false);
      if (!remain.length && !cardLeft) break;
      await page.waitForTimeout(15_000);
    }
    const groupsAfter = (await page.locator('#tabs-mci-group li[id^="serverGroup_status_icon_"]').allInnerTexts()).map((t) => t.trim());
    const c1 = await infraCounts(page, name);
    test.info().annotations.push({ type: 'nodegroups-after', description: groupsAfter.join(', ') || '(์—†์Œ)' });
    test.info().annotations.push({ type: 'list-after', description: c1.cells });
    test.info().annotations.push({ type: 'infra-info-after', description: await infraInfoStatus(page) });
    expect(remain, `${ng} ์˜ node ๋ชจ๋‘ ์‚ญ์ œ`).toEqual([]);
    expect(cardLeft, `nodegroup ${ng} ์นด๋“œ ์ œ๊ฑฐ`).toBeFalsy();
    expect(c1.total, `Total Nodes -${targets.length}`).toBe(c0.total - targets.length);

    // 6. ๋‹ค๋ฅธ nodegroup ์˜ํ–ฅ ์—†์Œ
    await page.getByRole('tab', { name: 'Default' }).click();
    expect((await nodeNames(page)).sort(), '๋‹ค๋ฅธ nodegroup node ์œ ์ง€').toEqual([...others].sort());
    expect(groupsAfter.sort(), '๋‹ค๋ฅธ nodegroup ์นด๋“œ ์œ ์ง€').toEqual(groupsBefore.filter((g) => !g.startsWith(`${ng}(`)).sort());
    await page.waitForTimeout(3000); // ๋…นํ™” ๋งˆ๋ฌด๋ฆฌ
  });
}

IV4_S12

tests/iv4/IV4_S12.spec.ts ยท 85์ค„ ยท ํ•ด์‹œ e7a5e620 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S12: Terminal ํŒŒ์ผ ์ „์†ก (Public IP Terminal) [์ •์ƒ, CSP๋ณ„]
// ํ™”๋ฉด: Infra ์„ ํƒ โ†’ Default ํƒญ Node List โ†’ node ์„ ํƒ โ†’ Node Info Public IP ์˜† 'Terminal' โ†’ ํ™•์ธ ํŒ์—… โ†’ Remote Terminal(Command / Transfer a file) โ†’ File Transfer Result
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ ์ƒํƒœ, Workspace=ws01 / Project=default ์„ ํƒ๋œ ์ƒํƒœ์—์„œ ์‹œ์ž‘. ๋Œ€์ƒ node(g{n}-1) Running, Public IP ๋ณด์œ 
// ์ ˆ์ฐจ: 1.Infra ๋ชฉ๋ก์—์„œ mc{n} ์„ ํƒ โ†’ node ์„ ํƒ(Node Info ์˜ Public/Private IP ํ™•์ธ) 2.Public IP ์˜† Terminal โ†’ 'Would you like to connect?' Confirm
//   3.Command ์—์„œ hostname -I(Private IP ์ผ์น˜), ์ธ์Šคํ„ด์Šค ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๊ณต์ธ IP(Public IP ์ผ์น˜) ํ™•์ธ
//   4.Transfer a file: Target Path /tmp/ โ†’ ํ…Œ์ŠคํŠธ ํŒŒ์ผ(iv4_transfer_test_<์‹œ๊ฐ>.txt) ๋“œ๋กญ โ†’ Transfer File 5.File Transfer Result 1/1 ์„ฑ๊ณต ํ™•์ธ
//   6.Command ์—์„œ ls -l /tmp/<ํŒŒ์ผ>, cat /tmp/<ํŒŒ์ผ> ๋กœ ์กด์žฌยท๋‚ด์šฉ ํ™•์ธ
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: Public IP ์˜† Terminal ๋กœ ๋Œ€์ƒ node ์— ์ ‘์†(Private/Public IP ๊ฐ€ Node Info ์™€ ์ผ์น˜), ํŒŒ์ผ์ด /tmp/ ์— ์ „์†ก๋˜๊ณ  ๋‚ด์šฉ์ด ์›๋ณธ๊ณผ ๊ฐ™์Œ
//
// ๋Œ€์ƒ ์ง€์ •: TC_VARIANT=<node CSP> IV4_TERM_INFRA=<Infra>(๊ธฐ๋ณธ mc{n}) IV4_TERM_NODE=<node>(๊ธฐ๋ณธ g{n}-1) โ†’ ๋…นํ™” {ID}_filetransfer_{node}
//   ์˜ˆ) TC_VARIANT=aws IV4_TERM_INFRA=mc1 IV4_TERM_NODE=g1b-2 โ†’ IV4_S12_AWS_filetransfer_g1b-2.webm
// ๊ณต์ธ IP ํ™•์ธ: AWS ๋Š” ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(IMDSv2 ํ† ํฐ โ†’ IMDSv1 ์ˆœ), ๊ทธ ์™ธ/์‹คํŒจ ์‹œ ifconfig.me ๋กœ ๋Œ€์ฒด(๊ฒฐ๊ณผ์— ์ถœ์ฒ˜ ๊ธฐ๋ก)

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups, ADMIN } from '../../lib/data';
import { loginAndSettle } from '../../lib/ui';
import { gotoInfraList, waitInfraListed, openInfraDetail, nodeNames, readNodeInfo, openNodeTerminal, termRun, termTransferFile } from '../../lib/iv4-infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) { // ์—‘์…€์— ์žˆ๋Š” CSP: AWS, Azure, Alibaba, GCP, Tencent, NHN Cloud, NCP, IBM, KT
  const recId = process.env.IV4_TERM_NODE ? `${tid("IV4_S12", c)}_filetransfer_${process.env.IV4_TERM_NODE}` : tid("IV4_S12", c);
  test(`${recId} ` + "Terminal ํŒŒ์ผ ์ „์†ก (Public IP Terminal)", async ({ page }) => {
    test.setTimeout(20 * 60_000);
    const name = process.env.IV4_TERM_INFRA || infraName(c);
    const node = process.env.IV4_TERM_NODE || `${infraNodegroups(c)[0]}-1`;
    const ng = node.replace(/-\d+$/, '');

    await loginAndSettle(page, ADMIN.id, ADMIN.pw);
    await gotoInfraList(page);
    expect(await waitInfraListed(page, name), `์„ ํ–‰ Infra ${name} ์กด์žฌ`).toBeTruthy();

    // 1. node ์„ ํƒ โ†’ Node Info (Running, Public/Private IP)
    await openInfraDetail(page, name);
    expect(await nodeNames(page), `${node} ์กด์žฌ`).toContain(node);
    const info = await readNodeInfo(page, ng, node);
    expect(info.csp.toLowerCase(), `${node} ์˜ CSP ๊ฐ€ ${c.code} ์—ฌ์•ผ ํ•จ โ†’ TC_VARIANT ์ง€์ •`).toContain(c.code.toLowerCase().replace('nhncloud', 'nhn'));
    expect(info.status, `${node} Running`).toMatch(/^Running/i);
    const publicIp = (await page.locator('#server_info_public_ip').innerText()).trim();
    const privateIp = (await page.locator('#server_info_private_ip').innerText()).trim();
    test.info().annotations.push({ type: 'node-ip', description: `Public ${publicIp} / Private ${privateIp}` });
    expect(publicIp, 'Public IP ๋ณด์œ ').toMatch(/^\d+\.\d+\.\d+\.\d+$/);

    // 2. Public IP ์˜† Terminal โ†’ Confirm โ†’ Remote Terminal
    await openNodeTerminal(page);

    // 3. ์ ‘์† node ํ™•์ธ: Private IP(hostname -I) / Public IP(๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋˜๋Š” ifconfig.me)
    const hostIps = await termRun(page, 'hostname -I');
    expect(hostIps.split(/\s+/), `hostname -I ์— Private IP ${privateIp}`).toContain(privateIp);
    let pub = '';
    let src = '';
    if (c.code === 'AWS') {
      await termRun(page, 'T=$(curl -s -m 5 -X PUT http://169.254.169.254/latest/api/token -H "X-aws-ec2-metadata-token-ttl-seconds: 60")');
      pub = (await termRun(page, 'curl -s -m 5 -H "X-aws-ec2-metadata-token: $T" http://169.254.169.254/latest/meta-data/public-ipv4')).match(/\d+\.\d+\.\d+\.\d+/)?.[0] ?? '';
      src = 'AWS IMDSv2';
      if (!pub) { pub = (await termRun(page, 'curl -s -m 5 http://169.254.169.254/latest/meta-data/public-ipv4')).match(/\d+\.\d+\.\d+\.\d+/)?.[0] ?? ''; src = 'AWS IMDSv1'; }
    }
    if (!pub) { pub = (await termRun(page, 'curl -s -m 8 ifconfig.me')).match(/\d+\.\d+\.\d+\.\d+/)?.[0] ?? ''; src = 'ifconfig.me'; }
    test.info().annotations.push({ type: 'public-ip-check', description: `${src}: ${pub || '(ํ™•์ธ ์‹คํŒจ)'} / Node Info ${publicIp}` });
    expect(pub, `node ์˜ ๊ณต์ธ IP(${src}) = Node Info Public IP`).toBe(publicIp);

    // 4~5. Transfer a file โ†’ /tmp/ โ†’ Result 1/1
    const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
    const fileName = `iv4_transfer_test_${stamp}.txt`;
    const content = `IV4_S12 ${c.code} ${name}/${node} file transfer test ${stamp}`;
    const result = await termTransferFile(page, '/tmp/', { name: fileName, mimeType: 'text/plain', buffer: Buffer.from(content + '\n') }, node);
    expect(result, 'File Transfer Result 1/1').toMatch(/\b1\s*\/\s*1\b/);
    await page.locator('#transferResultModal').getByRole('button').filter({ hasText: /close|๋‹ซ๊ธฐ/i }).first().click().catch(() => page.keyboard.press('Escape'));
    await expect(page.locator('#transferResultModal')).toBeHidden({ timeout: 10_000 }).catch(() => {});

    // 6. Command ์—์„œ ํŒŒ์ผ ์กด์žฌยท๋‚ด์šฉ ํ™•์ธ (์ „์†ก ๊ฒฐ๊ณผ ๋ชจ๋‹ฌ์„ ๋‹ซ์œผ๋ฉด Remote Terminal ์ด ๋‹ซํ˜”์„ ์ˆ˜ ์žˆ์Œ โ†’ ๋‹ค์‹œ ์—ฐ๋‹ค)
    if (!(await page.locator('#cmdtestmodal').isVisible().catch(() => false))) {
      test.info().annotations.push({ type: 'terminal-reopen', description: '์ „์†ก ๊ฒฐ๊ณผ ํ›„ Remote Terminal ์ด ๋‹ซํ˜€ ๋‹ค์‹œ ์ ‘์†' });
      await readNodeInfo(page, ng, node);
      await openNodeTerminal(page);
    }
    // ํ„ฐ๋ฏธ๋„ ํญ ๋•Œ๋ฌธ์— ์ถœ๋ ฅ์ด ์ค„๋ฐ”๊ฟˆ๋  ์ˆ˜ ์žˆ์–ด ๊ณต๋ฐฑยท์ค„๋ฐ”๊ฟˆ์„ ๋นผ๊ณ  ๋น„๊ต
    const squash = (s: string) => s.replace(/\s+/g, '');
    const ls = await termRun(page, `ls -l /tmp/${fileName}`);
    expect(squash(ls), `/tmp/${fileName} ์กด์žฌ`).toContain(fileName);
    expect(ls).not.toMatch(/No such file/i);
    const cat = await termRun(page, `cat /tmp/${fileName}`);
    expect(squash(cat), 'ํŒŒ์ผ ๋‚ด์šฉ = ์›๋ณธ').toContain(squash(content));
    await page.waitForTimeout(3000); // ๋…นํ™” ๋งˆ๋ฌด๋ฆฌ
  });
}

IV4_S13

tests/iv4/IV4_S13.spec.ts ยท 91์ค„ ยท ํ•ด์‹œ e725873d ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S13: K8s NodeGroup ์‚ญ์ œ ์š”์ฒญ โ†’ Deleting ํ™•์ธ [์ •์ƒ, CSP๋ณ„]
// ํ™”๋ฉด: Workloads > K8s Workloads > ๋ชฉ๋ก์—์„œ Cluster ์„ ํƒ > ์ƒ์„ธ > NodeGroup ์„ ํƒ > ์‚ญ์ œ โ†’ ํ™•์ธ ํŒ์—… โ†’ ์ƒํƒœ
// ์‹œ์ž‘ ์ƒํƒœ: admin ๋กœ๊ทธ์ธ ์ƒํƒœ, Workspace=ws01 / Project=p1 ์„ ํƒ ์ƒํƒœ์—์„œ ์‹œ์ž‘. IV3_S21 ์™„๋ฃŒ: k1d ๊ฐ€ Active, NodeGroup g1d ๊ฐ€ Ready
// ์ ˆ์ฐจ (์˜ˆ: AWS ๊ธฐ์ค€): 1.K8s Workloads ๋ชฉ๋ก(Project p1)์—์„œ k1d ์„ ํƒ 2.์ƒ์„ธ์—์„œ NodeGroup g1d ์„ ํƒ 3.NodeGroup ์‚ญ์ œ โ†’ ํ™•์ธ ํŒ์—… Confirm 4.g1d ๊ฐ€ Deleting(์ง„ํ–‰ ์ค‘)์œผ๋กœ ํ‘œ์‹œ๋˜๋Š”์ง€ ํ™•์ธ ํ›„ ์ข…๋ฃŒ (์‚ญ์ œ ์™„๋ฃŒ ๋Œ€๊ธฐ๋Š” ๋…นํ™”ํ•˜์ง€ ์•Š์Œ)
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: ์‚ญ์ œ ์š”์ฒญ์ด ์ ‘์ˆ˜๋˜๊ณ  NodeGroup g1d ๊ฐ€ Deleting ์œผ๋กœ ํ‘œ์‹œ. ๋งˆ์ง€๋ง‰ NodeGroup ์‚ญ์ œ๋ฅผ ๊ฑฐ๋ถ€ํ•˜๋Š” CSP ๋Š” ๊ฑฐ๋ถ€ ๋ฉ”์‹œ์ง€๋ฅผ ๊ธฐ๋ก
//
// ์‚ฌ์ „์ ๊ฒ€(2026-10-05, IV4 ์„ธ์…˜, p1/k1d): ์ƒ์„ธ 'NodeGroup List / Status' ์นด๋“œ โŒ„ ๋ฉ”๋‰ด = Delete(NodeGroupDelete, 'Would you like to delete NodeGroup ?'),
//   Edit Scaling, Export to JSON, Import from JSON. ๋ชฉ๋ก(List of Cluster) โŒ„ ๋ฉ”๋‰ด Delete ๋Š” ํด๋Ÿฌ์Šคํ„ฐ ์ „์ฒด ์‚ญ์ œ(PmkDelete) โ†’ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค.
//   nodegroup ์นด๋“œ #nodeGroup_status_icon_<ng>(ํด๋ฆญ = ์ฒดํฌ ํ† ๊ธ€), ์ฒดํฌ๋ฐ•์Šค #node_checkbox_<ng>. ๋ชฉ๋ก ์—ด: [ , ProviderImg, Status, Name, Node Group, VPC, Subnet, SG]

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, ADMIN } from '../../lib/data';
import { loginAndSettle } from '../../lib/ui';
import { withAsyncRequest, waitAsyncRequest } from '../../lib/iv4-infra';
import { gotoK8sList, clusterCells, openCluster, nodeGroupState } from '../../lib/iv4-k8s';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) { // ์—‘์…€์— ์žˆ๋Š” CSP: AWS, Azure, Alibaba, GCP, Tencent, NHN Cloud, NCP, IBM, KT
  test(`${tid("IV4_S13", c)} ` + "K8s NodeGroup ์‚ญ์ œ ์š”์ฒญ โ†’ Deleting ํ™•์ธ", async ({ page }) => {
    test.setTimeout(20 * 60_000);
    const project = process.env.IV4_K8S_PROJECT || `p${c.n}`;
    const cluster = process.env.IV4_K8S_CLUSTER || `k${c.n}d`;
    const ng = process.env.IV4_K8S_NG || `g${c.n}d`;

    await loginAndSettle(page, ADMIN.id, ADMIN.pw);
    // 1. ๋ชฉ๋ก์—์„œ ํด๋Ÿฌ์Šคํ„ฐ ์„ ํƒ
    expect(await gotoK8sList(page, project, cluster), `์„ ํ–‰: Project ${project} ์— ${cluster} ์กด์žฌ`).toBeTruthy();
    const before = await clusterCells(page, cluster);
    test.info().annotations.push({ type: 'list-before', description: before.join(' | ') });
    expect(before[2], `${cluster} Active`).toMatch(/^Active/i);
    await openCluster(page, cluster);
    const infoText = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
    const ci = infoText.indexOf('Cluster Info');
    test.info().annotations.push({ type: 'cluster-info', description: infoText.slice(ci, ci + 300) });

    // 2. NodeGroup ์นด๋“œ: ๋Œ€์ƒ๋งŒ ์ฒดํฌ
    const box = page.locator('#cluster_info');
    const card = page.locator(`#nodeGroup_status_icon_${ng}`);
    await expect(card, `์„ ํ–‰: nodegroup ${ng} ์กด์žฌ`).toBeVisible({ timeout: 20_000 });
    // ์ƒํƒœ ์ฝ๊ธฐ(์นด๋“œ ํด๋ฆญ์œผ๋กœ ์ฒดํฌ๊ฐ€ ํ† ๊ธ€๋  ์ˆ˜ ์žˆ์Œ)๋Š” ์ฒดํฌ ์ •๋ฆฌ ์ „์— ํ•œ๋‹ค
    test.info().annotations.push({ type: 'nodegroup-before', description: (await nodeGroupState(page, ng)).text });
    for (const id of await box.locator('input.vm-checkbox:checked').evaluateAll((els) => els.map((e) => e.id))) {
      if (id !== `node_checkbox_${ng}`) await box.locator(`#${id}`).uncheck();
    }
    if (!(await page.locator(`#node_checkbox_${ng}`).isChecked())) await card.click();
    await expect(page.locator(`#node_checkbox_${ng}`)).toBeChecked();
    expect(await box.locator('input.vm-checkbox:checked').evaluateAll((els) => els.map((e) => e.id)), `์ฒดํฌ๋œ nodegroup ์€ ${ng} ํ•˜๋‚˜`).toEqual([`node_checkbox_${ng}`]);
    await page.waitForTimeout(2000);

    // 3. NodeGroup ์นด๋“œ โŒ„ ๋ฉ”๋‰ด Delete โ†’ ํ™•์ธ ํŒ์—… Confirm (ํด๋Ÿฌ์Šคํ„ฐ ๋ชฉ๋ก์˜ Delete ์™€ ๊ตฌ๋ถ„: Edit Scaling ์ด ์žˆ๋Š” ๋ฉ”๋‰ด)
    const menu = box.locator('.dropdown-menu').filter({ hasText: 'Edit Scaling' }).first();
    await menu.locator('xpath=preceding-sibling::*[@data-bs-toggle="dropdown"][1]').click();
    await box.locator('.dropdown-menu.show .dropdown-item', { hasText: /^\s*Delete\s*$/ }).first().click();
    const modal = page.locator('#commonDefaultModal');
    await expect(modal).toBeVisible();
    const msg = (await modal.innerText()).replace(/\s+/g, ' ').trim();
    test.info().annotations.push({ type: 'confirm-modal', description: msg });
    expect(msg, 'nodegroup ์‚ญ์ œ ํ™•์ธ(ํด๋Ÿฌ์Šคํ„ฐ ์‚ญ์ œ ์•„๋‹˜)').toMatch(/delete NodeGroup/i);
    const reqId = await withAsyncRequest(page, async () => {
      await modal.locator('#commonDefaultModal-confirm-btn').click();
      await expect(modal).toBeHidden({ timeout: 15_000 });
    });

    // 4. ์ ‘์ˆ˜ ํ™•์ธ: ์š”์ฒญ ๊ฒฐ๊ณผ(๊ฑฐ๋ถ€ ์‹œ ๋ฉ”์‹œ์ง€ ๊ธฐ๋ก) + Deleting ํ‘œ์‹œ ํ™•์ธ ํ›„ ์ข…๋ฃŒ(์™„๋ฃŒ ๋Œ€๊ธฐ ์•ˆ ํ•จ)
    // 2026-10-05: ์š”์ฒญ์€ ์ด๋ ฅ์— 'K8s NG delete: g1d โ€” completed'(DeleteK8sNodeGroup)๋กœ ๋‚จ์•˜์ง€๋งŒ K8s ํ™”๋ฉด์˜ ์ข… ์•„์ด์ฝ˜ ํŒจ๋„์—์„œ๋Š” 3๋ถ„ ๋™์•ˆ ์ฐพ์ง€ ๋ชปํ•จ
    //   โ†’ ํŒจ๋„์— ์—†์œผ๋ฉด ์‹คํŒจ๊ฐ€ ์•„๋‹ˆ๋ผ ํ™”๋ฉด ์ƒํƒœ(Deleting)๋กœ ํŒ์ •ํ•˜๊ณ , ํŒจ๋„์— Failed ๋กœ ๋‚˜์˜ฌ ๋•Œ๋งŒ ๊ฑฐ๋ถ€๋กœ ์‹คํŒจ ์ฒ˜๋ฆฌ
    if (reqId) {
      try { await waitAsyncRequest(page, reqId, 90_000); } catch (e) {
        const m = String(e).split('\n')[0].slice(0, 300);
        test.info().annotations.push({ type: /์‹คํŒจ/.test(m) ? 'request-rejected' : 'request-not-in-panel', description: m });
        expect(m, '์‚ญ์ œ ์š”์ฒญ ๊ฑฐ๋ถ€(๊ฑฐ๋ถ€ ๋ฉ”์‹œ์ง€ ๊ธฐ๋ก)').not.toMatch(/๋น„๋™๊ธฐ ์š”์ฒญ ์‹คํŒจ/);
      }
    } else test.info().annotations.push({ type: 'no-async-request', description: '๋น„๋™๊ธฐ ์š”์ฒญ ID ๋ฏธํ™•์ธ โ†’ ํ™”๋ฉด ์ƒํƒœ๋กœ๋งŒ ํŒ์ •' });

    let st = { exists: true, text: '' };
    const end = Date.now() + 5 * 60_000;
    while (Date.now() < end) {
      expect(await gotoK8sList(page, project, cluster)).toBeTruthy();
      await openCluster(page, cluster);
      st = await nodeGroupState(page, ng);
      if (!st.exists || /delet/i.test(st.text)) break;
      await page.waitForTimeout(10_000);
    }
    const after = await clusterCells(page, cluster);
    test.info().annotations.push({ type: 'nodegroup-after', description: st.text.slice(0, 400) });
    test.info().annotations.push({ type: 'list-after', description: after.join(' | ') });
    if (!st.exists) test.info().annotations.push({ type: 'already-removed', description: `${ng} ๊ฐ€ Deleting ํ‘œ์‹œ ์—†์ด ๋ฐ”๋กœ ์‚ฌ๋ผ์ง โ†’ IV4_S14 ์—์„œ ์™„๋ฃŒ ํ™•์ธ` });
    expect(!st.exists || /delet/i.test(st.text), `${ng} Deleting ํ‘œ์‹œ(๋˜๋Š” ์ด๋ฏธ ์ œ๊ฑฐ)`).toBeTruthy();
    await page.waitForTimeout(3000); // ๋…นํ™” ๋งˆ๋ฌด๋ฆฌ
  });
}

IV4_S14

tests/iv4/IV4_S14.spec.ts ยท 54์ค„ ยท ํ•ด์‹œ afbc3978 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S14: K8s NodeGroup ์‚ญ์ œ ์™„๋ฃŒ ํ™•์ธ (๋ชฉ๋ก/์ƒ์„ธ์—์„œ NodeGroup ์ œ๊ฑฐ) [์ •์ƒ, CSP๋ณ„]
// ํ™”๋ฉด: Workloads > K8s Workloads > ๋ชฉ๋ก์—์„œ Cluster ์„ ํƒ > ์ƒ์„ธ > NodeGroup ๋ชฉ๋ก
// ์‹œ์ž‘ ์ƒํƒœ: admin ๋กœ๊ทธ์ธ ์ƒํƒœ, Workspace=ws01 / Project=p1 ์„ ํƒ ์ƒํƒœ์—์„œ ์‹œ์ž‘. IV4_S13 ์™„๋ฃŒ(g1d ์‚ญ์ œ ์š”์ฒญ, Deleting)
// ์ ˆ์ฐจ (์˜ˆ: AWS ๊ธฐ์ค€): 1.K8s Workloads ๋ชฉ๋ก(Project p1)์—์„œ k1d ์„ ํƒ 2.์ƒ์„ธ NodeGroup ๋ชฉ๋ก์—์„œ g1d ๊ฐ€ ์‚ฌ๋ผ์กŒ๋Š”์ง€ ํ™•์ธ 3.Cluster k1d ์ƒํƒœ๊ฐ€ Active ๋กœ ์œ ์ง€๋˜๋Š”์ง€, ๋ชฉ๋ก์˜ Node Group ์—ด ๊ฐ’์ด ๊ฐฑ์‹ ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: NodeGroup g1d ๊ฐ€ ๋ชฉ๋ก์—์„œ ์ œ๊ฑฐ๋˜๊ณ  Cluster k1d ๋Š” Active ์œ ์ง€. ์‚ญ์ œ๊ฐ€ ๊ฑฐ๋ถ€๋œ CSP ๋Š” NodeGroup ์ด ๊ทธ๋Œ€๋กœ ๋‚จ์•„ ์žˆ์Œ์„ ๊ธฐ๋ก
//
// ์กฐํšŒ๋งŒ ํ•œ๋‹ค(์ œ์–ด ์—†์Œ). ์‚ญ์ œ๊ฐ€ ์ง„ํ–‰ ์ค‘์ด๋ฉด IV4_S14_WAIT_MIN(๊ธฐ๋ณธ 15)๋ถ„๊นŒ์ง€ ์žฌ์กฐํšŒ. ํ—ฌํผ: lib/iv4-k8s.ts

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, ADMIN } from '../../lib/data';
import { loginAndSettle } from '../../lib/ui';
import { gotoK8sList, clusterCells, openCluster, clusterInfoField, nodeGroupNames, nodeGroupState } from '../../lib/iv4-k8s';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) { // ์—‘์…€์— ์žˆ๋Š” CSP: AWS, Azure, Alibaba, GCP, Tencent, NHN Cloud, NCP, IBM, KT
  test(`${tid("IV4_S14", c)} ` + "K8s NodeGroup ์‚ญ์ œ ์™„๋ฃŒ ํ™•์ธ (๋ชฉ๋ก/์ƒ์„ธ์—์„œ NodeGroup ์ œ๊ฑฐ)", async ({ page }) => {
    const waitMin = Number(process.env.IV4_S14_WAIT_MIN || 15);
    test.setTimeout((waitMin + 10) * 60_000);
    const project = process.env.IV4_K8S_PROJECT || `p${c.n}`;
    const cluster = process.env.IV4_K8S_CLUSTER || `k${c.n}d`;
    const ng = process.env.IV4_K8S_NG || `g${c.n}d`;

    await loginAndSettle(page, ADMIN.id, ADMIN.pw);

    // 1~2. ์ƒ์„ธ NodeGroup ๋ชฉ๋ก์—์„œ ๋Œ€์ƒ์ด ์‚ฌ๋ผ์งˆ ๋•Œ๊นŒ์ง€ ์žฌ์กฐํšŒ(์กฐํšŒ๋งŒ)
    const start = Date.now();
    const trail: string[] = [];
    let st = { exists: true, status: '', text: '' };
    while (true) {
      expect(await gotoK8sList(page, project, cluster), `${cluster} ์กด์žฌ(Project ${project})`).toBeTruthy();
      await openCluster(page, cluster);
      st = await nodeGroupState(page, ng);
      const label = st.exists ? st.status || '(์ƒํƒœ ์—†์Œ)' : '(์—†์Œ)';
      if (trail[trail.length - 1]?.split('@')[0] !== label) trail.push(`${label}@${Math.round((Date.now() - start) / 1000)}s`);
      if (!st.exists || Date.now() - start > waitMin * 60_000) break;
      await page.waitForTimeout(30_000);
    }
    test.info().annotations.push({ type: 'nodegroup-trail', description: trail.join(' โ†’ ') });
    if (st.exists) test.info().annotations.push({ type: 'nodegroup-remains', description: `${waitMin}๋ถ„ ํ›„์—๋„ ${ng} ๋‚จ์Œ: ${st.text.slice(0, 300)}` });
    expect(st.exists, `${ng} ๊ฐ€ NodeGroup ๋ชฉ๋ก์—์„œ ์ œ๊ฑฐ๋จ`).toBeFalsy();

    // 3. ํด๋Ÿฌ์Šคํ„ฐ Active ์œ ์ง€ + ๋ชฉ๋ก Node Group ์—ด ๊ฐฑ์‹ 
    const groups = await nodeGroupNames(page);
    const cells = await clusterCells(page, cluster);
    const infoStatus = await clusterInfoField(page, 'Status');
    test.info().annotations.push({ type: 'nodegroups-after', description: groups.join(', ') || '(์—†์Œ)' });
    test.info().annotations.push({ type: 'list-after', description: cells.join(' | ') });
    test.info().annotations.push({ type: 'cluster-status', description: `๋ชฉ๋ก ${cells[2]} / Cluster Info ${infoStatus}` });
    expect(cells[2], `${cluster} ๋ชฉ๋ก Status Active`).toMatch(/^Active/i);
    expect(infoStatus, `${cluster} Cluster Info Status Active`).toMatch(/^Active/i);
    expect(Number(cells[4]), '๋ชฉ๋ก Node Group ์—ด = ์ƒ์„ธ nodegroup ์ˆ˜').toBe(groups.length);
    await page.waitForTimeout(3000); // ๋…นํ™” ๋งˆ๋ฌด๋ฆฌ
  });
}

IV4_S15

tests/iv4/IV4_S15.spec.ts ยท 47์ค„ ยท ํ•ด์‹œ 72fbae64 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S15: Infra Node ๋ผ์ดํ”„์‚ฌ์ดํด โ‘ก-2: Resume โ†’ Running ํ™•์ธ [์ •์ƒ, CSP๋ณ„]  (IV4_S04 Suspend ์˜ ํ›„์†, ์‚ฌ์šฉ์ž ์Šน์ธ 2026-10-07)
// ๋Œ€์ƒ: Infra node (K8s node ์•„๋‹˜)
// ํ™”๋ฉด: Infra ๋ชฉ๋ก โ†’ Infra ์„ ํƒ โ†’ Default ํƒญ Node List โ†’ node ์„ ํƒ(Node Info) โ†’ Node List โŒ„ ๋ฉ”๋‰ด 'Resume' โ†’ ํ™•์ธ ํŒ์—… โ†’ ์ข… ์•„์ด์ฝ˜ Requests ํŒจ๋„
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ ์ƒํƒœ, Workspace=ws01 / Project=default ์„ ํƒ๋œ ์ƒํƒœ์—์„œ ์‹œ์ž‘. ๋Œ€์ƒ node ๊ฐ€ Suspended (IV4_S04 ์ดํ›„ ๋˜๋Š” ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ์‹œ Suspend ๋œ ์ƒํƒœ)
// ์ ˆ์ฐจ (์˜ˆ: AWS ๊ธฐ์ค€): 1.Infra ๋ชฉ๋ก์—์„œ mc1 ์„ ํƒ โ†’ Node List ์—์„œ node g1-1 ์„ ํƒ(Node Info Node Status=Suspended ํ™•์ธ) 2.node ์ฒดํฌ โ†’ Node List โŒ„ ๋ฉ”๋‰ด Resume โ†’ ํ™•์ธ ํŒ์—… Confirm
//   3.Requests ํŒจ๋„ Completed ํ™•์ธ 4.Node Status=Running ๋ณต๊ท€ ํ™•์ธ(Node Info) 5.๋ชฉ๋ก Running ์—ด +1 / Suspended ์—ด -1 (๋ชฉ๋ก ๊ฐฑ์‹  ์ง€์—ฐ ๋Œ€๋น„ ์žฌํ™•์ธ)
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: Resume ํ›„ Running ๋ณต๊ท€. ๋Œ€์ƒ node ๊ฐ€ Suspended ๊ฐ€ ์•„๋‹ˆ๋ฉด SKIP(์„ ํ–‰ ์กฐ๊ฑด ์—†์Œ)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups, ADMIN } from '../../lib/data';
import { loginAndSettle } from '../../lib/ui';
import { gotoInfraList, waitInfraListed, openInfraDetail, nodeNames, readNodeInfo, nodeMenuAction, waitAsyncRequest, waitNodeStatus, infraCounts, refreshInfraList } from '../../lib/iv4-infra';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV4_S15", c)} ` + "[Infra] Infra Node ๋ผ์ดํ”„์‚ฌ์ดํด โ‘ก-2: Resume โ†’ Running ํ™•์ธ", async ({ page }) => {
    test.setTimeout(40 * 60_000);
    const name = infraName(c);
    const ng = infraNodegroups(c)[0];
    test.info().annotations.push({ type: 'target', description: `Infra node (K8s ์•„๋‹˜): infra=${name}, nodegroup=${ng}` });

    await loginAndSettle(page, ADMIN.id, ADMIN.pw);
    await gotoInfraList(page);
    expect(await waitInfraListed(page, name), `์„ ํ–‰ Infra ${name} ์กด์žฌ`).toBeTruthy();
    const c0 = await infraCounts(page, name);
    test.info().annotations.push({ type: 'list-before', description: c0.cells });

    await openInfraDetail(page, name);
    const nodes = await nodeNames(page);
    const node = nodes.includes(`${ng}-1`) ? `${ng}-1` : nodes.find((n) => n.startsWith(`${ng}-`))!;
    expect(node, `nodegroup ${ng} ์˜ node`).toBeTruthy();
    const st0 = (await readNodeInfo(page, ng, node)).status;
    test.skip(!/^Suspended/i.test(st0), `์„ ํ–‰ ์กฐ๊ฑด ์—†์Œ: ${node} ๊ฐ€ Suspended ๊ฐ€ ์•„๋‹˜(${st0 || '๋นˆ ๊ฐ’'}) โ†’ IV4_S04(Suspend) ๋จผ์ €`);

    // 1~4. Resume โ†’ Completed โ†’ Running
    const r = await nodeMenuAction(page, node, 'Resume');
    expect(r, 'Resume ๋น„๋™๊ธฐ ์š”์ฒญ ID').toMatch(/^mcwc-/);
    await waitAsyncRequest(page, r, 15 * 60_000);
    expect(await waitNodeStatus(page, name, ng, node, /^Running/i), `${node} Running ๋ณต๊ท€`).toMatch(/^Running/i);

    // 5. ๋ชฉ๋ก Running +1 / Suspended -1 (์ตœ๋Œ€ 5๋ถ„ ์žฌํ™•์ธ)
    let c1 = await infraCounts(page, name);
    await expect.poll(async () => { await refreshInfraList(page); c1 = await infraCounts(page, name); return `${c1.running}/${c1.suspended}`; },
      { message: '๋ชฉ๋ก Running +1 / Suspended -1', timeout: 5 * 60_000, intervals: [15_000] }).toBe(`${c0.running + 1}/${c0.suspended - 1}`);
    test.info().annotations.push({ type: 'list-after', description: c1.cells });
    await page.waitForTimeout(3000); // ๋…นํ™” ๋งˆ๋ฌด๋ฆฌ
  });
}

IV4_S16

tests/iv4/IV4_S16.spec.ts ยท 49์ค„ ยท ํ•ด์‹œ 5a2517b7 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S16: [K8s] K8s NodeGroup(K8s node) Scale in (2โ†’1) [์ •์ƒ, CSP๋ณ„]  (IV4_S02 Scale out ์˜ ํ›„์†, ์‚ฌ์šฉ์ž ์ง€์‹œ 2026-10-07)
// ๋Œ€์ƒ: K8s NodeGroup / K8s node (Infra ์•„๋‹˜). ์„ ํ–‰: IV4_S02 Scale out ์„ฑ๊ณต(NodeGroup node 2๊ฐœ). node ๊ฐ€ 2๊ฐœ๊ฐ€ ์•„๋‹ˆ๋ฉด SKIP.
// ํ™”๋ฉด: K8s Workloads(p1) โ†’ k1d ์„ ํƒ โ†’ NodeGroup List ์—์„œ g1d ์„ ํƒ โ†’ โŒ„ Edit Scaling(Desired Node Count -/+, Apply) โ†’ Node List ํ™•์ธ
// ์•Œ๋ ค์ง„ ๋™์ž‘: Scale out ์งํ›„ NodeGroup Info ์˜ Desired/Min/Max ๊ฐ€ ๊ฐฑ์‹ ๋˜์ง€ ์•Š๊ณ (1/1/1, ์‹ค์ œ node 2๊ฐœ) Apply ๊ฐ€ ๋น„ํ™œ์„ฑํ™”๋˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์Œ(2026-10-07, ์•ฝ 30๋ถ„ ๋’ค 2/2 ๋กœ ๊ฐฑ์‹ ).
//   โ†’ ์‹œ์ž‘ ์‹œ ํ‘œ์‹œ๊ฐ’์ด node ์ˆ˜์™€ ๊ฐ™์•„์งˆ ๋•Œ๊นŒ์ง€ ์งง๊ฒŒ(์ตœ๋Œ€ 5๋ถ„) ํ™•์ธํ•˜๊ณ , ๊ทธ๋ž˜๋„ ๋‹ค๋ฅด๋ฉด ๊ทธ๋Œ€๋กœ ์‹œ๋„ํ•œ๋‹ค(์‹คํŒจํ•˜๋ฉด ์ œํ’ˆ์˜ ํ‘œ์‹œ ์ง€์—ฐ์œผ๋กœ ๊ธฐ๋ก).
// ๋Œ€์ƒ ๋ณ€๊ฒฝ: K8S_CLUSTER / K8S_NG ํ™˜๊ฒฝ๋ณ€์ˆ˜

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

const INPUT: Record<string, { cluster: string; ng: string } | undefined> = {
  AWS: { cluster: process.env.K8S_CLUSTER ?? 'k1d', ng: process.env.K8S_NG ?? 'g1d' },
  GCP: { cluster: process.env.K8S_CLUSTER ?? 'k4s', ng: process.env.K8S_NG ?? 'g4s' }, // K8s CSP ์‚ฌ์ดํด(p4, 2026-10-15)
};

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  const inp = INPUT[c.code];
  const prj = k8sProject(c);
  test(`IV4_S16_K8sWorkloads_EditScaling-ScaleIn_${c.code} [K8s] K8s NodeGroup scale in 2โ†’1 (K8s node)`, async ({ page }) => {
    test.setTimeout(60 * 60_000);
    test.skip(!inp, `${c.name}: ๋Œ€์ƒ ๋ฏธ์ง€์ •`);
    test.info().annotations.push({ type: 'target', description: `K8s NodeGroup (Infra ์•„๋‹˜): cluster=${inp!.cluster}, nodegroup=${inp!.ng}` });
    await loginAsAdmin(page);
    await gotoK8s(page, prj);
    let before = await selectNodeGroup(page, inp!.cluster, inp!.ng);
    test.skip(before.nodes.length !== 2, `์„ ํ–‰ ์กฐ๊ฑด ์—†์Œ: ${inp!.ng} node ๊ฐ€ ${before.nodes.length}๊ฐœ(2๊ฐœ์—ฌ์•ผ ํ•จ, IV4_S02 Scale out ๋จผ์ €)`);
    expect(before.info, 'NodeGroup Active').toMatch(/Status Active/i);
    // Desired ํ‘œ์‹œ ๊ฐฑ์‹  ํ™•์ธ(์ตœ๋Œ€ 5๋ถ„)
    const t0 = Date.now();
    for (let i = 0; i < 5 && before.desired !== before.nodes.length; i++) {
      await page.waitForTimeout(60_000);
      await gotoK8s(page, prj);
      before = await selectNodeGroup(page, inp!.cluster, inp!.ng);
    }
    test.info().annotations.push({ type: 'before', description: `Desired ํ‘œ์‹œ ${before.desired} / node ${before.nodes.length}๊ฐœ (ํ‘œ์‹œ ํ™•์ธ ${Math.round((Date.now() - t0) / 1000)}s) ${before.info.slice(0, 300)}` });
    await editScaling(page, 1);
    let st = before;
    await expect.poll(async () => { await gotoK8s(page, prj); st = await selectNodeGroup(page, inp!.cluster, inp!.ng); return st.nodes.length; },
      // AWS K8s Scale in ์€ node ๊ฐ€ ์ค„์–ด๋“œ๋Š” ๋ฐ 14๋ถ„ ์ด์ƒ ๊ฑธ๋ฆฐ๋‹ค(2026-10-07: 15๋ถ„ ํ•œ๋„ ์ดˆ๊ณผ๋กœ ์‹คํŒจ, ์ดํ›„ 1๊ฐœ ํ™•์ธ) โ†’ 40๋ถ„๊นŒ์ง€ ๋Œ€๊ธฐ
      { message: `${inp!.ng} node ์ˆ˜ 1`, timeout: 40 * 60_000, intervals: [30_000] }).toBe(1);
    test.info().annotations.push({ type: 'after', description: `Desired ํ‘œ์‹œ ${st.desired} / node ${st.nodes.length}๊ฐœ / ${st.info.slice(0, 300)}` });
    // GKE ๋Š” node ์ˆ˜๊ฐ€ ๋ฐ”๋€ ๋’ค์—๋„ NodeGroup ์ด ํ•œ๋™์•ˆ Updating(2026-10-15) โ€” Active ๋ณต๊ท€๊นŒ์ง€ ์ตœ๋Œ€ 20๋ถ„ ์žฌ์กฐํšŒ
    await expect.poll(async () => { if (/Status Active/i.test(st.info)) return 'Active'; await page.waitForTimeout(30_000); await gotoK8s(page, prj); st = await selectNodeGroup(page, inp!.cluster, inp!.ng); return st.info.match(/Status (\S+)/)?.[1] ?? '?'; },
      { message: 'NodeGroup Active ๋ณต๊ท€', timeout: 20 * 60_000, intervals: [1000] }).toBe('Active');
  });
}

IV4_S17

tests/iv4/IV4_S17.spec.ts ยท 33์ค„ ยท ํ•ด์‹œ 70952fab ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S17: TC-INFRA-026 SSH ์ ‘์† ์ •๋ณด ํ™•์ธ [์ •์ƒ, AWS]
// v0.6 TC-ID: TC-INFRA-SSH-01 (Drive v0.6: ์ ‘์† ์ •๋ณด ํš๋“) โ€” ๋ ˆ๊ฑฐ์‹œ registry ์˜ TC-INFRA-SSH-01 ์€ 'SSH ํ‚ค ๋ชฉ๋ก ์กฐํšŒ'๋กœ ๋‹ค๋ฅธ ๊ธฐ๋Šฅ(ํ˜ผ๋™ ์ฃผ์˜).
//   ๊ธฐ์กด spec(mcmp-e2e integration/specs/02-use/infra/use-flow-infra-03-ssh-access.spec.ts, 05-scenarios/c5-service-management-infra.spec.ts)์€ ์ ˆ์ฐจ๋งŒ ์ฐธ๊ณ (์‚ฌ์šฉ์ž ๊ฒฐ์ • 2026-10-14)
// ํ™”๋ฉด: Workloads > Infra Workloads โ†’ Infra(OBS ๊ณต์šฉ mc1) โ†’ node g1-1 Node Info / Settings > Cloud Resources > SSH Keys โ†’ ํ•ด๋‹น Keypair ์ƒ์„ธ
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ, ws01/default. IV3 ๋กœ ๋งŒ๋“  Infra mc1 Running(์ฝ๊ธฐ๋งŒ)
// ์ ˆ์ฐจ: 1.Node Info ์˜ Public IP ยท User ID(cb-user) ยท Keypair Name ํ™•์ธ 2.SSH Keys ์—์„œ Keypair ์„ ํƒ โ†’ ์ƒ์„ธ: ์—ฐ๊ฒฐ๋œ node(associatedObjectList) ยท Private Key ๋งˆ์Šคํ‚น 3.Private Key Copy โ†’ ํด๋ฆฝ๋ณด๋“œ์— PEM
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: SSH ์ ‘์†์— ํ•„์š”ํ•œ ๊ณต์ธ IP ยท ์‚ฌ์šฉ์ž ยท ๊ฐœ์ธํ‚ค๋ฅผ ํ™”๋ฉด์—์„œ ์–ป์„ ์ˆ˜ ์žˆ๋‹ค
// ๋น„๊ณ : Private Key ๋Š” Show ๋ฅผ ๋ˆ„๋ฅด์ง€ ์•Š๊ณ (๋…นํ™” ๋…ธ์ถœ ๋ฐฉ์ง€) Copy ๋กœ๋งŒ ํ™•์ธํ•˜๋ฉฐ ๊ฐ’์€ ๊ธฐ๋กํ•˜์ง€ ์•Š๋Š”๋‹ค

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { nodeSshInfo, copyPrivateKey, SSH_INFRA, SSH_NODE } from '../../lib/iv4-ssh';

test('IV4_S17_AWS TC-INFRA-026 SSH ์ ‘์† ์ •๋ณด ํ™•์ธ', async ({ page }) => {
  const key = { assoc: [] as string[] };
  page.on('response', async (r) => {
    if (/GetSshKey$/.test(r.url())) { try { key.assoc = (await r.json())?.responseData?.associatedObjectList ?? []; } catch { /* */ } }
  });
  await loginAsAdmin(page);
  const info = await test.step(`1. ${SSH_INFRA}/${SSH_NODE} Node Info: Public IP ยท User ยท Keypair`, () => nodeSshInfo(page));
  test.info().annotations.push({ type: 'node', description: `ip=${info.ip} user=${info.user} keypair=${info.keypair}` });
  expect(info.ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
  expect(info.user).toBe('cb-user');
  expect(info.keypair).not.toBe('');
  await test.step(`2~3. SSH Keys โ†’ ${info.keypair} ์ƒ์„ธ ยท Private Key Copy`, async () => {
    const pem = await copyPrivateKey(page, info.keypair);
    const body = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
    test.info().annotations.push({ type: 'keypair', description: `์—ฐ๊ฒฐ node: ${key.assoc.join(', ')} / ํ™”๋ฉด ๋งˆ์Šคํ‚น: ${/\*{4,}/.test(body)} / ํด๋ฆฝ๋ณด๋“œ: ${pem.split('\n')[0]} โ€ฆ (${pem.length}์ž, ๊ฐ’ ๊ธฐ๋ก ์•ˆ ํ•จ)` });
    expect(key.assoc.some((a) => a.endsWith(`/infra/${SSH_INFRA}/node/${SSH_NODE}`)), 'Keypair ๊ฐ€ ๋Œ€์ƒ node ์— ์—ฐ๊ฒฐ').toBe(true);
    expect(pem).toMatch(/^-----BEGIN [A-Z ]*PRIVATE KEY-----/);
  });
});

IV4_S18

tests/iv4/IV4_S18.spec.ts ยท 45์ค„ ยท ํ•ด์‹œ 07d046aa ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S18: TC-INFRA-027 SSH ํด๋ผ์ด์–ธํŠธ๋กœ ๋…ธ๋“œ ์ ‘์† [์ •์ƒ, AWS]
// v0.6 TC-ID: TC-INFRA-SSH-02 (Drive v0.6: SSH ํด๋ผ์ด์–ธํŠธ ์ ‘์†) โ€” ๋ ˆ๊ฑฐ์‹œ registry ์˜ TC-INFRA-SSH-02 ๋Š” 'SSH ํ‚ค ์ƒ์„ฑ'์œผ๋กœ ๋‹ค๋ฅธ ๊ธฐ๋Šฅ.
//   ๊ธฐ์กด spec ์—๋Š” ์™ธ๋ถ€ SSH ํด๋ผ์ด์–ธํŠธ ์ ‘์† ์ฝ”๋“œ๊ฐ€ ์—†์Œ(์›น Terminal ๋งŒ: TC-WORKLOAD-MCI-01, IV4_S06)
// ํ™”๋ฉด: IV4_S17 ๋กœ ์–ป์€ ์ ‘์† ์ •๋ณด(Node Info ยท SSH Keys Copy) โ†’ ํ…Œ์ŠคํŠธ PC ์˜ ssh ํด๋ผ์ด์–ธํŠธ
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ, ws01/default. IV4_S17(์ ‘์† ์ •๋ณด ํš๋“), node Running, ๋ณด์•ˆ๊ทธ๋ฃน 22 ํ—ˆ์šฉ
// ์ ˆ์ฐจ: 1.Node Info ์—์„œ ๊ณต์ธ IP ยท ์‚ฌ์šฉ์ž 2.SSH Keys Copy ๋กœ ๊ฐœ์ธํ‚ค 3.๊ฐœ์ธํ‚ค๋ฅผ ์ž„์‹œ ํŒŒ์ผ(600)๋กœ ์ €์žฅ 4.ssh -i <ํ‚ค> cb-user@<๊ณต์ธ IP> 'hostname; whoami' 5.์ž„์‹œ ํ‚ค ํŒŒ์ผ ์‚ญ์ œ
// ์ž…๋ ฅ๊ฐ’(์‹œํŠธ): ๊ณต์ธ IP ยท SSH ์‚ฌ์šฉ์ž ยท ๊ฐœ์ธํ‚ค ํŒŒ์ผ
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: SSH ์ ‘์† ์„ฑ๊ณต, ๋…ธ๋“œ hostname ยท ์‚ฌ์šฉ์ž ์ถœ๋ ฅ
// ๋น„๊ณ : ์‹œํŠธ ์ ˆ์ฐจ๊ฐ€ ์ฝ˜์†” ๋ฐ–(SSH ํด๋ผ์ด์–ธํŠธ) ๋™์ž‘์ด๋ผ ํ…Œ์ŠคํŠธ ์‹คํ–‰ PC ์˜ ssh ๋กœ ์ˆ˜ํ–‰. 22 ํฌํŠธ๊ฐ€ ๋ง‰ํ˜€ ์žˆ์œผ๋ฉด ์›์ธ(๋ณด์•ˆ๊ทธ๋ฃน)์„ ๊ธฐ๋ก. ๊ฐœ์ธํ‚ค ๊ฐ’์€ ๊ธฐ๋กํ•˜์ง€ ์•Š์Œ

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { nodeSshInfo, copyPrivateKey, SSH_INFRA, SSH_NODE } from '../../lib/iv4-ssh';

test('IV4_S18_AWS TC-INFRA-027 SSH ํด๋ผ์ด์–ธํŠธ๋กœ ๋…ธ๋“œ ์ ‘์†', async ({ page }) => {
  await loginAsAdmin(page);
  const info = await test.step(`1. ${SSH_INFRA}/${SSH_NODE} ๊ณต์ธ IP ยท ์‚ฌ์šฉ์ž`, () => nodeSshInfo(page));
  const pem = await test.step('2. SSH Keys Copy ๋กœ ๊ฐœ์ธํ‚ค', () => copyPrivateKey(page, info.keypair));
  expect(pem).toMatch(/PRIVATE KEY/);
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iv4s18-'));
  const keyFile = path.join(dir, 'id');
  try {
    await test.step(`3~4. ssh -i <์ž„์‹œํ‚ค> ${info.user}@${info.ip} 'hostname; whoami'`, async () => {
      fs.writeFileSync(keyFile, pem.endsWith('\n') ? pem : pem + '\n', { mode: 0o600 });
      let out = '';
      try {
        out = execFileSync('ssh', ['-i', keyFile, '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'ConnectTimeout=20', '-o', 'BatchMode=yes',
          `${info.user}@${info.ip}`, 'hostname; whoami'], { encoding: 'utf-8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'] });
      } catch (e: any) {
        test.info().annotations.push({ type: 'ssh-error', description: String(e.stderr || e.message).replace(/\s+/g, ' ').slice(0, 400) });
        throw e;
      }
      test.info().annotations.push({ type: 'ssh', description: out.replace(/\s+/g, ' ').trim() });
      expect(out).toContain(info.user);
      expect(out.trim().split(/\s+/)[0]).toMatch(/^ip-|^[\w-]+$/);
    });
  } finally {
    fs.rmSync(dir, { recursive: true, force: true }); // 5. ์ž„์‹œ ํ‚ค ์‚ญ์ œ
  }
});

IV4_S19

tests/iv4/IV4_S19.spec.ts ยท 40์ค„ ยท ํ•ด์‹œ 99e3f989 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S19: TC-INFRA-037 kubectl ์ ‘์† ์ •๋ณด ํš๋“ (kubeconfig / token) [์ •์ƒ, CSP๋ณ„]
// v0.6 TC-ID: TC-INFRA-K8S-05 โ€” ๊ธฐ์กด spec(mcmp-e2e mc-web-console/specs/web-fix-003-k8s-kubeconfig-endpoint*.spec.ts)์€ ์ ˆ์ฐจ๋งŒ ์ฐธ๊ณ (์‚ฌ์šฉ์ž ๊ฒฐ์ • 2026-10-14)
// ํ™”๋ฉด: Workloads > K8s Workloads(Project p{n}) โ†’ ํด๋Ÿฌ์Šคํ„ฐ ์„ ํƒ โ†’ Cluster Info ์˜ Copy KubeConfig
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ, ws01/p{n}. ํด๋Ÿฌ์Šคํ„ฐ Running(K8s CSP ์‚ฌ์ดํด ํด๋Ÿฌ์Šคํ„ฐ k{n}s โ€” ์‚ฌ์šฉ์ž ๊ฒฐ์ • 2026-10-15: CSP ํ•˜๋‚˜์”ฉ)
// ์ ˆ์ฐจ: 1.ํด๋Ÿฌ์Šคํ„ฐ ์ƒ์„ธ ์ง„์ž… 2.Copy KubeConfig โ†’ ํด๋ฆฝ๋ณด๋“œ = ํ™”๋ฉด์ด ๋ฐ›์€ kubeconfig(GET โ€ฆ/k8sCluster/{id}/kubeconfig) 3.exec ์ธ์ฆ CSP(AWS EKS, GCP GKE)๋Š” ๋‹จ๊ธฐ ํ† ํฐ(GET โ€ฆ/k8sCluster/{id}/token) ๋ฐœ๊ธ‰
// ์ž…๋ ฅ๊ฐ’: nsId, k8sClusterId
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: EKS/GKE ๋Š” exec ํ”Œ๋Ÿฌ๊ทธ์ธ ์ฐธ์กฐ kubeconfig + ๋‹จ๊ธฐ ํ† ํฐ, Azure/๊ธฐํƒ€๋Š” ์ธ์ฆ์„œยท๋น„๋ฐ€๋ฒˆํ˜ธ ๋‚ด์žฅ ์ •์  kubeconfig
// ๋น„๊ณ : ํ† ํฐ ๋ฐœ๊ธ‰์€ ํ™”๋ฉด ๋ฒ„ํŠผ์ด ์—†์–ด ์ฝ˜์†” API(GetK8sClusterToken)๋กœ ํ™•์ธ. kubeconfigยทtoken ๊ฐ’์€ ๊ธฐ๋กํ•˜์ง€ ์•Š์Œ
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { copyKubeconfig, consoleOp, K8S_CYCLE_CLUSTER } from '../../lib/iv4-kubectl';

const EXEC = ['AWS', 'GCP'];
for (const c of activeCsps(['AWS', 'Azure', 'Alibaba', 'GCP', 'Tencent', 'NHN Cloud', 'NCP', 'IBM'])) {
  test(`${tid('IV4_S19', c)} TC-INFRA-037 kubectl ์ ‘์† ์ •๋ณด ํš๋“ (kubeconfig / token)`, async ({ page }) => {
    const prj = k8sProject(c);
    const cluster = K8S_CYCLE_CLUSTER(c.n);
    await loginAsAdmin(page);
    const k = await test.step(`1~2. ${prj}/${cluster} โ†’ Copy KubeConfig`, () => copyKubeconfig(page, prj, cluster));
    const kind = /exec:/.test(k.clip) ? `exec(${(k.clip.match(/command:\s*(\S+)/) ?? [])[1]})` : /token:|client-certificate-data|password:/.test(k.clip) ? 'static' : 'unknown';
    test.info().annotations.push({ type: 'kubeconfig', description: `${k.clip.length}์ž, ๋ฐฉ์‹ ${kind}, server ${(k.clip.match(/server:\s*(\S+)/) ?? [])[1] ?? '?'} / ์•ˆ๋‚ด ${k.note} (๊ฐ’ ๊ธฐ๋ก ์•ˆ ํ•จ)` });
    expect(k.clip.length).toBeGreaterThan(100);
    expect(k.clip, 'ํด๋ฆฝ๋ณด๋“œ = ํ™”๋ฉด์ด ๋ฐ›์€ kubeconfig').toBe(k.api);
    expect(k.clip).toMatch(/apiVersion: v1[\s\S]*clusters:[\s\S]*users:/);
    if (EXEC.includes(c.code)) {
      expect(kind, 'EKS/GKE ๋Š” exec ํ”Œ๋Ÿฌ๊ทธ์ธ').toMatch(/^exec/);
      await test.step('3. ๋‹จ๊ธฐ ํ† ํฐ ๋ฐœ๊ธ‰(GetK8sClusterToken)', async () => {
        const t = await consoleOp(page, 'GetK8sClusterToken', { nsId: prj, k8sClusterId: cluster });
        const keys = (o: any, p = ''): string[] => (o && typeof o === 'object' ? Object.entries(o).flatMap(([k, v]) => [p + k, ...keys(v, `${p}${k}.`)]) : []);
        const ec = t.data?.execCredential ?? t.data;
        const tok = ec?.status?.token ?? '';
        test.info().annotations.push({ type: 'token', description: `HTTP ${t.status} ํ‚ค ${keys(t.data).join(',')} / ํ† ํฐ ๊ธธ์ด ${String(tok).length}, ๋งŒ๋ฃŒ ${ec?.status?.expirationTimestamp ?? '?'} (๊ฐ’ ๊ธฐ๋ก ์•ˆ ํ•จ)` });
        expect(t.status).toBe(200);
        expect(String(tok).length, '๋‹จ๊ธฐ ํ† ํฐ ๋ฐœ๊ธ‰').toBeGreaterThan(20);
      });
    } else expect(kind, '์ •์  kubeconfig').toBe('static');
  });
}

IV4_S20

tests/iv4/IV4_S20.spec.ts ยท 51์ค„ ยท ํ•ด์‹œ e057a45c ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

// IV4_S20: TC-INFRA-038 kubectl ๋กœ ์•ฑ ๋ฐฐํฌ [์ •์ƒ, CSP๋ณ„]
// v0.6 TC-ID: TC-INFRA-K8S-06 โ€” ๊ธฐ์กด spec ์—†์Œ(์›น Helm ๋ฐฐํฌ๋งŒ ์žˆ์Œ). 2026-10-15 ์ž‘์„ฑ
// ํ™”๋ฉด: IV4_S19 ์˜ Copy KubeConfig โ†’ ํ…Œ์ŠคํŠธ PC ์˜ kubectl
// ์‹œ์ž‘ ์ƒํƒœ: admin(mcmp) ๋กœ๊ทธ์ธ, ws01/p{n}. IV4_S19(kubeconfig), ํด๋Ÿฌ์Šคํ„ฐยทNodeGroup Ready
// ์ ˆ์ฐจ: 1.Copy KubeConfig(+ exec CSP ๋Š” GetK8sClusterToken ํ† ํฐ์œผ๋กœ user ์ธ์ฆ ๋Œ€์ฒด) 2.kubectl get nodes 3.kubectl apply -f nginx-deployment(์ด๋ฆ„ iv4-s20-nginx) 4.pod Running 5.(์ •๋ฆฌ) kubectl delete
// ์ž…๋ ฅ๊ฐ’(์‹œํŠธ): kubeconfig ํŒŒ์ผ, ๋ฐฐํฌ manifest(nginx-deployment.yaml)
// ๊ธฐ๋Œ€๊ฒฐ๊ณผ: deploymentยทpod ์ƒ์„ฑ ์„ฑ๊ณต, pod Running
// ๋น„๊ณ : EKS exec(aws-iam-authenticator)๋Š” ํ…Œ์ŠคํŠธ PC ์˜ AWS ์ž๊ฒฉ๊ณผ ํด๋Ÿฌ์Šคํ„ฐ ์ƒ์„ฑ ์ž๊ฒฉ์ด ๋‹ฌ๋ผ ์“ฐ์ง€ ์•Š๊ณ , ์ฝ˜์†”์ด ๋ฐœ๊ธ‰ํ•œ ํ† ํฐ์œผ๋กœ ์ ‘์†. ์ž„์‹œ kubeconfig ๋Š” ๋๋‚˜๋ฉด ์‚ญ์ œ
import fs from 'node:fs';
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { copyKubeconfig, consoleOp, writeTokenKubeconfig, kubectl, K8S_CYCLE_CLUSTER } from '../../lib/iv4-kubectl';

const NAME = 'iv4-s20-nginx';
const MANIFEST = `apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: ${NAME}\n  namespace: default\nspec:\n  replicas: 1\n  selector:\n    matchLabels: {app: ${NAME}}\n  template:\n    metadata:\n      labels: {app: ${NAME}}\n    spec:\n      containers:\n      - name: nginx\n        image: nginx:stable\n        ports: [{containerPort: 80}]\n`;
for (const c of activeCsps(['AWS', 'Azure', 'Alibaba', 'GCP', 'Tencent', 'NHN Cloud', 'NCP', 'IBM'])) {
  test(`${tid('IV4_S20', c)} TC-INFRA-038 kubectl ๋กœ ์•ฑ ๋ฐฐํฌ`, async ({ page }) => {
    test.setTimeout(20 * 60_000);
    const prj = k8sProject(c);
    const cluster = K8S_CYCLE_CLUSTER(c.n);
    await loginAsAdmin(page);
    const k = await copyKubeconfig(page, prj, cluster);
    let token: string | undefined;
    if (/exec:/.test(k.clip)) {
      const t = await consoleOp(page, 'GetK8sClusterToken', { nsId: prj, k8sClusterId: cluster });
      token = (t.data?.execCredential ?? t.data)?.status?.token; // ExecCredential(client.authentication.k8s.io)
      expect(token, '๋ฐœ๊ธ‰ ํ† ํฐ').toBeTruthy();
    }
    const { dir, f } = writeTokenKubeconfig(k.clip, token);
    try {
      await test.step('2. kubectl get nodes', async () => {
        const out = kubectl(f, ['get', 'nodes', '-o', 'wide']);
        test.info().annotations.push({ type: 'nodes', description: out.replace(/\s+/g, ' ').slice(0, 400) });
        expect(out).toMatch(/\bReady\b/);
      });
      await test.step(`3~4. kubectl apply โ†’ ${NAME} pod Running`, async () => {
        const mf = `${dir}/nginx.yaml`; fs.writeFileSync(mf, MANIFEST);
        test.info().annotations.push({ type: 'apply', description: kubectl(f, ['apply', '-f', mf]).trim() });
        kubectl(f, ['rollout', 'status', `deployment/${NAME}`, '--timeout=300s'], 330_000);
        const pods = kubectl(f, ['get', 'pods', '-l', `app=${NAME}`, '-o', 'wide']);
        test.info().annotations.push({ type: 'pods', description: pods.replace(/\s+/g, ' ').slice(0, 300) });
        expect(pods).toMatch(/Running/);
      });
    } finally {
      try { test.info().annotations.push({ type: 'cleanup', description: kubectl(f, ['delete', 'deployment', NAME, '--ignore-not-found']).trim() }); } catch (e) { /* ์ •๋ฆฌ ์‹คํŒจ๋Š” ๊ธฐ๋ก๋งŒ */ }
      fs.rmSync(dir, { recursive: true, force: true });
    }
  });
}
โš ๏ธ **GitHub.com Fallback** โš ๏ธ