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

Test Specs โ€” IV5 SW ์„ค์น˜ (SW Catalog / App Status)

์ƒ์„ฑ: 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

IV5_S01

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);
  });
}

IV5_S02

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);
  });
}

IV5_S03

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);
  });
}

IV5_S04

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+/);
  });
}

IV5_S05

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(' | ')}` });
  });
}

IV5_S06

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 ๋กœ ํ™•์ธ)' });
    }
  });
}

IV5_S07

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(' | ') });
  });
}

IV5_S08

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);
  });
}

IV5_S09

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);
  });
});

IV5_S10

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');
  });
});

IV5_S11

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();
  });
});

IV5_S12

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))} (์‹œํŠธ ์˜ˆ์‹œ)` });
  });
});

IV5_S13

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);
  });
});

IV5_S14

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(); });
});

IV5_S15

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/);
  });
});

IV5_S16

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');
  });
});

IV5_S17

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/);
  });
});

IV5_S18

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);
  });
});

IV5_S19

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);
    });
  }
});

IV5_S20

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);
  });
});

IV5_S21

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);
  });
});

IV5_S22

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('[email protected]');
    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);
  });
});

IV5_S23

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);
  });
});

IV5_S24

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(() => {});
});

IV5_S25

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(() => {});
});

IV5_S26

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/);
  });
});

IV5_S27

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 });
  }
});

IV5_S28

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]/);
  });
});

IV5_S29

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);
  });
});
โš ๏ธ **GitHub.com Fallback** โš ๏ธ