Repository navigation
Test Specs IV4
생성: 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 });
}
});
}