Skip to content

Test Specs IV6

dogfootman edited this page Oct 15, 2026 · 7 revisions

Test Specs — IV6 Cost Analysis

생성: 2026-10-15 · 기준: v07 설치 후 통합 검증(IV) · 원본: 여정별 계획서(Excel) ← Test Scenarios · IV 허브 · 기능 TC

Playwright(TypeScript) spec 소스입니다. 시나리오마다 ## <시나리오번호> 섹션이 있고 앵커는 #<시나리오번호 소문자> 입니다. 공용 헬퍼는 Test-Specs-Common. 비밀번호 등은 마스킹했습니다.

시나리오 파일 줄 해시
IV6_S01 tests/iv6/IV6_S01.spec.ts 26 1403e7ae
IV6_S02 tests/iv6/IV6_S02.spec.ts 26 12a46328
IV6_S03 tests/iv6/IV6_S03.spec.ts 21 bcde7f09
IV6_S04 tests/iv6/IV6_S04.spec.ts 11 b923b7d1
IV6_S05 tests/iv6/IV6_S05.spec.ts 12 48c5d3fd
IV6_S06 tests/iv6/IV6_S06.spec.ts 14 19fc48fb
IV6_S07 tests/iv6/IV6_S07.spec.ts 13 2fe01b98
IV6_S08 tests/iv6/IV6_S08.spec.ts 13 87d3975b
IV6_S09 tests/iv6/IV6_S09.spec.ts 13 278902e8
IV6_S10 tests/iv6/IV6_S10.spec.ts 20 61772b4a
IV6_S11 tests/iv6/IV6_S11.spec.ts 29 04ac610f
IV6_S12 tests/iv6/IV6_S12.spec.ts 13 00368daf
IV6_S13 tests/iv6/IV6_S13.spec.ts 63 2453da5d
IV6_S14 tests/iv6/IV6_S14.spec.ts 48 0113e697
IV6_S15 tests/iv6/IV6_S15.spec.ts 87 94ff9ef0
IV6_S16 tests/iv6/IV6_S16.spec.ts 37 ef9fd377
IV6_S17 tests/iv6/IV6_S17.spec.ts 44 1f57e8fb
IV6_S18 tests/iv6/IV6_S18.spec.ts 29 674106df
IV6_S19 tests/iv6/IV6_S19.spec.ts 36 733cea77
IV6_S20 tests/iv6/IV6_S20.spec.ts 40 d44d01c4

IV6_S01

tests/iv6/IV6_S01.spec.ts · 26줄 · 해시 1403e7ae · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { readCosts, costCspOf } from '../../lib/cost';

// IV6_S01 : Cost Analysis: Infra/K8s 비용 항목 확인 [정상, CSP별] (TC-COST-007)
// 화면: Cost Analysis > Billing Report(Base Info·Invoices) / Dash Board(Top 5 Services). Project=default, Workspace=ws01
// cost-optimizer 는 인프라를 직접 만들지 않는다 — 다른 여정(IV3·IV4)에서 만든 Infra·K8s 로 비용이 수집된다고 보고 그 시점 화면 값으로 판정(사용자 지시 2026-10-14)
// 판정: 대상 CSP 비용 > 0, Top 5 에 그 CSP 서비스 표시, CSP 별 합계 = Billing Report Base Info Total(시트 절차 4, 화면 표시는 소수 2자리).
// Dash Board Billing Summary 당월 합계와의 차이는 판정에 넣지 않고 기록만 한다(2026-10-14: GCP 신규 수집 뒤 Base Info 3.39 / Dash Board 당월 0.83 으로 어긋남 관찰).
// Cost Analysis 대상이 아닌 CSP(AWS·Azure·GCP·NCP 외)는 SKIP
for (const c of activeCsps(['AWS', 'Azure', 'Alibaba', 'GCP', 'Tencent', 'NHN Cloud', 'NCP', 'IBM', 'KT'])) {
  test(`${tid('IV6_S01', c)} Cost Analysis: Infra/K8s 비용 항목 확인`, async ({ page }) => {
    const csp = costCspOf(c);
    test.skip(!csp, `${c.name}: Cost Analysis 대상 CSP 아님(화면 CSP: AWS·Azure·GCP·NCP)`);
    const { base, top5, invoice, cur, total } = await readCosts(page);
    const mine = base.find((b) => b.csp === csp);
    const sum = base.reduce((s, b) => s + b.cost, 0);
    test.info().annotations.push({ type: 'judge', description: `${csp} ${mine?.cost ?? '없음'} USD, Top5 ${top5.some((t) => t.csp === csp) ? '포함' : '없음'}, Invoice ${invoice.filter((i) => i.csp === csp).length}건, CSP 합계 ${sum.toFixed(4)} = Base Info Total ${total}` });
    if (Math.abs(sum - cur) >= 0.01) test.info().annotations.push({ type: 'dashboard-mismatch', description: `참고: Dash Board Billing Summary 당월 ${cur.toFixed(4)} USD ≠ CSP 합계 ${sum.toFixed(4)} USD` });
    expect(mine, `Base Info 에 ${csp} 항목`).toBeTruthy();
    expect(Math.abs(sum - total), 'CSP 별 합계 = Base Info Total').toBeLessThan(0.01);
    expect(mine!.cost, `${csp} 비용 > 0 (다른 여정 Infra·K8s 비용 수집)`).toBeGreaterThan(0);
    expect(top5.map((t) => t.csp), `Top 5 Services 에 ${csp} 서비스`).toContain(csp);
  });
}

IV6_S02

tests/iv6/IV6_S02.spec.ts · 26줄 · 해시 12a46328 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { openCostSetupTab, closeModal } from '../../lib/cost';

// IV6_S02 : Cost Setup: CSP 탭 설정 상태 확인(설정 전/후 화면) [CSP별]  (사용자 지시 2026-10-07)
// 화면: Cost Analysis > Billing Report > Cost Setup 모달 > CSP 탭 (현재 AWS·GCP 탭만 제공 → 탭 없는 CSP 는 SKIP)
// 판정: AWS — 설정 완료(Configured / CUR setup is complete / S3 BUCKET / CUR REPORT) 또는 설정 단계 화면,
//       GCP — Dataset / Billing Export / Table Detection 단계 표시(완료 전 Waiting for Table). 조회만(Reconfigure·Change·Check Now 안 누름)
for (const c of activeCsps()) {
  test(`${tid('IV6_S02', c)} Cost Setup: CSP 탭 설정 상태 확인(설정 전/후 화면)`, async ({ page }) => {
    const { modal, tabs, has } = await openCostSetupTab(page, c);
    test.skip(!has, `Cost Setup 탭 없음(현재 탭: ${tabs.join(', ')}) - SKIP`);
    const text = (await modal.innerText()).replace(/\s+/g, ' ');
    test.info().annotations.push({ type: 'cost-setup-state', description: text.slice(0, 500) });
    if (c.code === 'AWS') {
      expect(text, 'AWS 탭 설정 상태').toMatch(/Configured|CUR setup|S3 BUCKET|Setup/i);
    } else if (c.code === 'GCP') {
      expect(text, 'GCP 설정 단계').toMatch(/Dataset/);
      expect(text, 'GCP 설정 단계').toMatch(/Billing Export/);
      expect(text, 'GCP 설정 단계').toMatch(/Table Detection/);
    } else {
      expect(text.length, `${c.name} 탭 내용`).toBeGreaterThan(30);
    }
    await closeModal(modal);
  });
}

IV6_S03

tests/iv6/IV6_S03.spec.ts · 21줄 · 해시 bcde7f09 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { openCostSetupTab, closeModal, frameText } from '../../lib/cost';

// IV6_S03 : Billing Report: 설정 CSP 기준 Base Info·Monthly Overview·Invoices 표시 확인 [CSP별]  (사용자 지시 2026-10-07)
// 화면: Cost Analysis > Billing Report. Cost Setup 탭이 없는 CSP 는 SKIP. 설정 후 24시간~3일은 금액 0 / No invoice 허용
for (const c of activeCsps()) {
  test(`${tid('IV6_S03', c)} Billing Report: 설정 CSP 기준 Base Info·Monthly Overview·Invoices 표시 확인`, async ({ page }) => {
    const { modal, tabs, has } = await openCostSetupTab(page, c);
    test.skip(!has, `Cost Setup 탭 없음(현재 탭: ${tabs.join(', ')}) - SKIP`);
    await closeModal(modal);
    await page.waitForTimeout(1000);
    const text = await frameText(page);
    for (const h of ['Base Info', 'Monthly Overview', 'Invoices', 'Archive Status']) expect.soft(text, `${h} 영역`).toContain(h);
    const re = new RegExp(`${c.code}\\s*([\\d.,]+)\\s*USD`, 'i');
    const m = text.match(re);
    test.info().annotations.push({ type: 'base-info', description: `${c.code} 금액: ${m ? m[1] + ' USD' : '(Base Info 에 항목 없음)'} / Total: ${text.match(/Total Amount\s*([\d.,]+)\s*USD/i)?.[1] ?? '?'} USD` });
    expect(text, `Base Info 에 ${c.code} 항목`).toMatch(re);
    test.info().annotations.push({ type: 'invoices', description: /No invoice/i.test(text) ? 'No invoice data (설정 직후 지연 허용)' : 'Invoice 데이터 있음' });
  });
}

IV6_S04

tests/iv6/IV6_S04.spec.ts · 11줄 · 해시 b923b7d1 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { openCost } from '../../lib/cost';

// IV6_S04 : Dash Board(Home): Billing Summary·Top 5 Services 표시 확인 [공통]  (사용자 지시 2026-10-07)
// 화면: Cost Analysis > Dash Board 메뉴. 데이터가 없으면 0 표시 허용
test('IV6_S04 Dash Board(Home): Billing Summary·Top 5 Services 표시 확인', async ({ page }) => {
  const text = await openCost(page, 'dashboard');
  expect(text, 'Billing Summary').toMatch(/Billing Summary/);
  expect(text, 'Top 5 Services by Cost').toMatch(/Top 5 Services/);
  test.info().annotations.push({ type: 'summary', description: text.match(/Billing Summary[^]{0,120}/)?.[0] ?? '' });
});

IV6_S05

tests/iv6/IV6_S05.spec.ts · 12줄 · 해시 48c5d3fd · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { openCost } from '../../lib/cost';

// IV6_S05 : Budget: 연간 예산 대비 사용·CSP 예산 설정 표 표시 확인 [공통]  (사용자 지시 2026-10-07)
// 화면: Cost Analysis > Budget. 조회만(Save Budget / Reset 안 누름)
test('IV6_S05 Budget: 연간 예산 대비 사용·CSP 예산 설정 표 표시 확인', async ({ page }) => {
  const text = await openCost(page, 'budget');
  expect(text, 'Budget vs Usage').toMatch(/Budget vs Usage/);
  expect(text, 'Monthly Summary').toMatch(/Monthly Summary/);
  expect(text, 'CSP Budget Setting').toMatch(/CSP Budget Setting/);
  for (const k of ['AWS', 'Azure', 'NCP', 'GCP']) expect.soft(text, `CSP 예산 표 ${k} 행`).toContain(k);
});

IV6_S06

tests/iv6/IV6_S06.spec.ts · 14줄 · 해시 19fc48fb · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { openCost } from '../../lib/cost';

// IV6_S06 : Alarm: Resource Recommendation 인스턴스 목록 표시 확인 [공통]  (사용자 지시 2026-10-07)
// 화면: Cost Analysis > Cost Alarm 메뉴. 조회만(Recommend 요청 안 함). API Key 미등록 안내 허용
test('IV6_S06 Alarm: Resource Recommendation 인스턴스 목록 표시 확인', async ({ page }) => {
  const text = await openCost(page, 'alarm');
  expect(text, 'Resource Recommendation').toMatch(/Resource Recommendation/);
  expect(text, '인스턴스 목록 헤더').toMatch(/INSTANCE ID/);
  const rows = text.match(/\b(i-[0-9a-f]{8,}|tb[a-z0-9]{10,})\s+\S+\s+(AWS|GCP|Azure|NCP)\b/g) ?? [];
  test.info().annotations.push({ type: 'instances', description: `${rows.length}건: ${rows.join(' | ').slice(0, 300)}` });
  expect(rows.length, '인스턴스 1건 이상').toBeGreaterThan(0);
  test.info().annotations.push({ type: 'api-key', description: /No API key registered/i.test(text) ? 'API Key 미등록 안내 표시' : 'API Key 있음' });
});

IV6_S07

tests/iv6/IV6_S07.spec.ts · 13줄 · 해시 2fe01b98 · 시나리오 보기

import { test } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { checkCollected } from '../../lib/cost';

// IV6_S07 : 비용 수집 결과 화면 반영: NCP (TC-COST-002)
// 시트 절차(NCP 청구 API 배치 :8094)의 cron/수동 배치 호출·DB 적재 확인은 화면 밖 작업이라 하지 않는다(사용자 지시 2026-10-14: mc-web-console 에서 테스트).
// 수집 결과가 화면에 반영됐는지로 판정: Billing Report Base Info 에 NCP 금액 > 0, Invoices 에 NCP 행, Dash Board Top 5 에 NCP 서비스.
// 금액이 없으면 수집 사전 설정(NCP API 인증키·Billing 조회 권한) 미비로 SKIP. 비용은 다른 여정에서 만든 Infra·K8s 로 수집된다고 본다.
for (const c of activeCsps(['NCP'])) {
  test(`${tid('IV6_S07', c)} 비용 수집 결과 화면 반영: NCP`, async ({ page }) => {
    await checkCollected(page, c);
  });
}

IV6_S08

tests/iv6/IV6_S08.spec.ts · 13줄 · 해시 87d3975b · 시나리오 보기

import { test } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { checkCollected } from '../../lib/cost';

// IV6_S08 : 비용 수집 결과 화면 반영: Azure (TC-COST-003)
// 시트 절차(Azure Cost Management 배치 :8093)의 cron/수동 배치 호출·DB 적재 확인은 화면 밖 작업이라 하지 않는다(사용자 지시 2026-10-14: mc-web-console 에서 테스트).
// 수집 결과가 화면에 반영됐는지로 판정: Billing Report Base Info 에 Azure 금액 > 0, Invoices 에 Azure 행, Dash Board Top 5 에 Azure 서비스.
// 금액이 없으면 수집 사전 설정(Entra ID 앱·Cost Management Reader 역할) 미비로 SKIP. 비용은 다른 여정에서 만든 Infra·K8s 로 수집된다고 본다.
for (const c of activeCsps(['Azure'])) {
  test(`${tid('IV6_S08', c)} 비용 수집 결과 화면 반영: Azure`, async ({ page }) => {
    await checkCollected(page, c);
  });
}

IV6_S09

tests/iv6/IV6_S09.spec.ts · 13줄 · 해시 278902e8 · 시나리오 보기

import { test } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { checkCollected } from '../../lib/cost';

// IV6_S09 : 비용 수집 결과 화면 반영: GCP (TC-COST-004)
// 시트 절차(GCP BigQuery 비용 수집 :8095)의 cron/수동 배치 호출·DB 적재 확인은 화면 밖 작업이라 하지 않는다(사용자 지시 2026-10-14: mc-web-console 에서 테스트).
// 수집 결과가 화면에 반영됐는지로 판정: Billing Report Base Info 에 GCP 금액 > 0, Invoices 에 GCP 행, Dash Board Top 5 에 GCP 서비스.
// 금액이 없으면 수집 사전 설정(BigQuery 비용 내보내기·서비스 계정) 미비로 SKIP. 비용은 다른 여정에서 만든 Infra·K8s 로 수집된다고 본다.
for (const c of activeCsps(['GCP'])) {
  test(`${tid('IV6_S09', c)} 비용 수집 결과 화면 반영: GCP`, async ({ page }) => {
    await checkCollected(page, c);
  });
}

IV6_S10

tests/iv6/IV6_S10.spec.ts · 20줄 · 해시 61772b4a · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { openCost, costFrame } from '../../lib/cost';

// IV6_S10 : Alarm: 알림 채널 설정 (Gmail / Slack) [공통] (TC-COST-005)
// 시트 절차: Alarm 페이지 'Mailing Apply/Guide'·'Slack Apply/Guide' → 계정·토큰 입력 → Save → Mail/Slack Test 수신
// mc-web-console 의 Cost Analysis > Alarm 화면에서 해당 기능을 찾고(조회만), 없거나 사전 설정(Gmail 앱 비밀번호·Slack 토큰)이 없으면 SKIP(사용자 지시 2026-10-14)
// 사전 설정이 있으면 COST_MAIL_USER·COST_MAIL_APP_PW·COST_SLACK_TOKEN·COST_SLACK_CHANNEL 환경변수로 넘긴다(값은 리포트에 남기지 않음)
test('IV6_S10 Alarm: 알림 채널 설정 (Gmail / Slack)', async ({ page }) => {
  const text = await openCost(page, 'alarm');
  const f = costFrame(page);
  const controls = await f.evaluate(() => [...document.querySelectorAll('button, a, [role=tab]')].map((e) => (e.textContent || '').trim().replace(/\s+/g, ' ')).filter((t) => t && t.length < 60));
  const mailSlack = controls.filter((t) => /mail|slack|gmail/i.test(t));
  test.info().annotations.push({ type: 'alarm-controls', description: controls.join(' | ').slice(0, 600) });
  test.info().annotations.push({ type: 'mail-slack', description: mailSlack.length ? mailSlack.join(', ') : 'Alarm 화면에 Gmail/Slack 채널 설정 버튼 없음' });
  test.skip(!mailSlack.length, 'Alarm 화면에 Gmail/Slack 채널 설정(Mailing/Slack Apply·Guide) 기능 없음 — 현 화면 미제공(SKIP)');
  const hasKeys = !!(process.env.COST_MAIL_USER && process.env.COST_MAIL_APP_PW) || !!(process.env.COST_SLACK_TOKEN && process.env.COST_SLACK_CHANNEL);
  test.skip(!hasKeys, 'Gmail 앱 비밀번호 / Slack 토큰 사전 설정 없음(SKIP)');
  expect(text).toBeTruthy(); // 채널 설정 UI 가 생기면 저장·테스트 발송 단계를 추가한다
});

IV6_S11

tests/iv6/IV6_S11.spec.ts · 29줄 · 해시 04ac610f · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { openCost, costFrame } from '../../lib/cost';

// IV6_S11 : Alarm: LLM 채널 설정 (Gemini) → Recommendation [공통] (TC-COST-006)
// 화면: Cost Analysis > Alarm > Resource Recommendation — '🔑 API Key Management' 모달(OpenAI / Anthropic / Google(Gemini))
// 키 등록 상태는 화면이 부르는 GET /api/costopti/be/llm_recommender/apikey/google/status 로 확인한다.
// Gemini 키가 등록돼 있지 않으면(사전 설정 없음) SKIP(사용자 지시 2026-10-14). 등록돼 있으면 Provider=Google 로 인스턴스 1개를 골라 Recommend → 결과 카드 확인.
test('IV6_S11 Alarm: LLM 채널 설정 (Gemini) → Recommendation', async ({ page }) => {
  test.setTimeout(10 * 60_000);
  const status = page.waitForResponse((r) => /llm_recommender\/apikey\/google\/status/.test(r.url()), { timeout: 60_000 }).then((r) => r.json()).catch(() => null);
  await openCost(page, 'alarm');
  const st = await status;
  const f = costFrame(page);
  await f.getByRole('button', { name: /API Key Management/i }).first().click();
  const modal = f.locator('.modal.show, [role=dialog]').first();
  await expect(modal, 'API Key Management 모달').toBeVisible({ timeout: 15_000 });
  const mtext = (await modal.innerText()).replace(/\s+/g, ' ');
  test.info().annotations.push({ type: 'api-key-modal', description: mtext.slice(0, 400) });
  test.info().annotations.push({ type: 'google-status', description: JSON.stringify(st) });
  expect(mtext, '모달에 Google (Gemini) 행').toMatch(/Google \(Gemini\)/);
  await modal.getByRole('button', { name: /^Close$/ }).click().catch(() => page.keyboard.press('Escape'));
  test.skip(!st?.registered, 'Gemini API 키 미등록 — LLM 키 사전 설정 없음(SKIP). 화면 안내: "No API key registered yet"');
  // 키가 있을 때: Provider=Google → 첫 인스턴스 선택 → Recommend → 결과 카드
  await f.locator('select').filter({ hasText: /Google|Gemini/i }).first().selectOption({ label: /Google|gemini/i as any }).catch(() => {});
  await f.locator('table tbody tr input[type=checkbox]').first().check({ force: true });
  await f.getByRole('button', { name: /^Recommend/ }).click();
  await expect(f.locator('body'), '추천 결과 카드').toContainText(/confidence|reasoning|recommend/i, { timeout: 5 * 60_000 });
});

IV6_S12

tests/iv6/IV6_S12.spec.ts · 13줄 · 해시 00368daf · 시나리오 보기

import { test } from '../../lib/fixtures';
import { openCost } from '../../lib/cost';

// IV6_S12 : 덤프 데이터로 화면 확인 및 최적화 알람 수신 [공통] (TC-COST-012)
// 시트 절차: cost DB 에 7일치 테스트 데이터(test_data.sql + test_*.csv) 수동 적재 → 예산 설정 → 4화면 확인 + 알람(Gmail/Slack) 수신
// 덤프 적재는 DB 직접 작업이라 mc-web-console 밖의 사전 설정이다 → 적재 여부를 화면으로 확인할 수 없고 알람 채널(IV6_S10)도 화면에 없으므로 SKIP(사용자 지시 2026-10-14).
// 4개 화면(Dash Board·Billing Report·Budget·Alarm) 렌더는 IV6_S03~S06 에서 확인한다. 여기서는 현재 화면 상태만 기록한다.
test('IV6_S12 덤프 데이터로 화면 확인 및 최적화 알람 수신', async ({ page }) => {
  const dash = await openCost(page, 'dashboard');
  test.info().annotations.push({ type: 'dashboard', description: dash.match(/Billing Summary[^]{0,160}/)?.[0] ?? dash.slice(0, 160) });
  test.skip(true, '덤프 데이터 적재(cost DB 직접 import)·알람 채널 설정은 화면 밖 사전 설정 — 미적재로 SKIP');
});

IV6_S13

tests/iv6/IV6_S13.spec.ts · 63줄 · 해시 2453da5d · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { openCost, costFrame, costApi, xlsxStrings } from '../../lib/cost';

// IV6_S13 : Invoice 내보내기 (CSV/XLSX) [정상, 공통] (TC-COST-013)
// 시트 절차: 1.Billing Report > Invoices 에서 서비스 그룹 1개 이상 펼침 2.CSV 아이콘 3.XLSX 아이콘 4.다운로드 파일 확인
// 입력값: 펼친 그룹 AmazonEC2, Object Storage (화면에 있는 그룹만 — 없는 그룹은 기록)
// 기대: invoice_YYYY-MM-DD.csv / .xlsx, 헤더(CSP, Account ID, Product, Resource, Amount (USD)) + 펼친 그룹 행이 원본(화면 API getInvoice) 값으로 들어 있음
// 선행: TC-COST-012 덤프 적재(또는 수집된 비용). Invoices 가 비어 있으면(No invoice data available) 사전 조건 없음 → SKIP
// 내보내기 버튼은 아이콘만 있어 svg 클래스(tabler-icon-file-type-csv / -xls)로 찾는다(2026-10-16 탐색)
const HEAD = ['CSP', 'Account ID', 'Product', 'Resource', 'Amount (USD)'];
test('IV6_S13 Invoice 내보내기 (CSV/XLSX)', async ({ page }) => {
  test.setTimeout(10 * 60_000);
  const note = (t: string, d: string) => test.info().annotations.push({ type: t, description: d.slice(0, 1500) });
  const get = costApi(page);
  const text = await openCost(page, 'billing');
  test.skip(/No invoice data available/i.test(text), 'Invoices 데이터 없음 — 사전 조건(TC-COST-012 덤프 적재 또는 수집 비용) 없음');
  const f = costFrame(page);
  const invoice: { csp: string; productID: string; bill: number; resourceID: string }[] = get('getInvoice')?.invoice ?? [];
  note('invoice-api', invoice.map((i) => `${i.csp} ${i.productID} ${i.resourceID} ${i.bill}`).join(' / ').slice(0, 800));
  // Invoices 는 Tabulator 그룹 2단(level-0 'AWS — N items' → level-1 'Service: AmazonEC2 — N items'). 기본: level-0 펼침, level-1 접힘.
  // 그룹 행의 .tabulator-group-toggle 을 눌러 펼치고, 펼침 여부는 행 class 'tabulator-group-visible' 로 본다(2026-10-16 탐색)
  const group = (lvl: number, re: RegExp) => f.locator(`.tabulator-group.tabulator-group-level-${lvl}`).filter({ hasText: re }).first();
  const open = async (g: ReturnType<typeof group>) => {
    for (let i = 0; i < 2 && !/tabulator-group-visible/.test((await g.getAttribute('class')) ?? ''); i++) { await g.locator('.tabulator-group-toggle').first().click(); await page.waitForTimeout(800); }
    return /tabulator-group-visible/.test((await g.getAttribute('class')) ?? '');
  };
  for (const g of ['AmazonEC2', 'Object Storage']) {
    const mine = invoice.filter((i) => i.productID === g || i.productID.includes(g));
    if (!mine.length) { note(`group ${g}`, '데이터 없음(화면에 그룹 없음)'); continue; }
    const okCsp = await open(group(0, new RegExp(`^\\s*${mine[0].csp}\\s*—`)));
    const okSvc = await open(group(1, new RegExp(`Service:\\s*${g}\\s*—`)));
    const shown = await f.locator('.tabulator-row:not(.tabulator-group)').filter({ hasText: mine[0].resourceID }).count();
    note(`group ${g}`, `CSP 펼침 ${okCsp} / Service 펼침 ${okSvc} / 리소스 행 표시 ${shown ? '예' : '아니오'} (데이터 ${mine.length}행)`);
    expect(okSvc && shown > 0, `${g} 그룹 펼침 → 리소스 행 표시`).toBe(true);
  }

  await test.step('CSV', async () => {
    const [d] = await Promise.all([page.waitForEvent('download', { timeout: 30_000 }), f.locator('button:has(svg.tabler-icon-file-type-csv)').first().click()]);
    const p = test.info().outputPath(d.suggestedFilename());
    await d.saveAs(p);
    const body = require('fs').readFileSync(p, 'utf8').replace(/^/, '');
    const lines = body.split(/\r?\n/).filter(Boolean);
    note('csv', `${d.suggestedFilename()} ${lines.length}줄 | ${lines.slice(0, 4).join(' ⏎ ').slice(0, 600)}`);
    expect(d.suggestedFilename(), 'CSV 파일 이름').toMatch(/^invoice_\d{4}-\d{2}-\d{2}\.csv$/);
    for (const h of HEAD) expect(lines[0], `CSV 헤더 ${h}`).toContain(h);
    expect(lines.length, 'CSV 데이터 행').toBeGreaterThan(1);
    for (const i of invoice.slice(0, 5)) expect(body, `CSV 에 리소스 ${i.resourceID}`).toContain(i.resourceID);
  });

  await test.step('XLSX', async () => {
    const [d] = await Promise.all([page.waitForEvent('download', { timeout: 30_000 }), f.locator('button:has(svg.tabler-icon-file-type-xls)').first().click()]);
    const p = test.info().outputPath(d.suggestedFilename());
    await d.saveAs(p);
    const buf: Buffer = require('fs').readFileSync(p);
    const strs = xlsxStrings(buf);
    note('xlsx', `${d.suggestedFilename()} ${buf.length}B | 문자열 ${strs.length}개: ${strs.slice(0, 20).join(' | ').slice(0, 600)}`);
    expect(d.suggestedFilename(), 'XLSX 파일 이름').toMatch(/^invoice_\d{4}-\d{2}-\d{2}\.xlsx$/);
    expect(buf.subarray(0, 2).toString(), 'xlsx(zip) 형식').toBe('PK');
    for (const h of HEAD) expect(strs, `XLSX 헤더 ${h}`).toContain(h);
    for (const i of invoice.slice(0, 5)) expect(strs.join('\n'), `XLSX 에 리소스 ${i.resourceID}`).toContain(i.resourceID);
  });
});

IV6_S14

tests/iv6/IV6_S14.spec.ts · 48줄 · 해시 0113e697 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { openCost, costFrame } from '../../lib/cost';

// IV6_S14 : 비용 아카이브 (Archive) [정상, 공통] (TC-COST-014)
// 시트 절차: 1.Billing Report > Invoices 의 Archive 2.Target month 선택, CSP 체크 조정 3.Archive 4.결과 확인 5.Close 후 Archive Status 카드 확인
// 입력값: Target month 2026-09 / CSP AWS, NCP (환경변수 COST_ARCHIVE_MONTH 로 바꿀 수 있음)
// 기대: 월·CSP 미선택 시 Archive 비활성, 실행 후 CSP 별 VERIFIED (N rows)/SKIPPED, Archive Status 에 해당 월 Archived 행 추가
// 선행: 해당 월 raw 데이터와 아카이브 저장소(S3) 설정 — Target month 목록(archive/months)에 그 월이 없으면 사전 조건 없음 → 비활성 확인까지만 하고 SKIP
// 2026-10-16 탐색: Target month 목록이 'Select…' 하나뿐(대상 월 없음)
const MONTH = process.env.COST_ARCHIVE_MONTH ?? '2026-09';
test('IV6_S14 비용 아카이브 (Archive)', async ({ page }) => {
  test.setTimeout(10 * 60_000);
  const note = (t: string, d: string) => test.info().annotations.push({ type: t, description: d.slice(0, 1500) });
  await openCost(page, 'billing');
  const f = costFrame(page);
  await f.getByRole('button', { name: /Archive/ }).first().click();
  const m = f.locator('.modal.show').filter({ hasText: 'Cost Archive' }).first();
  await expect(m, 'Cost Archive 모달').toBeVisible({ timeout: 15_000 });
  const run = m.locator('.modal-footer').getByRole('button', { name: /^Archive$/ });
  const close = m.locator('.modal-footer').getByRole('button', { name: /^Close$/ }); // 머리글 × 도 aria-label Close 라 footer 버튼으로 한정
  const month = m.locator('select').first();
  const cb = (csp: string) => m.locator('label.form-check').filter({ hasText: new RegExp(`^\\s*${csp}\\s*$`) }).locator('input');
  const opts = await month.locator('option').evaluateAll((os) => os.map((o) => (o as HTMLOptionElement).value).filter(Boolean));
  note('months', opts.join(', ') || '(대상 월 없음)');
  await expect(run, '월 미선택 시 Archive 비활성').toBeDisabled();
  if (!opts.includes(MONTH)) {
    await close.click();
    test.skip(true, `Target month 목록에 ${MONTH} 없음 — 해당 월 raw 데이터·아카이브 저장소(S3) 사전 설정 없음(목록: ${opts.join(', ') || '없음'})`);
  }
  await month.selectOption(MONTH);
  for (const c of ['AWS', 'GCP', 'NCP', 'AZURE']) await cb(c).uncheck();
  await expect(run, 'CSP 미선택 시 Archive 비활성').toBeDisabled();
  for (const c of ['AWS', 'NCP']) await cb(c).check();
  await expect(run, '월·CSP 선택 후 Archive 활성').toBeEnabled();
  await run.click();
  await expect.poll(async () => (await m.innerText()).replace(/\s+/g, ' '), { timeout: 5 * 60_000, intervals: [3000] }).toMatch(/VERIFIED|SKIPPED|fail|error/i);
  const res = (await m.innerText()).replace(/\s+/g, ' ');
  note('archive-result', res);
  expect(res, 'AWS 결과').toMatch(/AWS[^]*?(VERIFIED \(\d+ rows?\)|SKIPPED)/);
  expect(res, 'NCP 결과').toMatch(/NCP[^]*?(VERIFIED \(\d+ rows?\)|SKIPPED)/);
  expect(res, '실패 없음').not.toMatch(/\bfail|\berror/i);
  await close.click();
  await page.waitForTimeout(3000);
  const status = (await f.locator('body').innerText()).replace(/\s+/g, ' ').match(/Archive Status[^]*/)?.[0] ?? '';
  note('archive-status', status.slice(0, 600));
  expect(status, `Archive Status 에 ${MONTH} Archived 행`).toMatch(new RegExp(`${MONTH}[^]*Archived`, 'i'));
});

IV6_S15

tests/iv6/IV6_S15.spec.ts · 87줄 · 해시 94ff9ef0 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import type { Frame, Page } from '@playwright/test';
import { openCost, costFrame } from '../../lib/cost';

// IV6_S15 : Budget 예산 설정 저장 [정상, 공통] (TC-COST-015)
// 시트 절차: 1.Budget > CSP Budget Setting 에서 AWS 1월 셀 클릭 2.문자 입력 시도 후 숫자 입력, Enter 3.NCP 2월 셀에 KRW 금액 입력 4.Save Budget 5.새로고침 후 값·비교 차트 확인
// 입력값: AWS Jan 900 / NCP Feb 700000(KRW)
// 기대: 문자는 입력되지 않고 숫자만 반영, Total 즉시 갱신(KRW ÷1400 USD), 저장 시 Success 토스트, 새로고침 후 값 유지, Budget vs Usage 차트 예산 변경
// 환경 보호: 시작 전 두 셀의 원래 값을 기록하고, 끝나면(실패해도) 원래 값으로 되돌려 다시 저장한다.
// 2026-10-16 탐색: 셀은 div(클릭하면 편집), 비교 API budget/comparison/{year} 가 500 을 돌려줌(차트 'No data available')
const KRW_PER_USD = 1400;
const rowOf = (f: Frame, csp: string) => f.locator('table tbody tr').filter({ has: f.locator('td:first-child', { hasText: new RegExp(`^\\s*${csp}\\s*$`) }) }).first();
const COL: Record<string, number> = { Jan: 2, Feb: 3 }; // td 순서: CSP, Total, Jan, Feb, …
// 셀은 큰 값을 줄여 표시한다(700000 → '700K', 2026-10-16) — K·M 접미를 숫자로 풀어 비교
const unabbr = (s: string) => { const m = s.match(/^([\d.]+)([KM])$/i); return m ? String(Math.round(Number(m[1]) * (m[2].toUpperCase() === 'K' ? 1e3 : 1e6))) : s; };
const cellText = async (f: Frame, csp: string, mon: string) => unabbr((await rowOf(f, csp).locator('td').nth(COL[mon]).innerText()).replace(/[\s,$]/g, ''));
const totalText = async (f: Frame, csp: string) => (await rowOf(f, csp).locator('td').nth(1).innerText()).replace(/\s+/g, '');

/** 셀 편집: 클릭 → 입력란(있으면 input, 없으면 키보드) → 값 → Enter. 입력 후 편집기에 남은 값을 돌려준다 */
async function editCell(page: Page, f: Frame, csp: string, mon: string, typed: string[]) {
  const td = rowOf(f, csp).locator('td').nth(COL[mon]);
  await td.locator('div').first().click();
  await page.waitForTimeout(500);
  const inp = td.locator('input').first();
  const has = (await inp.count()) > 0;
  let seen = '';
  for (const t of typed) {
    if (has) { await inp.fill(''); await inp.pressSequentially(t); seen += `[${t}→${await inp.inputValue()}]`; }
    else { await page.keyboard.press('Control+A'); await page.keyboard.type(t); }
  }
  if (has) await inp.press('Enter'); else await page.keyboard.press('Enter');
  await page.waitForTimeout(800);
  return { editor: has ? 'input' : 'keyboard', seen };
}

test('IV6_S15 Budget 예산 설정 저장', async ({ page }) => {
  test.setTimeout(10 * 60_000);
  const note = (t: string, d: string) => test.info().annotations.push({ type: t, description: d.slice(0, 1500) });
  const comp: string[] = [];
  page.on('response', (r) => { if (/budget\/comparison\//.test(r.url())) comp.push(`${r.status()}`); });
  await openCost(page, 'budget');
  let f = costFrame(page);
  const orig = { awsJan: await cellText(f, 'AWS', 'Jan'), ncpFeb: await cellText(f, 'NCP', 'Feb') };
  note('original', JSON.stringify(orig));
  const save = async () => {
    await f.getByRole('button', { name: /^Save Budget$/ }).click();
    await expect(f.locator('body'), 'Success 토스트').toContainText(/success/i, { timeout: 15_000 });
  };
  try {
    await test.step('AWS Jan: 문자 입력 차단 → 900', async () => {
      const r = await editCell(page, f, 'AWS', 'Jan', ['abc', '900']);
      note('aws-jan-edit', JSON.stringify(r));
      if (r.editor === 'input') expect(r.seen, '문자는 입력되지 않음').toContain('[abc→]');
      expect(await cellText(f, 'AWS', 'Jan'), 'AWS Jan 900').toBe('900');
      expect(await totalText(f, 'AWS'), 'AWS Total 즉시 갱신').toMatch(/\$?900/);
    });
    await test.step('NCP Feb: 700000 KRW → Total USD 환산', async () => {
      await editCell(page, f, 'NCP', 'Feb', ['700000']);
      expect(await cellText(f, 'NCP', 'Feb'), 'NCP Feb 700000').toBe('700000');
      const usd = 700000 / KRW_PER_USD; // 500
      expect(await totalText(f, 'NCP'), `NCP Total ≈ $${usd}`).toMatch(new RegExp(`\\$?${usd}`));
    });
    await test.step('Save Budget → 새로고침 후 유지 · 비교 차트', async () => {
      await save();
      await openCost(page, 'budget');
      f = costFrame(page);
      // 2026-10-16: NCP 는 KRW 로 입력했는데 새로고침 후 USD 환산값(500)으로 보임 — 시트 기대('값 유지')와 다름. 차트 확인까지 이어가도록 soft
      note('reloaded', `AWS Jan=${await cellText(f, 'AWS', 'Jan')} NCP Feb=${await cellText(f, 'NCP', 'Feb')} | NCP Total=${await totalText(f, 'NCP')}`);
      expect.soft(await cellText(f, 'AWS', 'Jan'), '새로고침 후 AWS Jan').toBe('900');
      expect.soft(await cellText(f, 'NCP', 'Feb'), '새로고침 후 NCP Feb(입력한 KRW 700000 유지)').toBe('700000');
      const chart = (await f.locator('body').innerText()).replace(/\s+/g, ' ').match(/Budget vs Usage Comparison[^]{0,120}/)?.[0] ?? '';
      note('comparison', `API ${comp.join(',') || '(호출 없음)'} | ${chart}`);
      expect(comp.at(-1), 'Budget vs Usage 비교 API(budget/comparison) 응답').toBe('200');
      expect(chart, 'Budget vs Usage 차트에 예산 반영').not.toMatch(/No data available/);
    });
  } finally {
    // 원래 값으로 되돌림
    await openCost(page, 'budget').catch(() => {});
    f = costFrame(page);
    await editCell(page, f, 'AWS', 'Jan', [orig.awsJan || '0']).catch(() => {});
    await editCell(page, f, 'NCP', 'Feb', [orig.ncpFeb || '0']).catch(() => {});
    await f.getByRole('button', { name: /^Save Budget$/ }).click().catch(() => {});
    await page.waitForTimeout(3000);
    note('restored', `AWS Jan=${await cellText(f, 'AWS', 'Jan').catch(() => '?')} NCP Feb=${await cellText(f, 'NCP', 'Feb').catch(() => '?')} (원래 ${JSON.stringify(orig)})`);
  }
});

IV6_S16

tests/iv6/IV6_S16.spec.ts · 37줄 · 해시 ef9fd377 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { openCost, costFrame, llmKeyStatus } from '../../lib/cost';

// IV6_S16 : 리소스 추천 요청 (LLM Recommend) [정상, 공통] (TC-COST-016)
// 시트 절차: 1.Alarm > Resource Recommendation 2.Provider/Model 선택 3.인스턴스 최대 5개 체크(6번째 비활성) 4.빠른 질문 칩 5.Recommend → 결과 카드
// 입력값: Provider Google (Gemini) / Model gemini-flash-latest / 인스턴스 3개 / 문의 "Why this recommendation?"
// 기대: 진행률(n/N) 후 인스턴스별 카드에 UPSIZE/DOWNSIZE/KEEP/TERMINATE 배지·confidence·근거·Answer, 데이터 부족 자원은 INSUFFICIENT DATA 카드
// 선행: API Key 등록(TC-COST-006)·인스턴스 목록(TC-COST-011), 모니터링 데이터 있는 자원. Google 키가 없으면 사전 설정 없음 → SKIP
// 비고(시트): 실제 LLM 호출(과금)
test('IV6_S16 리소스 추천 요청 (LLM Recommend)', async ({ page }) => {
  test.setTimeout(15 * 60_000);
  const note = (t: string, d: string) => test.info().annotations.push({ type: t, description: d.slice(0, 1500) });
  const st = llmKeyStatus(page);
  const text = await openCost(page, 'alarm');
  await expect.poll(() => Object.keys(st).length, { timeout: 30_000 }).toBeGreaterThan(0).catch(() => {});
  note('key-status', JSON.stringify(st));
  test.skip(!st.google, `Google(Gemini) API 키 미등록 — 사전 설정(TC-COST-006 키 등록) 없음(SKIP). 화면: ${/No API key registered/i.test(text) ? '"No API key registered yet…"' : '키 있음(다른 Provider)'}`);
  const f = costFrame(page);
  const sel = f.locator('select');
  await sel.filter({ hasText: /Google|Gemini/i }).first().selectOption({ label: (await sel.filter({ hasText: /Google|Gemini/i }).first().locator('option', { hasText: /Google|Gemini/i }).first().innerText()).trim() });
  await page.waitForTimeout(1000);
  const model = sel.filter({ hasText: /gemini/i }).last();
  await model.selectOption({ label: 'gemini-flash-latest' }).catch(async () => note('model', `gemini-flash-latest 없음: ${(await model.innerText()).replace(/\s+/g, ' ')}`));
  const boxes = f.locator('table tbody tr input[type=checkbox]');
  const n = await boxes.count();
  note('instances', `${n}개`);
  for (let i = 0; i < Math.min(5, n); i++) await boxes.nth(i).check({ force: true });
  if (n >= 6) await expect(boxes.nth(5), '6번째 인스턴스 체크 비활성').toBeDisabled();
  for (let i = 3; i < Math.min(5, n); i++) await boxes.nth(i).uncheck({ force: true }); // 시트 입력값: 3개
  await f.getByRole('button', { name: 'Why this recommendation?' }).click();
  await f.getByRole('button', { name: /^Recommend/ }).click();
  await expect(f.locator('body'), '결과 카드').toContainText(/UPSIZE|DOWNSIZE|KEEP|TERMINATE|INSUFFICIENT DATA/, { timeout: 10 * 60_000 });
  const cards = (await f.locator('body').innerText()).replace(/\s+/g, ' ');
  note('cards', cards.match(/(UPSIZE|DOWNSIZE|KEEP|TERMINATE|INSUFFICIENT DATA)[^]{0,200}/g)?.join(' || ').slice(0, 1200) ?? '');
  expect(cards, 'confidence 표시').toMatch(/confidence/i);
});

IV6_S17

tests/iv6/IV6_S17.spec.ts · 44줄 · 해시 1f57e8fb · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { openCost, costFrame, llmKeyStatus } from '../../lib/cost';

// IV6_S17 : API Key 삭제 [정상, 공통] (TC-COST-017)
// 시트 절차: 1.Alarm > API Key Management 2.Google (Gemini) 행 Delete 3.확인 모달 Cancel 4.다시 Delete → 확인 모달 Delete 5.Close 후 Provider 드롭다운 확인
// 기대: Cancel 시 변화 없음, 확인 시 Success 토스트·입력란 재활성·Provider 드롭다운에서 빠짐(마지막 키였다면 미등록 안내로 전환)
// 선행: TC-COST-006 으로 Gemini 키 등록 상태. 키가 없으면 사전 설정 없음 → 모달에서 상태만 기록하고 SKIP
// 키 값은 다루지 않는다(삭제만). 등록·마스킹 확인은 TC-COST-006(IV6_S11)
test('IV6_S17 API Key 삭제', async ({ page }) => {
  test.setTimeout(10 * 60_000);
  const note = (t: string, d: string) => test.info().annotations.push({ type: t, description: d.slice(0, 1500) });
  const st = llmKeyStatus(page);
  await openCost(page, 'alarm');
  await expect.poll(() => Object.keys(st).length, { timeout: 30_000 }).toBeGreaterThan(0).catch(() => {});
  note('key-status', JSON.stringify(st));
  const f = costFrame(page);
  await f.getByRole('button', { name: /API Key Management/i }).click();
  const modal = f.locator('.modal.show').filter({ hasText: /Google \(Gemini\)/ }).first();
  await expect(modal, 'API Key Management 모달').toBeVisible({ timeout: 15_000 });
  const row = modal.locator('tr, .row, li, div.d-flex').filter({ hasText: /Google \(Gemini\)/ }).last();
  note('modal', (await modal.innerText()).replace(/\s+/g, ' ').slice(0, 500));
  if (!st.google) {
    await modal.getByRole('button', { name: /^Close$/ }).click().catch(() => page.keyboard.press('Escape'));
    test.skip(true, 'Google(Gemini) API 키 미등록 — 사전 설정(TC-COST-006 키 등록) 없음(SKIP)');
  }
  const del = row.getByRole('button', { name: /^Delete$/ });
  await del.click();
  const confirm = f.locator('.modal.show').filter({ hasText: /delete/i }).last();
  await confirm.getByRole('button', { name: /^Cancel$/ }).click();
  await page.waitForTimeout(1000);
  await expect(del, 'Cancel 후 변화 없음(Delete 그대로)').toBeVisible();
  await del.click();
  await confirm.getByRole('button', { name: /^Delete$/ }).click();
  await expect(f.locator('body'), 'Success 토스트').toContainText(/success/i, { timeout: 15_000 });
  await expect(row.locator('input').first(), '입력란 재활성').toBeEnabled();
  await modal.getByRole('button', { name: /^Close$/ }).click().catch(() => page.keyboard.press('Escape'));
  const body = (await f.locator('body').innerText()).replace(/\s+/g, ' ');
  const providers = await f.locator('select option').allInnerTexts().catch(() => [] as string[]);
  note('after', `providers=${providers.join('|')} | ${body.match(/Provider[^]{0,120}/)?.[0] ?? ''}`);
  const others = Object.entries(st).filter(([k, v]) => k !== 'google' && v).length;
  if (others) expect(providers.join('|'), 'Provider 드롭다운에서 Google 제외').not.toMatch(/Google|Gemini/i);
  else expect(body, '마지막 키 삭제 → 미등록 안내').toMatch(/No API key registered yet/i);
});

IV6_S18

tests/iv6/IV6_S18.spec.ts · 29줄 · 해시 674106df · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { openCost, costFrame, llmKeyStatus } from '../../lib/cost';

// IV6_S18 : API Key 미등록 상태 추천 요청 [예외, 공통] (TC-COST-018)
// 시트 절차: 1.API Key 가 하나도 없는 프로젝트로 Alarm 진입 2.Provider 영역 확인 3.인스턴스 선택 후 Recommend 버튼 확인
// 기대: "No API key registered yet…" 안내, Provider/Model 드롭다운 없음, 인스턴스를 선택해도 Recommend 비활성
// 선행: 모든 Provider 의 키 삭제 상태(Project=default). 키가 하나라도 있으면 사전 조건 아님 → SKIP
test('IV6_S18 API Key 미등록 상태 추천 요청', async ({ page }) => {
  test.setTimeout(10 * 60_000);
  const note = (t: string, d: string) => test.info().annotations.push({ type: t, description: d.slice(0, 1500) });
  const st = llmKeyStatus(page);
  const text = await openCost(page, 'alarm');
  await expect.poll(() => Object.keys(st).length, { timeout: 30_000 }).toBeGreaterThanOrEqual(3).catch(() => {});
  note('key-status', JSON.stringify(st));
  test.skip(Object.values(st).some(Boolean), `등록된 API 키 있음(${Object.entries(st).filter(([, v]) => v).map(([k]) => k).join(',')}) — 미등록 사전 조건 아님(SKIP)`);
  const f = costFrame(page);
  expect(text, '미등록 안내 문구').toMatch(/No API key registered yet/i);
  const provider = (await f.locator('body').innerText()).replace(/\s+/g, ' ').match(/Provider[^]{0,160}/)?.[0] ?? '';
  note('provider-area', provider);
  const selects = await f.locator('select:visible').count();
  expect(selects, 'Provider/Model 드롭다운 없음').toBe(0);
  const box = f.locator('table tbody tr input[type=checkbox]').first();
  await expect(box, '인스턴스 목록').toBeVisible({ timeout: 30_000 });
  await box.check({ force: true });
  const rec = f.getByRole('button', { name: /^Recommend/ });
  note('recommend', (await rec.innerText()).replace(/\s+/g, ' '));
  await expect(rec, '인스턴스 선택 후에도 Recommend 비활성').toBeDisabled();
});

IV6_S19

tests/iv6/IV6_S19.spec.ts · 36줄 · 해시 733cea77 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { openCost, costFrame } from '../../lib/cost';

// IV6_S19 : 추천 이력 조회 (History) [정상, 공통] (TC-COST-019)
// 시트 절차: 1.Alarm > History 탭 2.컬럼·행 확인 3.페이지 2, » 클릭
// 기대: Date/CSP/Resource ID/Resource Type/Alarm Type/Alarm Message/Recommendation Type 7컬럼에 ML 알람(mail)과 LLM 추천이 함께 표시, 10행 단위 페이지
// 선행: TC-COST-012 알람 수신·TC-COST-016 추천 실행 이후. 이력이 없으면(0행) 컬럼만 확인하고 사전 조건 없음으로 SKIP
const COLS = ['DATE', 'CSP', 'RESOURCE ID', 'RESOURCE TYPE', 'ALARM TYPE', 'ALARM MESSAGE', 'RECOMMENDATION TYPE'];
test('IV6_S19 추천 이력 조회 (History)', async ({ page }) => {
  test.setTimeout(10 * 60_000);
  const note = (t: string, d: string) => test.info().annotations.push({ type: t, description: d.slice(0, 1500) });
  await openCost(page, 'alarm');
  const f = costFrame(page);
  await f.getByRole('link', { name: /^History$/ }).or(f.getByText(/^History$/)).first().click();
  await page.waitForTimeout(4000);
  const table = f.locator('table').filter({ hasText: 'RECOMMENDATION TYPE' }).first();
  const heads = (await table.locator('thead th').allInnerTexts()).map((s) => s.trim().toUpperCase());
  note('columns', heads.join(' | '));
  expect(heads, '7컬럼').toEqual(COLS);
  const rows = table.locator('tbody tr').filter({ hasNot: f.locator('td[colspan]') });
  const n = await rows.count();
  note('rows', `${n}행 | ${(await rows.allInnerTexts()).slice(0, 3).map((s) => s.replace(/\s+/g, ' ')).join(' || ').slice(0, 600)}`);
  test.skip(n === 0, '이력 0행 — 사전 조건(TC-COST-012 알람 수신·TC-COST-016 추천 실행) 없음(SKIP). 7컬럼은 확인');
  expect(n, '한 페이지 10행 이하').toBeLessThanOrEqual(10);
  const text = (await table.innerText()).replace(/\s+/g, ' ');
  note('kinds', `mail: ${/mail/i.test(text)} / LLM 추천: ${/UPSIZE|DOWNSIZE|KEEP|TERMINATE|LLM/i.test(text)}`);
  const p2 = f.locator('button.page-link', { hasText: /^2$/ }).last();
  if (await p2.isVisible().catch(() => false)) {
    const first = await rows.first().innerText();
    await p2.click(); await page.waitForTimeout(2000);
    expect(await rows.first().innerText(), '페이지 2 로 이동').not.toBe(first);
    await f.locator('button.page-link', { hasText: '»' }).last().click(); await page.waitForTimeout(2000);
    note('paging', '2 · » 이동 확인');
  } else note('paging', '10행 이하라 페이지 2 없음');
});

IV6_S20

tests/iv6/IV6_S20.spec.ts · 40줄 · 해시 d44d01c4 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { openCost, costFrame, costApi } from '../../lib/cost';

// IV6_S20 : 비용 데이터 없는 프로젝트 조회 [예외, 공통] (TC-COST-020)
// 시트 절차: 1.비용 데이터가 없는 Project 선택 2.Home·Billing Report·Alarm(History) 진입 3.각 카드 문구 확인
// 기대: 오류 없이 Billing Summary "No billing data available", Invoices "No invoice data available" 등 빈 상태 문구, History 는 빈 테이블
// 선행(시트): 비용·이력이 없는 Project 생성 → 2026-10-16 탐색에서 기존 p3·p5·p6·p7·p8 이 비용 없음(getBillingBaseInfo []) → p5 사용(COST_EMPTY_PROJECT 로 변경)
// 화면 진입 시 그 Project 의 Base Info 가 비어 있지 않으면 사전 조건 아님 → SKIP
const PROJECT = process.env.COST_EMPTY_PROJECT ?? 'p5';
test('IV6_S20 비용 데이터 없는 프로젝트 조회', async ({ page }) => {
  test.setTimeout(10 * 60_000);
  const note = (t: string, d: string) => test.info().annotations.push({ type: t, description: d.slice(0, 1500) });
  const errs: string[] = [];
  page.on('response', (r) => { if (/costopti\/be/.test(r.url()) && r.status() >= 500) errs.push(`${r.status()} ${r.url().split('/be/')[1]}`); });
  page.on('dialog', (d) => { errs.push(`dialog: ${d.message()}`); d.dismiss().catch(() => {}); });
  const get = costApi(page);

  const billing = await openCost(page, 'billing', PROJECT);
  await expect.poll(() => get('getBillingBaseInfo') !== undefined, { timeout: 30_000 }).toBe(true).catch(() => {});
  note('base-info', JSON.stringify(get('getBillingBaseInfo')));
  test.skip((get('getBillingBaseInfo') ?? []).length > 0, `${PROJECT} 에 비용 데이터 있음 — 비용 없는 Project 사전 조건 아님(SKIP)`);
  note('billing', billing.slice(0, 400));
  expect(billing, 'Invoices 빈 상태 문구').toMatch(/No invoice data available/i);

  const home = await openCost(page, 'dashboard', PROJECT);
  note('home', home.slice(0, 400));
  expect(home, 'Billing Summary 빈 상태 문구').toMatch(/No billing data available/i);

  await openCost(page, 'alarm', PROJECT);
  const f = costFrame(page);
  await f.getByRole('link', { name: /^History$/ }).or(f.getByText(/^History$/)).first().click();
  await page.waitForTimeout(4000);
  const table = f.locator('table').filter({ hasText: 'RECOMMENDATION TYPE' }).first();
  const rows = await table.locator('tbody tr').filter({ hasNot: f.locator('td[colspan]') }).count();
  note('history', `${rows}행 | ${(await table.innerText()).replace(/\s+/g, ' ').slice(0, 300)}`);
  expect(rows, 'History 빈 테이블').toBe(0);
  note('errors', errs.join(' / ') || '(없음)');
  expect(errs, '오류 응답·alert 없음').toEqual([]);
});

Clone this wiki locally