@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
package/src/oracle.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { OracleLevel } from "./types";
|
|
2
|
+
import { detectDomain, type AppDomain } from "./domain-detector";
|
|
3
|
+
|
|
4
|
+
export interface OracleResult {
|
|
5
|
+
level: OracleLevel;
|
|
6
|
+
l0: { ok: boolean; errors: string[] };
|
|
7
|
+
l1: { ok: boolean; signals: string[] };
|
|
8
|
+
l2: { ok: boolean; signals: string[] };
|
|
9
|
+
l3: { ok: boolean; notes: string[] };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createDefaultOracle(level: OracleLevel): OracleResult {
|
|
13
|
+
return {
|
|
14
|
+
level,
|
|
15
|
+
l0: { ok: true, errors: [] },
|
|
16
|
+
l1: { ok: level !== "L0", signals: [] },
|
|
17
|
+
l2: { ok: true, signals: [] },
|
|
18
|
+
l3: { ok: true, notes: [] },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate L1 assertions based on detected domain
|
|
24
|
+
*/
|
|
25
|
+
export function generateL1Assertions(domain: AppDomain, routePath: string): string[] {
|
|
26
|
+
const assertions: string[] = [];
|
|
27
|
+
|
|
28
|
+
// Common structural assertions for all domains
|
|
29
|
+
assertions.push(`// L1: Domain-aware structure signals (${domain})`);
|
|
30
|
+
assertions.push(`await expect(page.locator("main, [role='main']")).toBeVisible();`);
|
|
31
|
+
|
|
32
|
+
switch (domain) {
|
|
33
|
+
case "ecommerce":
|
|
34
|
+
if (routePath.includes("/cart")) {
|
|
35
|
+
assertions.push(`await expect(page.locator("[data-testid='cart-items'], .cart-item, [class*='cart']")).toBeVisible();`);
|
|
36
|
+
assertions.push(`await expect(page.locator("button:has-text('Checkout'), button:has-text('Proceed')")).toBeVisible();`);
|
|
37
|
+
} else if (routePath.includes("/product")) {
|
|
38
|
+
assertions.push(`await expect(page.locator("h1, [data-testid='product-title']")).toBeVisible();`);
|
|
39
|
+
assertions.push(`await expect(page.locator("button:has-text('Add to Cart'), button[class*='add']")).toBeVisible();`);
|
|
40
|
+
assertions.push(`await expect(page.locator("[data-testid='price'], .price, [class*='price']")).toBeVisible();`);
|
|
41
|
+
} else if (routePath.includes("/checkout")) {
|
|
42
|
+
assertions.push(`await expect(page.locator("form, [data-testid='checkout-form']")).toBeVisible();`);
|
|
43
|
+
assertions.push(`await expect(page.locator("input[type='email'], input[name*='email']")).toBeVisible();`);
|
|
44
|
+
} else if (routePath.includes("/shop")) {
|
|
45
|
+
assertions.push(`await expect(page.locator("[data-testid='product-card'], .product, [class*='product']")).toHaveCount({ min: 1 });`);
|
|
46
|
+
assertions.push(`await expect(page.locator("a[href*='/product'], [data-testid='product-link']")).toHaveCount({ min: 1 });`);
|
|
47
|
+
} else {
|
|
48
|
+
// Generic ecommerce page fallback
|
|
49
|
+
assertions.push(`await expect(page.locator("nav, [role='navigation']")).toBeVisible();`);
|
|
50
|
+
assertions.push(`await expect(page.locator("a, button")).toHaveCount({ min: 1 });`);
|
|
51
|
+
}
|
|
52
|
+
break;
|
|
53
|
+
|
|
54
|
+
case "blog":
|
|
55
|
+
if (routePath.includes("/post") || routePath.includes("/article")) {
|
|
56
|
+
assertions.push(`await expect(page.locator("article, [role='article']")).toBeVisible();`);
|
|
57
|
+
assertions.push(`await expect(page.locator("h1, [data-testid='post-title']")).toBeVisible();`);
|
|
58
|
+
assertions.push(`await expect(page.locator("[data-testid='post-content'], .content, [class*='content']")).toBeVisible();`);
|
|
59
|
+
} else if (routePath.includes("/author")) {
|
|
60
|
+
assertions.push(`await expect(page.locator("[data-testid='author-name'], .author")).toBeVisible();`);
|
|
61
|
+
assertions.push(`await expect(page.locator("[data-testid='author-posts'], .posts")).toBeVisible();`);
|
|
62
|
+
} else {
|
|
63
|
+
// Blog index/listing fallback
|
|
64
|
+
assertions.push(`await expect(page.locator("h1, h2")).toBeVisible();`);
|
|
65
|
+
assertions.push(`await expect(page.locator("a")).toHaveCount({ min: 1 });`);
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
|
|
69
|
+
case "dashboard":
|
|
70
|
+
assertions.push(`await expect(page.locator("nav, [role='navigation'], aside, [data-testid='sidebar']")).toBeVisible();`);
|
|
71
|
+
if (routePath.includes("/analytics") || routePath.includes("/dashboard")) {
|
|
72
|
+
assertions.push(`await expect(page.locator("canvas, svg, [data-testid='chart']")).toHaveCount({ min: 1 });`);
|
|
73
|
+
assertions.push(`await expect(page.locator("[data-testid='metric'], .metric, [class*='stat']")).toHaveCount({ min: 1 });`);
|
|
74
|
+
} else if (routePath.includes("/settings")) {
|
|
75
|
+
assertions.push(`await expect(page.locator("form, [data-testid='settings-form']")).toBeVisible();`);
|
|
76
|
+
assertions.push(`await expect(page.locator("button:has-text('Save'), button[type='submit']")).toBeVisible();`);
|
|
77
|
+
} else {
|
|
78
|
+
// Generic dashboard page fallback
|
|
79
|
+
assertions.push(`await expect(page.locator("h1, h2")).toBeVisible();`);
|
|
80
|
+
assertions.push(`await expect(page.locator("a, button")).toHaveCount({ min: 1 });`);
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
|
|
84
|
+
case "auth":
|
|
85
|
+
assertions.push(`await expect(page.locator("form, [data-testid='auth-form']")).toBeVisible();`);
|
|
86
|
+
if (routePath.includes("/login")) {
|
|
87
|
+
assertions.push(`await expect(page.locator("input[type='email'], input[name*='email']")).toBeVisible();`);
|
|
88
|
+
assertions.push(`await expect(page.locator("input[type='password']")).toBeVisible();`);
|
|
89
|
+
assertions.push(`await expect(page.locator("button:has-text('Login'), button:has-text('Sign in')")).toBeVisible();`);
|
|
90
|
+
} else if (routePath.includes("/signup") || routePath.includes("/register")) {
|
|
91
|
+
assertions.push(`await expect(page.locator("input[type='email'], input[name*='email']")).toBeVisible();`);
|
|
92
|
+
assertions.push(`await expect(page.locator("input[type='password']")).toBeVisible();`);
|
|
93
|
+
assertions.push(`await expect(page.locator("button:has-text('Sign up'), button:has-text('Register')")).toBeVisible();`);
|
|
94
|
+
} else if (routePath.includes("/forgot-password")) {
|
|
95
|
+
assertions.push(`await expect(page.locator("input[type='email'], input[name*='email']")).toBeVisible();`);
|
|
96
|
+
assertions.push(`await expect(page.locator("button:has-text('Reset'), button:has-text('Send')")).toBeVisible();`);
|
|
97
|
+
} else {
|
|
98
|
+
// Generic auth page fallback
|
|
99
|
+
assertions.push(`await expect(page.locator("input")).toHaveCount({ min: 1 });`);
|
|
100
|
+
assertions.push(`await expect(page.locator("button[type='submit'], button")).toHaveCount({ min: 1 });`);
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
|
|
104
|
+
case "generic":
|
|
105
|
+
default:
|
|
106
|
+
// Generic fallback assertions
|
|
107
|
+
assertions.push(`await expect(page.locator("h1")).toBeVisible();`);
|
|
108
|
+
assertions.push(`await expect(page.locator("a, button")).toHaveCount({ min: 1 });`);
|
|
109
|
+
assertions.push(`await expect(page).toHaveTitle(/.+/);`);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return assertions;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Upgrade L0 test code to L1 with domain-aware assertions
|
|
118
|
+
*/
|
|
119
|
+
export function upgradeL0ToL1(testCode: string, routePath: string, sourceCode?: string): string {
|
|
120
|
+
const detection = detectDomain(routePath, sourceCode);
|
|
121
|
+
const l1Assertions = generateL1Assertions(detection.domain, routePath);
|
|
122
|
+
|
|
123
|
+
// Find the L0 error check assertion
|
|
124
|
+
const l0ErrorCheckRegex = /expect\(errors.*?\)\.toEqual\(\[\]\);/;
|
|
125
|
+
const match = testCode.match(l0ErrorCheckRegex);
|
|
126
|
+
|
|
127
|
+
if (!match) {
|
|
128
|
+
// If no L0 error check found, append L1 assertions before the closing braces
|
|
129
|
+
const closingBraceIndex = testCode.lastIndexOf("});");
|
|
130
|
+
if (closingBraceIndex === -1) return testCode;
|
|
131
|
+
|
|
132
|
+
const beforeClosing = testCode.slice(0, closingBraceIndex);
|
|
133
|
+
const afterClosing = testCode.slice(closingBraceIndex);
|
|
134
|
+
|
|
135
|
+
return `${beforeClosing}\n ${l1Assertions.join("\n ")}\n${afterClosing}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Insert L1 assertions before the L0 error check
|
|
139
|
+
const insertIndex = match.index!;
|
|
140
|
+
const before = testCode.slice(0, insertIndex);
|
|
141
|
+
const after = testCode.slice(insertIndex);
|
|
142
|
+
|
|
143
|
+
return `${before}${l1Assertions.join("\n ")}\n ${after}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get assertion count for a domain and route
|
|
148
|
+
*/
|
|
149
|
+
export function getAssertionCount(domain: AppDomain, routePath: string): number {
|
|
150
|
+
const assertions = generateL1Assertions(domain, routePath);
|
|
151
|
+
return assertions.filter((a) => a.includes("expect(")).length;
|
|
152
|
+
}
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { OracleLevel } from "./types";
|
|
2
|
+
import { ATEFileError } from "./fs";
|
|
3
|
+
import { ateExtract, ateGenerate, ateRun, ateReport, ateImpact, ateHeal } from "./index";
|
|
4
|
+
|
|
5
|
+
export interface AutoPipelineOptions {
|
|
6
|
+
repoRoot: string;
|
|
7
|
+
baseURL?: string;
|
|
8
|
+
oracleLevel?: OracleLevel;
|
|
9
|
+
ci?: boolean;
|
|
10
|
+
useImpactAnalysis?: boolean;
|
|
11
|
+
base?: string;
|
|
12
|
+
head?: string;
|
|
13
|
+
autoHeal?: boolean;
|
|
14
|
+
tsconfigPath?: string;
|
|
15
|
+
routeGlobs?: string[];
|
|
16
|
+
buildSalt?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AutoPipelineResult {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
steps: {
|
|
22
|
+
extract: { ok: boolean; error?: string };
|
|
23
|
+
generate: { ok: boolean; error?: string };
|
|
24
|
+
impact?: { ok: boolean; mode: "full" | "subset"; selectedRoutes: string[]; error?: string };
|
|
25
|
+
run: { ok: boolean; runId: string; exitCode: number; error?: string };
|
|
26
|
+
report: { ok: boolean; summaryPath?: string; error?: string };
|
|
27
|
+
heal?: { ok: boolean; suggestionsCount: number; error?: string };
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* ATE 전체 파이프라인 자동 실행
|
|
33
|
+
* Extract → Generate → (Impact) → Run → Report → (Heal)
|
|
34
|
+
*/
|
|
35
|
+
export async function runFullPipeline(options: AutoPipelineOptions): Promise<AutoPipelineResult> {
|
|
36
|
+
const result: AutoPipelineResult = {
|
|
37
|
+
ok: false,
|
|
38
|
+
steps: {
|
|
39
|
+
extract: { ok: false },
|
|
40
|
+
generate: { ok: false },
|
|
41
|
+
run: { ok: false, runId: "", exitCode: -1 },
|
|
42
|
+
report: { ok: false },
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const oracleLevel = options.oracleLevel ?? "L1";
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
// Step 1: Extract
|
|
50
|
+
console.log("📊 [ATE Pipeline] Step 1/5: Extract - 상호작용 그래프 추출 중...");
|
|
51
|
+
try {
|
|
52
|
+
await ateExtract({
|
|
53
|
+
repoRoot: options.repoRoot,
|
|
54
|
+
tsconfigPath: options.tsconfigPath,
|
|
55
|
+
routeGlobs: options.routeGlobs,
|
|
56
|
+
buildSalt: options.buildSalt,
|
|
57
|
+
});
|
|
58
|
+
result.steps.extract.ok = true;
|
|
59
|
+
console.log("✅ [ATE Pipeline] Extract 완료");
|
|
60
|
+
} catch (err: any) {
|
|
61
|
+
result.steps.extract.error = err.message;
|
|
62
|
+
console.error(`❌ [ATE Pipeline] Extract 실패: ${err.message}`);
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Step 2: Generate
|
|
67
|
+
console.log("🎬 [ATE Pipeline] Step 2/5: Generate - 시나리오 및 테스트 생성 중...");
|
|
68
|
+
try {
|
|
69
|
+
await ateGenerate({
|
|
70
|
+
repoRoot: options.repoRoot,
|
|
71
|
+
oracleLevel,
|
|
72
|
+
});
|
|
73
|
+
result.steps.generate.ok = true;
|
|
74
|
+
console.log("✅ [ATE Pipeline] Generate 완료");
|
|
75
|
+
} catch (err: any) {
|
|
76
|
+
result.steps.generate.error = err.message;
|
|
77
|
+
console.error(`❌ [ATE Pipeline] Generate 실패: ${err.message}`);
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Step 3 (Optional): Impact Analysis
|
|
82
|
+
let impactResult: { changedFiles: string[]; selectedRoutes: string[] } | undefined;
|
|
83
|
+
if (options.useImpactAnalysis) {
|
|
84
|
+
console.log("🔍 [ATE Pipeline] Step 3/5: Impact Analysis - 변경 영향 분석 중...");
|
|
85
|
+
try {
|
|
86
|
+
const impact = await ateImpact({
|
|
87
|
+
repoRoot: options.repoRoot,
|
|
88
|
+
base: options.base,
|
|
89
|
+
head: options.head,
|
|
90
|
+
});
|
|
91
|
+
result.steps.impact = {
|
|
92
|
+
ok: true,
|
|
93
|
+
mode: impact.selectedRoutes.length > 0 ? "subset" : "full",
|
|
94
|
+
selectedRoutes: impact.selectedRoutes,
|
|
95
|
+
};
|
|
96
|
+
impactResult = {
|
|
97
|
+
changedFiles: impact.changedFiles,
|
|
98
|
+
selectedRoutes: impact.selectedRoutes,
|
|
99
|
+
};
|
|
100
|
+
console.log(
|
|
101
|
+
`✅ [ATE Pipeline] Impact Analysis 완료 - ${impact.selectedRoutes.length}개 라우트 선택됨`,
|
|
102
|
+
);
|
|
103
|
+
} catch (err: any) {
|
|
104
|
+
result.steps.impact = {
|
|
105
|
+
ok: false,
|
|
106
|
+
mode: "full",
|
|
107
|
+
selectedRoutes: [],
|
|
108
|
+
error: err.message,
|
|
109
|
+
};
|
|
110
|
+
console.warn(`⚠️ [ATE Pipeline] Impact Analysis 실패, 전체 테스트 실행: ${err.message}`);
|
|
111
|
+
// Impact analysis 실패 시에도 계속 진행 (full test)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Step 4: Run
|
|
116
|
+
console.log("🧪 [ATE Pipeline] Step 4/5: Run - Playwright 테스트 실행 중...");
|
|
117
|
+
let runId = "";
|
|
118
|
+
let exitCode = -1;
|
|
119
|
+
let startedAt = "";
|
|
120
|
+
let finishedAt = "";
|
|
121
|
+
try {
|
|
122
|
+
const runResult = await ateRun({
|
|
123
|
+
repoRoot: options.repoRoot,
|
|
124
|
+
baseURL: options.baseURL,
|
|
125
|
+
ci: options.ci,
|
|
126
|
+
});
|
|
127
|
+
runId = runResult.runId;
|
|
128
|
+
exitCode = runResult.exitCode;
|
|
129
|
+
startedAt = runResult.startedAt;
|
|
130
|
+
finishedAt = runResult.finishedAt;
|
|
131
|
+
result.steps.run = { ok: exitCode === 0, runId, exitCode };
|
|
132
|
+
console.log(
|
|
133
|
+
`${exitCode === 0 ? "✅" : "⚠️"} [ATE Pipeline] Run 완료 - exitCode: ${exitCode}`,
|
|
134
|
+
);
|
|
135
|
+
} catch (err: any) {
|
|
136
|
+
result.steps.run.error = err.message;
|
|
137
|
+
console.error(`❌ [ATE Pipeline] Run 실패: ${err.message}`);
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Step 5: Report
|
|
142
|
+
console.log("📝 [ATE Pipeline] Step 5/5: Report - 테스트 리포트 생성 중...");
|
|
143
|
+
try {
|
|
144
|
+
const reportResult = await ateReport({
|
|
145
|
+
repoRoot: options.repoRoot,
|
|
146
|
+
runId,
|
|
147
|
+
startedAt,
|
|
148
|
+
finishedAt,
|
|
149
|
+
exitCode,
|
|
150
|
+
oracleLevel,
|
|
151
|
+
impact: impactResult
|
|
152
|
+
? {
|
|
153
|
+
mode: impactResult.selectedRoutes.length > 0 ? "subset" : "full",
|
|
154
|
+
changedFiles: impactResult.changedFiles,
|
|
155
|
+
selectedRoutes: impactResult.selectedRoutes,
|
|
156
|
+
}
|
|
157
|
+
: undefined,
|
|
158
|
+
});
|
|
159
|
+
result.steps.report = { ok: true, summaryPath: reportResult.summaryPath };
|
|
160
|
+
console.log(`✅ [ATE Pipeline] Report 완료 - ${reportResult.summaryPath}`);
|
|
161
|
+
} catch (err: any) {
|
|
162
|
+
result.steps.report.error = err.message;
|
|
163
|
+
console.error(`❌ [ATE Pipeline] Report 실패: ${err.message}`);
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Step 6 (Optional): Heal
|
|
168
|
+
if (options.autoHeal && exitCode !== 0) {
|
|
169
|
+
console.log("🔧 [ATE Pipeline] Step 6/6: Heal - 자동 복구 제안 생성 중...");
|
|
170
|
+
try {
|
|
171
|
+
const healResult = await ateHeal({
|
|
172
|
+
repoRoot: options.repoRoot,
|
|
173
|
+
runId,
|
|
174
|
+
});
|
|
175
|
+
result.steps.heal = {
|
|
176
|
+
ok: true,
|
|
177
|
+
suggestionsCount: healResult.suggestions?.length ?? 0,
|
|
178
|
+
};
|
|
179
|
+
console.log(
|
|
180
|
+
`✅ [ATE Pipeline] Heal 완료 - ${healResult.suggestions?.length ?? 0}개 제안 생성됨`,
|
|
181
|
+
);
|
|
182
|
+
} catch (err: any) {
|
|
183
|
+
result.steps.heal = {
|
|
184
|
+
ok: false,
|
|
185
|
+
suggestionsCount: 0,
|
|
186
|
+
error: err.message,
|
|
187
|
+
};
|
|
188
|
+
console.warn(`⚠️ [ATE Pipeline] Heal 실패: ${err.message}`);
|
|
189
|
+
// Heal 실패는 전체 파이프라인 실패로 보지 않음
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 최종 결과
|
|
194
|
+
result.ok = result.steps.extract.ok && result.steps.generate.ok && result.steps.report.ok;
|
|
195
|
+
console.log(
|
|
196
|
+
`\n${result.ok ? "✅" : "⚠️"} [ATE Pipeline] 파이프라인 완료 - 전체 성공: ${result.ok}`,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
return result;
|
|
200
|
+
} catch (err: any) {
|
|
201
|
+
throw new ATEFileError(
|
|
202
|
+
`파이프라인 실행 중 예상치 못한 오류: ${err.message}`,
|
|
203
|
+
"PIPELINE_ERROR",
|
|
204
|
+
options.repoRoot,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { getAtePaths, ensureDir, writeJson } from "./fs";
|
|
3
|
+
import { createDefaultOracle } from "./oracle";
|
|
4
|
+
import type { SummaryJson, OracleLevel } from "./types";
|
|
5
|
+
import { generateHtmlReport, type HtmlReportOptions } from "./reporter/html";
|
|
6
|
+
|
|
7
|
+
export function composeSummary(params: {
|
|
8
|
+
repoRoot: string;
|
|
9
|
+
runId: string;
|
|
10
|
+
startedAt: string;
|
|
11
|
+
finishedAt: string;
|
|
12
|
+
exitCode: number;
|
|
13
|
+
oracleLevel: OracleLevel;
|
|
14
|
+
impact?: { changedFiles: string[]; selectedRoutes: string[]; mode: "full" | "subset" };
|
|
15
|
+
heal?: { suggestions: Array<{ kind: string; title: string; diff: string }> };
|
|
16
|
+
}): SummaryJson {
|
|
17
|
+
// Validate required params
|
|
18
|
+
if (!params.repoRoot) {
|
|
19
|
+
throw new Error("repoRoot는 필수입니다");
|
|
20
|
+
}
|
|
21
|
+
if (!params.runId) {
|
|
22
|
+
throw new Error("runId는 필수입니다");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const paths = getAtePaths(params.repoRoot);
|
|
26
|
+
|
|
27
|
+
let oracle;
|
|
28
|
+
try {
|
|
29
|
+
oracle = createDefaultOracle(params.oracleLevel);
|
|
30
|
+
} catch (err: any) {
|
|
31
|
+
throw new Error(`Oracle 생성 실패: ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
schemaVersion: 1,
|
|
36
|
+
runId: params.runId,
|
|
37
|
+
startedAt: params.startedAt,
|
|
38
|
+
finishedAt: params.finishedAt,
|
|
39
|
+
ok: params.exitCode === 0,
|
|
40
|
+
oracle,
|
|
41
|
+
playwright: {
|
|
42
|
+
exitCode: params.exitCode,
|
|
43
|
+
reportDir: join(paths.reportsDir, params.runId),
|
|
44
|
+
jsonReportPath: join(paths.reportsDir, "latest", "playwright-report.json"),
|
|
45
|
+
junitPath: join(paths.reportsDir, "latest", "junit.xml"),
|
|
46
|
+
},
|
|
47
|
+
mandu: {
|
|
48
|
+
interactionGraphPath: paths.interactionGraphPath,
|
|
49
|
+
selectorMapPath: paths.selectorMapPath,
|
|
50
|
+
scenariosPath: paths.scenariosPath,
|
|
51
|
+
},
|
|
52
|
+
heal: {
|
|
53
|
+
attempted: true,
|
|
54
|
+
suggestions: params.heal?.suggestions ?? [],
|
|
55
|
+
},
|
|
56
|
+
impact: params.impact ?? {
|
|
57
|
+
mode: "full",
|
|
58
|
+
changedFiles: [],
|
|
59
|
+
selectedRoutes: [],
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function writeSummary(repoRoot: string, runId: string, summary: SummaryJson): string {
|
|
65
|
+
if (!repoRoot) {
|
|
66
|
+
throw new Error("repoRoot는 필수입니다");
|
|
67
|
+
}
|
|
68
|
+
if (!runId) {
|
|
69
|
+
throw new Error("runId는 필수입니다");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const paths = getAtePaths(repoRoot);
|
|
73
|
+
const runDir = join(paths.reportsDir, runId);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
ensureDir(runDir);
|
|
77
|
+
} catch (err: any) {
|
|
78
|
+
throw new Error(`Report 디렉토리 생성 실패: ${err.message}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const outPath = join(runDir, "summary.json");
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
writeJson(outPath, summary);
|
|
85
|
+
} catch (err: any) {
|
|
86
|
+
throw new Error(`Summary 파일 저장 실패: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return outPath;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type ReportFormat = "json" | "html" | "both";
|
|
93
|
+
|
|
94
|
+
export interface GenerateReportOptions {
|
|
95
|
+
repoRoot: string;
|
|
96
|
+
runId: string;
|
|
97
|
+
format?: ReportFormat;
|
|
98
|
+
includeScreenshots?: boolean;
|
|
99
|
+
includeTraces?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function generateReport(options: GenerateReportOptions): Promise<{ json?: string; html?: string }> {
|
|
103
|
+
const { repoRoot, runId, format = "both", includeScreenshots = true, includeTraces = true } = options;
|
|
104
|
+
|
|
105
|
+
const result: { json?: string; html?: string } = {};
|
|
106
|
+
|
|
107
|
+
// JSON은 이미 writeSummary로 생성되었다고 가정
|
|
108
|
+
if (format === "json" || format === "both") {
|
|
109
|
+
const paths = getAtePaths(repoRoot);
|
|
110
|
+
result.json = join(paths.reportsDir, runId, "summary.json");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// HTML 생성
|
|
114
|
+
if (format === "html" || format === "both") {
|
|
115
|
+
try {
|
|
116
|
+
const htmlResult = await generateHtmlReport({
|
|
117
|
+
repoRoot,
|
|
118
|
+
runId,
|
|
119
|
+
includeScreenshots,
|
|
120
|
+
includeTraces,
|
|
121
|
+
});
|
|
122
|
+
result.html = htmlResult.path;
|
|
123
|
+
} catch (err: any) {
|
|
124
|
+
throw new Error(`HTML 리포트 생성 실패: ${err.message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return result;
|
|
129
|
+
}
|