@mandujs/ate 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1103 -0
- package/package.json +46 -0
- package/src/codegen.ts +140 -0
- package/src/dep-graph.ts +279 -0
- package/src/domain-detector.ts +194 -0
- package/src/extractor.ts +159 -0
- package/src/fs.ts +145 -0
- package/src/heal.ts +427 -0
- package/src/impact.ts +146 -0
- package/src/index.ts +112 -0
- package/src/ir.ts +24 -0
- package/src/oracle.ts +152 -0
- package/src/pipeline.ts +207 -0
- package/src/report.ts +129 -0
- package/src/reporter/html-template.ts +275 -0
- package/src/reporter/html.test.ts +155 -0
- package/src/reporter/html.ts +83 -0
- package/src/runner.ts +100 -0
- package/src/scenario.ts +71 -0
- package/src/selector-map.ts +191 -0
- package/src/trace-parser.ts +270 -0
- package/src/types.ts +106 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type { SummaryJson } from "../types";
|
|
2
|
+
|
|
3
|
+
export function generateHtmlTemplate(summary: SummaryJson, screenshotUrls: string[] = []): string {
|
|
4
|
+
const { runId, startedAt, finishedAt, ok, oracle, playwright, heal, impact } = summary;
|
|
5
|
+
|
|
6
|
+
const duration = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
7
|
+
const durationStr = `${(duration / 1000).toFixed(2)}s`;
|
|
8
|
+
|
|
9
|
+
const passCount = ok ? 1 : 0;
|
|
10
|
+
const failCount = ok ? 0 : 1;
|
|
11
|
+
const skipCount = 0;
|
|
12
|
+
|
|
13
|
+
const statusClass = ok ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800";
|
|
14
|
+
const statusText = ok ? "PASSED" : "FAILED";
|
|
15
|
+
|
|
16
|
+
return `<!DOCTYPE html>
|
|
17
|
+
<html lang="ko">
|
|
18
|
+
<head>
|
|
19
|
+
<meta charset="UTF-8">
|
|
20
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
21
|
+
<title>ATE Test Report - ${runId}</title>
|
|
22
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
23
|
+
<style>
|
|
24
|
+
@media print {
|
|
25
|
+
.no-print { display: none; }
|
|
26
|
+
body { background: white; }
|
|
27
|
+
}
|
|
28
|
+
.dark-mode { background: #1a202c; color: #e2e8f0; }
|
|
29
|
+
.dark-mode .card { background: #2d3748; }
|
|
30
|
+
.dark-mode .border-gray-200 { border-color: #4a5568; }
|
|
31
|
+
</style>
|
|
32
|
+
</head>
|
|
33
|
+
<body class="bg-gray-50 p-8">
|
|
34
|
+
<div class="max-w-7xl mx-auto">
|
|
35
|
+
<!-- Header -->
|
|
36
|
+
<div class="mb-8">
|
|
37
|
+
<h1 class="text-4xl font-bold text-gray-900 mb-2">ATE Test Report</h1>
|
|
38
|
+
<div class="flex items-center gap-4 text-sm text-gray-600">
|
|
39
|
+
<span>Run ID: <strong>${runId}</strong></span>
|
|
40
|
+
<span>•</span>
|
|
41
|
+
<span>Started: ${new Date(startedAt).toLocaleString()}</span>
|
|
42
|
+
<span>•</span>
|
|
43
|
+
<span>Duration: ${durationStr}</span>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<!-- Summary Cards -->
|
|
48
|
+
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
|
49
|
+
<div class="bg-white rounded-lg shadow p-6 border-l-4 ${ok ? "border-green-500" : "border-red-500"}">
|
|
50
|
+
<div class="text-sm font-medium text-gray-600 mb-1">Status</div>
|
|
51
|
+
<div class="text-2xl font-bold ${statusClass} inline-block px-3 py-1 rounded">${statusText}</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="bg-white rounded-lg shadow p-6 border-l-4 border-green-500">
|
|
55
|
+
<div class="text-sm font-medium text-gray-600 mb-1">Passed</div>
|
|
56
|
+
<div class="text-3xl font-bold text-green-600">${passCount}</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div class="bg-white rounded-lg shadow p-6 border-l-4 border-red-500">
|
|
60
|
+
<div class="text-sm font-medium text-gray-600 mb-1">Failed</div>
|
|
61
|
+
<div class="text-3xl font-bold text-red-600">${failCount}</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="bg-white rounded-lg shadow p-6 border-l-4 border-gray-400">
|
|
65
|
+
<div class="text-sm font-medium text-gray-600 mb-1">Skipped</div>
|
|
66
|
+
<div class="text-3xl font-bold text-gray-600">${skipCount}</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<!-- Oracle Levels -->
|
|
71
|
+
<div class="bg-white rounded-lg shadow mb-8">
|
|
72
|
+
<div class="px-6 py-4 border-b border-gray-200">
|
|
73
|
+
<h2 class="text-xl font-bold text-gray-900">Oracle Verification (${oracle.level})</h2>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="p-6">
|
|
76
|
+
<div class="space-y-4">
|
|
77
|
+
${generateOracleSection("L0: Critical Errors", oracle.l0.ok, oracle.l0.errors)}
|
|
78
|
+
${generateOracleSection("L1: Console Warnings", oracle.l1.ok, oracle.l1.signals)}
|
|
79
|
+
${generateOracleSection("L2: Network Issues", oracle.l2.ok, oracle.l2.signals)}
|
|
80
|
+
${generateOracleSection("L3: Performance Notes", oracle.l3.ok, oracle.l3.notes)}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<!-- Impact Analysis -->
|
|
86
|
+
${generateImpactSection(impact)}
|
|
87
|
+
|
|
88
|
+
<!-- Heal Suggestions -->
|
|
89
|
+
${generateHealSection(heal)}
|
|
90
|
+
|
|
91
|
+
<!-- Screenshots -->
|
|
92
|
+
${generateScreenshotsSection(screenshotUrls)}
|
|
93
|
+
|
|
94
|
+
<!-- Playwright Trace -->
|
|
95
|
+
<div class="bg-white rounded-lg shadow mb-8">
|
|
96
|
+
<div class="px-6 py-4 border-b border-gray-200">
|
|
97
|
+
<h2 class="text-xl font-bold text-gray-900">Playwright Details</h2>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="p-6">
|
|
100
|
+
<div class="space-y-2 text-sm">
|
|
101
|
+
<div><strong>Exit Code:</strong> ${playwright.exitCode}</div>
|
|
102
|
+
<div><strong>Report Directory:</strong> <code class="bg-gray-100 px-2 py-1 rounded">${playwright.reportDir}</code></div>
|
|
103
|
+
${
|
|
104
|
+
playwright.jsonReportPath
|
|
105
|
+
? `<div><strong>JSON Report:</strong> <code class="bg-gray-100 px-2 py-1 rounded">${playwright.jsonReportPath}</code></div>`
|
|
106
|
+
: ""
|
|
107
|
+
}
|
|
108
|
+
${
|
|
109
|
+
playwright.junitPath
|
|
110
|
+
? `<div><strong>JUnit XML:</strong> <code class="bg-gray-100 px-2 py-1 rounded">${playwright.junitPath}</code></div>`
|
|
111
|
+
: ""
|
|
112
|
+
}
|
|
113
|
+
<div class="mt-4">
|
|
114
|
+
<a href="./playwright-report/index.html" target="_blank" class="inline-block bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition">
|
|
115
|
+
View Playwright Report →
|
|
116
|
+
</a>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<!-- Footer -->
|
|
123
|
+
<div class="text-center text-sm text-gray-500 mt-8">
|
|
124
|
+
Generated by <strong>@mandujs/ate</strong> at ${new Date().toLocaleString()}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<script>
|
|
129
|
+
// Dark mode toggle (optional)
|
|
130
|
+
function toggleDarkMode() {
|
|
131
|
+
document.body.classList.toggle('dark-mode');
|
|
132
|
+
}
|
|
133
|
+
</script>
|
|
134
|
+
</body>
|
|
135
|
+
</html>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function generateOracleSection(title: string, ok: boolean, items: string[]): string {
|
|
139
|
+
const icon = ok ? "✅" : "❌";
|
|
140
|
+
const statusClass = ok ? "text-green-600" : "text-red-600";
|
|
141
|
+
|
|
142
|
+
return `
|
|
143
|
+
<div class="border border-gray-200 rounded-lg p-4">
|
|
144
|
+
<div class="flex items-center gap-2 mb-2">
|
|
145
|
+
<span class="${statusClass} text-xl">${icon}</span>
|
|
146
|
+
<h3 class="font-semibold text-gray-900">${title}</h3>
|
|
147
|
+
</div>
|
|
148
|
+
${
|
|
149
|
+
items.length > 0
|
|
150
|
+
? `<ul class="list-disc list-inside text-sm text-gray-700 space-y-1">
|
|
151
|
+
${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}
|
|
152
|
+
</ul>`
|
|
153
|
+
: '<div class="text-sm text-gray-500 italic">No issues detected</div>'
|
|
154
|
+
}
|
|
155
|
+
</div>
|
|
156
|
+
`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function generateImpactSection(impact: SummaryJson["impact"]): string {
|
|
160
|
+
if (impact.mode === "full") {
|
|
161
|
+
return `
|
|
162
|
+
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
|
|
163
|
+
<h2 class="text-xl font-bold text-blue-900 mb-2">Impact Analysis</h2>
|
|
164
|
+
<p class="text-blue-700">Full test run (no filtering applied)</p>
|
|
165
|
+
</div>
|
|
166
|
+
`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return `
|
|
170
|
+
<div class="bg-white rounded-lg shadow mb-8">
|
|
171
|
+
<div class="px-6 py-4 border-b border-gray-200">
|
|
172
|
+
<h2 class="text-xl font-bold text-gray-900">Impact Analysis (Subset Mode)</h2>
|
|
173
|
+
</div>
|
|
174
|
+
<div class="p-6">
|
|
175
|
+
<div class="mb-4">
|
|
176
|
+
<h3 class="font-semibold text-gray-900 mb-2">Changed Files (${impact.changedFiles.length})</h3>
|
|
177
|
+
${
|
|
178
|
+
impact.changedFiles.length > 0
|
|
179
|
+
? `<ul class="list-disc list-inside text-sm text-gray-700 space-y-1">
|
|
180
|
+
${impact.changedFiles.map((file) => `<li><code class="bg-gray-100 px-1">${escapeHtml(file)}</code></li>`).join("")}
|
|
181
|
+
</ul>`
|
|
182
|
+
: '<div class="text-sm text-gray-500 italic">No changed files</div>'
|
|
183
|
+
}
|
|
184
|
+
</div>
|
|
185
|
+
<div>
|
|
186
|
+
<h3 class="font-semibold text-gray-900 mb-2">Selected Routes (${impact.selectedRoutes.length})</h3>
|
|
187
|
+
${
|
|
188
|
+
impact.selectedRoutes.length > 0
|
|
189
|
+
? `<ul class="list-disc list-inside text-sm text-gray-700 space-y-1">
|
|
190
|
+
${impact.selectedRoutes.map((route) => `<li><code class="bg-gray-100 px-1">${escapeHtml(route)}</code></li>`).join("")}
|
|
191
|
+
</ul>`
|
|
192
|
+
: '<div class="text-sm text-gray-500 italic">No routes selected</div>'
|
|
193
|
+
}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function generateHealSection(heal: SummaryJson["heal"]): string {
|
|
201
|
+
if (!heal.attempted || heal.suggestions.length === 0) {
|
|
202
|
+
return `
|
|
203
|
+
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 mb-8">
|
|
204
|
+
<h2 class="text-xl font-bold text-gray-900 mb-2">Heal Suggestions</h2>
|
|
205
|
+
<p class="text-gray-600 italic">No heal suggestions available</p>
|
|
206
|
+
</div>
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return `
|
|
211
|
+
<div class="bg-white rounded-lg shadow mb-8">
|
|
212
|
+
<div class="px-6 py-4 border-b border-gray-200">
|
|
213
|
+
<h2 class="text-xl font-bold text-gray-900">Heal Suggestions (${heal.suggestions.length})</h2>
|
|
214
|
+
</div>
|
|
215
|
+
<div class="p-6 space-y-4">
|
|
216
|
+
${heal.suggestions
|
|
217
|
+
.map(
|
|
218
|
+
(sug, idx) => `
|
|
219
|
+
<div class="border border-yellow-200 bg-yellow-50 rounded-lg p-4">
|
|
220
|
+
<div class="flex items-start gap-3">
|
|
221
|
+
<span class="bg-yellow-500 text-white rounded-full w-6 h-6 flex items-center justify-center font-bold text-sm">${idx + 1}</span>
|
|
222
|
+
<div class="flex-1">
|
|
223
|
+
<div class="font-semibold text-gray-900 mb-1">${escapeHtml(sug.title)}</div>
|
|
224
|
+
<div class="text-sm text-gray-600 mb-2">Kind: <code class="bg-white px-2 py-1 rounded">${escapeHtml(sug.kind)}</code></div>
|
|
225
|
+
<details class="mt-2">
|
|
226
|
+
<summary class="cursor-pointer text-blue-600 hover:text-blue-700 text-sm font-medium">View Diff</summary>
|
|
227
|
+
<pre class="mt-2 bg-white border border-gray-200 rounded p-3 text-xs overflow-x-auto"><code>${escapeHtml(sug.diff)}</code></pre>
|
|
228
|
+
</details>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
`
|
|
233
|
+
)
|
|
234
|
+
.join("")}
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function generateScreenshotsSection(screenshotUrls: string[]): string {
|
|
241
|
+
if (screenshotUrls.length === 0) {
|
|
242
|
+
return "";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return `
|
|
246
|
+
<div class="bg-white rounded-lg shadow mb-8">
|
|
247
|
+
<div class="px-6 py-4 border-b border-gray-200">
|
|
248
|
+
<h2 class="text-xl font-bold text-gray-900">Screenshots (${screenshotUrls.length})</h2>
|
|
249
|
+
</div>
|
|
250
|
+
<div class="p-6">
|
|
251
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
252
|
+
${screenshotUrls
|
|
253
|
+
.map(
|
|
254
|
+
(url, idx) => `
|
|
255
|
+
<a href="${escapeHtml(url)}" target="_blank" class="block border border-gray-200 rounded-lg overflow-hidden hover:shadow-lg transition">
|
|
256
|
+
<img src="${escapeHtml(url)}" alt="Screenshot ${idx + 1}" class="w-full h-48 object-cover" loading="lazy" />
|
|
257
|
+
<div class="p-2 bg-gray-50 text-xs text-gray-600 text-center">Screenshot ${idx + 1}</div>
|
|
258
|
+
</a>
|
|
259
|
+
`
|
|
260
|
+
)
|
|
261
|
+
.join("")}
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function escapeHtml(str: string): string {
|
|
269
|
+
return str
|
|
270
|
+
.replace(/&/g, "&")
|
|
271
|
+
.replace(/</g, "<")
|
|
272
|
+
.replace(/>/g, ">")
|
|
273
|
+
.replace(/"/g, """)
|
|
274
|
+
.replace(/'/g, "'");
|
|
275
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { generateHtmlReport } from "./html";
|
|
6
|
+
import type { SummaryJson } from "../types";
|
|
7
|
+
|
|
8
|
+
describe("HTML Reporter", () => {
|
|
9
|
+
let testDir: string;
|
|
10
|
+
let repoRoot: string;
|
|
11
|
+
let runId: string;
|
|
12
|
+
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
testDir = mkdtempSync(join(tmpdir(), "ate-html-test-"));
|
|
15
|
+
repoRoot = testDir;
|
|
16
|
+
runId = "test-run-001";
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
if (testDir) {
|
|
21
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("HTML 리포트 생성 - 기본", async () => {
|
|
26
|
+
// Setup
|
|
27
|
+
const manduDir = join(repoRoot, ".mandu");
|
|
28
|
+
const reportsDir = join(manduDir, "reports");
|
|
29
|
+
const runDir = join(reportsDir, runId);
|
|
30
|
+
|
|
31
|
+
// Create directories
|
|
32
|
+
await Bun.write(join(runDir, ".gitkeep"), "");
|
|
33
|
+
|
|
34
|
+
const summary: SummaryJson = {
|
|
35
|
+
schemaVersion: 1,
|
|
36
|
+
runId,
|
|
37
|
+
startedAt: "2026-02-15T04:00:00.000Z",
|
|
38
|
+
finishedAt: "2026-02-15T04:00:10.000Z",
|
|
39
|
+
ok: true,
|
|
40
|
+
oracle: {
|
|
41
|
+
level: "L1",
|
|
42
|
+
l0: { ok: true, errors: [] },
|
|
43
|
+
l1: { ok: true, signals: [] },
|
|
44
|
+
l2: { ok: true, signals: [] },
|
|
45
|
+
l3: { ok: true, notes: [] },
|
|
46
|
+
},
|
|
47
|
+
playwright: {
|
|
48
|
+
exitCode: 0,
|
|
49
|
+
reportDir: runDir,
|
|
50
|
+
},
|
|
51
|
+
mandu: {
|
|
52
|
+
interactionGraphPath: join(manduDir, "interaction-graph.json"),
|
|
53
|
+
selectorMapPath: join(manduDir, "selector-map.json"),
|
|
54
|
+
scenariosPath: join(manduDir, "scenarios.json"),
|
|
55
|
+
},
|
|
56
|
+
heal: {
|
|
57
|
+
attempted: false,
|
|
58
|
+
suggestions: [],
|
|
59
|
+
},
|
|
60
|
+
impact: {
|
|
61
|
+
mode: "full",
|
|
62
|
+
changedFiles: [],
|
|
63
|
+
selectedRoutes: [],
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
writeFileSync(join(runDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
|
|
68
|
+
|
|
69
|
+
// Execute
|
|
70
|
+
const result = await generateHtmlReport({
|
|
71
|
+
repoRoot,
|
|
72
|
+
runId,
|
|
73
|
+
includeScreenshots: false,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Verify
|
|
77
|
+
expect(result.path).toBe(join(runDir, "index.html"));
|
|
78
|
+
expect(result.size).toBeGreaterThan(0);
|
|
79
|
+
|
|
80
|
+
const html = readFileSync(result.path, "utf-8");
|
|
81
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
82
|
+
expect(html).toContain("ATE Test Report");
|
|
83
|
+
expect(html).toContain(runId);
|
|
84
|
+
expect(html).toContain("PASSED");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("HTML 리포트 생성 - 실패 케이스 with Heal", async () => {
|
|
88
|
+
const runId2 = "test-run-002";
|
|
89
|
+
const manduDir = join(repoRoot, ".mandu");
|
|
90
|
+
const reportsDir = join(manduDir, "reports");
|
|
91
|
+
const runDir = join(reportsDir, runId2);
|
|
92
|
+
|
|
93
|
+
await Bun.write(join(runDir, ".gitkeep"), "");
|
|
94
|
+
|
|
95
|
+
const summary: SummaryJson = {
|
|
96
|
+
schemaVersion: 1,
|
|
97
|
+
runId: runId2,
|
|
98
|
+
startedAt: "2026-02-15T04:00:00.000Z",
|
|
99
|
+
finishedAt: "2026-02-15T04:00:10.000Z",
|
|
100
|
+
ok: false,
|
|
101
|
+
oracle: {
|
|
102
|
+
level: "L2",
|
|
103
|
+
l0: { ok: false, errors: ["TypeError at line 42"] },
|
|
104
|
+
l1: { ok: false, signals: ["Console error: API failed"] },
|
|
105
|
+
l2: { ok: true, signals: [] },
|
|
106
|
+
l3: { ok: true, notes: [] },
|
|
107
|
+
},
|
|
108
|
+
playwright: {
|
|
109
|
+
exitCode: 1,
|
|
110
|
+
reportDir: runDir,
|
|
111
|
+
},
|
|
112
|
+
mandu: {
|
|
113
|
+
interactionGraphPath: join(manduDir, "interaction-graph.json"),
|
|
114
|
+
},
|
|
115
|
+
heal: {
|
|
116
|
+
attempted: true,
|
|
117
|
+
suggestions: [
|
|
118
|
+
{
|
|
119
|
+
kind: "selector_update",
|
|
120
|
+
title: "Fix button selector",
|
|
121
|
+
diff: "@@ -1,1 +1,1 @@\n-click('button')\n+click('[data-testid=\"submit\"]')\n",
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
impact: {
|
|
126
|
+
mode: "subset",
|
|
127
|
+
changedFiles: ["src/App.tsx"],
|
|
128
|
+
selectedRoutes: ["/login"],
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
writeFileSync(join(runDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
|
|
133
|
+
|
|
134
|
+
const result = await generateHtmlReport({
|
|
135
|
+
repoRoot,
|
|
136
|
+
runId: runId2,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const html = readFileSync(result.path, "utf-8");
|
|
140
|
+
expect(html).toContain("FAILED");
|
|
141
|
+
expect(html).toContain("TypeError at line 42");
|
|
142
|
+
expect(html).toContain("Fix button selector");
|
|
143
|
+
expect(html).toContain("selector_update");
|
|
144
|
+
expect(html).toContain("src/App.tsx");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("에러 처리 - summary.json 없음", async () => {
|
|
148
|
+
expect(
|
|
149
|
+
generateHtmlReport({
|
|
150
|
+
repoRoot,
|
|
151
|
+
runId: "non-existent-run",
|
|
152
|
+
})
|
|
153
|
+
).rejects.toThrow("Summary 파일을 찾을 수 없습니다");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { getAtePaths, ensureDir } from "../fs";
|
|
4
|
+
import type { SummaryJson } from "../types";
|
|
5
|
+
import { generateHtmlTemplate } from "./html-template";
|
|
6
|
+
|
|
7
|
+
export interface HtmlReportOptions {
|
|
8
|
+
repoRoot: string;
|
|
9
|
+
runId: string;
|
|
10
|
+
outputPath?: string;
|
|
11
|
+
includeScreenshots?: boolean;
|
|
12
|
+
includeTraces?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface HtmlReportResult {
|
|
16
|
+
path: string;
|
|
17
|
+
size: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function generateHtmlReport(options: HtmlReportOptions): Promise<HtmlReportResult> {
|
|
21
|
+
const { repoRoot, runId, outputPath, includeScreenshots = true } = options;
|
|
22
|
+
|
|
23
|
+
if (!repoRoot) {
|
|
24
|
+
throw new Error("repoRoot는 필수입니다");
|
|
25
|
+
}
|
|
26
|
+
if (!runId) {
|
|
27
|
+
throw new Error("runId는 필수입니다");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const paths = getAtePaths(repoRoot);
|
|
31
|
+
const runDir = join(paths.reportsDir, runId);
|
|
32
|
+
const summaryPath = join(runDir, "summary.json");
|
|
33
|
+
|
|
34
|
+
// 1. summary.json 읽기
|
|
35
|
+
if (!existsSync(summaryPath)) {
|
|
36
|
+
throw new Error(`Summary 파일을 찾을 수 없습니다: ${summaryPath}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let summary: SummaryJson;
|
|
40
|
+
try {
|
|
41
|
+
const content = readFileSync(summaryPath, "utf-8");
|
|
42
|
+
summary = JSON.parse(content);
|
|
43
|
+
} catch (err: any) {
|
|
44
|
+
throw new Error(`Summary 파일 읽기 실패: ${err.message}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. 스크린샷 URL 수집 (선택)
|
|
48
|
+
const screenshotUrls: string[] = [];
|
|
49
|
+
if (includeScreenshots) {
|
|
50
|
+
try {
|
|
51
|
+
const screenshotsDir = join(runDir, "screenshots");
|
|
52
|
+
if (existsSync(screenshotsDir)) {
|
|
53
|
+
const files = readdirSync(screenshotsDir);
|
|
54
|
+
files
|
|
55
|
+
.filter((f) => /\.(png|jpg|jpeg)$/i.test(f))
|
|
56
|
+
.forEach((f) => {
|
|
57
|
+
screenshotUrls.push(`./screenshots/${f}`);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
} catch (err: any) {
|
|
61
|
+
console.warn(`스크린샷 수집 실패: ${err.message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. HTML 생성
|
|
66
|
+
const html = generateHtmlTemplate(summary, screenshotUrls);
|
|
67
|
+
|
|
68
|
+
// 4. 파일 저장
|
|
69
|
+
const htmlPath = outputPath ?? join(runDir, "index.html");
|
|
70
|
+
try {
|
|
71
|
+
ensureDir(join(htmlPath, "..")); // 상위 디렉토리 확인
|
|
72
|
+
writeFileSync(htmlPath, html, "utf-8");
|
|
73
|
+
} catch (err: any) {
|
|
74
|
+
throw new Error(`HTML 파일 저장 실패: ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const size = Buffer.byteLength(html, "utf-8");
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
path: htmlPath,
|
|
81
|
+
size,
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAtePaths, ensureDir, writeJson } from "./fs";
|
|
4
|
+
import type { RunInput } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface RunResult {
|
|
7
|
+
runId: string;
|
|
8
|
+
reportDir: string;
|
|
9
|
+
exitCode: number;
|
|
10
|
+
jsonReportPath?: string;
|
|
11
|
+
junitPath?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function nowRunId(): string {
|
|
15
|
+
return `run-${Date.now()}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runPlaywright(input: RunInput): Promise<RunResult> {
|
|
19
|
+
const repoRoot = input.repoRoot;
|
|
20
|
+
const paths = getAtePaths(repoRoot);
|
|
21
|
+
const runId = nowRunId();
|
|
22
|
+
|
|
23
|
+
// Validate input
|
|
24
|
+
if (!repoRoot) {
|
|
25
|
+
throw new Error("repoRoot는 필수입니다");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const runDir = join(paths.reportsDir, runId);
|
|
29
|
+
const latestDir = join(paths.reportsDir, "latest");
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
ensureDir(runDir);
|
|
33
|
+
ensureDir(latestDir);
|
|
34
|
+
} catch (err: any) {
|
|
35
|
+
throw new Error(`Report 디렉토리 생성 실패: ${err.message}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const baseURL = input.baseURL ?? process.env.BASE_URL ?? "http://localhost:3333";
|
|
39
|
+
|
|
40
|
+
const args = [
|
|
41
|
+
"playwright",
|
|
42
|
+
"test",
|
|
43
|
+
"--config",
|
|
44
|
+
"tests/e2e/playwright.config.ts",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const env = {
|
|
48
|
+
...process.env,
|
|
49
|
+
CI: input.ci ? "true" : process.env.CI,
|
|
50
|
+
BASE_URL: baseURL,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
let child;
|
|
54
|
+
try {
|
|
55
|
+
child = spawn("bunx", args, {
|
|
56
|
+
cwd: repoRoot,
|
|
57
|
+
stdio: "inherit",
|
|
58
|
+
env,
|
|
59
|
+
});
|
|
60
|
+
} catch (err: any) {
|
|
61
|
+
throw new Error(`Playwright 프로세스 시작 실패: ${err.message}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const exitCode: number = await new Promise((resolve, reject) => {
|
|
65
|
+
// Timeout protection (10 minutes)
|
|
66
|
+
const timeout = setTimeout(() => {
|
|
67
|
+
child.kill("SIGTERM");
|
|
68
|
+
reject(new Error("Playwright 실행 타임아웃 (10분 초과)"));
|
|
69
|
+
}, 10 * 60 * 1000);
|
|
70
|
+
|
|
71
|
+
child.on("exit", (code) => {
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
resolve(code ?? 1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
child.on("error", (err) => {
|
|
77
|
+
clearTimeout(timeout);
|
|
78
|
+
console.error(`[ATE] Playwright 실행 에러: ${err.message}`);
|
|
79
|
+
resolve(1); // Fail gracefully
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const result: RunResult = {
|
|
84
|
+
runId,
|
|
85
|
+
reportDir: runDir,
|
|
86
|
+
exitCode,
|
|
87
|
+
jsonReportPath: join(latestDir, "playwright-report.json"),
|
|
88
|
+
junitPath: join(latestDir, "junit.xml"),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// record minimal run metadata
|
|
92
|
+
try {
|
|
93
|
+
writeJson(join(runDir, "run.json"), { ...result, baseURL, at: new Date().toISOString() });
|
|
94
|
+
} catch (err: any) {
|
|
95
|
+
console.warn(`[ATE] Run metadata 저장 실패: ${err.message}`);
|
|
96
|
+
// Non-fatal: continue
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return result;
|
|
100
|
+
}
|
package/src/scenario.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { InteractionGraph, OracleLevel } from "./types";
|
|
2
|
+
import { getAtePaths, readJson, writeJson } from "./fs";
|
|
3
|
+
|
|
4
|
+
export interface GeneratedScenario {
|
|
5
|
+
id: string;
|
|
6
|
+
kind: "route-smoke";
|
|
7
|
+
route: string;
|
|
8
|
+
oracleLevel: OracleLevel;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ScenarioBundle {
|
|
12
|
+
schemaVersion: 1;
|
|
13
|
+
generatedAt: string;
|
|
14
|
+
oracleLevel: OracleLevel;
|
|
15
|
+
scenarios: GeneratedScenario[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const VALID_ORACLE_LEVELS: OracleLevel[] = ["L0", "L1", "L2", "L3"];
|
|
19
|
+
|
|
20
|
+
export function generateScenariosFromGraph(graph: InteractionGraph, oracleLevel: OracleLevel): ScenarioBundle {
|
|
21
|
+
// Validate oracle level
|
|
22
|
+
if (!VALID_ORACLE_LEVELS.includes(oracleLevel)) {
|
|
23
|
+
throw new Error(`잘못된 oracleLevel입니다: ${oracleLevel} (허용: ${VALID_ORACLE_LEVELS.join(", ")})`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Validate graph
|
|
27
|
+
if (!graph || !graph.nodes) {
|
|
28
|
+
throw new Error("빈 interaction graph입니다 (nodes가 없습니다)");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const routes = graph.nodes.filter((n) => n.kind === "route") as Array<{ kind: "route"; id: string; path: string }>;
|
|
32
|
+
|
|
33
|
+
if (routes.length === 0) {
|
|
34
|
+
console.warn("[ATE] 경고: route가 없습니다. 빈 시나리오 번들을 생성합니다.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const scenarios: GeneratedScenario[] = routes.map((r) => ({
|
|
38
|
+
id: `route:${r.id}`,
|
|
39
|
+
kind: "route-smoke",
|
|
40
|
+
route: r.id,
|
|
41
|
+
oracleLevel,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
schemaVersion: 1,
|
|
46
|
+
generatedAt: new Date().toISOString(),
|
|
47
|
+
oracleLevel,
|
|
48
|
+
scenarios,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function generateAndWriteScenarios(repoRoot: string, oracleLevel: OracleLevel): { scenariosPath: string; count: number } {
|
|
53
|
+
const paths = getAtePaths(repoRoot);
|
|
54
|
+
|
|
55
|
+
let graph: InteractionGraph;
|
|
56
|
+
try {
|
|
57
|
+
graph = readJson<InteractionGraph>(paths.interactionGraphPath);
|
|
58
|
+
} catch (err: any) {
|
|
59
|
+
throw new Error(`Interaction graph 읽기 실패: ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const bundle = generateScenariosFromGraph(graph, oracleLevel);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
writeJson(paths.scenariosPath, bundle);
|
|
66
|
+
} catch (err: any) {
|
|
67
|
+
throw new Error(`시나리오 파일 저장 실패: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { scenariosPath: paths.scenariosPath, count: bundle.scenarios.length };
|
|
71
|
+
}
|