Repository navigation
Test Specs INFRA
생성: 2026-10-22 · 기준: v07 설치 후 통합 검증(IV) · 원본: 여정별 계획서(Excel) ← Test Scenarios · IV 허브 · 기능 TC
mc-infra-manager 기능 TC 의 Playwright(TypeScript) spec 소스입니다. 절마다 앵커는 #<TC-ID 소문자>(예: #tc-infra-001)이고, 제목 끝 (구 …) 는 이전 시나리오 번호입니다. 공용 헬퍼는 Test-Specs-Common. 비밀번호 등은 마스킹했습니다.
| TC | 구 번호 | 파일 | 줄 | 해시 |
|---|---|---|---|---|
| TC-INFRA-001 | IV1_S32 | tests/infra/TC-INFRA-001.spec.ts |
43 | 234e7da5 |
| TC-INFRA-002 | IV1_S33 | tests/infra/TC-INFRA-002.spec.ts |
37 | d0e960d4 |
| TC-INFRA-003 | IV1_S34 | tests/infra/TC-INFRA-003.spec.ts |
38 | 0136433f |
| TC-INFRA-004 | IV1_S35 | tests/infra/TC-INFRA-004.spec.ts |
6 | 77867b11 |
| TC-INFRA-005 | IV1_S36 | tests/infra/TC-INFRA-005.spec.ts |
7 | ccb3495d |
| TC-INFRA-006 | IV1_S37 | tests/infra/TC-INFRA-006.spec.ts |
26 | b845a82c |
| TC-INFRA-007 | IV2_S01 | tests/infra/TC-INFRA-007.spec.ts |
15 | 4f27d30b |
| TC-INFRA-008 | IV2_S02 | tests/infra/TC-INFRA-008.spec.ts |
15 | 46c3bada |
| TC-INFRA-009 | IV2_S03 | tests/infra/TC-INFRA-009.spec.ts |
15 | 298328ae |
| TC-INFRA-010 | IV2_S04 | tests/infra/TC-INFRA-010.spec.ts |
15 | 31d65e2a |
| TC-INFRA-011 | IV3_S14 | tests/infra/TC-INFRA-011.spec.ts |
22 | 38c451fa |
| TC-INFRA-012 | IV2_S05 | tests/infra/TC-INFRA-012.spec.ts |
53 | 4b06d481 |
| TC-INFRA-013 | IV2_S06 | tests/infra/TC-INFRA-013.spec.ts |
88 | f240d0bb |
| TC-INFRA-014 | IV2_S07 | tests/infra/TC-INFRA-014.spec.ts |
31 | 0c7c5fc8 |
| TC-INFRA-015 | IV2_S08 | tests/infra/TC-INFRA-015.spec.ts |
41 | 4eef14f2 |
| TC-INFRA-016 | IV3_S23 | tests/infra/TC-INFRA-016.spec.ts |
42 | 82984fa2 |
| TC-INFRA-017 | IV3_S24 | tests/infra/TC-INFRA-017.spec.ts |
39 | 815221fe |
| TC-INFRA-018 | IV3_S02 | tests/infra/TC-INFRA-018.spec.ts |
47 | 407e8dad |
| TC-INFRA-019 | IV3_S10 | tests/infra/TC-INFRA-019.spec.ts |
59 | b51a11be |
| TC-INFRA-020 | IV3_S48 | tests/infra/TC-INFRA-020.spec.ts |
52 | 9399dc5c |
| TC-INFRA-021 | IV3_S49 | tests/infra/TC-INFRA-021.spec.ts |
60 | 7b818b5c |
| TC-INFRA-022 | IV3_S17 | tests/infra/TC-INFRA-022.spec.ts |
41 | 30a5aa4b |
| TC-INFRA-023 | IV3_S18 | tests/infra/TC-INFRA-023.spec.ts |
89 | dae0cc4b |
| TC-INFRA-024 | IV3_S19 | tests/infra/TC-INFRA-024.spec.ts |
74 | 6c69e2d5 |
| TC-INFRA-025 | IV3_S22 | tests/infra/TC-INFRA-025.spec.ts |
49 | 5903d95b |
| TC-INFRA-026 | IV4_S17 | tests/infra/TC-INFRA-026.spec.ts |
34 | d04e3c97 |
| TC-INFRA-027 | IV4_S18 | tests/infra/TC-INFRA-027.spec.ts |
46 | e015c85f |
| TC-INFRA-028 | IV4_S06 | tests/infra/TC-INFRA-028.spec.ts |
116 | 4ff4042c |
| TC-INFRA-029 | IV4_S12 | tests/infra/TC-INFRA-029.spec.ts |
85 | fdf31b3d |
| TC-INFRA-030 | IV3_S03 | tests/infra/TC-INFRA-030.spec.ts |
120 | de2b9892 |
| TC-INFRA-031 | IV4_S07 | tests/infra/TC-INFRA-031.spec.ts |
133 | c24289cf |
| TC-INFRA-032 | IV4_S08 | tests/infra/TC-INFRA-032.spec.ts |
181 | be0a743c |
| TC-INFRA-033 | IV3_S21 | tests/infra/TC-INFRA-033.spec.ts |
60 | 5d238d08 |
| TC-INFRA-034 | IV3_S20 | tests/infra/TC-INFRA-034.spec.ts |
75 | 8e863c1e |
| TC-INFRA-035 | IV4_S02 | tests/infra/TC-INFRA-035.spec.ts |
95 | 876599b1 |
| TC-INFRA-036 | IV4_S16 | tests/infra/TC-INFRA-036.spec.ts |
100 | 24cfb3c6 |
| TC-INFRA-037 | IV4_S19 | tests/infra/TC-INFRA-037.spec.ts |
40 | 93deea9d |
| TC-INFRA-038 | IV4_S20 | tests/infra/TC-INFRA-038.spec.ts |
51 | 5b27d35a |
| TC-INFRA-039 | IV3_S04 | tests/infra/TC-INFRA-039.spec.ts |
39 | 54fb560e |
| TC-INFRA-040 | IV3_S05 | tests/infra/TC-INFRA-040.spec.ts |
69 | 9c6811a4 |
| TC-INFRA-041 | IV3_S15 | tests/infra/TC-INFRA-041.spec.ts |
36 | ce1c96e6 |
| TC-INFRA-042 | IV3_S25 | tests/infra/TC-INFRA-042.spec.ts |
40 | 3ee3783a |
| TC-INFRA-043 | IV3_S16 | tests/infra/TC-INFRA-043.spec.ts |
37 | 6b312d78 |
| TC-INFRA-044 | IV3_S30 | tests/infra/TC-INFRA-044.spec.ts |
54 | 6e89942a |
| TC-INFRA-045 | IV3_S07 | tests/infra/TC-INFRA-045.spec.ts |
106 | 205ea527 |
| TC-INFRA-046 | IV3_S29 | tests/infra/TC-INFRA-046.spec.ts |
75 | 59020982 |
| TC-INFRA-047 | IV3_S06 | tests/infra/TC-INFRA-047.spec.ts |
61 | b8385420 |
| TC-INFRA-048 | IV4_S01 | tests/infra/TC-INFRA-048.spec.ts |
81 | 6f7a5106 |
| TC-INFRA-049 | IV4_S03 | tests/infra/TC-INFRA-049.spec.ts |
74 | 1267b6e1 |
| TC-INFRA-050 | IV4_S04 | tests/infra/TC-INFRA-050.spec.ts |
52 | 52d8b72a |
| TC-INFRA-051 | IV4_S15 | tests/infra/TC-INFRA-051.spec.ts |
48 | 0fa435c5 |
| TC-INFRA-052 | IV4_S05 | tests/infra/TC-INFRA-052.spec.ts |
52 | 62576e12 |
| TC-INFRA-053 | IV4_S09 | tests/infra/TC-INFRA-053.spec.ts |
75 | 17d4affe |
| TC-INFRA-054 | IV4_S10 | tests/infra/TC-INFRA-054.spec.ts |
72 | cae83f0f |
| TC-INFRA-055 | IV4_S11 | tests/infra/TC-INFRA-055.spec.ts |
154 | cf4a1dad |
| TC-INFRA-056 | CL_S02 | tests/infra/TC-INFRA-056.spec.ts |
60 | 14f207cd |
| TC-INFRA-057 | IV4_S13 | tests/infra/TC-INFRA-057.spec.ts |
95 | d6df5bca |
| TC-INFRA-058 | IV4_S14 | tests/infra/TC-INFRA-058.spec.ts |
67 | 52e320f1 |
| TC-INFRA-059 | CL_S01 | tests/infra/TC-INFRA-059.spec.ts |
49 | 62aec192 |
| TC-INFRA-060 | IV3_S31 | tests/infra/TC-INFRA-060.spec.ts |
70 | b819ce5c |
| TC-INFRA-061 | IV3_S32 | tests/infra/TC-INFRA-061.spec.ts |
48 | 6873797a |
| TC-INFRA-062 | IV3_S47 | tests/infra/TC-INFRA-062.spec.ts |
95 | d267a7f0 |
tests/infra/TC-INFRA-001.spec.ts · 43줄 · 해시 234e7da5 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoCredentials, holderRow, rowCells, consoleApi } from '../../lib/credentials';
// TC-INFRA-001 Credential 목록 조회 (Settings > Cloud SPs > Credentials)
// 출처: 위키 Test-Specs-IV10 의 IV10_S01(다른 세션 작성, 2026-10-14). 같은 날 IV1(플랫폼 온보딩)로 편입하며 번호만 바꿈
test('TC-INFRA-001 Credential 목록 조회 (구 TC-INFRA-001)', async ({ page }) => {
await test.step('1. admin 로그인', () => loginAsAdmin(page));
await test.step('2. Settings > Cloud SPs > Credentials 이동 → 목록 표시', () => gotoCredentials(page));
await test.step('3. 기본 holder(admin) 행의 Providers·Connections·Verified·Default 표시 확인', async () => {
const row = holderRow(page, 'admin');
await expect(row).toHaveCount(1);
const cells = await rowCells(row);
test.info().annotations.push({ type: 'admin-row', description: JSON.stringify(cells) });
expect(Object.values(cells).join(' ')).toMatch(/\d+/);
});
await test.step('4. 화면 목록 = GetCredentialHolderList 응답 (holder 이름·수)', async () => {
const api = await consoleApi(page, 'GetCredentialHolderList');
const list: any[] = api.data?.responseData?.credentialHolderList ?? [];
const shown = await page.locator('#credential-table [tabulator-field="credentialHolder"]').filter({ hasNotText: 'Credential Holder' }).allInnerTexts();
test.info().annotations.push({ type: 'holders', description: `API ${list.map((h) => `${h.credentialHolder}(${h.connectionCount}/${h.verifiedConnectionCount})`).join(', ')} / 화면 ${shown.map((s) => s.trim()).join(', ')}` });
expect(shown.map((s) => s.trim()).sort()).toEqual(list.map((h) => h.credentialHolder).sort());
});
// 2026-10-19 사용자 지시: Credential 화면에서 항목을 클릭해 상세를 보고 3초 후 종료
await test.step('5. admin 항목 클릭 → 상세 확인', async () => {
const before = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
await holderRow(page, 'admin').locator('[tabulator-field="credentialHolder"]').first().click();
await page.waitForTimeout(2500);
const after = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
// 클릭 후 새로 나타난 영역(상세) 텍스트 기록
const added = after.length > before.length ? after.slice(after.indexOf(before.slice(-40)) + 40 || 0) : '';
test.info().annotations.push({ type: 'detail', description: (added || after.slice(-600)).slice(0, 600) });
const detail = page.locator('.card, .modal.show').filter({ hasText: /admin/ }).last();
await detail.scrollIntoViewIfNeeded().catch(() => {});
expect(after, '상세 표시(클릭 후 화면 변화)').not.toBe(before);
await page.waitForTimeout(3000);
});
});tests/infra/TC-INFRA-002.spec.ts · 37줄 · 해시 d0e960d4 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoCredentials, holderRow, consoleApi } from '../../lib/credentials';
// TC-INFRA-002 Credential 단건 조회 — 목록에서 holder 클릭 → 상세 패널, GetCredentialHolder 응답과 비교
// 출처: 위키 Test-Specs-IV10 의 IV10_S02(다른 세션 작성, 2026-10-14). 같은 날 IV1(플랫폼 온보딩)로 편입하며 번호만 바꿈
const HOLDER = 'admin';
test('TC-INFRA-002 Credential 단건 조회 (구 TC-INFRA-002)', async ({ page }) => {
await test.step('1. admin 로그인 후 Credentials 이동', async () => { await loginAsAdmin(page); await gotoCredentials(page); });
await test.step(`2. 목록에서 ${HOLDER} 행 클릭 → 상세 패널 표시`, async () => {
await holderRow(page, HOLDER).click();
await expect(page.locator('#credential-detail-panel')).toBeVisible();
await expect(page.locator('#detail-holder-id')).toHaveText(HOLDER);
});
await test.step('3. 상세(Providers·Connections·Verified·Default) = GetCredentialHolder 응답', async () => {
const ui = {
providers: (await page.locator('#detail-holder-provider').innerText()).replace(/\s+/g, ' ').trim(),
connections: (await page.locator('#detail-holder-connections').innerText()).trim(),
verified: (await page.locator('#detail-holder-verified').innerText()).trim(),
isDefault: (await page.locator('#detail-holder-default').innerText()).trim(),
};
const api = await consoleApi(page, 'GetCredentialHolder', { pathParams: { holderId: HOLDER } });
const h = api.data?.responseData ?? {};
test.info().annotations.push({ type: 'detail', description: `화면 ${JSON.stringify(ui)} / API(${api.status}) ${JSON.stringify(h).slice(0, 300)}` });
expect(api.status, 'GetCredentialHolder 호출 실패').toBe(200);
expect(h.credentialHolder).toBe(HOLDER);
expect(Number(ui.connections)).toBe(h.connectionCount);
expect(Number(ui.verified)).toBe(h.verifiedConnectionCount);
for (const p of h.providers ?? []) expect(ui.providers.toLowerCase(), `Providers 에 ${p}`).toContain(String(p).toLowerCase());
});
// 2026-10-19 사용자 지시: 상세 내용 확인 후 3초 뒤 종료
await page.locator('#credential-detail-panel').scrollIntoViewIfNeeded().catch(() => {});
await page.waitForTimeout(3000);
});tests/infra/TC-INFRA-003.spec.ts · 38줄 · 해시 0136433f · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoCredentials, consoleApi, credValues, getHolder, scrub, TEST_HOLDER } from '../../lib/credentials';
// TC-INFRA-003 Credential 등록 — API (웹 콘솔 API POST /api/mc-infra-manager/RegisterCredential, 평문 → API 서버가 암호화)
// 출처: 위키 Test-Specs-IV10 의 IV10_S03(다른 세션 작성, 2026-10-14). 같은 날 IV1(플랫폼 온보딩)로 편입하며 번호만 바꿈
// mc-infra-manager 가 허용하는 CSP 형식 키(metainfo CredentialCSP: aws_access_key_id / aws_secret_access_key)로 holder 'test' 에 AWS 를 등록한다.
// 주의: mc-infra-manager 에 credential 삭제 API 가 없어 등록 결과는 남는다(2026-10-14 사용자 승인).
const CSP = 'aws';
test('TC-INFRA-003 Credential 등록 (API) (구 TC-INFRA-003)', async ({ page }) => {
const v = credValues(CSP);
const kv = ['aws_access_key_id', 'aws_secret_access_key'].map((key) => ({ key, value: v[key] }));
expect(kv.every((x) => x.value), 'aws 자격증명 값 없음').toBe(true);
await test.step('1. admin 로그인 후 Credentials 이동', async () => { await loginAsAdmin(page); await gotoCredentials(page); });
await test.step(`2. RegisterCredential API 호출 — holder ${TEST_HOLDER}, provider ${CSP}, CSP 형식 키`, async () => {
const r = await consoleApi(page, 'RegisterCredential', { request: { credentialHolder: TEST_HOLDER, providerName: CSP, credentialKeyValueList: kv } });
const text = scrub(JSON.stringify(r.data ?? {}), v);
test.info().annotations.push({ type: 'register', description: `HTTP ${r.status}: ${text.slice(0, 300)}` });
expect(r.status, text.slice(0, 200)).toBe(200);
});
await test.step(`3. GetCredentialHolder(${TEST_HOLDER}) → ${CSP} 포함, 연결·verified 생성`, async () => {
const h = await getHolder(page, TEST_HOLDER);
test.info().annotations.push({ type: 'holder', description: JSON.stringify(h.info) });
expect(h.status).toBe(200);
expect(h.info.providers ?? []).toContain(CSP);
expect(h.info.connectionCount ?? 0).toBeGreaterThan(0);
expect(h.info.verifiedConnectionCount ?? 0).toBeGreaterThan(0);
});
await test.step(`4. 목록 새로고침 → ${TEST_HOLDER} 행 표시`, async () => {
await gotoCredentials(page);
await expect(page.locator('#credential-table [tabulator-field="credentialHolder"]', { hasText: TEST_HOLDER })).toBeVisible();
});
});tests/infra/TC-INFRA-004.spec.ts · 6줄 · 해시 77867b11 · 시나리오 보기
import { activeCsps, tid } from '../../lib/data';
import { uiRegisterTest, PROVIDER } from '../../lib/credentials';
// TC-INFRA-004 Credential 등록 — AWS UI (화면 자동 Key — WEB-TECH-108 수정 후 aws_access_key_id, aws_secret_access_key)
// 출처: 위키 Test-Specs-IV10 의 IV10_S04(다른 세션 작성, 2026-10-14). 같은 날 IV1(플랫폼 온보딩)로 편입하며 번호만 바꿈
for (const c of activeCsps(['AWS'])) uiRegisterTest(tid('TC-INFRA-004', c), 'TC-INFRA-004', PROVIDER[c.code]);tests/infra/TC-INFRA-005.spec.ts · 7줄 · 해시 ccb3495d · 시나리오 보기
import { activeCsps, tid } from '../../lib/data';
import { uiRegisterTest, PROVIDER } from '../../lib/credentials';
// TC-INFRA-005 Credential 등록 — CSP별 UI (Provider 마다 자동 생성 Key 가 다름). 테스트 1건 = CSP 1개
// 출처: 위키 Test-Specs-IV10 의 IV10_S05(다른 세션 작성, 2026-10-14). 같은 날 IV1(플랫폼 온보딩)로 편입하며 번호만 바꿈
// AWS 는 TC-INFRA-004. openstack 은 stage 에 연결 정보가 없어 제외. 대상 CSP 는 TC_VARIANT 로 고른다.
for (const c of activeCsps(['Azure', 'Alibaba', 'GCP', 'Tencent', 'NHN Cloud', 'NCP', 'IBM', 'KT'])) uiRegisterTest(tid('TC-INFRA-005', c), 'TC-INFRA-005', PROVIDER[c.code]);tests/infra/TC-INFRA-006.spec.ts · 26줄 · 해시 b845a82c · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoCredentials, holderRow, TEST_HOLDER } from '../../lib/credentials';
// TC-INFRA-006 Credential 삭제
// 출처: 위키 Test-Specs-IV10 의 IV10_S06(다른 세션 작성, 2026-10-14). 같은 날 IV1(플랫폼 온보딩)로 편입하며 번호만 바꿈
// 2026-10-14 확인: mc-infra-manager(edge) Swagger 에 credential·credentialHolder·connConfig 삭제 API 가 없고, Credentials 화면에도 삭제 기능이 없다.
// 화면에서 삭제 수단을 찾고, 없으면 기능 미제공으로 SKIP 한다(삭제 기능이 생기면 이 spec 을 삭제 절차로 바꾼다).
test('TC-INFRA-006 Credential 삭제 (구 TC-INFRA-006)', async ({ page }) => {
await test.step('1. admin 로그인 후 Credentials 이동', async () => { await loginAsAdmin(page); await gotoCredentials(page); });
const found = await test.step(`2. ${TEST_HOLDER} 행 선택 → 상세·목록·Actions 에서 Delete 기능 찾기`, async () => {
const row = holderRow(page, TEST_HOLDER);
if (await row.count()) await row.click();
const del = page.locator('#credential-detail-panel, .page-header, #page-header-btn-list, .card').getByRole('button', { name: /delete|remove/i });
const n = await del.count();
await page.screenshot({ path: test.info().outputPath('credentials-no-delete.png'), fullPage: true });
await test.info().attach('credentials-no-delete.png', { path: test.info().outputPath('credentials-no-delete.png'), contentType: 'image/png' });
test.info().annotations.push({ type: 'delete-ui', description: `Delete 버튼 ${n}개` });
return n > 0;
});
test.skip(!found, 'Credential 삭제 기능 미제공 — 화면에 삭제 버튼 없음, mc-infra-manager 에 삭제 API 없음');
expect(found).toBe(true);
});tests/infra/TC-INFRA-007.spec.ts · 15줄 · 해시 4f27d30b · 시나리오 보기
import { test } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { createListFilter, resourceBlocker } from '../../lib/resources';
// TC-INFRA-007 : vNet 생성 → 목록 표시 → CSP 필터 [정상, CSP별]
// Cloud Resources > Networks (Create VPC) → 목록 → Provider
// 재실행 가능: 같은 이름이 이미 있으면 생성은 건너뛰고 목록/필터만 검증한다. 엑셀의 CSP 행과 1:1 (TC_VARIANT 로 CSP 선택).
for (const c of activeCsps()) {
test(`${tid('TC-INFRA-007', c)} vNet 생성 → 목록 표시 → CSP 필터 (구 TC-INFRA-007)`, async ({ page }) => {
const why = resourceBlocker(c, 'vnet');
test.skip(!!why, why);
await createListFilter(page, c, 'vnet');
});
}tests/infra/TC-INFRA-008.spec.ts · 15줄 · 해시 46c3bada · 시나리오 보기
import { test } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { createListFilter, resourceBlocker } from '../../lib/resources';
// TC-INFRA-008 : Security Group 생성 → 목록 표시 → CSP 필터 [정상, CSP별]
// Cloud Resources > Security Groups (Create SG) → 목록 → Provider. 선행: 같은 CSP 의 vNet(TC-INFRA-007)
// 재실행 가능: 같은 이름이 이미 있으면 생성은 건너뛰고 목록/필터만 검증한다.
for (const c of activeCsps()) {
test(`${tid('TC-INFRA-008', c)} Security Group 생성 → 목록 표시 → CSP 필터 (구 TC-INFRA-008)`, async ({ page }) => {
const why = resourceBlocker(c, 'sg');
test.skip(!!why, why);
await createListFilter(page, c, 'sg');
});
}tests/infra/TC-INFRA-009.spec.ts · 15줄 · 해시 298328ae · 시나리오 보기
import { test } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { createListFilter, resourceBlocker } from '../../lib/resources';
// TC-INFRA-009 : SSH Key 생성 → 목록 표시 → CSP 필터 [정상, CSP별]
// Cloud Resources > SSH Keys (Create SSH Key) → 목록 → Provider
// 재실행 가능: 같은 이름이 이미 있으면 생성은 건너뛰고 목록/필터만 검증한다.
for (const c of activeCsps()) {
test(`${tid('TC-INFRA-009', c)} SSH Key 생성 → 목록 표시 → CSP 필터 (구 TC-INFRA-009)`, async ({ page }) => {
const why = resourceBlocker(c, 'key');
test.skip(!!why, why);
await createListFilter(page, c, 'key');
});
}tests/infra/TC-INFRA-010.spec.ts · 15줄 · 해시 31d65e2a · 시나리오 보기
import { test } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { createListFilter, resourceBlocker } from '../../lib/resources';
// TC-INFRA-010 : Data Disk 생성 → 목록 표시 → CSP 필터 [정상, CSP별]
// Cloud Resources > Disks (Create Data Disk) → 목록 → Provider
// 재실행 가능: 같은 이름이 이미 있으면 생성은 건너뛰고 목록/필터만 검증한다.
for (const c of activeCsps()) {
test(`${tid('TC-INFRA-010', c)} Data Disk 생성 → 목록 표시 → CSP 필터 (구 TC-INFRA-010)`, async ({ page }) => {
const why = resourceBlocker(c, 'disk');
test.skip(!!why, why);
await createListFilter(page, c, 'disk');
});
}tests/infra/TC-INFRA-011.spec.ts · 22줄 · 해시 38c451fa · 시나리오 보기
// TC-INFRA-011: Data Disk 생성 [정상, CSP별] — Disk 테스트 3분리(CreateDisk / AttachDisk / DetachDisk) 중 1 (사용자 지시 2026-10-05)
// 화면: Cloud Resources > Disks > Create Data Disk (Connection, Disk Name, Disk Type(optional), Disk Size, Description)
// 절차(AWS): 1.Disks 진입 2.Create Data Disk: Connection aws-ap-northeast-2, Name disk1d, Size 10GB → Create 3.목록에 disk1d Available 확인
// 기대결과: 토스트 'Data Disk "disk1d" created successfully', 목록에 Available(Attached To '-')
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { DISK_TARGET, createDisk, diskTargetBlocker, waitDiskState } from '../../lib/infra';
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-011", c)} ` + "Data Disk 생성 (CreateDisk) (구 TC-INFRA-011)", async ({ page }) => {
const why = diskTargetBlocker(c);
test.skip(!!why, why);
const tg = DISK_TARGET[c.code]!;
await loginAsAdmin(page);
await createDisk(page, c.infra.conn, tg.diskSplit, tg.size, tg.diskType);
const cells = await waitDiskState(page, tg.diskSplit, 'available');
expect(cells._attachedTo ?? '-', '미연결').toBe('-');
});
}tests/infra/TC-INFRA-012.spec.ts · 53줄 · 해시 4b06d481 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, menuSnapshot, selectWorkspaceProject } from '../../lib/ui';
import { WORKSPACE, DEFAULT_PROJECT } from '../../lib/data';
import { filterByProvider } from '../../lib/resources';
// TC-INFRA-012 : Cloud Resources 8종 목록 통합 조회 (멀티클라우드) -> CSP 필터
// 실제 메뉴명(2026-10-01 확인): Specs, Images, Networks, Security Groups, MyImages, SSH Keys, Disks, NLB (+ Resource Sync)
// 메뉴 클릭은 드롭다운 전개/선택 초기화로 timeout 이 났으므로(2026-10-02) 메뉴 href 를 읽어 직접 이동하고, 이동 후 ws01/default 를 다시 선택한다.
const CLOUD_RESOURCES = ['Networks', 'Security Groups', 'SSH Keys', 'Disks', 'Specs', 'Images', 'MyImages', 'NLB'];
// TC-INFRA-007~S04 로 여러 CSP 자원이 만들어지는 목록: 2개 이상 CSP 가 함께 표시 + Provider 필터 검증 대상
const MULTI = ['Networks', 'Security Groups', 'SSH Keys', 'Disks'];
const norm = (s: string) => s.toLowerCase().replace(/\s+/g, '');
test('TC-INFRA-012 8종 목록 통합 조회 (멀티클라우드) → CSP 필터 (구 TC-INFRA-012)', async ({ page }) => {
test.setTimeout(20 * 60_000);
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
await loginAsAdmin(page); // admin, ws01 / default
const links = (await menuSnapshot(page)).map((l) => l.split(' -> ')).filter(([, href]) => href?.includes('/cloudresources/') || /serverspecs|serverimages|nlbs/.test(href ?? ''));
test.info().annotations.push({ type: 'menu', description: links.map(([t, h]) => `${t}=${h}`).join(', ') });
for (const name of CLOUD_RESOURCES) {
const hit = links.find(([t]) => norm(t) === norm(name));
expect(hit, `Cloud Resources 메뉴에 ${name} 존재`).toBeTruthy();
await page.goto(hit![1]);
await selectWorkspaceProject(page, WORKSPACE, DEFAULT_PROJECT, true);
await page.getByText(/Showing \d+-\d+ of \d+ rows|No registered|No .* (found|registered)|Search with connection/i).first().waitFor({ timeout: 30_000 }).catch(() => {});
await page.waitForTimeout(1500);
await expect(page.locator('.tabulator, table, [role="grid"]').first(), `${name} 목록 표시`).toBeVisible();
const status = await page.getByText(/Showing \d+-\d+ of \d+ rows/).first().innerText().catch(() => '(행 없음/안내 문구)');
// 행별 [Name, Provider] (헤더 제목으로 열 찾기)
const rows = await page.locator('.tabulator').first().evaluate((t) => {
const cols = [...t.querySelectorAll('.tabulator-header .tabulator-col')];
const f = (title: string) => cols.find((c) => (c.textContent ?? '').trim() === title)?.getAttribute('tabulator-field');
const [fn, fp] = [f('Name'), f('Provider')];
if (!fp) return [] as string[][];
const cell = (r: Element, k?: string | null) => (k ? r.querySelector(`.tabulator-cell[tabulator-field="${k}"]`)?.textContent ?? '' : '').trim();
return [...t.querySelectorAll('.tabulator-row')].map((r) => [cell(r, fn), cell(r, fp).toLowerCase()]);
}).catch(() => [] as string[][]);
const uniq = [...new Set(rows.map((r) => r[1]).filter(Boolean))];
test.info().annotations.push({ type: `list:${name}`, description: `${status} / Provider: ${uniq.join(',') || '-'}` });
if (MULTI.includes(name)) {
expect(uniq.length, `${name}: 2개 이상 CSP 자원이 함께 표시`).toBeGreaterThanOrEqual(2);
// aws(없으면 첫 CSP)로 필터 → 그 CSP 자원만 남는지 검증
const prov = uniq.includes('aws') ? 'aws' : uniq[0];
await filterByProvider(page, prov, rows.find((r) => r[1] === prov)![0]);
}
}
expect(errors, 'JS 예외 없음').toEqual([]);
});tests/infra/TC-INFRA-013.spec.ts · 88줄 · 해시 f240d0bb · 시나리오 보기
// TC-INFRA-013: Resource Sync: NHN vNet 동기화 → Networks 목록에 NHN vNet 표시 [정상, NHN Cloud] (사용자 지시 2026-10-08)
// 화면: Cloud Resources > Resource Sync (Sync Status Resources 탭) → Cloud Resources > Networks
// 절차: 1.Resource Sync 진입 2.Search Conditions: Provider=Partial → NHN 체크, Connection=NHN 체크 → nhn-kr1, nhn-kr2 체크, Resource Type=Partial → vNet 체크 → Query
// 3.CSP Resource Sync Status 에서 Connection nhn-kr1, nhn-kr2 선택 4.Run Sync: Namespace=default, Target Provider=Partial → NHN 체크, Resource Type=Partial → vNet 체크 → Run Sync
// 5.Cloud Resources > Networks 에서 NHN vNet 이 목록에 표시되는지 확인(사용자 지시: 확인 방법)
// 기대결과: Run Sync 수행 후 Networks 목록에 Provider nhn 의 vNet 이 있다
// 화면 요소(2026-10-08 프로브): 조회 조건 radio name=query-provider-radio / query-restype-radio, 체크박스 value=nhn, nhn-kr1, nhn-kr2, vNet, Run Sync 영역 #sync-ns, radio name=provider-radio / restype-radio
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin, selectWorkspaceProject, toasts } from '../../lib/ui';
const SYNC_URL = '/webconsole/settings/environment/cloudresources/resourcesync';
const NETWORKS_URL = '/webconsole/settings/environment/cloudresources/networks';
const CONNS = ['nhn-kr1', 'nhn-kr2'];
for (const c of activeCsps(['NHN Cloud'])) {
test(`${tid('TC-INFRA-013', c)} Resource Sync: NHN vNet 동기화 → Networks 목록에 NHN vNet 표시 (구 TC-INFRA-013)`, async ({ page }) => {
test.setTimeout(15 * 60_000);
await loginAsAdmin(page);
await page.goto(SYNC_URL);
await selectWorkspaceProject(page, 'ws01', 'default', true);
await expect(page.locator('input[name="query-provider-radio"][value="partial"]')).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(3000);
// 2. Search Conditions
await page.locator('input[name="query-provider-radio"][value="partial"]').check();
await page.locator('input[value="nhn"]').first().check(); // Provider NHN
await expect(page.locator('input[value="nhn-kr1"]').first(), 'Provider NHN 체크 후 Connection 목록 표시').toBeVisible({ timeout: 15_000 });
await page.locator('input[value="nhn"]').nth(1).check(); // Connection 그룹 NHN
for (const cn of CONNS) {
const box = page.locator(`input[value="${cn}"]`).first();
if (!(await box.isChecked())) await box.check();
await expect(box, `${cn} 체크`).toBeChecked();
}
await page.locator('input[name="query-restype-radio"][value="partial"]').check();
await page.locator('input[value="vNet"]').first().check(); // 조회 조건의 vNet
await page.getByRole('button', { name: 'Query', exact: true }).click();
// 3. CSP Resource Sync Status: 'Fetching resource status...' 가 끝나면(약 15초) 연결별 행(Connection / vNet 등록수/전체수)이 나온다 → 행을 눌러 연결 선택
await expect(page.getByText('Fetching resource status')).toBeHidden({ timeout: 180_000 });
await page.waitForTimeout(1500);
const bodyAfterQuery = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
const si = bodyAfterQuery.indexOf('CSP Resource Sync Status');
test.info().annotations.push({ type: 'sync-status', description: bodyAfterQuery.slice(si, si + 400) });
for (const cn of CONNS) {
const row = page.getByRole('row').filter({ has: page.getByRole('gridcell', { name: cn, exact: true }) }).first();
await expect(row, `Sync Status 에 ${cn} 행`).toBeVisible({ timeout: 30_000 });
await row.getByRole('checkbox').first().check(); // 행 클릭은 Connection Detail 만 열고, Target Connection 은 행 체크박스로 선택된다
}
const target = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
test.info().annotations.push({ type: 'target-connection', description: target.slice(target.indexOf('Target Connection'), target.indexOf('Target Connection') + 160) });
// 4. Run Sync (Namespace default, Target Provider partial+NHN, Resource Type partial+vNet)
const run = page.locator('form, div').filter({ has: page.locator('#sync-ns') }).last();
await page.locator('#sync-ns').selectOption({ label: 'default' });
await page.locator('input[name="provider-radio"][value="partial"]').check();
await page.waitForTimeout(800);
// Run Sync 영역의 NHN / vNet 체크박스(조회 조건의 같은 이름 체크박스와 구분하려고 해당 라디오 바로 뒤의 첫 체크박스를 쓴다)
const runNhn = page.locator('input[name="provider-radio"][value="partial"]').locator('xpath=following::input[@value="nhn"][1]');
if (!(await runNhn.isChecked())) await runNhn.check(); // 연결을 고르면 NHN 이 자동 체크되어 있음
await page.locator('input[name="restype-radio"][value="partial"]').check();
await page.waitForTimeout(800);
await page.locator('input[name="restype-radio"][value="partial"]').locator('xpath=following::input[@value="vNet"][1]').check();
test.info().annotations.push({ type: 'run-sync-form', description: (await run.innerText().catch(() => '')).replace(/\s+/g, ' ').slice(0, 300) });
await page.getByRole('button', { name: 'Run Sync', exact: true }).click();
// 결과: 확인 팝업/토스트/결과 문구 수집(최대 ~2분)
const msgs = new Set<string>();
for (let i = 0; i < 24; i++) {
for (const m of await page.locator('.modal.show').all()) { const t = (await m.innerText().catch(() => '')).replace(/\s+/g, ' ').trim(); if (t) msgs.add(`modal: ${t.slice(0, 300)}`); }
for (const t of await toasts(page)) { const s = t.replace(/\s+/g, ' ').trim(); if (s) msgs.add(`toast: ${s.slice(0, 300)}`); }
const confirm = page.locator('.modal.show').getByRole('button', { name: /Confirm|OK|Yes|Run/i }).last();
if (await confirm.isVisible().catch(() => false)) await confirm.click().catch(() => {});
await page.waitForTimeout(5000);
}
test.info().annotations.push({ type: 'run-sync-messages', description: [...msgs].join(' || ').slice(0, 800) || '(메시지 없음)' });
const after = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
test.info().annotations.push({ type: 'sync-after', description: after.slice(after.indexOf('Run Sync'), after.indexOf('Run Sync') + 500) });
// 5. Networks 목록에 NHN vNet
await page.goto(NETWORKS_URL);
await selectWorkspaceProject(page, 'ws01', 'default', true);
await page.getByText(/Showing \d+-\d+ of \d+ rows|No registered/).first().waitFor({ timeout: 30_000 }).catch(() => {});
await page.waitForTimeout(2000);
const rows = (await page.locator('.tabulator-row').allInnerTexts()).map((s) => s.replace(/\s+/g, ' ')).filter((s) => /nhn/i.test(s));
test.info().annotations.push({ type: 'networks-nhn', description: rows.join(' || ') || '(NHN vNet 없음)' });
expect(rows.length, 'Networks 목록에 NHN vNet').toBeGreaterThan(0);
});
}tests/infra/TC-INFRA-014.spec.ts · 31줄 · 해시 0c7c5fc8 · 시나리오 보기
// TC-INFRA-014: Resource Sync: NCP vNet·Security Group 동기화 → Networks / Security Groups 목록에 NCP 표시 [정상, NCP] (사용자 지시 2026-10-08)
// 배경: NCP 계정은 VPC 신규 생성이 막혀(returnCode 1015009 'You can no longer create a product') TC-INFRA-007/S02 를 신규 생성으로 할 수 없다 → 기존 NCP VPC/SG 를 Resource Sync 로 가져와 사용
// 화면: Cloud Resources > Resource Sync → Cloud Resources > Networks, Security Groups
// 절차: 1.Resource Sync 진입 2.Search Conditions: Provider=Partial → NCP, Connection=NCP → ncp-kr, Resource Type=Partial → vNet, Security Group → Query
// 3.CSP Resource Sync Status 에서 ncp-kr 선택 4.Run Sync: Namespace=default, Target Provider=Partial → NCP, Resource Type=Partial → vNet, Security Group → Run Sync
// 5.Networks 목록에 NCP vNet, Security Groups 목록에 NCP SG 표시 확인
// 기대결과: 동기화 후 두 목록에 Provider ncp 자원이 있다
// 동기화 대상 연결: SYNC_CONNS 환경변수(쉼표, 기본 ncp-kr — IV2 가 쓰는 연결)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { runResourceSync, listProviderRows } from '../../lib/sync';
const NETWORKS_URL = '/webconsole/settings/environment/cloudresources/networks';
const SG_URL = '/webconsole/settings/environment/cloudresources/securitygroups';
for (const c of activeCsps(['NCP'])) {
test(`${tid('TC-INFRA-014', c)} Resource Sync: NCP vNet·Security Group 동기화 → 목록에 NCP 표시 (구 TC-INFRA-014)`, async ({ page }) => {
test.setTimeout(15 * 60_000);
const conns = (process.env.SYNC_CONNS || 'ncp-kr').split(',').map((s) => s.trim()).filter(Boolean);
await loginAsAdmin(page);
await runResourceSync(page, { provider: 'ncp', conns, types: ['vNet', 'securityGroup'] });
const nets = await listProviderRows(page, NETWORKS_URL, 'ncp');
test.info().annotations.push({ type: 'networks-ncp', description: nets.map((s) => s.slice(0, 90)).join(' || ') || '(NCP vNet 없음)' });
const sgs = await listProviderRows(page, SG_URL, 'ncp');
test.info().annotations.push({ type: 'securitygroups-ncp', description: `${sgs.length}건: ${sgs.slice(0, 5).map((s) => s.slice(0, 70)).join(' || ') || '(NCP SG 없음)'}` });
expect.soft(nets.length, 'Networks 목록에 NCP vNet').toBeGreaterThan(0);
expect.soft(sgs.length, 'Security Groups 목록에 NCP SG').toBeGreaterThan(0);
});
}tests/infra/TC-INFRA-015.spec.ts · 41줄 · 해시 4eef14f2 · 시나리오 보기
// TC-INFRA-015: Resource Sync 후 CSP Resource Sync Status 등록수 확인 (목록과 일치) [정상, NHN Cloud·NCP] (사용자 지시 2026-10-08)
// 화면: Cloud Resources > Resource Sync (Search Conditions → Query → CSP Resource Sync Status 의 연결별 등록수/전체수) → Cloud Resources > Networks, Security Groups
// 절차: 1.Resource Sync 진입 2.Provider/Connection/Resource Type 조건 → Query(Run Sync 는 하지 않음) 3.연결별 등록수/전체수 읽기 4.목록(Networks / Security Groups)에서 같은 연결(이름이 '<연결>-' 로 시작)의 개수 세기 5.비교
// 기대결과: 동기화한 연결의 등록수 = 전체수 이고, 목록 개수와 같다. 다르면 FAIL(값은 비고에 기록) — NHN nhn-kr2 는 목록 3개인데 Sync Status 0/3 으로 남는 불일치가 관찰됨(2026-10-08)
// 선행: NHN → TC-INFRA-013, NCP → TC-INFRA-014 (동기화 완료)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { queryResourceStatus, listProviderRows } from '../../lib/sync';
const NET = '/webconsole/settings/environment/cloudresources/networks';
const SG = '/webconsole/settings/environment/cloudresources/securitygroups';
const CFG: Record<string, { provider: string; conns: string[]; types: Array<{ value: 'vNet' | 'securityGroup'; label: string }> }> = {
NHNCloud: { provider: 'nhn', conns: ['nhn-kr1', 'nhn-kr2'], types: [{ value: 'vNet', label: 'vNet' }] },
NCP: { provider: 'ncp', conns: ['ncp-kr'], types: [{ value: 'vNet', label: 'vNet' }, { value: 'securityGroup', label: 'Security Group' }] },
};
for (const c of activeCsps(['NHN Cloud', 'NCP'])) {
test(`${tid('TC-INFRA-015', c)} Resource Sync 후 CSP Resource Sync Status 등록수 확인 (목록과 일치) (구 TC-INFRA-015)`, async ({ page }) => {
test.setTimeout(10 * 60_000);
const cfg = CFG[c.code];
await loginAsAdmin(page);
const status = await queryResourceStatus(page, cfg);
test.info().annotations.push({ type: 'sync-status', description: JSON.stringify(status) });
const lists: Record<string, string[]> = {};
for (const t of cfg.types) lists[t.label] = await listProviderRows(page, t.label === 'vNet' ? NET : SG, cfg.provider);
const mismatches: string[] = [];
for (const cn of cfg.conns) {
for (const t of cfg.types) {
const m = (status[cn][t.label] ?? '').match(/^(\d+)\/(\d+)$/);
const listed = lists[t.label].filter((r) => r.startsWith(`${cn}-`)).length;
const reg = m ? Number(m[1]) : -1, total = m ? Number(m[2]) : -1;
test.info().annotations.push({ type: 'compare', description: `${cn} ${t.label}: Sync Status ${status[cn][t.label]} / 목록 ${listed}개` });
if (reg !== total) mismatches.push(`${cn} ${t.label}: 등록수 ${reg} ≠ 전체수 ${total}`);
if (reg !== listed) mismatches.push(`${cn} ${t.label}: Sync Status 등록수 ${reg} ≠ 목록 ${listed}개`);
}
}
expect(mismatches, `Sync Status 와 목록 불일치: ${mismatches.join(' | ')}`).toEqual([]);
});
}tests/infra/TC-INFRA-016.spec.ts · 42줄 · 해시 82984fa2 · 시나리오 보기
// TC-INFRA-016: Infra Node Recommendation 확인 (Provider 별) [정상, CSP별]
// 화면: Workloads > Infra Workloads > Add Infra > + NodeGroup > Spec 검색 → Node Recommendation 팝업
// 절차: 1.Add Infra → + NodeGroup 2.Spec 검색 → Node Recommendation 3.Priority Option(location)=Seoul, Cloud Provider={CSP} 체크
// 4.Spec Config(Memory/vCPU) → 검색 5.추천 목록이 해당 Provider 의 spec 만 표시하는지 확인(입력 spec 이 있으면 그 행도 확인) 6.Cancel(생성하지 않음)
// 기대결과: 선택한 Provider 의 spec 만 추천 목록에 표시. Infra 를 생성하지 않는다.
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { INFRA_INPUT } from '../../lib/infra';
import { openNodeForm, searchSpec, specTargetRow } from '../../lib/reco';
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-016", c)} ` + "Infra Node Recommendation 확인 (Provider 별) (구 TC-INFRA-016)", async ({ page }) => {
await loginAsAdmin(page);
await openNodeForm(page);
const { sm, count, conns, pv } = await searchSpec(page, c);
expect(count, `${c.name} 추천 결과 행 수`).toBeGreaterThan(0);
// 선택한 Provider 의 연결(예: aws-ap-northeast-2)만 보여야 한다
const other = conns.filter((s) => !s.toLowerCase().startsWith(`${pv}-`));
expect(other, `다른 Provider 의 spec 이 섞이면 안 됨: ${other.join(', ')}`).toEqual([]);
const inp = INFRA_INPUT[c.code];
if (inp) await expect(specTargetRow(page, sm, c), `${inp.spec}(${inp.region}) 행`).toBeVisible();
// 2026-10-19 사용자 지시: 검색 조건에 맞는 목록인지 3초 보여 준 뒤 Apply → 반영 확인(3초) 후 종료(Infra 는 생성하지 않음)
await page.waitForTimeout(3000);
if (!inp) {
await sm.getByRole('button', { name: 'Cancel' }).click(); // 입력 spec 미정 CSP: 저장/생성 없이 종료
return;
}
// 행 중앙 클릭은 넓은 행에서 빗나감(TC-INFRA-017 2026-10-19) → 보이는 셀 클릭
// 같은 조건의 숨은 행이 먼저 잡혀 클릭 시간 초과(2026-10-19) → 보이는 행·보이는 셀만
const visRow = sm.locator('.tabulator-row:visible').filter({ hasText: inp.spec }).filter({ hasText: `${inp.provider}-${inp.region}` }).first();
await visRow.locator('.tabulator-cell:visible').first().click();
await sm.getByRole('button', { name: 'Apply' }).click();
await expect(sm).toBeHidden({ timeout: 15_000 });
const spec = page.locator('#ep_specId');
await expect(spec, `Spec 칸에 ${inp.spec} 반영`).toHaveValue(new RegExp(inp.spec.replace(/\./g, '\\.')), { timeout: 15_000 });
test.info().annotations.push({ type: 'applied', description: `#ep_specId = ${await spec.inputValue()}` });
await spec.scrollIntoViewIfNeeded().catch(() => {});
await page.waitForTimeout(3000);
});
}tests/infra/TC-INFRA-017.spec.ts · 39줄 · 해시 815221fe · 시나리오 보기
// TC-INFRA-017: Infra Image Recommendation 확인 (Spec 조회 선행) [정상, CSP별]
// 화면: Workloads > Infra Workloads > Add Infra > + NodeGroup > Spec 검색(Node Recommendation) → Apply → Image 검색 → Image Recommendation 팝업
// 절차: 1.(선행) Node Recommendation 에서 Provider 로 Spec 조회 → 행 선택 → Apply 2.Image 검색 → Image Recommendation(Provider/Region 은 Spec 에서 자동 입력)
// 3.OS Type='ubuntu 22.04' 입력 → 검색 4.Available 이미지 표시 확인 5.이미지 선택 → Apply 로 Image 입력 확인 6.저장 없이 종료
// 기대결과: Spec 선택 후 해당 Provider/Region 의 Image 추천 목록이 표시(Available), Apply 시 Image 입력. Infra 를 생성하지 않는다.
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { INFRA_INPUT } from '../../lib/infra';
import { openNodeForm, searchSpec, searchImage, specTargetRow } from '../../lib/reco';
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-017", c)} ` + "Infra Image Recommendation 확인 (Spec 조회 선행) (구 TC-INFRA-017)", async ({ page }) => {
await loginAsAdmin(page);
await openNodeForm(page);
// 1. 선행: Spec 조회 → 선택 → Apply
const { sm, count } = await searchSpec(page, c);
expect(count, `${c.name} Spec 추천 결과 행 수(선행 조건)`).toBeGreaterThan(0);
await specTargetRow(page, sm, c).click();
await sm.getByRole('button', { name: 'Apply' }).click();
const spec = await page.locator('#ep_specId').inputValue();
expect(spec, 'Spec 선택(Apply) 후 Spec 입력란').not.toBe('');
const conn = await page.locator('#ep_connectionName').inputValue();
test.info().annotations.push({ type: 'spec-applied', description: `spec=${spec}, connection=${conn}` });
const inp = INFRA_INPUT[c.code];
if (inp) expect(conn, 'Spec 선택으로 Connection 자동 지정').toBe(c.infra.conn);
// 2~4. Image Recommendation
const { im, avail } = await searchImage(page, inp?.os ?? 'ubuntu 22.04');
expect(await avail.count(), `${c.name} Available 이미지 수`).toBeGreaterThan(0);
// 5. 이미지 선택 → Apply
// 행 중앙 클릭 금지: 설명이 긴 이미지(NHN kr1 등)는 행이 표 영역보다 넓어(가로 스크롤) 중앙점이 모달 본문에 떨어져 선택되지 않는다 → 보이는 셀을 클릭
await avail.first().locator('.tabulator-cell').nth(2).click();
await im.getByRole('button', { name: 'Apply' }).click();
expect(await page.locator('#ep_imageId').inputValue(), 'Image 선택(Apply) 후 Image 입력란').not.toBe('');
// 6. 저장 없이 종료(Done/Deploy 클릭 안 함)
});
}tests/infra/TC-INFRA-018.spec.ts · 47줄 · 해시 407e8dad · 시나리오 보기
// TC-INFRA-018: 직접 Infra 생성 (Add Infra) [정상, CSP별]
// 화면: Workloads > Infra Workloads > Add Infra (Create Infra 폼: Name, Description, Deployment Algorithm, Policy on Partial Failure, Labels, + NodeGroup → Deploy)
// 시작 상태: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default 선택된 상태에서 시작
// 절차 (예: AWS 기준): 1.Infra Workloads 진입 2.Add Infra: Name=mc1, Deployment Algorithm=Express, Policy on Partial Failure=Continue 3.+ NodeGroup: g1 (연결=aws-ap-northeast-2, Spec=t3.micro, Image=Ubuntu 22.04 기본 이미지, node 1) → Done 4.Deploy 5.Running 도달까지 대기
// 6.mc1 선택 → Infra Info > Node List 에서 NodeGroup g1 의 node g1-1 선택 → Node Info 상세 확인 7.Group 탭에 g1(1)
// 기대결과: Infra mc1 Running (nodegroup g1, node 1), Node Info [ g1 / g1-1 ] 에 CSP, Node Status=Running, Spec, Image ID, Connection, Public/Private IP 표시
// 멱등: 같은 이름 Infra 가 이미 있으면 생성은 건너뛰고(annotation preexisting) 상태만 검증한다(이름 중복 생성은 TC-INFRA-019 대상).
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { INFRA_INPUT, createInfra, gotoInfraList, infraInputBlocker, infraRow, nodeField, openInfraDetail, openNodeInfo, rowCells, waitInfraRunning } from '../../lib/infra';
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-018", c)} ` + "직접 Infra 생성 (Add Infra) (구 TC-INFRA-018)", async ({ page }) => {
const why = infraInputBlocker(c);
test.skip(!!why, why);
const name = infraName(c);
const ng = infraNodegroups(c)[0];
await loginAsAdmin(page);
await gotoInfraList(page);
if (await infraRow(page, name).count()) {
test.info().annotations.push({ type: 'preexisting', description: `${name} 이미 존재 → 생성 건너뜀: ${JSON.stringify(await rowCells(infraRow(page, name).first()))}` });
} else {
await createInfra(page, c, name, ng);
}
const cells = await waitInfraRunning(page, name);
test.info().annotations.push({ type: 'list-row', description: JSON.stringify(cells) });
// 상세: NodeGroup {ng} 의 node {ng}-1 선택 → Node Info 상세 확인
await openInfraDetail(page, name);
const inp = INFRA_INPUT[c.code]!;
const info = await openNodeInfo(page, ng, `${ng}-1`);
test.info().annotations.push({ type: 'node-info', description: info.slice(0, 900) });
expect(nodeField(info, 'CSP'), 'CSP').toBe(inp.provider);
expect(nodeField(info, 'Node Status'), 'Node Status').toBe('Running');
expect(nodeField(info, 'Node Spec'), 'Node Spec').toBe(inp.spec);
expect(nodeField(info, 'Config Name'), 'Connection').toBe(c.infra.conn);
expect(nodeField(info, 'Image ID'), 'Image ID').not.toBe('');
expect(nodeField(info, 'Public IP\\(IPv4\\)'), 'Public IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
expect(nodeField(info, 'Private IP'), 'Private IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
// Group 탭 NodeGroup List 에 {ng}(1)
await page.getByText('Group', { exact: true }).first().click();
await expect(page.getByText(`${ng}(1)`).first(), `nodegroup ${ng}(1) 표시`).toBeVisible({ timeout: 30_000 });
});
}tests/infra/TC-INFRA-019.spec.ts · 59줄 · 해시 b51a11be · 시나리오 보기
// TC-INFRA-019: 직접 Infra 생성 이름 중복 (예상실패) [예상실패, CSP별]
// 화면: Workloads > Infra Workloads > Add Infra (Create Infra 폼 → + NodeGroup → Done → NodeGroup Validation 확인 팝업 → Deploy → Infra Deployment Warning 팝업)
// 전제: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default. TC-INFRA-018_AWS 완료(mc1 존재)
// 절차(AWS): 1.Add Infra: Name=mc1(기존과 동일), nodegroup 1개(g1dup) 2.Deploy → 경고 팝업은 계속 진행(Confirm)
// 기대결과: 이름 중복 시 Infra 가 생성되지 않음: 경고(already exists) 표시, 계속 진행해도 중복 오류로 생성 실패, 추가 자원 미생성
// 판정: 경고/오류 문구 기록 + 기존 mc1 의 Total Nodes·목록 행 수가 시작 시와 같으면 PASS(기대한 실패). 늘어나면 FAIL(중복 생성됨 → 즉시 정리 필요)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName } from '../../lib/data';
import { loginAsAdmin, toasts } from '../../lib/ui';
import { addNodeGroup, gotoInfraList, infraInputBlocker, infraRow, rowCells } from '../../lib/infra';
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-019", c)} ` + "직접 Infra 생성 이름 중복 (예상실패) (구 TC-INFRA-019)", async ({ page }) => {
const why = infraInputBlocker(c);
test.skip(!!why, why);
const name = infraName(c);
await loginAsAdmin(page);
await gotoInfraList(page);
test.skip(!(await infraRow(page, name).count()), `선행 TC-INFRA-018: ${name} 없음`);
const rowsBefore = await page.locator('[role=row][class*="tabulator-row"], .tabulator-row').count();
const before = await rowCells(infraRow(page, name).first());
test.info().annotations.push({ type: 'before', description: `${name}: Total=${before['statusCount.countTotal']} Running=${before['statusCount.countRunning']} / 목록 ${rowsBefore}행` });
await page.getByText('Add Infra', { exact: true }).first().click();
await page.locator('#mci_name').waitFor();
await page.locator('#mci_name').fill(name); // 기존과 동일한 이름
await page.locator('#mci_deploy_algorithm').selectOption('express');
await page.locator('#mci_policy_on_partial_failure').selectOption('continue');
await addNodeGroup(page, c, 'g1dup', 1);
await page.getByRole('button', { name: 'Deploy', exact: true }).click();
// 경고 팝업(Infra Deployment Warning 등)은 계속 진행. 나타나는 팝업 문구를 모두 기록(최대 3회)
const popups: string[] = [];
for (let i = 0; i < 3; i++) {
const m = page.locator('.modal.show').last();
if (!(await m.waitFor({ state: 'visible', timeout: 10_000 }).then(() => true, () => false))) break;
popups.push((await m.innerText()).replace(/\s+/g, ' ').slice(0, 300));
const btn = m.getByRole('button', { name: /Confirm|Continue|Deploy|OK|Yes/i }).last();
if (!(await btn.count())) break;
await btn.click();
await page.waitForTimeout(2000);
}
test.info().annotations.push({ type: 'popups', description: popups.join(' || ') || '(팝업 없음)' });
await page.waitForTimeout(5000);
const t = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
test.info().annotations.push({ type: 'toast', description: t.join(' | ').slice(0, 400) || '(토스트 없음)' });
// 추가 자원 미생성 확인: 일정 시간 뒤 기존 mc1 노드 수·목록 행 수가 그대로
await page.waitForTimeout(60_000);
await gotoInfraList(page);
const after = await rowCells(infraRow(page, name).first());
const rowsAfter = await page.locator('[role=row][class*="tabulator-row"], .tabulator-row').count();
test.info().annotations.push({ type: 'after', description: `${name}: Total=${after['statusCount.countTotal']} Running=${after['statusCount.countRunning']} / 목록 ${rowsAfter}행` });
expect(after['statusCount.countTotal'], '기존 Infra 노드 수 변화 없음').toBe(before['statusCount.countTotal']);
expect(rowsAfter, 'Infra 목록 행 수 변화 없음').toBe(rowsBefore);
expect(popups.join(' ') + t.join(' '), '중복 경고/오류 문구(already exists)').toMatch(/already exist|duplicate|exists|중복/i);
});
}tests/infra/TC-INFRA-020.spec.ts · 52줄 · 해시 9399dc5c · 시나리오 보기
// TC-INFRA-020 CSP별 K8s 지원 현황 확인 [정상, 공통] (기능목록 제외 TC)
// v0.6 TC-ID: TC-INFRA-K8S-00 — 기존 정의(mcmp-e2e definitions/01-unit-tests/infra.md, TODO)는 절차만 참고(사용자 결정 2026-10-14)
// 화면: Workloads > K8s Workloads → Add cluster(기본 폼) → Cloud Provider / Region
// 시작 상태: admin(mcmp) 로그인, Workspace=ws01 / Project=p1. CSP Connection 등록
// 절차: 1.Add cluster 2.Provider 별로 한국(또는 기본) Region 선택 3.화면이 부르는 지원 버전 조회(GetAvailableK8sVersion) 응답과 Cluster Version 후보 기록
// 4.노드 이미지 지정 필요 여부: NodeGroup 이미지 칸(Use Default / 검색) 표시 기록 5.Deploy 는 누르지 않음
// 기대결과: Provider 별 지원 K8s 버전 목록 조회(2xx), 이미지 지정 방식 표시
// 비고: 레거시 정의의 k8sClusterInfo · checkK8sNodeImageDesignation 은 2026-10-14 화면에서 호출되지 않음(호출 여부를 기록). KT 는 K8s 미구현(IV4 K8s SKIP 결정)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoK8s } from '../../lib/iv3-k8s';
const CONN: Record<string, string> = { AWS: 'aws-ap-northeast-2', AZURE: 'azure-koreacentral', GCP: 'gcp-asia-northeast3', ALIBABA: 'alibaba-ap-northeast-2', TENCENT: 'tencent-ap-seoul', NHN: 'nhn-kr1', NCP: 'ncp-kr', IBM: 'ibm-jp-tok', KT: 'kt-kr1' };
test('TC-INFRA-020 CSP별 K8s 지원 현황 확인 (구 TC-INFRA-020)', async ({ page }) => {
test.setTimeout(15 * 60_000);
const calls: { url: string; req: string; status: number; body: string }[] = [];
page.on('response', async (r) => {
if (!/AvailableK8sVersion|K8sClusterInfo|K8sNodeImageDesignation/i.test(r.url())) return;
calls.push({ url: r.url().replace(/^https?:\/\/[^/]+/, ''), req: r.request().postData() ?? '', status: r.status(), body: (await r.text().catch(() => '')).slice(0, 600) });
});
await loginAsAdmin(page);
await gotoK8s(page, 'p1');
await page.getByText('Add cluster').first().click();
await page.locator('#cluster_name_dynamic').waitFor({ timeout: 30_000 });
const results: Record<string, string> = {};
for (const [p, conn] of Object.entries(CONN)) {
await test.step(`${p}: Region [${p}] ${conn} → 지원 버전`, async () => {
const n0 = calls.length;
await page.locator('#cluster_provider_dynamic').selectOption(p);
const region = `[${p}] ${conn}`;
const has = await expect.poll(() => page.locator('#cluster_region_dynamic').evaluate((s, v) => [...(s as HTMLSelectElement).options].some((o) => o.value === v), region), { timeout: 30_000 }).toBe(true).then(() => true, () => false);
if (!has) { results[p] = `Region ${region} 없음`; return; }
await page.locator('#cluster_region_dynamic').selectOption(region);
await expect.poll(() => calls.slice(n0).some((c) => /AvailableK8sVersion/.test(c.url)), { timeout: 30_000 }).toBe(true).catch(() => {});
const c = calls.slice(n0).filter((x) => /AvailableK8sVersion/.test(x.url)).at(-1);
let versions: string[] = [];
try { versions = (JSON.parse(c?.body ?? '{}').responseData ?? []).map((v: any) => v.name || v.id).filter(Boolean); } catch { /* */ }
const img = await page.locator('#nodegroup_image_dynamic').isVisible().catch(() => false);
results[p] = `HTTP ${c?.status ?? '-'} 버전 ${versions.length ? versions.join(',') : '(없음)'} / NodeGroup 이미지 칸 ${img ? '표시(Use Default·검색)' : '없음(클러스터만 생성)'}`;
});
}
test.info().annotations.push({ type: 'support', description: Object.entries(results).map(([k, v]) => `${k}: ${v}`).join(' || ') });
test.info().annotations.push({ type: 'other-apis', description: calls.filter((c) => !/AvailableK8sVersion/.test(c.url)).map((c) => `${c.status} ${c.url}`).join(', ') || 'k8sClusterInfo · checkK8sNodeImageDesignation 호출 없음' });
const failed = calls.filter((c) => c.status >= 400);
expect(failed.map((c) => `${c.status} ${c.req}`), '지원 버전 조회 오류 없음').toEqual([]);
const supported = Object.entries(results).filter(([k, v]) => k !== 'KT' && /버전 (?!\(없음\))/.test(v)).map(([k]) => k);
test.info().annotations.push({ type: 'supported', description: supported.join(', ') });
expect(supported.length, 'K8s 지원 버전이 조회된 CSP').toBeGreaterThan(0);
});tests/infra/TC-INFRA-021.spec.ts · 60줄 · 해시 7b818b5c · 시나리오 보기
// TC-INFRA-021 K8s 이미지 선택 (이미지 지정 필요 CSP) [정상, NHN Cloud]
// v0.6 TC-ID: TC-INFRA-K8S-02 — 기존 spec(mcmp-e2e mc-web-console/specs/web-bug-092-k8s-image-selection.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Workloads > K8s Workloads → Add cluster → NodeGroup 의 Image 칸 검색(돋보기) → 이미지 검색 모달 → 이미지 선택
// 시작 상태: admin(mcmp) 로그인, Workspace=ws01 / Project=p1. NHN Cloud Connection(nhn-kr1)
// 절차: 1.Add cluster → Provider NHN · Region nhn-kr1 2.NodeGroup Spec 검색 → r2.c4m16 적용 3.Image 검색 모달 → 검색 → 이미지 목록 4.이미지 행 선택 → 적용 → Image 칸에 반영 5.Deploy 는 누르지 않음(과금 없음)
// 입력값: provider NHN, region nhn-kr1, Spec r2.c4m16(4 vCPU / 16 GB, lib/data NHN K8s)
// 기대결과: 이미지 검색 2xx, 선택 가능한 이미지 목록 표시, 선택한 이미지가 NodeGroup Image 에 반영
// 비고: NHN 은 노드 이미지 지정이 필요한 CSP(WEB-BUG-092 이력). 이미지 모달은 Spec 선택 후 열린다(validateAndOpenImageModalPmk)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoK8s, pickSpecInModal } from '../../lib/iv3-k8s';
test('TC-INFRA-021_NHNCloud TC-INFRA-021 K8s 이미지 선택 (이미지 지정 필요 CSP) (구 TC-INFRA-021)', async ({ page }) => {
test.setTimeout(15 * 60_000);
const img: { status: number; url: string; n: number }[] = [];
page.on('response', async (r) => {
if (!/Image/i.test(r.url()) || !/mc-infra-manager/.test(r.url())) return;
let n = -1; try { const j = await r.json(); n = (j?.responseData?.imageList ?? j?.responseData ?? []).length; } catch { /* */ }
img.push({ status: r.status(), url: r.url().replace(/^https?:\/\/[^/]+/, ''), n });
});
await loginAsAdmin(page);
await gotoK8s(page, 'p1');
await test.step('1. Add cluster → NHN · nhn-kr1', async () => {
await page.getByText('Add cluster').first().click();
await page.locator('#cluster_provider_dynamic').selectOption('NHN');
await expect.poll(() => page.locator('#cluster_region_dynamic').evaluate((s) => [...(s as HTMLSelectElement).options].some((o) => o.value === '[NHN] nhn-kr1')), { timeout: 30_000 }).toBe(true);
await page.locator('#cluster_region_dynamic').selectOption('[NHN] nhn-kr1');
await page.waitForTimeout(3000);
});
await test.step('2. NodeGroup Spec r2.c4m16 적용', async () => {
await page.locator('#nodegroup_spec_dynamic').locator('xpath=../..').locator('a[data-bs-target="#spec-search-pmk"]').click();
expect(await pickSpecInModal(page, { spec: 'r2.c4m16', mem: 16, cpu: 4 }), 'Spec r2.c4m16 검색').toBe(true);
await page.locator('#spec-search-pmk button[onclick*="applySpecInfoPmk"]').click();
await expect(page.locator('#nodegroup_spec_dynamic')).toHaveValue(/r2\.c4m16/, { timeout: 10_000 });
});
await test.step('3. Image 검색 모달 → 검색 → 목록', async () => {
await page.locator('a[onclick*="validateAndOpenImageModalPmk"]').first().click();
await page.locator('#image-search-pmk.show').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#image-search-pmk a[onclick*="getRecommendImageInfoPmk"]').first().click();
const rows = page.locator('#image-table-pmk .tabulator-tableholder .tabulator-row');
await expect(rows.first()).toBeVisible({ timeout: 120_000 });
test.info().annotations.push({ type: 'images', description: `API ${img.map((i) => `${i.status} ${i.url.split('/').pop()} ${i.n}건`).join(', ')} / 화면 ${(await page.locator('#image-table-pmk .tabulator-page-counter').textContent().catch(() => '')) ?? ''} / 첫 행 ${(await rows.first().innerText()).replace(/\s+/g, ' ').slice(0, 200)}` });
expect(img.some((i) => i.status < 300)).toBe(true);
});
await test.step('4. 이미지 행 선택 → Apply → Image 칸 반영', async () => {
const row = page.locator('#image-table-pmk .tabulator-tableholder .tabulator-row').first();
const rowText = (await row.innerText()).replace(/\s+/g, ' ');
const imageId = rowText.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? '';
await row.click();
// 모달 하단 버튼은 Use Default(applyDefaultImagePmk) · Apply(applyImageInfoPmk) — Apply 를 정확히 지정(2026-10-14)
await page.locator('#image-search-pmk button[onclick*="applyImageInfoPmk"]').click();
await expect(page.locator('#nodegroup_image_dynamic')).not.toHaveValue('', { timeout: 15_000 });
const v = await page.locator('#nodegroup_image_dynamic').inputValue();
test.info().annotations.push({ type: 'selected', description: `행 ${rowText.slice(0, 120)} → Image 칸 ${v}` });
expect(v, '선택한 이미지(Use Default 아님)').not.toBe('default');
if (imageId) expect(v).toContain(imageId);
});
});tests/infra/TC-INFRA-022.spec.ts · 41줄 · 해시 30a5aa4b · 시나리오 보기
// TC-INFRA-022: K8s 직접 생성 타입 A: Cluster 생성 요청 → 목록에서 Creating 확인 (K8s Workloads) [정상, CSP별]
// 화면: Workloads > K8s Workloads(Project p{n}) > 'Add cluster' 링크(#createcluster) → Create Cluster 기본 폼(Cluster Name, Provider, Region, Connection, Description, Version) → Deploy → 목록
// 절차(AWS): 1.K8s Workloads 진입(ws01 → p1) 2.Add cluster: Cluster Name=k1d, Provider=AWS, Region=[AWS] aws-ap-northeast-2, Connection=aws-ap-northeast-2, Version 비움 3.Deploy
// 4.목록에 k1d 가 Creating 으로 표시되는지 확인 후 종료(Active 대기는 녹화하지 않음)
// AWS 는 기본 폼에 NodeGroup 입력란이 없는 타입 A(2026-10-05 프로브·사용자 확인). Deploy 는 실제 과금 → 실행 전 사용자 확인(K8S_DEPLOY_OK=1 일 때만 Deploy)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { clusterCells, deployCluster, fillCreateCluster, gotoK8s, waitClusterStatus } from '../../lib/iv3-k8s';
const INPUT: Record<string, { name: string; provider: string; conn: string; version?: string } | undefined> = {
AWS: { name: 'k1d', provider: 'AWS', conn: 'aws-ap-northeast-2' },
// Azure(2026-10-06): 기본 폼에 NodeGroup 입력란이 나타나는 타입 B — 입력란을 비우고 Cluster 만 요청해 허용/거부를 기록(사용자 지시)
Azure: { name: 'k2d', provider: 'AZURE', conn: 'azure-koreacentral' },
// 2026-10-19 미실행 CSP(사용자 지시): 타입 A(기본 폼 NodeGroup 입력란 없음) Alibaba·Tencent. Version 은 허용값만(Alibaba aliyun 접미사)
Alibaba: { name: 'k3d', provider: 'ALIBABA', conn: 'alibaba-ap-northeast-2', version: '1.35.7-aliyun.1' },
Tencent: { name: 'k5d', provider: 'TENCENT', conn: 'tencent-ap-seoul', version: '1.34.1' },
};
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-022", c)} ` + "K8s 직접 생성 타입 A: Cluster 생성 요청 → Creating 확인 (구 TC-INFRA-022)", async ({ page }) => {
const inp = INPUT[c.code];
test.skip(!inp, `${c.name}: 입력값 미지정(AWS 완료 후 별도 지시)`);
test.skip(process.env.K8S_DEPLOY_OK !== '1', 'Deploy 전 사용자 확인 필요(K8S_DEPLOY_OK=1)');
const prj = k8sProject(c);
await loginAsAdmin(page);
await gotoK8s(page, prj);
const before = await clusterCells(page, inp!.name);
test.skip(!!before.name || Object.keys(before).length > 0, `${inp!.name} 이미 존재: ${JSON.stringify(before)}`);
await fillCreateCluster(page, { ...inp!, desc: 'TC-INFRA-022 타입 A' });
// 타입 A 확인: 기본 폼에 NodeGroup 입력란 없음
const ngField = await page.locator('input[id*="nodegroup" i]:visible, input[id*="ng_" i]:visible').count();
test.info().annotations.push({ type: 'nodegroup-field', description: ngField ? `NodeGroup 입력란 ${ngField}개 있음` : '없음(타입 A)' });
const toasts = await deployCluster(page);
expect(toasts.find((t) => /fail|error|invalid/i.test(t)), 'Deploy 직후 오류 토스트 없음').toBeUndefined();
const cells = await waitClusterStatus(page, prj, inp!.name, /creat|provision|pending|progress/i, 5 * 60_000);
test.info().annotations.push({ type: 'result', description: `${inp!.name} 목록 상태: ${cells.status}` });
});
}tests/infra/TC-INFRA-023.spec.ts · 89줄 · 해시 dae0cc4b · 시나리오 보기
// TC-INFRA-023: K8s 직접 생성 타입 B: Cluster 와 NodeGroup 한 번에 생성 요청 → 목록에서 Creating 확인 (K8s Workloads) [정상, CSP별]
// 화면: Workloads > K8s Workloads > Add cluster (Create Cluster: Provider 선택 시 나타나는 NodeGroup 입력란 포함) → Deploy → 목록
// 절차(Azure, p2): 1.K8s Workloads 진입(ws01 → p2) 2.Add cluster: Cluster Name=k2e, Provider=AZURE, Region=[AZURE] azure-koreacentral, Connection=azure-koreacentral
// + NodeGroup g2e(Spec 검색 vCPU 4 / Memory 16 → Standard_B4ms, Image Use Default, node 1) 3.Deploy 4.목록에 k2e 가 Creating 으로 표시되는지 확인 후 종료(Active 대기 녹화 안 함)
// Spec: Standard_B2s(2 vCPU)는 stage 카탈로그에 없음(azure-koreacentral 819개, 최소 4 vCPU — 2026-10-06 확인) → 사용자 지시로 B4ms → 실패 시 B4as_v2 → D4s_v5 (env AZ_SPEC 로 지정)
// Deploy 는 실제 과금 → K8S_DEPLOY_OK=1 일 때만 Deploy. K8S_DRY=1 이면 폼 입력까지만 확인
// AWS 는 타입 A(기본 폼에 NodeGroup 입력란 없음) → SKIP 기록 완료
// 2026-10-16 수정(9 CSP 실행): NHN·NCP·IBM 입력값 추가, Azure 기본 Spec Standard_E4as_v6, 폼에 NodeGroup 입력란이 없으면(타입 A: AWS·Alibaba·Tencent) Deploy 없이 SKIP,
// KT 는 SKIP_REASON(Project p9 없음). 정리: wsl/probe/k8s-cycle.spec.ts K8S_ACTION=delete CYCLE_CLUSTER=k{n}e TC_VARIANT=<csp>
// 테스트 시간: GKE·IBM 등 N/A 재조회 포함 최대 40분 → test.setTimeout 지정
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { clusterCells, deployCluster, fillCreateCluster, fillDynamicNodeGroup, gotoK8s, openCluster, waitClusterStatus } from '../../lib/iv3-k8s';
const SPECS: Record<string, { mem: number; cpu: number }> = {
Standard_B4ms: { mem: 16, cpu: 4 }, Standard_B4as_v2: { mem: 16, cpu: 4 }, Standard_D4s_v5: { mem: 16, cpu: 4 },
// 2026-10-13 IV5: 16GB 급은 검색 버그로 불가 → 사용자 지시로 Standard_E4as_v6 사용(약 6분 Active). 2026-10-16 Azure 기본값으로 지정
Standard_E4as_v6: { mem: 0, cpu: 4 }, 'r2.c4m16': { mem: 0, cpu: 4 }, 's4-g3': { mem: 0, cpu: 4 }, 'bx2-4x16': { mem: 0, cpu: 4 },
// GCP: stage 카탈로그(asia-northeast3)에 e2-standard 4vCPU 이하 없음 · 메모리 정확 일치 검색 0건 → vCPU=4 로 검색(mem 0 = 조건 없음, 2026-10-15)
'e2-highmem-4': { mem: 0, cpu: 4 },
};
const INPUT: Record<string, { name: string; provider: string; conn: string; ng: string; spec: string } | undefined> = {
Azure: { name: process.env.K8S_CLUSTER || 'k2e', provider: 'AZURE', conn: 'azure-koreacentral', ng: process.env.K8S_NG || 'g2e', spec: process.env.AZ_SPEC || 'Standard_E4as_v6' },
// GCP(2026-10-15 K8s CSP 사이클): p4 에 k4e(NodeGroup g4e, e2-highmem-4). Creating 확인 후 사이클 유틸로 삭제
GCP: { name: process.env.K8S_CLUSTER || 'k4e', provider: 'GCP', conn: 'gcp-asia-northeast3', ng: process.env.K8S_NG || 'g4e', spec: 'e2-highmem-4' },
// 2026-10-16 9 CSP 확장(미실행 TC 실행): 2026-10-13 IV5 K8s 확장 기록 기준 타입 B 후보 NHN·NCP·IBM 입력값 추가.
// Spec 검색 팝업 메모리 하한 버그(16 고정·근사값) 때문에 vCPU=4 로만 검색하고 이름으로 고른다(mem 0)
NHNCloud: { name: process.env.K8S_CLUSTER || 'k6e', provider: 'NHN', conn: 'nhn-kr1', ng: process.env.K8S_NG || 'g6e', spec: 'r2.c4m16' },
NCP: { name: process.env.K8S_CLUSTER || 'k7e', provider: 'NCP', conn: 'ncp-kr', ng: process.env.K8S_NG || 'g7e', spec: 's4-g3' }, // c2-g3 는 검색 불가(2026-10-13), NG 이름 3자 이상
IBM: { name: process.env.K8S_CLUSTER || 'k8e', provider: 'IBM', conn: 'ibm-jp-tok', ng: process.env.K8S_NG || 'g8e', spec: 'bx2-4x16' },
// 타입 A 확인용(폼에 NodeGroup 입력란이 없으면 SKIP — Deploy 안 함): AWS·Alibaba·Tencent (2026-10-05·10-13 기록)
AWS: { name: 'k1e', provider: 'AWS', conn: 'aws-ap-northeast-2', ng: 'g1e', spec: 't3.medium' },
Alibaba: { name: process.env.K8S_CLUSTER || 'k3e', provider: 'ALIBABA', conn: 'alibaba-ap-northeast-2', ng: process.env.K8S_NG || 'g3e', spec: 'ecs.e-c1m4.xlarge' },
Tencent: { name: process.env.K8S_CLUSTER || 'k5e', provider: 'TENCENT', conn: 'tencent-ap-seoul', ng: process.env.K8S_NG || 'g5e', spec: 'S8.LARGE16' },
};
// KT: K8s 는 AWS 에 한해 진행(lib/data.ts KT k8s.note), ws01 에 Project p9 없음(2026-10-16 확인) → SKIP
const SKIP_REASON: Record<string, string> = { KT: 'KT: K8s 진행 대상 아님(lib/data KT k8s.note "K8s 는 AWS 에 한해 진행"), ws01 에 Project p9 없음(GetAllK8sCluster nsId=p9 → 404, 2026-10-16)' };
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-023", c)} ` + "K8s 직접 생성 타입 B: Cluster 와 NodeGroup 한 번에 생성 요청 → 목록에서 Creating 확인 (K8s Workloads) (구 TC-INFRA-023)", async ({ page }) => {
test.setTimeout(45 * 60_000);
test.skip(!!SKIP_REASON[c.code], SKIP_REASON[c.code]);
const inp = INPUT[c.code];
test.skip(!inp, `${c.name}: 입력값 미지정(별도 지시)`);
const dry = process.env.K8S_DRY === '1';
test.skip(!dry && process.env.K8S_DEPLOY_OK !== '1', 'Deploy 전 사용자 확인 필요(K8S_DEPLOY_OK=1)');
// K8S_PROJECT: CSP 기본 Project(p{n})의 VPC 쿼터·한도 초과 시 다른 Project 로 실행(2026-10-16 사용자 지시: NHN·NCP 는 default)
const prj = process.env.K8S_PROJECT || k8sProject(c);
await loginAsAdmin(page);
await gotoK8s(page, prj);
const before = await clusterCells(page, inp!.name);
test.skip(process.env.K8S_RECHECK !== '1' && Object.keys(before).length > 0, `${inp!.name} 이미 존재: ${JSON.stringify(before)}`);
// K8S_RECHECK=1: 이미 요청한 클러스터(같은 이름)를 Deploy 없이 상태만 재확인(2026-10-15 GKE: 생성 중 Status N/A)
if (!(process.env.K8S_RECHECK === '1' && Object.keys(before).length > 0)) {
await fillCreateCluster(page, { name: inp!.name, provider: inp!.provider, conn: inp!.conn, desc: 'TC-INFRA-023 타입 B' });
// 2026-10-16: 기본 폼에 NodeGroup 입력란이 없으면 타입 A → 이 TC(타입 B) 비대상으로 SKIP(Deploy 안 함)
const typeB = await page.locator('#nodegroup_name_dynamic').waitFor({ state: 'visible', timeout: 15_000 }).then(() => true, () => false);
test.info().annotations.push({ type: 'form-type', description: typeB ? '타입 B(NodeGroup 입력란 있음)' : '타입 A(NodeGroup 입력란 없음)' });
test.skip(!typeB, `${c.name}: 기본 폼에 NodeGroup 입력란 없음(타입 A) — 타입 B TC 비대상`);
const found = await fillDynamicNodeGroup(page, { name: inp!.ng, spec: inp!.spec, ...SPECS[inp!.spec], size: 1 });
expect(found, `Spec 검색 결과에 ${inp!.spec}`).toBe(true);
if (dry) { test.info().annotations.push({ type: 'dry', description: 'K8S_DRY=1: Deploy 안 함' }); return; }
const toasts = await deployCluster(page);
expect(toasts.find((t) => /fail|error|invalid|warning/i.test(t)), 'Deploy 직후 오류/경고 토스트 없음').toBeUndefined();
}
// Azure: 목록 Status 는 생성 중 'N/A'(2026-10-06), 상세 Cluster Info Status 가 Creating → 목록에 행이 생기면 상세로 판정
const cells = await waitClusterStatus(page, prj, inp!.name, /creat|provision|pending|progress|active|fail|N\/A/i, 8 * 60_000);
const info = await openCluster(page, inp!.name);
const st = info.match(/Status (\S+)/)?.[1] ?? '?';
test.info().annotations.push({ type: 'result', description: `${inp!.name} 목록 Status: ${cells.status}, Node Group: ${cells.nodegroup} / 상세 Status: ${st}, NodeGroup ${inp!.ng} ${info.includes(inp!.ng) ? '표시' : '없음'}` });
// GKE 는 생성 중 목록·상세 Status 가 'N/A'(2026-10-15, 결함 후보) → Active/Failed 로 바뀔 때까지 최대 25분 재조회해 생성 결과로 판정
// 2026-10-16: 재조회한 상세(info)로 판정(이전엔 첫 N/A 상세로 NodeGroup 을 검사해 오판 — Azure k2e). 상세 NodeGroup List 는 클러스터 생성 중엔 비어 있을 수 있어
// NodeGroup 이 보이거나 Active/Failed 가 될 때까지 최대 30분 재조회
let final = st;
let last = info;
const end = Date.now() + 30 * 60_000;
while (Date.now() < end && (/^N\/A$|^\?$/.test(final) || (!last.includes(inp!.ng) && !/active|fail/i.test(final)))) {
await page.waitForTimeout(60_000); await gotoK8s(page, prj);
last = await openCluster(page, inp!.name); final = last.match(/Status (\S+)/)?.[1] ?? '?';
}
if (final !== st || last !== info) test.info().annotations.push({ type: 'recheck', description: `첫 상세 Status ${st} → 재조회 Status ${final}, NodeGroup ${inp!.ng} ${last.includes(inp!.ng) ? '표시' : '없음'}: ${last.slice(0, 400)}` });
expect(final, '상세 Status Creating 계열(또는 생성 완료 Active)').toMatch(/creat|provision|pending|progress|active/i);
expect(last, `NodeGroup ${inp!.ng} 표시`).toContain(inp!.ng);
});
}tests/infra/TC-INFRA-024.spec.ts · 74줄 · 해시 6c69e2d5 · 시나리오 보기
// TC-INFRA-024: K8s 직접 생성 Expert 모드: VPC/Subnet/Security Group 선택 생성 요청 → 목록에서 Creating 확인 (K8s Workloads) [정상, CSP별]
// 화면: K8s Workloads(p{n}) > Add cluster > Expert Creation(Cluster Name, Description, Provider/Region, Connection, VPC, Subnet, Security Group, Version) → Deploy → 목록
// AWS(2026-10-05): 지시서의 v1/s1-1/sg1 은 Project default 자원이라 p1 Expert 폼에 표시되지 않음(VPC 선택지는 p1-shared-aws-ap-northeast-2 뿐)
// → 사용자 결정으로 p1 에 vNet v1k(10.11.0.0/16, subnet s1k-1 10.11.1.0/24)·SG sg1k(TCP 22/80) 를 만들어 사용
// 절차(AWS): 1.K8s Workloads(p1) → Add cluster → Expert Creation 2.Cluster Name=k1f, Provider=AWS, Region/Connection=aws-ap-northeast-2 3.VPC=v1k(최대 12초 대기, 없으면 SKIP) → Subnet=s1k-1 → SG=sg1k
// 4.Deploy 5.목록에 k1f Creating, VPC/Subnet/SG 열이 선택값과 일치하는지 확인 후 종료
// Deploy 는 실제 과금 → 실행 전 사용자 확인(K8S_DEPLOY_OK=1 일 때만 Deploy)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { addNodeGroupForm, clusterCells, deployCluster, fillExpertCluster, gotoK8s, openCluster, waitClusterStatus } from '../../lib/iv3-k8s';
import type { NodeGroupInput } from '../../lib/iv3-k8s';
const INPUT: Record<string, { name: string; provider: string; conn: string; vpc: string; subnet: string; sg: string; version?: string; ng?: NodeGroupInput } | undefined> = {
// Expert 는 Version 필수 → k1d 와 같은 1.35
// 2026-10-15: p1 의 v1k 가 없어 10-07 SKIP → p1 공용 vNet(p1-shared-aws-ap-northeast-2, subnet 2a·2b 다중 선택, SG …-sg-k8s) 사용
AWS: { name: process.env.K8S_CLUSTER || 'k1f', provider: 'AWS', conn: 'aws-ap-northeast-2', vpc: 'p1-shared-aws-ap-northeast-2', subnet: 'p1-shared-aws-ap-northeast-2', sg: 'p1-shared-aws-ap-northeast-2-sg-k8s', version: '1.35' },
// Azure(2026-10-06, 사용자 지시): p2 에 vNet v2k(10.12.0.0/16, subnet s2k-1 10.12.1.0/24)·SG sg2k(TCP 22/80) 생성 후 사용. Version 은 env K8S_VERSION(없으면 첫 옵션)
// 2026-10-19: v2k 는 p2 에 없음(선택지 p2-shared 뿐) → 공용 vNet. Spec 은 B 계열 vCPU 할당량 부족(TC-INFRA-035 BLOCKED 이력) → S18_Azure 와 같은 Standard_E4as_v6
Azure: { name: process.env.K8S_CLUSTER || 'k2f', provider: 'AZURE', conn: 'azure-koreacentral', vpc: 'p2-shared-azure-koreacentral', subnet: 'p2-shared-azure-koreacentral', sg: 'p2-shared-azure-koreacentral-sg-k8s', version: process.env.K8S_VERSION ?? '1.35.8',
ng: { name: 'g2f', spec: 'Standard_E4as_v6', mem: 0, cpu: 4, size: 1 } },
// GCP(2026-10-15): p4 공용 vNet(subnet 단일 선택), Version 1.35/1.34, NodeGroup 필수 → g4f e2-highmem-4(vCPU=4 검색)
// 2026-10-19 미실행 CSP(사용자 지시): p{n} 공용 vNet(p{n}-shared-<conn>, SG …-sg-k8s). 타입 A(Alibaba·Tencent)는 NodeGroup 없이, 타입 B(IBM·Azure)는 NodeGroup 포함
Alibaba: { name: process.env.K8S_CLUSTER || 'k3f', provider: 'ALIBABA', conn: 'alibaba-ap-northeast-2', vpc: 'p3-shared-alibaba-ap-northeast-2', subnet: 'p3-shared-alibaba-ap-northeast-2', sg: 'p3-shared-alibaba-ap-northeast-2-sg-k8s', version: '1.35.7-aliyun.1' },
Tencent: { name: process.env.K8S_CLUSTER || 'k5f', provider: 'TENCENT', conn: 'tencent-ap-seoul', vpc: 'p5-shared-tencent-ap-seoul', subnet: 'p5-shared-tencent-ap-seoul', sg: 'p5-shared-tencent-ap-seoul-sg-k8s', version: '1.34.1' },
IBM: { name: process.env.K8S_CLUSTER || 'k8f', provider: 'IBM', conn: 'ibm-jp-tok', vpc: 'p8-shared-ibm-jp-tok', subnet: 'p8-shared-ibm-jp-tok', sg: 'p8-shared-ibm-jp-tok-sg-k8s', version: '1.35.9',
ng: { name: 'g8f', spec: 'bx2-4x16', mem: 0, cpu: 4, size: 1 } },
// NHN·NCP: 계정 VPC 한도(연결당 3) 정리 후(2026-10-19) TC-INFRA-023 이 만든 p{n}-shared vNet 사용. 타입 B → NodeGroup 포함
NHNCloud: { name: process.env.K8S_CLUSTER || 'k6f', provider: 'NHN', conn: 'nhn-kr1', vpc: 'p6-shared-nhn-kr1', subnet: 'p6-shared-nhn-kr1', sg: 'p6-shared-nhn-kr1-sg-k8s', version: 'v1.34.3',
ng: { name: 'g6f', spec: 'r2.c4m16', mem: 0, cpu: 4, size: 1 } },
NCP: { name: process.env.K8S_CLUSTER || 'k7f', provider: 'NCP', conn: 'ncp-kr', vpc: 'p7-shared-ncp-kr', subnet: 'p7-shared-ncp-kr', sg: 'p7-shared-ncp-kr-sg-k8s', version: '1.34.3-nks.2',
ng: { name: 'g7f', spec: 's4-g3', mem: 0, cpu: 4, size: 1 } },
GCP: { name: process.env.K8S_CLUSTER || 'k4f', provider: 'GCP', conn: 'gcp-asia-northeast3', vpc: 'p4-shared-gcp-asia-northeast3', subnet: 'p4-shared-gcp-asia-northeast3', sg: 'p4-shared-gcp-asia-northeast3-sg-k8s', version: '1.35',
ng: { name: 'g4f', spec: 'e2-highmem-4', mem: 0, cpu: 4, size: 1 } },
};
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-024", c)} ` + "K8s 직접 생성 Expert 모드 → Creating 확인 (구 TC-INFRA-024)", async ({ page }) => {
const inp = INPUT[c.code];
test.skip(!inp, `${c.name}: 입력값 미지정(AWS 완료 후 별도 지시)`);
const dry = process.env.K8S_DRY === '1';
test.skip(!dry && process.env.K8S_DEPLOY_OK !== '1', 'Deploy 전 사용자 확인 필요(K8S_DEPLOY_OK=1)');
const prj = k8sProject(c);
await loginAsAdmin(page);
await gotoK8s(page, prj);
const before = await clusterCells(page, inp!.name);
test.skip(process.env.K8S_RECHECK !== '1' && Object.keys(before).length > 0, `${inp!.name} 이미 존재: ${JSON.stringify(before)}`);
// K8S_RECHECK=1: 이미 요청한 클러스터(같은 이름)를 Deploy 없이 상태만 재확인(2026-10-15 GKE: 생성 중 Status N/A)
if (!(process.env.K8S_RECHECK === '1' && Object.keys(before).length > 0)) {
const form = await fillExpertCluster(page, { ...inp!, desc: 'TC-INFRA-024 Expert' });
test.skip(!form, `VPC 선택지에 ${inp!.vpc} 없음(12초 대기)`);
// Azure: Expert 도 NodeGroup 필수(Deploy 시 'NodeGroup Required' 팝업, 2026-10-06) → 폼 하단 '+ nodeGroup' 으로 추가
if (inp!.ng) await addNodeGroupForm(page, inp!.ng, { expert: true });
if (dry) { test.info().annotations.push({ type: 'dry', description: 'K8S_DRY=1: Deploy 안 함' }); return; }
const toasts = await deployCluster(page);
expect(toasts.find((t) => /fail|error|invalid|warning/i.test(t)), 'Deploy 직후 오류/경고 토스트 없음').toBeUndefined();
}
// Azure: 생성 중 목록 Status 'N/A'(상세는 Creating, 2026-10-06) → 목록에 행이 생기면 상세 Status 로 판정
const cells = await waitClusterStatus(page, prj, inp!.name, /creat|provision|pending|progress|active|fail|N\/A/i, 8 * 60_000);
const info = await openCluster(page, inp!.name);
const st = info.match(/Status (\S+)/)?.[1] ?? '?';
test.info().annotations.push({ type: 'result', description: `${inp!.name}: ${JSON.stringify(cells)} / 상세 Status: ${st} / ${info.slice(0, 600)}` });
// GKE 는 생성 중 목록·상세 Status 가 'N/A'(2026-10-15, 결함 후보) → Active/Failed 로 바뀔 때까지 최대 25분 재조회해 생성 결과로 판정
let final = st;
if (/^N\/A$|^\?$/.test(st)) {
const end = Date.now() + 25 * 60_000;
while (Date.now() < end && /^N\/A$|^\?$/.test(final)) { await page.waitForTimeout(60_000); await gotoK8s(page, prj); final = (await openCluster(page, inp!.name)).match(/Status (\S+)/)?.[1] ?? '?'; }
test.info().annotations.push({ type: 'status-na', description: `생성 중 Status N/A 표시 → 재조회 결과 ${final}` });
}
expect(final, '상세 Status Creating 계열(또는 생성 완료 Active)').toMatch(/creat|provision|pending|progress|active/i);
});
}tests/infra/TC-INFRA-025.spec.ts · 49줄 · 해시 5903d95b · 시나리오 보기
// TC-INFRA-025: K8s Expert 생성 — Subnet 1개(단일 AZ) 선택 시 오류 (예상실패) [예상실패, CSP별]
// 화면: Workloads > K8s Workloads(p1) > Add cluster > Expert Creation(Cluster Name, Provider/Region, Connection, VPC, Subnet(단일 선택), Security Group, Version(필수)) → Deploy → 토스트
// 시작 상태: admin 로그인, ws01 / p1. p1 공용 vNet p1-shared-aws-ap-northeast-2(subnet 2a·2b)·SG …-sg-k8s 존재(2026-10-15: 지시서의 v1k 는 p1 에 없음)
// 절차 (AWS): 1.K8s Workloads(p1) → Add cluster → Expert Creation 2.Cluster Name=k1g, Provider=AWS, Region/Connection=aws-ap-northeast-2, VPC=p1-shared-aws-ap-northeast-2,
// Subnet=p1-shared-aws-ap-northeast-2(2a 하나만), SG=p1-shared-aws-ap-northeast-2-sg-k8s, Version=1.35 3.Deploy 4.오류 안내(화면 검증 또는 생성 거부)와 목록에 k1g 가 Active 로 생기지 않음을 확인
// 기대결과: AWS EKS 는 서로 다른 AZ 의 Subnet 2개 이상이 필요하므로 생성 거부('Select at least 2 subnets in different availability zones' 또는
// 'InvalidParameterException: Subnets specified must be in at least two different AZs'), 클러스터 미생성
// 비고: 2026-10-15 구현(stub 대체). 화면이 Deploy 시 'Subnet Selection' 모달로 사전 차단(Confirm 을 눌러도 생성 요청 안 감) → 과금 없음. 잘못 생성되면(결함) 사이클 유틸로 삭제
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { clusterCells, deployCluster, fillExpertCluster, gotoK8s } from '../../lib/iv3-k8s';
const INPUT: Record<string, { name: string; provider: string; conn: string; vpc: string; subnet: string; sg: string; version: string } | undefined> = {
AWS: { name: process.env.K8S_CLUSTER || 'k1g', provider: 'AWS', conn: 'aws-ap-northeast-2', vpc: 'p1-shared-aws-ap-northeast-2', subnet: 'p1-shared-aws-ap-northeast-2', sg: 'p1-shared-aws-ap-northeast-2-sg-k8s', version: '1.35' },
};
for (const c of activeCsps(['AWS'])) { // 엑셀에 있는 CSP: AWS
test(`${tid('TC-INFRA-025', c)} K8s Expert 생성 — Subnet 1개(단일 AZ) 선택 시 오류 (예상실패) (구 TC-INFRA-025)`, async ({ page }) => {
test.setTimeout(15 * 60_000);
const inp = INPUT[c.code]!;
const prj = k8sProject(c);
const dialogs: string[] = [];
page.on('dialog', (d) => { dialogs.push(d.message()); });
await loginAsAdmin(page);
await gotoK8s(page, prj);
expect(Object.keys(await clusterCells(page, inp.name)).length, `${inp.name} 미존재(시작 상태)`).toBe(0);
const form = await fillExpertCluster(page, { ...inp, desc: 'TC-INFRA-025 single AZ' });
expect(form, `VPC ${inp.vpc} 선택 가능`).toBeTruthy();
// 다중 선택 Subnet 을 2a 하나만 남긴다
await page.locator('#cluster_subnet').selectOption([inp.subnet]);
const picked = await page.locator('#cluster_subnet').evaluate((s) => [...(s as HTMLSelectElement).selectedOptions].map((o) => o.value));
test.info().annotations.push({ type: 'subnet-selected', description: picked.join(', ') });
expect(picked).toHaveLength(1);
const toasts = await deployCluster(page);
await page.waitForTimeout(5000);
// 화면 사전 차단은 확인 모달(deployCluster 가 'deploy-modal' 로 기록, 2026-10-15: 'Subnet Selection — AWS requires subnets in at least 2 different availability zones')
const modalText = test.info().annotations.filter((a) => a.type === 'deploy-modal').map((a) => a.description ?? '');
const msgs = [...modalText, ...toasts, ...dialogs, ...(await page.locator('.invalid-feedback:visible, .text-danger:visible, [role="alert"]').allInnerTexts())].map((t) => t.replace(/\s+/g, ' ').trim()).filter(Boolean);
test.info().annotations.push({ type: 'messages', description: msgs.join(' | ').slice(0, 1200) || '(없음)' });
// 2분 동안 목록에 Active/Creating 으로 생기지 않는지 확인(생겼다면 결함 — 상태 기록)
let cells: Record<string, string> = {};
for (let i = 0; i < 4; i++) { await page.waitForTimeout(30_000); await gotoK8s(page, prj); cells = await clusterCells(page, inp.name); if (cells.status) break; }
test.info().annotations.push({ type: 'list-after', description: JSON.stringify(cells) || '(행 없음)' });
expect(msgs.join(' '), '단일 AZ 오류 안내').toMatch(/two different|availability zone|at least 2|AZs|subnet/i);
expect(cells.status ?? '', '클러스터 미생성(또는 Failed)').not.toMatch(/active|creat|provision|pending/i);
});
}tests/infra/TC-INFRA-026.spec.ts · 34줄 · 해시 d04e3c97 · 시나리오 보기
// 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('TC-INFRA-026_AWS TC-INFRA-026 SSH 접속 정보 확인 (구 TC-INFRA-026)', 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-----/);
});
await page.waitForTimeout(3000); // END_WAIT: 마지막 동작 후 3초 대기 후 종료(2026-10-19 사용자 지시)
});tests/infra/TC-INFRA-027.spec.ts · 46줄 · 해시 e015c85f · 시나리오 보기
// 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, TC-INFRA-028)
// 화면: TC-INFRA-026 로 얻은 접속 정보(Node Info · SSH Keys Copy) → 테스트 PC 의 ssh 클라이언트
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-INFRA-026(접속 정보 획득), 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('TC-INFRA-027_AWS TC-INFRA-027 SSH 클라이언트로 노드 접속 (구 TC-INFRA-027)', 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. 임시 키 삭제
}
await page.waitForTimeout(3000); // END_WAIT: 마지막 동작 후 3초 대기 후 종료(2026-10-19 사용자 지시)
});tests/infra/TC-INFRA-028.spec.ts · 116줄 · 해시 4ff4042c · 시나리오 보기
// TC-INFRA-028: 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) TC-WF-007 Workflow 결과 mw{n}(해당 CSP node Running) 2) 이 세션 TC-WF-008 결과 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 헬퍼는 TC-INFRA-029 와 같은 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("TC-INFRA-028", c)} ` + "Infra Terminal 접속 (Infra/Node) (구 TC-INFRA-028)", 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/infra/TC-INFRA-029.spec.ts · 85줄 · 해시 fdf31b3d · 시나리오 보기
// TC-INFRA-029: 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 → TC-INFRA-029_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("TC-INFRA-029", c)}_filetransfer_${process.env.IV4_TERM_NODE}` : tid("TC-INFRA-029", c);
test(`${recId} ` + "Terminal 파일 전송 (Public IP Terminal) (구 TC-INFRA-029)", 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 = `TC-INFRA-029 ${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/infra/TC-INFRA-030.spec.ts · 120줄 · 해시 de2b9892 · 시나리오 보기
// TC-INFRA-030: 생성한 Infra에 nodegroup 추가 [정상, CSP별]
// 화면: Workloads > Infra Workloads > Infra 선택 > 상세 Node List 'Add server'(⌄ 메뉴 'New' 와 같은 화면, #addserver) → '+ Node' → Node Configuration → Done → Deploy
// 시작 상태: admin(mcmp) 로그인, ws01/default. mc{n} Running(nodegroup g{n})
// 절차(AWS): 1.Infra 목록에서 mc1 선택 2.NodeGroup g1b 추가(Spec t3.micro, Image Ubuntu 22.04 ami-0efae34c528c68a9c — 계획서 ami-0afe1fd15675c3f15 는 카탈로그에 없음, node 1)
// 3.Deploy → mc1 노드 2개 Running 대기 4.상세 Group 탭에 g1, g1b 표시 확인 5.Default 탭 Node List 에서 g1b-1 선택 → Node Info 상세(CSP, Running, Spec, Image ID, Connection, IP)
// 기대결과: mc1 에 nodegroup 2개(g1, g1b), 신규 node g1b-1 Running, Node Info 상세 표시
// 멱등: g1b 가 이미 있으면 추가는 건너뛰고(preexisting) 확인만 한다.
// MYIMAGE=<이름>: Image 를 Image Source=MyImage 에서 고른다(2026-10-19 Azure: TC-INFRA-047 의 img2 로 g2b 추가 — 원본 g2-1 이 일반화돼 재시작 불가). Image ID 는 기록만.
import { test, expect } from '../../lib/fixtures';
import { CSPS, activeCsps, tid, infraName, infraNodegroups } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { INFRA_INPUT, addNodeGroupToInfra, gotoInfraList, infraInputBlocker, infraRow, nodeField, openInfraDetail, openNodeInfo, rowCells, waitInfraNodes } from '../../lib/infra';
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-030", c)} ` + "생성한 Infra에 nodegroup 추가 (구 TC-INFRA-030)", async ({ page }) => {
const why = infraInputBlocker(c);
test.skip(!!why, why);
const inp = INFRA_INPUT[c.code]!;
const name = infraName(c);
const [ng, ngB] = infraNodegroups(c);
await loginAsAdmin(page);
await gotoInfraList(page);
test.skip(!(await infraRow(page, name).count()), `선행 TC-INFRA-018 의 ${name} 없음`);
const before = await rowCells(infraRow(page, name).first());
test.info().annotations.push({ type: 'before', description: JSON.stringify(before) });
// g1b 존재 여부: 상세 Node List 의 node 카드 #server_status_icon_{ngB}-1
await openInfraDetail(page, name);
let image = inp.image ?? '';
let expectNodes = Number(before['statusCount.countTotal']);
if (await page.locator(`[id^="server_status_icon_${ngB}-"]`).count()) {
test.info().annotations.push({ type: 'preexisting', description: `${ngB} 이미 존재 → 추가 건너뜀` });
} else {
({ image } = await addNodeGroupToInfra(page, c, name, ngB, 1, { myImage: process.env.MYIMAGE }));
expectNodes += 1;
}
// MYIMAGE 모드: 기존 node(g2-1)는 일반화돼 Running 이 될 수 없음 → 기존 Running 수 + 1 만 기다린다
const cells = process.env.MYIMAGE
? await waitInfraNodes(page, name, expectNodes, 20 * 60_000, Number(before['statusCount.countRunning']) + 1)
: await waitInfraNodes(page, name, expectNodes);
test.info().annotations.push({ type: 'after', description: JSON.stringify(cells) });
// Group 탭: g1, g1b
await openInfraDetail(page, name);
await page.getByText('Group', { exact: true }).first().click();
await expect(page.getByText(new RegExp(`^${ng}\\(\\d+\\)$`)).first(), `nodegroup ${ng} 표시`).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(new RegExp(`^${ngB}\\(\\d+\\)$`)).first(), `nodegroup ${ngB} 표시`).toBeVisible({ timeout: 30_000 });
// Default 탭: g1b-1 Node Info
await page.getByText('Default', { exact: true }).first().click();
// 추가한 NodeGroup 의 node id 는 {ng}-1 이 아닐 수 있다(KT: Infra 전체 번호를 이어서 g9b-2) → 카드 id 로 찾는다
const cardB = page.locator(`[id^="server_status_icon_${ngB}-"]`).first();
await cardB.waitFor({ state: 'visible', timeout: 60_000 });
const nodeB = ((await cardB.getAttribute('id')) ?? '').replace('server_status_icon_', '');
test.info().annotations.push({ type: 'node-id', description: `${ngB} 의 node: ${nodeB}` });
const info = await openNodeInfo(page, ngB, nodeB);
test.info().annotations.push({ type: 'node-info', description: info.slice(0, 900) });
expect(nodeField(info, 'CSP'), 'CSP').toBe(inp.provider);
expect(nodeField(info, 'Node Status'), 'Node Status').toBe('Running');
expect(nodeField(info, 'Node Spec'), 'Node Spec').toBe(inp.spec);
if (process.env.MYIMAGE) test.info().annotations.push({ type: 'myimage', description: `MyImage ${process.env.MYIMAGE}: 입력 Image=${image} / Node Info Image ID=${nodeField(info, 'Image ID')}` });
// 지정 이미지명은 접두어일 수 있다(Alibaba ubuntu_22_04_x64_20G_alibase → 실제 …_20260810.vhd, 2026-10-19) → 포함 여부로 판정
else expect(nodeField(info, 'Image ID'), 'Image ID').toContain(image);
expect(nodeField(info, 'Config Name'), 'Connection').toBe(c.infra.conn);
expect(nodeField(info, 'Public IP\\(IPv4\\)'), 'Public IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
expect(nodeField(info, 'Private IP'), 'Private IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
});
}
// TC-INFRA-030_AWS-GCP: 같은 시나리오로 AWS Infra mc1 에 provider=gcp nodegroup g1c(node 2개) 추가 → 멀티 CSP Infra (사용자 지시 2026-10-05)
// 절차: mc1 선택 → Add server → + Node: g1c(GCP e2-small, Ubuntu 22.04, size 2) → Done → Deploy → mc1 노드 +2 Running 대기
// → Group 탭 g1, g1b, g1c 표시 → g1c-1, g1c-2 Node Info(CSP gcp, Running, Spec, Image ID, Connection, IP)
if (activeCsps(['AWS']).length) {
test('TC-INFRA-030_AWS-GCP 생성한 Infra에 다른 CSP(GCP) nodegroup 추가 (구 TC-INFRA-030)', async ({ page }) => {
const aws = CSPS.find((x) => x.code === 'AWS')!;
const gcp = CSPS.find((x) => x.code === 'GCP')!;
const inp = INFRA_INPUT.GCP!;
const name = infraName(aws);
const ngC = `g${aws.n}c`;
const size = 2;
await loginAsAdmin(page);
await gotoInfraList(page);
test.skip(!(await infraRow(page, name).count()), `선행 ${name} 없음`);
const before = await rowCells(infraRow(page, name).first());
test.info().annotations.push({ type: 'before', description: JSON.stringify(before) });
await openInfraDetail(page, name);
let expectNodes = Number(before['statusCount.countTotal']);
if (await page.locator(`#server_status_icon_${ngC}-1`).count()) {
test.info().annotations.push({ type: 'preexisting', description: `${ngC} 이미 존재 → 추가 건너뜀` });
} else {
await addNodeGroupToInfra(page, gcp, name, ngC, size);
expectNodes += size;
}
const cells = await waitInfraNodes(page, name, expectNodes, 25 * 60_000);
test.info().annotations.push({ type: 'after', description: JSON.stringify(cells) });
await openInfraDetail(page, name);
await page.getByText('Group', { exact: true }).first().click();
for (const g of infraNodegroups(aws).concat(ngC)) {
await expect(page.getByText(new RegExp(`^${g}\\(\\d+\\)$`)).first(), `nodegroup ${g} 표시`).toBeVisible({ timeout: 30_000 });
}
await expect(page.getByText(`${ngC}(${size})`).first(), `${ngC} 노드 ${size}개`).toBeVisible();
await page.getByText('Default', { exact: true }).first().click();
for (let k = 1; k <= size; k++) {
const info = await openNodeInfo(page, ngC, `${ngC}-${k}`);
test.info().annotations.push({ type: `node-info:${ngC}-${k}`, description: info.slice(0, 900) });
expect(nodeField(info, 'CSP'), 'CSP').toBe(inp.provider);
expect(nodeField(info, 'Node Status'), 'Node Status').toBe('Running');
expect(nodeField(info, 'Node Spec'), 'Node Spec').toBe(inp.spec);
expect(nodeField(info, 'Image ID'), 'Image ID').not.toBe('');
expect(nodeField(info, 'Config Name'), 'Connection').toBe(gcp.infra.conn);
expect(nodeField(info, 'Public IP\\(IPv4\\)'), 'Public IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
expect(nodeField(info, 'Private IP'), 'Private IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
}
});
}tests/infra/TC-INFRA-031.spec.ts · 133줄 · 해시 c24289cf · 시나리오 보기
// TC-INFRA-031: 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 → 녹화 TC-INFRA-031_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/TC-INFRA-031_AWS_scaleout_g1c.webm
const recId = process.env.IV4_SCALE_NG ? `${tid("TC-INFRA-031", c)}_scaleout_${process.env.IV4_SCALE_NG}` : tid("TC-INFRA-031", c);
test(`${recId} ` + "Infra NodeGroup Scale (Scale Group Size) out (구 TC-INFRA-031)", 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/infra/TC-INFRA-032.spec.ts · 181줄 · 해시 be0a743c · 시나리오 보기
// TC-INFRA-032: 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) TC-WF-007 Workflow 결과 mw{n}(해당 CSP node Running) 2) 이 세션 TC-WF-008 결과 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("TC-INFRA-032", c)} ` + "Scale Policy 등록 → 확인 (구 TC-INFRA-032)", 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(`TC-INFRA-032 ${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/infra/TC-INFRA-033.spec.ts · 60줄 · 해시 5d238d08 · 시나리오 보기
// TC-INFRA-033: K8s 목록에서 Cluster 선택 → NodeGroup 선택 → node 확인 [정상, CSP별]
// 화면: K8s Workloads(p{n}) → 클러스터 행 선택 → Cluster Info + NodeGroup List / Status → NodeGroup 카드(#nodeGroup_status_icon_{ng}) 클릭
// → NodeGroup Info(Name, Id, Status, Image, Spec, Key Pair, Desired/Min/Max Node Size, Auto Scaling, Root Disk) + Node List / Status(node 카드 #node_status_icon_{instance-id}) → node 선택 → 상세
// 절차(AWS): 1.목록에서 k1d 선택(Cluster Info Status Active) 2.NodeGroup g1d 선택 → NodeGroup Info Status Active(NodeGroup 상태값은 Running 이 아니라 Active — 사용자 확인 2026-10-05), Spec c6g.2xlarge
// 3.Node List 의 node(1개) 선택 → 표시되는 상태·정보 기록
// 기대결과: g1d(Active) 의 node 1개가 Node List 에 표시되고 node 상세 정보 확인
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { gotoK8s, selectNodeGroup } from '../../lib/iv3-k8s';
const INPUT: Record<string, { cluster: string; ng: string; spec: string } | undefined> = {
AWS: { cluster: process.env.K8S_CLUSTER ?? 'k1d', ng: process.env.K8S_NG ?? 'g1d', spec: 'c6g.2xlarge' }, // K8S_CLUSTER/K8S_NG: K8s CSP 사이클(k1s/g1s, 2026-10-15)
Azure: { cluster: process.env.K8S_CLUSTER ?? 'k2f', ng: process.env.K8S_NG ?? 'g2f', spec: 'Standard_E4as_v6' }, // 2026-10-19 TC-INFRA-024 클러스터(이전 k2e 는 삭제됨)
Alibaba: { cluster: process.env.K8S_CLUSTER ?? 'k3d', ng: process.env.K8S_NG ?? 'g3d', spec: 'ecs.e-c1m4.xlarge' },
Tencent: { cluster: process.env.K8S_CLUSTER ?? 'k5d', ng: process.env.K8S_NG ?? 'g5d', spec: 'S8.LARGE16' },
IBM: { cluster: process.env.K8S_CLUSTER ?? 'k8f', ng: process.env.K8S_NG ?? 'g8f', spec: 'bx2-4x16' },
NHNCloud: { cluster: process.env.K8S_CLUSTER ?? 'k6e', ng: process.env.K8S_NG ?? 'g6e', spec: 'r2.c4m16' }, // TC-INFRA-023 클러스터
NCP: { cluster: process.env.K8S_CLUSTER ?? 'k7e', ng: process.env.K8S_NG ?? 'g7e', spec: 's4-g3' },
GCP: { cluster: process.env.K8S_CLUSTER ?? 'k4s', ng: process.env.K8S_NG ?? 'g4s', spec: 'e2-highmem-4' }, // K8s CSP 사이클(타입 B, p4, 2026-10-15)
};
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-033", c)} ` + "K8s Cluster 선택 → NodeGroup 선택 → node 확인 (구 TC-INFRA-033)", async ({ page }) => {
const inp = INPUT[c.code];
test.skip(!inp, `${c.name}: 입력값 미지정`);
const prj = k8sProject(c);
await loginAsAdmin(page);
await gotoK8s(page, prj);
// Cluster → NodeGroup 선택
const ng = await selectNodeGroup(page, inp!.cluster, inp!.ng);
const body0 = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
const ci = body0.slice(body0.indexOf('Cluster Info'), body0.indexOf('Cluster Info') + 500);
test.info().annotations.push({ type: 'cluster-info', description: ci });
expect(ci, 'Cluster Status Active').toMatch(/Status Active/i);
test.info().annotations.push({ type: 'nodegroup-info', description: ng.info.slice(0, 600) });
expect(ng.info, 'NodeGroup Status Active').toMatch(/Status Active/i);
expect(ng.info, `Spec ${inp!.spec}`).toContain(inp!.spec);
expect(ng.nodes.length, 'Node List 에 node 1개 이상').toBeGreaterThan(0);
// node 선택 → 상세
const nodeId = ng.nodes[0];
// Azure node id 는 '/subscriptions/…/virtualMachines/0' 형식 → #id 대신 속성 선택자
const card = page.locator(`li[id="node_status_icon_${nodeId}"]`);
test.info().annotations.push({ type: 'node-card', description: `${nodeId} class=${await card.getAttribute('class')}` });
await card.click();
await page.waitForTimeout(5000);
const b = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
// 카드 표시 이름(AWS 는 id 그대로, Azure 는 다를 수 있음)으로 상세 위치를 찾는다
const key = (await card.innerText()).trim() || nodeId;
const nl = b.indexOf('Node List');
const i = b.indexOf(key, nl) >= 0 ? b.indexOf(key, nl) : nl;
const nodeInfo = b.slice(i, i + 1500);
test.info().annotations.push({ type: 'node-info', description: `card='${key}' ${nodeInfo}` });
expect(nodeInfo, 'node 상세 표시').toContain(key);
await page.screenshot({ path: `results/${tid('TC-INFRA-033', c)}-node.png`, fullPage: true });
});
}tests/infra/TC-INFRA-034.spec.ts · 75줄 · 해시 8e863c1e · 시나리오 보기
// TC-INFRA-034: K8s NodeGroup 추가 요청 → Creating 확인 (타입 A, Cluster Active 상태에서 시작) [정상, CSP별]
// 화면: K8s Workloads(p{n}) → 클러스터 행 선택 → 상세 'Add NodeGroup' → Add node(#addnode, 클러스터 정보 고정) → '+ NodeGroup' → NodeGroup Configuration → Done → Deploy
// 절차(AWS): 1.목록에서 k1d Active 확인 2.k1d 선택 → Add NodeGroup → + NodeGroup: Node Name g1d, Spec c6g.2xlarge(Spec Recommendation 메모리 16 / vCPU 8 검색, 사용자 지시 2026-10-05 — t3.medium 대신),
// Image 검색 팝업에서 AL2023_ARM_64_STANDARD(OS Architecture arm64 자동), SSH Key(기본 선택지), Desired Node Count 1 → Done 3.Deploy 4.상세 NodeGroup List / 목록에서 g1d Creating 확인 후 종료(Ready 대기는 녹화하지 않음)
// EKS 안내: 'EKS node groups are always backed by an Auto Scaling group…' — autoscaling 끌 수 없고 min/max = desired
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { addNodeGroupForm, clusterCells, deployNodeGroup, gotoK8s, openCluster, selectNodeGroup } from '../../lib/iv3-k8s';
// AWS: c6g.2xlarge(arm64) 는 'Use Default' 이미지(AL2023_x86_64_STANDARD)와 맞지 않아 실패(1차) → 이미지 검색에서 ARM 이미지 선택(사용자 지시 2026-10-05)
const INPUT: Record<string, { cluster: string; ng: { name: string; spec: string; mem: number; cpu: number; size: number; image?: string; rootDisk?: string; rootDiskSize?: number } } | undefined> = {
// K8S_CLUSTER / K8S_NG 로 대상 변경 가능(2026-10-06 localhost:3009 Expert 클러스터 k1f / g1f)
AWS: { cluster: process.env.K8S_CLUSTER ?? 'k1d', ng: { name: process.env.K8S_NG ?? 'g1d', spec: 'c6g.2xlarge', mem: 16, cpu: 8, size: 1, image: 'AL2023_ARM_64_STANDARD' } },
// GCP: K8s CSP 사이클 k4s(p4). stage 카탈로그에 e2-standard-2/4 없음 → vCPU=4 최저가 e2-highmem-4, 메모리 조건 없이 검색(2026-10-15)
// 2026-10-19 미실행 CSP: 타입 A 는 TC-INFRA-022 클러스터(k{n}d)에 g{n}d, 타입 B 는 TC-INFRA-024 클러스터(k{n}f)에 g{n}t. vCPU=4 검색(메모리 조건 없음)
Alibaba: { cluster: process.env.K8S_CLUSTER ?? 'k3d', ng: { name: process.env.K8S_NG ?? 'g3d', spec: 'ecs.e-c1m4.xlarge', mem: 0, cpu: 4, size: 1, rootDisk: 'cloud_essd', rootDiskSize: 40 } },
Tencent: { cluster: process.env.K8S_CLUSTER ?? 'k5d', ng: { name: process.env.K8S_NG ?? 'g5d', spec: 'S8.LARGE16', mem: 0, cpu: 4, size: 1, rootDisk: 'CLOUD_BSSD', rootDiskSize: 50 } },
IBM: { cluster: process.env.K8S_CLUSTER ?? 'k8f', ng: { name: process.env.K8S_NG ?? 'g8t', spec: 'bx2-4x16', mem: 0, cpu: 4, size: 1 } },
NHNCloud: { cluster: process.env.K8S_CLUSTER ?? 'k6e', ng: { name: process.env.K8S_NG ?? 'g6t', spec: 'r2.c4m16', mem: 0, cpu: 4, size: 1 } }, // TC-INFRA-023 클러스터(타입 B)
NCP: { cluster: process.env.K8S_CLUSTER ?? 'k7e', ng: { name: process.env.K8S_NG ?? 'g7t', spec: 's4-g3', mem: 0, cpu: 4, size: 1 } },
Azure: { cluster: process.env.K8S_CLUSTER ?? 'k2f', ng: { name: process.env.K8S_NG ?? 'g2t', spec: 'Standard_E4as_v6', mem: 0, cpu: 4, size: 1 } },
GCP: { cluster: process.env.K8S_CLUSTER ?? 'k4s', ng: { name: process.env.K8S_NG ?? 'g4t', spec: 'e2-highmem-4', mem: 0, cpu: 4, size: 1 } },
};
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-034", c)} ` + "K8s NodeGroup 추가 요청 → Creating 확인 (구 TC-INFRA-034)", async ({ page }) => {
const inp = INPUT[c.code];
test.skip(!inp, `${c.name}: 입력값 미지정`);
test.skip(process.env.K8S_DEPLOY_OK !== '1', 'Deploy 전 사용자 확인 필요(K8S_DEPLOY_OK=1)');
const prj = k8sProject(c);
await loginAsAdmin(page);
await gotoK8s(page, prj);
// 목록 행이 늦게 그려지면 {} 로 읽혀 TypeError(2026-10-20 IBM·NHN) → 행이 나올 때까지 최대 3분 재조회
let cells: Record<string, string> = {};
await expect.poll(async () => { await gotoK8s(page, prj); cells = await clusterCells(page, inp!.cluster); return cells.status ?? ''; },
{ timeout: 180_000, intervals: [15_000] }).not.toBe('');
test.info().annotations.push({ type: 'cluster', description: JSON.stringify(cells) });
expect(cells.status, `${inp!.cluster} Active`).toMatch(/active/i);
const before = await openCluster(page, inp!.cluster);
test.info().annotations.push({ type: 'detail-before', description: before.slice(0, 600) });
test.skip(new RegExp(`\\b${inp!.ng.name}\\b`).test(before.slice(before.indexOf('NodeGroup List'))), `${inp!.ng.name} 이미 존재`);
await addNodeGroupForm(page, inp!.ng);
const toasts = await deployNodeGroup(page);
expect(toasts.find((t) => /fail|error|invalid/i.test(t)), 'Deploy 직후 오류 토스트 없음').toBeUndefined();
// 상세 NodeGroup List 에서 g1d 상태(Creating) 확인.
// v07(2026-10-07) 보완: (1) 폴링 안에서 예외(클러스터 행 지연 등)가 나도 계속 재시도 (2) AWS NodeGroup 은 몇 분 안에 Active 가 되어
// 20초 폴링 사이에 Creating 이 지나갈 수 있음 → 요청 접수 토스트(queued / in progress)가 있었으면 Active 로 바뀐 것도 '요청 처리됨'으로 인정하고 annotation 에 남긴다.
const accepted = toasts.some((t) => /queued|in progress/i.test(t));
// 2026-10-15 보완: 상세 NodeGroup List 카드는 이름만 표시(상태 문구 없음) — 이전 PASS 는 토스트 'K8s NG create: …' 의 create 가 우연히 걸린 것.
// → NodeGroup 카드를 열어 NodeGroup Info 의 Status 로 판정(최대 15분). AWS 는 spider 가 Active 까지 기다린 뒤 응답해 Creating 이 안 보일 수 있음
let detail = '';
let ngStatus = '';
await expect.poll(async () => {
try {
await gotoK8s(page, prj);
const st = await selectNodeGroup(page, inp!.cluster, inp!.ng.name);
detail = st.info;
ngStatus = st.info.match(/Status (\S+)/)?.[1] ?? '';
} catch (e) {
return `재시도: ${String(e).split('\n')[0].slice(0, 80)}`;
}
if (/creat|pending|provision|progress|updat/i.test(ngStatus)) return 'creating';
if (accepted && /active/i.test(ngStatus)) return 'active-after-accepted';
return `status=${ngStatus || '(없음)'}`;
}, { message: `${inp!.ng.name} Creating`, timeout: 15 * 60_000, intervals: [15_000] }).toMatch(/^(creating|active-after-accepted)$/);
test.info().annotations.push({ type: 'nodegroup-status', description: `${inp!.ng.name}: ${ngStatus}${accepted ? ' (요청 접수 토스트 확인)' : ''}` });
test.info().annotations.push({ type: 'detail-after', description: detail.slice(0, 600) });
test.info().annotations.push({ type: 'list-after', description: JSON.stringify(await clusterCells(page, inp!.cluster)) });
});
}tests/infra/TC-INFRA-035.spec.ts · 95줄 · 해시 876599b1 · 시나리오 보기
// TC-INFRA-035: [K8s] K8s NodeGroup(K8s node) Scale out (1→2) [정상, CSP별] (Scale in 은 TC-INFRA-036 으로 분리, 사용자 지시 2026-10-07)
// 대상: K8s NodeGroup / K8s node (Infra 아님 — Infra NodeGroup scale out 은 TC-INFRA-031)
// 사용자 지시(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';
import { scaleWorkload } from '../../lib/iv4-scale-workload';
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' },
// 2026-10-19 미실행 CSP: 타입 A 는 k{n}d/g{n}d, 타입 B 는 k{n}f/g{n}f
Alibaba: { cluster: process.env.K8S_CLUSTER ?? 'k3d', ng: process.env.K8S_NG ?? 'g3d' },
Tencent: { cluster: process.env.K8S_CLUSTER ?? 'k5d', ng: process.env.K8S_NG ?? 'g5d' },
IBM: { cluster: process.env.K8S_CLUSTER ?? 'k8f', ng: process.env.K8S_NG ?? 'g8f' },
Azure: { cluster: process.env.K8S_CLUSTER ?? 'k2f', ng: process.env.K8S_NG ?? 'g2f' },
NHNCloud: { cluster: process.env.K8S_CLUSTER ?? 'k6e', ng: process.env.K8S_NG ?? 'g6e' }, // TC-INFRA-023 클러스터(2026-10-19)
NCP: { cluster: process.env.K8S_CLUSTER ?? 'k7e', ng: process.env.K8S_NG ?? 'g7e' }, // K8s CSP 사이클(p4, 2026-10-15)
};
// K8S_SCALE_MODE=autoscaler (2026-10-20 사용자 지시, NHN·IBM): 노드 수를 직접 바꾸는 경로가 없어(SPIDER-TECH-001) UI Edit Scaling 으로
// autoscaling on + 범위를 지정하고, 노드당 1개만 뜨는 워크로드(lib/iv4-scale-workload)로 필요한 노드 수를 만든 뒤 UI Node List 의 node 수로 판정
// 같은 클러스터 행을 다시 클릭하면 선택이 풀려 상세(NodeGroup Info)가 닫힌다(2026-10-20 NHN) → 항상 목록 재진입 후 선택
async function freshNodeGroup(page: import('@playwright/test').Page, prj: string, cluster: string, ng: string) {
await gotoK8s(page, prj);
return selectNodeGroup(page, cluster, ng);
}
async function waitNodes(page: import('@playwright/test').Page, prj: string, cluster: string, ng: string, n: number, min: number, label: string) {
let st = await freshNodeGroup(page, prj, cluster, ng);
const t0 = Date.now();
await expect.poll(async () => { await gotoK8s(page, prj); st = await selectNodeGroup(page, cluster, ng); return st.nodes.length; },
{ message: `${ng} node 수 ${n}`, timeout: min * 60_000, intervals: [30_000] }).toBe(n);
test.info().annotations.push({ type: label, description: `node ${st.nodes.length}개(${Math.round((Date.now() - t0) / 1000)}s) / ${st.info.slice(0, 300)}` });
return st;
}
async function waitActive(page: import('@playwright/test').Page, prj: string, cluster: string, ng: string) {
await expect.poll(async () => { await gotoK8s(page, prj); return (await selectNodeGroup(page, cluster, ng)).info.match(/Status (\S+)/)?.[1] ?? '?'; },
{ message: 'NodeGroup Active', timeout: 20 * 60_000, intervals: [30_000] }).toBe('Active');
}
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 은 TC-INFRA-031)
test(`TC-INFRA-035_K8sWorkloads_EditScaling-ScaleOut_${c.code} [K8s] K8s NodeGroup scale out 1→2 (K8s node) (구 TC-INFRA-035)`, async ({ page }) => {
test.skip(!inp, `${c.name}: 대상 미지정`);
test.info().annotations.push({ type: 'target', description: `K8s NodeGroup (Infra 아님): cluster=${inp!.cluster}, nodegroup=${inp!.ng}` });
if (process.env.K8S_SCALE_MODE === 'autoscaler') {
test.setTimeout(60 * 60_000);
await loginAsAdmin(page);
await gotoK8s(page, prj);
await waitActive(page, prj, inp!.cluster, inp!.ng);
const st0 = await freshNodeGroup(page, prj, inp!.cluster, inp!.ng);
const n0 = st0.nodes.length;
const target = Number(process.env.K8S_SCALE_TARGET ?? n0 + 1);
const max = Number(process.env.K8S_SCALE_MAX ?? target);
test.info().annotations.push({ type: 'mode', description: `autoscaler 경유: node ${n0} → ${target}, Edit Scaling autoscaling on / min 1 / max ${max} + 워크로드 ${target}개(노드당 1개) / ${st0.info.slice(0, 250)}` });
await editScaling(page, Number.isFinite(st0.desired) ? st0.desired : n0, { autoscaling: true, min: 1, max });
await scaleWorkload(page, prj, inp!.cluster, target);
await waitNodes(page, prj, inp!.cluster, inp!.ng, target, 25, 'after');
return;
}
await scaleTo(page, prj, inp!.cluster, inp!.ng, 2);
});
}tests/infra/TC-INFRA-036.spec.ts · 100줄 · 해시 24cfb3c6 · 시나리오 보기
// TC-INFRA-036: [K8s] K8s NodeGroup(K8s node) Scale in (2→1) [정상, CSP별] (TC-INFRA-035 Scale out 의 후속, 사용자 지시 2026-10-07)
// 대상: K8s NodeGroup / K8s node (Infra 아님). 선행: TC-INFRA-035 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';
import { scaleWorkload } from '../../lib/iv4-scale-workload';
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' },
// 2026-10-19 미실행 CSP: 타입 A 는 k{n}d/g{n}d, 타입 B 는 k{n}f/g{n}f
Alibaba: { cluster: process.env.K8S_CLUSTER ?? 'k3d', ng: process.env.K8S_NG ?? 'g3d' },
Tencent: { cluster: process.env.K8S_CLUSTER ?? 'k5d', ng: process.env.K8S_NG ?? 'g5d' },
IBM: { cluster: process.env.K8S_CLUSTER ?? 'k8f', ng: process.env.K8S_NG ?? 'g8f' },
Azure: { cluster: process.env.K8S_CLUSTER ?? 'k2f', ng: process.env.K8S_NG ?? 'g2f' },
NHNCloud: { cluster: process.env.K8S_CLUSTER ?? 'k6e', ng: process.env.K8S_NG ?? 'g6e' }, // TC-INFRA-023 클러스터(2026-10-19)
NCP: { cluster: process.env.K8S_CLUSTER ?? 'k7e', ng: process.env.K8S_NG ?? 'g7e' }, // K8s CSP 사이클(p4, 2026-10-15)
};
// K8S_SCALE_MODE=autoscaler (2026-10-20 사용자 지시, NHN·IBM): 노드 수를 직접 바꾸는 경로가 없어(SPIDER-TECH-001) UI Edit Scaling 으로
// autoscaling on + 범위를 지정하고, 노드당 1개만 뜨는 워크로드(lib/iv4-scale-workload)로 필요한 노드 수를 만든 뒤 UI Node List 의 node 수로 판정
// 같은 클러스터 행을 다시 클릭하면 선택이 풀려 상세(NodeGroup Info)가 닫힌다(2026-10-20 NHN) → 항상 목록 재진입 후 선택
async function freshNodeGroup(page: import('@playwright/test').Page, prj: string, cluster: string, ng: string) {
await gotoK8s(page, prj);
return selectNodeGroup(page, cluster, ng);
}
async function waitNodes(page: import('@playwright/test').Page, prj: string, cluster: string, ng: string, n: number, min: number, label: string) {
let st = await freshNodeGroup(page, prj, cluster, ng);
const t0 = Date.now();
await expect.poll(async () => { await gotoK8s(page, prj); st = await selectNodeGroup(page, cluster, ng); return st.nodes.length; },
{ message: `${ng} node 수 ${n}`, timeout: min * 60_000, intervals: [30_000] }).toBe(n);
test.info().annotations.push({ type: label, description: `node ${st.nodes.length}개(${Math.round((Date.now() - t0) / 1000)}s) / ${st.info.slice(0, 300)}` });
return st;
}
async function waitActive(page: import('@playwright/test').Page, prj: string, cluster: string, ng: string) {
await expect.poll(async () => { await gotoK8s(page, prj); return (await selectNodeGroup(page, cluster, ng)).info.match(/Status (\S+)/)?.[1] ?? '?'; },
{ message: 'NodeGroup Active', timeout: 20 * 60_000, intervals: [30_000] }).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);
test(`TC-INFRA-036_K8sWorkloads_EditScaling-ScaleIn_${c.code} [K8s] K8s NodeGroup scale in 2→1 (K8s node) (구 TC-INFRA-036)`, 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}` });
if (process.env.K8S_SCALE_MODE === 'autoscaler') {
// NHN·IBM 은 2→1 이 구조상 불가(시스템 파드·ALB, SPIDER-TECH-001 실측) → 사용자 지시로 3→2 측정(2026-10-20)
const from = Number(process.env.K8S_SCALE_FROM ?? 3), to = Number(process.env.K8S_SCALE_TO ?? 2);
await loginAsAdmin(page);
await gotoK8s(page, prj);
let st = await selectNodeGroup(page, inp!.cluster, inp!.ng);
test.info().annotations.push({ type: 'mode', description: `autoscaler 경유: node ${from} → ${to}(시작 node ${st.nodes.length}개) / ${st.info.slice(0, 250)}` });
if (st.nodes.length !== from) { // 준비: node 를 from 개로(워크로드 from 개)
await waitActive(page, prj, inp!.cluster, inp!.ng);
st = await freshNodeGroup(page, prj, inp!.cluster, inp!.ng);
await editScaling(page, Number.isFinite(st.desired) ? st.desired : st.nodes.length, { autoscaling: true, min: 1, max: from });
await scaleWorkload(page, prj, inp!.cluster, from);
await waitNodes(page, prj, inp!.cluster, inp!.ng, from, 25, 'prep');
}
await waitActive(page, prj, inp!.cluster, inp!.ng);
st = await freshNodeGroup(page, prj, inp!.cluster, inp!.ng);
// Scale in: Edit Scaling 으로 autoscaling on·범위(min 1/max from) 확인 후 워크로드를 to 개로 줄여 autoscaler 회수 대기(최대 30분)
await editScaling(page, Number.isFinite(st.desired) ? st.desired : st.nodes.length, { autoscaling: true, min: 1, max: from });
await scaleWorkload(page, prj, inp!.cluster, to);
try { await waitNodes(page, prj, inp!.cluster, inp!.ng, to, 30, 'after'); }
finally { await scaleWorkload(page, prj, inp!.cluster, 'delete'); }
return;
}
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개여야 함, TC-INFRA-035 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/infra/TC-INFRA-037.spec.ts · 40줄 · 해시 93deea9d · 시나리오 보기
// 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('TC-INFRA-037', c)} TC-INFRA-037 kubectl 접속 정보 획득 (kubeconfig / token) (구 TC-INFRA-037)`, 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/infra/TC-INFRA-038.spec.ts · 51줄 · 해시 5b27d35a · 시나리오 보기
// TC-INFRA-038 kubectl 로 앱 배포 [정상, CSP별]
// v0.6 TC-ID: TC-INFRA-K8S-06 — 기존 spec 없음(웹 Helm 배포만 있음). 2026-10-15 작성
// 화면: TC-INFRA-037 의 Copy KubeConfig → 테스트 PC 의 kubectl
// 시작 상태: admin(mcmp) 로그인, ws01/p{n}. TC-INFRA-037(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('TC-INFRA-038', c)} TC-INFRA-038 kubectl 로 앱 배포 (구 TC-INFRA-038)`, 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 });
}
});
}tests/infra/TC-INFRA-039.spec.ts · 39줄 · 해시 54fb560e · 시나리오 보기
// TC-INFRA-039: Disk attach/detach — Infra 상세(Node Info)에서 [정상, CSP별] (생성→attach→detach 통합. 개별 동작은 TC-INFRA-011~S16)
// 화면: Cloud Resources > Disks(Create Data Disk) → Infra Workloads > Infra 선택 > Node List 에서 node 선택 > Node Info > Block Device '+ Attach Disk' / 'Detach'
// 절차(AWS): 1.Disks 에서 disk1c 생성(aws-ap-northeast-2, 10GB) 2.mc1 → Running node(g1b-2) → Node Info 3.+ Attach Disk → Existing Disk disk1c → Attach
// 4.Disks 목록 Attached / Attached To 'mc1 / g1b-2', Block Device 에 disk1c 5.Block Device disk1c Detach → Confirm 6.Disks 목록 Available, Block Device 에서 사라짐
// 기대결과: disk1c 가 node 에 attach 되고 detach 후 해제(Available)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { DISK_TARGET, attachExistingDisk, blockDevice, createDisk, detachDisk, diskTargetBlocker, openInfraDetail, openNodeInfo, pickRunningNode, waitDiskState } from '../../lib/infra';
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-039", c)} ` + "Disk attach/detach: Infra 상세에서 (구 TC-INFRA-039)", async ({ page }) => {
const why = diskTargetBlocker(c);
test.skip(!!why, why);
const tg = DISK_TARGET[c.code]!;
await loginAsAdmin(page);
await createDisk(page, c.infra.conn, tg.disk, tg.size, tg.diskType);
await waitDiskState(page, tg.disk, 'available');
const node = await pickRunningNode(page, tg.infra, tg.node);
const ng = node.replace(/-\d+$/, '');
let info = await openNodeInfo(page, ng, node);
expect(blockDevice(info), '시작 시 disk 미연결').not.toContain(tg.disk);
const target = await attachExistingDisk(page, tg.disk);
expect(target, '대상 노드 표시').toContain(node);
await waitDiskState(page, tg.disk, 'attached', node);
await openInfraDetail(page, tg.infra);
info = await openNodeInfo(page, ng, node);
expect(blockDevice(info), 'Block Device 에 disk 표시').toContain(tg.disk);
await detachDisk(page, tg.disk);
await waitDiskState(page, tg.disk, 'available');
await openInfraDetail(page, tg.infra);
info = await openNodeInfo(page, ng, node);
expect(blockDevice(info), 'Block Device 에서 disk 해제').not.toContain(tg.disk);
});
}tests/infra/TC-INFRA-040.spec.ts · 69줄 · 해시 9c6811a4 · 시나리오 보기
// TC-INFRA-040: Disk attach/detach: Disks 화면 action item에서 [정상, CSP별] — Disk 테스트 (Infra 상세에서 하는 S04 와 구분)
// 화면: Cloud Resources > Disks (Create Data Disk 팝업) → Disk 행 선택 → 목록 상단 'Attach selected Data Disk' → attach, 행 선택 시 열리는 Data Disk Detail 의 Detach
// 전제: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default. TC-INFRA-018 mc1 Running
// 절차(AWS): 1.Disks 진입 2.Create Data Disk: Connection=aws-ap-northeast-2, Disk Name=disk1b, 10GB 3.disk1b 행 선택 → Attach selected Data Disk: Infra mc1 / NodeGroup / Node 선택
// 4.attach 확인(Disks 목록 Attached, Attached To) 5.행 선택 → Data Disk Detail 의 Detach → Confirm 6.detach 확인(Available, Attached To '-')
// 기대결과: disk1b 가 mc1 의 node 에 attach/detach
// 화면 확인(2026-10-08 AWS 프로브): Attach Data Disk 모달(#attach-disk-mci Infra / #attach-disk-nodegroup / #attach-disk-vm / #attach-disk-execute-btn),
// Detach 버튼 #detail-detach-btn. disk 의 AZ 가 node 와 다르면 attach 가 거부될 수 있다(TC-INFRA-041 참고) → 결과 그대로 기록
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin, toasts } from '../../lib/ui';
import { DISK_TARGET, createDisk, diskCells, diskRow, diskTargetBlocker, gotoDisks, nodeField, openNodeInfo, pickRunningNode, waitDiskState } from '../../lib/infra';
// disk 이름: CSP 별 disk{n}b (2026-10-19: 'disk1b' 고정이라 Alibaba 에서 AWS 의 disk1b 를 집어 attach 모달 선택 실패). node 와 같은 AZ 의 disk 로 바꿀 때 TARGET_DISK_B 지정(2026-10-12)
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-040", c)} ` + "Disk attach/detach: Disks 화면 action item에서 (구 TC-INFRA-040)", async ({ page }) => {
const why = diskTargetBlocker(c);
test.skip(!!why, why);
const tg = DISK_TARGET[c.code]!;
const DISK_B = process.env.TARGET_DISK_B ?? `disk${c.n}b`;
await loginAsAdmin(page);
await createDisk(page, c.infra.conn, DISK_B, tg.size, tg.diskType);
const node = await pickRunningNode(page, tg.infra, tg.node);
const ng = node.replace(/-\d+$/, '');
const info = await openNodeInfo(page, ng, node);
test.info().annotations.push({ type: 'node-zone', description: `${node}: Zone ${nodeField(info, 'Zone')}` });
// 3. Disks 목록에서 행 선택 → Attach selected Data Disk
await gotoDisks(page);
await diskRow(page, DISK_B).first().getByRole('checkbox').check();
await page.locator('[title="Attach selected Data Disk"], [data-bs-original-title="Attach selected Data Disk"]').first().click();
const m = page.locator('.modal.show').last();
await expect(m).toBeVisible();
await m.locator('#attach-disk-mci').selectOption({ label: tg.infra });
await m.locator('#attach-disk-nodegroup').selectOption({ label: ng });
// node 목록은 NodeGroup 선택 후 채워지고 표시는 'g1-1 (Running)'
await expect.poll(async () => (await m.locator('#attach-disk-vm option').allInnerTexts()).length, { timeout: 30_000 }).toBeGreaterThan(1);
const vmLabel = (await m.locator('#attach-disk-vm option').allInnerTexts()).find((s) => s.trim().startsWith(node))!;
await m.locator('#attach-disk-vm').selectOption({ label: vmLabel });
await m.locator('#attach-disk-execute-btn').click();
// 거부되면(AZ 불일치 등) 모달이 닫히지 않고 오류 표시도 없다(TC-INFRA-041 와 같은 증상) → 닫히지 않으면 기록 후 취소하고 Disks 상태로 판정
const closed = await m.waitFor({ state: 'hidden', timeout: 90_000 }).then(() => true).catch(() => false);
if (!closed) {
test.info().annotations.push({ type: 'attach-modal-open', description: `Attach 후 90초 넘게 모달이 닫히지 않음: ${(await m.innerText()).replace(/\s+/g, ' ').slice(0, 120)}` });
await m.getByRole('button', { name: 'Cancel' }).click();
const st = await diskCells(page, DISK_B);
test.info().annotations.push({ type: 'disk-after-attach', description: JSON.stringify(st) });
expect(st.status, `attach 실패(${DISK_B} 기본 AZ 와 ${node}(${nodeField(info, 'Zone')}) 불일치 추정, TC-INFRA-041 참고)`).toMatch(/attached/i);
}
await page.waitForTimeout(3000);
const t1 = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
test.info().annotations.push({ type: 'attach', description: `target=${tg.infra} / ${node} disk=${DISK_B} / ${t1.join(' | ').slice(0, 300) || '(토스트 없음)'}` });
await waitDiskState(page, DISK_B, 'attached');
test.info().annotations.push({ type: 'disk-attached', description: JSON.stringify(await diskCells(page, DISK_B)) });
// 5. detach: 행 선택 → Data Disk Detail 의 Detach → Confirm
await diskRow(page, DISK_B).first().click();
const det = page.locator('#detail-detach-btn');
await expect(det, 'Detach 버튼').toBeVisible({ timeout: 15_000 });
await det.click();
const m2 = page.locator('.modal.show').last();
await expect(m2).toBeVisible();
test.info().annotations.push({ type: 'detach-confirm', description: (await m2.innerText()).replace(/\s+/g, ' ').slice(0, 160) });
await m2.getByRole('button', { name: /Confirm|Detach|OK/ }).last().click();
await waitDiskState(page, DISK_B, 'available');
test.info().annotations.push({ type: 'disk-detached', description: JSON.stringify(await diskCells(page, DISK_B)) });
});
}tests/infra/TC-INFRA-041.spec.ts · 36줄 · 해시 ce1c96e6 · 시나리오 보기
// TC-INFRA-041: Data Disk attach — Infra 상세(Node Info)에서 [정상, CSP별] — Disk 테스트 3분리 중 2 (선행 TC-INFRA-011)
// 화면: Infra Workloads > Infra 선택 > Node List 에서 node 선택 > Node Info > Block Device '+ Attach Disk' (Attach Data Disk: Target Node 고정, Existing Disk / Create New)
// 절차(AWS): 1.mc1 선택 → Running node(g1b-2) 선택 → Node Info 2.+ Attach Disk → Existing Disk: disk1d → Attach 3.Block Device 에 disk1d, Disks 목록 Attached / Attached To 'mc1 / {node}' 확인
// 기대결과: disk1d 가 node 에 attach
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { DISK_TARGET, attachExistingDisk, blockDevice, diskCells, diskTargetBlocker, openInfraDetail, openNodeInfo, pickRunningNode, waitDiskState } from '../../lib/infra';
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-041", c)} ` + "Data Disk attach (AttachDisk) (구 TC-INFRA-041)", async ({ page }) => {
const why = diskTargetBlocker(c);
test.skip(!!why, why);
const tg = DISK_TARGET[c.code]!;
await loginAsAdmin(page);
const before = await diskCells(page, tg.diskSplit);
test.skip(!before.name, `선행 TC-INFRA-011 의 ${tg.diskSplit} 없음`);
test.info().annotations.push({ type: 'disk-before', description: JSON.stringify(before) });
const node = await pickRunningNode(page, tg.infra, tg.node);
const ng = node.replace(/-\d+$/, '');
let info = await openNodeInfo(page, ng, node);
if (/attached/i.test(before.status ?? '')) {
test.info().annotations.push({ type: 'preexisting', description: `${tg.diskSplit} 이미 attach: ${before._attachedTo}` });
} else {
const target = await attachExistingDisk(page, tg.diskSplit);
expect(target, '대상 노드 표시').toContain(node);
}
await waitDiskState(page, tg.diskSplit, 'attached');
await openInfraDetail(page, tg.infra);
info = await openNodeInfo(page, ng, node);
test.info().annotations.push({ type: 'block', description: blockDevice(info) });
expect(blockDevice(info), 'Block Device 에 disk 표시').toContain(tg.diskSplit);
});
}tests/infra/TC-INFRA-042.spec.ts · 40줄 · 해시 3ee3783a · 시나리오 보기
// TC-INFRA-042: Data Disk attach — Create New (node 와 같은 AZ 에 새 disk 를 만들어 attach) [정상, CSP별]
// 배경: Existing Disk 는 disk 의 AZ 가 node 와 다르면 attach 가 거부된다(TC-INFRA-041, AWS 2a/2d). 같은 AZ 에 없으면 Create New 로 만든다(사용자 지시 2026-10-07).
// 화면: Infra Workloads > Infra 선택 > Node List 에서 node 선택 > Node Info > Block Device '+ Attach Disk' > Create New
// 절차(AWS): 1.mc1 선택 → Running node 선택 → Node Info(Zone 기록) 2.+ Attach Disk → Create New: Disk Name=disk1e, Size 10GB → Attach
// 3.Block Device 에 disk1e, Disks 목록 Attached / Attached To 'mc1 / {node}' 확인
// 기대결과: disk1e 가 node 와 같은 AZ 에 생성되어 attach 됨
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { DISK_TARGET, attachNewDisk, blockDevice, diskCells, diskTargetBlocker, nodeField, openInfraDetail, openNodeInfo, pickRunningNode, waitDiskState } from '../../lib/infra';
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-042", c)} ` + "Data Disk attach — Create New (node 와 같은 AZ) (구 TC-INFRA-042)", async ({ page }) => {
const why = diskTargetBlocker(c);
test.skip(!!why, why);
const tg = DISK_TARGET[c.code]!;
test.skip(!tg.diskNew, `${c.name}: Create New 대상 disk 이름 미지정`);
const disk = tg.diskNew!;
await loginAsAdmin(page);
const before = await diskCells(page, disk);
const node = await pickRunningNode(page, tg.infra, tg.node);
const ng = node.replace(/-\d+$/, '');
let info = await openNodeInfo(page, ng, node);
test.info().annotations.push({ type: 'node-zone', description: `${node}: Zone ${nodeField(info, 'Zone')}` });
if (/attached/i.test(before.status ?? '')) {
test.info().annotations.push({ type: 'preexisting', description: `${disk} 이미 attach: ${before._attachedTo}` });
} else {
const target = await attachNewDisk(page, disk, tg.size, tg.diskType);
expect(target, '대상 노드 표시').toContain(node);
}
await waitDiskState(page, disk, 'attached');
const after = await diskCells(page, disk);
test.info().annotations.push({ type: 'disk-after', description: JSON.stringify(after) });
await openInfraDetail(page, tg.infra);
info = await openNodeInfo(page, ng, node);
test.info().annotations.push({ type: 'block', description: blockDevice(info) });
expect(blockDevice(info), 'Block Device 에 disk 표시').toContain(disk);
});
}tests/infra/TC-INFRA-043.spec.ts · 37줄 · 해시 6b312d78 · 시나리오 보기
// TC-INFRA-043: Data Disk detach — Infra 상세(Node Info)에서 [정상, CSP별] — Disk 테스트 3분리 중 3 (선행 TC-INFRA-041)
// 화면: Infra Workloads > Infra 선택 > Node List 에서 node 선택 > Node Info > Block Device 의 disk 'Detach' → 'Detach disk "{disk}"?' Confirm
// 절차(AWS): 1.Disks 목록에서 disk1d 가 attach 된 node 확인 2.mc1 → 해당 node 선택 → Node Info 3.Block Device disk1d Detach → Confirm 4.Disks 목록 Available, Block Device 에서 사라짐 확인
// 기대결과: disk1d 가 node 에서 해제되어 Available
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { DISK_TARGET, blockDevice, detachDisk, diskCells, diskTargetBlocker, openInfraDetail, openNodeInfo, waitDiskState } from '../../lib/infra';
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-043", c)} ` + "Data Disk detach (DetachDisk) (구 TC-INFRA-043)", async ({ page }) => {
const why = diskTargetBlocker(c);
test.skip(!!why, why);
const tg = DISK_TARGET[c.code]!;
await loginAsAdmin(page);
// disk1d(S15) 가 AZ 불일치로 attach 되지 않은 경우(v07 AWS) S25 로 attach 된 disk1e 를 대상으로 한다
let disk = tg.diskSplit;
let before = await diskCells(page, disk);
if (!/attached/i.test(before.status ?? '') && tg.diskNew) { disk = tg.diskNew; before = await diskCells(page, disk); }
test.info().annotations.push({ type: 'disk-before', description: `${disk}: ${JSON.stringify(before)}` });
test.skip(!/attached/i.test(before.status ?? ''), `선행 TC-INFRA-041/S25: attach 된 disk 없음`);
// Attached To 'mc1 / g1b-2' 에서 node 추출
const node = (before._attachedTo ?? '').split('/').pop()!.trim();
const ng = node.replace(/-\d+$/, '');
await openInfraDetail(page, tg.infra);
let info = await openNodeInfo(page, ng, node);
expect(blockDevice(info), '시작 시 Block Device 에 disk').toContain(disk);
await detachDisk(page, disk);
await waitDiskState(page, disk, 'available');
await openInfraDetail(page, tg.infra);
info = await openNodeInfo(page, ng, node);
test.info().annotations.push({ type: 'block', description: blockDevice(info) });
expect(blockDevice(info), 'Block Device 에서 disk 해제').not.toContain(disk);
});
}tests/infra/TC-INFRA-044.spec.ts · 54줄 · 해시 6e89942a · 시나리오 보기
// TC-INFRA-044: Disks 화면에서 Data Disk Detach (Data Disk Detail 의 Detach) [정상, AWS] (사용자 지시 2026-10-08)
// 화면: Cloud Resources > Disks → 행 선택 → 하단 Data Disk Detail(Attached To) → Detach(#detail-detach-btn) → 확인
// 절차: 1.Disks 진입 2.Attached 상태 disk(disk1e) 행 선택 3.Attached To 확인 4.Detach → 확인 5.목록 Status Available, Attached To '-' 확인
// 기대결과: disk 가 node 에서 분리되어 Available 이 된다
// 선행: node 와 같은 AZ 에 attach 된 disk(disk1e). Available 이면 Node Info 의 + Attach Disk(Existing Disk)로 mc1 node 에 먼저 attach(전제 준비), disk1e 가 없으면 TC-INFRA-042 로 만든다
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin, toasts } from '../../lib/ui';
import { DISK_TARGET, attachExistingDisk, attachNewDisk, diskCells, diskRow, diskTargetBlocker, gotoDisks, openNodeInfo, pickRunningNode, waitDiskState } from '../../lib/infra';
for (const c of activeCsps(['AWS'])) {
test(`${tid('TC-INFRA-044', c)} Disks 화면에서 Data Disk Detach (Data Disk Detail 의 Detach) (구 TC-INFRA-044)`, async ({ page }) => {
test.setTimeout(20 * 60_000);
const why = diskTargetBlocker(c);
test.skip(!!why, why);
const tg = DISK_TARGET[c.code]!;
const disk = tg.diskNew!;
await loginAsAdmin(page);
let cells = await diskCells(page, disk);
// 전제 준비: attach 상태가 아니면 attach
if (!/attached/i.test(cells.status ?? '')) {
const node = await pickRunningNode(page, tg.infra, 'g1-1');
const ng = node.replace(/-\d+$/, '');
await openNodeInfo(page, ng, node);
if (cells.name) await attachExistingDisk(page, disk); else await attachNewDisk(page, disk, tg.size);
await waitDiskState(page, disk, 'attached');
test.info().annotations.push({ type: 'precondition', description: `${disk} 를 ${tg.infra} / ${node} 에 attach(전제 준비)` });
cells = await diskCells(page, disk);
}
test.info().annotations.push({ type: 'before', description: JSON.stringify(cells) });
expect(cells.status, `${disk} Attached`).toMatch(/attached/i);
// Disks 화면에서 행 선택 → 상세 Detach
await gotoDisks(page);
await diskRow(page, disk).first().locator('[tabulator-field="name"]').click();
await page.waitForTimeout(2000);
const detail = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
test.info().annotations.push({ type: 'detail', description: detail.slice(detail.indexOf('Data Disk Detail'), detail.indexOf('Data Disk Detail') + 220) });
const det = page.locator('#detail-detach-btn');
await expect(det, 'Detach 버튼(Attached 상태에서만 표시)').toBeVisible({ timeout: 15_000 });
await det.click();
const m = page.locator('.modal.show').last();
await expect(m).toBeVisible();
test.info().annotations.push({ type: 'detach-confirm', description: (await m.innerText()).replace(/\s+/g, ' ').slice(0, 200) });
await m.getByRole('button', { name: /Confirm|Detach|OK|Yes/i }).last().click();
await page.waitForTimeout(3000);
const t = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
test.info().annotations.push({ type: 'detach-result', description: t.join(' | ').slice(0, 250) || '(토스트 없음)' });
await waitDiskState(page, disk, 'available');
const after = await diskCells(page, disk);
test.info().annotations.push({ type: 'after', description: JSON.stringify(after) });
expect(after.status).toMatch(/available/i);
});
}tests/infra/TC-INFRA-045.spec.ts · 106줄 · 해시 205ea527 · 시나리오 보기
// TC-INFRA-045: NLB 생성 → 같은 Infra 의 NodeGroup node 할당 [정상, CSP별]
// 2026-10-12 사용자 결정: 기대결과를 '여러 Infra 할당' → '같은 Infra NodeGroup 할당'으로 변경(NLB 는 Infra+NodeGroup 단위, 다른 Infra node 는 할당 대상 아님)
// 화면: Cloud Resources > NLB (Create NLB 팝업) → NLB 목록 → NLB Detail(Assigned Nodes) → Edit(assign / unassign nodes)
// 전제: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default. mc1 의 g1 node(g1-1, g1-2) Running
// 화면 확인(2026-10-08 AWS 프로브): 사이드바 Cloud Resources > NLB(/cloudresources/nlbs). Create NLB 모달: #create-nlb-infra(Infra), #create-nlb-nodegroup(Target NodeGroup),
// #create-nlb-listener-port(80), #create-nlb-target-port(80), #create-nlb-protocol(TCP/UDP), Type/Scope(PUBLIC / REGION), Health Checker(interval/timeout/threshold, 0=provider default), #create-nlb-execute-btn.
// "NLB is created per target NodeGroup of the selected Infra. The NLB id follows the NodeGroup id."
// 절차(AWS): 1.NLB 진입 2.Create NLB: Infra=mc1, Target NodeGroup=g1, Listener Port=80, Target Port=80, Protocol=TCP, Health Checker 기본값 3.Create 4.목록에 NLB 표시
// 5.NLB Detail 의 Assigned Nodes 에 g1-1, g1-2 확인 6.Edit 에서 Assigned/Available Nodes 가 같은 Infra(mc1) node 로 구성되는지 확인(변경 없이 Cancel)
// 기대결과: NLB 생성·목록 표시, 같은 Infra 의 Target NodeGroup node(g1-1, g1-2)가 NLB 에 할당
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups } from '../../lib/data';
import { loginAsAdmin, selectWorkspaceProject, toasts } from '../../lib/ui';
import { infraInputBlocker, openInfraDetail } from '../../lib/infra';
const NLB_URL = '/webconsole/settings/environment/cloudresources/nlbs';
async function openNlb(page: import('@playwright/test').Page) {
await page.goto(NLB_URL);
await selectWorkspaceProject(page, 'ws01', 'default', true);
await page.getByText(/Showing \d+(-\d+)? (of \d+ )?rows|No NLBs/i).first().waitFor({ timeout: 30_000 }).catch(() => {});
await page.waitForTimeout(1500);
}
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-045", c)} ` + "NLB 생성 → 실행 중인 여러 Infra 할당 (구 TC-INFRA-045)", async ({ page }) => {
test.setTimeout(20 * 60_000);
const why = infraInputBlocker(c);
test.skip(!!why, why);
// 2026-10-19: Azure mc2 는 VM generalized(MyImage 생성 영향)로 Start 409 → NLB_INFRA/NLB_NG 로 다른 Infra(mw2/g2-azure) 지정 가능
const infra = process.env.NLB_INFRA ?? infraName(c);
const ng = process.env.NLB_NG ?? infraNodegroups(c)[0];
// NLB·NodeGroup API 응답 기록(2026-10-19 NHN 모달 미닫힘, NCP NodeGroup 목록 비어 있음 원인 확인용)
page.on('response', async (r) => {
if (!/PostNLB|GetAllNLB|GetInfra|GetAllNodeGroup|nodegroup/i.test(r.url()) || /GetAllNLB/.test(r.url()) && r.ok()) return;
const body = await r.text().catch(() => '');
if (r.ok() && !/PostNLB/.test(r.url())) { test.info().annotations.push({ type: 'api', description: `${r.url().replace(/^https?:\/\/[^/]+/, '')} → ${r.status()} (${body.length}B)` }); return; }
test.info().annotations.push({ type: 'api', description: `${r.url().replace(/^https?:\/\/[^/]+/, '')} → ${r.status()} ${body.slice(0, 600)}` });
});
await loginAsAdmin(page);
await openNlb(page);
const before = (await page.locator('.tabulator-row').allInnerTexts()).map((s) => s.replace(/\s+/g, ' '));
test.info().annotations.push({ type: 'nlb-before', description: before.join(' || ') || '(NLB 없음)' });
const exists = before.some((s) => s.includes(infra) && s.includes(ng));
if (exists) test.info().annotations.push({ type: 'preexisting', description: `NLB(${infra}/${ng}) 이미 존재 → 생성 건너뜀` });
if (!exists) {
await page.getByRole('button', { name: 'Create NLB' }).click();
const m = page.locator('.modal.show').last();
await expect(m).toBeVisible();
await m.locator('#create-nlb-infra').selectOption({ label: infra });
await expect.poll(async () => (await m.locator('#create-nlb-nodegroup option').allInnerTexts()).length, { timeout: 30_000 }).toBeGreaterThan(1).catch(async (e) => {
test.info().annotations.push({ type: 'nodegroup-options', description: `infra options: ${(await m.locator('#create-nlb-infra option').allInnerTexts()).join(',')} / nodegroup options: ${(await m.locator('#create-nlb-nodegroup option').allInnerTexts()).join(',')} | toasts: ${(await toasts(page)).join(' | ').slice(0, 300)}` });
throw e;
});
await m.locator('#create-nlb-nodegroup').selectOption({ label: ng });
await m.locator('#create-nlb-listener-port').fill('80');
await m.locator('#create-nlb-target-port').fill('80');
await m.locator('#create-nlb-protocol').selectOption('TCP');
await m.locator('#create-nlb-execute-btn').click();
await expect(m).toBeHidden({ timeout: 300_000 }).catch(async (e) => {
test.info().annotations.push({ type: 'modal-open', description: `${(await m.innerText().catch(() => '')).replace(/\s+/g, ' ').slice(0, 500)} | toasts: ${(await toasts(page)).join(' | ').slice(0, 300)}` });
throw e;
});
await page.waitForTimeout(3000);
const t = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
test.info().annotations.push({ type: 'nlb-create', description: t.join(' | ').slice(0, 300) || '(토스트 없음)' });
}
await openNlb(page);
const rows = (await page.locator('.tabulator-row').allInnerTexts()).map((s) => s.replace(/\s+/g, ' '));
test.info().annotations.push({ type: 'nlb-after', description: rows.join(' || ') });
expect(rows.join(' '), `NLB 목록에 ${infra} / ${ng}`).toMatch(new RegExp(`${infra}[^|]*${ng}|${ng}[^|]*${infra}`));
// 4-1. 상세: 생성 직후 같은 NodeGroup 의 node 가 자동 할당되어 있어야 한다(Assigned Nodes)
const row = page.locator('.tabulator-row').filter({ hasText: infra }).first();
await row.click();
await page.waitForTimeout(2000);
const body = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
const detail = body.slice(body.indexOf('NLB Detail'), body.indexOf('NLB Detail') + 700);
test.info().annotations.push({ type: 'nlb-detail', description: detail });
// 2026-10-12 사용자 지시: AWS mc1 의 g1-1, g1-2 가 모두 할당되는지 확인(NLB_NODES 로 지정, 기본 {ng}-1)
for (const n of (process.env.NLB_NODES ?? `${ng}-1`).split(',')) expect.soft(detail, `Assigned Nodes 에 ${n}`).toContain(n);
// 5. Edit(assign / unassign nodes): 할당 대상은 같은 Infra 의 NodeGroup node 로 한정(2026-10-12 사용자 결정으로 기대결과 변경 —
// NLB 는 Infra+NodeGroup 단위라 다른 Infra(mw1) node 는 표시되지 않는 것이 사양)
await openInfraDetail(page, infra);
const sameNodes = (await page.locator('[id^="server_status_icon_"]').evaluateAll((es) => es.map((e) => e.id.replace('server_status_icon_', ''))));
test.info().annotations.push({ type: 'same-infra-nodes', description: `${infra}: ${sameNodes.join(', ')}` });
await openNlb(page);
await page.locator('.tabulator-row').filter({ hasText: infra }).first().getByRole('checkbox').check();
await page.locator('[title="Edit selected (assign / unassign nodes)"], [data-bs-original-title="Edit selected (assign / unassign nodes)"]').first().click();
const em = page.locator('.modal.show').last();
await expect(em).toBeVisible();
await page.waitForTimeout(2000);
const editText = (await em.innerText()).replace(/\s+/g, ' ');
test.info().annotations.push({ type: 'nlb-edit-modal', description: editText.slice(0, 500) });
await em.getByRole('button', { name: 'Cancel' }).click(); // 변경하지 않는다
// Edit 의 Assigned + Available Nodes 가 모두 같은 Infra 의 node 인지 확인(Assigned 에 지정 node, Available 에 같은 Infra 의 나머지 node)
const avail = editText.slice(editText.indexOf('Available Nodes'));
const shown = sameNodes.filter((n) => editText.includes(n));
test.info().annotations.push({ type: 'edit-nodes', description: `같은 Infra node 중 Edit 에 표시: ${shown.join(', ')} / Available 영역: ${avail.slice(0, 200)}` });
for (const n of (process.env.NLB_NODES ?? `${ng}-1`).split(',')) expect(editText, `Edit Assigned Nodes 에 ${n}`).toContain(n);
expect(shown.length, `Edit 에 같은 Infra(${infra}) 의 NodeGroup node 표시`).toBe(sameNodes.length);
});
}tests/infra/TC-INFRA-046.spec.ts · 75줄 · 해시 59020982 · 시나리오 보기
// TC-INFRA-046: NLB Edit: NLB 대상 node 할당 해제 / 재할당 [정상, AWS] (사용자 지시 2026-10-08)
// 화면: Cloud Resources > NLB (/cloudresources/nlbs) → 행 체크 → 상단 'Edit selected (assign / unassign nodes)' → Edit NLB 모달(Assigned Nodes 체크=해제 / Available Nodes 체크=할당, 저장 #nlb-edit-execute-btn)
// 절차: 1.NLB 진입 2.NLB g1(mc1) 행 선택 → Edit 3.Assigned Nodes 의 g1-2 체크(해제) → Save 4.상세 Assigned Nodes 가 g1-1 만 남았는지 확인 5.다시 Edit → Available Nodes 의 g1-2 체크(할당) → Save 6.상세가 g1-1, g1-2 로 돌아왔는지 확인
// 기대결과: node 할당 해제와 재할당이 반영되어 Assigned Nodes 가 변경된다
// 주의: 마지막 node 는 해제하지 않는다(일부 CSP 는 전부 해제를 거부). 다른 CSP(GCP) node 를 AWS NLB 에 할당하지 않는다. 시작 시 g1-2 가 이미 해제 상태면 재할당부터 시작한다.
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups } from '../../lib/data';
import { loginAsAdmin, selectWorkspaceProject, toasts } from '../../lib/ui';
const NLB_URL = '/webconsole/settings/environment/cloudresources/nlbs';
const EDIT = '[title="Edit selected (assign / unassign nodes)"], [data-bs-original-title="Edit selected (assign / unassign nodes)"]';
for (const c of activeCsps(['AWS'])) {
test(`${tid('TC-INFRA-046', c)} NLB Edit: NLB 대상 node 할당 해제 / 재할당 (구 TC-INFRA-046)`, async ({ page }) => {
test.setTimeout(15 * 60_000);
const infra = infraName(c);
const ng = infraNodegroups(c)[0];
const keep = `${ng}-1`, toggle = `${ng}-2`;
const row = () => page.locator('.tabulator-row').filter({ hasText: infra }).filter({ hasText: ng }).first();
const open = async () => {
await page.goto(NLB_URL);
await selectWorkspaceProject(page, 'ws01', 'default', true);
await page.getByText(/Showing \d+(-\d+)? (of \d+ )?rows|No NLBs/i).first().waitFor({ timeout: 30_000 }).catch(() => {});
await page.waitForTimeout(1500);
};
const assigned = async () => {
await open();
await row().locator('[tabulator-field="id"], [tabulator-field="name"]').first().click().catch(() => row().click());
await page.waitForTimeout(2000);
const b = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
const i = b.indexOf('Assigned Nodes', b.indexOf('NLB Detail'));
return b.slice(i, i + 60);
};
const edit = async (which: 'assigned' | 'available') => {
await open();
await row().getByRole('checkbox').first().check();
await page.locator(EDIT).first().click();
const m = page.locator('.modal.show').last();
await expect(m).toBeVisible();
const before = (await m.innerText()).replace(/\s+/g, ' ').slice(0, 260);
test.info().annotations.push({ type: `edit-modal-${which}`, description: before });
const box = m.locator(`input[type=checkbox][value="${toggle}"]`).first();
await expect(box, `${which} 목록에 ${toggle}`).toBeVisible({ timeout: 10_000 });
await box.check();
await m.locator('#nlb-edit-execute-btn').click();
await expect(m).toBeHidden({ timeout: 180_000 });
await page.waitForTimeout(3000);
const t = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
test.info().annotations.push({ type: `edit-result-${which}`, description: t.join(' | ').slice(0, 250) || '(토스트 없음)' });
};
await loginAsAdmin(page);
let cur = await assigned();
test.info().annotations.push({ type: 'before', description: cur });
expect(cur, `시작 상태에 ${keep} 가 할당됨`).toContain(keep);
// 1) 해제 (g1-2 가 할당돼 있으면)
if (cur.includes(toggle)) {
await edit('assigned');
await expect.poll(async () => await assigned(), { timeout: 120_000, intervals: [10_000], message: `${toggle} 해제 반영` }).not.toContain(toggle);
cur = await assigned();
test.info().annotations.push({ type: 'after-unassign', description: cur });
expect(cur, `${keep} 는 유지`).toContain(keep);
} else {
test.info().annotations.push({ type: 'preexisting', description: `${toggle} 이미 해제 상태 → 재할당부터` });
}
// 2) 재할당
await edit('available');
await expect.poll(async () => await assigned(), { timeout: 120_000, intervals: [10_000], message: `${toggle} 재할당 반영` }).toContain(toggle);
cur = await assigned();
test.info().annotations.push({ type: 'after-assign', description: cur });
expect(cur).toContain(keep);
expect(cur).toContain(toggle);
});
}tests/infra/TC-INFRA-047.spec.ts · 61줄 · 해시 b8385420 · 시나리오 보기
// TC-INFRA-047: Infra에서 Custom Image 생성 → MyImages 목록 표시 [정상, CSP별]
// 화면: Workloads > Infra Workloads > Infra 선택 > Default 탭 Node List 에서 node 선택 > 툴바 ⌄ 메뉴 'Create MyImage'(모달: MyImage Name #node-myimage-name / Description / Create #node-myimage-submit-btn) → Cloud Resources > MyImages
// 전제: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default. TC-INFRA-018 mc1 Running
// 절차(AWS): 1.mc1 선택 → Running node 선택 2.툴바 ⌄ > Create MyImage: 이름 img1 → Create 3.생성 완료 대기 4.Cloud Resources > MyImages 목록에 img1 표시 확인 5.Provider(CSP) 열 확인
// 기대결과: img1 생성, MyImages 목록에 표시
// 생성 이미지는 이후 삭제 테스트(IV2 계열/CL)용으로 남긴다. 이미 img1 이 있으면 생성 생략(preexisting)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin, toasts } from '../../lib/ui';
import { DISK_TARGET, openNodeInfo, pickRunningNode, rowCells } from '../../lib/infra';
// MyImage 이름은 CSP 번호 기준(img{n}): AWS img1, KT img9 — AWS 의 img1 이 이미 있어도 KT 는 별도로 만든다(2026-10-12)
const imgOf = (c: { n: number }) => `img${c.n}`;
async function openMyImages(page: import('@playwright/test').Page) {
const link = page.locator('a#sidebar_cloudresources_myimages');
if (!(await link.isVisible().catch(() => false))) await page.getByText('Cloud Resources', { exact: true }).first().click(); // 그룹 펼침
await link.click();
await page.waitForURL(/myimages/i);
await page.getByText(/Showing \d+-\d+ of \d+ rows|No registered|No data/i).first().waitFor({ timeout: 30_000 }).catch(() => {});
await page.waitForTimeout(1500);
}
const imgRowOf = (page: import('@playwright/test').Page, name: string) => page.getByRole('row').filter({ has: page.getByRole('gridcell', { name, exact: true }) });
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-047", c)} ` + "Infra에서 Custom Image 생성 → MyImages 목록 표시 (구 TC-INFRA-047)", async ({ page }) => {
const IMG = imgOf(c);
const imgRow = (page: import('@playwright/test').Page) => imgRowOf(page, IMG);
const tg = DISK_TARGET[c.code];
test.skip(!tg, `${c.name}: 대상 Infra/node 미지정(사전점검 필요)`);
await loginAsAdmin(page);
await openMyImages(page);
if (await imgRow(page).count()) {
test.info().annotations.push({ type: 'preexisting', description: `${IMG} 이미 존재 → 생성 건너뜀` });
} else {
const node = await pickRunningNode(page, tg!.infra, tg!.node); // 대상 node: DISK_TARGET.node (KT g9-1, 사용자 지시 2026-10-12)
const ng = node.replace(/-\d+$/, '');
await openNodeInfo(page, ng, node);
test.info().annotations.push({ type: 'target', description: `${tg!.infra} / ${node}` });
const item = page.locator('a[onclick*="openCreateNodeMyImageModal"]').first();
await item.locator('xpath=ancestor::*[contains(@class,"dropdown-menu")][1]/preceding-sibling::*[1]').click(); // 툴바 ⌄
await item.click();
const m = page.locator('.modal.show').last();
await expect(m).toBeVisible();
await m.locator('#node-myimage-name').fill(IMG);
await m.locator('#node-myimage-submit-btn').click();
await expect(m).toBeHidden({ timeout: 180_000 });
await page.waitForTimeout(3000);
const t = (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean);
test.info().annotations.push({ type: 'myimage-create', description: t.join(' | ').slice(0, 300) || '(토스트 없음)' });
await openMyImages(page);
}
// 생성 완료 대기(스냅샷 → 이미지 등록): 목록 재조회 최대 15분
await expect.poll(async () => { await openMyImages(page); return await imgRow(page).count(); }, { timeout: 15 * 60_000, intervals: [30_000] }).toBeGreaterThan(0);
const cells = await rowCells(imgRow(page).first());
test.info().annotations.push({ type: 'myimage-row', description: JSON.stringify(cells) });
// Provider 열은 driver 기준 소문자(nhn 등) — CSP 코드(NHNCloud)와 다를 수 있어 매핑(2026-10-16 NHN 오판 수정)
const providerKey = ({ NHNCloud: 'nhn' } as Record<string, string>)[c.code] ?? c.code.toLowerCase();
expect(JSON.stringify(cells).toLowerCase(), `Provider ${c.code}`).toContain(providerKey);
});
}tests/infra/TC-INFRA-048.spec.ts · 81줄 · 해시 6f7a5106 · 시나리오 보기
// TC-INFRA-048: 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("TC-INFRA-048", c)} ` + "Infra Node 라이프사이클 ①: 상세(nodegroup·node) 확인 → Node Reboot (구 TC-INFRA-048)", async ({ page }) => {
test.setTimeout(20 * 60_000);
const name = infraName(c);
const [ng0, ngB] = infraNodegroups(c);
// IV4_POWER_NG=<nodegroup>: 대상 nodegroup 지정(TC-INFRA-055 의 IV4_NG=삭제 대상과 구분)(2026-10-19 Azure: g2-1 일반화로 삭제 → MyImage 로 추가한 g2b 로 진행)
const ng = process.env.IV4_POWER_NG || ng0;
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/infra/TC-INFRA-049.spec.ts · 74줄 · 해시 1267b6e1 · 시나리오 보기
// TC-INFRA-049: 멀티 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 선택된 상태에서 시작. 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(TC-WF-008) 생성 결과는 g10-<csp>(예 g10-aws)
// reboot 대상은 Node List ⌄ 메뉴(node 대상 VmReboot). 목록 툴바 ⌄ Reboot 은 Infra 전체 대상이라 쓰지 않음(TC-INFRA-048 과 같음)
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('TC-INFRA-049')} 멀티 CSP Infra 제어: nodegroup별 CSP 확인 → Node reboot (구 TC-INFRA-049)`, 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 (선행 TC-WF-008)
expect(await waitInfraListed(page, name), `선행 TC-WF-008 의 ${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(', ') || '(없음)' });
// TC-WF-008 에서 생성되지 않은 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(', ')} 없음(TC-WF-008 미생성)` : '없음(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/infra/TC-INFRA-050.spec.ts · 52줄 · 해시 52d8b72a · 시나리오 보기
// TC-INFRA-050: Infra Node 라이프사이클 ②-1: Suspend → Suspended 확인 [정상, CSP별] (Resume 은 TC-INFRA-051 로 분리, 사용자 승인 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 은 TC-INFRA-051, 또는 다음 테스트 시작 시 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("TC-INFRA-050", c)} ` + "[Infra] Infra Node 라이프사이클 ②-1: Suspend → Suspended 확인 (구 TC-INFRA-050)", async ({ page }) => {
test.setTimeout(40 * 60_000);
const name = infraName(c);
// IV4_POWER_NG=<nodegroup>: 대상 nodegroup 지정(TC-INFRA-055 의 IV4_NG=삭제 대상과 구분)(2026-10-19 Azure: g2-1 일반화로 삭제 → MyImage 로 추가한 g2b 로 진행)
const ng = process.env.IV4_POWER_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 이면 TC-INFRA-051 로 먼저 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/infra/TC-INFRA-051.spec.ts · 48줄 · 해시 0fa435c5 · 시나리오 보기
// TC-INFRA-051: Infra Node 라이프사이클 ②-2: Resume → Running 확인 [정상, CSP별] (TC-INFRA-050 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 (TC-INFRA-050 이후 또는 테스트 종료 시 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("TC-INFRA-051", c)} ` + "[Infra] Infra Node 라이프사이클 ②-2: Resume → Running 확인 (구 TC-INFRA-051)", async ({ page }) => {
test.setTimeout(40 * 60_000);
const name = infraName(c);
// IV4_POWER_NG=<nodegroup>: 대상 nodegroup 지정(TC-INFRA-055 의 IV4_NG=삭제 대상과 구분)(2026-10-19 Azure: g2-1 일반화로 삭제 → MyImage 로 추가한 g2b 로 진행)
const ng = process.env.IV4_POWER_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 || '빈 값'}) → TC-INFRA-050(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/infra/TC-INFRA-052.spec.ts · 52줄 · 해시 62576e12 · 시나리오 보기
// TC-INFRA-052: 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}(TC-INFRA-018 직접 생성) 과 mw{n}(TC-WF-007 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 은 대상 아님(TC-INFRA-056 로 삭제). 그 밖의 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("TC-INFRA-052", c)} ` + "CSP 테스트 종료: Infra 전체 중지 (비용 방지, 녹화 선택) (구 TC-INFRA-052)", 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/infra/TC-INFRA-053.spec.ts · 75줄 · 해시 17d4affe · 시나리오 보기
// TC-INFRA-053: Infra Node 라이프사이클 ③: Node Terminate [정상, CSP별]
// 화면: Infra 목록 → Infra 선택 → Default 탭 Node List → node 선택(Node Info) → Node List ⌄ 메뉴 'Terminate' → 확인 팝업 → 종 아이콘 Requests 패널
// 시작 상태: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default 선택된 상태에서 시작. TC-INFRA-031 완료(scale out 으로 추가된 node g1-2 Running)
// 절차 (예: AWS 기준): 1.Infra 목록에서 mc1 선택 2.Default 탭 Node List 에서 TC-INFRA-031 로 추가된 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(TC-INFRA-031 추가분)만. 없으면 선행 미충족으로 실패(g{n}-1 을 대신 지우지 않는다).
// nodeMenuAction 이 대상 외 체크를 모두 해제하고 "체크된 node 가 대상 하나"임을 확인한 뒤 실행한다.
// 목록(List of Infra) ⌄ 메뉴 Terminate 는 Infra 전체 대상(MciTerminate) → 사용하지 않는다. 2026-10-02 기준 미실행.
// IV4_TARGET_NODE=<node>: 대상 node 직접 지정(2026-10-19 Azure: TC-INFRA-047 MyImage 로 일반화돼 재시작 불가한 g2-1 을 Terminate/Delete — 사용자 결정).
// 지정 시 시작 상태 Running 조건은 기록만 하고, 대상 외 node(다른 nodegroup 포함)의 상태 불변을 확인한다.
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("TC-INFRA-053", c)} ` + "Infra Node 라이프사이클 ③: Node Terminate (구 TC-INFRA-053)", async ({ page }) => {
test.setTimeout(30 * 60_000);
const name = infraName(c);
const ng = infraNodegroups(c)[0];
const forced = process.env.IV4_TARGET_NODE;
const ngOf = (n: string) => n.replace(/-\d+$/, '');
let 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: TC-INFRA-031 로 추가된 g{n}-k (k≥2) 중 Running 인 마지막 것
await openInfraDetail(page, name);
const nodes = await nodeNames(page);
test.info().annotations.push({ type: 'nodes', description: nodes.join(', ') });
let target: string;
if (forced) {
expect(nodes, `지정 대상 ${forced} 존재`).toContain(forced);
target = forced;
keep = nodes.find((n) => n !== forced) ?? '';
} else {
const candidates = nodes.filter((n) => n.startsWith(`${ng}-`) && n !== keep);
expect(candidates.length, `선행: TC-INFRA-031 로 추가된 node 존재 (${ng}-1 외)`).toBeGreaterThan(0);
target = candidates[candidates.length - 1];
}
// 대상 외 node 는 '영향 없음' = Terminate 전후 상태 동일(Running 이어야 한다는 가정은 하지 않는다: 앞 시나리오에서 Suspended 일 수 있음, 2026-10-07)
const keepBefore = nodes.includes(keep) ? (await readNodeInfo(page, ngOf(keep), keep)).status : '';
const info = await readNodeInfo(page, ngOf(target), target);
test.info().annotations.push({ type: 'target', description: `${target} 시작 상태=${info.status}${forced ? ' (IV4_TARGET_NODE 지정)' : ''} / 대상 외=${keep}:${keepBefore}` });
if (!forced) 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, ngOf(target), 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, ngOf(keep), 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/infra/TC-INFRA-054.spec.ts · 72줄 · 해시 cae83f0f · 시나리오 보기
// TC-INFRA-054: 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 선택된 상태에서 시작. TC-INFRA-053 완료(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 삭제는 TC-INFRA-059). 2026-10-02 기준 미실행.
// IV4_TARGET_NODE=<node>: 대상 node 직접 지정(2026-10-19 Azure: TC-INFRA-047 MyImage 로 일반화돼 재시작 불가한 g2-1 을 Delete(TC-INFRA-053 로 Terminated) — 사용자 결정).
// 지정 시 시작 상태 Running 조건은 기록만 하고, 대상 외 node(다른 nodegroup 포함)의 상태 불변을 확인한다.
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("TC-INFRA-054", c)} ` + "Infra Node 라이프사이클 ④: Terminated Node Delete (구 TC-INFRA-054)", async ({ page }) => {
test.setTimeout(30 * 60_000);
const name = infraName(c);
const ng = infraNodegroups(c)[0];
const forced = process.env.IV4_TARGET_NODE;
const ngOf = (n: string) => n.replace(/-\d+$/, '');
let 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 = '';
if (forced) {
keep = nodes.find((n) => n !== forced) ?? '';
if (nodes.includes(forced) && /^Terminated/i.test((await readNodeInfo(page, ngOf(forced), forced)).status)) target = forced;
} else {
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, '선행: TC-INFRA-053 로 Terminated 된 node 존재').not.toBe('');
// 대상 외 node 는 '영향 없음' = Delete 전후 상태 동일(Running 이어야 한다는 가정은 하지 않는다, 2026-10-07)
const keepBefore = nodes.includes(keep) ? (await readNodeInfo(page, ngOf(keep), 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, ngOf(target), 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, ngOf(keep), 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/infra/TC-INFRA-055.spec.ts · 154줄 · 해시 cf4a1dad · 시나리오 보기
// TC-INFRA-055: 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 → TC-INFRA-055_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("TC-INFRA-055", c)}_ngdelete_${process.env.IV4_NG}` : tid("TC-INFRA-055", c);
test(`${recId} ` + "NodeGroup 삭제 (nodegroup 내 node 전체 삭제) (구 TC-INFRA-055)", 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/infra/TC-INFRA-056.spec.ts · 60줄 · 해시 14f207cd · 시나리오 보기
// TC-INFRA-056: 멀티 CSP Infra mc10 삭제 (비용 방지, 녹화 선택) [정상, 공통] — TC-INFRA-056
// 화면: Infra Workloads 목록 → mc10 행 선택 → 목록 카드 'List of Infra' ⌄ Terminate → Confirm → (Terminated) → ⌄ Delete → Confirm → 목록에서 제거
// 시작 상태: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default 선택된 상태에서 시작. IV 종료(TC-WF-008 로 만든 mc10 을 쓰는 TC-INFRA-049·S06·S08 완료)
// 대상: Workspace ws01 / Project default(namespace default) 의 Infra mc10 하나(TC-WF-008 이 Workflow 로 만든 8개 CSP NodeGroup g10-<csp>, node 각 1개)
// — 다른 Infra(mc1~mc9, mw*, mt1)·공용 vNet/SG/SSH Key(default-shared-*)는 삭제하지 않음
// 절차: 1.목록에서 mc10 선택 → ⌄ Terminate → Confirm → Status Terminated 대기 2.⌄ Delete → Confirm → 삭제 응답(DelInfra) 기록 3.목록에서 mc10 제거 확인
// 4.잔여 자원 확인: 삭제 응답에 '[WARNING] … node(s) may remain on the CSP' 가 없어야 함(Running 상태 force Delete 시 잔존 경고 사례 2026-10-02 → Terminate 먼저)
// 입력값: Infra=mc10 / Workspace=ws01 / Project=default
// 기대결과: 과금 자원 잔존 없음(mc10 목록 제거, 8개 CSP node 잔존 경고 없음). 화면으로 확인할 수 없는 CSP 콘솔 잔존은 결과 문서에 사용자 확인 요청으로 남김
// 실행: 사용자/메인 세션 지시가 있을 때만(2026-10-16 작성 시점에는 실행하지 않음)
// 비고: 2026-10-16 구현(stub 대체). Infra 단위 제어는 lib/iv3-mc10.ts infraListAction
import { test, expect } from '../../lib/fixtures';
import { tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { gotoInfraList, refreshInfraList, waitInfraListed, infraRow, infraRowText, waitInfraStatus } from '../../lib/iv4-infra';
import { MC10_NAME, infraListAction } from '../../lib/iv3-mc10';
test(`${tid('TC-INFRA-056')} 멀티 CSP Infra mc10 삭제 (비용 방지, 녹화 선택) (구 TC-INFRA-056)`, async ({ page }) => {
test.setTimeout(60 * 60_000);
const name = process.env.CL_S02_INFRA ?? MC10_NAME;
test.info().annotations.push({ type: 'scope', description: `ws01 / default(namespace default) / Infra=${name} 만 삭제` });
await loginAsAdmin(page);
await gotoInfraList(page);
test.skip(!(await waitInfraListed(page, name)), `${name} 목록에 없음(이미 삭제됐거나 TC-WF-008 미실행)`);
const before = await infraRowText(page, name);
test.info().annotations.push({ type: 'list-before', description: before });
// 1. Terminate → Terminated
if (!/^Terminated/i.test(before)) {
await infraListAction(page, name, 'Terminate');
await gotoInfraList(page);
const t = await waitInfraStatus(page, name, /^Terminated/i, 30 * 60_000);
test.info().annotations.push({ type: 'terminated', description: t });
}
// 2. Delete → 응답 기록
const del: string[] = [];
const onResp = async (r: import('@playwright/test').Response) => {
if (/DelInfra|DeleteInfra|DelMci/i.test(r.url())) del.push(`${r.status()} ${(await r.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 1500)}`);
};
page.on('response', onResp);
await infraListAction(page, name, 'Delete');
// 3. 목록에서 제거(최대 10분)
let row = 'x';
for (let i = 0; i < 20 && row; i++) {
await page.waitForTimeout(30_000);
await refreshInfraList(page);
await page.waitForTimeout(3000);
row = (await infraRow(page, name).count()) ? await infraRowText(page, name) : '';
}
page.off('response', onResp);
test.info().annotations.push({ type: 'delete-response', description: del.join(' || ') || '(응답 미수집)' });
test.info().annotations.push({ type: 'list-after', description: row || `${name} 목록에서 제거` });
expect(row, `${name} 목록에서 제거`).toBe('');
// 4. 잔여 자원 경고 없음
expect(del.join(' '), '삭제 응답에 CSP 잔존 경고 없음').not.toMatch(/may remain|WARNING/i);
await page.waitForTimeout(3000); // 녹화 마무리
});tests/infra/TC-INFRA-057.spec.ts · 95줄 · 해시 d6df5bca · 시나리오 보기
// TC-INFRA-057: K8s NodeGroup 삭제 요청 → Deleting 확인 [정상, CSP별]
// 화면: Workloads > K8s Workloads > 목록에서 Cluster 선택 > 상세 > NodeGroup 선택 > 삭제 → 확인 팝업 → 상태
// 시작 상태: admin 로그인 상태, Workspace=ws01 / Project=p1 선택 상태에서 시작. TC-INFRA-033 완료: 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, ngCardId } 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("TC-INFRA-057", c)} ` + "K8s NodeGroup 삭제 요청 → Deleting 확인 (구 TC-INFRA-057)", 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');
// 카드·체크박스 id 는 CSP SystemId(Alibaba·Tencent 는 이름과 다름, 2026-10-19) → 표시 이름으로 id 를 찾는다
await page.locator('li[id^="nodeGroup_status_icon_"]').first().waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {});
const cid = await ngCardId(page, ng);
const card = page.locator(`[id="nodeGroup_status_icon_${cid}"]`);
const cbox = `node_checkbox_${cid}`;
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 !== cbox) await box.locator(`[id="${id}"]`).uncheck();
}
if (!(await page.locator(`[id="${cbox}"]`).isChecked())) await card.click();
await expect(page.locator(`[id="${cbox}"]`)).toBeChecked();
expect(await box.locator('input.vm-checkbox:checked').evaluateAll((els) => els.map((e) => e.id)), `체크된 nodegroup 은 ${ng} 하나`).toEqual([cbox]);
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 표시 없이 바로 사라짐 → TC-INFRA-058 에서 완료 확인` });
expect(!st.exists || /delet/i.test(st.text), `${ng} Deleting 표시(또는 이미 제거)`).toBeTruthy();
await page.waitForTimeout(3000); // 녹화 마무리
});
}tests/infra/TC-INFRA-058.spec.ts · 67줄 · 해시 52e320f1 · 시나리오 보기
// TC-INFRA-058: K8s NodeGroup 삭제 완료 확인 (목록/상세에서 NodeGroup 제거) [정상, CSP별]
// 화면: Workloads > K8s Workloads > 목록에서 Cluster 선택 > 상세 > NodeGroup 목록
// 시작 상태: admin 로그인 상태, Workspace=ws01 / Project=p1 선택 상태에서 시작. TC-INFRA-057 완료(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("TC-INFRA-058", c)} ` + "K8s NodeGroup 삭제 완료 확인 (목록/상세에서 NodeGroup 제거) (구 TC-INFRA-058)", 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);
// 목록 행은 이번 반복 시작 때(상세보다 몇 초 먼저) 받아 온 값이다. tumblebug 목록·상세는 둘 다 GetK8sCluster(spider 실시간)라
// 차이는 조회 시점뿐 → 삭제 직후 경계에서 1/0 이 갈릴 수 있다(2026-10-20 Alibaba). 목록을 다시 불러와 상세 수와 같아질 때까지 최대 15분(1분 간격) 확인하고 지연을 기록
let cells = await clusterCells(page, cluster);
const listStart = Date.now();
const listTrail: string[] = [`${cells[4]}@0s`];
while (Number(cells[4]) !== groups.length && Date.now() - listStart < 15 * 60_000) {
await page.waitForTimeout(60_000);
expect(await gotoK8sList(page, project, cluster), `${cluster} 존재(Project ${project})`).toBeTruthy();
cells = await clusterCells(page, cluster);
listTrail.push(`${cells[4]}@${Math.round((Date.now() - listStart) / 1000)}s`);
}
if (listTrail.length > 1) await openCluster(page, cluster); // 목록 재조회로 닫힌 상세(Cluster Info)를 다시 연다
test.info().annotations.push({ type: 'list-nodegroup-trail', description: `목록 Node Group 열(상세 ${groups.length}개와 비교): ${listTrail.join(' → ')}` });
const infoStatus = await clusterInfoField(page, 'Status');
test.info().annotations.push({ type: 'nodegroups-after', description: groups.join(', ') || '(없음)' });
expect(groups, `최종 NodeGroup 목록에 ${ng} 없음(카드 표시 이름 기준, 2026-10-20 보완)`).not.toContain(ng);
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/infra/TC-INFRA-059.spec.ts · 49줄 · 해시 62aec192 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sName, k8sProject } from '../../lib/data';
import { openK8sList, deleteClusterFromList, clusterRow, waitList } from '../../lib/k8s';
// TC-INFRA-059 : CSP 테스트 자원 전체 삭제 (비용 방지, 녹화 선택) [CSP별]
// **현재는 5번(Project p{n} 의 K8s 삭제)만 구현**: 다른 단계(NLB/MyImages/Disks/Infra/Projects/SSH Key/SG/vNet)는 미구현.
// K8s 삭제: K8s Workloads 클러스터 목록 → 행의 action item → Delete (사용자 안내 2026-10-05).
// 대상 클러스터는 환경변수 CL_K8S(쉼표 구분)로 지정, 없으면 k{n}d. 예) CL_K8S=k1d,k1f
for (const c of activeCsps()) {
test(`${tid('TC-INFRA-059', c)} CSP 테스트 자원 전체 삭제 (K8s 단계만 구현) (구 TC-INFRA-059)`, async ({ page }) => {
test.skip(c.code === 'KT', 'KT Credential 없음');
const project = k8sProject(c);
const targets = (process.env.CL_K8S ?? `${k8sName(c)}d`).split(',').map((s) => s.trim()).filter(Boolean);
test.info().annotations.push({ type: 'scope', description: `ws01 / ${project} / K8s=${targets.join(',')} (TC-INFRA-059 5번만)` });
await openK8sList(page, project);
for (const name of targets) {
// 목록은 자동 갱신되지 않고 로딩 중 일시적으로 비어 보일 수 있다(직전 삭제 직후 'k1d 없음'으로 오판, 2026-10-08) →
// 메뉴로 다시 진입해 행이 나타나는지 최대 ~60초 재확인한 뒤에도 없을 때만 '없음'으로 본다
let found = (await clusterRow(page, name).count()) > 0;
for (let i = 0; i < 4 && !found; i++) {
await page.waitForTimeout(10_000);
await page.getByRole('link', { name: 'K8s Workloads' }).first().click();
await waitList(page);
found = (await clusterRow(page, name).count()) > 0;
}
if (!found) {
test.info().annotations.push({ type: 'skip-not-found', description: `${name} 목록에 없음 → 건너뜀(존재하는 것만 삭제)` });
continue;
}
await deleteClusterFromList(page, name); // action item → Delete → 확인 팝업 Confirm (요청 직후 상태는 어노테이션)
// 삭제 요청 후 행이 사라지거나 삭제 진행 상태(Deleting 등)로 바뀔 때까지 최대 ~2분 확인.
// 목록은 자동 갱신되지 않아 사이드 메뉴로 다시 진입(Workspace/Project 선택 유지)해서 본다.
const started = Date.now();
let state = '';
for (let i = 0; i < 8; i++) {
await page.getByRole('link', { name: 'K8s Workloads' }).first().click();
await waitList(page);
const row = clusterRow(page, name);
state = (await row.count()) ? (await row.first().innerText()).replace(/\s+/g, ' ') : '(행 없음)';
if (/\(행 없음\)|Delet|Terminat/i.test(state)) break;
await page.waitForTimeout(12_000);
}
test.info().annotations.push({ type: 'k8s-state-after', description: `${name}: ${state} (요청 후 ${Math.round((Date.now() - started) / 1000)}s)` });
expect(state, `${name} 삭제 요청 후 상태(제거 또는 Deleting)`).toMatch(/\(행 없음\)|Delet|Terminat/i);
}
});
}tests/infra/TC-INFRA-060.spec.ts · 70줄 · 해시 b819ce5c · 시나리오 보기
// TC-INFRA-060: Data Disk attach — disk 와 node 의 AZ 가 다를 때 거부 (예상실패) [AWS] — 사용자 지시 2026-10-12
// 화면: Infra Workloads > mt1 > Node List g1-1 > Node Info > Block Device '+ Attach Disk'(Existing Disk) / Cloud Resources > Disks
// 절차: 1.Disks 에서 disk1c(ap-northeast-2a, Available) 확인 2.mt1 → g1-1(ap-northeast-2d) Node Info 3.+ Attach Disk → Existing Disk disk1c → Attach
// 4.오류 표시/팝업 상태 기록(90초) → 팝업이 남아 있으면 Cancel 5.Disks 목록 disk1c Available·Attached To '-', Block Device 에 없음 확인
// 기대결과: attach 거부 + AZ 불일치 원인을 알리는 오류 표시. disk 는 Available 유지
// 참고(2026-10-12 S04/S05/S15 실행): 거부 시 팝업이 닫히지 않고 오류 표시 없음. Disks 화면·상세에는 Zone 이 표시되지 않음(API GetDataDisk 에는 zone 있음)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin, toasts, watchToasts } from '../../lib/ui';
import { blockDevice, diskCells, nodeField, openInfraDetail, openNodeInfo } from '../../lib/infra';
const INPUT: Record<string, { infra: string; ng: string; node: string; disk: string } | undefined> = {
AWS: { infra: 'mt1', ng: 'g1', node: 'g1-1', disk: process.env.TARGET_DISK ?? 'disk1c' },
};
for (const c of activeCsps(["AWS"])) {
test(`${tid("TC-INFRA-060", c)} ` + "Data Disk attach — disk 와 node 의 AZ 가 다를 때 거부 (구 TC-INFRA-060)", async ({ page }) => {
const inp = INPUT[c.code];
test.skip(!inp, `${c.name}: 대상 미지정`);
let diskZone = '';
page.on('response', async (r) => {
if (/GetAllDataDisk|GetDataDisk/.test(r.url())) {
const t = await r.text().catch(() => ''); const i = t.indexOf(`"${inp!.disk}"`);
const z = i >= 0 ? t.slice(i, i + 3000).match(/"zone"\s*:\s*"([^"]*)"/) : null; if (z) diskZone = z[1];
}
});
await loginAsAdmin(page);
const before = await diskCells(page, inp!.disk);
test.info().annotations.push({ type: 'disk-before', description: `${JSON.stringify(before)} zone(API)=${diskZone}` });
expect(before.status, `${inp!.disk} Available`).toMatch(/available/i);
await openInfraDetail(page, inp!.infra);
let info = await openNodeInfo(page, inp!.ng, inp!.node);
const nodeZone = nodeField(info, 'Zone');
test.info().annotations.push({ type: 'zones', description: `node ${inp!.node}=${nodeZone} (Node Status ${nodeField(info, 'Node Status')}) / disk ${inp!.disk}=${diskZone}` });
// Attach 요청 응답 기록(2026-10-14): 화면에 오류가 없어도 서버가 무엇을 돌려주는지 남긴다
const api: string[] = [];
page.on('response', async (r) => {
if (r.request().method() === 'POST' && /attach|datadisk|disk/i.test(r.url()) && !/GetAllDataDisk|GetDataDisk/.test(r.url())) {
const t = (await r.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 300);
api.push(`${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, '')} ${t}`);
}
});
expect(diskZone && nodeZone && diskZone !== nodeZone, 'disk 와 node 의 AZ 가 다름(전제)').toBeTruthy();
await page.getByText('+ Attach Disk').first().click();
const m = page.locator('#node-attach-disk-modal');
await expect(m).toBeVisible();
await m.locator('#node-attach-mode-existing').check();
await m.locator('#node-attach-disk-select').selectOption({ label: inp!.disk });
// 오류 토스트는 autohide 5초 → 팝업 대기(최대 90초) 뒤에 읽으면 놓친다. Attach 전에 기록을 걸어 둔다(2026-10-14)
const readToasts = await watchToasts(page);
await m.locator('#node-attach-disk-execute-btn').click();
const closed = await m.waitFor({ state: 'hidden', timeout: 90_000 }).then(() => true, () => false);
const msgs = [...await readToasts(), ...(await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean)];
const modalText = closed ? '' : (await m.innerText()).replace(/\s+/g, ' ').slice(0, 300);
test.info().annotations.push({ type: 'attach-result', description: `팝업 ${closed ? '닫힘' : '90초 후에도 열림'} / 토스트: ${msgs.join(' | ').slice(0, 400) || '(없음)'} / 팝업: ${modalText}` });
test.info().annotations.push({ type: 'attach-api', description: api.join(' | ').slice(0, 600) || '(요청 없음)' });
if (!closed) await m.getByRole('button', { name: 'Cancel' }).click();
const after = await diskCells(page, inp!.disk);
test.info().annotations.push({ type: 'disk-after', description: JSON.stringify(after) });
expect(after.status, 'attach 거부 → disk Available 유지').toMatch(/available/i);
await openInfraDetail(page, inp!.infra);
info = await openNodeInfo(page, inp!.ng, inp!.node);
expect(blockDevice(info), 'Block Device 에 disk 없음').not.toContain(inp!.disk);
// 기대: 사용자에게 오류 표시(AZ 불일치). 표시가 없으면 FAIL(제품 개선 필요)
expect(msgs.join(' ') + modalText, '거부 사유(오류) 표시').toMatch(/fail|error|zone|availability|AZ|오류|실패/i);
});
}tests/infra/TC-INFRA-061.spec.ts · 48줄 · 해시 6873797a · 시나리오 보기
// TC-INFRA-061: NLB 삭제 [정상, AWS] — 사용자 지시 2026-10-12(NLB 삭제 테스트 추가 후 TC-INFRA-045 재시도)
// 화면: Cloud Resources > NLB(/cloudresources/nlbs) → 목록 행 체크 → 상단 'Delete selected'(confirmBulkDelete) → 확인 팝업 Confirm → 목록
// 절차: 1.NLB 진입 2.NLB g1(Infra mc1 / NodeGroup g1) 행 체크 3.Delete selected → 확인 팝업 문구 기록 → Confirm 4.목록에서 g1 이 사라지는지 확인(최대 10분)
// 기대결과: 삭제 요청이 접수되고 NLB 목록에서 mc1/g1 NLB 가 제거됨
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups, type Csp } from '../../lib/data';
import { loginAsAdmin, selectWorkspaceProject, toasts } from '../../lib/ui';
const NLB_URL = '/webconsole/settings/environment/cloudresources/nlbs';
// AWS 는 TC 대상. 다른 CSP 는 TC-INFRA-045 로 만든 NLB 정리용(2026-10-19 TC-INFRA-045 미실행 CSP 실행 후) — NLB 이름 = 첫 NodeGroup(g{n}), Infra = mc{n}
const INPUT = (c: Csp) => ({ infra: process.env.NLB_INFRA ?? infraName(c), ng: process.env.NLB_NG ?? infraNodegroups(c)[0] }); // NLB_INFRA/NLB_NG: TC-INFRA-045 과 같은 대상 지정
async function nlbRows(page: import('@playwright/test').Page) {
await page.goto(NLB_URL);
await selectWorkspaceProject(page, 'ws01', 'default', true);
await page.getByText(/Showing \d+(-\d+)? (of \d+ )?rows|No NLBs|No data/i).first().waitFor({ timeout: 30_000 }).catch(() => {});
await page.waitForTimeout(2000);
return (await page.locator('.tabulator-row').allInnerTexts()).map((s) => s.replace(/\s+/g, ' '));
}
const nlbRow = (page: import('@playwright/test').Page, infra: string, ng: string) => page.locator('.tabulator-row')
.filter({ has: page.getByRole('gridcell', { name: ng, exact: true }) }).filter({ has: page.getByRole('gridcell', { name: infra, exact: true }) });
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-INFRA-061", c)} ` + "NLB 삭제 (구 TC-INFRA-061)", async ({ page }) => {
test.setTimeout(20 * 60_000);
const inp = INPUT(c);
await loginAsAdmin(page);
const before = await nlbRows(page);
test.info().annotations.push({ type: 'nlb-before', description: before.join(' || ') || '(없음)' });
// 행 텍스트는 셀이 붙어 나옴('g1mc1aws…') → 셀 단위로 NLB 이름(g1)·Infra(mc1) 일치 확인
const row = nlbRow(page, inp!.infra, inp!.ng).first();
await expect(row, `NLB ${inp!.infra}/${inp!.ng} 존재(선행 TC-INFRA-045)`).toBeVisible();
await row.getByRole('checkbox').check();
await page.locator('[title="Delete selected"], [data-bs-original-title="Delete selected"]').first().click();
const m = page.locator('.modal.show').last();
await expect(m).toBeVisible();
test.info().annotations.push({ type: 'confirm-modal', description: (await m.innerText()).replace(/\s+/g, ' ').slice(0, 200) });
await m.getByRole('button', { name: /Confirm|Delete|OK|Yes/i }).last().click();
await page.waitForTimeout(4000);
test.info().annotations.push({ type: 'delete-toast', description: (await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean).join(' | ').slice(0, 300) || '(토스트 없음)' });
let rows: string[] = [];
await expect.poll(async () => { rows = await nlbRows(page); return nlbRow(page, inp!.infra, inp!.ng).count(); },
{ message: `NLB ${inp!.infra}/${inp!.ng} 목록에서 제거`, timeout: 10 * 60_000, intervals: [20_000] }).toBe(0);
test.info().annotations.push({ type: 'nlb-after', description: rows.join(' || ') || '(없음)' });
});
}tests/infra/TC-INFRA-062.spec.ts · 95줄 · 해시 d267a7f0 · 시나리오 보기
// TC-INFRA-062: Disks 화면 action item 에서 Data Disk attach — disk 와 node 의 AZ 가 다를 때 거부 (예상실패) [AWS] — 사용자 지시 2026-10-14
// 화면: Cloud Resources > Disks 목록 → disk 행 체크 → 'Attach selected Data Disk'(Infra / NodeGroup / Node 선택) / Infra Workloads > mt1 > Node List(Resume·Suspend)
// 시작 상태: admin 로그인, ws01 / default. Infra mt1, node g1-1(Zone ap-northeast-2d). Disks 에 node 와 다른 AZ 의 disk1c(ap-northeast-2a, Available)
// 절차: 0.Attach 모달의 Node 목록은 Running node 만 표시 → g1-1 이 Suspended 면 Resume(사용자 지시, 끝나면 다시 Suspend)
// 1.Disks 에서 disk1c Available 확인 2.disk1c 행 체크 → Attach selected Data Disk → Infra mt1 / NodeGroup g1 / Node g1-1 → Attach
// 3.오류 토스트(autohide 5초 → Attach 전부터 기록)·attach API 응답·팝업 상태 기록 → 팝업이 남아 있으면 Cancel 4.disk1c Available·Attached To '-' 확인
// 기대결과: AZ 가 달라 attach 가 거부되고 원인(AZ 불일치)을 알리는 오류가 표시됨. disk 는 Available 유지(거부 후 팝업이 남는 것은 정상 — 다른 disk 선택용)
// 관련: TC-INFRA-060(같은 거부를 Node Info '+ Attach Disk' 경로로 확인), TC-INFRA-040(같은 화면의 정상 attach)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin, toasts, watchToasts } from '../../lib/ui';
import { diskCells, diskRow, gotoDisks } from '../../lib/infra';
import { gotoInfraList, waitInfraListed, openInfraDetail, readNodeInfo, nodeMenuAction, waitAsyncRequest, waitNodeStatus } from '../../lib/iv4-infra';
const INPUT: Record<string, { infra: string; ng: string; node: string; disk: string } | undefined> = {
AWS: { infra: 'mt1', ng: 'g1', node: 'g1-1', disk: process.env.TARGET_DISK ?? 'disk1c' },
};
for (const c of activeCsps(["AWS"])) {
test(`${tid("TC-INFRA-062", c)} ` + "Disks 화면 Data Disk attach — disk 와 node 의 AZ 가 다를 때 거부 (구 TC-INFRA-062)", async ({ page }) => {
test.setTimeout(40 * 60_000);
const inp = INPUT[c.code];
test.skip(!inp, `${c.name}: 대상 미지정`);
const { infra, ng, node, disk } = inp!;
let diskZone = '';
const api: string[] = [];
page.on('response', async (r) => {
if (/GetAllDataDisk|GetDataDisk/.test(r.url())) {
const t = await r.text().catch(() => ''); const i = t.indexOf(`"${disk}"`);
const z = i >= 0 ? t.slice(i, i + 3000).match(/"zone"\s*:\s*"([^"]*)"/) : null; if (z) diskZone = z[1];
} else if (r.request().method() === 'POST' && /attach|datadisk|disk/i.test(r.url())) {
api.push(`${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, '')} ${(await r.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 300)}`);
}
});
await loginAsAdmin(page);
// 0. 대상 node 를 Running 으로(Attach 모달 Node 목록은 Running node 만 표시)
await gotoInfraList(page);
expect(await waitInfraListed(page, infra), `Infra ${infra} 존재`).toBeTruthy();
await openInfraDetail(page, infra);
const n0 = await readNodeInfo(page, ng, node);
test.info().annotations.push({ type: 'node-before', description: `${node}: ${n0.status} / Zone ${n0.zone ?? ''}` });
let resumed = false;
if (!/^Running/i.test(n0.status)) {
const r = await nodeMenuAction(page, node, 'Resume');
await waitAsyncRequest(page, r, 15 * 60_000);
expect(await waitNodeStatus(page, infra, ng, node, /^Running/i), `${node} Running`).toMatch(/^Running/i);
resumed = true;
}
try {
// 1~2. Disks → disk 체크 → Attach selected Data Disk → 다른 AZ 의 node
const before = await diskCells(page, disk);
test.info().annotations.push({ type: 'disk-before', description: `${JSON.stringify(before)} zone(API)=${diskZone}` });
expect(before.status, `${disk} Available`).toMatch(/available/i);
await gotoDisks(page);
await diskRow(page, disk).first().getByRole('checkbox').check();
await page.locator('[title="Attach selected Data Disk"], [data-bs-original-title="Attach selected Data Disk"]').first().click();
const m = page.locator('.modal.show').last();
await expect(m).toBeVisible();
await m.locator('#attach-disk-mci').selectOption({ label: infra });
await m.locator('#attach-disk-nodegroup').selectOption({ label: ng });
const vmOpts = async () => (await m.locator('#attach-disk-vm option').allInnerTexts()).map((s) => s.trim());
await expect.poll(async () => (await vmOpts()).some((s) => s.startsWith(node)), { message: `Node 목록에 ${node}(Running)`, timeout: 30_000 }).toBe(true);
const vmLabel = (await vmOpts()).find((s) => s.startsWith(node))!;
test.info().annotations.push({ type: 'target', description: `${infra} / ${ng} / ${vmLabel} ← ${disk}(zone ${diskZone}) / node Zone ${n0.zone ?? ''}` });
await m.locator('#attach-disk-vm').selectOption({ label: vmLabel });
// 3. Attach → 오류 토스트(autohide 5초)는 클릭 전부터 기록
const readToasts = await watchToasts(page);
await m.locator('#attach-disk-execute-btn').click();
const closed = await m.waitFor({ state: 'hidden', timeout: 30_000 }).then(() => true, () => false);
const msgs = [...await readToasts(), ...(await toasts(page)).map((s) => s.replace(/\s+/g, ' ')).filter(Boolean)];
test.info().annotations.push({ type: 'attach-result', description: `팝업 ${closed ? '닫힘' : '30초 후에도 열림(거부 후 남는 것은 정상)'} / 토스트: ${msgs.join(' | ').slice(0, 400) || '(없음)'}` });
test.info().annotations.push({ type: 'attach-api', description: api.join(' | ').slice(0, 600) || '(요청 없음)' });
if (!closed) await m.getByRole('button', { name: 'Cancel' }).click();
// 4. disk 는 Available 유지 + 거부 사유 표시
const after = await diskCells(page, disk);
test.info().annotations.push({ type: 'disk-after', description: JSON.stringify(after) });
expect(after.status, 'attach 거부 → disk Available 유지').toMatch(/available/i);
expect(msgs.join(' '), '거부 사유(오류) 표시').toMatch(/fail|error|zone|availability|오류|실패/i);
} finally {
// 0 에서 Resume 했으면 원래대로 Suspend
if (resumed) {
await gotoInfraList(page);
await openInfraDetail(page, infra);
const r = await nodeMenuAction(page, node, 'Suspend');
await waitAsyncRequest(page, r, 15 * 60_000);
test.info().annotations.push({ type: 'node-after', description: `${node}: ${await waitNodeStatus(page, infra, ng, node, /^Suspended/i)}(테스트 후 Suspend)` });
}
}
});
}