Repository navigation
Test Specs IV5
생성: 2026-10-16 · 기준: v07 설치 후 통합 검증(IV) · 원본: 여정별 계획서(Excel) ← Test Scenarios · IV 허브 · 기능 TC
Playwright(TypeScript) spec 소스입니다. 시나리오마다 ## <시나리오번호> 섹션이 있고 앵커는 #<시나리오번호 소문자> 입니다. 공용 헬퍼는 Test-Specs-Common. 비밀번호 등은 마스킹했습니다.
| 시나리오 | 파일 | 줄 | 해시 |
|---|---|---|---|
| IV5_S01 | tests/iv5/IV5_S01.spec.ts |
32 | 4c203285 |
| IV5_S02 | tests/iv5/IV5_S02.spec.ts |
42 | eb84b3c2 |
| IV5_S03 | tests/iv5/IV5_S03.spec.ts |
28 | 93f828e5 |
| IV5_S04 | tests/iv5/IV5_S04.spec.ts |
37 | 86135518 |
| IV5_S05 | tests/iv5/IV5_S05.spec.ts |
69 | 87f17b3d |
| IV5_S06 | tests/iv5/IV5_S06.spec.ts |
47 | 987d3a9b |
| IV5_S07 | tests/iv5/IV5_S07.spec.ts |
67 | d1ef508b |
| IV5_S08 | tests/iv5/IV5_S08.spec.ts |
27 | b95cdfad |
| IV5_S09 | tests/iv5/IV5_S09.spec.ts |
28 | a5bdb721 |
| IV5_S10 | tests/iv5/IV5_S10.spec.ts |
32 | 856b6932 |
| IV5_S11 | tests/iv5/IV5_S11.spec.ts |
28 | 519e2611 |
| IV5_S12 | tests/iv5/IV5_S12.spec.ts |
38 | c7838d9c |
| IV5_S13 | tests/iv5/IV5_S13.spec.ts |
37 | 2a4b2ab8 |
| IV5_S14 | tests/iv5/IV5_S14.spec.ts |
34 | 2693d893 |
| IV5_S15 | tests/iv5/IV5_S15.spec.ts |
69 | 3fa4aae3 |
| IV5_S16 | tests/iv5/IV5_S16.spec.ts |
56 | e6af5efe |
| IV5_S17 | tests/iv5/IV5_S17.spec.ts |
57 | d7291202 |
| IV5_S18 | tests/iv5/IV5_S18.spec.ts |
39 | 6d1a0d83 |
| IV5_S19 | tests/iv5/IV5_S19.spec.ts |
36 | 561dd9dd |
| IV5_S20 | tests/iv5/IV5_S20.spec.ts |
29 | a0bc955e |
| IV5_S21 | tests/iv5/IV5_S21.spec.ts |
42 | 27d1329c |
| IV5_S22 | tests/iv5/IV5_S22.spec.ts |
53 | 619a390d |
| IV5_S23 | tests/iv5/IV5_S23.spec.ts |
79 | 62189d52 |
| IV5_S24 | tests/iv5/IV5_S24.spec.ts |
35 | 57f7eb94 |
| IV5_S25 | tests/iv5/IV5_S25.spec.ts |
57 | ce7516d4 |
| IV5_S26 | tests/iv5/IV5_S26.spec.ts |
60 | af800887 |
| IV5_S27 | tests/iv5/IV5_S27.spec.ts |
68 | 461f5265 |
| IV5_S28 | tests/iv5/IV5_S28.spec.ts |
44 | 8d072c8a |
| IV5_S29 | tests/iv5/IV5_S29.spec.ts |
77 | 3f81d492 |
tests/iv5/IV5_S01.spec.ts · 32줄 · 해시 4c203285 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin, selectWorkspaceProject } from '../../lib/ui';
import { CATALOG_URL, openNode, catalogFrame, deployToVm, appStatus, vmTarget } from '../../lib/iv5';
// IV5_S01 : SW Catalogs: Infra에 SW 설치 [정상, CSP별]
// Applications > SW Catalogs(iframe) → Deploy → Application Installation 모달(VM / mc{n} / g{n}-1 / SW) → Deploy
// 선행: IV3 Infra mc{n} 의 g{n}-1 Running. 같은 VM 에 IV5_S03(Nginx)도 설치하므로 여기서는 Apache HTTP Server(포트 8081)를 쓴다.
const APP = 'Apache HTTP Server';
const PORT = '8081';
for (const c of activeCsps()) {
test(`${tid('IV5_S01', c)} SW Catalogs: Infra에 SW 설치`, async ({ page, dialogs }) => {
await loginAsAdmin(page);
// 선행 확인: 대상 node Running
const st = await openNode(page, c);
expect(st, '선행조건: 대상 node Running').toMatch(/Running/i);
await page.goto(CATALOG_URL);
await selectWorkspaceProject(page, 'ws01', 'default', true);
const f = await catalogFrame(page);
const r = await deployToVm(page, f, c, { app: APP, port: PORT });
// 기대결과: 설치 요청 접수(오류 알럿/문구 없음)
const errs = [...dialogs, ...r.msgs, r.after].filter((s) => /error|fail|invalid|오류|실패/i.test(s));
expect(errs, '설치 요청 오류 없음').toEqual([]);
// 2026-10-05 첫 실행: 요청은 접수됐으나 App Status=Failed(SSH Docker 준비 실패, g1-1 Public IP 없음)인데 통과 처리됨 → 상태까지 판정
const s = await appStatus(page, vmTarget(c), APP);
expect(s, 'App Status 설치 완료(Failed 면 deployment-logs 어노테이션 참조)').toMatch(/^Running$/i);
});
}tests/iv5/IV5_S02.spec.ts · 42줄 · 해시 eb84b3c2 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sName, k8sProject } from '../../lib/data';
import { loginAsAdmin, selectWorkspaceProject } from '../../lib/ui';
import { gotoK8s, clusterCells } from '../../lib/iv3-k8s';
import { CATALOG_URL, catalogFrame, deployToK8s, appStatus } from '../../lib/iv5';
// IV5_S02 : SW Catalogs: K8s에 SW/workload 배포 [정상, CSP별]
// Applications > SW Catalogs(iframe) → Deploy → Application Installation 모달 Target Infra=K8S(namespace=Project 고정, Cluster, Helm chart)
// → Nginx Helm chart, Port 80 → Spec Check → Deploy → Applications > App Status 에서 상태 확인
// 대상: 기본 k{n} / p{n}. Azure 는 사용자 지시(2026-10-06)로 IV3_S18 클러스터 k2e(NodeGroup g2e, p2). 환경변수 K8S_CLUSTER 로 덮어쓸 수 있음.
const APP = 'Nginx';
const PORT = '80';
const CLUSTER: Record<string, string> = { Azure: 'k2e' };
for (const c of activeCsps()) {
test(`${tid('IV5_S02', c)} SW Catalogs: K8s에 SW/workload 배포`, async ({ page, dialogs }) => {
const prj = process.env.K8S_PROJECT || k8sProject(c); // K8S_PROJECT 로 프로젝트 지정 가능(2026-10-13 NHN: default 의 기존 vNet 사용)
const cluster = process.env.K8S_CLUSTER || CLUSTER[c.code] || k8sName(c);
await loginAsAdmin(page, 'ws01', prj);
// 선행 확인: 클러스터 Active
await gotoK8s(page, prj);
// 목록이 늦게 채워짐(2026-10-06: 진입 직후 읽으면 행 없음) → 행이 나올 때까지 대기
let cells: Record<string, string> = {};
await expect.poll(async () => (cells = await clusterCells(page, cluster)).status ?? '', { timeout: 60_000, intervals: [3000] }).not.toBe('');
test.info().annotations.push({ type: 'cluster', description: `${prj}/${cluster}: status=${cells.status ?? '(목록에 없음)'} version=${cells.version ?? ''} nodegroup=${cells.nodegroup ?? ''}` });
expect(cells.status, `선행조건: 클러스터 ${cluster} Active`).toMatch(/Active/i);
await page.goto(CATALOG_URL);
await selectWorkspaceProject(page, 'ws01', prj, true);
const f = await catalogFrame(page, prj);
const r = await deployToK8s(page, f, cluster, { app: APP, port: PORT });
const errs = [...dialogs, ...r.msgs, r.after].filter((s) => /error|fail|invalid|오류|실패/i.test(s));
expect(errs, '배포 요청 오류 없음').toEqual([]);
// 기대결과: 배포 요청 접수 + App Status Running
const s = await appStatus(page, { key: cluster, project: prj }, APP);
expect(s, 'App Status 에 배포 SW 행 표시').not.toBe('');
expect(s, 'App Status 배포 완료(Failed 면 deployment-logs 어노테이션 참조)').toMatch(/^Running$/i);
});
}tests/iv5/IV5_S03.spec.ts · 28줄 · 해시 93f828e5 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { openNode, installFromNode, appStatus, vmTarget } from '../../lib/iv5';
// IV5_S03 : Infra 상세 Install SW 버튼으로 SW 설치 [정상, CSP별]
// Infra Workloads > mc{n} > node g{n}-1 > Node Info 'Install SW' → "Install SW — Node [g{n} / g{n}-1]" 모달(대상 고정)
// → Nginx 선택 → Spec Check → Deploy → Applications > App Status 에서 상태 확인
const APP = 'Nginx';
const PORT = '8082';
for (const c of activeCsps()) {
test(`${tid('IV5_S03', c)} Infra 상세 Install SW 버튼으로 SW 설치`, async ({ page, dialogs }) => {
await loginAsAdmin(page);
const st = await openNode(page, c);
expect(st, '선행조건: 대상 node Running').toMatch(/Running/i);
const r = await installFromNode(page, c, { app: APP, port: PORT });
const errs = [...dialogs, ...r.msgs, r.after].filter((s) => /error|fail|invalid|오류|실패/i.test(s));
expect(errs, '설치 요청 오류 없음').toEqual([]);
// 기대결과: 진행/완료 상태 표시
const s = await appStatus(page, vmTarget(c), APP);
expect(s, 'App Status 에 설치 SW 행 표시').not.toBe('');
expect(s, 'App Status 설치 완료(Failed 면 deployment-logs 어노테이션 참조)').toMatch(/^Running$/i);
});
}tests/iv5/IV5_S04.spec.ts · 37줄 · 해시 86135518 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { openNode, termRunLong } from '../../lib/iv5';
import { openNodeTerminal, termRun } from '../../lib/iv4-infra';
// IV5_S04 : Terminal 로 VM 접속·Docker 확인 (SW 설치 후) [정상, CSP별]
// Infra Workloads > mc{n} > node g{n}-1 > Node Info Public IP 옆 'Terminal' → Confirm → Remote Terminal(xterm)
// 1) 접속 확인: echo hello / whoami / hostname 2) Docker 확인: which docker / docker version 2>&1 / sudo docker version 2>&1
// 선행: IV5_S03 으로 SW 설치 완료(App Status Running) → Docker 가 준비되어 있어야 함.
// 2026-10-06 배경: Public IP 가 Tumblebug 에 없을 때 Terminal 명령 출력이 비어 있었고 SW 설치도 실패했다(설치 전 docker: command not found 는 정상).
for (const c of activeCsps()) {
test(`${tid('IV5_S04', c)} Terminal 로 VM 접속·Docker 확인`, async ({ page }) => {
await loginAsAdmin(page);
const st = await openNode(page, c);
expect(st, '선행조건: 대상 node Running').toMatch(/Running/i);
await openNodeTerminal(page);
// 1) 접속 확인 — 출력이 비어 있으면 원격 명령 경로(SSH/Public IP) 문제
expect(await termRun(page, 'echo hello'), 'echo 출력').toContain('hello');
const who = await termRun(page, 'whoami');
expect(who, 'whoami 출력').not.toBe('');
expect(await termRun(page, 'hostname'), 'hostname 출력').not.toBe('');
// 2) Docker 확인 — 오류 출력도 보이도록 2>&1
const which = await termRun(page, 'which docker');
expect(which, 'docker 설치 경로(IV5_S03 설치로 준비됨)').toMatch(/\/docker\b/);
// 출력이 길어 termRunLong 사용(앞부분은 화면 밖으로 밀릴 수 있어 끝부분 Server 섹션으로 판정)
const plain = await termRunLong(page, 'docker version 2>&1');
// sudo 없이: docker 그룹이 아니면 permission denied 가 날 수 있음 → 판정에 쓰지 않고 기록만
test.info().annotations.push({ type: 'docker-no-sudo', description: /permission denied/i.test(plain) ? `권한 오류(${who} 가 docker 그룹 아님)` : /Server/.test(plain) ? 'Server 응답' : '기타(terminal 어노테이션 참조)' });
const sudo = await termRunLong(page, 'sudo docker version 2>&1');
expect(sudo, 'sudo docker version: Server(데몬 응답)').toMatch(/Server/);
expect(sudo, 'sudo docker version: Engine 버전').toMatch(/Version:\s*\d+\.\d+/);
});
}tests/iv5/IV5_S05.spec.ts · 69줄 · 해시 87f17b3d · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { openNode, appStatus, vmTarget, termRunLong, appAction } from '../../lib/iv5';
import { openNodeTerminal } from '../../lib/iv4-infra';
// IV5_S05 : [Infra] 설치된 SW 호출 확인 — Apache(8081)·Nginx(8082) HTTP 응답 [정상, CSP별] (사용자 지시 2026-10-07)
// 대상: Infra node (K8s 아님). 선행: IV5_S01(Apache 8081)·IV5_S03(Nginx 8082) 설치, 대상 node Running (Suspend 상태면 Resume 후)
// 화면: Infra Workloads > mc{n} > node g{n}-1 > Node Info Terminal(Remote Terminal) + Applications > App Status
// 절차: 1.node Running 확인 2.App Status 에서 두 SW 상태 확인(기록) 3.Terminal 에서 curl http://localhost:{port}/ → HTTP 200 + 기본 페이지 본문 4.공인 IP:{port} 외부 호출 결과 기록
// 기대결과: Apache(8081)·Nginx(8082) 가 HTTP 200 과 기본 페이지 본문으로 응답
// 판정: HTTP 200 + 응답 헤더 Server 값. 본문은 Nginx 만 기본 페이지 문구로 확인(Apache 컨테이너의 기본 응답은 'It works!' 가 아니라 HTML 4.01 형식 페이지였음, 2026-10-07 → 본문 앞부분은 기록만)
const APPS = [
{ name: 'Apache HTTP Server', port: '8081', server: /Server:\s*Apache/i, sign: null as RegExp | null },
{ name: 'Nginx', port: '8082', server: /Server:\s*nginx/i, sign: /Welcome to nginx/i },
];
for (const c of activeCsps()) {
test(`${tid('IV5_S05', c)} [Infra] 설치된 SW 호출 확인: Apache(8081)·Nginx(8082) HTTP 응답`, async ({ page, request }) => {
test.setTimeout(30 * 60_000);
test.info().annotations.push({ type: 'target', description: 'Infra node (K8s 아님): mc/g-1 의 Docker 컨테이너 SW' });
await loginAsAdmin(page);
const st = await openNode(page, c);
expect(st, '선행조건: 대상 node Running').toMatch(/Running/i);
const pip = (await page.getByText('Public IP(IPv4)', { exact: true }).locator('xpath=following-sibling::*[1]').first().innerText().catch(() => '')).trim();
// 2. App Status 상태 기록. VM 을 Suspend→Resume 하면 Docker 컨테이너가 다시 올라오지 않아 Stopped(Resume 직후에는 Error)로 보인다(2026-10-07 AWS 관찰).
// Stopped 이면 App Status 의 'Start' 로 기동한 뒤 호출한다.
const statuses: Record<string, string> = {};
for (const a of APPS) {
statuses[a.name] = await appStatus(page, vmTarget(c), a.name, 3 * 60_000);
if (/stopped/i.test(statuses[a.name])) {
await appAction(page, vmTarget(c), a.name, 'Start');
statuses[a.name] += ` → Start → ${await appStatus(page, vmTarget(c), a.name, 5 * 60_000)}`;
}
}
test.info().annotations.push({ type: 'app-status-summary', description: JSON.stringify(statuses) });
// 3. Terminal 에서 호출(localhost)
await openNode(page, c);
await openNodeTerminal(page);
const dock = await termRunLong(page, 'sudo docker ps --format "{{.Names}} {{.Status}} {{.Ports}}"');
test.info().annotations.push({ type: 'docker-ps', description: dock.replace(/\s+/g, ' ').slice(0, 300) });
const results: string[] = [];
for (const a of APPS) {
const code = await termRunLong(page, `curl -s -o /dev/null -w "HTTP_%{http_code}" -m 5 http://localhost:${a.port}/`);
const hdr = await termRunLong(page, `curl -sI -m 5 http://localhost:${a.port}/ | grep -i "^server"`);
const body = await termRunLong(page, `curl -s -m 5 http://localhost:${a.port}/ | head -c 100`);
results.push(`${a.name}(:${a.port}) code=${code.replace(/\s+/g, ' ').slice(-12)} ${hdr.replace(/\s+/g, ' ').slice(-30)} body=${body.replace(/\s+/g, ' ').slice(0, 70)}`);
expect.soft(code, `${a.name} localhost:${a.port} HTTP 200`).toContain('HTTP_200');
expect.soft(hdr, `${a.name} 응답 헤더 Server`).toMatch(a.server);
if (a.sign) expect.soft(body, `${a.name} 본문`).toMatch(a.sign);
}
test.info().annotations.push({ type: 'localhost-call', description: results.join(' | ') });
// 4. 외부 호출(공인 IP) — 보안그룹 등으로 차단될 수 있어 기록만
const ext: string[] = [];
for (const a of APPS) {
try {
const r = await request.get(`http://${pip}:${a.port}/`, { timeout: 10_000, failOnStatusCode: false });
ext.push(`${a.name}(:${a.port}) ${r.status()} ${(await r.text()).replace(/\s+/g, ' ').slice(0, 50)}`);
} catch (e) {
ext.push(`${a.name}(:${a.port}) 외부 호출 불가(${String(e).split('\n')[0].slice(0, 60)})`);
}
}
test.info().annotations.push({ type: 'external-call', description: `${pip}: ${ext.join(' | ')}` });
});
}tests/iv5/IV5_S06.spec.ts · 47줄 · 해시 987d3a9b · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sName, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { appStatus } from '../../lib/iv5';
// IV5_S06 : [K8s] 설치된 Nginx(Helm) 동작 확인 — 상태·Health Check·Pod [정상, CSP별] (사용자 지시 2026-10-07)
// 대상: K8s cluster (Infra 아님). 선행: IV5_S02(Nginx Helm 배포) 완료, K8s Active (Project p{n})
// 화면: Applications > App Status(Project p{n}) > K8S 행 선택 > Application Detail(Status, Health Check, Network & Service Information)
// 절차: 1.App Status 에서 K8S (p{n} / {cluster}) Nginx for Kubernetes Running 확인 2.행 선택 → Application Detail: Status Running, Health Check Healthy, Pod n/n running, Service Port 80
// 기대결과: Nginx(Helm) 가 Running / Healthy 이고 Pod 가 모두 running. PUBLIC IP 가 있으면 호출 확인(AWS 는 비어 있음 → 상태/Health 로 확인)
// 대상 클러스터: 기본 k{n}, K8S_CLUSTER 로 변경(v07 AWS: k1d)
const APP = 'Nginx for Kubernetes';
for (const c of activeCsps()) {
test(`${tid('IV5_S06', c)} [K8s] 설치된 Nginx(Helm) 동작 확인: 상태·Health Check·Pod`, async ({ page, request }) => {
const prj = process.env.K8S_PROJECT || k8sProject(c); // K8S_PROJECT 로 프로젝트 지정 가능(2026-10-13 NHN: default 의 기존 vNet 사용)
const cluster = process.env.K8S_CLUSTER || k8sName(c);
test.info().annotations.push({ type: 'target', description: `K8s cluster (Infra 아님): ${prj} / ${cluster}` });
await loginAsAdmin(page, 'ws01', prj);
const s = await appStatus(page, { key: cluster, project: prj }, APP, 3 * 60_000);
expect(s, `App Status ${APP} Running`).toMatch(/^Running$/i);
// 상세: 행(앱 이름) 클릭 → Application Detail
const f = page.frames().find((x) => x.url().includes('/applications/status'))!;
await f.getByText(APP, { exact: true }).first().click();
await expect(f.getByText('Application Detail').first()).toBeVisible({ timeout: 30_000 });
await page.waitForTimeout(3000);
const body = (await f.locator('body').innerText()).replace(/\s+/g, ' ');
const detail = body.slice(body.indexOf('Application Detail'), body.indexOf('Application Detail') + 1600);
test.info().annotations.push({ type: 'app-detail', description: detail.slice(0, 700) });
expect(detail, 'Status Running').toMatch(/Status:\s*Running/i);
expect(detail, 'Health Check Healthy').toMatch(/Health Check:\s*Healthy/i);
const pod = detail.match(/(\d+)\/(\d+) running/);
test.info().annotations.push({ type: 'pods', description: pod ? pod[0] : '(Pod 정보 없음)' });
expect(pod, 'Pod running 표시').not.toBeNull();
expect(pod![1], 'Pod 모두 running').toBe(pod![2]);
// PUBLIC IP 가 있으면 호출(없으면 기록만)
const pubIp = detail.match(/PUBLIC IP[^0-9]{0,40}(\d+\.\d+\.\d+\.\d+)/)?.[1];
if (pubIp) {
const r = await request.get(`http://${pubIp}/`, { timeout: 10_000, failOnStatusCode: false }).catch((e) => e);
test.info().annotations.push({ type: 'external-call', description: `${pubIp}: ${typeof r.status === 'function' ? r.status() : String(r).split('\n')[0].slice(0, 80)}` });
} else {
test.info().annotations.push({ type: 'external-call', description: '서비스 PUBLIC IP 없음 → 외부 호출 불가(상태/Health Check 로 확인)' });
}
});
}tests/iv5/IV5_S07.spec.ts · 67줄 · 해시 d1ef508b · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { openNode, appStatus, vmTarget, termRunLong, appAction, waitUninstalled } from '../../lib/iv5';
import { openNodeTerminal } from '../../lib/iv4-infra';
// IV5_S07 : [Infra] 설치 SW Uninstall → 삭제 확인: Apache(8081)·Nginx(8082) [정상, CSP별] (사용자 지시 2026-10-07)
// 대상: Infra node (K8s 아님). 선행: IV5_S05 완료, 대상 node Running (Suspend 상태면 Resume 후)
// 절차: 1.App Status 에서 두 SW 가 Running 인지 확인(Stopped 면 Start) 2.각 SW 행 Uninstall → 확인 3.행 제거(또는 Uninstalled) 확인
// 4.Terminal: docker ps 에 해당 컨테이너 없음, curl localhost:{port} 연결 실패(HTTP_000)
// 기대결과: Uninstall 후 App Status 에서 제거되고 컨테이너/포트가 정리된다
const APPS = [
{ name: 'Apache HTTP Server', port: '8081', ctr: /apache/i },
{ name: 'Nginx', port: '8082', ctr: /nginx/i },
];
for (const c of activeCsps()) {
test(`${tid('IV5_S07', c)} [Infra] 설치 SW Uninstall → 삭제 확인: Apache·Nginx`, async ({ page }) => {
test.setTimeout(60 * 60_000);
test.info().annotations.push({ type: 'target', description: 'Infra node (K8s 아님): mc/g-1 의 Docker 컨테이너 SW' });
await loginAsAdmin(page);
const st = await openNode(page, c);
expect(st, '선행조건: 대상 node Running').toMatch(/Running/i);
// 1. 사전 상태: Running 이 아니면(Resume 후 Stopped) Start
for (const a of APPS) {
let s = await appStatus(page, vmTarget(c), a.name, 3 * 60_000);
if (/stopped/i.test(s)) { await appAction(page, vmTarget(c), a.name, 'Start'); s += ` → Start → ${await appStatus(page, vmTarget(c), a.name, 5 * 60_000)}`; }
test.info().annotations.push({ type: 'pre-status', description: `${a.name}: ${s}` });
}
// 2~3. Uninstall → 제거 확인
const progress: string[] = [];
for (const a of APPS) {
await appAction(page, vmTarget(c), a.name, 'Uninstall');
const r = await waitUninstalled(page, vmTarget(c), a.name, 5 * 60_000);
progress.push(`${a.name}: ${r.seen.join(' → ')}${r.done ? '' : ' (시간 내 제거 안 됨)'}`);
expect.soft(r.done, `${a.name} App Status 에서 제거/Uninstalled`).toBe(true);
}
test.info().annotations.push({ type: 'uninstall-progress', description: progress.join(' | ') });
// 4. Terminal 확인 (Resume 직후에는 bastion 공인 IP 갱신 지연으로 연결 실패할 수 있어 재시도)
let first = '';
for (let i = 0; i < 8; i++) {
await openNode(page, c);
try { first = await openNodeTerminal(page); } catch (e) { first = 'ERR ' + String(e).split('\n')[0].slice(0, 80); }
if (!/Error|ERR/i.test(first)) break;
test.info().annotations.push({ type: 'terminal-retry', description: `${i + 1}회차 실패: ${first.replace(/\s+/g, ' ').slice(0, 100)}` });
await page.waitForTimeout(150_000);
}
expect(first, 'Terminal 접속').not.toMatch(/Error|ERR/i);
const dock = (await termRunLong(page, 'sudo docker ps -a --format "{{.Names}} {{.Status}}"')).replace(/\s+/g, ' ');
test.info().annotations.push({ type: 'docker-ps-a', description: dock.slice(-300) });
const res: string[] = [];
for (const a of APPS) {
const code = (await termRunLong(page, `curl -s -o /dev/null -w "HTTP_%{http_code}" -m 5 http://localhost:${a.port}/`)).replace(/\s+/g, ' ');
const tail = code.slice(-14);
res.push(`${a.name}(:${a.port}) ${tail}`);
expect.soft(code, `${a.name} 호출 실패(HTTP_000)`).toMatch(/HTTP_000\s*@@L\d+@@\s*$|HTTP_000/);
expect.soft(code.endsWith('HTTP_200') || /HTTP_200@@L\d+@@\s*$/.test(code), `${a.name} 200 응답 아님`).toBe(false);
}
// docker ps -a 출력 끝부분만(명령 에코 제외) 보고 컨테이너 잔존 여부 확인
const after = dock.split('@@')[1] ?? dock;
for (const a of APPS) expect.soft(after, `${a.name} 컨테이너 잔존 없음`).not.toMatch(a.ctr);
test.info().annotations.push({ type: 'curl-after', description: res.join(' | ') });
});
}tests/iv5/IV5_S08.spec.ts · 27줄 · 해시 b95cdfad · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sName, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { appStatus, appAction, waitUninstalled } from '../../lib/iv5';
// IV5_S08 : [K8s] Nginx(Helm) Uninstall → 삭제 확인 [정상, CSP별] (사용자 지시 2026-10-07)
// 대상: K8s cluster (Infra 아님). 선행: IV5_S06 완료, K8s Active (Project p{n}). 대상 클러스터는 K8S_CLUSTER(v07 AWS: k1d)
// 절차: 1.App Status(Project p{n}) 에서 K8S 행 Nginx for Kubernetes Running 확인 2.Uninstall → 확인 3.행 제거(또는 Uninstalled) 확인
// 기대결과: Uninstall 후 App Status 에서 제거된다(Helm release/Pod 정리는 화면으로 직접 확인할 수 없어 행 제거로 판단 — 필요 시 별도 확인)
const APP = 'Nginx for Kubernetes';
for (const c of activeCsps()) {
test(`${tid('IV5_S08', c)} [K8s] Nginx(Helm) Uninstall → 삭제 확인`, async ({ page }) => {
test.setTimeout(30 * 60_000);
const prj = process.env.K8S_PROJECT || k8sProject(c); // K8S_PROJECT 로 프로젝트 지정 가능(2026-10-13 NHN: default 의 기존 vNet 사용)
const cluster = process.env.K8S_CLUSTER || k8sName(c);
const t = { key: cluster, project: prj };
test.info().annotations.push({ type: 'target', description: `K8s cluster (Infra 아님): ${prj} / ${cluster}` });
await loginAsAdmin(page, 'ws01', prj);
const s = await appStatus(page, t, APP, 3 * 60_000);
expect(s, `선행조건: ${APP} Running`).toMatch(/^Running$/i);
await appAction(page, t, APP, 'Uninstall');
const r = await waitUninstalled(page, t, APP, 10 * 60_000);
test.info().annotations.push({ type: 'uninstall-progress', description: `${APP}: ${r.seen.join(' → ')}${r.done ? '' : ' (시간 내 제거 안 됨)'}` });
expect(r.done, 'App Status 에서 제거/Uninstalled').toBe(true);
});
}tests/iv5/IV5_S09.spec.ts · 28줄 · 해시 a5bdb721 · 시나리오 보기
// IV5_S09: TC-APP-001 Repository 목록 조회 [정상, 공통]
// v0.6 TC-ID: TC-APP-REP-01 — 기존 spec(mcmp-e2e mc-web-console/specs/sw/TC-APP-REP-01-repository-목록-조회.spec.ts)은 절차만 참고(사용자 결정 2026-10-14: 동작 코드는 재사용하지 않음)
// 화면: Applications > Repository (iframe :18084/web/applications/repository)
// 시작 상태: admin(mcmp) 로그인, Workspace=ws01 / Project=default
// 절차: 1.Repository 메뉴 진입 2.목록 컬럼(Name/Format/URL/Type(hosted)/Action) 확인 3.등록된 Nexus repository 표시 = GET /oss/v1/repositories/nexus/list 4.목록 없음이면 오류 없이 빈 목록
// 기대결과: Repository 목록 화면 표시, 등록된 Nexus repository 정보 표시, 목록 없음 상태도 오류 없이 표시
// 비고: 시트 절차는 'SW Catalogs > Repository 탭'이나 v0.7 화면은 사이드바 Applications > Repository 별도 메뉴(2026-10-14)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoApp, REPO_URL, repoList, showAllRows, clean } from '../../lib/iv5-app';
test('IV5_S09 TC-APP-001 Repository 목록 조회', async ({ page }) => {
await loginAsAdmin(page);
const f = await test.step('1. Applications > Repository 진입', () => gotoApp(page, REPO_URL, /repository/));
await test.step('2. 컬럼 Name / Format / URL / Type(hosted) / Action', async () => {
for (const h of ['Name', 'Format', 'URL', 'Type(hosted)', 'Action']) await expect(f.getByText(h, { exact: true }).first()).toBeVisible();
});
await test.step('3~4. 화면 목록 = Nexus repository 목록 API (없으면 빈 목록, 오류 없음)', async () => {
await showAllRows(f);
const api = await repoList(page);
const names = (await f.locator('.tabulator-row .tabulator-cell:first-child').allInnerTexts()).map(clean);
test.info().annotations.push({ type: 'rows', description: `화면 ${names.length}건: ${names.join(', ') || '(없음)'} / API ${api.length}건: ${api.map((r) => `${r.name}(${r.format})`).join(', ')}` });
expect(names.sort()).toEqual(api.map((r) => r.name).sort());
await expect(f.getByText(/Unable to fetch data/)).toHaveCount(0);
});
});tests/iv5/IV5_S10.spec.ts · 32줄 · 해시 856b6932 · 시나리오 보기
// IV5_S10: TC-APP-002 Repository 신규 생성 [정상, 공통]
// v0.6 TC-ID: TC-APP-REP-02 — 기존 spec(mcmp-e2e mc-web-console/specs/sw/TC-APP-REP-02-repository-신규-생성-*.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Applications > Repository → New Repository 모달(#repositoryForm "Repository Create")
// 시작 상태: admin(mcmp) 로그인, ws01/default. IV5_S09 완료, Nexus 생성 권한
// 절차: 1.New Repository 클릭 2.생성 화면(모달) 표시 3.Name am-test-docker-hosted, Format docker (Type 은 hosted 고정) 4.Create 5.목록에 신규 표시
// 기대결과: Repository 생성 성공 메시지, 목록에 신규 항목, Nexus 에 동일 repository 생성(목록 API 로 확인)
// 비고: 이미 있으면(재실행) 먼저 삭제
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { createRepository, deleteRepository, repoList, gotoApp, REPO_URL, repoRow, showAllRows } from '../../lib/iv5-app';
export const REPO = 'am-test-docker-hosted';
test('IV5_S10 TC-APP-002 Repository 신규 생성', async ({ page }) => {
await loginAsAdmin(page);
if ((await repoList(page)).some((r) => r.name === REPO)) test.info().annotations.push({ type: 'pre-clean', description: (await deleteRepository(page, REPO)).toasts.join(' | ') });
await test.step(`1~4. New Repository → ${REPO} / docker → Create`, async () => {
const { toasts } = await createRepository(page, REPO, 'docker');
test.info().annotations.push({ type: 'create', description: toasts.join(' | ') || '(토스트 없음)' });
expect(toasts.join(' ')).toMatch(/Repository created successfully/);
});
await test.step('5. 목록 표시 · Nexus 목록 API 에 생성', async () => {
const f = await gotoApp(page, REPO_URL, /repository/);
await showAllRows(f);
await expect(repoRow(f, REPO)).toHaveCount(1);
const r = (await repoList(page)).find((x) => x.name === REPO);
test.info().annotations.push({ type: 'nexus', description: JSON.stringify(r) });
expect(r?.format).toBe('docker');
});
});tests/iv5/IV5_S11.spec.ts · 28줄 · 해시 519e2611 · 시나리오 보기
// IV5_S11: TC-APP-003 Repository 상세 진입 [정상, 공통]
// v0.6 TC-ID: TC-APP-REP-03 — 기존 spec(mcmp-e2e mc-web-console/specs/sw/TC-APP-REP-03-repository-상세-진입.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Applications > Repository → 행의 이름 셀 클릭 → Repository Detail
// 시작 상태: admin(mcmp) 로그인, ws01/default. IV5_S10 완료
// 절차: 1.목록에서 am-test-docker-hosted 선택(이름 셀 클릭) 2.상세 화면 표시 3.Name / Format / URL(Path) / Type(hosted) 확인
// 기대결과: Repository 상세 화면 표시(Name, Format, Type, URL)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openRepositoryDetail, repoList } from '../../lib/iv5-app';
const REPO = 'am-test-docker-hosted';
test('IV5_S11 TC-APP-003 Repository 상세 진입', async ({ page }) => {
await loginAsAdmin(page);
const r = (await repoList(page)).find((x) => x.name === REPO);
test.skip(!r, `선행 IV5_S10 의 ${REPO} 없음`);
const f = await test.step(`1~2. ${REPO} 이름 클릭 → Repository Detail`, () => openRepositoryDetail(page, REPO));
await test.step('3. Name / Format / URL(Path) / Type(hosted)', async () => {
const vals = await f.locator('input[disabled]').evaluateAll((els) => els.map((e) => (e as HTMLInputElement).value));
test.info().annotations.push({ type: 'detail', description: vals.join(' | ') });
for (const l of ['Name', 'Format', 'URL(Path)', 'Type(hosted)']) await expect(f.getByText(l, { exact: true }).first()).toBeVisible();
expect(vals).toContain(REPO);
expect(vals.join(' ')).toMatch(/docker/i);
for (const h of ['Name', 'URL(Path)', 'Size', 'Action']) await expect(f.getByText(h, { exact: true }).first()).toBeVisible();
});
});tests/iv5/IV5_S12.spec.ts · 38줄 · 해시 c7838d9c · 시나리오 보기
// IV5_S12: TC-APP-006 빌트인 Catalog 목록 표시 [정상, 공통]
// v0.6 TC-ID: TC-APP-CAT-01 — 기존 spec(mcmp-e2e mc-web-console/specs/sw/TC-APP-CAT-01-빌트인-catalog-목록-표시.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Applications > SW Catalogs (iframe :18084/web/applications/swcatalog)
// 시작 상태: admin(mcmp) 로그인, ws01/default. Built-in Catalog seed 등록
// 절차: 1.SW Catalogs 진입 2.Built-in Catalog 목록 표시 3.테스트 대상(Elasticsearch, Grafana 등) 표시 4.항목마다 이름·요약·Category·Rating
// 기대결과: Built-in Catalog 목록 표시, 이름·요약·Category·Rating 영역 표시
// 비고: VM/K8s 멤버는 한 카드로 묶여 API 13건 → 화면 12건(Nginx for Kubernetes 는 Nginx 카드). 시트 예시의 Loki 는 2026-10-14 stage 빌트인 목록에 없음(12종: Tomcat, Redis, Nginx, Apache HTTP Server, Nexus, MariaDB, Grafana, Prometheus, Elasticsearch, Rclone GUI, JupyterLab for Object Storage, PostgreSQL)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoApp, CATALOG_URL, catalogList, clean } from '../../lib/iv5-app';
const MUST = ['Elasticsearch', 'Grafana', 'Nginx', 'MariaDB'];
test('IV5_S12 TC-APP-006 빌트인 Catalog 목록 표시', async ({ page }) => {
await loginAsAdmin(page);
const f = await test.step('1. SW Catalogs 진입', () => gotoApp(page, CATALOG_URL, /swcatalog/));
await test.step('2~4. 목록 = GET /catalog/software · 대상 Catalog 표시 · 이름/요약/Category/Rating', async () => {
const items = f.locator('#sc-list-group .list-group-item');
await expect(items.first()).toBeVisible();
const api = await catalogList(page);
const n = await items.count();
const shown: string[] = [];
for (let i = 0; i < n; i++) shown.push(clean(await items.nth(i).innerText()).slice(0, 90));
test.info().annotations.push({ type: 'items', description: `${n}건: ${shown.join(' || ')}` });
test.info().annotations.push({ type: 'api', description: `${api.length}건: ${api.map((c) => c.title ?? c.name).join(', ')}` });
// VM/K8s 멤버는 한 카드로 묶인다(예: Nginx + Nginx for Kubernetes → Nginx 카드, 2026-10-14) → API 항목은 그대로 보이거나 base 이름 카드에 묶여야 한다
const titles = api.map((c) => String(c.title ?? c.name));
const hidden = titles.filter((t) => !shown.some((s) => s.startsWith(t)));
test.info().annotations.push({ type: 'grouped', description: hidden.join(', ') || '없음' });
for (const t of hidden) expect(shown.some((s) => s.startsWith(t.replace(/ for Kubernetes$/i, ''))), `${t} → 묶인 카드 표시`).toBe(true);
for (const t of MUST) expect(shown.some((s) => s.startsWith(t)), `${t} 표시`).toBe(true);
const first = clean(await items.first().innerText());
expect(first, 'Rating 형식 "평균 (건수)"').toMatch(/\d+(\.\d+)? \(\d+\)/);
test.info().annotations.push({ type: 'loki', description: `Loki 표시 ${shown.some((s) => /Loki/i.test(s))} (시트 예시)` });
});
});tests/iv5/IV5_S13.spec.ts · 37줄 · 해시 2a4b2ab8 · 시나리오 보기
// IV5_S13: TC-APP-007 Catalog 상세 펼침/접힘 [정상, 공통]
// v0.6 TC-ID: TC-APP-CAT-02 — 기존 spec(mcmp-e2e mc-web-console/specs/sw/TC-APP-CAT-02-catalog-상세-펼침-접힘.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Applications > SW Catalogs → 목록 항목 이름 클릭(펼침 #accordion_{id}) → 다시 클릭(접힘)
// 시작 상태: admin(mcmp) 로그인, ws01/default. IV5_S12 완료
// 절차: 1.대상 Catalog(Elasticsearch) 선택 2.이름 클릭 → 상세 펼침 3.Description·Ref Information·TAGS·Recommended Spec·Deployment Status 표시 4.다시 클릭 → 접힘, 목록 레이아웃 유지
// 기대결과: 상세 정보 펼쳐짐, Ref Information·Recommended Spec·Deployment Status 표시, 접힘 후 목록 유지
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoApp, CATALOG_URL, catalogItem, clean } from '../../lib/iv5-app';
const TARGET = 'Elasticsearch';
test('IV5_S13 TC-APP-007 Catalog 상세 펼침/접힘', async ({ page }) => {
await loginAsAdmin(page);
const f = await gotoApp(page, CATALOG_URL, /swcatalog/);
const item = catalogItem(f, TARGET);
const count = await f.locator('#sc-list-group .list-group-item').count();
const acc = item.locator('[id^="accordion_"]').first();
await test.step(`1~2. ${TARGET} 이름 클릭 → 펼침`, async () => {
await item.locator('.col-5').first().click();
await expect(acc).toBeVisible();
});
await test.step('3. Ref Information · TAGS · Recommended Spec · Deployment Status', async () => {
const t = clean(await acc.innerText());
test.info().annotations.push({ type: 'detail', description: t.slice(0, 600) });
for (const s of ['Ref Information', 'Recommended Spec', 'Deployment Status']) expect(t, s).toContain(s);
expect(t).toMatch(/CPU : [\d.]+ Core/);
});
await test.step('4. 다시 클릭 → 접힘 · 목록 항목 수 유지', async () => {
await item.locator('.col-5').first().click();
await expect(acc).toBeHidden();
await expect(f.locator('#sc-list-group .list-group-item')).toHaveCount(count);
});
});tests/iv5/IV5_S14.spec.ts · 34줄 · 해시 2693d893 · 시나리오 보기
// IV5_S14: TC-APP-008 외부 검색 결과 표시 (DockerHub/ArtifactHub) [정상, 공통]
// v0.6 TC-ID: TC-APP-CAT-03 — 기존 spec(mcmp-e2e mc-web-console/specs/sw/TC-APP-CAT-03-외부-검색-결과-표시.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Applications > SW Catalogs → Register → Application Registration 1. Source & Package → Public Registry
// 시작 상태: admin(mcmp) 로그인, ws01/default. 외부망(Docker Hub / Artifact Hub API) 접근 가능
// 절차: 1.Register → Public Registry 2.Target VM(Docker Hub)에서 ghost 검색 → 결과 목록 3.Target K8S(Artifact Hub)에서 ghost 검색 → 결과 목록 4.Cancel(등록 안 함)
// 입력값(시트): ghost
// 기대결과: 외부 검색 결과 목록 표시
// 비고: v0.7 화면에는 목록의 외부 검색창이 없고 Register 마법사의 Public Registry 가 외부 검색(2026-10-14 확인). VM=Docker Hub, K8S=Artifact Hub
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openRegister, publicSearch } from '../../lib/iv5-app';
test('IV5_S14 TC-APP-008 외부 검색 결과 표시 (DockerHub/ArtifactHub)', async ({ page }) => {
await loginAsAdmin(page);
const { m } = await openRegister(page);
await test.step('1~2. Public Registry · VM(Docker Hub) → ghost 검색', async () => {
const r = await publicSearch(page, m, 'ghost');
test.info().annotations.push({ type: 'dockerhub', description: r.join(', ') || '(결과 없음)' });
expect(r.length, 'Docker Hub 결과').toBeGreaterThan(0);
expect(r.join(' ')).toMatch(/ghost/i);
});
await test.step('3. K8S(Artifact Hub) → ghost 검색', async () => {
await m.locator('#targetK8S').check();
await m.locator('#registrySearchInput').fill('ghost');
await m.getByRole('button', { name: 'Search', exact: true }).click();
await expect(m.locator('.registry-results button.list-group-item').first().or(m.getByText('No matching packages found.'))).toBeVisible({ timeout: 60_000 });
const r = (await m.locator('.registry-results button.list-group-item .fw-semibold').allInnerTexts()).map((s) => s.trim());
test.info().annotations.push({ type: 'artifacthub', description: r.join(', ') || '(결과 없음)' });
expect(r.length, 'Artifact Hub 결과').toBeGreaterThan(0);
});
await test.step('4. Cancel', async () => { await m.getByRole('button', { name: 'Cancel' }).first().click(); });
});tests/iv5/IV5_S15.spec.ts · 69줄 · 해시 3fa4aae3 · 시나리오 보기
// IV5_S15: TC-APP-009 외부 검색 결과 업로드 (Public Registry → Import & Create) [정상, 공통]
// v0.6 TC-ID: TC-APP-CAT-04 — 기존 spec(mcmp-e2e mc-web-console/specs/sw/TC-APP-CAT-04-외부-검색-결과-업로드.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Applications > SW Catalogs → Register → 1. Source & Package(Public Registry) → 2~4단계 → Import & Create
// 시작 상태: admin(mcmp) 로그인, ws01/default. **IV5_S10(Nexus docker repository, :5000) 완료 필수**, Docker Hub 접근 가능
// 절차: 1.Public Registry · Target VM 에서 ghost 검색 → 결과 ghost 선택 2.Version(Tag) 6.44.0-bookworm 선택 · Category 선택 3.General/Resource/Network 입력 4.Import & Create
// 5.결과 메시지 확인 · 카탈로그 목록에 반입 항목 표시
// 입력값(시트): Source DockerHub / Package ghost / Tag 6.44.0-bookworm (화면 Version 목록에 없으면 bookworm) / Category CONTENT_MANAGEMENT_SYSTEM
// 기대결과: 업로드(반입) 요청 접수, 성공 또는 실패 메시지 표시
// 비고: 외부 검색 결과를 저장소로 올리는 별도 업로드 화면(uploadForm)은 v0.7 FE 에서 쓰이지 않고 Import & Create 가 반입(POST /catalog/docker/register)을 겸함(2026-10-14 소스 확인)
// 반입으로 생긴 카탈로그(ghost-import-test)는 IV5_S19(삭제) 단계에서 함께 정리
// 2026-10-14 stage: Nexus docker 레지스트리(:5000)가 'You must accept the End User License Agreement' 403 → 반입 실패(메시지 표시). EULA 수락은 콘솔 밖 사전 설정
// 선행 조건(2026-10-16 확인): Nexus 에 5000 커넥터를 가진 docker hosted 저장소가 있어야 한다 — IV5_S10(Repository 신규 생성, docker)이 콘솔에서 만들면 httpPort 5000 으로 생성.
// Docker Hub 반입 경로(DockerHubService.registerDockerHubImage → pushImageToNexus)는 저장소 존재를 확인하지 않는다(자동 생성 ensureDockerRepositoryExists 는 registerToInternalNexus 경로에서만 호출).
// 저장소가 없으면(예: stage 재시작으로 Nexus 초기화) :5000 미기동 → 'Failed to copy image to Nexus … connection refused' 로 실패. S15 단독 실행 전 S10 선행 필요
// 2026-10-16: EULA 는 이미 수락 상태(accepted:true). Nexus 초기화로 docker 저장소가 없어 실패 → IV5_S10 재실행 후 반입·등록 성공(PASS)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openRegister, publicSearch, selectByText, fillWizardRest, catalogList, collectToasts, clean } from '../../lib/iv5-app';
const NAME = 'ghost-import-test';
const TAG = '6.44.0-bookworm';
test('IV5_S15 TC-APP-009 외부 검색 결과 업로드 (Public Registry → Import & Create)', async ({ page }) => {
test.setTimeout(15 * 60_000);
await loginAsAdmin(page);
test.skip((await catalogList(page)).some((c) => (c.title ?? c.name) === NAME), `${NAME} 이미 있음(이전 실행 잔여) → IV5_S19 로 정리 후 재실행`);
const imports: string[] = [];
page.on('response', async (r) => { if (/\/catalog\/(docker|helm)\/register|\/catalog\/software$/.test(r.url()) && r.request().method() === 'POST') { const b = clean(await r.text().catch(() => '')); imports.push(`${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, '')} :: ${b.slice(0, 200)} … ${b.slice(-700)}`); } });
const { f, m } = await openRegister(page);
await test.step('1. Public Registry · VM(Docker Hub) → ghost 검색 → ghost 선택', async () => {
const r = await publicSearch(page, m, 'ghost');
test.info().annotations.push({ type: 'results', description: r.join(', ') });
await m.locator('.registry-results button.list-group-item').filter({ has: f.locator('.fw-semibold', { hasText: /^ghost$/ }) }).first().click();
});
await test.step(`2. Version ${TAG} · Category 선택`, async () => {
const selects = m.locator('select');
// Version 목록은 Docker Hub 최근 태그 10개만 나온다(2026-10-14: latest, next-*, bookworm, alpine, 6.70.0-next-*) → 시트 값이 없으면 같은 계열 'bookworm' 으로(지침: 화면 값으로 실행·비고 기록)
const verSel = selects.filter({ has: f.locator('option', { hasText: 'Select Version' }) }).first();
await expect.poll(async () => (await verSel.locator('option').count()) > 1, { timeout: 60_000 }).toBe(true);
const opts = (await verSel.locator('option').allInnerTexts()).map((o) => o.trim());
const ver = await selectByText(verSel, opts.includes(TAG) ? new RegExp(`^${TAG}$`) : /^bookworm$/);
test.info().annotations.push({ type: 'versions', description: `${opts.join(', ')} → ${ver}${ver !== TAG ? ` (시트 ${TAG} 목록에 없음)` : ''}` });
const catSel = selects.filter({ has: f.locator('option', { hasText: /Select Category/ }) }).first();
const cat = await selectByText(catSel, /CONTENT_MANAGEMENT/i);
test.info().annotations.push({ type: 'selected', description: `version ${ver} / category ${cat}` });
});
let toasts: string[] = [];
await test.step('3~4. General · Resource · Network 입력 → Import & Create', async () => {
// 반입(이미지 push)이 성공하면 30초를 넘길 수 있어(2026-10-16: 6개 이미지 복사) 카탈로그 생성 응답(POST /catalog/software)까지 기다린 뒤 토스트를 다시 모은다
const created = page.waitForResponse((r) => /\/catalog\/software$/.test(r.url()) && r.request().method() === 'POST', { timeout: 10 * 60_000 }).catch(() => null);
toasts = await fillWizardRest(page, f, m, { name: NAME, summary: 'ghost import test', desc: 'Imported from Docker Hub by IV5_S15', cpu: [1, 2], mem: [1, 2], disk: [1, 5], port: '2368' }, /Import & Create/);
if (!toasts.length && (await created)) toasts = await collectToasts(page, f, 15_000);
test.info().annotations.push({ type: 'toasts', description: toasts.join(' | ') || '(토스트 없음)' });
test.info().annotations.push({ type: 'api', description: imports.join(' || ') || '(기록 없음)' });
});
await test.step('5. 반입 요청 접수 · 결과 메시지 · 목록', async () => {
expect(imports.some((x) => /\/catalog\/docker\/register/.test(x)), '반입 요청(POST /catalog/docker/register) 접수').toBe(true);
expect(toasts.length, '성공 또는 실패 메시지 표시').toBeGreaterThan(0);
const inList = (await catalogList(page)).some((c) => (c.title ?? c.name) === NAME);
test.info().annotations.push({ type: 'catalog', description: `목록에 ${NAME}: ${inList}` });
// 시트 기대결과는 '성공 또는 실패 메시지 표시' → 메시지 표시로 판정하고 반입 성공 여부는 기록(2026-10-14: Nexus :5000 이 EULA 미수락 403 으로 push 거부)
const push = imports.find((x) => /docker\/register/.test(x)) ?? '';
test.info().annotations.push({ type: 'import-result', description: /"success":\s*false/.test(push) ? `반입 실패: ${(push.match(/StatusCode: \d+[^"\\]*/) ?? [''])[0]}` : (/Registration Success/.test(toasts.join(' ')) ? '반입·등록 성공' : '(판단 불가)') });
expect(toasts.join(' '), '성공 또는 실패 메시지').toMatch(/Registration Success|Failed to import|Registration Failed|Failed/);
});
});tests/iv5/IV5_S16.spec.ts · 56줄 · 해시 e6af5efe · 시나리오 보기
// IV5_S16: TC-APP-010 Catalog 신규 등록 (Register, Existing Package) [정상, 공통]
// v0.6 TC-ID: TC-APP-CAT-05 — 기존 spec(mcmp-e2e mc-web-console/specs/sw/TC-APP-CAT-05-catalog-신규-등록.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Applications > SW Catalogs → Register → Application Registration(1. Source & Package / 2. General / 3. Resource Requirements / 4. Network)
// 시작 상태: admin(mcmp) 로그인, ws01/default. IV5_S15(ghost 반입) 완료
// 절차: 1.Register 2.Existing Package · Target VM 3.Category → Package ghost → Version 6.44.0-bookworm 4.Application Name Ghost CMS, Summary, Description, Reference
// 5.Resource(CPU 0.5/1, Memory 1/2, Storage 1/5) · Network Port 2368 6.Create → 목록에 Ghost CMS, 상세(펼침)에 설명·spec 표시
// 입력값(시트): Packages ghost / Version 6.44.0-bookworm / Application Name Ghost CMS / CPU min 0.5 rec 1 / Memory 1·2 GB / Storage 1·5 GB / Port 2368 TCP
// 기대결과: Catalog 등록 성공 메시지, Ghost CMS 가 목록에 표시, 상세 화면에서 입력한 설명·spec·port 표시
// 비고: 버튼 이름은 시트의 'Regist' 가 아니라 'Register'(2026-10-14). Existing Package 목록은 반입(IV5_S15)한 패키지만 나온다(반입 전 Category 목록 비어 있음).
// Version 은 S15 에서 반입한 태그(시트 6.44.0-bookworm 이 Docker Hub 최근 목록에 없어 bookworm). 이미 있으면(재실행) skip
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openRegister, selectByText, fillWizardRest, catalogList, gotoApp, CATALOG_URL, catalogItem, clean } from '../../lib/iv5-app';
const NAME = 'Ghost CMS';
const DESC = 'Ghost CMS registered by IV5_S16 (VM, ghost 6.44.0-bookworm)';
test('IV5_S16 TC-APP-010 Catalog 신규 등록 (Register)', async ({ page }) => {
test.setTimeout(10 * 60_000);
await loginAsAdmin(page);
test.skip((await catalogList(page)).some((c) => (c.title ?? c.name) === NAME), `${NAME} 이미 있음 → IV5_S19 로 정리 후 재실행`);
const { f, m } = await openRegister(page);
await test.step('1~3. Existing Package · VM · Category → ghost → 6.44.0-bookworm', async () => {
await m.locator('#sourceExisting').check();
await m.locator('#targetVM').check();
const selects = m.locator('select');
const cat = await selectByText(selects.filter({ has: f.locator('option', { hasText: 'Select Category' }) }).first(), /content.?management/i);
const pkg = await selectByText(selects.filter({ has: f.locator('option', { hasText: 'Select Package' }) }).first(), /^\s*ghost\s*$/i);
const verSel = selects.filter({ has: f.locator('option', { hasText: 'Select Version' }) }).first();
await expect.poll(async () => (await verSel.locator('option').count()) > 1, { timeout: 60_000 }).toBe(true);
const ver = await selectByText(verSel, (await verSel.locator('option').allInnerTexts()).some((o) => /6\.44\.0-bookworm/.test(o)) ? /6\.44\.0-bookworm/ : /bookworm/); // IV5_S15 에서 반입한 태그
test.info().annotations.push({ type: 'package', description: `${cat} / ${pkg} / ${ver}` });
});
let toasts: string[] = [];
await test.step('4~6. General · Resource · Network → Create', async () => {
toasts = await fillWizardRest(page, f, m, { name: NAME, summary: 'Ghost publishing platform', desc: DESC, ref: ['HOMEPAGE', 'https://ghost.org/'], cpu: [0.5, 1], mem: [1, 2], disk: [1, 5], port: '2368' }, /^Create$/);
test.info().annotations.push({ type: 'toasts', description: toasts.join(' | ') || '(토스트 없음)' });
expect(toasts.join(' ')).toMatch(/Registration Success/);
});
await test.step('7. 목록 · 상세(펼침): 설명 · spec · port', async () => {
const c = (await catalogList(page)).find((x) => (x.title ?? x.name) === NAME);
test.info().annotations.push({ type: 'saved', description: JSON.stringify(c).slice(0, 800) });
expect(c, '목록 API 에 등록').toBeTruthy();
const g = await gotoApp(page, CATALOG_URL, /swcatalog/);
const item = catalogItem(g, NAME);
await expect(item).toBeVisible();
await item.locator('.col-5').first().click();
const t = clean(await item.innerText());
test.info().annotations.push({ type: 'detail', description: t.slice(0, 600) });
expect(t).toContain(DESC);
expect(t).toMatch(/CPU : 1 Core/);
expect(JSON.stringify(c)).toContain('2368');
});
});tests/iv5/IV5_S17.spec.ts · 57줄 · 해시 d7291202 · 시나리오 보기
// IV5_S17: TC-APP-011 Catalog 수정 (Edit) [정상, 공통]
// v0.6 TC-ID: TC-APP-CAT-06 — 기존 spec(mcmp-e2e mc-web-console/specs/sw/TC-APP-CAT-06-catalog-수정.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Applications > SW Catalogs → Ghost CMS 행 편집 아이콘 → Application Update(#modal-wizard) → Update
// 시작 상태: admin(mcmp) 로그인, ws01/default. IV5_S16 완료
// 절차: 1.Ghost CMS 행의 편집 아이콘 2.Application Name · Summary · Description · Recommended Spec 수정 3.Update 4.상세 재조회 · 목록 갱신
// 기대결과: Catalog 수정 성공 메시지, 상세 재조회 시 수정값 표시, 목록 표시 정보 갱신
// 비고: 편집/삭제는 행 hover 시 나오는 아이콘(텍스트 없음, 첫 번째=편집). 이름은 Ghost CMS 로 되돌리지 않고 'Ghost CMS (edited)' 로 남김 → IV5_S19 가 이 이름을 삭제
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoApp, CATALOG_URL, catalogAction, catalogList, catalogItem, collectToasts, toastGone, clean } from '../../lib/iv5-app';
const FROM = 'Ghost CMS';
const TO = 'Ghost CMS (edited)';
const DESC = 'Ghost CMS updated by IV5_S17';
test('IV5_S17 TC-APP-011 Catalog 수정 (Edit)', async ({ page }) => {
test.setTimeout(10 * 60_000);
await loginAsAdmin(page);
test.skip(!(await catalogList(page)).some((c) => (c.title ?? c.name) === FROM), `선행 IV5_S16 의 ${FROM} 없음`);
const f = await gotoApp(page, CATALOG_URL, /swcatalog/);
const m = f.locator('#modal-wizard');
await test.step(`1. ${FROM} 편집 아이콘 → Application Update`, async () => {
await catalogAction(f, FROM, 'edit');
await expect(m.getByText('Application Update')).toBeVisible();
});
let toasts: string[] = [];
await test.step('2~3. Name · Summary · Description · Recommended Spec 수정 → Update', async () => {
await m.getByRole('button', { name: 'Next', exact: true }).click();
await m.getByPlaceholder('Application name').fill(TO);
await m.getByPlaceholder('Application summary').fill('Ghost publishing platform (edited)');
await m.getByPlaceholder('Application description').fill(DESC);
await m.getByRole('button', { name: 'Next', exact: true }).click();
await m.getByPlaceholder('2', { exact: true }).first().fill('2'); // CPU recommended 1 → 2
await m.getByPlaceholder('8', { exact: true }).first().fill('4'); // Memory recommended 2 → 4
await m.getByRole('button', { name: 'Next', exact: true }).click();
await toastGone(f);
await m.getByRole('button', { name: /^Update$/ }).click();
toasts = await collectToasts(page, f, 15_000);
test.info().annotations.push({ type: 'toasts', description: toasts.join(' | ') || '(토스트 없음)' });
expect(toasts.join(' ')).toMatch(/Update Success/);
});
await test.step('4. 재조회: 수정값 · 목록 갱신', async () => {
const c = (await catalogList(page)).find((x) => (x.title ?? x.name) === TO);
test.info().annotations.push({ type: 'saved', description: JSON.stringify(c).slice(0, 800) });
expect(c, `목록 API 에 ${TO}`).toBeTruthy();
const g = await gotoApp(page, CATALOG_URL, /swcatalog/);
const item = catalogItem(g, TO);
await expect(item).toBeVisible();
await item.locator('.col-5').first().click();
const t = clean(await item.innerText());
test.info().annotations.push({ type: 'detail', description: t.slice(0, 500) });
expect(t).toContain(DESC);
expect(t).toMatch(/CPU : 2 Core/);
});
});tests/iv5/IV5_S18.spec.ts · 39줄 · 해시 6d1a0d83 · 시나리오 보기
// IV5_S18: TC-APP-004 Repository 컴포넌트 삭제 [정상, 공통]
// v0.6 TC-ID: TC-APP-REP-04 — 기존 spec(mcmp-e2e mc-web-console/specs/sw/TC-APP-REP-04-repository-컴포넌트-삭제.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Applications > Repository → am-test-docker-hosted 상세 → 컴포넌트 행 Delete → Delete Component 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. IV5_S11 완료, 삭제 검증용 Component 존재(IV5_S15 반입으로 생긴 이미지)
// 절차: 1.Repository 상세 이동 2.삭제 대상 Component 확인 3.Delete 4.확인 모달 Delete 5.Component 목록 갱신
// 기대결과: 삭제 확인 모달, Component 삭제 성공 메시지, 대상 Component 목록에서 제거
// 비고: 상세 화면의 File Upload 버튼은 FE 에서 주석 처리(2026-10-14) → 컴포넌트는 반입(IV5_S15)으로만 생긴다. 컴포넌트가 없으면 SKIP(선행 미충족)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openRepositoryDetail, repoList, collectToasts, toastGone, appApi, clean } from '../../lib/iv5-app';
const REPO = 'am-test-docker-hosted';
test('IV5_S18 TC-APP-004 Repository 컴포넌트 삭제', async ({ page }) => {
await loginAsAdmin(page);
test.skip(!(await repoList(page)).some((r) => r.name === REPO), `선행 IV5_S10 의 ${REPO} 없음`);
const comps = ((await appApi(page, 'GET', `/oss/v1/components/nexus/list/${REPO}`)).body?.data ?? []) as any[];
test.info().annotations.push({ type: 'components', description: `${comps.length}건: ${comps.map((c) => c.name).join(', ')}` });
test.skip(!comps.length, `${REPO} 에 삭제할 Component 없음(반입 실패 등 선행 미충족)`);
const f = await test.step('1~2. 상세 이동 · Component 확인', () => openRepositoryDetail(page, REPO));
const target = clean(await f.locator('.tabulator-row').first().locator('.tabulator-cell').first().innerText());
await test.step(`3~4. ${target} Delete → 확인 모달 Delete`, async () => {
await f.locator('.tabulator-row').first().locator('#delete-btn').click();
const m = f.locator('#deleteComponent');
await expect(m.getByText('Delete Component')).toBeVisible();
await toastGone(f);
await m.getByText('Delete', { exact: true }).last().click();
const t = await collectToasts(page, f, 5000);
test.info().annotations.push({ type: 'delete', description: t.join(' | ') || '(토스트 없음)' });
expect(t.join(' ')).toMatch(/Deleted successfully/);
});
await test.step('5. Component 목록 갱신', async () => {
const after = ((await appApi(page, 'GET', `/oss/v1/components/nexus/list/${REPO}`)).body?.data ?? []) as any[];
test.info().annotations.push({ type: 'after', description: `${after.length}건` });
expect(after.length).toBe(comps.length - 1);
});
});tests/iv5/IV5_S19.spec.ts · 36줄 · 해시 561dd9dd · 시나리오 보기
// IV5_S19: TC-APP-012 Catalog 삭제 (Trash) [정상, 공통]
// v0.6 TC-ID: TC-APP-CAT-07 — 기존 spec(mcmp-e2e mc-web-console/specs/sw/TC-APP-CAT-07-catalog-삭제.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Applications > SW Catalogs → Ghost CMS (edited) 행 삭제 아이콘 → Confirm Catalog Deletion 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. IV5_S17 완료(배포 테스트에 필요한 Catalog 는 사용 완료)
// 절차: 1.대상 행 삭제(휴지통) 아이콘 2.삭제 확인 모달 표시 3.Delete 4.목록에서 제거 5.(정리) IV5_S15 반입 카탈로그 ghost-import-test 도 같은 방법으로 삭제(있으면)
// 기대결과: 삭제 확인 모달, Catalog 삭제 성공 메시지, 삭제 대상 Catalog 목록에서 제거
// 비고: S17 에서 이름을 'Ghost CMS (edited)' 로 바꿈. 배포된 앱이 있으면 Delete 비활성(모달 안내)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoApp, CATALOG_URL, catalogAction, catalogList, collectToasts, toastGone } from '../../lib/iv5-app';
const TARGETS = ['Ghost CMS (edited)', 'Ghost CMS', 'ghost-import-test'];
test('IV5_S19 TC-APP-012 Catalog 삭제 (Trash)', async ({ page }) => {
await loginAsAdmin(page);
const names = (await catalogList(page)).map((c) => String(c.title ?? c.name));
const todo = TARGETS.filter((t) => names.includes(t));
test.skip(!todo.length, '삭제할 Ghost Catalog 없음(선행 IV5_S16/S17 미충족)');
for (const name of todo) {
await test.step(`${name}: 삭제 아이콘 → Confirm Catalog Deletion → Delete`, async () => {
const f = await gotoApp(page, CATALOG_URL, /swcatalog/);
await catalogAction(f, name, 'delete');
const m = f.locator('#deleteConfirmModal');
await expect(m.getByText('Confirm Catalog Deletion')).toBeVisible();
await expect(m.getByRole('button', { name: 'Delete', exact: true })).toBeEnabled({ timeout: 30_000 });
await toastGone(f);
await m.getByRole('button', { name: 'Delete', exact: true }).click();
const t = await collectToasts(page, f, 5000);
test.info().annotations.push({ type: `delete-${name}`, description: t.join(' | ') || '(토스트 없음)' });
expect(t.join(' ')).toMatch(/successfully deleted/);
expect((await catalogList(page)).map((c) => String(c.title ?? c.name))).not.toContain(name);
});
}
});tests/iv5/IV5_S20.spec.ts · 29줄 · 해시 a0bc955e · 시나리오 보기
// IV5_S20: TC-APP-005 Repository 삭제 [정상, 공통]
// v0.6 TC-ID: TC-APP-REP-05 — 기존 spec(mcmp-e2e mc-web-console/specs/sw/TC-APP-REP-05-repository-삭제.spec.ts, describe.skip 상태)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Applications > Repository → 대상 행 Delete → Delete Repository 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. IV5_S18 완료(삭제 대상 Repository 내 필수 Component 정리)
// 절차: 1.Repository 목록 2.am-test-docker-hosted 행 Delete 3.확인 모달 Delete 4.목록에서 제거 · Nexus 목록 API 에서 제거
// 기대결과: Repository 삭제 성공 메시지, 목록에서 제거, Nexus 에서도 제거
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { deleteRepository, repoList, gotoApp, REPO_URL, repoRow, showAllRows } from '../../lib/iv5-app';
const REPO = 'am-test-docker-hosted';
test('IV5_S20 TC-APP-005 Repository 삭제', async ({ page }) => {
await loginAsAdmin(page);
test.skip(!(await repoList(page)).some((r) => r.name === REPO), `선행 IV5_S10 의 ${REPO} 없음`);
await test.step(`1~3. ${REPO} Delete → 확인 모달 Delete`, async () => {
const { toasts } = await deleteRepository(page, REPO);
test.info().annotations.push({ type: 'delete', description: toasts.join(' | ') || '(토스트 없음)' });
expect(toasts.join(' ')).toMatch(/Deleted successfully/);
});
await test.step('4. 목록 · Nexus 목록 API 에서 제거', async () => {
const f = await gotoApp(page, REPO_URL, /repository/);
await showAllRows(f);
await expect(repoRow(f, REPO)).toHaveCount(0);
expect((await repoList(page)).some((r) => r.name === REPO)).toBe(false);
});
});tests/iv5/IV5_S21.spec.ts · 42줄 · 해시 27d1329c · 시나리오 보기
// IV5_S21: TC-APP-017 애플리케이션 배포 상태 목록 갱신 (Refresh) [정상, 공통]
// v0.6 TC-ID: TC-APP-APPS-01 — 기존 spec(mcmp-e2e mc-web-console/specs/sw/TC-APP-APPS-01-배포-상태-목록-갱신-refresh.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Applications > App Status (iframe :18084/web/applications/status) → Refresh
// 시작 상태: admin(mcmp) 로그인, ws01/default. VM/K8S 배포 이력 존재(IV5_S01~S08)
// 절차: 1.App Status 진입 2.Refresh 클릭 → 목록 재조회(GET /api/applications/status/groups) · 갱신 시각 변경 3.최근 VM/K8S 배포 항목이 목록에 표시
// 기대결과: 배포 상태 목록이 갱신됨
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoApp, STATUS_URL, showAllRows, clean } from '../../lib/iv5-app';
test('IV5_S21 TC-APP-017 애플리케이션 배포 상태 목록 갱신 (Refresh)', async ({ page }) => {
await loginAsAdmin(page);
const f = await gotoApp(page, STATUS_URL, /status/);
const stamp = async () => clean((await f.locator('body').innerText()).match(/\d{4}\. ?\d{1,2}\. ?\d{1,2}\. ?[^\n]*?\d{1,2}:\d{2}:\d{2}/)?.[0] ?? '');
const before = await stamp();
let refreshed: any[] = [];
await test.step('1~2. Refresh → 목록 재조회 · 갱신 시각', async () => {
await page.waitForTimeout(1500);
const [res] = await Promise.all([
page.waitForResponse((r) => /\/api\/applications\/status\/groups/.test(r.url()), { timeout: 30_000 }),
f.getByRole('button', { name: 'Refresh', exact: true }).click(),
]);
await page.waitForTimeout(1500);
const after = await stamp();
refreshed = ((await res.json().catch(() => ({})))?.data ?? []) as any[];
test.info().annotations.push({ type: 'refresh', description: `GET ${res.status()} ${res.url().replace(/^https?:\/\/[^/]+/, '')} / 갱신 시각 ${before} → ${after}` });
expect(res.status()).toBe(200);
expect(after, '갱신 시각 변경').not.toBe(before);
});
await test.step('3. 목록 = API(VM/K8S 배포 항목)', async () => {
await showAllRows(f);
const api = refreshed; // 화면이 받은 응답(테스트 컨텍스트 직접 호출은 인증 헤더가 없어 0건, 2026-10-14)
const rows = (await f.locator('.tabulator-row').allInnerTexts()).map(clean);
test.info().annotations.push({ type: 'rows', description: `화면 ${rows.length} / API ${api.length}: ${rows.slice(0, 6).join(' || ')}` });
const total = Number((await f.locator('body').innerText()).match(/of (\d+) rows/)?.[1] ?? rows.length); // 페이지 크기 최대 10
test.info().annotations.push({ type: 'total', description: `화면 전체 ${total}건 / API ${api.length}건` });
expect(total).toBe(api.length);
expect(rows.some((r) => /^VM /.test(r)) || rows.some((r) => /^K8S /.test(r)), 'VM/K8S 배포 항목').toBe(true);
});
});tests/iv5/IV5_S22.spec.ts · 53줄 · 해시 619a390d · 시나리오 보기
// IV5_S22: TC-APP-018 애플리케이션 Rating 제출 [정상, 공통]
// v0.6 TC-ID: TC-APP-APPS-04 — 기존 spec(mcmp-e2e mc-web-console/specs/sw/TC-APP-APPS-04-rating-제출.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Applications > App Status → 배포 항목 행의 Rating → Application Rating 모달(#rating-modal) → Submit
// 시작 상태: admin(mcmp) 로그인, ws01/default. Rating 대상 배포 항목 존재
// 절차: 1.App Status 첫 배포 항목의 Rating 2.별점 4 선택 3.Category Performance · Comment · 이름 · 이메일 입력 4.Submit 5.SW Catalogs 목록의 해당 Catalog Rating 점수·건수 갱신
// 기대결과: Rating 제출 성공 메시지, Rating 점수 또는 건수 갱신
// 비고: Rating 버튼은 설치 상태 항목에서만 활성. 대상이 없으면 SKIP(2026-10-14 stage: 전 항목 Uninstalled). Submit 은 별점 + Category·의견·이름·이메일이 모두 있어야 활성화(2026-10-14 FE 확인)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoApp, STATUS_URL, showAllRows, catalogList, collectToasts, toastGone } from '../../lib/iv5-app';
test('IV5_S22 TC-APP-018 애플리케이션 Rating 제출', async ({ page }) => {
await loginAsAdmin(page);
// 목록 데이터는 화면이 받은 응답으로(테스트 컨텍스트 직접 호출은 인증 헤더가 없어 0건, 2026-10-14)
const resP = page.waitForResponse((r) => /\/api\/applications\/status\/groups/.test(r.url()), { timeout: 90_000 });
const f = await gotoApp(page, STATUS_URL, /status/);
const groups = ((await (await resP).json().catch(() => ({})))?.data ?? []) as any[];
// Rating 버튼은 설치된(Uninstalled 아닌) 항목에서만 활성(2026-10-14: 전 항목 Uninstalled → 비활성)
const target = groups.find((g) => !/UNINSTALL|FAIL/i.test(String(g.status))); // UNINSTALLED·FAILED 는 Rating 비활성(2026-10-14)
test.info().annotations.push({ type: 'statuses', description: groups.map((g) => `${g.applicationName}:${g.status}`).join(', ') });
test.skip(!target, `Rating 대상(설치 상태) 배포 항목 없음 — 전 ${groups.length}건 Uninstalled/Failed, Rating 버튼 비활성`);
const before = (await catalogList(page)).find((c) => c.id === target!.catalogId);
test.info().annotations.push({ type: 'target', description: `${target!.applicationName} (catalogId ${target!.catalogId}) / 이전 Rating ${before?.averageRating ?? 0} (${before?.ratingCount ?? 0})` });
const m = f.locator('#rating-modal');
await test.step(`1. ${target!.applicationName} 행 Rating → Application Rating`, async () => {
// 대상 행이 2페이지 이후일 수 있다(페이지 크기 최대 10) → 활성 Rating 버튼이 보일 때까지 Next
await showAllRows(f);
const btn = f.locator('.tabulator-row #rating-btn:not([disabled])').first();
for (let i = 0; i < 6 && !(await btn.count()); i++) { await f.getByRole('button', { name: 'Next', exact: true }).click(); await page.waitForTimeout(800); }
test.info().annotations.push({ type: 'row', description: (await btn.locator('xpath=ancestor::div[contains(@class,"tabulator-row")]').innerText()).replace(/\s+/g, ' ') });
await btn.click();
await expect(m.getByText('Application Rating')).toBeVisible();
});
await test.step('2~4. 별점 4 · Category · Comment · 이름 · 이메일 → Submit', async () => {
await m.locator('.rating-stars span.star').nth(3).click();
await m.locator('select').first().selectOption({ label: 'Performance' });
await m.getByPlaceholder('Enter detailed comments').fill('IV5_S22 rating test');
await m.getByPlaceholder('Enter your name').fill('mcmp');
await m.getByPlaceholder('Enter your email').fill('mcmp@mcmp.test');
await toastGone(f);
await m.getByRole('button', { name: 'Submit', exact: true }).click();
const t = await collectToasts(page, f, 5000);
test.info().annotations.push({ type: 'submit', description: t.join(' | ') || '(토스트 없음)' });
expect(t.join(' ')).toMatch(/Rating submitted successfully/);
});
await test.step('5. Catalog Rating 점수·건수 갱신', async () => {
const after = (await catalogList(page)).find((c) => c.id === target!.catalogId);
test.info().annotations.push({ type: 'after', description: `${after?.averageRating ?? 0} (${after?.ratingCount ?? 0})` });
expect(Number(after?.ratingCount ?? 0)).toBe(Number(before?.ratingCount ?? 0) + 1);
});
});tests/iv5/IV5_S23.spec.ts · 79줄 · 해시 62189d52 · 시나리오 보기
// IV5_S23: TC-APP-015 VM 다중 배포 (Clustering) [정상, AWS]
// v0.6 TC-ID: TC-APP-DEP-02 — 기존 spec(mcmp-e2e integration/specs/05-scenarios/c4-app-deploy-infra.spec.ts Step4, mc-web-console/specs/sw/TC-APP-DEP-02-vm-clustering.spec.ts)은 절차만 참고(사용자 결정 2026-10-14)
// 화면: Applications > SW Catalogs → Deploy → Application Installation(#install-form) → Spec Check → Deploy → App Status
// 시작 상태: admin(mcmp) 로그인, ws01/default. Workflow 로 만든 MCI(VM 2대 이상, Running), Built-in Elasticsearch Catalog
// 절차: 1.Deploy 2.Target Infra VM · Namespace · Infra ID(WF_CLUSTER_INFRA) 선택 3.Deployment Target VM · Clustering 4.VM 2대 이상 선택 5.Application Elasticsearch · Port 9200
// 6.Spec Check(사양 미달 confirm 은 수락) → Deploy 7.App Status 에서 선택 VM 들의 Elasticsearch 가 진행 후 Running(성공)
// 입력값(시트): Catalog Elasticsearch / Target VM Clustering / MCI VM 2대 이상 / Cluster node count 2 이상
// 기대결과: Elasticsearch clustering 배포 요청 생성, 선택한 VM 들에 node 구성, Apps Status 에서 배포 성공
// 비고: 💰 VM 2대 이상 MCI 가 필요(WF 로 생성) → 실행 보류(사용자 결정 2026-10-14). 대상 Infra 는 환경변수 WF_CLUSTER_INFRA(기본 없음 → SKIP)
// Clustering 라디오는 Redis/Elasticsearch 카탈로그·VM 대상에서만 표시(FE 확인)
// 2026-10-16 실행: WF multi-csp-vm-deploy 로 mwc1(AWS t3.medium ×2, gc1-aws-1·2, Image img1) 생성 후 WF_CLUSTER_INFRA=mwc1. 1차 실패 — #vm-namespace 가 disabled(고정) → 값 확인으로 수정
// 2차 실행 FAIL: 배포 로그 'All VM deployments successful' 이후 두 VM 모두 Stopped/Unhealthy. Terminal 확인: 컨테이너 Exited(78),
// 'bootstrap checks failed [1]', VM vm.max_map_count=65530(ES 요구 262144) — 설치가 커널 설정을 맞추지 않음(결함 후보). 7단계는 상태 재조회로 보강
// Clustering 에서 #vm-name 은 선택한 VM 이 칩(X)으로 쌓이고 목록에서 빠짐(2026-10-16 프로브). 정리: multi-csp-vm-cleanup(INFRA_ID_LIST=mwc1)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoApp, CATALOG_URL, STATUS_URL, collectToasts } from '../../lib/iv5-app';
import { WORKSPACE, DEFAULT_PROJECT } from '../../lib/data';
const INFRA = process.env.WF_CLUSTER_INFRA ?? '';
const NS = process.env.WF_CLUSTER_NS ?? DEFAULT_PROJECT;
test('IV5_S23_AWS TC-APP-015 VM 다중 배포 (Clustering)', async ({ page }) => {
test.setTimeout(40 * 60_000);
test.skip(!INFRA, 'WF_CLUSTER_INFRA 미지정 — VM 2대 이상 MCI 준비 전 실행 보류(사용자 결정 2026-10-14)');
await loginAsAdmin(page);
const f = await gotoApp(page, CATALOG_URL, /swcatalog/);
const m = f.locator('#install-form');
await test.step('1~2. Deploy → VM · Namespace · Infra ID', async () => {
await f.getByRole('button', { name: 'Deploy', exact: true }).click();
await expect(m.getByText('Application Installation')).toBeVisible();
await m.locator('#infra').selectOption({ label: 'VM' });
// 2026-10-16: Namespace 는 헤더 Project 로 고정(disabled, 'Deployment targets are scoped to ws01 / default') → 값만 확인
if (await m.locator('#vm-namespace').isDisabled()) await expect(m.locator('#vm-namespace')).toHaveValue(NS);
else await m.locator('#vm-namespace').selectOption({ label: NS });
await m.locator('#vm-mci').selectOption({ label: INFRA });
});
const vms: string[] = [];
await test.step('3~5. VM · Clustering · VM 2대 이상 · Elasticsearch · Port 9200', async () => {
await m.locator('#vm-target-single').check();
await m.locator('select').filter({ has: f.locator('option', { hasText: /Elasticsearch/ }) }).first().selectOption({ label: (await m.locator('option', { hasText: /Elasticsearch/ }).first().innerText()).trim() });
await m.locator('#Clustering').check();
const vmSel = m.locator('#vm-name');
for (let i = 0; i < 2; i++) {
const opt = (await vmSel.locator('option').allInnerTexts()).map((o) => o.trim()).find((o) => o && o !== 'Select VM')!;
await vmSel.selectOption({ label: opt }); vms.push(opt);
}
await m.getByPlaceholder('8080').fill('9200');
test.info().annotations.push({ type: 'vms', description: vms.join(', ') });
expect(vms.length).toBeGreaterThanOrEqual(2);
});
await test.step('6. Spec Check → Deploy', async () => {
await m.getByRole('button', { name: 'Spec Check' }).click();
test.info().annotations.push({ type: 'spec-check', description: (await collectToasts(page, f, 15_000)).join(' | ') });
const [sub] = await Promise.all([
page.waitForResponse((r) => /deployment-submissions\/VM/.test(r.url()) && r.request().method() === 'POST', { timeout: 60_000 }),
m.getByRole('button', { name: 'Deploy', exact: true }).click(),
]);
test.info().annotations.push({ type: 'submit', description: `${sub.status()} ${(await sub.text()).slice(0, 300)}` });
expect(sub.status()).toBeLessThan(300);
await expect(m.getByText('Deployment completed.')).toBeVisible({ timeout: 30 * 60_000 });
});
await test.step('7. App Status: 선택 VM 들의 Elasticsearch Running', async () => {
// 2026-10-16: 배포 직후 상태는 SUCCESS(배포 완료) → 이후 RUNNING/STOPPED 로 바뀜. 1차 실행에서 SUCCESS 를 바로 읽고 판정 → 최대 5분 재조회
let es: any[] = [];
for (let i = 0; i < 6; i++) {
const resP = page.waitForResponse((r) => /\/api\/applications\/status\/groups/.test(r.url()), { timeout: 90_000 });
await gotoApp(page, STATUS_URL, /status/, NS);
const groups = ((await (await resP).json())?.data ?? []) as any[];
es = groups.filter((g) => /Elasticsearch/.test(g.applicationName) && g.mciId === INFRA);
test.info().annotations.push({ type: `status-${i}`, description: es.map((g) => `${g.vmId}:${g.status}`).join(', ') });
if (es.length && es.every((g) => /RUNNING|STOPPED|FAIL/i.test(g.status))) break;
await page.waitForTimeout(50_000);
}
expect(es.filter((g) => /RUNNING/i.test(g.status)).length).toBeGreaterThanOrEqual(2);
});
});tests/iv5/IV5_S24.spec.ts · 35줄 · 해시 57f7eb94 · 시나리오 보기
// IV5_S24: TC-APP-024 등록된 Object Storage 조회·선택 [정상, Alibaba]
// v0.6 TC-ID: 없음(mc-application-manager 시트에만 있는 TC, 2026-10-15 작성)
// 화면: Applications > SW Catalogs → Deploy → Application Installation → [JupyterLab for Object Storage] → Object Storage Configuration
// 시작 상태: admin(mcmp) 로그인, ws01/default. 시험 Project(default)에 Object Storage 등록 — Data Migrations 로 만든 Alibaba bucket mcmp-e2e-v07-osam(Tumblebug objectStorage)
// 절차: 1.Deploy → VM · Infra mc3 · VM g3-1 · JupyterLab for Object Storage 2.Registered Object Storage Refresh(GET /applications/object-storages?namespace=default) 3.시험 버킷 선택
// 입력값(시트): Registered Object Storage 시험 버킷
// 기대결과: 선택 Project 의 등록 버킷 목록 표시, 사용 가능한 시험 버킷 선택 가능
// 비고: 시험 버킷은 준비 유틸 wsl/probe/obj-prep.spec.ts 로 생성(2026-10-15, Alibaba ap-northeast-2, 객체 10개). Deploy 는 누르지 않음
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openJupyterDeploy, refreshStorages, OBJ_BUCKET, OBJ_NS } from '../../lib/iv5-objstore';
test('IV5_S24_Alibaba TC-APP-024 등록된 Object Storage 조회·선택', async ({ page }) => {
await loginAsAdmin(page);
const { m } = await test.step('1. Deploy → VM mc3/g3-1 · JupyterLab for Object Storage', () => openJupyterDeploy(page, 'VM'));
await test.step('2. Registered Object Storage Refresh → 등록 버킷 목록', async () => {
const r = await refreshStorages(page, m);
test.info().annotations.push({ type: 'storages', description: `HTTP ${r.status} / API ${r.data.map((s) => `${s.id}(${s.provider}/${s.region}/${s.status})`).join(', ')} / 화면 ${r.shown.join(', ')}` });
expect(r.status).toBe(200);
expect(r.shown.sort()).toEqual(r.data.map((s) => s.id).sort());
const mine = r.data.find((s) => s.id === OBJ_BUCKET);
expect(mine, `${OBJ_NS} 에 등록된 ${OBJ_BUCKET}`).toBeTruthy();
expect(String(mine.status)).toMatch(/available/i);
});
await test.step(`3. 시험 버킷 ${OBJ_BUCKET} 선택`, async () => {
await m.locator('#vmObjectStorageEnabled').check().catch(() => {});
const box = m.locator(`#vm-object-storage-${OBJ_BUCKET}`);
await expect(box).toBeEnabled();
await box.check();
await expect(box).toBeChecked();
});
await m.getByRole('button', { name: 'Cancel' }).first().click().catch(() => {});
});tests/iv5/IV5_S25.spec.ts · 57줄 · 해시 ce7516d4 · 시나리오 보기
// IV5_S25: TC-APP-025 Object Storage 연결 사전 확인(Storage Check) [정상, Alibaba]
// v0.6 TC-ID: 없음(mc-application-manager 시트에만 있는 TC, 2026-10-15 작성)
// 화면: SW Catalogs → Deploy → JupyterLab for Object Storage → Object Storage Configuration → Storage Check
// 시작 상태: admin(mcmp) 로그인, ws01/default. IV5_S24 완료(시험 버킷 등록), 대상 VM mc3/g3-1 Running
// 절차: 1.시험 버킷 · Access Mode Read and write · Jupyter Access Token(12자 이상) → Storage Check 2.접근 불가 버킷(등록 상태 Failed 인 버킷)만 선택 → Storage Check 3.접근 불가 상태로 Deploy → 차단
// 입력값(시트): Access Mode Read and write / Token 시험용 12자 이상 / 정상 버킷·접근 불가 시험 버킷
// 기대결과: 정상 버킷은 검사 통과, 접근 불가 조건은 오류 표시·배포 차단
// 비고: 접근 불가 버킷은 default 에 등록된 상태 Failed 버킷(2026-10-15: mcmp-test-nhn-*). 없으면 그 단계는 SKIP
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openJupyterDeploy, refreshStorages, fillObjectStorage, storageCheck, OBJ_BUCKET } from '../../lib/iv5-objstore';
import { collectToasts, toastGone } from '../../lib/iv5-app';
test('IV5_S25_Alibaba TC-APP-025 Object Storage 연결 사전 확인(Storage Check)', async ({ page }) => {
test.setTimeout(10 * 60_000);
await loginAsAdmin(page);
const { f, m } = await openJupyterDeploy(page, 'VM');
const list = await refreshStorages(page, m);
const bad = list.data.find((s) => !/available/i.test(String(s.status)));
await test.step(`1. 정상 버킷 ${OBJ_BUCKET} · Read and write · Token → Storage Check 통과`, async () => {
await fillObjectStorage(m, { buckets: [OBJ_BUCKET], mode: 'READ_WRITE' });
const r = await storageCheck(page, f, m);
test.info().annotations.push({ type: 'check-ok', description: `HTTP ${r.status} ${JSON.stringify(r.body).slice(0, 500)} / ${r.toasts.join(' | ')}` });
expect(r.toasts.join(' ')).toMatch(/Object Storage check succeeded/);
expect(r.body?.data?.success ?? r.body?.success).toBe(true);
});
await test.step(`2~3. 접근 불가 버킷${bad ? ` ${bad.id}(${bad.status})` : ''} → Storage Check 실패 · Deploy 차단`, async () => {
test.skip(!bad, '접근 불가(상태 비정상) 등록 버킷 없음');
// 상태가 Failed 인 버킷은 체크박스가 비활성(2026-10-15) → 선택 자체가 차단되면 그것을 '오류 표시·배포 차단'으로 본다
const box = m.locator(`#vm-object-storage-${bad!.id}`);
const disabled = await box.isDisabled();
const label = (await m.locator(`label[for="vm-object-storage-${bad!.id}"]`).innerText().catch(() => '')).replace(/\s+/g, ' ');
test.info().annotations.push({ type: 'bad-bucket', description: `${bad!.id} 체크박스 ${disabled ? '비활성(선택 불가)' : '선택 가능'} / 라벨 ${label}` });
if (!disabled) {
await fillObjectStorage(m, { buckets: [bad!.id], mode: 'READ_WRITE' });
const r = await storageCheck(page, f, m);
test.info().annotations.push({ type: 'check-bad', description: `HTTP ${r.status} ${JSON.stringify(r.body).slice(0, 500)} / ${r.toasts.join(' | ')}` });
expect(r.toasts.join(' ')).toMatch(/Object Storage check failed/);
} else {
await fillObjectStorage(m, { buckets: [], mode: 'READ_WRITE' }); // 접근 가능한 버킷을 하나도 고르지 못한 상태
}
await toastGone(f);
const deploy = m.getByRole('button', { name: 'Deploy', exact: true });
if (await deploy.isDisabled()) {
test.info().annotations.push({ type: 'deploy-blocked', description: 'Deploy 버튼 비활성(배포 차단)' });
} else {
await deploy.click();
const t = await collectToasts(page, f, 5000);
test.info().annotations.push({ type: 'deploy-blocked', description: t.join(' | ') || '(토스트 없음)' });
expect(t.join(' ')).toMatch(/Object Storage|Spec Check|before deploy|Select at least one/i);
}
await expect(m.getByText('Deployment completed.')).toHaveCount(0);
});
await m.getByRole('button', { name: 'Cancel' }).first().click().catch(() => {});
});tests/iv5/IV5_S26.spec.ts · 60줄 · 해시 af800887 · 시나리오 보기
// IV5_S26: TC-APP-026 VM Jupyter Object Storage 연동 배포·읽기/쓰기 [정상, Alibaba]
// v0.6 TC-ID: 없음(mc-application-manager 시트에만 있는 TC, 2026-10-15 작성)
// 화면: SW Catalogs → Deploy → JupyterLab for Object Storage(VM mc3/g3-1) → Storage Check → Spec Check → Deploy → Jupyter(http://<VM 공인 IP>:<port>)
// 시작 상태: admin(mcmp) 로그인, ws01/default. IV5_S25 정상 조건, 시험 VM mc3/g3-1 Running(기존 Infra 재사용 — 사용자 결정 2026-10-15)
// 절차: 1.A: Read only · 포트 8888 · 테스트 PC IP/32 허용으로 배포 2.Jupyter(token) 커널에서 기본 notebook helper(storages / list_objects / download / upload)로 A 읽기 가능·쓰기 거부
// 3.A Uninstall(같은 VM 에 같은 카탈로그는 하나만 설치 가능) 4.B: Read and write · Prefix am-test/ · 포트 8889 로 배포 5.B 읽기·쓰기 가능 확인
// 입력값(시트): Target VM / 시험 버킷 / Access Mode Read only · Read and write / Token 12자 이상
// 기대결과: VM Jupyter 배포·접속 성공, Read only 는 읽기 가능·쓰기 거부, Read and write 는 읽기·쓰기 가능
// 비고: 💰 VM 은 기존 mc3 사용(추가 VM 없음). B 는 IV5_S28(Prefix 권한)에서 이어 쓰고 IV5_S29 에서 Uninstall
// 2026-10-15 1차: A·B 동시 배포 시도 → B 가 'Skipping deployment on VMs with existing installations: g3-1' 로 실패(제품 동작: VM 당 카탈로그 1개) → 순차로 변경
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { deployJupyterVm, jupyterRun, jupyterVmIp, myPublicIp, uninstallJupyterVm, jupyterTarget, JUPYTER_APP_NAME, APPS, OBJ_BUCKET, OBJ_MCI, OBJ_VM } from '../../lib/iv5-objstore';
import { appStatus } from '../../lib/iv5';
test('IV5_S26_Alibaba TC-APP-026 VM Jupyter Object Storage 연동 배포·읽기/쓰기', async ({ page }) => {
test.setTimeout(70 * 60_000);
await loginAsAdmin(page);
const ip = await jupyterVmIp(page);
const cidr = `${await myPublicIp(page)}/32`;
test.info().annotations.push({ type: 'target', description: `${OBJ_MCI}/${OBJ_VM} ip=${ip} / 허용 CIDR ${cidr}` });
expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
const deploy = async (k: 'ro' | 'rw') => {
const a = APPS[k];
const r = await deployJupyterVm(page, { buckets: [OBJ_BUCKET], mode: a.mode, prefix: a.prefix, port: a.port, cidr });
test.info().annotations.push({ type: `deploy-${k}`, description: `spec: ${r.specToasts.join(' | ')} / submit ${r.submit} / done=${r.done} ${r.status} / ${r.toasts.join(' | ')}` });
expect(r.done, `${k} Deployment completed`).toBe(true);
};
await test.step('1. A: Read only · 포트 8888 배포(이미 Running 이면 재사용)', async () => {
const st = await appStatus(page, jupyterTarget(), JUPYTER_APP_NAME, 1);
if (/running/i.test(st)) test.info().annotations.push({ type: 'deploy-ro', description: `기존 A(Running) 재사용` });
else await deploy('ro');
});
await test.step('2. A Read only(8888): 읽기 가능 · 쓰기 거부', async () => {
const code = `objs = list_objects(alias, '')\nprint('LIST', len(objs))\nkey = objs.iloc[0]['key'] if len(objs) else None\nprint('READ', len(download(alias, key)) if key else -1)\nimport tempfile, os\np = tempfile.mktemp(); open(p,'w').write('ro-test')\ntry:\n upload(alias, p, 'am-test/ro-write.txt'); print('WRITE OK')\nexcept Exception as e:\n print('WRITE DENIED', type(e).__name__, str(e)[:120])\n`;
const r = await jupyterRun(page, ip, APPS.ro.port, code);
test.info().annotations.push({ type: 'helper', description: `notebook helper 함수: ${[...r.helper.matchAll(/def (\w+)/g)].map((x) => x[1]).join(', ')}` });
test.info().annotations.push({ type: 'ro', description: `${r.out.slice(0, 800)} ${r.err}` });
expect(r.out).toMatch(/LIST [1-9]/);
expect(r.out).toMatch(/READ [1-9]/);
expect(r.out).toMatch(/WRITE DENIED/);
});
await test.step('3. A Uninstall(VM 당 같은 카탈로그 1개)', async () => {
const u = await uninstallJupyterVm(page);
test.info().annotations.push({ type: 'uninstall-ro', description: u.seen.join(' → ') });
expect(u.done).toBe(true);
});
await test.step('4. B: Read and write · Prefix am-test/ · 포트 8889 배포', () => deploy('rw'));
await test.step('5. B Read and write(8889, Prefix am-test/): 쓰기 · 읽기 가능', async () => {
const code = `import tempfile\np = tempfile.mktemp(); open(p,'w').write('rw-test-iv5s26')\nupload(alias, p, 'am-test/iv5_s26.txt'); print('WRITE OK')\nprint('READBACK', download(alias, 'am-test/iv5_s26.txt').decode())\nprint('LIST', len(list_objects(alias, 'am-test/')))\n`;
const r = await jupyterRun(page, ip, APPS.rw.port, code);
test.info().annotations.push({ type: 'rw', description: `${r.out.slice(0, 800)} ${r.err}` });
expect(r.err).toBe('');
expect(r.out).toMatch(/WRITE OK/);
expect(r.out).toMatch(/READBACK rw-test-iv5s26/);
});
});tests/iv5/IV5_S27.spec.ts · 68줄 · 해시 461f5265 · 시나리오 보기
// IV5_S27: TC-APP-027 K8S Jupyter Object Storage 연동 배포·읽기/쓰기 [정상, AWS]
// v0.6 TC-ID: 없음(mc-application-manager 시트에만 있는 TC, 2026-10-15 작성)
// 화면: SW Catalogs(Project p1) → Deploy → Target Infra K8S · ClusterName → JupyterLab for Object Storage → Storage Class/Notebook volume · Allowed IPv4 CIDR(워커 SG TCP 30880)
// → Ingress Host(<node IP>.nip.io, DNS 이름 필수) → Object Storage(버킷·Access Mode·Token) → Storage Check → Spec Check → Deploy → Jupyter(http://<node IP>.nip.io:30880)
// 시작 상태: admin(mcmp) 로그인, ws01/p1. 클러스터·워커 Ready(K8s CSP 사이클 k1s/g1s — 사용자 결정 2026-10-15: CSP 하나씩), 사용 가능한 Storage Class(gp2)
// 절차: 1.K8S · k1s · JupyterLab 선택, Storage Class 첫 값 · 1GiB · 테스트 PC IP/32 2.시험 버킷 Read only · Token → Storage Check → Spec Check → Deploy
// 3.kubectl 로 Jupyter Pod Ready 확인(IV4_S19 kubeconfig + 토큰) 4.Jupyter(token) 커널에서 notebook helper 로 목록·읽기 가능, 쓰기 거부 확인
// 입력값(시트): Target Infra K8S / 시험 버킷 / Access Mode / Storage Class·Size(표시된 사용 가능 값) / Token 12자 이상
// 기대결과: Pod Ready 및 Jupyter 접속 성공, Access Mode 에 맞는 읽기·쓰기 동작
// 비고: p1 에 등록된 버킷은 다른 주체의 버킷(mcmp07-*)뿐이고 Data Migrations 로 만든 시험 버킷은 ns default 에만 등록된다(2026-10-15 확인)
// → K8S 는 Read only 로만 확인(남의 버킷에 쓰지 않음, OBJ_K8S_BUCKET 으로 변경 가능). Read and write 동작은 VM(IV5_S26)에서 확인. 배포 앱은 IV5_S29 에서 Uninstall
// 2026-10-15 AWS(k1s) 1차: BLOCKED(환경) — Storage Check SUCCESS · 제출 OK, 그러나 PVC mcmp-jupyter-37(gp2) Pending → Pod 실패.
// 원인: EKS 의 ebs-csi-controller CrashLoopBackOff('no EC2 IMDS role found', SA ebs-csi-controller-sa 에 IRSA/Pod Identity 없음) — 클러스터 생성 측(tumblebug/spider EKS 애드온 IAM) 한계
// 앱 로그: 'K8s Jupyter installation failed; inspect Pod events and AM Gateway connectivity' / 제출 상태: 'Cluster or registry access was denied'
// → 근본원인: spider 가 IMDS hop limit 2 를 NodeGroup 생성 직후 node 에만 적용 — Scale 로 새로 뜬 node 는 1. cb-spider 수정(Launch Template, MZC-CSC fork fix/aws-eks-nodegroup-imds-hoplimit)을 stage 에 반영 후 재실행 PASS
// 접속 node: Ingress Controller 가 source-IP 보존(Local) → Ingress Controller Pod 가 있는 node IP 로 Host 지정
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { deployJupyterK8s, jupyterRun, myPublicIp, JUPYTER_APP_NAME } from '../../lib/iv5-objstore';
import { appStatus } from '../../lib/iv5';
import { copyKubeconfig, consoleOp, writeTokenKubeconfig, kubectl, K8S_CYCLE_CLUSTER } from '../../lib/iv4-kubectl';
import fs from 'node:fs';
const PRJ = process.env.K8S_PROJECT ?? 'p1';
const CLUSTER = process.env.K8S_CLUSTER ?? K8S_CYCLE_CLUSTER(1);
const BUCKET = process.env.OBJ_K8S_BUCKET ?? 'mcmp07-aws';
// GCP(2026-10-15): K8S_CSP=GCP K8S_PROJECT=p4 K8S_CLUSTER=k4s OBJ_K8S_BUCKET=mcmp-e2e-v07-os4-gke(p4 에 등록한 시험 버킷)
test(`IV5_S27_${process.env.K8S_CSP ?? 'AWS'} TC-APP-027 K8S Jupyter Object Storage 연동 배포·읽기/쓰기`, async ({ page }) => {
test.setTimeout(60 * 60_000);
await loginAsAdmin(page);
const k = await copyKubeconfig(page, PRJ, CLUSTER);
const t = await consoleOp(page, 'GetK8sClusterToken', { nsId: PRJ, k8sClusterId: CLUSTER });
const { dir, f } = writeTokenKubeconfig(k.clip, (t.data?.execCredential ?? t.data)?.status?.token);
try {
// Ingress Controller 는 source-IP 보존(Local) — Ingress Controller Pod 가 있는 node 로만 접속된다(화면 안내, 2026-10-15 확인). 아직 없으면(첫 배포) 첫 node
const ingNode = kubectl(f, ['get', 'pods', '-A', '-l', 'app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller', '-o', 'jsonpath={.items[*].spec.nodeName}']).trim().split(/\s+/)[0] ?? '';
const nodeIp = kubectl(f, ['get', 'nodes', ...(ingNode ? [ingNode] : []), '-o', ingNode ? 'jsonpath={.status.addresses[?(@.type=="ExternalIP")].address}' : 'jsonpath={.items[0].status.addresses[?(@.type=="ExternalIP")].address}']).trim();
const cidr = `${await myPublicIp(page)}/32`;
const host = `${nodeIp}.nip.io`; // Ingress Host(DNS 이름 필수) — nip.io 로 node 공인 IP 에 매핑
test.info().annotations.push({ type: 'target', description: `${PRJ}/${CLUSTER} node ${nodeIp}(ingress ${ingNode || '없음'}) / 버킷 ${BUCKET} / 허용 CIDR ${cidr}` });
expect(nodeIp).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
await test.step('1~2. K8S Jupyter(Read only) 배포(이미 Running 이면 재사용)', async () => {
const st = await appStatus(page, { key: `(${PRJ} / ${CLUSTER})`, project: PRJ }, JUPYTER_APP_NAME, 1);
if (/running/i.test(st)) { test.info().annotations.push({ type: 'deploy', description: '기존 Jupyter(Running) 재사용' }); return; }
const r = await deployJupyterK8s(page, { project: PRJ, cluster: CLUSTER, buckets: [BUCKET], mode: 'READ_ONLY', sizeGi: '1', cidr, ingressHost: host });
test.info().annotations.push({ type: 'deploy', description: `StorageClass ${r.storageClasses.join(",")} 선택 ${r.scSelected} / spec: ${r.specToasts.join(' | ')} / submit ${r.submit} / done=${r.done} ${r.status} / ${r.toasts.join(' | ')}` });
expect(r.done, 'Deployment completed').toBe(true);
});
await test.step('3. Jupyter Pod Ready(kubectl)', async () => {
await expect.poll(() => kubectl(f, ['get', 'pods', '-A', '-o', 'wide']).split('\n').filter((l) => /jupyter/i.test(l)).join(' / '), { timeout: 10 * 60_000, intervals: [15_000] }).toMatch(/\s(\d+)\/\1\s+Running/); // Jupyter + gateway 사이드카(2/2, 2026-10-15)
const pods = kubectl(f, ['get', 'pods,svc,pvc', '-A']).split('\n').filter((l) => /jupyter|NAME/i.test(l)).join(' / ');
test.info().annotations.push({ type: 'pods', description: pods.replace(/\s+/g, ' ').slice(0, 900) });
});
await test.step('4. Jupyter(30880): 읽기 가능 · 쓰기 거부', async () => {
const code = `objs = list_objects(alias, '')\nprint('LIST', len(objs))\nkey = objs.iloc[0]['key'] if len(objs) else None\nprint('READ', len(download(alias, key)) if key else -1)\nimport tempfile\np = tempfile.mktemp(); open(p,'w').write('ro-test')\ntry:\n upload(alias, p, 'am-test/iv5_s27_ro.txt'); print('WRITE OK')\nexcept Exception as e:\n print('WRITE DENIED', type(e).__name__, str(e)[:120])\n`;
const r = await jupyterRun(page, host, '30880', code);
test.info().annotations.push({ type: 'ro', description: `${r.out.slice(0, 800)} ${r.err}` });
expect(r.out).toMatch(/STORAGES .*READ_ONLY/);
expect(r.out).toMatch(/LIST \d+/);
expect(r.out).toMatch(/WRITE DENIED/);
});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});tests/iv5/IV5_S28.spec.ts · 44줄 · 해시 8d072c8a · 시나리오 보기
// IV5_S28: TC-APP-028 Object Storage 접근 모드·Prefix 권한 확인 [정상, Alibaba]
// v0.6 TC-ID: 없음(mc-application-manager 시트에만 있는 TC, 2026-10-15 작성)
// 화면: IV5_S26 에서 배포한 VM Jupyter B(Read and write · Allowed Object Prefix am-test/ · 포트 8889) → Jupyter(token) 커널
// 시작 상태: admin(mcmp) 로그인, ws01/default. IV5_S26 완료(B Running, am-test/iv5_s26.txt 존재), 시험 VM mc3/g3-1 Running
// 절차: 1.Jupyter 커널에서 notebook helper 로 grant 확인(accessMode READ_WRITE, prefix am-test/) 2.허용 Prefix: am-test/ 쓰기·읽기·목록
// 3.Prefix 밖: 버킷 루트 목록, 루트 객체 읽기, outside/ 쓰기 → 거부 확인
// 입력값(시트): Allowed Object Prefix am-test/, Access Mode Read only / Read and write(Read only 동작은 IV5_S26 A 에서 확인)
// 기대결과: Access Mode 에 맞게 읽기·쓰기 허용/차단, 허용 Prefix 밖 접근 차단
// 비고: K8S 는 p1 에 시험 버킷이 없어(다른 주체 버킷만 등록) Read only 로만 확인(IV5_S27). 시험 파일은 IV5_S29 에서 정리
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { jupyterRun, jupyterVmIp, APPS } from '../../lib/iv5-objstore';
const tryPy = (label: string, expr: string) => `try:\n _r = ${expr}\n print('${label} OK', (len(_r) if hasattr(_r, '__len__') else _r))\nexcept Exception as e:\n print('${label} DENIED', type(e).__name__, str(e)[:160])\n`;
test('IV5_S28_Alibaba TC-APP-028 Object Storage 접근 모드·Prefix 권한 확인', async ({ page }) => {
test.setTimeout(20 * 60_000);
await loginAsAdmin(page);
const ip = await jupyterVmIp(page);
const code = [
"import tempfile, inspect\np = tempfile.mktemp(); open(p,'w').write('iv5-s28')\n",
"print('PRESIGNED_SIG', str(inspect.signature(_presigned)))\n",
tryPy('IN_WRITE', "upload(alias, p, 'am-test/iv5_s28.txt')"),
tryPy('IN_READ', "download(alias, 'am-test/iv5_s28.txt')"),
tryPy('IN_LIST', "list_objects(alias, 'am-test/')"),
tryPy('OUT_LIST', "list_objects(alias, '')"),
tryPy('OUT_READ', "download(alias, 'iv5_s28_outside_probe.txt')"),
tryPy('OUT_WRITE', "upload(alias, p, 'outside/iv5_s28.txt')"),
].join('');
const r = await jupyterRun(page, ip, APPS.rw.port, code);
test.info().annotations.push({ type: 'prefix', description: `${r.out.slice(0, 2000)} ${r.err}` });
await test.step('1. grant: READ_WRITE · am-test/', async () => expect(r.out).toMatch(/"accessMode": "READ_WRITE", "prefix": "am-test\/"/));
await test.step('2. 허용 Prefix 쓰기·읽기·목록', async () => {
expect(r.out).toMatch(/IN_WRITE OK/);
expect(r.out).toMatch(/IN_READ OK 7/);
expect(r.out).toMatch(/IN_LIST OK [1-9]/);
});
await test.step('3. Prefix 밖 접근 차단', async () => {
expect(r.out).toMatch(/OUT_WRITE DENIED/);
expect(r.out).toMatch(/OUT_READ DENIED/);
// 루트 목록은 거부되거나, 허용 Prefix 아래만 보여야 한다
expect(r.out).toMatch(/OUT_LIST DENIED|OUT_LIST OK [0-9]/);
});
});tests/iv5/IV5_S29.spec.ts · 77줄 · 해시 3f81d492 · 시나리오 보기
// IV5_S29: TC-APP-029 Jupyter 삭제·연동 권한 해제 및 Object Storage 데이터 보존 [정상, Alibaba]
// v0.6 TC-ID: 없음(mc-application-manager 시트에만 있는 TC, 2026-10-15 작성)
// 화면: Jupyter B(8889) 커널(시험 파일 정리) → Applications > App Status(Uninstall) → Data Migrations > Generate(버킷 Browse objects)
// 시작 상태: admin(mcmp) 로그인, ws01/default. IV5_S28 완료(B Running, am-test/ 시험 파일 존재)
// 절차: 1.B 커널에서 am-test/ 시험 파일 목록 기록(삭제는 gateway 가 download/upload 만 지원 — 'Operation must be download or upload.', 2026-10-15)
// 2.App Status 에서 JupyterLab for Object Storage(mc3/g3-1) Uninstall → 제거 확인 3.Jupyter(8889) 접속 불가(연동 권한 해제)
// 4.Data Migrations 의 시험 버킷 Browse objects → 원본 파일(시험 데이터 Generate 결과) 보존 확인 5.같은 화면에서 am-test/ 시험 파일 Delete(정리)
// 입력값(시트): TC-APP-026·027 의 시험 Jupyter
// 기대결과: 앱 제거·연동 권한 해제, Object Storage 원본 파일 보존
// 비고: K8S Jupyter(IV5_S27, p1/k1s)는 K8s 사이클의 클러스터 삭제(CL_S01) 전에 App Status 에서 Uninstall — 이 spec 은 VM 대상. 시험 VM mc3 는 끝나면 사용자 확인 후 Suspend
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { CSPS } from '../../lib/data';
import { jupyterRun, jupyterVmIp, uninstallJupyterVm, jupyterTarget, JUPYTER_APP_NAME, APPS, OBJ_BUCKET } from '../../lib/iv5-objstore';
import { appStatus } from '../../lib/iv5';
import { openDm, selectCred, readObjects } from '../../lib/iv8-os';
test('IV5_S29_Alibaba TC-APP-029 Jupyter 삭제·연동 권한 해제 및 Object Storage 데이터 보존', async ({ page }) => {
test.setTimeout(30 * 60_000);
await loginAsAdmin(page);
const ip = await jupyterVmIp(page);
const running = /running/i.test(await appStatus(page, jupyterTarget(), JUPYTER_APP_NAME, 1));
await test.step('1. 시험 파일 목록 · Jupyter 삭제 시도(gateway 미지원 기록)', async () => {
if (!running) { test.info().annotations.push({ type: 'jupyter-delete', description: 'B 가 이미 제거됨(재실행) — 정리는 5단계에서' }); return; }
const code = "keys = list(list_objects(alias, 'am-test/')['key'])\nprint('KEYS', keys)\ntry:\n _presigned(alias, keys[0], 'delete'); print('DEL_PRESIGN OK')\nexcept Exception as e:\n print('DEL_PRESIGN', type(e).__name__, str(e)[:160])\n";
const r = await jupyterRun(page, ip, APPS.rw.port, code);
test.info().annotations.push({ type: 'jupyter-delete', description: `${r.out.slice(0, 1200)} ${r.err}` });
});
await test.step('2. Uninstall → App Status 제거', async () => {
if (!running) { test.info().annotations.push({ type: 'uninstall', description: '이미 Uninstalled(재실행)' }); return; }
const u = await uninstallJupyterVm(page);
test.info().annotations.push({ type: 'uninstall', description: u.seen.join(' → ') });
expect(u.done).toBe(true);
});
await test.step('3. Jupyter(8889) 접속 불가', async () => {
const res = await page.request.get(`http://${ip}:${APPS.rw.port}/api/status`, { timeout: 15_000 }).then((x) => `HTTP ${x.status()}`, (e) => `연결 실패 ${String(e).split('\n')[0].slice(0, 120)}`);
test.info().annotations.push({ type: 'jupyter-after', description: res });
expect(res).not.toMatch(/HTTP 200/);
});
await test.step('4. 버킷 원본 보존(Data Migrations Browse objects)', async () => {
const f = await openDm(page, 'Generate');
await selectCred(page, f, CSPS.find((c) => c.code === 'Alibaba')!);
const rows = await readObjects(page, f, OBJ_BUCKET);
expect(rows, '객체 목록').not.toBeNull();
expect(rows!.filter((x) => !/^am-test\//.test(x)).length, '원본 파일 보존').toBeGreaterThan(0);
});
await test.step('5. 시험 파일(am-test/) 정리 — Data Migrations Delete', async () => {
const f = await openDm(page, 'Generate');
await selectCred(page, f, CSPS.find((c) => c.code === 'Alibaba')!);
await f.locator(`button.bkt-browse-btn[data-name="${OBJ_BUCKET}"]`).first().click();
const table = f.locator('table:has(thead:has-text("Last Modified"))').first();
await table.waitFor({ state: 'visible', timeout: 30_000 });
await table.getByText('am-test/', { exact: false }).first().click();
await expect(table).toContainText('iv5_s2', { timeout: 15_000 });
const del = f.locator('button.bkt-object-delete-btn');
const done: string[] = [];
for (let i = 0; i < 5 && (await del.count()); i++) {
const row = del.first().locator('xpath=ancestor::tr[1]');
const key = (await row.innerText()).replace(/\s+/g, ' ').trim();
page.once('dialog', (d) => d.accept().catch(() => {}));
await del.first().click();
// 확인 모달 #bktObjectDeleteModal(2026-10-15) — 위험(btn-danger) 버튼으로 확정
const modal = f.locator('#bktObjectDeleteModal');
await modal.waitFor({ state: 'visible', timeout: 10_000 });
if (!i) test.info().annotations.push({ type: 'delete-modal', description: (await modal.innerText()).replace(/\s+/g, ' ').slice(0, 300) });
await modal.locator('button.btn-danger').last().click();
await modal.waitFor({ state: 'hidden', timeout: 30_000 });
await page.waitForTimeout(3000);
await f.locator('#bkt-object-refresh').click().catch(() => {});
await page.waitForTimeout(3000);
done.push(key);
}
test.info().annotations.push({ type: 'dm-delete', description: `삭제 ${done.join(' / ')} → 남은 Delete 버튼 ${await del.count()}개` });
expect(await del.count(), 'am-test/ 시험 파일 정리').toBe(0);
});
});