Repository navigation
Test Specs WF
생성: 2026-10-22 · 기준: v07 설치 후 통합 검증(IV) · 원본: 여정별 계획서(Excel) ← Test Scenarios · IV 허브 · 기능 TC
mc-workflow-manager 기능 TC 의 Playwright(TypeScript) spec 소스입니다. 절마다 앵커는 #<TC-ID 소문자>(예: #tc-wf-001)이고, 제목 끝 (구 …) 는 이전 시나리오 번호입니다. 공용 헬퍼는 Test-Specs-Common. 비밀번호 등은 마스킹했습니다.
| TC | 구 번호 | 파일 | 줄 | 해시 |
|---|---|---|---|---|
| TC-WF-001 | IV3_S34 | tests/wf/TC-WF-001.spec.ts |
82 | 53663e60 |
| TC-WF-002 | IV3_S35 | tests/wf/TC-WF-002.spec.ts |
53 | ad4518f2 |
| TC-WF-003 | IV3_S36 | tests/wf/TC-WF-003.spec.ts |
55 | f707cca0 |
| TC-WF-004 | IV3_S33 | tests/wf/TC-WF-004.spec.ts |
44 | 77b23e1d |
| TC-WF-005 | IV3_S37 | tests/wf/TC-WF-005.spec.ts |
83 | 9432125f |
| TC-WF-006 | IV3_S38 | tests/wf/TC-WF-006.spec.ts |
68 | 51d91c81 |
| TC-WF-007 | IV3_S01 | tests/wf/TC-WF-007.spec.ts |
76 | 4c1d1407 |
| TC-WF-008 | IV3_S11 | tests/wf/TC-WF-008.spec.ts |
142 | 55d5d3a9 |
| TC-WF-009 | IV3_S08 | tests/wf/TC-WF-009.spec.ts |
75 | df6b3c4c |
| TC-WF-010 | IV3_S09 | tests/wf/TC-WF-010.spec.ts |
50 | 12e953ed |
| TC-WF-011 | IV3_S13 | tests/wf/TC-WF-011.spec.ts |
66 | 93b5b74f |
| TC-WF-012 | IV3_S42 | tests/wf/TC-WF-012.spec.ts |
45 | 6424fa8c |
| TC-WF-013 | IV3_S43 | tests/wf/TC-WF-013.spec.ts |
43 | 5cdf3fac |
| TC-WF-014 | IV3_S39 | tests/wf/TC-WF-014.spec.ts |
43 | fd712f78 |
| TC-WF-015 | IV3_S40 | tests/wf/TC-WF-015.spec.ts |
65 | 22d09a90 |
| TC-WF-016 | IV3_S41 | tests/wf/TC-WF-016.spec.ts |
52 | 6f9ebd68 |
| TC-WF-017 | IV3_S44 | tests/wf/TC-WF-017.spec.ts |
62 | 7c90f946 |
| TC-WF-018 | IV3_S45 | tests/wf/TC-WF-018.spec.ts |
62 | 1ed97d4d |
| TC-WF-019 | IV3_S46 | tests/wf/TC-WF-019.spec.ts |
110 | 5d8aca50 |
tests/wf/TC-WF-001.spec.ts · 82줄 · 해시 53663e60 · 시나리오 보기
// TC-WF-001 Workflow Engine(Jenkins) 수정 및 연동 확인 [정상, 공통]
// v0.6 TC-ID: TC-WF-FLOW-01 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-jenkins.spec.ts, deploy/tc/workflow/TC-WF-FLOW-01.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: mc-workflow-manager OSS 목록(:18083/web/oss/list) → SampleOss EDIT → Edit OSS 모달
// (OSS 관리는 콘솔 메뉴에 없어 시트 입력값의 Server URL 화면으로 직접 진입, 2026-10-14 확인)
// 시작 상태: admin(mcmp) 로그인. Jenkins 서버 구동
// 절차: 1.SampleOss 의 EDIT 에서 Jenkins URL/ID/인증값 입력 2.Connection Check 후 Edit 로 저장 3.재조회한 접속 정보와 연결 결과 확인
// 입력값: 현재 SampleOss 접속 정보를 그대로 다시 입력(공용 설정이므로 값은 바꾸지 않음 — 사용자 결정 2026-10-14). 시트 값: ID admin / PW(시트 참고)
// 비밀번호는 코드에 두지 않고 WF_OSS_PW 환경변수, 없으면 현재 저장값(목록 API 의 base64 값)을 디코딩해 쓴다
// 기대결과: Jenkins 연결 확인 성공, 수정한 접속 정보 저장 및 재조회 일치
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { clean, WF_ORIGIN } from '../../lib/iv3-wf';
const OSS_LIST = `${WF_ORIGIN}/web/oss/list`;
const OSS_API = `${WF_ORIGIN}/oss/list`;
test('TC-WF-001 Workflow Engine(Jenkins) 수정 및 연동 확인 (구 IV3_S34)', async ({ page, dialogs }) => {
const apiCalls: string[] = [];
page.on('response', async (r) => {
const u = r.url();
if (u.includes(':18083') && r.request().method() !== 'GET' && r.request().method() !== 'OPTIONS') apiCalls.push(`${r.status()} ${r.request().method()} ${u.replace(/^https?:\/\/[^/]+/, '')} :: ${clean(await r.text().catch(() => '')).slice(0, 200)}`);
});
const before = ((await (await page.request.get(OSS_API)).json()).data ?? []).find((o: any) => o.ossName === 'SampleOss');
test.info().annotations.push({ type: 'before', description: JSON.stringify({ ...before, ossPassword: '***' }) });
const OSS_PW = process.env.WF_OSS_PW ?? Buffer.from(String(before?.ossPassword ?? ''), 'base64').toString('utf-8');
expect(before, 'SampleOss 등록되어 있음').toBeTruthy();
const modal = page.locator('.modal.show');
await test.step('1. OSS 목록 → SampleOss EDIT → URL/ID/PW 입력(현재 값 그대로)', async () => {
await loginAsAdmin(page);
await page.goto(OSS_LIST);
const row = page.locator('.tabulator-row').filter({ hasText: 'SampleOss' }).first();
await row.locator('#edit-btn').click();
await expect(modal.getByText('Edit OSS')).toBeVisible();
await modal.getByPlaceholder('Enter the Server URL').fill(before.ossUrl);
await modal.getByPlaceholder('Enter the OSS ID').fill(before.ossUsername);
await modal.getByPlaceholder('Enter the OSS Password').fill(OSS_PW);
});
let checkMsg = '';
await test.step('2. Duplicate Check · Connection Check → Edit 저장', async () => {
const n0 = dialogs.length;
const consoleMsgs: string[] = [];
page.on('console', (m) => { if (m.type() === 'error' || m.type() === 'warning') consoleMsgs.push(m.text().slice(0, 200)); });
const toasts = async () => clean((await page.locator('.toast, [class*="toast"], [role="alert"], .invalid-feedback:visible, .text-danger:visible').allInnerTexts().catch(() => [])).join(' | '));
await modal.getByRole('button', { name: 'Duplicate Check' }).click();
await page.waitForTimeout(2000);
test.info().annotations.push({ type: 'duplicate-check', description: `${dialogs.slice(n0).join(' | ')} ${await toasts()}` || '(알림 없음)' });
await modal.getByRole('button', { name: 'Connection Check' }).click();
await page.waitForTimeout(5000);
checkMsg = dialogs.slice(n0).join(' | ') + ' ' + clean(await page.locator('[class*="toast"], [role="alert"]').allInnerTexts().then((a) => a.join(' ')).catch(() => ''));
test.info().annotations.push({ type: 'connection-check', description: checkMsg || '(알림 없음)' });
expect(checkMsg, 'Connection Check 실패 문구 없음').not.toMatch(/fail|error|invalid|unable/i);
const n1 = dialogs.length;
// Edit 저장(PATCH /oss/{idx})은 응답이 늦을 수 있다(2026-10-14: 3초 안에 응답 없음 → 토스트·모달 닫힘 없음) → 응답까지 최대 3분 대기
const t0 = Date.now();
const [patch] = await Promise.all([
page.waitForResponse((r) => /\/oss\/\d+$/.test(r.url()) && r.request().method() === 'PATCH', { timeout: 180_000 }),
modal.getByRole('button', { name: 'Edit', exact: true }).click(),
]);
test.info().annotations.push({ type: 'patch', description: `HTTP ${patch.status()} ${Math.round((Date.now() - t0) / 1000)}s :: ${clean(await patch.text().catch(() => '')).slice(0, 200)}` });
await page.waitForTimeout(1500);
test.info().annotations.push({ type: 'after-edit-msgs', description: `toast: ${await toasts()} / console: ${consoleMsgs.join(' | ')}` });
test.info().annotations.push({ type: 'save', description: dialogs.slice(n1).join(' | ') || '(알림 없음)' });
test.info().annotations.push({ type: 'api', description: apiCalls.join(' || ') || '(기록 없음)' });
test.info().annotations.push({ type: 'modal-after-edit', description: (await modal.count()) ? clean(await modal.innerText()).slice(0, 200) : '(모달 닫힘)' });
expect(apiCalls.some((c) => /^200 (PATCH|PUT|POST) \/oss(\/\d+)?(\s|$)/.test(c)), 'Edit 저장 요청 200').toBe(true);
});
await test.step('3. 재조회: 접속 정보 일치 · 연결 결과', async () => {
await page.goto(OSS_LIST);
await expect(page.locator('.tabulator-row').filter({ hasText: 'SampleOss' }).first()).toContainText(before.ossUrl);
const after = ((await (await page.request.get(OSS_API)).json()).data ?? []).find((o: any) => o.ossName === 'SampleOss');
test.info().annotations.push({ type: 'after', description: JSON.stringify({ ...after, ossPassword: '***' }) });
expect({ url: after.ossUrl, id: after.ossUsername, pwSame: after.ossPassword === before.ossPassword })
.toEqual({ url: before.ossUrl, id: before.ossUsername, pwSame: true });
expect(apiCalls.some((c) => /connection/i.test(c) && /^200/.test(c)) || /success|connected|true/i.test(checkMsg), '연결 확인 성공(응답 200 또는 성공 알림)').toBe(true);
});
});tests/wf/TC-WF-002.spec.ts · 53줄 · 해시 ad4518f2 · 시나리오 보기
// TC-WF-002 신규 Workflow 생성 [정상, 공통]
// v0.6 TC-ID: TC-WF-FLOW-03 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/TC-WORKFLOW-02-create-workflow.spec.ts, wf-flow.spec.ts, deploy/tc/workflow/TC-WF-FLOW-03.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Workflows → New Workflow (iframe /web/workflows/workflow/new)
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-001) 완료, Workflow Stage 기본 데이터 등록
// 절차: 1.이름 입력 후 Duplicate Check 2.Purpose 선택 및 Create Script 후 Stage/Param 구성 3.Regist 후 목록과 저장 내용 확인
// 입력값(시트): Workflow Name workflow-create-test / Purpose For Deployment / Stage namespace-ensure → infra-create / Parameter 선택 Stage 의 기본값 / Tumblebug Selector 사용 안 함
// 기대결과: Workflow 목록 등록(Jenkins Job 생성), Stage 순서와 Parameter 설정 일치, Params Count = 공백 키 제외·대문자 정규화 후 저장 키 수
// 비고: Jenkins Job 은 mc-web-console 화면에서 직접 볼 수 없어 등록 응답(Registered successfully)·목록·상세(저장된 Stage/Parameter)로 판정한다(지침 4-0)
// 이미 같은 이름이 있으면(재실행) 먼저 삭제하고 진행한다(멱등)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { createWorkflow, deleteWorkflow, gotoWorkflows, workflowRows, workflowDetailByName, normalizedKeyCount } from '../../lib/iv3-wf';
const NAME = 'workflow-create-test';
const STAGES: [string, string][] = [['Utility', 'namespace-ensure'], ['Infra', 'infra-create']];
test('TC-WF-002 신규 Workflow 생성 (구 IV3_S35)', async ({ page }) => {
await loginAsAdmin(page);
if (await workflowDetailByName(page, NAME)) {
test.info().annotations.push({ type: 'pre-clean', description: `기존 ${NAME} 삭제: ${(await deleteWorkflow(page, NAME)).join(' | ')}` });
}
let r!: Awaited<ReturnType<typeof createWorkflow>>;
await test.step(`1~3. New Workflow: ${NAME} → Duplicate Check → For Deployment → Create Script → ${STAGES.map((s) => s[1]).join(' → ')} → Regist`, async () => {
r = await createWorkflow(page, NAME, 'For Deployment', STAGES);
test.info().annotations.push({ type: 'duplicate-check', description: r.dupToasts.join(' | ') });
test.info().annotations.push({ type: 'stages-form', description: r.shownStages.join(' → ') });
test.info().annotations.push({ type: 'params-form', description: r.params.map((p) => `${p.key}=${p.value}`).join(', ') });
test.info().annotations.push({ type: 'regist', description: r.registToasts.join(' | ') || '(토스트 없음)' });
expect(r.dupToasts.join(' ')).toMatch(/Name is available/);
expect(r.shownStages).toEqual(STAGES.map((s) => s[1]));
expect(r.registToasts.join(' ')).toMatch(/Registered successfully/);
});
await test.step('4. 목록 등록 · 저장된 Stage 순서와 Parameter · Params Count 규칙', async () => {
const fl = await gotoWorkflows(page);
const rows = await workflowRows(fl);
expect(rows[NAME], '목록에 등록').toBeTruthy();
const d = await workflowDetailByName(page, NAME);
const savedStages = (d?.workflowStageMappings ?? []).filter((m: any) => !m.isDefaultScript && m.workflowStageName).sort((a: any, b: any) => a.stageOrder - b.stageOrder).map((m: any) => m.workflowStageName);
const savedKeys = (d?.workflowParams ?? []).map((p: any) => String(p.paramKey ?? ''));
test.info().annotations.push({ type: 'saved', description: `purpose ${d?.workflowInfo?.workflowPurpose} / stages ${savedStages.join(' → ')} / params(${savedKeys.length}) ${savedKeys.join(', ')} / 목록 Params Count ${rows[NAME].params}` });
expect(d?.workflowInfo?.workflowPurpose).toBe('For Deployment');
expect(savedStages, '저장된 Stage 순서').toEqual(STAGES.map((s) => s[1]));
// 화면에 보이는 키는 일부(TUMBLEBUG·USER·USERPASS 등은 Advanced/Show Secret 아래 접혀 있음, 2026-10-14) → 보인 키가 모두 저장됐는지 확인
const formKeys = r.params.map((p) => p.key.trim().toUpperCase()).filter(Boolean);
const savedUpper = savedKeys.map((k: string) => k.trim().toUpperCase());
expect(formKeys.filter((k) => !savedUpper.includes(k)), '화면에서 구성한 키 중 저장되지 않은 키').toEqual([]);
expect(Number(rows[NAME].params), 'Params Count = 정규화한 저장 키 수').toBe(normalizedKeyCount(savedKeys));
});
});tests/wf/TC-WF-003.spec.ts · 55줄 · 해시 f707cca0 · 시나리오 보기
// TC-WF-003 Workflow 삭제 [정상, 공통]
// v0.6 TC-ID: TC-WF-FLOW-05 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/TC-WORKFLOW-04-delete-workflow.spec.ts, wf-flow.spec.ts, deploy/tc/workflow/TC-WF-FLOW-05.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Workflows → 대상 행 DELETE → Delete Workflow 확인 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-002 절차로 삭제 전용 Workflow 생성(실행 이력·연결 Event Listener 없음), Jenkins 정상 연결
// 절차: 1.삭제 대상의 DELETE → Delete 실행 2.Workflow 목록에서 제거 확인 3.동일 이름으로 다시 등록 가능한지 확인
// 입력값(시트): Workflow workflow-delete-test
// 기대결과: Workflow 목록(및 Jenkins Job)에서 삭제, 동일 Workflow Name 재사용 가능
// 비고: Jenkins Job 은 화면에서 볼 수 없어 목록·상세 API(404/빈 응답)·재등록 성공으로 판정. 재등록한 것은 끝에 다시 삭제(정리)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { createWorkflow, deleteWorkflow, gotoWorkflows, workflowRows, workflowDetailByName, wfApi } from '../../lib/iv3-wf';
const NAME = 'workflow-delete-test';
const STAGES: [string, string][] = [['Utility', 'namespace-ensure']];
test('TC-WF-003 Workflow 삭제 (구 IV3_S36)', async ({ page }) => {
await loginAsAdmin(page);
await test.step(`0. 삭제 전용 Workflow ${NAME} 생성(TC-WF-002 절차) · 실행 이력/연결 Event Listener 없음 확인`, async () => {
if (!(await workflowDetailByName(page, NAME))) {
const r = await createWorkflow(page, NAME, 'For Testing', STAGES);
test.info().annotations.push({ type: 'pre-create', description: r.registToasts.join(' | ') });
expect(r.registToasts.join(' ')).toMatch(/Registered successfully/);
}
const d = await workflowDetailByName(page, NAME);
const idx = d?.workflowInfo?.workflowIdx;
const el = await wfApi(page, 'GET', `/workflow/existEventListener/${idx}`);
const hist = await wfApi(page, 'GET', `/workflow/runHistory/${idx}`);
test.info().annotations.push({ type: 'pre-state', description: `idx ${idx} / existEventListener ${JSON.stringify(el.body?.data)} / runHistory ${JSON.stringify(hist.body?.data ?? []).slice(0, 200)}` });
expect(el.body?.data, '연결된 Event Listener 없음').toBeFalsy();
});
await test.step('1. DELETE → Delete 실행', async () => {
const toasts = await deleteWorkflow(page, NAME);
test.info().annotations.push({ type: 'delete', description: toasts.join(' | ') || '(토스트 없음)' });
});
await test.step('2. 목록·상세에서 제거', async () => {
const rows = await workflowRows(await gotoWorkflows(page));
expect(rows[NAME], '목록에서 제거').toBeUndefined();
expect(await workflowDetailByName(page, NAME), '상세 조회 없음').toBeUndefined();
});
await test.step('3. 같은 이름으로 다시 등록 가능(Duplicate Check 통과 + Regist 성공) → 정리 삭제', async () => {
const r = await createWorkflow(page, NAME, 'For Testing', STAGES);
test.info().annotations.push({ type: 're-regist', description: `${r.dupToasts.join(' | ')} / ${r.registToasts.join(' | ')}` });
expect(r.dupToasts.join(' ')).toMatch(/Name is available/);
expect(r.registToasts.join(' ')).toMatch(/Registered successfully/);
expect(await workflowDetailByName(page, NAME)).toBeTruthy();
test.info().annotations.push({ type: 'cleanup', description: (await deleteWorkflow(page, NAME)).join(' | ') });
expect(await workflowDetailByName(page, NAME)).toBeUndefined();
});
});tests/wf/TC-WF-004.spec.ts · 44줄 · 해시 77b23e1d · 시나리오 보기
// TC-WF-004 Workflow 목록 조회 [정상, 공통]
// v0.6 TC-ID: TC-WF-FLOW-02 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/TC-WORKFLOW-01-list-workflows.spec.ts, wf-list.spec.ts, deploy/tc/workflow/TC-WF-FLOW-02.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Workflows (iframe :18083/web/workflow/list)
// 시작 상태: admin(mcmp) 로그인, Workspace=ws01 / Project=default. 기본 Workflow seed 등록
// 절차: 1.Workspace/Project 선택 후 Workflows 목록 조회 2.기본 Workflow 10종과 컬럼(Status, Params Count, Last Run Date)·Action 버튼 확인
// 3.화면 목록 = 화면이 부르는 GET /eventlistener/workflowList/N 응답(이름·수)
// 기대결과: 기본 Workflow 10종 모두 포함, Status·Params Count·Last Run Date 표시, Detail/DELETE/RUN/LOG 버튼 표시
// 비고: 사용자 Workflow 가 있으면 전체 목록 수는 10개를 초과할 수 있음(시트 비고)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoWorkflows, workflowRows, wfApi, SEED_WORKFLOWS } from '../../lib/iv3-wf';
test('TC-WF-004 Workflow 목록 조회 (구 IV3_S33)', async ({ page }) => {
let rows: Awaited<ReturnType<typeof workflowRows>> = {};
await test.step('1. admin 로그인 → Workflows 진입(ws01/default)', async () => {
await loginAsAdmin(page);
const f = await gotoWorkflows(page);
await expect(f.getByText('Workflow Name', { exact: true }).first()).toBeVisible();
for (const h of ['Purpose', 'Status', 'Params Count', 'Last Run Date', 'Action']) await expect(f.getByText(h, { exact: true }).first()).toBeVisible();
rows = await workflowRows(f);
test.info().annotations.push({ type: 'rows', description: Object.entries(rows).map(([n, r]) => `${n}(${r.purpose}/${r.status || '-'}/${r.params}/${r.runDate || '-'})`).join(', ') });
});
await test.step('2. 기본 Workflow 10종 포함 · 행마다 Params Count 숫자 · Detail/DELETE/RUN/LOG 버튼', async () => {
const missing = SEED_WORKFLOWS.filter((n) => !rows[n]);
expect(missing, '목록에 없는 기본 Workflow').toEqual([]);
for (const n of SEED_WORKFLOWS) {
expect(rows[n].params, `${n} Params Count`).toMatch(/^\d+$/);
expect(rows[n].buttons, `${n} Action 버튼`).toEqual(expect.arrayContaining(['Detail', 'DELETE', 'RUN', 'LOG']));
}
});
await test.step('3. 화면 목록 = GET /eventlistener/workflowList/N 응답', async () => {
const api = await wfApi(page, 'GET', '/eventlistener/workflowList/N');
const list: any[] = api.body?.data ?? [];
const apiNames = list.map((w) => w.workflowInfo?.workflowName).sort();
test.info().annotations.push({ type: 'api', description: `HTTP ${api.status}, ${apiNames.length}건: ${apiNames.join(', ')}` });
expect(api.status).toBe(200);
expect(Object.keys(rows).sort()).toEqual(apiNames);
});
});tests/wf/TC-WF-005.spec.ts · 83줄 · 해시 9432125f · 시나리오 보기
// TC-WF-005 Workflow 상세 수정 [정상, 공통]
// v0.6 TC-ID: TC-WF-FLOW-04 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-flow.spec.ts, deploy/tc/workflow/TC-WF-FLOW-04.spec.ts, deploy/scenarios/C3-svc-wf/C3-02-wf-modify.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Workflows → vm-mariadb-backup-import-data-init Detail (iframe /web/workflows/workflow/detail/{idx})
// 시작 상태: admin(mcmp) 로그인, ws01/default. 수정 대상 Workflow 존재, TC-WF-001) 완료
// 절차: 1.Detail 에서 Parameter 변경 + Stage 추가 2.Edit 로 저장한 뒤 재조회 3.저장값과 Pipeline(script)/Parameter 비교
// 4.(정리) 원본으로 되돌리고 같은지 확인 — seed Workflow 는 다른 테스트도 쓰므로 반드시 원복(사용자 결정 2026-10-14)
// Detail 화면에서 저장하면 바꾸지 않은 값도 바뀐다(2026-10-14 관찰: NAMESPACE 가 상단 선택 Project 로 덮임 ns01→default, IMAGE/IMAGE_ID 가 빈 값,
// 전 Stage 의 기본 파라미터 15개 추가 25→40, Stage 를 지워도 그 파라미터는 남음) → 화면으로는 원복 불가. 백업한 원본을 화면과 같은 저장 API(PATCH /workflow/{idx})로 저장
// 입력값(시트): Workflow vm-mariadb-backup-import-data-init / DB_NAME testdb_updated / Stage 변경: 추가(Database > db-data-verify, 맨 뒤)
// 기대결과: 변경한 Parameter 와 Stage 구성 유지, 재생성된 Pipeline(script)에 반영, Params Count = 저장된 Parameter 키 수
// 비고: Script 는 Stage 구성으로 재생성(직접 본문 편집은 제외, 시트 비고). Jenkins 설정은 화면에서 볼 수 없어 저장된 script 로 판정(지침 4-0)
// 원본 상세는 results/v07/iv3_s37-original.json 에 백업한다
import fs from 'node:fs';
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { RUN_DIR } from '../../lib/runtag';
import { wfApi, openWorkflowDetail, setFormParam, addStage, pipelineStages, collectToasts, workflowDetailByName, detailSummary, gotoWorkflows, workflowRows, normalizedKeyCount } from '../../lib/iv3-wf';
const NAME = 'vm-mariadb-backup-import-data-init';
const NEW_DB = 'testdb_updated';
const ADD: [string, string] = ['Database', 'db-data-verify'];
async function saveEdit(page: import('@playwright/test').Page, f: import('@playwright/test').Frame) {
await f.getByRole('button', { name: 'Edit', exact: true }).click();
return collectToasts(page, f, 6000);
}
test('TC-WF-005 Workflow 상세 수정 (구 IV3_S37)', async ({ page }) => {
await loginAsAdmin(page);
const orig = await workflowDetailByName(page, NAME);
expect(orig, `${NAME} 존재`).toBeTruthy();
const o = detailSummary(orig);
fs.mkdirSync(RUN_DIR, { recursive: true });
fs.writeFileSync(`${RUN_DIR}/iv3_s37-original.json`, JSON.stringify(orig, null, 2));
test.info().annotations.push({ type: 'original', description: `stages ${o.stages.join(' → ')} / DB_NAME=${o.params.DB_NAME} / params ${Object.keys(o.params).length}` });
expect(o.stages, '원본에 추가할 Stage 가 이미 있으면 안 됨').not.toContain(ADD[1]);
let modified = false;
try {
await test.step(`1. Detail → DB_NAME=${NEW_DB} · Stage 추가(${ADD.join(' > ')})`, async () => {
const f = await openWorkflowDetail(page, NAME);
expect(await setFormParam(f, 'DB_NAME', NEW_DB), 'DB_NAME 입력란(화면에 보이는 행)').toBe(true);
await addStage(page, f, ADD[0], ADD[1]);
const shown = await pipelineStages(f);
test.info().annotations.push({ type: 'stages-form', description: shown.join(' → ') });
expect(shown).toEqual([...o.stages, ADD[1]]);
const toasts = await saveEdit(page, f);
modified = true;
test.info().annotations.push({ type: 'edit', description: toasts.join(' | ') || '(토스트 없음)' });
expect(toasts.join(' ')).toMatch(/Updated successfully/);
});
await test.step('2~3. 재조회: Parameter·Stage 유지 · script 에 추가 Stage 반영 · Params Count', async () => {
const m = detailSummary(await workflowDetailByName(page, NAME));
const rows = await workflowRows(await gotoWorkflows(page));
test.info().annotations.push({ type: 'modified', description: `stages ${m.stages.join(' → ')} / DB_NAME=${m.params.DB_NAME} / script에 stage("${ADD[1]}") ${m.script.includes(`stage("${ADD[1]}")`)} / 목록 Params Count ${rows[NAME]?.params}` });
const side = Object.keys({ ...o.params, ...m.params }).filter((k) => k !== 'DB_NAME' && o.params[k] !== m.params[k]);
test.info().annotations.push({ type: 'side-effect', description: `수정하지 않았는데 바뀐 파라미터: ${side.map((k) => `${k} ${JSON.stringify(o.params[k])}→${JSON.stringify(m.params[k])}`).join(', ') || '없음'}` });
expect(m.params.DB_NAME).toBe(NEW_DB);
expect(m.stages).toEqual([...o.stages, ADD[1]]);
expect(m.script, '재생성된 Pipeline script 에 추가 Stage').toContain(`stage("${ADD[1]}")`);
expect(Number(rows[NAME]?.params), 'Params Count = 정규화한 저장 키 수').toBe(normalizedKeyCount(Object.keys(m.params)));
// 다시 Detail 을 열었을 때도 화면에 같은 값
const f = await openWorkflowDetail(page, NAME);
expect(await pipelineStages(f)).toEqual([...o.stages, ADD[1]]);
});
} finally {
if (modified) {
await test.step('4. (정리) 원래 값으로 되돌려 저장 → 원본과 비교', async () => {
const res = await wfApi(page, 'PATCH', `/workflow/${orig!.workflowInfo.workflowIdx}`, { workflowInfo: orig!.workflowInfo, workflowParams: orig!.workflowParams, workflowStageMappings: orig!.workflowStageMappings });
const toasts = [`PATCH ${res.status} ${JSON.stringify(res.body?.data)}`];
const r = detailSummary(await workflowDetailByName(page, NAME));
const diffKeys = Object.keys({ ...o.params, ...r.params }).filter((k) => o.params[k] !== r.params[k]);
test.info().annotations.push({ type: 'restore', description: `${toasts.join(' | ')} / stages ${r.stages.join(' → ')} / DB_NAME=${r.params.DB_NAME} / 다른 파라미터 ${diffKeys.join(', ') || '없음'} / script 동일 ${r.script === o.script}` });
expect(r.stages, '원복: Stage').toEqual(o.stages);
expect(diffKeys, '원복: Parameter').toEqual([]);
expect(r.script === o.script, '원복: script').toBe(true);
});
}
}
});tests/wf/TC-WF-006.spec.ts · 68줄 · 해시 51d91c81 · 시나리오 보기
// TC-WF-006 Workflow 로그 모달 표시 [정상, 공통]
// v0.6 TC-ID: TC-WF-FLOW-07 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-flow.spec.ts, TC-WORKFLOW-03-run-workflow.spec.ts, deploy/tc/workflow/TC-WF-FLOW-07.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Workflows → 실행 이력이 있는 Workflow 의 LOG → Workflow Log 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. 확인 대상 Workflow 의 Build 이력 존재(IV3 실행으로 생긴 multi-csp-k8s-cluster-deploy 등), Jenkins 정상 연결
// 절차: 1.실행 완료한 Workflow(Status SUCCESS/FAILURE)의 LOG 클릭 2.Build 선택(최신 Build) 3.해당 Build 의 Stage 로그·실패 원인 확인
// 기대결과: 선택한 Build 번호와 Console Log 일치, 대상 Workflow 의 Stage 로그 표시, 실패한 경우 원인 표시
// 비고: 로그 조회를 위해 Workflow 를 다시 실행하지 않음(시트 비고)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoWorkflows, workflowRows, workflowRow, wfApi, clean } from '../../lib/iv3-wf';
test('TC-WF-006 Workflow 로그 모달 표시 (구 IV3_S38)', async ({ page }) => {
await loginAsAdmin(page);
const f = await gotoWorkflows(page);
const rows = await workflowRows(f);
// 실행 이력이 있는 Workflow(Status 가 SUCCESS/FAILURE) 중 하나
const target = ['multi-csp-k8s-cluster-deploy', 'multi-csp-vm-deploy', ...Object.keys(rows)].find((n) => /SUCCESS|FAILURE/.test(rows[n]?.status ?? ''));
test.skip(!target, '실행 이력(Status SUCCESS/FAILURE)이 있는 Workflow 없음');
const status = rows[target!].status;
test.info().annotations.push({ type: 'target', description: `${target} (Status ${status})` });
// Stage 이름: Workflow 정의 스크립트의 stage("...") 목록
const wf = (await wfApi(page, 'GET', '/eventlistener/workflowList/N')).body?.data?.find((w: any) => w.workflowInfo?.workflowName === target);
const stages: string[] = [...String(wf?.workflowInfo?.script ?? '').matchAll(/stage\("([^"]+)"\)/g)].map((m) => m[1]);
test.info().annotations.push({ type: 'stages', description: stages.join(' → ') });
const modal = f.locator('.modal.show');
await test.step(`1. ${target} 의 LOG 클릭 → Workflow Log 모달`, async () => {
await workflowRow(f, target!).locator('#log-btn').click();
await expect(modal.getByText('Workflow Log')).toBeVisible();
});
// Build 별 카드(card-header 의 h3.card-title = Build 번호, card-body textarea = Console Log). 화면 데이터는 GET /workflow/log/{workflowIdx}
const logs: any[] = (await wfApi(page, 'GET', `/workflow/log/${wf?.workflowInfo?.workflowIdx}`)).body?.data ?? [];
const latest = logs.reduce((a, b) => (Number(b.buildIdx) > Number(a?.buildIdx ?? -1) ? b : a), undefined as any);
test.info().annotations.push({ type: 'builds', description: `API Build ${logs.map((l) => l.buildIdx).join(', ')} / 최신 ${latest?.buildIdx}` });
let build = '';
await test.step('2. 최신 Build 선택(Build 카드 헤더)', async () => {
const titles = (await modal.locator('.card-title').allInnerTexts()).map(clean);
test.info().annotations.push({ type: 'cards', description: titles.join(', ') });
build = String(latest?.buildIdx ?? '');
expect(titles, '최신 Build 카드 표시').toContain(build);
const card = modal.locator('.card').filter({ has: f.locator('.card-title', { hasText: new RegExp(`^${build}$`) }) }).first();
if (!(await card.locator('textarea').isVisible().catch(() => false))) await card.locator('.card-header').click();
await expect(card.locator('textarea')).toBeVisible();
});
await test.step('3. Console Log: 선택 Build 와 일치 · Stage 로그 표시 · 실패면 원인', async () => {
const card = modal.locator('.card').filter({ has: f.locator('.card-title', { hasText: new RegExp(`^${build}$`) }) }).first();
const log = await card.locator('textarea').inputValue();
test.info().annotations.push({ type: 'log-head', description: log.slice(0, 500) });
test.info().annotations.push({ type: 'log-tail', description: log.slice(-800) });
expect(clean(log), `Build ${build} 화면 로그 = API buildLog`).toBe(clean(String(latest.buildLog)));
const shown = stages.filter((st) => log.includes(`(${st})`) || log.includes(`STAGE: ${st}`));
test.info().annotations.push({ type: 'stage-in-log', description: `${shown.length}/${stages.length}: ${shown.join(', ')}` });
expect(shown.length, 'Workflow 의 Stage 로그가 Console Log 에 표시').toBeGreaterThan(0);
const finished = log.match(/Finished: (\w+)/)?.[1] ?? '(진행 중/표기 없음)';
test.info().annotations.push({ type: 'finished', description: finished });
if (finished === 'FAILURE') {
const cause = log.split(/\r?\n/).filter((l) => /ERROR|error:|Exception|failed/i.test(l)).slice(-3).join(' / ');
test.info().annotations.push({ type: 'failure-cause', description: cause || '(원인 줄 없음)' });
expect(cause, '실패 원인 표시').not.toBe('');
}
});
});tests/wf/TC-WF-007.spec.ts · 76줄 · 해시 4c1d1407 · 시나리오 보기
// TC-WF-007: Workflow로 단일 CSP Infra 생성 [정상, CSP별]
// 화면: Workflows(iframe) → multi-csp-vm-deploy RUN 팝업(Run Workflow, 파라미터 입력) → 실행 상태
// 시작 상태: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default 선택된 상태에서 시작
// 절차 (예: AWS 기준): 1.Workflows 진입(iframe) 2.multi-csp-vm-deploy RUN: Spec=t3.micro, Image=Ubuntu 22.04(ami-0efae34c528c68a9c),
// INFRA_ID=mw1, CSP_LIST=aws, INFRA_NODEGROUP_PREFIX=g1, INFRA_NODEGROUP_SIZE=1 3.Run 4.Infra Workloads 에서 mw1 Running 도달까지 대기
// 5.mw1 선택 → Infra Info > Node List 에서 NodeGroup g1-aws 의 node g1-aws-1 선택 → Node Info 상세 확인
// 기대결과: Infra mw1 Running, Node Info [ g1-aws / g1-aws-1 ] 에 CSP=aws, Node Status=Running, Spec=t3.micro, Image ID, Connection, Public/Private IP 표시
// 멱등: mw1 이 이미 있으면 Run 은 건너뛰고(annotation preexisting) 상태만 검증한다(이름 중복 Run 은 TC-WF-010 대상).
// 2026-10-16 수정(9 CSP 실행): lib/workflow VM_DEPLOY_INPUT 에 Azure~KT 입력값 추가, runVmDeploy 가 상단 CSP 를 먼저 선택.
// 기존 Infra 가 Suspended 면 Resume → 검증 → 다시 Suspend(사용자 지시 2026-10-16, lib/iv3-mc10 withResumed). 기존 Infra 는 Image ID 값만 기록. 새 이름은 WF_INFRA_NAME. 테스트 시간 40분
// 만든 mw{n} 은 이후 TC(INFRA-028 Terminal·032 Scale Policy·052 전체 중지)용으로 삭제하지 않는다
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, wfInfraName } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { gotoInfraList, infraRow, nodeField, openInfraDetail, openNodeInfo, rowCells, statusOf, waitInfraRunning } from '../../lib/infra';
import { VM_DEPLOY_INPUT as WF_INPUT, runVmDeploy } from '../../lib/workflow';
import { withResumed } from '../../lib/iv3-mc10';
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-WF-007", c)} ` + "Workflow로 단일 CSP Infra 생성 (구 IV3_S01)", async ({ page }) => {
const inp = WF_INPUT[c.code];
test.skip(!inp, `${c.name}: Workflow Run 입력값 미확정(사전점검 필요) → AWS 확정 후 확장`);
test.setTimeout(40 * 60_000);
// WF_INFRA_NAME: 기존 mw{n} 을 다른 세션이 중지(Suspended)해 둔 경우 등 새 이름으로 생성할 때(2026-10-16, AWS mw1 Suspended → mw1b)
const name = process.env.WF_INFRA_NAME || wfInfraName(c);
const ng = `g${c.n}`;
await loginAsAdmin(page);
await gotoInfraList(page);
if (await infraRow(page, name).count()) {
const pre = await rowCells(infraRow(page, name).first());
test.info().annotations.push({ type: 'preexisting', description: `${name} 이미 존재 → Run 건너뜀: ${JSON.stringify(pre)}` });
// 2026-10-16 사용자 지시: "Test에 필요한 infra가 suspended 상태면 resume 후 테스트 한다. 테스트 완료 후 다시 suspended 한다." → withResumed
// 목록 status 는 'Stopped' 로 보이고 statusCount.countSuspended 가 node 수(2026-10-16 mw1) → Suspended 로 본다
if (/suspend/i.test(statusOf(pre)) || (Number(pre['statusCount.countSuspended'] ?? 0) > 0 && Number(pre['statusCount.countSuspended']) === Number(pre['statusCount.countTotal']))) {
await withResumed(page, { infra: name, ng: '', node: '', why: `기존 ${name} Suspended → Resume`, resume: true, origStatus: statusOf(pre) }, () => verify(true));
return;
}
test.skip(/terminat|fail/i.test(statusOf(pre)), `${name} 이 ${statusOf(pre)} 상태 — 검증 불가`);
await verify(true);
return;
} else {
const { toasts } = await runVmDeploy(page, inp!, name, ng);
const err = toasts.find((t) => /fail|error|invalid|not found/i.test(t));
expect(err, `Run 직후 오류 토스트 없음`).toBeUndefined();
}
await verify(false);
async function verify(preexisting: boolean) {
const cells = await waitInfraRunning(page, name, 25 * 60_000);
test.info().annotations.push({ type: 'list-row', description: JSON.stringify(cells) });
await openInfraDetail(page, name);
const body = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
const i = body.indexOf('Infra Info');
test.info().annotations.push({ type: 'detail', description: body.slice(i, i + 400) });
// NodeGroup 의 node 선택 → Node Info 상세 확인. nodegroup 이름은 {INFRA_NODEGROUP_PREFIX}-{csp} (예: g1-aws)
const wfNg = `${ng}-${inp!.csp}`;
const node = `${wfNg}-1`;
const info = await openNodeInfo(page, wfNg, node);
test.info().annotations.push({ type: 'node-info', description: info.slice(0, 900) });
expect(nodeField(info, 'CSP'), 'CSP').toBe(inp!.csp);
expect(nodeField(info, 'Node Status'), 'Node Status').toBe('Running');
expect(nodeField(info, 'Node Spec'), 'Node Spec').toBe(inp!.specQuery);
// 기존 Infra(이전 실행에서 생성)는 생성 당시 이미지라 현재 입력값과 다를 수 있음 → 값만 기록
if (preexisting) {
test.info().annotations.push({ type: 'preexisting-image', description: `Image ID ${nodeField(info, 'Image ID')} (현재 입력값 ${inp!.imageQuery})` });
expect(nodeField(info, 'Image ID'), 'Image ID').not.toBe('');
} else expect(nodeField(info, 'Image ID'), 'Image ID').toBe(inp!.imageQuery);
expect(nodeField(info, 'Config Name'), 'Connection').toBe(c.infra.conn);
expect(nodeField(info, 'Public IP\\(IPv4\\)'), 'Public IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
expect(nodeField(info, 'Private IP'), 'Private IP').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
}
});
}tests/wf/TC-WF-008.spec.ts · 142줄 · 해시 55d5d3a9 · 시나리오 보기
// TC-WF-008: Workflow로 멀티 CSP Infra mc10 생성 (CSP별 nodegroup 1개, node 1개) [정상, 공통] — TC-WF-008
// 화면: Workflows(iframe) → multi-csp-vm-deploy RUN 팝업(Tumblebug Parameter Selection + Parameters) → Run → Infra Workloads(mc10 목록·상세 Node List·Node Info)
// 시작 상태: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default 선택된 상태에서 시작. TC-WF-001 완료, 같은 Namespace(default)에 mc10 없음
// 절차: 1.Workflows 진입 → multi-csp-vm-deploy RUN 2.상단 CSP=aws 에서 Spec=t3.micro(TC-WF-007 과 같음)·Image=목록의 Ubuntu 22.04 server(TC-WF-007 값 ami-012a353bb3afb92ee 가 목록에 없으면 목록 값 — 2026-10-16 후보는 img1 1개) 선택
// 3.파라미터: NAMESPACE=default, INFRA_ID=mc10, INFRA_NODEGROUP_PREFIX=g10, INFRA_NODEGROUP_SIZE=1, CSP_LIST=alibaba,aws,azure,gcp,ibm,ncp,nhn,tencent(KT 제외)
// 나머지 CSP 의 <CSP>_REGION/CONNECTION_NAME/ZONE/SPEC_ID/IMAGE_ID 는 팝업이 현재 카탈로그 목록에서 채운 값 그대로(run-params 에 전부 기록)
// 4.Run → 토스트 기록 5.Jenkins Build(Workflows LOG 와 같은 GET /workflow/log/{idx}) 완료와 Infra 목록 mc10 상태를 최대 45분 관찰
// 6.mc10 상세 Node List 에서 CSP별 NodeGroup g10-<csp> 의 node g10-<csp>-1 을 하나씩 선택해 Node Info 의 CSP·Node Status 확인
// 입력값(Drive): Workspace ws01 / Project default / NAMESPACE default / INFRA_ID mc10 / CSP_LIST alibaba,aws,azure,gcp,ibm,ncp,nhn,tencent /
// INFRA_NODEGROUP_PREFIX g10 / INFRA_NODEGROUP_SIZE 1 / <CSP>_REGION·CONNECTION_NAME·ZONE·SPEC_ID·IMAGE_ID = 현재 목록에서 선택한 값
// 기대결과: Jenkins SUCCESS 및 mc10 Running, CSP별 g10-<csp> NodeGroup 1개·Node 1개 Running
// 멱등: mc10 이 이미 있으면 Run 은 건너뛰고(annotation preexisting) 상태만 검증
// 과금: CSP 8개 VM 각 1대(테스트 후 TC-INFRA-056 로 삭제 — 사용자 지시 시 실행). TC-INFRA-049)·TC-INFRA-028·S08 대상으로도 사용
// 비고: 2026-10-16 구현(stub 대체)
import { test, expect } from '../../lib/fixtures';
import type { Page, FrameLocator } from '@playwright/test';
import { MC10, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { VM_DEPLOY_INPUT, pickSearchable, setParam, getParam, runAndCollectToasts } from '../../lib/workflow';
import { openRunRetry } from '../../lib/iv3-mc10';
import { workflowDetailByName, workflowBuilds, failureLines } from '../../lib/iv3-wf';
import { gotoInfraList, refreshInfraList, infraRow, infraRowText, waitInfraListed, openInfraDetail, nodeNames, readNodeInfo } from '../../lib/iv4-infra';
import { MC10_CSPS } from '../../lib/iv3-mc10'; // Drive 입력값 순서(KT 제외)
/** 상단 Image 검색형 목록에서 선택: preferId 가 있으면 그것, 없으면 fallback 에 맞는 첫 옵션. 반환: 선택된 Image ID(옵션 'ID / 이름' 의 ID) */
async function pickImageFromList(page: Page, f: FrameLocator, preferId: string, fallback: RegExp) {
const input = f.getByPlaceholder('Image', { exact: true });
await input.click({ force: true });
await input.evaluate((el) => { (el as HTMLInputElement).value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); });
await page.waitForTimeout(3000);
const opts = f.locator('.searchable-select-option');
if (!(await opts.count())) { await input.click({ force: true }); await page.waitForTimeout(3000); }
const texts = (await opts.allInnerTexts()).map((t) => t.replace(/\s+/g, ' ').trim());
test.info().annotations.push({ type: 'aws-image-options', description: texts.slice(0, 20).join(' | ') || '(없음)' });
let i = texts.findIndex((t) => t.startsWith(`${preferId} `) || t === preferId);
if (i < 0) i = texts.findIndex((t) => fallback.test(t));
expect(i, `Image 목록에 ${preferId} 또는 ${fallback}`).toBeGreaterThanOrEqual(0);
await opts.nth(i).click();
const id = texts[i].split(' / ')[0].trim();
test.info().annotations.push({ type: 'aws-image', description: `선택: ${texts[i]}${id !== preferId ? ` (${preferId} 는 목록에 없음)` : ''}` });
return id;
}
const NAME = MC10.name;
const PREFIX = 'g10';
test(`${tid('TC-WF-008')} Workflow로 멀티 CSP Infra mc10 생성 (CSP별 nodegroup 1개, node 1개) (구 IV3_S11)`, async ({ page }) => {
test.setTimeout(70 * 60_000);
const aws = VM_DEPLOY_INPUT.AWS!;
await loginAsAdmin(page);
await gotoInfraList(page);
const exists = await infraRow(page, NAME).first().waitFor({ timeout: 15_000 }).then(() => true, () => false);
const wf = await workflowDetailByName(page, 'multi-csp-vm-deploy');
expect(wf, 'seed Workflow multi-csp-vm-deploy 존재(TC-WF-001)').toBeTruthy();
const idx = Number(wf!.workflowInfo.workflowIdx);
const lastBefore = Math.max(0, ...(await workflowBuilds(page, idx)).map((b) => Number(b.buildIdx)));
let finished = '';
if (exists) {
test.info().annotations.push({ type: 'preexisting', description: `${NAME} 이미 존재 → Run 건너뜀: ${await infraRowText(page, NAME)}` });
} else {
// 1~3. RUN 팝업
const f = await openRunRetry(page, 'multi-csp-vm-deploy');
await pickSearchable(f, 'VM/K8s Spec', aws.specQuery, aws.specOption);
// Image: 현재 목록에서 선택(Drive 입력값 '<선택 Spec과 호환되는 Image ID>'). TC-WF-007 값(ami-012a353bb3afb92ee)이 없으면 목록의 Ubuntu 22.04 server 이미지
// (2026-10-16 t3.micro 의 후보는 'img1 / ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-20260731' 1개뿐 — ami 는 No matches)
const awsImage = await pickImageFromList(page, f, aws.imageQuery, /ubuntu-jammy-22\.04-amd64-server/);
await setParam(f, 'NAMESPACE', 'default');
await setParam(f, 'INFRA_ID', NAME);
await setParam(f, 'INFRA_NODEGROUP_PREFIX', PREFIX);
await setParam(f, 'INFRA_NODEGROUP_SIZE', '1');
await setParam(f, 'CSP_LIST', MC10_CSPS.join(','));
const keys = ['NAMESPACE', 'INFRA_ID', 'INFRA_PREFIX', 'INFRA_NODEGROUP_PREFIX', 'INFRA_NODEGROUP_SIZE', 'PROVIDER', 'CSP', 'CSP_LIST',
...MC10_CSPS.flatMap((c) => ['REGION', 'CONNECTION_NAME', 'ZONE', 'SPEC_ID', 'IMAGE_ID'].map((k) => `${c.toUpperCase()}_${k}`))];
const vals: Record<string, string> = {};
for (const k of keys) vals[k] = await getParam(f, k).catch(() => '?');
test.info().annotations.push({ type: 'run-params', description: Object.entries(vals).map(([k, v]) => `${k}=${v}`).join(', ') });
for (const c of MC10_CSPS) {
expect(vals[`${c.toUpperCase()}_SPEC_ID`], `${c} SPEC_ID 값`).toMatch(new RegExp(`^${c}\\+`));
expect(vals[`${c.toUpperCase()}_IMAGE_ID`], `${c} IMAGE_ID 값`).not.toBe('');
}
expect(vals.AWS_IMAGE_ID, 'AWS_IMAGE_ID 선택됨').toBe(awsImage);
await page.waitForTimeout(2000); // 녹화용
// 4. Run
const toasts = await runAndCollectToasts(page, f, 30_000);
const err = toasts.find((t) => /fail|error|invalid|not found|reselect/i.test(t));
expect(err, 'Run 직후 오류 토스트 없음').toBeUndefined();
}
// 5. Build 완료 + mc10 상태 관찰(최대 45분)
const trail: string[] = [];
const start = Date.now();
let row = '';
// Build 는 같은 Workflow 를 다른 세션도 실행하므로(2026-10-16 TC-WF-007 mw* 동시 실행) payload 의 "name":"mc10" 으로 찾는다(1차 실행은 다른 세션 Build 를 잡음)
const myBuild = async () => (await workflowBuilds(page, idx))
.filter((x) => x.buildLog.includes(`payload: {"name":"${NAME}"`) && (exists || Number(x.buildIdx) > lastBefore))
.sort((a, b) => Number(b.buildIdx) - Number(a.buildIdx))[0];
while (Date.now() - start < 45 * 60_000) {
if (!finished) {
const b = await myBuild();
finished = b?.buildLog.match(/Finished: (\w+)/)?.[1] ?? '';
if (finished) {
const resp = b!.buildLog.split(/\r?\n/).find((l) => l.startsWith('{"resourceType":"infra"')) ?? '';
const sys = resp.match(/"systemMessage":(\[[^\]]*\]|null)/)?.[1] ?? '';
test.info().annotations.push({ type: 'build', description: `#${b!.buildIdx} Finished: ${finished}${finished !== 'SUCCESS' ? ' / ' + failureLines(b!.buildLog).join(' / ') : ''}` });
test.info().annotations.push({ type: 'build-systemMessage', description: sys.slice(0, 1500) || '(없음)' });
}
}
await refreshInfraList(page);
row = await infraRowText(page, NAME);
const st = row.split(' ')[0] || '(행 없음)';
if (trail[trail.length - 1]?.split('@')[0] !== st) trail.push(`${st}@${Math.round((Date.now() - start) / 1000)}s`);
const done = /^Running/i.test(st) && !/partial/i.test(row);
if (finished && (done || /Failed|Partial/i.test(st))) break;
await page.waitForTimeout(30_000);
}
test.info().annotations.push({ type: 'status-trail', description: trail.join(' → ') });
test.info().annotations.push({ type: 'list-row', description: row });
// 6. CSP별 NodeGroup·node 확인
expect(await waitInfraListed(page, NAME), `${NAME} 목록 표시`).toBeTruthy();
await openInfraDetail(page, NAME);
const nodes = await nodeNames(page);
test.info().annotations.push({ type: 'nodes', description: nodes.join(', ') });
const bad: string[] = [];
for (const c of MC10_CSPS) {
const ng = `${PREFIX}-${c}`;
const node = `${ng}-1`;
if (!nodes.includes(node)) { bad.push(`${node} 없음`); continue; }
const info = await readNodeInfo(page, ng, node);
if (!info.csp.toLowerCase().includes(c)) bad.push(`${node} CSP=${info.csp}`);
if (!/^Running/i.test(info.status)) bad.push(`${node} ${info.status || '(상태 빈 값)'}`);
}
const extra = nodes.filter((n) => !MC10_CSPS.some((c) => n === `${PREFIX}-${c}-1`));
if (extra.length) bad.push(`예상 외 node: ${extra.join(',')}`);
test.info().annotations.push({ type: 'per-csp', description: bad.length ? bad.join(' / ') : `8개 CSP 모두 g10-<csp>-1 Running` });
expect(finished, 'Jenkins Build SUCCESS').toBe('SUCCESS');
expect(bad, 'CSP별 NodeGroup 1개·Node 1개 Running').toEqual([]);
expect(row, `${NAME} Running`).toMatch(/^Running/i);
});tests/wf/TC-WF-009.spec.ts · 75줄 · 해시 df6b3c4c · 시나리오 보기
// TC-WF-009: Workflow 필수 파라미터 누락 RUN [예상실패, 공통] — TC-WF-009
// 화면: Workflows(iframe) → multi-csp-vm-deploy RUN 팝업(Run Workflow) → Run → 토스트 / Infra Workloads
// 시작 상태: admin(mcmp) 로그인 상태, Workspace=ws01 / Project=default 선택된 상태에서 시작. TC-WF-001 완료(seed Workflow 존재)
// 절차: 1.Workflows 진입 → multi-csp-vm-deploy RUN 2.정상 입력값 설정: CSP_LIST=aws, Spec=t3.micro(상단 VM/K8s Spec), INFRA_ID=mcmp-e2e-v07-wf009,
// INFRA_NODEGROUP_PREFIX=g1, INFRA_NODEGROUP_SIZE=1 3.AWS_IMAGE_ID 만 공란(상단 Image 미선택 + 파라미터 공란) 4.Run → 토스트 기록
// 5.약 2분 동안 Jenkins Build 목록(GET /workflow/log/{idx} — Workflows LOG 화면과 같은 출처)과 Infra 목록 관찰
// 입력값(Drive): CSP_LIST aws / 누락 항목 AWS_SPEC_ID 또는 AWS_IMAGE_ID 공란(이 spec 은 AWS_IMAGE_ID) / 나머지 유효한 값. INFRA_ID 공란은 기본 이름을 쓰므로 대상 아님
// 기대결과: 누락 값 검증 오류로 실행 요청 차단, 신규 Jenkins Build 와 자원(Infra mcmp-e2e-v07-wf009) 미생성
// 판정: 오류 안내가 있고 Build 수·Infra 변화가 없으면 PASS. Build 가 새로 생기거나 Infra 가 생기면 FAIL(검증 없이 실행)
// 비고: 2026-10-16 구현(stub 대체). 잘못 생성되면(결함) Infra mcmp-e2e-v07-wf009 는 정리 대상으로 결과 문서에 기록
import { test, expect } from '../../lib/fixtures';
import { tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { gotoInfraList, infraRow } from '../../lib/infra';
import { VM_DEPLOY_INPUT, pickSearchable, setParam, getParam, runAndCollectToasts } from '../../lib/workflow';
import { openRunRetry } from '../../lib/iv3-mc10';
import { workflowDetailByName, workflowBuilds } from '../../lib/iv3-wf';
const INFRA_ID = process.env.WF009_INFRA ?? 'mcmp-e2e-v07-wf009';
test(`${tid('TC-WF-009')} Workflow 필수 파라미터 누락 RUN (구 IV3_S08)`, async ({ page }) => {
test.setTimeout(15 * 60_000);
const inp = VM_DEPLOY_INPUT.AWS!;
await loginAsAdmin(page);
await gotoInfraList(page);
expect(await infraRow(page, INFRA_ID).count(), `${INFRA_ID} 미존재(시작 상태)`).toBe(0);
const wf = await workflowDetailByName(page, 'multi-csp-vm-deploy');
expect(wf, 'seed Workflow multi-csp-vm-deploy 존재(TC-WF-001)').toBeTruthy();
const idx = Number(wf!.workflowInfo.workflowIdx);
const before = await workflowBuilds(page, idx);
const lastBefore = Math.max(0, ...before.map((b) => Number(b.buildIdx)));
test.info().annotations.push({ type: 'builds-before', description: `workflowIdx ${idx} / Build ${before.length}건, 마지막 #${lastBefore}` });
// 1~3. RUN 팝업: 정상값 + AWS_IMAGE_ID 공란
const f = await openRunRetry(page, 'multi-csp-vm-deploy');
await pickSearchable(f, 'VM/K8s Spec', inp.specQuery, inp.specOption);
await setParam(f, 'CSP_LIST', inp.csp);
await setParam(f, 'INFRA_ID', INFRA_ID);
await setParam(f, 'INFRA_NODEGROUP_PREFIX', 'g1');
await setParam(f, 'INFRA_NODEGROUP_SIZE', '1');
await setParam(f, 'AWS_IMAGE_ID', '');
const keys = ['NAMESPACE', 'INFRA_ID', 'INFRA_NODEGROUP_PREFIX', 'INFRA_NODEGROUP_SIZE', 'CSP_LIST', 'AWS_REGION', 'AWS_CONNECTION_NAME', 'AWS_ZONE', 'AWS_SPEC_ID', 'AWS_IMAGE_ID'];
const vals: string[] = [];
for (const k of keys) vals.push(`${k}=${await getParam(f, k).catch(() => '?')}`);
test.info().annotations.push({ type: 'run-params', description: vals.join(', ') });
test.info().annotations.push({ type: 'top-image', description: `상단 Image='${await f.getByPlaceholder('Image', { exact: true }).inputValue()}'` });
expect(await getParam(f, 'AWS_IMAGE_ID'), 'AWS_IMAGE_ID 공란').toBe('');
expect(await getParam(f, 'AWS_SPEC_ID'), 'AWS_SPEC_ID 정상값').toContain(inp.specQuery);
// 4. Run
const toasts = await runAndCollectToasts(page, f, 20_000);
const modalOpen = await f.locator('.modal.show').isVisible().catch(() => false);
const modalErr = modalOpen ? (await f.locator('.modal.show .invalid-feedback:visible, .modal.show .text-danger:visible, .modal.show .is-invalid').allInnerTexts().catch(() => [])).join(' | ') : '';
test.info().annotations.push({ type: 'after-run', description: `팝업 ${modalOpen ? '열림(실행 안 됨)' : '닫힘'}${modalErr ? ` / 표시 오류: ${modalErr}` : ''}` });
await page.waitForTimeout(3000); // 녹화용
// 5. 관찰: 신규 Build·Infra
let newBuild: { buildIdx: number; buildLog: string } | undefined;
for (let i = 0; i < 4; i++) {
await page.waitForTimeout(30_000);
newBuild = (await workflowBuilds(page, idx)).find((b) => Number(b.buildIdx) > lastBefore);
if (newBuild) break;
}
await gotoInfraList(page);
const created = await infraRow(page, INFRA_ID).count();
const tail = newBuild ? newBuild.buildLog.split(/\r?\n/).filter((l) => /ERROR|error|Finished|IMAGE/i.test(l)).slice(-5).join(' / ').slice(0, 600) : '';
test.info().annotations.push({ type: 'result', description: `오류 토스트: ${toasts.join(' | ') || '(없음)'} / 신규 Build: ${newBuild ? `#${newBuild.buildIdx} ${tail}` : '없음'} / Infra ${INFRA_ID}: ${created ? '생성됨' : '없음'}` });
expect(toasts.join(' '), '누락 값 검증 오류 안내').toMatch(/IMAGE|image|required|empty|missing|invalid|fail|error|select/i);
expect(newBuild, '신규 Jenkins Build 미생성').toBeUndefined();
expect(created, `Infra ${INFRA_ID} 미생성`).toBe(0);
});tests/wf/TC-WF-010.spec.ts · 50줄 · 해시 12e953ed · 시나리오 보기
// TC-WF-010: Workflow Infra 이름 중복 RUN [예상실패, AWS]
// 화면: Workflows(iframe) → multi-csp-vm-deploy RUN 팝업 → Run
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-007 로 만든 mw1 이 Running
// 절차: 1.Infra Workloads 에서 mw1 의 노드 수·전체 Infra 수 기록 2.Workflows → multi-csp-vm-deploy RUN: INFRA_ID=mw1(기존과 동일), 나머지는 TC-WF-007 과 같음
// 3.Run → 토스트 기록 4.일정 시간(최대 6분) Workflow 상태와 Infra 목록을 관찰
// 기대결과: 중복 에러 또는 실행 거부, 추가 자원 미생성(mw1 노드 수·Infra 수 변화 없음)
// 판정: 오류 토스트/Workflow 실패가 있고 자원이 늘지 않으면 PASS. 자원이 늘면 FAIL(중복 허용).
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, wfInfraName } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { gotoInfraList, infraRow, rowCells } from '../../lib/infra';
import { VM_DEPLOY_INPUT, runVmDeploy, workflowStatus } from '../../lib/workflow';
const nodesOf = (cells: Record<string, string>) => Number(cells['statusCount.countTotal'] ?? 'NaN');
for (const c of activeCsps(["AWS"])) {
test(`${tid("TC-WF-010", c)} ` + "Workflow Infra 이름 중복 RUN (구 IV3_S09)", async ({ page }) => {
const inp = VM_DEPLOY_INPUT[c.code]!;
const name = wfInfraName(c);
await loginAsAdmin(page);
await gotoInfraList(page);
test.skip(!(await infraRow(page, name).count()), `선행 TC-WF-007 의 ${name} 없음`);
const before = await rowCells(infraRow(page, name).first());
const infraCountBefore = await page.locator('.tabulator-row').filter({ has: page.locator('[tabulator-field="statusCount.countTotal"]') }).count();
const wfBefore = await workflowStatus(page, 'multi-csp-vm-deploy');
test.info().annotations.push({ type: 'before', description: `${name}: ${JSON.stringify(before)} / Infra 수 ${infraCountBefore} / workflow ${JSON.stringify(wfBefore)}` });
const { toasts } = await runVmDeploy(page, inp, name, `g${c.n}`);
const errToast = toasts.find((t) => /fail|error|exist|duplicat|invalid/i.test(t));
// 관찰: Workflow 상태와 mw1 노드 수(최대 6분)
let after = before; let wf = wfBefore; let infraCountAfter = infraCountBefore;
for (let i = 0; i < 6; i++) {
await page.waitForTimeout(45_000);
wf = await workflowStatus(page, 'multi-csp-vm-deploy');
await gotoInfraList(page);
after = await rowCells(infraRow(page, name).first());
infraCountAfter = await page.locator('.tabulator-row').filter({ has: page.locator('[tabulator-field="statusCount.countTotal"]') }).count();
test.info().annotations.push({ type: `observe-${i}`, description: `workflow ${JSON.stringify(wf)} / ${name} ${after.status} nodes ${nodesOf(after)} / Infra 수 ${infraCountAfter}` });
if (/fail|error|success|complete|done/i.test(wf.status) && i >= 1) break;
}
test.info().annotations.push({ type: 'result', description: `오류 토스트: ${errToast ?? '(없음)'} / workflow 최종 ${JSON.stringify(wf)} / 노드 ${nodesOf(before)} → ${nodesOf(after)} / Infra 수 ${infraCountBefore} → ${infraCountAfter}` });
expect(nodesOf(after), `${name} 노드 수 변화 없음(추가 자원 미생성)`).toBe(nodesOf(before));
expect(infraCountAfter, 'Infra 수 변화 없음').toBe(infraCountBefore);
expect(errToast || /fail|error/i.test(wf.status) ? 'rejected' : 'accepted', '중복 에러 또는 실행 거부').toBe('rejected');
});
}tests/wf/TC-WF-011.spec.ts · 66줄 · 해시 93b5b74f · 시나리오 보기
// TC-WF-011: Workflow로 K8s 클러스터 생성 (nodegroup 1개) [정상, CSP별]
// 화면: Workflows(iframe) → multi-csp-k8s-cluster-deploy RUN → Workflows Status / LOG → Workloads > K8s Workloads(Project p{n})
// 시작 상태: admin(mcmp) 로그인, ws01. Project p{n} 존재(TC-IAM-052)
// 절차(AWS): 1.Workflows → multi-csp-k8s-cluster-deploy RUN: NAMESPACE=p1, CSP_LIST=aws, Spec t3.medium, CLUSTER_PREFIX=k1, K8S_NODEGROUP_PREFIX=g1, node 1(min/max 1)
// 2.Run → Workflow Status(SUCCESS/FAILURE) 대기 3.K8s Workloads(p1) 목록에 클러스터 표시·상태 확인
// 기대결과: K8s 클러스터 Ready(Active), nodegroup 1개(node 1)
// 클러스터 이름은 {CLUSTER_PREFIX}-{csp} 형식일 수 있음(Infra 워크플로우 nodegroup 규칙과 같다면 k1-aws) → 실행 결과로 확인
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sName, k8sNodegroup, k8sProject, WORKSPACE } from '../../lib/data';
import { loginAsAdmin, selectWorkspaceProject } from '../../lib/ui';
import { K8S_DEPLOY_INPUT, runK8sDeploy, workflowStatus } from '../../lib/workflow';
const K8S_URL = '/webconsole/operations/manage/workloads/k8sworkloads';
async function k8sList(page: import('@playwright/test').Page, prj: string) {
await page.goto(K8S_URL);
await selectWorkspaceProject(page, WORKSPACE, prj, true);
await page.getByText(/Showing \d+/).first().waitFor({ timeout: 30_000 }).catch(() => {});
await page.waitForTimeout(2000);
return (await page.getByRole('row').allInnerTexts()).map((s) => s.replace(/\s+/g, ' '));
}
for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
test(`${tid("TC-WF-011", c)} ` + "Workflow로 K8s 클러스터 생성 (nodegroup 1개) (구 IV3_S13)", async ({ page }) => {
const inp = K8S_DEPLOY_INPUT[c.code];
test.skip(!inp, `${c.name}: K8s Workflow 입력값 미확정(사전점검 필요)`);
const prj = k8sProject(c);
const prefix = k8sName(c);
await loginAsAdmin(page);
const before = await k8sList(page, prj);
test.info().annotations.push({ type: 'k8s-before', description: before.join(' || ').slice(0, 500) });
const exists = before.some((r) => new RegExp(`\\b${prefix}(-${inp!.csp})?\\b`).test(r));
if (exists) {
test.info().annotations.push({ type: 'preexisting', description: `${prefix} 클러스터 이미 존재 → Run 생략` });
} else {
const { toasts } = await runK8sDeploy(page, inp!, prj, prefix, k8sNodegroup(c), '1');
expect(toasts.find((t) => /fail|error|invalid/i.test(t)), 'Run 직후 오류 토스트 없음').toBeUndefined();
// Workflow 완료 대기(최대 40분)
let wf = { status: '', runDate: '' };
for (let i = 0; i < 80; i++) {
await page.waitForTimeout(30_000);
wf = await workflowStatus(page, 'multi-csp-k8s-cluster-deploy');
if (i % 4 === 0) test.info().annotations.push({ type: `wf-${i}`, description: JSON.stringify(wf) });
if (/success|failure|fail|abort/i.test(wf.status)) break;
}
test.info().annotations.push({ type: 'wf-final', description: JSON.stringify(wf) });
expect(wf.status, 'Workflow SUCCESS').toMatch(/success/i);
}
// K8s Workloads 목록: 클러스터 표시·상태
let rows: string[] = [];
await expect.poll(async () => { rows = await k8sList(page, prj); return rows.some((r) => r.includes(prefix)); },
{ message: `K8s Workloads(${prj}) 에 ${prefix}*`, timeout: 10 * 60_000, intervals: [30_000] }).toBe(true);
const row = rows.find((r) => r.includes(prefix))!;
test.info().annotations.push({ type: 'k8s-row', description: row });
expect(row, '클러스터 Active/Running/Ready').toMatch(/active|running|ready/i);
// 기대결과 'nodegroup 1개(node 1)' — 목록 Node Group 칸(개수)으로 확인(2026-10-15 보완: 이전엔 Active 만 봐서 NodeGroup 0개도 PASS 로 판정됨)
// AWS 는 tumblebug nodeGroupsOnCreation=false(assets/k8sclusterinfo.yaml) → 클러스터 생성 후 NodeGroup 을 따로 추가해야 하는데 workflow 에 그 단계가 없어 0개(2026-10-15 AWS FAIL)
// 행 텍스트 예: 'Active k1-aws 0 vpc-…'(셀 사이 공백이 없을 수 있음) → 클러스터 이름 바로 뒤 숫자가 Node Group 개수
const cname = row.match(new RegExp(`${prefix}(?:-${inp!.csp})?`))?.[0] ?? prefix;
const ngCount = Number(row.match(new RegExp(`${cname}\\s*(\\d+)\\s*(?:vpc-|tb|N/A)`))?.[1] ?? NaN);
test.info().annotations.push({ type: 'nodegroup-count', description: String(ngCount) });
expect(ngCount, 'NodeGroup 1개').toBeGreaterThanOrEqual(1);
});
}tests/wf/TC-WF-012.spec.ts · 45줄 · 해시 6424fa8c · 시나리오 보기
// TC-WF-012 Event Listener 를 통한 Workflow 실행 (GET) [정상, AWS]
// v0.6 TC-ID: TC-WF-EL-06 — 기존 spec(mcmp-e2e deploy/tc/workflow/TC-WF-EL-06.spec.ts, deploy/scenarios/C3-svc-wf/C3-07-wf-el-run.spec.ts, mc-web-console/specs/workflow/wf-el.spec.ts)은 절차만 참고. 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Event Listeners 의 Action URL(목록 표시값)을 외부에서 GET 호출 → Workflows 목록 Status · LOG
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-015) 완료, 연결 Workflow 실행 가능, Jenkins 정상 연결
// 절차: 1.Listener 의 Action URL 에 Query 를 붙여 GET 호출 2.접수 응답과 신규 Build 확인 3.Build LOG 에서 전달값 확인
// 입력값(시트): GET /eventlistener/run/{eventListenerIdx}?NAMESPACE=ns01&INFRA_ID=vm-mariadb-data-init®ION=ap-northeast-2&CONNECTION_NAME=aws-ap-northeast-2&DB_NAME=testdb_get
// 기대결과: 응답 JSON code=200, data=true(요청 접수), 연결 Workflow 의 신규 Build 생성·상태 갱신, Query Parameter 가 해당 Build 에 반영
// 비고: 접수 응답과 최종 Build 결과는 별도 판정(시트). 최종 결과·실패 원인은 annotation 으로 남긴다(최대 20분 관찰)
// 💰 연결 Workflow 는 infra-create 로 AWS VM 을 만든다 → 테스트 후 vm-mariadb-data-init-cleanup 으로 정리(사용자 결정 2026-10-14)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { elByName, callEventListener, failureLines, gotoWorkflows, workflowRows } from '../../lib/iv3-wf';
const EL_NAME = 'el-vm-mariadb-backup-import-data-init';
const QUERY = { NAMESPACE: 'ns01', INFRA_ID: 'vm-mariadb-data-init', REGION: 'ap-northeast-2', CONNECTION_NAME: 'aws-ap-northeast-2', DB_NAME: 'testdb_get' };
test('TC-WF-012_AWS TC-WF-012 Event Listener 를 통한 Workflow 실행 (GET) (구 IV3_S42)', async ({ page }) => {
test.setTimeout(30 * 60_000);
await loginAsAdmin(page);
const el = await elByName(page, EL_NAME);
test.skip(!el, `선행 TC-WF-015 의 ${EL_NAME} 없음`);
const wfIdx = Number(el.workflowIdx);
let r!: Awaited<ReturnType<typeof callEventListener>>;
await test.step(`1~2. GET ${el.eventListenerCallUrl}?… → 접수 응답 · 신규 Build`, async () => {
r = await callEventListener(page, el.eventListenerIdx, wfIdx, 'GET', QUERY);
expect(r.res.status).toBe(200);
expect({ code: r.res.body?.code, data: r.res.body?.data }, '접수 응답').toEqual({ code: 200, data: true });
expect(r.build, '연결 Workflow 의 신규 Build').toBeTruthy();
const rows = await workflowRows(await gotoWorkflows(page));
test.info().annotations.push({ type: 'wf-status', description: `${el.workflowName}: ${rows[el.workflowName]?.status} / Last Run ${rows[el.workflowName]?.runDate}` });
});
await test.step('3. Build LOG: 전달값 반영 · 최종 결과/실패 원인', async () => {
const log = r.build!.buildLog;
const reflected = Object.entries(QUERY).filter(([, v]) => log.includes(v)).map(([k]) => k);
test.info().annotations.push({ type: 'reflected', description: `로그에 나타난 Query 값: ${reflected.join(', ') || '없음'} (DB_NAME 은 mariadb-install 이후 Stage 에서만 사용)` });
if (r.finished === 'FAILURE') test.info().annotations.push({ type: 'failure-cause', description: failureLines(log).join(' / ') });
test.info().annotations.push({ type: 'log-tail', description: log.slice(-1200) });
expect(reflected, 'NAMESPACE 반영(namespace-ensure 로그)').toContain('NAMESPACE');
// 시트: 접수 응답과 최종 Build 결과는 별도 판정 → 최종 결과는 판정에 넣지 않는다
});
});tests/wf/TC-WF-013.spec.ts · 43줄 · 해시 5cdf3fac · 시나리오 보기
// TC-WF-013 Event Listener 를 통한 Workflow 실행 (POST) [정상, AWS]
// v0.6 TC-ID: TC-WF-EL-07 — 기존 spec(mcmp-e2e deploy/tc/workflow/TC-WF-EL-07.spec.ts, deploy/scenarios/C3-svc-wf/C3-07-wf-el-run.spec.ts, mc-web-console/specs/workflow/wf-el.spec.ts)은 절차만 참고. 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Event Listeners 의 Action URL 을 외부에서 POST 호출 → Workflows 목록 Status · LOG
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-015) 완료, 연결 Workflow 실행 가능, Jenkins 정상 연결
// 절차: 1.Listener 의 Action URL 에 JSON Body 를 POST 2.접수 응답과 신규 Build 확인 3.Build 의 전달값과 최종 결과/실패 원인 확인
// 입력값(시트): POST /eventlistener/run/{eventListenerIdx}, Content-Type application/json,
// Body {"NAMESPACE":"ns01","INFRA_ID":"vm-mariadb-data-init","REGION":"ap-northeast-2","CONNECTION_NAME":"aws-ap-northeast-2","DB_NAME":"testdb_post","INSERT_SQL":"INSERT INTO sample_data (id, name) VALUES (2, 'event row');"}
// 기대결과: 응답 JSON code=200, data=true(요청 접수), Request Body 가 해당 Build 에 반영, 최종 실행 결과 또는 실패 원인 확인 가능
// 비고: 접수 응답과 최종 Build 결과는 별도 판정(시트). 💰 연결 Workflow 는 AWS VM 을 만든다 → 테스트 후 정리
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { elByName, callEventListener, failureLines } from '../../lib/iv3-wf';
const EL_NAME = 'el-vm-mariadb-backup-import-data-init';
const BODY = { NAMESPACE: 'ns01', INFRA_ID: 'vm-mariadb-data-init', REGION: 'ap-northeast-2', CONNECTION_NAME: 'aws-ap-northeast-2', DB_NAME: 'testdb_post', INSERT_SQL: "INSERT INTO sample_data (id, name) VALUES (2, 'event row');" };
test('TC-WF-013_AWS TC-WF-013 Event Listener 를 통한 Workflow 실행 (POST) (구 IV3_S43)', async ({ page }) => {
test.setTimeout(30 * 60_000);
await loginAsAdmin(page);
const el = await elByName(page, EL_NAME);
test.skip(!el, `선행 TC-WF-015 의 ${EL_NAME} 없음`);
let r!: Awaited<ReturnType<typeof callEventListener>>;
await test.step(`1~2. POST ${el.eventListenerCallUrl} (JSON Body) → 접수 응답 · 신규 Build`, async () => {
r = await callEventListener(page, el.eventListenerIdx, Number(el.workflowIdx), 'POST', BODY);
expect(r.res.status).toBe(200);
expect({ code: r.res.body?.code, data: r.res.body?.data }, '접수 응답').toEqual({ code: 200, data: true });
expect(r.build, '연결 Workflow 의 신규 Build').toBeTruthy();
});
await test.step('3. Build LOG: Body 값 반영 · 최종 결과 또는 실패 원인', async () => {
const log = r.build!.buildLog;
const reflected = Object.entries(BODY).filter(([, v]) => log.includes(v)).map(([k]) => k);
const cause = r.finished === 'FAILURE' ? failureLines(log) : [];
test.info().annotations.push({ type: 'reflected', description: `로그에 나타난 Body 값: ${reflected.join(', ') || '없음'} (DB_NAME·INSERT_SQL 은 DB Stage 에서만 사용)` });
test.info().annotations.push({ type: 'final', description: `${r.finished ?? '(진행 중)'}${cause.length ? ' / 원인: ' + cause.join(' / ') : ''}` });
test.info().annotations.push({ type: 'log-tail', description: log.slice(-1200) });
expect(reflected, 'NAMESPACE 반영').toContain('NAMESPACE');
expect(r.finished === 'SUCCESS' || cause.length > 0, '최종 결과 SUCCESS 또는 실패 원인 확인 가능').toBe(true);
});
});tests/wf/TC-WF-014.spec.ts · 43줄 · 해시 fd712f78 · 시나리오 보기
// TC-WF-014 Event Listener 목록 조회 [정상, 공통]
// v0.6 TC-ID: TC-WF-EL-01 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-el.spec.ts, deploy/tc/workflow/TC-WF-EL-01.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Event Listeners (iframe :18083/web/workflows/eventListener/list)
// 시작 상태: admin(mcmp) 로그인, Workspace=ws01 / Project=default. Workflow 메뉴 접근 가능
// 절차: 1.Workspace/Project 선택 후 Event Listener 목록 조회 2.등록 항목의 연결 정보·Action URL·버튼 확인
// 기대결과: Listener 이름·연결 Workflow·Description·Action URL 표시, Edit/DELETE 버튼 표시. 미등록 상태에서는 빈 목록(Showing 0 rows)
// 판정: 화면 목록 = 화면이 부르는 GET /eventlistener/list 응답(이름·수)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoEventListeners, wfApi, clean } from '../../lib/iv3-wf';
test('TC-WF-014 Event Listener 목록 조회 (구 IV3_S39)', async ({ page }) => {
await loginAsAdmin(page);
const f = await gotoEventListeners(page);
await test.step('1. 컬럼: Event Listener Name / Connect Workflow Name / Event Listener Desc / Action URL / Action', async () => {
for (const h of ['Event Listener Name', 'Connect Workflow Name', 'Event Listener Desc', 'Action URL', 'Action']) await expect(f.getByText(h, { exact: true }).first()).toBeVisible();
});
await test.step('2. 화면 목록 = GET /eventlistener/list · 행마다 Action URL 과 Edit/DELETE 버튼', async () => {
const api = await wfApi(page, 'GET', '/eventlistener/list');
const list: any[] = api.body?.data ?? [];
const rows = f.locator('.tabulator-row');
const n = await rows.count();
const shown: string[] = [];
for (let i = 0; i < n; i++) {
const r = rows.nth(i);
const name = clean(await r.locator('[tabulator-field="eventListenerName"]').innerText());
const url = clean(await r.locator('[tabulator-field="eventListenerUrl"]').innerText());
const btns = (await r.locator('button').allInnerTexts()).map(clean);
shown.push(`${name}(${clean(await r.locator('[tabulator-field="workflowName"]').innerText())}) ${url} [${btns.join('/')}]`);
expect(url, `${name} Action URL`).toMatch(/eventlistener\/run\/\d+|https?:\/\//i);
expect(btns.map((b) => b.toUpperCase()), `${name} 버튼`).toEqual(expect.arrayContaining(['EDIT', 'DELETE']));
}
test.info().annotations.push({ type: 'rows', description: shown.join(' || ') || '(빈 목록)' });
test.info().annotations.push({ type: 'api', description: `HTTP ${api.status}, ${list.length}건: ${list.map((x) => x.eventListenerName).join(', ')}` });
expect(api.status).toBe(200);
expect(n).toBe(list.length);
if (n === 0) await expect(f.getByText(/Showing 0 rows/).first()).toBeVisible();
});
});tests/wf/TC-WF-015.spec.ts · 65줄 · 해시 22d09a90 · 시나리오 보기
// TC-WF-015 신규 Event Listener 생성 [정상, 공통]
// v0.6 TC-ID: TC-WF-EL-02 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-el.spec.ts, deploy/tc/workflow/TC-WF-EL-02.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Event Listeners → New Event Listener 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-014) 완료, 연결 Workflow 존재, Jenkins 정상 연결
// 절차: 1.New Event Listener 에서 이름 입력 및 Duplicate Check 2.Description / Connect Workflow / 기본 Parameter 입력 3.Create 후 목록과 저장값·Action URL 확인
// 입력값(시트): Name el-vm-mariadb-backup-import-data-init / Description VM MariaDB data init webhook / Workflow vm-mariadb-backup-import-data-init
// Parameter NAMESPACE ns01, INFRA_ID vm-mariadb-data-init, DB_NAME testdb, DB_USER mariadb_user, DB_PASSWORD mariadb_pass
// 기대결과: 신규 Listener 목록 등록 및 Action URL 생성, 연결 Workflow 와 기본 Parameter 저장
// 비고: Workflow 를 고르면 파라미터가 그 Workflow 값으로 채워지고 NAMESPACE 는 상단 선택 Project(default)로 바뀐다 → 시트 값으로 다시 입력. 이미 있으면(재실행) 삭제 후 진행
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openNewEventListener, setFormParam, collectToasts, gotoEventListeners, elRow, elByName, deleteEventListener, clean } from '../../lib/iv3-wf';
export const EL_NAME = 'el-vm-mariadb-backup-import-data-init';
export const EL_WF = 'vm-mariadb-backup-import-data-init';
const DESC = 'VM MariaDB data init webhook';
const PARAMS: Record<string, string> = { NAMESPACE: 'ns01', INFRA_ID: 'vm-mariadb-data-init', DB_NAME: 'testdb', DB_USER: 'mariadb_user', DB_PASSWORD: process.env.WF_DB_PASSWORD ?? 'mariadb_pass' };
test('TC-WF-015 신규 Event Listener 생성 (구 IV3_S40)', async ({ page }) => {
await loginAsAdmin(page);
if (await elByName(page, EL_NAME)) test.info().annotations.push({ type: 'pre-clean', description: (await deleteEventListener(page, EL_NAME)).join(' | ') });
await test.step(`1. New Event Listener → ${EL_NAME} → Duplicate Check`, async () => {
const { f, m } = await openNewEventListener(page);
await m.getByPlaceholder('Enter Event Listener Name').fill(EL_NAME);
await m.getByRole('button', { name: 'Duplicate Check' }).click();
const t = await collectToasts(page, f, 2500);
test.info().annotations.push({ type: 'duplicate-check', description: t.join(' | ') });
expect(t.join(' ')).toMatch(/Name is available/);
await test.step(`2. Description · Workflow ${EL_WF} · Parameter 입력`, async () => {
await m.getByPlaceholder('Enter Event Listener Description').fill(DESC);
await m.locator('select').first().selectOption({ label: EL_WF });
await page.waitForTimeout(2000);
const missing: string[] = [];
for (const [k, v] of Object.entries(PARAMS)) if (!(await setFormParam(f, k, v))) missing.push(k);
test.info().annotations.push({ type: 'missing', description: missing.join(', ') || '없음' });
expect(missing, '입력란을 찾지 못한 Parameter').toEqual([]);
});
await test.step('3. Create', async () => {
await m.getByRole('button', { name: 'Create', exact: true }).click();
const t2 = await collectToasts(page, f, 5000);
test.info().annotations.push({ type: 'create', description: t2.join(' | ') || '(토스트 없음)' });
expect(t2.join(' ')).toMatch(/Registered successfully/);
});
});
await test.step('4. 목록 등록 · Action URL · 저장된 연결 Workflow/Parameter', async () => {
const fl = await gotoEventListeners(page);
const row = elRow(fl, EL_NAME);
await expect(row).toHaveCount(1);
const url = clean(await row.locator('[tabulator-field="eventListenerUrl"]').innerText());
const d = await elByName(page, EL_NAME);
const saved: Record<string, string> = {};
for (const p of d?.workflowParams ?? []) saved[p.paramKey] = p.paramValue;
test.info().annotations.push({ type: 'saved', description: `idx ${d?.eventListenerIdx} / workflow ${d?.workflowName ?? d?.workflowIdx} / desc ${d?.eventListenerDesc} / callUrl ${d?.eventListenerCallUrl} / 화면 URL ${url} / ${Object.keys(PARAMS).map((k) => `${k}=${k === 'DB_PASSWORD' ? '***' : saved[k]}`).join(', ')}` });
expect(url, '화면 Action URL').toContain(`/eventlistener/run/${d?.eventListenerIdx}`);
expect(d?.eventListenerDesc).toBe(DESC);
expect(clean(await row.locator('[tabulator-field="workflowName"]').innerText())).toBe(EL_WF);
for (const [k, v] of Object.entries(PARAMS)) expect(saved[k], `저장된 ${k}`).toBe(v);
});
});tests/wf/TC-WF-016.spec.ts · 52줄 · 해시 6f9ebd68 · 시나리오 보기
// TC-WF-016 Event Listener 이름 중복 검사 [예외, 공통]
// v0.6 TC-ID: TC-WF-EL-03 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-el.spec.ts, deploy/tc/workflow/TC-WF-EL-03.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Event Listeners → New Event Listener 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-015) 완료 → 기존 Listener 존재
// 절차: 1.기존 이름으로 Duplicate Check 2.미사용 이름으로 바꿔 다시 Duplicate Check 3.이름을 다시 바꾼 뒤 검증 없이 Create 클릭
// 입력값(시트): 기존 el-vm-mariadb-backup-import-data-init / 미사용 …-02 / 재변경 …-03
// 기대결과: 기존 이름 중복 안내, 미사용 이름 통과, 재변경 후 미검증 Create 시 오류 안내 및 차단(Listener 미생성)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openNewEventListener, collectToasts, elByName, wfApi } from '../../lib/iv3-wf';
const EXIST = 'el-vm-mariadb-backup-import-data-init';
test('TC-WF-016 Event Listener 이름 중복 검사 (구 IV3_S41)', async ({ page }) => {
await loginAsAdmin(page);
test.skip(!(await elByName(page, EXIST)), `선행 TC-WF-015 의 ${EXIST} 없음`);
const before = ((await wfApi(page, 'GET', '/eventlistener/list')).body?.data ?? []).length;
const { f, m } = await openNewEventListener(page);
const name = m.getByPlaceholder('Enter Event Listener Name');
const toastGone = () => f.locator('[class*="toast"]').first().waitFor({ state: 'detached', timeout: 8000 }).catch(() => {}); // 이전 단계 토스트가 섞이지 않게
const check = async () => { await toastGone(); await m.getByRole('button', { name: 'Duplicate Check' }).click(); return (await collectToasts(page, f, 2500)).join(' | '); };
await test.step(`1. 기존 이름 ${EXIST} → Duplicate Check → 중복 안내`, async () => {
await name.fill(EXIST);
const t = await check();
test.info().annotations.push({ type: 'exist', description: t });
expect(t).toMatch(/already in use/i);
});
await test.step(`2. 미사용 이름 ${EXIST}-02 → Duplicate Check 통과`, async () => {
await name.fill(`${EXIST}-02`);
const t = await check();
test.info().annotations.push({ type: 'unused', description: t });
expect(t).toMatch(/Name is available/);
});
await test.step(`3. 이름을 ${EXIST}-03 으로 바꾸고 검증 없이 Create → 오류 안내 · 미생성`, async () => {
await name.fill(`${EXIST}-03`);
await m.getByPlaceholder('Enter Event Listener Description').fill('duplicate check test');
await toastGone();
await m.getByRole('button', { name: 'Create', exact: true }).click();
const t = (await collectToasts(page, f, 4000)).join(' | ');
test.info().annotations.push({ type: 'create-unchecked', description: t || '(토스트 없음)' });
expect(t).toMatch(/perform duplicate check/i);
await expect(m, '모달 유지(저장 차단)').toBeVisible();
const after = ((await wfApi(page, 'GET', '/eventlistener/list')).body?.data ?? []) as any[];
expect(after.length, 'Listener 수 변화 없음').toBe(before);
expect(after.map((x) => x.eventListenerName)).not.toContain(`${EXIST}-03`);
});
});tests/wf/TC-WF-017.spec.ts · 62줄 · 해시 7c90f946 · 시나리오 보기
// TC-WF-017 Event Listener 상세 수정 [정상, AWS]
// v0.6 TC-ID: TC-WF-EL-04 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-el.spec.ts, deploy/tc/workflow/TC-WF-EL-04.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Event Listeners → 대상 행 Edit → Event Listener Edit 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-015) 완료, 연결 Workflow 실행 가능, Jenkins 정상 연결
// 절차: 1.Edit 에서 Description/Parameter 변경 후 저장 2.재조회하여 저장값 확인 3.Action URL 을 호출하여 수정값 반영 확인
// 입력값(시트): Event Listener el-vm-mariadb-backup-import-data-init / Description updated webhook desc / DB_NAME testdb_updated
// INSERT_SQL INSERT INTO sample_data (id, name) VALUES (3, 'updated row');
// 기대결과: 수정한 Description/Parameter 및 연결 Workflow 유지, 호출 Build 에 수정된 Parameter 반영
// 비고: 3 의 호출은 Query/Body 없이 GET(저장된 Parameter 만으로 실행). 💰 연결 Workflow 는 AWS VM 을 만든다 → 테스트 후 정리
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoEventListeners, elRow, elByName, wfFrame, setFormParam, collectToasts, callEventListener, failureLines } from '../../lib/iv3-wf';
const EL_NAME = 'el-vm-mariadb-backup-import-data-init';
const DESC = 'updated webhook desc';
const PARAMS: Record<string, string> = { DB_NAME: 'testdb_updated', INSERT_SQL: "INSERT INTO sample_data (id, name) VALUES (3, 'updated row');" };
test('TC-WF-017_AWS TC-WF-017 Event Listener 상세 수정 (구 IV3_S44)', async ({ page }) => {
test.setTimeout(30 * 60_000);
await loginAsAdmin(page);
const before = await elByName(page, EL_NAME);
test.skip(!before, `선행 TC-WF-015 의 ${EL_NAME} 없음`);
await test.step(`1. Edit → Description "${DESC}" · DB_NAME · INSERT_SQL 변경 → Edit 저장`, async () => {
const fl = await gotoEventListeners(page);
await elRow(fl, EL_NAME).locator('#edit-btn').click();
const f = await wfFrame(page);
const m = f.locator('.modal.show');
await expect(m.getByText('Event Listener Edit')).toBeVisible();
await page.waitForTimeout(2000);
await m.getByPlaceholder('Enter Event Listener Description').fill(DESC);
const missing: string[] = [];
for (const [k, v] of Object.entries(PARAMS)) if (!(await setFormParam(f, k, v))) missing.push(k);
expect(missing, '입력란을 찾지 못한 Parameter').toEqual([]);
await m.getByRole('button', { name: 'Edit', exact: true }).click();
const t = await collectToasts(page, f, 5000);
test.info().annotations.push({ type: 'edit', description: t.join(' | ') || '(토스트 없음)' });
expect(t.join(' ')).toMatch(/Updated successfully/);
});
await test.step('2. 재조회: Description · Parameter · 연결 Workflow 유지', async () => {
const d = await elByName(page, EL_NAME);
const saved: Record<string, string> = {};
for (const p of d?.workflowParams ?? []) saved[p.paramKey] = p.paramValue;
test.info().annotations.push({ type: 'saved', description: `desc ${d?.eventListenerDesc} / workflow ${d?.workflowName} (${d?.workflowIdx}) / DB_NAME=${saved.DB_NAME} / INSERT_SQL=${saved.INSERT_SQL} / NAMESPACE=${saved.NAMESPACE}` });
expect(d?.eventListenerDesc).toBe(DESC);
expect(String(d?.workflowIdx)).toBe(String(before.workflowIdx));
for (const [k, v] of Object.entries(PARAMS)) expect(saved[k], `저장된 ${k}`).toBe(v);
});
await test.step('3. Action URL 호출(GET, Query 없음) → 신규 Build · 수정값 반영', async () => {
const r = await callEventListener(page, before.eventListenerIdx, Number(before.workflowIdx), 'GET', {});
expect({ code: r.res.body?.code, data: r.res.body?.data }, '접수 응답').toEqual({ code: 200, data: true });
expect(r.build, '신규 Build').toBeTruthy();
const log = r.build!.buildLog;
const reflected = Object.entries(PARAMS).filter(([, v]) => log.includes(v)).map(([k]) => k);
test.info().annotations.push({ type: 'reflected', description: `로그에 나타난 수정값: ${reflected.join(', ') || '없음'} / 결과 ${r.finished ?? '(진행 중)'}${r.finished === 'FAILURE' ? ' / 원인: ' + failureLines(log).join(' / ') : ''}` });
test.info().annotations.push({ type: 'log-tail', description: log.slice(-1200) });
});
});tests/wf/TC-WF-018.spec.ts · 62줄 · 해시 1ed97d4d · 시나리오 보기
// TC-WF-018 Event Listener 삭제 [정상, 공통]
// v0.6 TC-ID: TC-WF-EL-05 — 기존 spec(mcmp-e2e mc-web-console/specs/workflow/wf-el.spec.ts, deploy/tc/workflow/TC-WF-EL-05.spec.ts)은 절차만 참고. 셀렉터가 현재 화면과 맞지 않고 실패 시 warn 후 통과하는 구조라 동작 코드는 재사용하지 않음(사용자 결정 2026-10-14)
// 화면: Workflows > Event Listeners → 대상 행 Delete → Delete Event Listener 확인 모달
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-015 절차로 삭제용 Listener 생성, 연결 Workflow 존재
// 절차: 1.삭제용 Listener 의 Delete → Delete 실행 2.목록에서 제거된 뒤 기존 Action URL 호출 3.응답과 신규 Build 미생성 확인
// 입력값(시트): Event Listener el-delete-test
// 기대결과: Listener 목록에서 삭제, 삭제 URL 응답 data=false 및 신규 Build 미생성
// 비고: 삭제용 Listener 의 연결 Workflow 는 TC-WF-002 의 workflow-create-test(없으면 S35 선행 필요). 이 Workflow 는 Image 가 비어 있어 잘못 실행돼도 infra-create 에서 실패한다(자원 미생성)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openNewEventListener, collectToasts, elByName, deleteEventListener, gotoEventListeners, elRow, workflowBuilds, workflowDetailByName, wfApi } from '../../lib/iv3-wf';
const EL_NAME = 'el-delete-test';
const TARGET_WF = 'workflow-create-test';
test('TC-WF-018 Event Listener 삭제 (구 IV3_S45)', async ({ page }) => {
await loginAsAdmin(page);
const wf = await workflowDetailByName(page, TARGET_WF);
test.skip(!wf, `연결할 Workflow ${TARGET_WF} 없음(TC-WF-002 선행)`);
const wfIdx = Number(wf!.workflowInfo.workflowIdx);
await test.step(`0. 삭제용 Listener ${EL_NAME} 생성(→ ${TARGET_WF})`, async () => {
if (await elByName(page, EL_NAME)) return;
const { f, m } = await openNewEventListener(page);
await m.getByPlaceholder('Enter Event Listener Name').fill(EL_NAME);
await m.getByRole('button', { name: 'Duplicate Check' }).click();
await page.waitForTimeout(1500);
await m.getByPlaceholder('Enter Event Listener Description').fill('delete test');
await m.locator('select').first().selectOption({ label: TARGET_WF });
await page.waitForTimeout(1500);
await m.getByRole('button', { name: 'Create', exact: true }).click();
const t = await collectToasts(page, f, 5000);
test.info().annotations.push({ type: 'pre-create', description: t.join(' | ') });
expect(t.join(' ')).toMatch(/Registered successfully/);
});
const el = await elByName(page, EL_NAME);
const buildsBefore = (await workflowBuilds(page, wfIdx)).length;
await test.step('1. Delete → 확인 모달 Delete', async () => {
const t = await deleteEventListener(page, EL_NAME);
test.info().annotations.push({ type: 'delete', description: t.join(' | ') || '(토스트 없음)' });
expect(t.join(' ')).toMatch(/Deleted successfully/);
});
await test.step('2. 목록에서 제거', async () => {
const fl = await gotoEventListeners(page);
await expect(elRow(fl, EL_NAME)).toHaveCount(0);
expect(await elByName(page, EL_NAME)).toBeUndefined();
});
await test.step(`3. 삭제된 URL(${el.eventListenerCallUrl}) GET 호출 → data=false · 신규 Build 없음`, async () => {
const res = await wfApi(page, 'GET', el.eventListenerCallUrl);
test.info().annotations.push({ type: 'call-deleted', description: `HTTP ${res.status} ${JSON.stringify(res.body).slice(0, 200)}` });
expect(res.body?.data, '삭제 URL 응답 data').toBe(false);
await page.waitForTimeout(30_000);
const after = (await workflowBuilds(page, wfIdx)).length;
test.info().annotations.push({ type: 'builds', description: `${TARGET_WF} Build 수 ${buildsBefore} → ${after}` });
expect(after, '신규 Build 미생성').toBe(buildsBefore);
});
});tests/wf/TC-WF-019.spec.ts · 110줄 · 해시 5d8aca50 · 시나리오 보기
// TC-WF-019 Workflow 로 오브젝트 스토리지 활용 [정상, AWS]
// v0.6 TC-ID: 없음(mc-workflow-manager 시트에만 있는 TC, 2026-10-14 추가)
// 화면: Workflows > Workflows → vm-object-storage-data-lab-init RUN → Run Workflow 팝업 → Workflows Status/LOG · Infra Workloads
// 시작 상태: admin(mcmp) 로그인, ws01/default. TC-WF-001) 완료, AWS 연결 및 VM/Object Storage 생성 권한, Infra ID·Bucket 논리 ID 미사용
// 절차: 1.RUN 모달에 VM/Object Storage 값을 설정하고 Run 2.Jenkins 결과와 VM/Bucket 생성·연결 확인
// (시트 3.CSP 콘솔 S3 업로드 · 4.JupyterLab 접속은 mc-web-console 밖 작업이라 수행하지 않음 — 사용자 결정 2026-10-14, 지침 4-0)
// 5.(정리) vm-object-storage-data-lab-cleanup RUN 으로 VM·Bucket 삭제
// 입력값(시트): NAMESPACE <선택 Project 의 ns_id>=default / INFRA_ID wf-object-storage-test / CSP aws / REGION ap-northeast-2 / CONNECTION_NAME aws-ap-northeast-2
// ZONE 기본값 / SPEC_ID·IMAGE_ID 현재 목록에서 선택(t3.medium, Ubuntu 22.04 ami-012a353bb3afb92ee — TC-INFRA-017 추천 확인값)
// OBJECT_STORAGE_PROVIDER aws / REGION ap-northeast-2 / NAMESPACE default / BUCKET bucket-aws-01 / JUPYTER_PORT 8888 / JUPYTER_ALLOWED_CIDR 0.0.0.0/0
// 기대결과: Jenkins SUCCESS 및 VM Running, 지정 Bucket 생성·연결(시트의 적재·JupyterLab 항목은 화면 밖이라 판정 제외)
// 비고: RUN 모달의 Image 칸은 기본이 빈 값(2026-10-14) → 상단 Image 검색 선택으로 지정. 💰 VM·Bucket 과금 → 5 에서 정리
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoWorkflows, openRun, setParam, getParam, pickSearchable, runAndCollectToasts } from '../../lib/workflow';
import { gotoInfraList, infraRow, rowCells, statusOf } from '../../lib/infra';
import { showAllRows, workflowDetailByName, workflowBuilds, failureLines } from '../../lib/iv3-wf';
const INIT = 'vm-object-storage-data-lab-init';
const CLEANUP = 'vm-object-storage-data-lab-cleanup';
const INFRA_ID = 'wf-object-storage-test';
const BUCKET = 'bucket-aws-01';
const NS = 'default';
// 2026-10-16: ami-012a353bb3afb92ee 가 Image 검색에서 'No matches'(1차 실행 실패). 같은 날 AWS Image 검색 결과는 custom image img1(ubuntu-jammy-22.04 server 20260731 기반) 1건뿐
// → WF_AWS_IMAGE 로 화면에 있는 이미지 지정(실행: WF_AWS_IMAGE=img1)
const IMAGE = process.env.WF_AWS_IMAGE ?? 'ami-012a353bb3afb92ee';
async function waitBuild(page: import('@playwright/test').Page, wfIdx: number, after: number, maxMs: number) {
const end = Date.now() + maxMs;
let b: { buildIdx: number; buildLog: string } | undefined;
while (Date.now() < end) {
await page.waitForTimeout(30_000);
b = (await workflowBuilds(page, wfIdx)).find((x) => Number(x.buildIdx) > after);
if (b && /Finished: \w+/.test(b.buildLog)) break;
}
return { build: b, finished: b?.buildLog.match(/Finished: (\w+)/)?.[1] };
}
test('TC-WF-019_AWS TC-WF-019 Workflow 로 오브젝트 스토리지 활용 (구 IV3_S46)', async ({ page }) => {
test.setTimeout(80 * 60_000);
await loginAsAdmin(page);
const init = await workflowDetailByName(page, INIT);
const cleanup = await workflowDetailByName(page, CLEANUP);
expect(init && cleanup, 'seed Workflow 2종 존재').toBeTruthy();
const initIdx = Number(init!.workflowInfo.workflowIdx);
const cleanIdx = Number(cleanup!.workflowInfo.workflowIdx);
const lastInit = Math.max(0, ...(await workflowBuilds(page, initIdx)).map((b) => Number(b.buildIdx)));
let ran = false;
try {
await test.step(`1. ${INIT} RUN → 입력값 설정 → Run`, async () => {
const f = await gotoWorkflows(page);
await showAllRows(f);
await openRun(f, INIT);
await pickSearchable(f, 'VM/K8s Spec', 't3.medium', 'aws+ap-northeast-2+t3.medium');
await pickSearchable(f, 'Image', IMAGE, IMAGE);
const vals: Record<string, string> = {
NAMESPACE: NS, INFRA_ID, CSP: 'aws', REGION: 'ap-northeast-2', CONNECTION_NAME: 'aws-ap-northeast-2',
OBJECT_STORAGE_PROVIDER: 'aws', OBJECT_STORAGE_REGION: 'ap-northeast-2', OBJECT_STORAGE_NAMESPACE: NS, OBJECT_STORAGE_BUCKET: BUCKET,
JUPYTER_PORT: '8888', JUPYTER_ALLOWED_CIDR: '0.0.0.0/0',
};
for (const [k, v] of Object.entries(vals)) await setParam(f, k, v);
const keys = [...Object.keys(vals), 'ZONE', 'SPEC_ID', 'IMAGE_ID'];
const shown: string[] = [];
for (const k of keys) shown.push(`${k}=${await getParam(f, k).catch(() => '?')}`);
test.info().annotations.push({ type: 'run-params', description: shown.join(', ') });
expect(await getParam(f, 'IMAGE_ID')).toBe(IMAGE);
expect(await getParam(f, 'SPEC_ID')).toContain('t3.medium');
await runAndCollectToasts(page, f);
ran = true;
});
await test.step('2. Jenkins 결과 SUCCESS · VM Running · Bucket 생성/연결', async () => {
const { build, finished } = await waitBuild(page, initIdx, lastInit, 45 * 60_000);
const log = build?.buildLog ?? '';
test.info().annotations.push({ type: 'build', description: `#${build?.buildIdx ?? '(없음)'} ${finished ?? '(진행 중)'}${finished === 'FAILURE' ? ' / 원인: ' + failureLines(log).join(' / ') : ''}` });
const bucketLines = log.split(/\r?\n/).filter((l) => /object storage|bucket/i.test(l) && !/curl /.test(l)).slice(0, 6).map((l) => l.slice(0, 200));
test.info().annotations.push({ type: 'bucket-log', description: bucketLines.join(' / ') || '(없음)' });
const jupyter = log.split(/\r?\n/).filter((l) => /jupyter|:8888/i.test(l) && /http/i.test(l)).slice(-2).map((l) => l.replace(/token=\w+/g, 'token=***').slice(0, 200));
test.info().annotations.push({ type: 'jupyter-url', description: `${jupyter.join(' / ') || '(로그에 없음)'} — 접속 확인은 화면 밖 작업이라 수행하지 않음` });
test.info().annotations.push({ type: 'log-tail', description: log.slice(-1000).replace(/token=\w+/g, 'token=***').replace(/(jupyter token\s*:\s*)\w+/gi, '$1***') }); // 2026-10-16: 'jupyter token : …' 줄도 마스킹
expect(finished, 'Jenkins 결과').toBe('SUCCESS');
await gotoInfraList(page);
const cells = await rowCells(infraRow(page, INFRA_ID).first());
test.info().annotations.push({ type: 'infra', description: JSON.stringify(cells) });
expect(statusOf(cells), `${INFRA_ID} 상태`).toMatch(/Running/i);
expect(log, `Bucket ${BUCKET} 생성/재사용 로그`).toContain(BUCKET);
});
} finally {
if (ran) {
await test.step(`5. (정리) ${CLEANUP} RUN → VM·Bucket 삭제`, async () => {
const lastClean = Math.max(0, ...(await workflowBuilds(page, cleanIdx)).map((b) => Number(b.buildIdx)));
const f = await gotoWorkflows(page);
await showAllRows(f);
await openRun(f, CLEANUP).catch(async () => { /* Tumblebug 선택 없는 Workflow 는 상단 선택 영역이 없을 수 있음 */ });
for (const [k, v] of Object.entries({ NAMESPACE: NS, INFRA_ID, OBJECT_STORAGE_BUCKET: BUCKET, OBJECT_STORAGE_NAMESPACE: NS, OBJECT_STORAGE_PROVIDER: 'aws', OBJECT_STORAGE_REGION: 'ap-northeast-2', OBJECT_STORAGE_DELETE_ENABLED: 'true' })) {
await setParam(f, k, v).catch(() => test.info().annotations.push({ type: 'cleanup-param-missing', description: k }));
}
await runAndCollectToasts(page, f);
const { build, finished } = await waitBuild(page, cleanIdx, lastClean, 25 * 60_000);
test.info().annotations.push({ type: 'cleanup', description: `#${build?.buildIdx ?? '(없음)'} ${finished ?? '(진행 중)'}${finished === 'FAILURE' ? ' / ' + failureLines(build!.buildLog).join(' / ') : ''}` });
await gotoInfraList(page);
const left = await infraRow(page, INFRA_ID).count();
test.info().annotations.push({ type: 'cleanup-check', description: `${INFRA_ID} 목록 잔존 ${left}건` });
});
}
}
});