@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/heal.ts
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { readFileSync, existsSync, writeFileSync, copyFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAtePaths, ensureDir } from "./fs";
|
|
4
|
+
import type { HealInput } from "./types";
|
|
5
|
+
import { parseTrace, generateAlternativeSelectors } from "./trace-parser";
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
export interface HealSuggestion {
|
|
9
|
+
kind: "selector-map" | "test-code" | "note";
|
|
10
|
+
title: string;
|
|
11
|
+
diff: string; // unified diff suggestion (no auto-commit)
|
|
12
|
+
metadata?: {
|
|
13
|
+
selector?: string;
|
|
14
|
+
alternatives?: string[];
|
|
15
|
+
testFile?: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type FailureCategory = "selector" | "timeout" | "assertion" | "unknown";
|
|
20
|
+
|
|
21
|
+
export interface FeedbackAnalysis {
|
|
22
|
+
category: FailureCategory;
|
|
23
|
+
suggestions: HealSuggestion[];
|
|
24
|
+
autoApplicable: boolean;
|
|
25
|
+
priority: number; // 1-10, higher = more confident
|
|
26
|
+
reasoning: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FeedbackInput {
|
|
30
|
+
repoRoot: string;
|
|
31
|
+
runId: string;
|
|
32
|
+
autoApply?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ApplyHealInput {
|
|
36
|
+
repoRoot: string;
|
|
37
|
+
runId: string;
|
|
38
|
+
healIndex: number;
|
|
39
|
+
createBackup?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ApplyHealResult {
|
|
43
|
+
success: boolean;
|
|
44
|
+
appliedFile: string;
|
|
45
|
+
backupPath?: string;
|
|
46
|
+
error?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Healing Engine
|
|
51
|
+
* - Parses Playwright trace/report to find failed locators
|
|
52
|
+
* - Generates alternative selector suggestions
|
|
53
|
+
* - Creates unified diffs for selector-map.json or test files
|
|
54
|
+
* - Does NOT auto-commit or patch files (user must review and apply)
|
|
55
|
+
*/
|
|
56
|
+
export function heal(input: HealInput): { attempted: true; suggestions: HealSuggestion[] } {
|
|
57
|
+
const paths = getAtePaths(input.repoRoot);
|
|
58
|
+
const reportDir = join(paths.reportsDir, input.runId || "latest");
|
|
59
|
+
const jsonReportPath = join(reportDir, "playwright-report.json");
|
|
60
|
+
|
|
61
|
+
// Try to read Playwright report
|
|
62
|
+
if (!existsSync(jsonReportPath)) {
|
|
63
|
+
return {
|
|
64
|
+
attempted: true,
|
|
65
|
+
suggestions: [{ kind: "note", title: "No Playwright JSON report found", diff: "" }],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const suggestions: HealSuggestion[] = [];
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
// Parse trace to extract failed locators
|
|
73
|
+
const parseResult = parseTrace(jsonReportPath);
|
|
74
|
+
|
|
75
|
+
if (parseResult.failedLocators.length === 0) {
|
|
76
|
+
suggestions.push({
|
|
77
|
+
kind: "note",
|
|
78
|
+
title: "No failed locators detected in trace",
|
|
79
|
+
diff: "",
|
|
80
|
+
});
|
|
81
|
+
return { attempted: true, suggestions };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Generate healing suggestions for each failed locator
|
|
85
|
+
for (const failed of parseResult.failedLocators) {
|
|
86
|
+
const alternatives = generateAlternativeSelectors(failed.selector, failed.actionType);
|
|
87
|
+
|
|
88
|
+
if (alternatives.length === 0) {
|
|
89
|
+
suggestions.push({
|
|
90
|
+
kind: "note",
|
|
91
|
+
title: `Failed locator: ${failed.selector} (no alternatives)`,
|
|
92
|
+
diff: "",
|
|
93
|
+
metadata: {
|
|
94
|
+
selector: failed.selector,
|
|
95
|
+
alternatives: [],
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Generate selector-map diff
|
|
102
|
+
const selectorMapDiff = generateSelectorMapDiff(failed.selector, alternatives);
|
|
103
|
+
suggestions.push({
|
|
104
|
+
kind: "selector-map",
|
|
105
|
+
title: `Update selector-map for: ${failed.selector}`,
|
|
106
|
+
diff: selectorMapDiff,
|
|
107
|
+
metadata: {
|
|
108
|
+
selector: failed.selector,
|
|
109
|
+
alternatives,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// If we have test file context, generate test code diff
|
|
114
|
+
if (parseResult.metadata.testFile && failed.context) {
|
|
115
|
+
const testCodeDiff = generateTestCodeDiff(
|
|
116
|
+
parseResult.metadata.testFile,
|
|
117
|
+
failed.selector,
|
|
118
|
+
alternatives[0], // Use first alternative
|
|
119
|
+
failed.context,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
if (testCodeDiff) {
|
|
123
|
+
suggestions.push({
|
|
124
|
+
kind: "test-code",
|
|
125
|
+
title: `Update test code: ${failed.selector} → ${alternatives[0]}`,
|
|
126
|
+
diff: testCodeDiff,
|
|
127
|
+
metadata: {
|
|
128
|
+
selector: failed.selector,
|
|
129
|
+
alternatives,
|
|
130
|
+
testFile: parseResult.metadata.testFile,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
suggestions.push({
|
|
138
|
+
kind: "note",
|
|
139
|
+
title: `Healing failed: ${String(err)}`,
|
|
140
|
+
diff: "",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (suggestions.length === 0) {
|
|
145
|
+
suggestions.push({
|
|
146
|
+
kind: "note",
|
|
147
|
+
title: "No healing suggestions available",
|
|
148
|
+
diff: "",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { attempted: true, suggestions };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Generate unified diff for selector-map.json
|
|
157
|
+
*/
|
|
158
|
+
function generateSelectorMapDiff(originalSelector: string, alternatives: string[]): string {
|
|
159
|
+
const escapedSelector = JSON.stringify(originalSelector);
|
|
160
|
+
const alternativesJson = JSON.stringify(alternatives, null, 2).split("\n").join("\n+ ");
|
|
161
|
+
|
|
162
|
+
const lines = [
|
|
163
|
+
"--- a/.mandu/selector-map.json",
|
|
164
|
+
"+++ b/.mandu/selector-map.json",
|
|
165
|
+
"@@ -1,3 +1,8 @@",
|
|
166
|
+
" {",
|
|
167
|
+
"+ " + escapedSelector + ": {",
|
|
168
|
+
"+ \"fallbacks\": " + alternativesJson,
|
|
169
|
+
"+ },",
|
|
170
|
+
" \"version\": \"1.0.0\"",
|
|
171
|
+
" }",
|
|
172
|
+
"",
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
return lines.join("\n");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Generate unified diff for test code file
|
|
180
|
+
*/
|
|
181
|
+
function generateTestCodeDiff(
|
|
182
|
+
testFile: string,
|
|
183
|
+
originalSelector: string,
|
|
184
|
+
newSelector: string,
|
|
185
|
+
context: string,
|
|
186
|
+
): string | null {
|
|
187
|
+
// Escape special regex characters
|
|
188
|
+
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
189
|
+
|
|
190
|
+
// Try to infer the line content from context
|
|
191
|
+
const contextLine = context.trim();
|
|
192
|
+
|
|
193
|
+
if (!contextLine) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const lines = [
|
|
198
|
+
`--- a/${testFile}`,
|
|
199
|
+
`+++ b/${testFile}`,
|
|
200
|
+
"@@ -1,3 +1,3 @@",
|
|
201
|
+
` // ${contextLine}`,
|
|
202
|
+
`-await page.locator('${originalSelector}')`,
|
|
203
|
+
`+await page.locator('${newSelector}')`,
|
|
204
|
+
"",
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
return lines.join("\n");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Analyze test failure feedback and categorize for heal suggestions
|
|
212
|
+
*/
|
|
213
|
+
export function analyzeFeedback(input: FeedbackInput): FeedbackAnalysis {
|
|
214
|
+
const healResult = heal({
|
|
215
|
+
repoRoot: input.repoRoot,
|
|
216
|
+
runId: input.runId,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (!healResult.attempted || healResult.suggestions.length === 0) {
|
|
220
|
+
return {
|
|
221
|
+
category: "unknown",
|
|
222
|
+
suggestions: [],
|
|
223
|
+
autoApplicable: false,
|
|
224
|
+
priority: 0,
|
|
225
|
+
reasoning: "No healing suggestions available",
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Categorize failure based on suggestions
|
|
230
|
+
const hasSelector = healResult.suggestions.some((s) => s.kind === "selector-map");
|
|
231
|
+
const hasTestCode = healResult.suggestions.some((s) => s.kind === "test-code");
|
|
232
|
+
const onlyNotes = healResult.suggestions.every((s) => s.kind === "note");
|
|
233
|
+
|
|
234
|
+
let category: FailureCategory = "unknown";
|
|
235
|
+
let autoApplicable = false;
|
|
236
|
+
let priority = 5;
|
|
237
|
+
let reasoning = "";
|
|
238
|
+
|
|
239
|
+
if (hasSelector) {
|
|
240
|
+
category = "selector";
|
|
241
|
+
// Auto-apply selector-map changes only (safe)
|
|
242
|
+
autoApplicable = true;
|
|
243
|
+
priority = 8;
|
|
244
|
+
reasoning = "Failed locator detected. Selector-map update is safe to auto-apply.";
|
|
245
|
+
} else if (hasTestCode) {
|
|
246
|
+
category = "assertion";
|
|
247
|
+
// Test code changes require manual review
|
|
248
|
+
autoApplicable = false;
|
|
249
|
+
priority = 6;
|
|
250
|
+
reasoning = "Test code modification suggested. Manual review required.";
|
|
251
|
+
} else if (onlyNotes) {
|
|
252
|
+
const noteText = healResult.suggestions[0]?.title.toLowerCase() || "";
|
|
253
|
+
if (noteText.includes("timeout")) {
|
|
254
|
+
category = "timeout";
|
|
255
|
+
priority = 4;
|
|
256
|
+
reasoning = "Timeout detected. May require wait time adjustment.";
|
|
257
|
+
} else {
|
|
258
|
+
category = "unknown";
|
|
259
|
+
priority = 3;
|
|
260
|
+
reasoning = "Unable to categorize failure automatically.";
|
|
261
|
+
}
|
|
262
|
+
autoApplicable = false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
category,
|
|
267
|
+
suggestions: healResult.suggestions,
|
|
268
|
+
autoApplicable: autoApplicable && (input.autoApply ?? false),
|
|
269
|
+
priority,
|
|
270
|
+
reasoning,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Check if git working directory has uncommitted changes
|
|
276
|
+
*/
|
|
277
|
+
function hasUncommittedChanges(repoRoot: string): boolean {
|
|
278
|
+
try {
|
|
279
|
+
const result = execSync("git status --porcelain", {
|
|
280
|
+
cwd: repoRoot,
|
|
281
|
+
encoding: "utf8",
|
|
282
|
+
});
|
|
283
|
+
return result.trim().length > 0;
|
|
284
|
+
} catch {
|
|
285
|
+
// Not a git repo or git not available
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Apply a heal suggestion diff to the actual file
|
|
292
|
+
*/
|
|
293
|
+
export function applyHeal(input: ApplyHealInput): ApplyHealResult {
|
|
294
|
+
const paths = getAtePaths(input.repoRoot);
|
|
295
|
+
const reportDir = join(paths.reportsDir, input.runId);
|
|
296
|
+
|
|
297
|
+
// Get heal suggestions
|
|
298
|
+
const healResult = heal({
|
|
299
|
+
repoRoot: input.repoRoot,
|
|
300
|
+
runId: input.runId,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
if (!healResult.attempted || healResult.suggestions.length === 0) {
|
|
304
|
+
return {
|
|
305
|
+
success: false,
|
|
306
|
+
appliedFile: "",
|
|
307
|
+
error: "No heal suggestions available",
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (input.healIndex < 0 || input.healIndex >= healResult.suggestions.length) {
|
|
312
|
+
return {
|
|
313
|
+
success: false,
|
|
314
|
+
appliedFile: "",
|
|
315
|
+
error: `Invalid heal index: ${input.healIndex} (available: 0-${healResult.suggestions.length - 1})`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const suggestion = healResult.suggestions[input.healIndex];
|
|
320
|
+
|
|
321
|
+
// Only apply selector-map or test-code suggestions
|
|
322
|
+
if (suggestion.kind === "note") {
|
|
323
|
+
return {
|
|
324
|
+
success: false,
|
|
325
|
+
appliedFile: "",
|
|
326
|
+
error: "Cannot apply note-type suggestions",
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Safety check: require backup if working directory is dirty
|
|
331
|
+
const createBackup = input.createBackup ?? true;
|
|
332
|
+
if (!createBackup && hasUncommittedChanges(input.repoRoot)) {
|
|
333
|
+
return {
|
|
334
|
+
success: false,
|
|
335
|
+
appliedFile: "",
|
|
336
|
+
error: "Backup required: git working directory has uncommitted changes",
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let targetFile: string;
|
|
341
|
+
let backupPath: string | undefined;
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
if (suggestion.kind === "selector-map") {
|
|
345
|
+
targetFile = paths.selectorMapPath;
|
|
346
|
+
|
|
347
|
+
// Create backup
|
|
348
|
+
if (createBackup) {
|
|
349
|
+
ensureDir(reportDir);
|
|
350
|
+
backupPath = join(reportDir, `selector-map.backup-${Date.now()}.json`);
|
|
351
|
+
if (existsSync(targetFile)) {
|
|
352
|
+
copyFileSync(targetFile, backupPath);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Apply selector-map diff
|
|
357
|
+
const currentContent = existsSync(targetFile)
|
|
358
|
+
? JSON.parse(readFileSync(targetFile, "utf8"))
|
|
359
|
+
: { version: "1.0.0" };
|
|
360
|
+
|
|
361
|
+
// Extract selector and alternatives from metadata
|
|
362
|
+
const { selector, alternatives } = suggestion.metadata || {};
|
|
363
|
+
if (!selector || !alternatives || alternatives.length === 0) {
|
|
364
|
+
throw new Error("Invalid suggestion metadata");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Update selector-map
|
|
368
|
+
currentContent[selector] = {
|
|
369
|
+
fallbacks: alternatives,
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
writeFileSync(targetFile, JSON.stringify(currentContent, null, 2), "utf8");
|
|
373
|
+
} else if (suggestion.kind === "test-code") {
|
|
374
|
+
// Test code modification - extract file path from metadata
|
|
375
|
+
const testFile = suggestion.metadata?.testFile;
|
|
376
|
+
if (!testFile) {
|
|
377
|
+
throw new Error("No test file specified in suggestion metadata");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
targetFile = join(input.repoRoot, testFile);
|
|
381
|
+
|
|
382
|
+
if (!existsSync(targetFile)) {
|
|
383
|
+
throw new Error(`Test file not found: ${targetFile}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Create backup
|
|
387
|
+
if (createBackup) {
|
|
388
|
+
ensureDir(reportDir);
|
|
389
|
+
backupPath = join(reportDir, `${testFile.replace(/\//g, "_")}.backup-${Date.now()}`);
|
|
390
|
+
copyFileSync(targetFile, backupPath);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Apply test code diff (simple string replacement)
|
|
394
|
+
const { selector, alternatives } = suggestion.metadata || {};
|
|
395
|
+
if (!selector || !alternatives || alternatives.length === 0) {
|
|
396
|
+
throw new Error("Invalid suggestion metadata");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const content = readFileSync(targetFile, "utf8");
|
|
400
|
+
const newContent = content.replace(
|
|
401
|
+
new RegExp(`locator\\(['"\`]${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"\`]\\)`, "g"),
|
|
402
|
+
`locator('${alternatives[0]}')`,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
writeFileSync(targetFile, newContent, "utf8");
|
|
406
|
+
} else {
|
|
407
|
+
return {
|
|
408
|
+
success: false,
|
|
409
|
+
appliedFile: "",
|
|
410
|
+
error: `Unsupported suggestion kind: ${suggestion.kind}`,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
success: true,
|
|
416
|
+
appliedFile: targetFile,
|
|
417
|
+
backupPath,
|
|
418
|
+
};
|
|
419
|
+
} catch (err) {
|
|
420
|
+
return {
|
|
421
|
+
success: false,
|
|
422
|
+
appliedFile: targetFile!,
|
|
423
|
+
backupPath,
|
|
424
|
+
error: String(err),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
}
|
package/src/impact.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { getAtePaths, readJson } from "./fs";
|
|
4
|
+
import type { ImpactInput, InteractionGraph } from "./types";
|
|
5
|
+
import { buildDependencyGraph, findDependents } from "./dep-graph";
|
|
6
|
+
|
|
7
|
+
function verifyGitRev(repoRoot: string, rev: string): void {
|
|
8
|
+
// Prevent command injection: disallow whitespace and common shell metacharacters.
|
|
9
|
+
if (!/^[0-9A-Za-z._/~-]+$/.test(rev)) {
|
|
10
|
+
throw new Error(`Invalid git revision: ${rev}`);
|
|
11
|
+
}
|
|
12
|
+
// Ensure it resolves (commit-ish)
|
|
13
|
+
execFileSync("git", ["rev-parse", "--verify", `${rev}^{commit}`], {
|
|
14
|
+
cwd: repoRoot,
|
|
15
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toPosixPath(p: string): string {
|
|
20
|
+
return p.replace(/\\/g, "/");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizePath(path: string, rootDir: string): string {
|
|
24
|
+
const abs = resolve(rootDir, path);
|
|
25
|
+
return abs.replace(/\\/g, "/");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function computeImpact(input: ImpactInput): Promise<{ changedFiles: string[]; selectedRoutes: string[]; warnings: string[] }> {
|
|
29
|
+
const repoRoot = input.repoRoot;
|
|
30
|
+
const base = input.base ?? "HEAD~1";
|
|
31
|
+
const head = input.head ?? "HEAD";
|
|
32
|
+
const warnings: string[] = [];
|
|
33
|
+
|
|
34
|
+
// Validate input
|
|
35
|
+
if (!repoRoot) {
|
|
36
|
+
throw new Error("repoRoot는 필수입니다");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Verify git revisions
|
|
40
|
+
try {
|
|
41
|
+
verifyGitRev(repoRoot, base);
|
|
42
|
+
} catch (err: any) {
|
|
43
|
+
throw new Error(`잘못된 base revision: ${base} (${err.message})`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
verifyGitRev(repoRoot, head);
|
|
48
|
+
} catch (err: any) {
|
|
49
|
+
throw new Error(`잘못된 head revision: ${head} (${err.message})`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let out: string;
|
|
53
|
+
try {
|
|
54
|
+
out = execFileSync("git", ["diff", "--name-only", `${base}..${head}`], {
|
|
55
|
+
cwd: repoRoot,
|
|
56
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
57
|
+
}).toString("utf8");
|
|
58
|
+
} catch (err: any) {
|
|
59
|
+
throw new Error(`Git diff 실행 실패: ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const changedFiles = out.split("\n").map((s) => toPosixPath(s.trim())).filter(Boolean);
|
|
63
|
+
|
|
64
|
+
if (changedFiles.length === 0) {
|
|
65
|
+
warnings.push(`경고: 변경된 파일이 없습니다 (${base}..${head})`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Load interaction graph
|
|
69
|
+
const paths = getAtePaths(repoRoot);
|
|
70
|
+
|
|
71
|
+
let graph: InteractionGraph;
|
|
72
|
+
try {
|
|
73
|
+
graph = readJson<InteractionGraph>(paths.interactionGraphPath);
|
|
74
|
+
} catch (err: any) {
|
|
75
|
+
throw new Error(`Interaction graph 읽기 실패: ${err.message}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!graph.nodes || graph.nodes.length === 0) {
|
|
79
|
+
warnings.push("경고: Interaction graph가 비어있습니다");
|
|
80
|
+
return { changedFiles, selectedRoutes: [], warnings };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const routes = graph.nodes.filter((n) => n.kind === "route") as Array<{ kind: "route"; id: string; file: string }>;
|
|
84
|
+
|
|
85
|
+
if (routes.length === 0) {
|
|
86
|
+
warnings.push("경고: Route가 없습니다");
|
|
87
|
+
return { changedFiles, selectedRoutes: [], warnings };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Build dependency graph for deep impact analysis
|
|
91
|
+
let depGraph;
|
|
92
|
+
try {
|
|
93
|
+
depGraph = await buildDependencyGraph({
|
|
94
|
+
rootDir: repoRoot,
|
|
95
|
+
include: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
|
|
96
|
+
exclude: ["**/node_modules/**", "**/*.test.ts", "**/*.spec.ts"],
|
|
97
|
+
});
|
|
98
|
+
} catch (err: any) {
|
|
99
|
+
warnings.push(`Dependency graph 빌드 실패: ${err.message}`);
|
|
100
|
+
// Fallback: only direct file matching
|
|
101
|
+
const selected = new Set<string>();
|
|
102
|
+
for (const changedFile of changedFiles) {
|
|
103
|
+
const normalizedChangedFile = normalizePath(changedFile, repoRoot);
|
|
104
|
+
for (const r of routes) {
|
|
105
|
+
const routeFile = normalizePath(r.file, repoRoot);
|
|
106
|
+
if (normalizedChangedFile === routeFile) {
|
|
107
|
+
selected.add(r.id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { changedFiles, selectedRoutes: Array.from(selected), warnings };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const selected = new Set<string>();
|
|
115
|
+
|
|
116
|
+
for (const changedFile of changedFiles) {
|
|
117
|
+
try {
|
|
118
|
+
const normalizedChangedFile = normalizePath(changedFile, repoRoot);
|
|
119
|
+
|
|
120
|
+
// Direct match: if the route file itself changed
|
|
121
|
+
for (const r of routes) {
|
|
122
|
+
const routeFile = normalizePath(r.file, repoRoot);
|
|
123
|
+
if (normalizedChangedFile === routeFile) {
|
|
124
|
+
selected.add(r.id);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Transitive impact: find all files that depend on the changed file
|
|
129
|
+
const affectedFiles = findDependents(depGraph, normalizedChangedFile);
|
|
130
|
+
|
|
131
|
+
for (const affectedFile of affectedFiles) {
|
|
132
|
+
for (const r of routes) {
|
|
133
|
+
const routeFile = normalizePath(r.file, repoRoot);
|
|
134
|
+
if (affectedFile === routeFile) {
|
|
135
|
+
selected.add(r.id);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch (err: any) {
|
|
140
|
+
warnings.push(`파일 영향 분석 실패 (${changedFile}): ${err.message}`);
|
|
141
|
+
// Continue with next file
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { changedFiles, selectedRoutes: Array.from(selected), warnings };
|
|
146
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export * from "./types";
|
|
2
|
+
export * as ATEFS from "./fs";
|
|
3
|
+
export { ATEFileError, ensureDir, readJson, writeJson, fileExists, getAtePaths } from "./fs";
|
|
4
|
+
|
|
5
|
+
export { extract } from "./extractor";
|
|
6
|
+
export { generateAndWriteScenarios } from "./scenario";
|
|
7
|
+
export { generatePlaywrightSpecs } from "./codegen";
|
|
8
|
+
export { runPlaywright } from "./runner";
|
|
9
|
+
export { composeSummary, writeSummary, generateReport } from "./report";
|
|
10
|
+
export type { ReportFormat, GenerateReportOptions } from "./report";
|
|
11
|
+
export { generateHtmlReport } from "./reporter/html";
|
|
12
|
+
export type { HtmlReportOptions, HtmlReportResult } from "./reporter/html";
|
|
13
|
+
export { heal, analyzeFeedback, applyHeal } from "./heal";
|
|
14
|
+
export type {
|
|
15
|
+
HealSuggestion,
|
|
16
|
+
FeedbackAnalysis,
|
|
17
|
+
FeedbackInput,
|
|
18
|
+
ApplyHealInput,
|
|
19
|
+
ApplyHealResult,
|
|
20
|
+
FailureCategory,
|
|
21
|
+
} from "./heal";
|
|
22
|
+
export { computeImpact } from "./impact";
|
|
23
|
+
export * from "./selector-map";
|
|
24
|
+
export { parseTrace, generateAlternativeSelectors } from "./trace-parser";
|
|
25
|
+
export type { TraceAction, FailedLocator, TraceParseResult } from "./trace-parser";
|
|
26
|
+
|
|
27
|
+
// Oracle and domain detection
|
|
28
|
+
export { detectDomain, detectDomainFromRoute, detectDomainFromSource } from "./domain-detector";
|
|
29
|
+
export type { AppDomain, DomainDetectionResult } from "./domain-detector";
|
|
30
|
+
export { generateL1Assertions, upgradeL0ToL1, getAssertionCount, createDefaultOracle } from "./oracle";
|
|
31
|
+
export type { OracleResult } from "./oracle";
|
|
32
|
+
|
|
33
|
+
import type { ExtractInput, GenerateInput, RunInput, ImpactInput, HealInput, OracleLevel } from "./types";
|
|
34
|
+
import { getAtePaths } from "./fs";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* High-level ATE pipeline helpers (JSON in/out)
|
|
38
|
+
*/
|
|
39
|
+
export async function ateExtract(input: ExtractInput) {
|
|
40
|
+
const { extract } = await import("./extractor");
|
|
41
|
+
return extract(input);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function ateGenerate(input: GenerateInput) {
|
|
45
|
+
const paths = getAtePaths(input.repoRoot);
|
|
46
|
+
const oracleLevel = input.oracleLevel ?? ("L1" as OracleLevel);
|
|
47
|
+
// generate scenarios then specs - lazy load codegen and scenario
|
|
48
|
+
const { generateAndWriteScenarios } = await import("./scenario");
|
|
49
|
+
const { generatePlaywrightSpecs } = await import("./codegen");
|
|
50
|
+
|
|
51
|
+
generateAndWriteScenarios(input.repoRoot, oracleLevel);
|
|
52
|
+
const res = generatePlaywrightSpecs(input.repoRoot, { onlyRoutes: input.onlyRoutes });
|
|
53
|
+
return {
|
|
54
|
+
ok: true,
|
|
55
|
+
scenariosPath: paths.scenariosPath,
|
|
56
|
+
generatedSpecs: res.files,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function ateRun(input: RunInput) {
|
|
61
|
+
const startedAt = new Date().toISOString();
|
|
62
|
+
const { runPlaywright } = await import("./runner");
|
|
63
|
+
const run = await runPlaywright(input);
|
|
64
|
+
const finishedAt = new Date().toISOString();
|
|
65
|
+
return { ok: run.exitCode === 0, ...run, startedAt, finishedAt };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function ateReport(params: {
|
|
69
|
+
repoRoot: string;
|
|
70
|
+
runId: string;
|
|
71
|
+
startedAt: string;
|
|
72
|
+
finishedAt: string;
|
|
73
|
+
exitCode: number;
|
|
74
|
+
oracleLevel: OracleLevel;
|
|
75
|
+
impact?: { changedFiles: string[]; selectedRoutes: string[]; mode: "full" | "subset" };
|
|
76
|
+
format?: "json" | "html" | "both";
|
|
77
|
+
}) {
|
|
78
|
+
const { composeSummary, writeSummary, generateReport } = await import("./report");
|
|
79
|
+
const summary = composeSummary({
|
|
80
|
+
repoRoot: params.repoRoot,
|
|
81
|
+
runId: params.runId,
|
|
82
|
+
startedAt: params.startedAt,
|
|
83
|
+
finishedAt: params.finishedAt,
|
|
84
|
+
exitCode: params.exitCode,
|
|
85
|
+
oracleLevel: params.oracleLevel,
|
|
86
|
+
impact: params.impact,
|
|
87
|
+
});
|
|
88
|
+
const summaryPath = writeSummary(params.repoRoot, params.runId, summary);
|
|
89
|
+
|
|
90
|
+
const format = params.format ?? "both";
|
|
91
|
+
const reportPaths = await generateReport({
|
|
92
|
+
repoRoot: params.repoRoot,
|
|
93
|
+
runId: params.runId,
|
|
94
|
+
format,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return { ok: true, summaryPath, summary, reportPaths };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function ateImpact(input: ImpactInput) {
|
|
101
|
+
const { computeImpact } = await import("./impact");
|
|
102
|
+
const result = await computeImpact(input);
|
|
103
|
+
return { ok: true, ...result };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function ateHeal(input: HealInput) {
|
|
107
|
+
const { heal } = await import("./heal");
|
|
108
|
+
return { ok: true, ...heal(input) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export { runFullPipeline } from "./pipeline";
|
|
112
|
+
export type { AutoPipelineOptions, AutoPipelineResult } from "./pipeline";
|
package/src/ir.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { InteractionGraph, InteractionNode, InteractionEdge } from "./types";
|
|
2
|
+
|
|
3
|
+
export function createEmptyGraph(buildSalt: string): InteractionGraph {
|
|
4
|
+
return {
|
|
5
|
+
schemaVersion: 1,
|
|
6
|
+
generatedAt: new Date().toISOString(),
|
|
7
|
+
buildSalt,
|
|
8
|
+
nodes: [],
|
|
9
|
+
edges: [],
|
|
10
|
+
stats: { routes: 0, navigations: 0, modals: 0, actions: 0 },
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function addNode(graph: InteractionGraph, node: InteractionNode): void {
|
|
15
|
+
graph.nodes.push(node);
|
|
16
|
+
if (node.kind === "route") graph.stats.routes++;
|
|
17
|
+
if (node.kind === "modal") graph.stats.modals++;
|
|
18
|
+
if (node.kind === "action") graph.stats.actions++;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function addEdge(graph: InteractionGraph, edge: InteractionEdge): void {
|
|
22
|
+
graph.edges.push(edge);
|
|
23
|
+
if (edge.kind === "navigate") graph.stats.navigations++;
|
|
24
|
+
}
|