Test Specs IV4 - MZC-CSC/mc-web-console GitHub Wiki
์์ฑ: 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 |
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); // ๋
นํ ๋ง๋ฌด๋ฆฌ
});
}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);
});
}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); // ๋
นํ ๋ง๋ฌด๋ฆฌ
});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); // ๋
นํ ๋ง๋ฌด๋ฆฌ
});
}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); // ๋
นํ ๋ง๋ฌด๋ฆฌ
});
}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'));
});
});
}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();
}
}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);
});
});
}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); // ๋
นํ ๋ง๋ฌด๋ฆฌ
});
}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); // ๋
นํ ๋ง๋ฌด๋ฆฌ
});
}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); // ๋
นํ ๋ง๋ฌด๋ฆฌ
});
}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); // ๋
นํ ๋ง๋ฌด๋ฆฌ
});
}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); // ๋
นํ ๋ง๋ฌด๋ฆฌ
});
}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); // ๋
นํ ๋ง๋ฌด๋ฆฌ
});
}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); // ๋
นํ ๋ง๋ฌด๋ฆฌ
});
}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');
});
}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-----/);
});
});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. ์์ ํค ์ญ์
}
});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');
});
}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 });
}
});
}