Skip to content

Commit 7bd5bd2

Browse files
committed
test: e2e 시나리오 테스트 프레임워크 추가 (claude -p 기반)
claude -p 서브프로세스로 자연어 질의 → CLI 명령 매칭 → 결과 검증하는 E2E 테스트 프레임워크. 7개 API 카테고리 15개 시나리오 커버. - runner: spawn + detached로 claude -p 실행, JSON 파싱 - evaluators: keyword(키워드 매칭) + structure(에러/길이/턴 검증) - reporter: 카테고리별 요약, 시나리오별 결과 테이블, JSON 리포트 저장 - 시나리오: index, stock, etp, bond, derivative, commodity, esg, market
1 parent 9901763 commit 7bd5bd2

21 files changed

Lines changed: 854 additions & 0 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ dist/
44
.env
55
.krx-cli/
66
coverage/
7+
tests/e2e/reports/

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"lint": "eslint src/",
2020
"format": "prettier --write 'src/**/*.ts' 'tests/**/*.ts'",
2121
"typecheck": "tsc --noEmit",
22+
"test:e2e": "vitest run --config tests/vitest.config.e2e.ts",
2223
"check": "pnpm lint && pnpm typecheck && pnpm test",
2324
"prepare": "husky"
2425
},

tests/e2e/config.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { execFileSync } from "node:child_process";
2+
import { existsSync } from "node:fs";
3+
import { resolve } from "node:path";
4+
5+
export interface E2EConfig {
6+
readonly model: string;
7+
readonly maxTurns: number;
8+
readonly timeoutMs: number;
9+
readonly cwd: string;
10+
}
11+
12+
export function getConfig(): E2EConfig {
13+
const cwd = resolve(process.env["E2E_CWD"] ?? process.cwd());
14+
15+
return {
16+
model: process.env["E2E_MODEL"] ?? "sonnet",
17+
maxTurns: Number(process.env["E2E_MAX_TURNS"] ?? "10"),
18+
timeoutMs: Number(process.env["E2E_TIMEOUT_MS"] ?? "180000"),
19+
cwd,
20+
};
21+
}
22+
23+
export function validatePrerequisites(config: E2EConfig): void {
24+
try {
25+
execFileSync("claude", ["--version"], { stdio: "pipe" });
26+
} catch {
27+
throw new Error(
28+
"claude CLI not found. Install: npm install -g @anthropic-ai/claude-code",
29+
);
30+
}
31+
32+
if (!existsSync(resolve(config.cwd, "dist/cli.js"))) {
33+
throw new Error("dist/cli.js not found. Run 'pnpm build' first.");
34+
}
35+
}

tests/e2e/evaluators/keyword.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { RunResult, Scenario } from "../scenarios/types.js";
2+
import type { EvalResult, Evaluator } from "./types.js";
3+
4+
export const keywordEvaluator: Evaluator = {
5+
name: "keyword",
6+
7+
evaluate(result: RunResult, scenario: Scenario): EvalResult {
8+
const keywords = scenario.expectedKeywords;
9+
10+
if (!keywords) {
11+
return { passed: true, message: "No keyword expectations" };
12+
}
13+
14+
const text = (result.result ?? "").toLowerCase();
15+
16+
if (keywords.mustContain) {
17+
for (const kw of keywords.mustContain) {
18+
if (!text.includes(kw.toLowerCase())) {
19+
return {
20+
passed: false,
21+
message: `Missing required keyword: "${kw}"`,
22+
};
23+
}
24+
}
25+
}
26+
27+
if (keywords.mustNotContain) {
28+
for (const kw of keywords.mustNotContain) {
29+
if (text.includes(kw.toLowerCase())) {
30+
return {
31+
passed: false,
32+
message: `Contains forbidden keyword: "${kw}"`,
33+
};
34+
}
35+
}
36+
}
37+
38+
if (keywords.anyOf) {
39+
const found = keywords.anyOf.some((kw) =>
40+
text.includes(kw.toLowerCase()),
41+
);
42+
43+
if (!found) {
44+
return {
45+
passed: false,
46+
message: `None of expected keywords found: [${keywords.anyOf.join(", ")}]`,
47+
};
48+
}
49+
}
50+
51+
return { passed: true, message: "All keyword checks passed" };
52+
},
53+
};

tests/e2e/evaluators/structure.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { RunResult, Scenario } from "../scenarios/types.js";
2+
import type { EvalResult, Evaluator } from "./types.js";
3+
4+
export const structureEvaluator: Evaluator = {
5+
name: "structure",
6+
7+
evaluate(result: RunResult, scenario: Scenario): EvalResult {
8+
const structure = scenario.expectedStructure;
9+
10+
if (!structure) {
11+
return { passed: true, message: "No structure expectations" };
12+
}
13+
14+
if (structure.mustNotBeError && result.isError) {
15+
return {
16+
passed: false,
17+
message: `Expected success but got error: ${result.result.slice(0, 200)}`,
18+
};
19+
}
20+
21+
if (structure.minLength && result.result.length < structure.minLength) {
22+
return {
23+
passed: false,
24+
message: `Response too short: ${result.result.length} < ${structure.minLength}`,
25+
};
26+
}
27+
28+
if (structure.maxTurns && result.numTurns > structure.maxTurns) {
29+
return {
30+
passed: false,
31+
message: `Too many turns: ${result.numTurns} > ${structure.maxTurns}`,
32+
};
33+
}
34+
35+
return { passed: true, message: "All structure checks passed" };
36+
},
37+
};

tests/e2e/evaluators/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { RunResult, Scenario } from "../scenarios/types.js";
2+
3+
export interface EvalResult {
4+
readonly passed: boolean;
5+
readonly message: string;
6+
}
7+
8+
export interface Evaluator {
9+
readonly name: string;
10+
evaluate(result: RunResult, scenario: Scenario): EvalResult;
11+
}

tests/e2e/reporter.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { writeFileSync, mkdirSync } from "node:fs";
2+
import { resolve } from "node:path";
3+
import type { EvalResult } from "./evaluators/types.js";
4+
5+
export interface ScenarioReport {
6+
readonly scenarioId: string;
7+
readonly description: string;
8+
readonly tags: readonly string[];
9+
readonly passed: boolean;
10+
readonly durationMs: number;
11+
readonly costUsd: number;
12+
readonly numTurns: number;
13+
readonly isError: boolean;
14+
readonly evalResults: readonly (EvalResult & {
15+
readonly evaluator: string;
16+
})[];
17+
readonly resultPreview: string;
18+
}
19+
20+
interface CategorySummary {
21+
readonly category: string;
22+
readonly total: number;
23+
readonly passed: number;
24+
readonly failed: number;
25+
readonly totalDurationMs: number;
26+
readonly totalCostUsd: number;
27+
readonly avgTurns: number;
28+
}
29+
30+
interface TestReport {
31+
readonly timestamp: string;
32+
readonly model: string;
33+
readonly total: number;
34+
readonly passed: number;
35+
readonly failed: number;
36+
readonly passRate: string;
37+
readonly totalDurationMs: number;
38+
readonly totalCostUsd: number;
39+
readonly avgTurns: number;
40+
readonly avgDurationMs: number;
41+
readonly categories: readonly CategorySummary[];
42+
readonly scenarios: readonly ScenarioReport[];
43+
readonly failures: readonly ScenarioReport[];
44+
}
45+
46+
function pad(str: string, len: number): string {
47+
return str.length >= len
48+
? str.slice(0, len)
49+
: str + " ".repeat(len - str.length);
50+
}
51+
52+
function padLeft(str: string, len: number): string {
53+
return str.length >= len
54+
? str.slice(0, len)
55+
: " ".repeat(len - str.length) + str;
56+
}
57+
58+
function formatDuration(ms: number): string {
59+
if (ms < 1000) return `${ms}ms`;
60+
return `${(ms / 1000).toFixed(1)}s`;
61+
}
62+
63+
function formatCost(usd: number): string {
64+
return `$${usd.toFixed(4)}`;
65+
}
66+
67+
function buildCategorySummaries(
68+
reports: readonly ScenarioReport[],
69+
): readonly CategorySummary[] {
70+
const categoryMap = new Map<string, ScenarioReport[]>();
71+
72+
for (const report of reports) {
73+
const category = report.tags[0] ?? "unknown";
74+
const existing = categoryMap.get(category) ?? [];
75+
categoryMap.set(category, [...existing, report]);
76+
}
77+
78+
return [...categoryMap.entries()].map(([category, items]) => {
79+
const passed = items.filter((r) => r.passed).length;
80+
const totalTurns = items.reduce((sum, r) => sum + r.numTurns, 0);
81+
82+
return {
83+
category,
84+
total: items.length,
85+
passed,
86+
failed: items.length - passed,
87+
totalDurationMs: items.reduce((sum, r) => sum + r.durationMs, 0),
88+
totalCostUsd: items.reduce((sum, r) => sum + r.costUsd, 0),
89+
avgTurns: items.length > 0 ? totalTurns / items.length : 0,
90+
};
91+
});
92+
}
93+
94+
function buildReport(
95+
reports: readonly ScenarioReport[],
96+
model: string,
97+
): TestReport {
98+
const passed = reports.filter((r) => r.passed).length;
99+
const total = reports.length;
100+
const totalDurationMs = reports.reduce((sum, r) => sum + r.durationMs, 0);
101+
const totalCostUsd = reports.reduce((sum, r) => sum + r.costUsd, 0);
102+
const totalTurns = reports.reduce((sum, r) => sum + r.numTurns, 0);
103+
104+
return {
105+
timestamp: new Date().toISOString(),
106+
model,
107+
total,
108+
passed,
109+
failed: total - passed,
110+
passRate: total > 0 ? `${((passed / total) * 100).toFixed(1)}%` : "N/A",
111+
totalDurationMs,
112+
totalCostUsd,
113+
avgTurns: total > 0 ? totalTurns / total : 0,
114+
avgDurationMs: total > 0 ? totalDurationMs / total : 0,
115+
categories: buildCategorySummaries(reports),
116+
scenarios: reports,
117+
failures: reports.filter((r) => !r.passed),
118+
};
119+
}
120+
121+
function renderConsoleReport(report: TestReport): string {
122+
const lines: string[] = [];
123+
const divider = "=".repeat(90);
124+
const thinDivider = "-".repeat(90);
125+
126+
lines.push("");
127+
lines.push(divider);
128+
lines.push(" KRX CLI E2E 시나리오 테스트 리포트");
129+
lines.push(divider);
130+
lines.push("");
131+
132+
// Overall summary
133+
lines.push(` 모델: ${report.model}`);
134+
lines.push(` 실행 시각: ${report.timestamp}`);
135+
lines.push(` 총 소요 시간: ${formatDuration(report.totalDurationMs)}`);
136+
lines.push(` 총 API 비용: ${formatCost(report.totalCostUsd)}`);
137+
lines.push("");
138+
139+
const passIcon = report.failed === 0 ? "PASS" : "FAIL";
140+
lines.push(
141+
` 결과: ${passIcon} ${report.passed}/${report.total} (${report.passRate})`,
142+
);
143+
lines.push("");
144+
145+
// Category summary table
146+
lines.push(thinDivider);
147+
lines.push(
148+
` ${pad("카테고리", 14)} ${padLeft("통과", 6)} ${padLeft("실패", 6)} ${padLeft("소요시간", 10)} ${padLeft("비용", 10)} ${padLeft("평균턴", 8)}`,
149+
);
150+
lines.push(thinDivider);
151+
152+
for (const cat of report.categories) {
153+
const status = cat.failed === 0 ? "OK" : "NG";
154+
lines.push(
155+
` ${pad(`${status} ${cat.category}`, 14)} ${padLeft(String(cat.passed), 6)} ${padLeft(String(cat.failed), 6)} ${padLeft(formatDuration(cat.totalDurationMs), 10)} ${padLeft(formatCost(cat.totalCostUsd), 10)} ${padLeft(cat.avgTurns.toFixed(1), 8)}`,
156+
);
157+
}
158+
159+
lines.push(thinDivider);
160+
lines.push("");
161+
162+
// Per-scenario table
163+
lines.push(thinDivider);
164+
lines.push(
165+
` ${pad("시나리오", 26)} ${padLeft("결과", 6)} ${padLeft("시간", 10)} ${padLeft("턴", 5)} ${padLeft("비용", 10)} 평가`,
166+
);
167+
lines.push(thinDivider);
168+
169+
for (const s of report.scenarios) {
170+
const status = s.passed ? "PASS" : "FAIL";
171+
const evalSummary = s.evalResults
172+
.map((e) => `${e.evaluator}:${e.passed ? "OK" : "NG"}`)
173+
.join(" ");
174+
175+
lines.push(
176+
` ${pad(s.scenarioId, 26)} ${padLeft(status, 6)} ${padLeft(formatDuration(s.durationMs), 10)} ${padLeft(String(s.numTurns), 5)} ${padLeft(formatCost(s.costUsd), 10)} ${evalSummary}`,
177+
);
178+
}
179+
180+
lines.push(thinDivider);
181+
lines.push("");
182+
183+
// Failed scenarios detail
184+
if (report.failures.length > 0) {
185+
lines.push(` 실패 시나리오 상세 (${report.failures.length}건)`);
186+
lines.push(thinDivider);
187+
188+
for (const f of report.failures) {
189+
lines.push(` [${f.scenarioId}] ${f.description}`);
190+
191+
for (const e of f.evalResults) {
192+
if (!e.passed) {
193+
lines.push(` ${e.evaluator}: ${e.message}`);
194+
}
195+
}
196+
197+
lines.push(` 응답 미리보기: ${f.resultPreview.slice(0, 150)}...`);
198+
lines.push("");
199+
}
200+
201+
lines.push(thinDivider);
202+
}
203+
204+
// Summary footer
205+
lines.push(
206+
` 평균 소요시간: ${formatDuration(report.avgDurationMs)} | 평균 턴: ${report.avgTurns.toFixed(1)} | 총 비용: ${formatCost(report.totalCostUsd)}`,
207+
);
208+
lines.push(divider);
209+
lines.push("");
210+
211+
return lines.join("\n");
212+
}
213+
214+
function saveJsonReport(report: TestReport, outputDir: string): string {
215+
mkdirSync(outputDir, { recursive: true });
216+
const timestamp = report.timestamp.replace(/[:.]/g, "-").slice(0, 19);
217+
const filePath = resolve(outputDir, `e2e-report-${timestamp}.json`);
218+
writeFileSync(filePath, JSON.stringify(report, null, 2));
219+
return filePath;
220+
}
221+
222+
export function generateReport(
223+
reports: readonly ScenarioReport[],
224+
model: string,
225+
options?: { readonly saveJson?: boolean; readonly outputDir?: string },
226+
): void {
227+
const report = buildReport(reports, model);
228+
const consoleOutput = renderConsoleReport(report);
229+
230+
console.log(consoleOutput);
231+
232+
if (options?.saveJson) {
233+
const outputDir =
234+
options.outputDir ?? resolve(process.cwd(), "tests/e2e/reports");
235+
const filePath = saveJsonReport(report, outputDir);
236+
console.log(` JSON 리포트 저장: ${filePath}\n`);
237+
}
238+
}

0 commit comments

Comments
 (0)