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

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