Skip to content

Test Specs WF

dogfootman edited this page Oct 22, 2026 · 1 revision

Test Specs — WF (mc-workflow-manager)

생성: 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

TC-WF-001 (구 IV3_S34)

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

TC-WF-002 (구 IV3_S35)

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

TC-WF-003 (구 IV3_S36)

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

TC-WF-004 (구 IV3_S33)

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

TC-WF-005 (구 IV3_S37)

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

TC-WF-006 (구 IV3_S38)

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

TC-WF-007 (구 IV3_S01)

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

TC-WF-008 (구 IV3_S11)

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

TC-WF-009 (구 IV3_S08)

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

TC-WF-010 (구 IV3_S09)

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

TC-WF-011 (구 IV3_S13)

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

TC-WF-012 (구 IV3_S42)

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&REGION=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 결과는 별도 판정 → 최종 결과는 판정에 넣지 않는다
  });
});

TC-WF-013 (구 IV3_S43)

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

TC-WF-014 (구 IV3_S39)

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

TC-WF-015 (구 IV3_S40)

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

TC-WF-016 (구 IV3_S41)

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

TC-WF-017 (구 IV3_S44)

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

TC-WF-018 (구 IV3_S45)

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

TC-WF-019 (구 IV3_S46)

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

Clone this wiki locally