Test Specs IV6 - MZC-CSC/mc-web-console GitHub Wiki
์์ฑ: 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 |
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);
});
}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);
});
}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 ๋ฐ์ดํฐ ์์' });
});
}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] ?? '' });
});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);
});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 ์์' });
});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);
});
}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);
});
}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);
});
}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 ๊ฐ ์๊ธฐ๋ฉด ์ ์ฅยทํ
์คํธ ๋ฐ์ก ๋จ๊ณ๋ฅผ ์ถ๊ฐํ๋ค
});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 });
});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');
});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);
});
});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'));
});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)})`);
}
});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);
});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);
});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();
});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 ์์');
});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([]);
});