@continuum8032/playwright-ai-tools 0.2.0-oidc-bootstrap.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/LICENSE +21 -0
- package/README.md +14 -0
- package/dist/chunk-3VDDPPGG.js +927 -0
- package/dist/chunk-GSXFV7YQ.js +544 -0
- package/dist/chunk-QL3XW2UN.js +205 -0
- package/dist/cli.cjs +1750 -0
- package/dist/cli.d.cts +4 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +308 -0
- package/dist/index.cjs +2030 -0
- package/dist/index.d.cts +218 -0
- package/dist/index.d.ts +218 -0
- package/dist/index.js +354 -0
- package/dist/reporter.cjs +1265 -0
- package/dist/reporter.d.cts +22 -0
- package/dist/reporter.d.ts +22 -0
- package/dist/reporter.js +133 -0
- package/dist/types-B55GjfW7.d.cts +355 -0
- package/dist/types-B55GjfW7.d.ts +355 -0
- package/package.json +58 -0
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
// src/signature.ts
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
function normalizeErrorForSignature(error) {
|
|
4
|
+
return error.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "UUID").replace(/\d+/g, "N").replace(/(['"])(.*?)\1/g, (_match, quote, value) => {
|
|
5
|
+
const normalizedValue = value.replace(/\d+/g, "N");
|
|
6
|
+
return `${quote}${normalizedValue}${quote}`;
|
|
7
|
+
}).replace(/\/[\w./-]+/g, "PATH").slice(0, 500);
|
|
8
|
+
}
|
|
9
|
+
function failureSignature(failure) {
|
|
10
|
+
return createHash("sha256").update(normalizeErrorForSignature(failure.error)).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
function readablePattern(error) {
|
|
13
|
+
const firstLine2 = error.split("\n")[0] || "Unknown failure";
|
|
14
|
+
return firstLine2.length > 120 ? `${firstLine2.slice(0, 117)}...` : firstLine2;
|
|
15
|
+
}
|
|
16
|
+
function extractSelectors(error) {
|
|
17
|
+
const selectors = /* @__PURE__ */ new Set();
|
|
18
|
+
const patterns = [
|
|
19
|
+
/locator\((['"`])(.+?)\1\)/g,
|
|
20
|
+
/selector\s+(['"`])(.+?)\1/g,
|
|
21
|
+
/(?:getByTestId|data-testid)[^\n'"`]*(['"`])(.+?)\1/g
|
|
22
|
+
];
|
|
23
|
+
for (const pattern of patterns) {
|
|
24
|
+
for (const match of error.matchAll(pattern)) {
|
|
25
|
+
const value = match[2]?.trim();
|
|
26
|
+
if (value) {
|
|
27
|
+
selectors.add(value);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return [...selectors];
|
|
32
|
+
}
|
|
33
|
+
function extractUrls(error) {
|
|
34
|
+
const urls = /* @__PURE__ */ new Set();
|
|
35
|
+
for (const match of error.matchAll(/https?:\/\/[^\s)'"]+|\/api\/[^\s)'"]+/g)) {
|
|
36
|
+
urls.add(match[0]);
|
|
37
|
+
}
|
|
38
|
+
return [...urls];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/taxonomy.ts
|
|
42
|
+
function firstLine(text) {
|
|
43
|
+
return text.split("\n")[0] || text;
|
|
44
|
+
}
|
|
45
|
+
function evidence(failure) {
|
|
46
|
+
const items = [firstLine(failure.error)];
|
|
47
|
+
for (const item of failure.consoleErrors || []) {
|
|
48
|
+
items.push(`console:${item.level}:${item.text}`);
|
|
49
|
+
}
|
|
50
|
+
for (const item of failure.networkFailures || []) {
|
|
51
|
+
items.push(`network:${item.method || "GET"}:${item.status || item.errorText}:${item.url}`);
|
|
52
|
+
}
|
|
53
|
+
for (const item of failure.lastActions || []) {
|
|
54
|
+
items.push(`action:${item.name}${item.selector ? `:${item.selector}` : ""}`);
|
|
55
|
+
}
|
|
56
|
+
return items.filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
function evidenceRefs(failure) {
|
|
59
|
+
const refs = ["error"];
|
|
60
|
+
if (failure.consoleErrors?.length) refs.push("consoleErrors[0]");
|
|
61
|
+
if (failure.networkFailures?.length) refs.push("networkFailures[0]");
|
|
62
|
+
if (failure.lastActions?.length) refs.push("lastActions[-1]");
|
|
63
|
+
if (failure.pageSnapshot) refs.push("pageSnapshot");
|
|
64
|
+
if (failure.artifactRefs?.length || failure.artifacts?.length) refs.push("artifactRefs");
|
|
65
|
+
return refs;
|
|
66
|
+
}
|
|
67
|
+
function matchFailure(failure) {
|
|
68
|
+
const text = [
|
|
69
|
+
failure.error,
|
|
70
|
+
...(failure.consoleErrors || []).map((item) => item.text),
|
|
71
|
+
...(failure.networkFailures || []).map((item) => `${item.status || ""} ${item.errorText || ""} ${item.url}`)
|
|
72
|
+
].join("\n").toLowerCase();
|
|
73
|
+
if (/strict mode violation|resolved to \d+ elements|locator\(.+\) resolved/.test(text)) {
|
|
74
|
+
return {
|
|
75
|
+
category: "selector",
|
|
76
|
+
risk: "medium",
|
|
77
|
+
side: "test_bug",
|
|
78
|
+
rootCause: "A locator is ambiguous or no longer points to the intended element.",
|
|
79
|
+
suggestions: ["Make the locator specific to the intended role, label, text, or test id."],
|
|
80
|
+
confidence: 94,
|
|
81
|
+
humanReview: false,
|
|
82
|
+
reason: "Playwright reported strict locator matching evidence."
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (/not receiving pointer events|intercepts pointer events|element is covered|element is detached|not enabled|not editable/.test(text)) {
|
|
86
|
+
return {
|
|
87
|
+
category: "interaction",
|
|
88
|
+
risk: "medium",
|
|
89
|
+
side: "test_bug",
|
|
90
|
+
rootCause: "The page changed the interaction state before Playwright could complete the action.",
|
|
91
|
+
suggestions: ["Wait for the actionable UI state and inspect overlays, disabled states, or animations."],
|
|
92
|
+
confidence: 88,
|
|
93
|
+
humanReview: true,
|
|
94
|
+
reason: "The failure describes an element actionability or interaction problem."
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (/screenshot|snapshot|pixel|visual|image comparison|tohavescreenshot/.test(text)) {
|
|
98
|
+
return {
|
|
99
|
+
category: "visual",
|
|
100
|
+
risk: "medium",
|
|
101
|
+
side: "unknown",
|
|
102
|
+
rootCause: "A visual assertion differs from the stored baseline or expected snapshot.",
|
|
103
|
+
suggestions: ["Inspect the screenshot diff and update the baseline only if the product change is intended."],
|
|
104
|
+
confidence: 86,
|
|
105
|
+
humanReview: true,
|
|
106
|
+
reason: "The error contains visual comparison evidence."
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (/no test data|fixture|seed|factory|test data|not found for .*user|missing data/.test(text)) {
|
|
110
|
+
return {
|
|
111
|
+
category: "test_data",
|
|
112
|
+
risk: "medium",
|
|
113
|
+
side: "test_bug",
|
|
114
|
+
rootCause: "The test appears to depend on missing or inconsistent data setup.",
|
|
115
|
+
suggestions: ["Make the data fixture explicit and validate setup before the user journey starts."],
|
|
116
|
+
confidence: 84,
|
|
117
|
+
humanReview: false,
|
|
118
|
+
reason: "The failure references missing seeded data or fixture setup."
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (/net::err_|econnrefused|enotfound|timed out connecting|500|502|503|504|internal server error|service unavailable|fetch failed/.test(text)) {
|
|
122
|
+
return {
|
|
123
|
+
category: "environment",
|
|
124
|
+
risk: "high",
|
|
125
|
+
side: "env_issue",
|
|
126
|
+
rootCause: "A backend, network, or environment dependency failed during the test.",
|
|
127
|
+
suggestions: ["Check dependency health, request logs, and CI environment configuration for the failing endpoint."],
|
|
128
|
+
confidence: 86,
|
|
129
|
+
humanReview: true,
|
|
130
|
+
reason: "Network or server failure evidence was found."
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (/typeerror|referenceerror|cannot read properties|is not defined|uncaught|unhandled/.test(text)) {
|
|
134
|
+
return {
|
|
135
|
+
category: "runtime",
|
|
136
|
+
risk: "high",
|
|
137
|
+
side: "app_bug",
|
|
138
|
+
rootCause: "The application or test runtime threw an exception during the scenario.",
|
|
139
|
+
suggestions: ["Inspect the console stack, failing page state, and recent runtime changes."],
|
|
140
|
+
confidence: 88,
|
|
141
|
+
humanReview: true,
|
|
142
|
+
reason: "Runtime exception evidence was found in the error or console output."
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (/timeout|waiting for locator|waiting for selector|tobevisible|to be visible|waiting for .*state/.test(text)) {
|
|
146
|
+
return {
|
|
147
|
+
category: "timing",
|
|
148
|
+
risk: "medium",
|
|
149
|
+
side: "test_bug",
|
|
150
|
+
rootCause: "A Playwright wait timed out before the expected element or state appeared.",
|
|
151
|
+
suggestions: [
|
|
152
|
+
"Verify the expected UI state is still correct.",
|
|
153
|
+
"Wait for the user-visible state with an assertion before acting.",
|
|
154
|
+
"Check whether navigation or data setup leaves the element hidden."
|
|
155
|
+
],
|
|
156
|
+
confidence: 90,
|
|
157
|
+
humanReview: false,
|
|
158
|
+
reason: "Playwright reported a timeout while waiting for an expected state."
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
category: "unknown",
|
|
163
|
+
risk: "high",
|
|
164
|
+
side: "unknown",
|
|
165
|
+
rootCause: "No deterministic taxonomy rule matched this failure.",
|
|
166
|
+
suggestions: ["Review trace, screenshot, console output, network output, and recent changes."],
|
|
167
|
+
confidence: 30,
|
|
168
|
+
humanReview: true,
|
|
169
|
+
reason: "No deterministic classifier matched the available evidence."
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function analysisWithDefaults(analysis, failure, source = analysis.source || "unknown") {
|
|
173
|
+
const classified = matchFailure(failure);
|
|
174
|
+
const urls = extractUrls(failure.error);
|
|
175
|
+
return {
|
|
176
|
+
side: analysis.side || classified.side,
|
|
177
|
+
rootCause: analysis.rootCause || classified.rootCause,
|
|
178
|
+
evidence: analysis.evidence?.length ? analysis.evidence : evidence(failure),
|
|
179
|
+
reproductionSteps: analysis.reproductionSteps?.length ? analysis.reproductionSteps : failure.steps?.length ? failure.steps : ["Run the failing Playwright test."],
|
|
180
|
+
suspects: analysis.suspects || {
|
|
181
|
+
selectors: extractSelectors(failure.error),
|
|
182
|
+
pages: urls.filter((url) => !url.startsWith("/api/")),
|
|
183
|
+
endpoints: [
|
|
184
|
+
...urls.filter((url) => url.startsWith("/api/")),
|
|
185
|
+
...(failure.networkFailures || []).map((item) => item.url)
|
|
186
|
+
]
|
|
187
|
+
},
|
|
188
|
+
fixSuggestions: analysis.fixSuggestions?.length ? analysis.fixSuggestions : classified.suggestions,
|
|
189
|
+
confidence: Number(analysis.confidence ?? classified.confidence),
|
|
190
|
+
category: analysis.category || classified.category,
|
|
191
|
+
risk: analysis.risk || classified.risk,
|
|
192
|
+
evidenceRefs: analysis.evidenceRefs?.length ? analysis.evidenceRefs : evidenceRefs(failure),
|
|
193
|
+
confidenceReasons: analysis.confidenceReasons?.length ? analysis.confidenceReasons : [classified.reason],
|
|
194
|
+
needsHumanReview: analysis.needsHumanReview ?? classified.humanReview,
|
|
195
|
+
suggestedFixes: analysis.suggestedFixes,
|
|
196
|
+
source,
|
|
197
|
+
tokenUsage: analysis.tokenUsage
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function classifyFailure(failure) {
|
|
201
|
+
const matched = matchFailure(failure);
|
|
202
|
+
return analysisWithDefaults(
|
|
203
|
+
{
|
|
204
|
+
side: matched.side,
|
|
205
|
+
rootCause: matched.rootCause,
|
|
206
|
+
fixSuggestions: matched.suggestions,
|
|
207
|
+
confidence: matched.confidence,
|
|
208
|
+
category: matched.category,
|
|
209
|
+
risk: matched.risk,
|
|
210
|
+
needsHumanReview: matched.humanReview,
|
|
211
|
+
source: matched.category === "unknown" ? "unknown" : "deterministic"
|
|
212
|
+
},
|
|
213
|
+
failure,
|
|
214
|
+
matched.category === "unknown" ? "unknown" : "deterministic"
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// src/analyzer.ts
|
|
219
|
+
function deterministicAnalysis(failure) {
|
|
220
|
+
const classified = classifyFailure(failure);
|
|
221
|
+
return classified.category === "unknown" ? null : classified;
|
|
222
|
+
}
|
|
223
|
+
function unknownAnalysis(failure) {
|
|
224
|
+
return analysisWithDefaults(
|
|
225
|
+
{
|
|
226
|
+
rootCause: "No deterministic pattern matched and no AI provider was configured.",
|
|
227
|
+
source: "unknown"
|
|
228
|
+
},
|
|
229
|
+
failure,
|
|
230
|
+
"unknown"
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
function contextQuery(failure) {
|
|
234
|
+
return [
|
|
235
|
+
failure.title,
|
|
236
|
+
failure.file,
|
|
237
|
+
...extractSelectors(failure.error),
|
|
238
|
+
...failure.steps || [],
|
|
239
|
+
failure.error.split("\n")[0]
|
|
240
|
+
].filter(Boolean).join(" ").slice(0, 500);
|
|
241
|
+
}
|
|
242
|
+
function queryTokens(query) {
|
|
243
|
+
return [
|
|
244
|
+
...new Set(
|
|
245
|
+
query.toLowerCase().split(/[^a-z0-9_-]+/i).map((item) => item.trim()).filter((item) => item.length > 2)
|
|
246
|
+
)
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
function matchesAnyToken(value, tokens) {
|
|
250
|
+
const text = JSON.stringify(value).toLowerCase();
|
|
251
|
+
return tokens.some((token) => text.includes(token));
|
|
252
|
+
}
|
|
253
|
+
function memorySuggestion(record) {
|
|
254
|
+
const value = record.value || {};
|
|
255
|
+
const summary = typeof value.summary === "string" ? value.summary : typeof value.text === "string" ? value.text : JSON.stringify(value);
|
|
256
|
+
return summary.length > 240 ? `${summary.slice(0, 237)}...` : summary;
|
|
257
|
+
}
|
|
258
|
+
async function contextForFailure(store, failure, options) {
|
|
259
|
+
if (!store) {
|
|
260
|
+
return { memories: [], feedbackReasons: [], similarFailures: [], similarAnalyses: [] };
|
|
261
|
+
}
|
|
262
|
+
const query = contextQuery(failure);
|
|
263
|
+
const tokens = queryTokens(query);
|
|
264
|
+
const namespace = options.projectContext?.namespace || "default";
|
|
265
|
+
const limit = options.similarityLimit ?? 5;
|
|
266
|
+
const memories = [
|
|
267
|
+
...await store.searchMemory({ namespace, limit: 100 }),
|
|
268
|
+
...namespace === "default" ? [] : await store.searchMemory({ namespace: "default", limit: 100 })
|
|
269
|
+
].filter((record) => matchesAnyToken(record, tokens)).slice(0, limit);
|
|
270
|
+
const feedback = (await store.searchFeedback({ limit: 100 })).filter((record) => matchesAnyToken(record, tokens)).slice(0, limit);
|
|
271
|
+
const similarFailures = (await store.findFailures({ limit: 100 })).filter((record) => matchesAnyToken(record, tokens)).slice(0, limit);
|
|
272
|
+
const similarAnalyses = (await Promise.all(similarFailures.map((item) => store.getCachedAnalysis(failureSignature(item))))).filter((item) => Boolean(item));
|
|
273
|
+
return {
|
|
274
|
+
memories,
|
|
275
|
+
feedbackReasons: feedback.map((record) => `feedback:${record.decision}${record.reason ? `:${record.reason}` : ""}`),
|
|
276
|
+
similarFailures,
|
|
277
|
+
similarAnalyses
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function applyContext(analysis, context) {
|
|
281
|
+
const knownFixes = context.memories.filter((record) => ["known_fix", "knowledge", "known_issue", "owner"].includes(record.kind));
|
|
282
|
+
const suggestions = knownFixes.map(memorySuggestion).filter(Boolean);
|
|
283
|
+
const confidenceReasons = [
|
|
284
|
+
...analysis.confidenceReasons,
|
|
285
|
+
...knownFixes.map((record) => `memory:${record.kind}:${record.key}`),
|
|
286
|
+
...context.feedbackReasons
|
|
287
|
+
];
|
|
288
|
+
if (context.similarFailures.length > 0) {
|
|
289
|
+
confidenceReasons.push(
|
|
290
|
+
`history:${context.similarFailures.length} similar failure${context.similarFailures.length === 1 ? "" : "s"}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const flakyFeedback = context.feedbackReasons.some((reason) => reason.startsWith("feedback:mark_flaky"));
|
|
294
|
+
return {
|
|
295
|
+
...analysis,
|
|
296
|
+
side: flakyFeedback && analysis.side === "unknown" ? "flaky_test" : analysis.side,
|
|
297
|
+
fixSuggestions: [...analysis.fixSuggestions, ...suggestions],
|
|
298
|
+
evidence: analysis.evidence,
|
|
299
|
+
evidenceRefs: analysis.evidenceRefs,
|
|
300
|
+
confidenceReasons: [...new Set(confidenceReasons)],
|
|
301
|
+
needsHumanReview: analysis.needsHumanReview || flakyFeedback || knownFixes.some((record) => record.kind === "known_issue")
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
var DeterministicAnalyzer = class {
|
|
305
|
+
constructor(options = {}) {
|
|
306
|
+
this.provider = options.provider;
|
|
307
|
+
this.outputLanguage = options.outputLanguage || process.env.PW_AI_OUTPUT_LANGUAGE || "ru";
|
|
308
|
+
}
|
|
309
|
+
async analyzeFailure(failure, similarAnalyses = []) {
|
|
310
|
+
const deterministic = deterministicAnalysis(failure);
|
|
311
|
+
if (deterministic) {
|
|
312
|
+
return deterministic;
|
|
313
|
+
}
|
|
314
|
+
if (this.provider) {
|
|
315
|
+
const ai = await this.provider.analyzeFailure({
|
|
316
|
+
failure,
|
|
317
|
+
outputLanguage: this.outputLanguage,
|
|
318
|
+
similarAnalyses
|
|
319
|
+
});
|
|
320
|
+
return analysisWithDefaults(ai, failure, "ai");
|
|
321
|
+
}
|
|
322
|
+
return unknownAnalysis(failure);
|
|
323
|
+
}
|
|
324
|
+
async analyzeFailures(failures) {
|
|
325
|
+
return Promise.all(failures.map((failure) => this.analyzeFailure(failure)));
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
async function analyzeFailures(failures, options = {}) {
|
|
329
|
+
const outputLanguage = options.outputLanguage || process.env.PW_AI_OUTPUT_LANGUAGE || "ru";
|
|
330
|
+
const analyzer = new DeterministicAnalyzer({ provider: options.provider, outputLanguage });
|
|
331
|
+
const results = [];
|
|
332
|
+
for (const failure of failures) {
|
|
333
|
+
const signature = failureSignature(failure);
|
|
334
|
+
const cached = options.store ? await options.store.getCachedAnalysis(signature) : null;
|
|
335
|
+
if (cached) {
|
|
336
|
+
results.push(cached);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const context = await contextForFailure(options.store, failure, options);
|
|
340
|
+
const rawAnalysis = await analyzer.analyzeFailure(failure, context.similarAnalyses);
|
|
341
|
+
let analysis = applyContext(rawAnalysis, context);
|
|
342
|
+
if (options.store) {
|
|
343
|
+
await options.store.saveAnalysis(signature, analysis);
|
|
344
|
+
}
|
|
345
|
+
results.push(analysis);
|
|
346
|
+
}
|
|
347
|
+
return results;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/suggestions.ts
|
|
351
|
+
import { existsSync, readFileSync } from "fs";
|
|
352
|
+
import path from "path";
|
|
353
|
+
function assertionCount(text) {
|
|
354
|
+
return (text.match(/\bexpect\s*\(/g) || []).length;
|
|
355
|
+
}
|
|
356
|
+
function onlyTimeoutChanged(oldSnippet, newSnippet) {
|
|
357
|
+
const oldNormalized = oldSnippet.replace(/\d+/g, "#").replace(/\s+/g, " ").trim();
|
|
358
|
+
const newNormalized = newSnippet.replace(/\d+/g, "#").replace(/\s+/g, " ").trim();
|
|
359
|
+
return oldNormalized === newNormalized && /timeout/i.test(`${oldSnippet}
|
|
360
|
+
${newSnippet}`);
|
|
361
|
+
}
|
|
362
|
+
function testIdLocator(selector) {
|
|
363
|
+
const match = selector.match(/\[data-testid=['"]?([^'"\]]+)['"]?\]/i);
|
|
364
|
+
if (match) return `page.getByTestId("${match[1]}")`;
|
|
365
|
+
return `page.locator(${JSON.stringify(selector)})`;
|
|
366
|
+
}
|
|
367
|
+
function firstSelector(failure) {
|
|
368
|
+
return extractSelectors(failure.error)[0] || failure.lastActions?.find((action) => action.selector)?.selector;
|
|
369
|
+
}
|
|
370
|
+
function sourceLine(failure, options) {
|
|
371
|
+
if (!failure.line) return void 0;
|
|
372
|
+
const filePath = path.isAbsolute(failure.file) ? failure.file : path.resolve(options.workspaceRoot || process.cwd(), failure.file);
|
|
373
|
+
if (!existsSync(filePath)) return void 0;
|
|
374
|
+
const lines = readFileSync(filePath, "utf8").split(/\r?\n/);
|
|
375
|
+
return lines[failure.line - 1];
|
|
376
|
+
}
|
|
377
|
+
function evaluatePatchPolicy(suggestion) {
|
|
378
|
+
const oldAssertions = assertionCount(suggestion.oldSnippet);
|
|
379
|
+
const newAssertions = assertionCount(suggestion.newSnippet);
|
|
380
|
+
if (oldAssertions > 0 && newAssertions < oldAssertions) {
|
|
381
|
+
return {
|
|
382
|
+
...suggestion,
|
|
383
|
+
policyDecision: "blocked",
|
|
384
|
+
reviewReason: "Blocked: patch weakens or removes an assertion."
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
if (/assertion removed|delete assertion|remove assertion/i.test(suggestion.newSnippet)) {
|
|
388
|
+
return {
|
|
389
|
+
...suggestion,
|
|
390
|
+
policyDecision: "blocked",
|
|
391
|
+
reviewReason: "Blocked: patch appears to remove an assertion."
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
if (onlyTimeoutChanged(suggestion.oldSnippet, suggestion.newSnippet) && suggestion.evidence.length === 0) {
|
|
395
|
+
return {
|
|
396
|
+
...suggestion,
|
|
397
|
+
policyDecision: "blocked",
|
|
398
|
+
reviewReason: "Blocked: timeout-only changes require supporting evidence."
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (/\btest\.skip\b|\breturn;\s*$|TODO:\s*remove check/i.test(suggestion.newSnippet)) {
|
|
402
|
+
return {
|
|
403
|
+
...suggestion,
|
|
404
|
+
policyDecision: "blocked",
|
|
405
|
+
reviewReason: "Blocked: patch changes test intent instead of explaining the failure."
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
...suggestion,
|
|
410
|
+
policyDecision: "review",
|
|
411
|
+
reviewReason: suggestion.reviewReason || "Requires human approval; core never applies patches automatically."
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function generatePatchSuggestions(failure, analysis, options = {}) {
|
|
415
|
+
const evidence2 = [...analysis.evidence || [], ...analysis.evidenceRefs || []].filter(Boolean);
|
|
416
|
+
const selector = firstSelector(failure);
|
|
417
|
+
const locator = selector ? testIdLocator(selector) : 'page.locator("<target>")';
|
|
418
|
+
const lastAction = failure.lastActions?.slice(-1)[0];
|
|
419
|
+
const oldSourceLine = sourceLine(failure, options);
|
|
420
|
+
let suggestion;
|
|
421
|
+
if (analysis.category === "selector") {
|
|
422
|
+
suggestion = {
|
|
423
|
+
filePath: failure.file,
|
|
424
|
+
oldSnippet: `await ${locator}.click();`,
|
|
425
|
+
newSnippet: `await ${locator}.filter({ hasText: "<expected text>" }).click();`,
|
|
426
|
+
explanation: "Narrow the locator to the intended element instead of relying on an ambiguous match.",
|
|
427
|
+
evidence: evidence2,
|
|
428
|
+
risk: analysis.risk,
|
|
429
|
+
policyDecision: "review",
|
|
430
|
+
reviewReason: ""
|
|
431
|
+
};
|
|
432
|
+
} else if (analysis.category === "environment") {
|
|
433
|
+
suggestion = {
|
|
434
|
+
filePath: failure.file,
|
|
435
|
+
oldSnippet: 'await page.goto("<page>");',
|
|
436
|
+
newSnippet: 'const response = await page.goto("<page>");\nawait expect(response, "page request should succeed").toBeOK();',
|
|
437
|
+
explanation: "Expose the failing backend or environment dependency before continuing with UI assertions.",
|
|
438
|
+
evidence: evidence2,
|
|
439
|
+
risk: analysis.risk,
|
|
440
|
+
policyDecision: "review",
|
|
441
|
+
reviewReason: ""
|
|
442
|
+
};
|
|
443
|
+
} else if (analysis.category === "runtime") {
|
|
444
|
+
suggestion = {
|
|
445
|
+
filePath: failure.file,
|
|
446
|
+
oldSnippet: 'await page.goto("<page>");',
|
|
447
|
+
newSnippet: 'const consoleErrors: string[] = [];\npage.on("console", (message) => message.type() === "error" && consoleErrors.push(message.text()));\nawait page.goto("<page>");',
|
|
448
|
+
explanation: "Capture runtime console evidence so the owner can fix the thrown exception with context.",
|
|
449
|
+
evidence: evidence2,
|
|
450
|
+
risk: analysis.risk,
|
|
451
|
+
policyDecision: "review",
|
|
452
|
+
reviewReason: ""
|
|
453
|
+
};
|
|
454
|
+
} else {
|
|
455
|
+
const actionLocator = lastAction?.selector ? testIdLocator(lastAction.selector) : locator;
|
|
456
|
+
suggestion = {
|
|
457
|
+
filePath: failure.file,
|
|
458
|
+
line: failure.line,
|
|
459
|
+
oldSnippet: oldSourceLine || `await ${actionLocator}.click();`,
|
|
460
|
+
newSnippet: oldSourceLine ? `${oldSourceLine.match(/^\s*/)?.[0] || ""}await expect(${actionLocator}).toBeVisible();
|
|
461
|
+
${oldSourceLine}` : `await expect(${actionLocator}).toBeVisible();
|
|
462
|
+
await ${actionLocator}.click();`,
|
|
463
|
+
explanation: "Wait for the user-visible state before the action; do not increase timeouts without evidence.",
|
|
464
|
+
evidence: evidence2,
|
|
465
|
+
risk: analysis.risk,
|
|
466
|
+
policyDecision: "review",
|
|
467
|
+
reviewReason: ""
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
return [evaluatePatchPolicy(suggestion)];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// src/stores/sqlite.ts
|
|
474
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
475
|
+
import path2 from "path";
|
|
476
|
+
import initSqlJs from "sql.js";
|
|
477
|
+
var sqlModule;
|
|
478
|
+
function sql() {
|
|
479
|
+
sqlModule || (sqlModule = initSqlJs());
|
|
480
|
+
return sqlModule;
|
|
481
|
+
}
|
|
482
|
+
function now() {
|
|
483
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
484
|
+
}
|
|
485
|
+
function normalizeTags(tags) {
|
|
486
|
+
return [...new Set(tags || [])].filter(Boolean).sort();
|
|
487
|
+
}
|
|
488
|
+
function parsePayload(value) {
|
|
489
|
+
return typeof value === "string" ? JSON.parse(value) : value;
|
|
490
|
+
}
|
|
491
|
+
function hasSQLiteHeader(buffer) {
|
|
492
|
+
return buffer.subarray(0, 16).toString("utf8") === "SQLite format 3\0";
|
|
493
|
+
}
|
|
494
|
+
var SQLiteStore = class {
|
|
495
|
+
constructor(options = {}) {
|
|
496
|
+
this.loaded = false;
|
|
497
|
+
this.filePath = options.path || path2.join(process.cwd(), ".playwright-ai-tools", "ai-tools.sqlite");
|
|
498
|
+
this.inMemory = this.filePath === ":memory:";
|
|
499
|
+
}
|
|
500
|
+
async migrate() {
|
|
501
|
+
const db = await this.load();
|
|
502
|
+
db.run(`
|
|
503
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
504
|
+
id TEXT PRIMARY KEY,
|
|
505
|
+
payload TEXT NOT NULL,
|
|
506
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
CREATE TABLE IF NOT EXISTS failures (
|
|
510
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
511
|
+
signature TEXT,
|
|
512
|
+
file TEXT,
|
|
513
|
+
title TEXT,
|
|
514
|
+
run_id TEXT,
|
|
515
|
+
payload TEXT NOT NULL,
|
|
516
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
CREATE TABLE IF NOT EXISTS analyses (
|
|
520
|
+
signature TEXT PRIMARY KEY,
|
|
521
|
+
payload TEXT NOT NULL,
|
|
522
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
CREATE TABLE IF NOT EXISTS memory_records (
|
|
526
|
+
id TEXT PRIMARY KEY,
|
|
527
|
+
namespace TEXT NOT NULL,
|
|
528
|
+
kind TEXT NOT NULL,
|
|
529
|
+
key TEXT NOT NULL,
|
|
530
|
+
tags TEXT NOT NULL,
|
|
531
|
+
payload TEXT NOT NULL,
|
|
532
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
CREATE TABLE IF NOT EXISTS feedback_records (
|
|
536
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
537
|
+
signature TEXT NOT NULL,
|
|
538
|
+
decision TEXT NOT NULL,
|
|
539
|
+
reason TEXT,
|
|
540
|
+
payload TEXT NOT NULL,
|
|
541
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
CREATE INDEX IF NOT EXISTS idx_failures_signature ON failures(signature);
|
|
545
|
+
CREATE INDEX IF NOT EXISTS idx_failures_file ON failures(file);
|
|
546
|
+
CREATE INDEX IF NOT EXISTS idx_memory_namespace_kind ON memory_records(namespace, kind);
|
|
547
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_signature ON feedback_records(signature);
|
|
548
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_decision ON feedback_records(decision);
|
|
549
|
+
CREATE INDEX IF NOT EXISTS idx_analyses_category ON analyses(json_extract(payload, '$.category'));
|
|
550
|
+
CREATE INDEX IF NOT EXISTS idx_analyses_risk ON analyses(json_extract(payload, '$.risk'));
|
|
551
|
+
CREATE INDEX IF NOT EXISTS idx_failures_error ON failures(json_extract(payload, '$.error'));
|
|
552
|
+
`);
|
|
553
|
+
await this.persist();
|
|
554
|
+
}
|
|
555
|
+
async saveRun(run) {
|
|
556
|
+
const db = await this.load();
|
|
557
|
+
db.run(
|
|
558
|
+
"INSERT OR REPLACE INTO runs (id, payload) VALUES (?, ?)",
|
|
559
|
+
[run.id, JSON.stringify(run)]
|
|
560
|
+
);
|
|
561
|
+
await this.persist();
|
|
562
|
+
}
|
|
563
|
+
async saveFailure(failure) {
|
|
564
|
+
const db = await this.load();
|
|
565
|
+
const signature = failureSignature(failure);
|
|
566
|
+
const runId = typeof failure.run?.id === "string" ? failure.run.id : null;
|
|
567
|
+
db.run(
|
|
568
|
+
"INSERT INTO failures (signature, file, title, run_id, payload) VALUES (?, ?, ?, ?, ?)",
|
|
569
|
+
[signature, failure.file, failure.title, runId, JSON.stringify(failure)]
|
|
570
|
+
);
|
|
571
|
+
await this.persist();
|
|
572
|
+
}
|
|
573
|
+
async saveAnalysis(signature, analysis) {
|
|
574
|
+
const db = await this.load();
|
|
575
|
+
db.run(
|
|
576
|
+
`
|
|
577
|
+
INSERT INTO analyses (signature, payload, updated_at)
|
|
578
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
579
|
+
ON CONFLICT(signature) DO UPDATE SET payload = excluded.payload, updated_at = CURRENT_TIMESTAMP
|
|
580
|
+
`,
|
|
581
|
+
[signature, JSON.stringify(analysis)]
|
|
582
|
+
);
|
|
583
|
+
await this.persist();
|
|
584
|
+
}
|
|
585
|
+
async getCachedAnalysis(signature) {
|
|
586
|
+
const rows = await this.all("SELECT payload FROM analyses WHERE signature = ? LIMIT 1", [signature]);
|
|
587
|
+
return rows[0]?.payload ? { ...parsePayload(rows[0].payload), source: "cache" } : null;
|
|
588
|
+
}
|
|
589
|
+
async upsertMemory(record) {
|
|
590
|
+
const db = await this.load();
|
|
591
|
+
const id = `${record.namespace}:${record.kind}:${record.key}`;
|
|
592
|
+
const existing = await this.searchMemory({
|
|
593
|
+
namespace: record.namespace,
|
|
594
|
+
kind: record.kind,
|
|
595
|
+
query: record.key,
|
|
596
|
+
limit: 100
|
|
597
|
+
});
|
|
598
|
+
const previous = existing.find((item) => item.key === record.key);
|
|
599
|
+
const memory = {
|
|
600
|
+
namespace: record.namespace,
|
|
601
|
+
kind: record.kind,
|
|
602
|
+
key: record.key,
|
|
603
|
+
value: record.value,
|
|
604
|
+
tags: normalizeTags(record.tags),
|
|
605
|
+
confidence: record.confidence ?? 1,
|
|
606
|
+
source: record.source,
|
|
607
|
+
supersededBy: previous?.supersededBy ?? null,
|
|
608
|
+
createdAt: previous?.createdAt ?? now(),
|
|
609
|
+
updatedAt: now()
|
|
610
|
+
};
|
|
611
|
+
db.run(
|
|
612
|
+
`
|
|
613
|
+
INSERT INTO memory_records (id, namespace, kind, key, tags, payload, updated_at)
|
|
614
|
+
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
615
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
616
|
+
namespace = excluded.namespace,
|
|
617
|
+
kind = excluded.kind,
|
|
618
|
+
key = excluded.key,
|
|
619
|
+
tags = excluded.tags,
|
|
620
|
+
payload = excluded.payload,
|
|
621
|
+
updated_at = CURRENT_TIMESTAMP
|
|
622
|
+
`,
|
|
623
|
+
[id, memory.namespace, memory.kind, memory.key, JSON.stringify(memory.tags), JSON.stringify(memory)]
|
|
624
|
+
);
|
|
625
|
+
await this.persist();
|
|
626
|
+
}
|
|
627
|
+
async searchMemory(input) {
|
|
628
|
+
const rows = await this.all("SELECT payload FROM memory_records ORDER BY updated_at DESC");
|
|
629
|
+
const query = input.query?.toLowerCase().trim();
|
|
630
|
+
const tags = new Set(input.tags || []);
|
|
631
|
+
return rows.map((row) => parsePayload(row.payload)).filter((record) => {
|
|
632
|
+
if (input.namespace && record.namespace !== input.namespace) return false;
|
|
633
|
+
if (input.kind && record.kind !== input.kind) return false;
|
|
634
|
+
if (tags.size > 0 && !record.tags.some((tag) => tags.has(tag))) return false;
|
|
635
|
+
if (query && !JSON.stringify([record.key, record.kind, record.source, record.tags, record.value]).toLowerCase().includes(query)) {
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
return !record.supersededBy;
|
|
639
|
+
}).slice(0, input.limit ?? 20);
|
|
640
|
+
}
|
|
641
|
+
async recordFeedback(input) {
|
|
642
|
+
const db = await this.load();
|
|
643
|
+
const feedback = {
|
|
644
|
+
signature: input.signature,
|
|
645
|
+
decision: input.decision,
|
|
646
|
+
reason: input.reason || "",
|
|
647
|
+
metadata: input.metadata || {},
|
|
648
|
+
createdAt: now()
|
|
649
|
+
};
|
|
650
|
+
db.run(
|
|
651
|
+
"INSERT INTO feedback_records (signature, decision, reason, payload) VALUES (?, ?, ?, ?)",
|
|
652
|
+
[feedback.signature, feedback.decision, feedback.reason, JSON.stringify(feedback)]
|
|
653
|
+
);
|
|
654
|
+
await this.persist();
|
|
655
|
+
}
|
|
656
|
+
async searchFeedback(input) {
|
|
657
|
+
const rows = await this.all("SELECT payload FROM feedback_records ORDER BY created_at DESC");
|
|
658
|
+
const query = input.query?.toLowerCase().trim();
|
|
659
|
+
return rows.map((row) => parsePayload(row.payload)).filter((record) => {
|
|
660
|
+
if (input.signature && record.signature !== input.signature) return false;
|
|
661
|
+
if (input.decision && record.decision !== input.decision) return false;
|
|
662
|
+
if (query && !JSON.stringify(record).toLowerCase().includes(query)) return false;
|
|
663
|
+
return true;
|
|
664
|
+
}).slice(0, input.limit ?? 20);
|
|
665
|
+
}
|
|
666
|
+
async findFailures(input) {
|
|
667
|
+
const rows = await this.all("SELECT payload FROM failures ORDER BY id DESC");
|
|
668
|
+
const query = input.query?.toLowerCase().trim();
|
|
669
|
+
return rows.map((row) => parsePayload(row.payload)).filter((failure) => {
|
|
670
|
+
if (input.signature && failureSignature(failure) !== input.signature) return false;
|
|
671
|
+
if (input.file && failure.file !== input.file) return false;
|
|
672
|
+
if (query && !JSON.stringify(failure).toLowerCase().includes(query)) return false;
|
|
673
|
+
return true;
|
|
674
|
+
}).slice(0, input.limit ?? 20);
|
|
675
|
+
}
|
|
676
|
+
async getRun(id) {
|
|
677
|
+
const rows = await this.all("SELECT payload FROM runs WHERE id = ? LIMIT 1", [id]);
|
|
678
|
+
return rows[0]?.payload ? parsePayload(rows[0].payload) : null;
|
|
679
|
+
}
|
|
680
|
+
async stats() {
|
|
681
|
+
const tables = ["runs", "failures", "analyses", "memory_records", "feedback_records"];
|
|
682
|
+
const stats = {};
|
|
683
|
+
for (const table of tables) {
|
|
684
|
+
const rows = await this.all(`SELECT COUNT(*) AS count FROM ${table}`);
|
|
685
|
+
stats[table === "memory_records" ? "memoryRecords" : table === "feedback_records" ? "feedbackRecords" : table] = Number(rows[0]?.count || 0);
|
|
686
|
+
}
|
|
687
|
+
return stats;
|
|
688
|
+
}
|
|
689
|
+
async close() {
|
|
690
|
+
if (!this.db) return;
|
|
691
|
+
await this.persist();
|
|
692
|
+
this.db.close();
|
|
693
|
+
this.db = void 0;
|
|
694
|
+
this.loaded = false;
|
|
695
|
+
}
|
|
696
|
+
async load() {
|
|
697
|
+
if (this.loaded && this.db) return this.db;
|
|
698
|
+
this.loaded = true;
|
|
699
|
+
const SQL = await sql();
|
|
700
|
+
if (this.inMemory) {
|
|
701
|
+
this.db = new SQL.Database();
|
|
702
|
+
await this.migrate();
|
|
703
|
+
return this.db;
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
const buffer = await readFile(this.filePath);
|
|
707
|
+
if (buffer.length === 0) {
|
|
708
|
+
this.db = new SQL.Database();
|
|
709
|
+
await this.migrate();
|
|
710
|
+
} else if (hasSQLiteHeader(buffer)) {
|
|
711
|
+
this.db = new SQL.Database(buffer);
|
|
712
|
+
} else {
|
|
713
|
+
this.db = new SQL.Database();
|
|
714
|
+
await this.migrate();
|
|
715
|
+
await this.importSnapshot(JSON.parse(buffer.toString("utf8")));
|
|
716
|
+
}
|
|
717
|
+
} catch (error) {
|
|
718
|
+
if (error?.code !== "ENOENT") {
|
|
719
|
+
throw error;
|
|
720
|
+
}
|
|
721
|
+
this.db = new SQL.Database();
|
|
722
|
+
await this.migrate();
|
|
723
|
+
}
|
|
724
|
+
return this.db;
|
|
725
|
+
}
|
|
726
|
+
async importSnapshot(snapshot) {
|
|
727
|
+
for (const run of snapshot.runs || []) {
|
|
728
|
+
await this.saveRun(run);
|
|
729
|
+
}
|
|
730
|
+
for (const failure of snapshot.failures || []) {
|
|
731
|
+
await this.saveFailure(failure);
|
|
732
|
+
}
|
|
733
|
+
for (const [signature, analysis] of snapshot.analyses || []) {
|
|
734
|
+
await this.saveAnalysis(signature, analysis);
|
|
735
|
+
}
|
|
736
|
+
for (const [, memory] of snapshot.memory || []) {
|
|
737
|
+
await this.upsertMemory(memory);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
async all(sqlText, params = []) {
|
|
741
|
+
const db = await this.load();
|
|
742
|
+
const statement = db.prepare(sqlText, params);
|
|
743
|
+
const rows = [];
|
|
744
|
+
try {
|
|
745
|
+
while (statement.step()) {
|
|
746
|
+
rows.push(statement.getAsObject());
|
|
747
|
+
}
|
|
748
|
+
} finally {
|
|
749
|
+
statement.free();
|
|
750
|
+
}
|
|
751
|
+
return rows;
|
|
752
|
+
}
|
|
753
|
+
async persist() {
|
|
754
|
+
if (!this.db || this.inMemory) return;
|
|
755
|
+
await mkdir(path2.dirname(this.filePath), { recursive: true });
|
|
756
|
+
await writeFile(this.filePath, Buffer.from(this.db.export()));
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
// src/grouper.ts
|
|
761
|
+
var FailureGrouper = class {
|
|
762
|
+
group(failures) {
|
|
763
|
+
const groups = /* @__PURE__ */ new Map();
|
|
764
|
+
for (const failure of failures) {
|
|
765
|
+
const signature = failureSignature(failure);
|
|
766
|
+
groups.set(signature, [...groups.get(signature) || [], failure]);
|
|
767
|
+
}
|
|
768
|
+
return [...groups.entries()].map(([signature, groupedFailures]) => ({
|
|
769
|
+
signature,
|
|
770
|
+
pattern: readablePattern(groupedFailures[0]?.error || ""),
|
|
771
|
+
failures: groupedFailures
|
|
772
|
+
})).sort((a, b) => b.failures.length - a.failures.length);
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
// src/report.ts
|
|
777
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
|
|
778
|
+
import path3 from "path";
|
|
779
|
+
function escapeHtml(value) {
|
|
780
|
+
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
781
|
+
}
|
|
782
|
+
function normalizedReport(report) {
|
|
783
|
+
return {
|
|
784
|
+
...report,
|
|
785
|
+
suggestions: report.suggestions || report.failures.map((failure, index) => {
|
|
786
|
+
const analysis = report.analyses[index];
|
|
787
|
+
return analysis ? generatePatchSuggestions(failure, analysis) : [];
|
|
788
|
+
})
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
function markdownReport(report) {
|
|
792
|
+
const portable = normalizedReport(report);
|
|
793
|
+
const lines = [
|
|
794
|
+
"# Playwright AI Analysis Report",
|
|
795
|
+
"",
|
|
796
|
+
`Run: ${portable.run.id}`,
|
|
797
|
+
`Failures: ${portable.failures.length}`,
|
|
798
|
+
""
|
|
799
|
+
];
|
|
800
|
+
portable.failures.forEach((failure, index) => {
|
|
801
|
+
const analysis = portable.analyses[index];
|
|
802
|
+
const suggestions = portable.suggestions[index] || [];
|
|
803
|
+
lines.push(`## ${failure.title}`);
|
|
804
|
+
lines.push("");
|
|
805
|
+
lines.push(`- File: ${failure.file}${failure.line ? `:${failure.line}` : ""}`);
|
|
806
|
+
lines.push(`- Category: ${analysis?.category || "unknown"}`);
|
|
807
|
+
lines.push(`- Side: ${analysis?.side || "unknown"}`);
|
|
808
|
+
lines.push(`- Risk: ${analysis?.risk || "high"}`);
|
|
809
|
+
lines.push(`- Confidence: ${analysis?.confidence ?? 0}`);
|
|
810
|
+
lines.push(`- Root cause: ${analysis?.rootCause || "n/a"}`);
|
|
811
|
+
if (analysis?.evidenceRefs?.length) {
|
|
812
|
+
lines.push(`- Evidence refs: ${analysis.evidenceRefs.join(", ")}`);
|
|
813
|
+
}
|
|
814
|
+
if (failure.consoleErrors?.length || failure.networkFailures?.length) {
|
|
815
|
+
lines.push("- Evidence:");
|
|
816
|
+
for (const item of failure.consoleErrors || []) lines.push(` - Console ${item.level}: ${item.text}`);
|
|
817
|
+
for (const item of failure.networkFailures || []) {
|
|
818
|
+
lines.push(` - Network ${item.status || item.errorText}: ${item.method || "GET"} ${item.url}`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (suggestions.length) {
|
|
822
|
+
lines.push("");
|
|
823
|
+
lines.push("### Suggested Fixes");
|
|
824
|
+
for (const suggestion of suggestions) {
|
|
825
|
+
lines.push(`- ${suggestion.policyDecision}: ${suggestion.explanation} (${suggestion.reviewReason})`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
lines.push("");
|
|
829
|
+
});
|
|
830
|
+
return `${lines.join("\n")}
|
|
831
|
+
`;
|
|
832
|
+
}
|
|
833
|
+
function htmlReport(report) {
|
|
834
|
+
const portable = normalizedReport(report);
|
|
835
|
+
const groups = new FailureGrouper().group(portable.failures);
|
|
836
|
+
const groupSummary = groups.map((group) => `<li><code>${escapeHtml(group.signature)}</code> ${group.failures.length} failure(s)</li>`).join("");
|
|
837
|
+
const failures = portable.failures.map((failure, index) => {
|
|
838
|
+
const analysis = portable.analyses[index];
|
|
839
|
+
const suggestions = portable.suggestions[index] || [];
|
|
840
|
+
const evidence2 = [
|
|
841
|
+
...(failure.consoleErrors || []).map((item) => `Console ${item.level}: ${item.text}`),
|
|
842
|
+
...(failure.networkFailures || []).map(
|
|
843
|
+
(item) => `Network ${item.status || item.errorText}: ${item.method || "GET"} ${item.url}`
|
|
844
|
+
),
|
|
845
|
+
...(failure.lastActions || []).map((item) => `Action ${item.name}${item.selector ? ` ${item.selector}` : ""}`),
|
|
846
|
+
...(failure.artifactRefs || failure.artifacts || []).map((item) => `Artifact ${item.type}: ${item.path}`)
|
|
847
|
+
];
|
|
848
|
+
return `<section class="failure" data-category="${escapeHtml(analysis?.category || "unknown")}">
|
|
849
|
+
<h2>${escapeHtml(failure.title)}</h2>
|
|
850
|
+
<p><strong>${escapeHtml(analysis?.category || "unknown")}</strong> ${escapeHtml(analysis?.side || "unknown")} risk=${escapeHtml(analysis?.risk || "high")} confidence=${escapeHtml(analysis?.confidence ?? 0)} humanReview=${escapeHtml(analysis?.needsHumanReview ?? true)}</p>
|
|
851
|
+
<p>${escapeHtml(analysis?.rootCause || failure.error)}</p>
|
|
852
|
+
<h3>Evidence</h3>
|
|
853
|
+
<ul>${evidence2.map((item) => `<li>${escapeHtml(item)}</li>`).join("") || "<li>No extra evidence collected.</li>"}</ul>
|
|
854
|
+
<h3>Suggested Fixes</h3>
|
|
855
|
+
<ul>${suggestions.map(
|
|
856
|
+
(suggestion) => `<li><strong>${escapeHtml(suggestion.policyDecision)}</strong> ${escapeHtml(suggestion.explanation)}<pre>${escapeHtml(suggestion.newSnippet)}</pre><small>${escapeHtml(suggestion.reviewReason)}</small></li>`
|
|
857
|
+
).join("") || "<li>No suggestion available.</li>"}</ul>
|
|
858
|
+
</section>`;
|
|
859
|
+
}).join("\n");
|
|
860
|
+
return `<!doctype html>
|
|
861
|
+
<html lang="en">
|
|
862
|
+
<head>
|
|
863
|
+
<meta charset="utf-8">
|
|
864
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
865
|
+
<title>Playwright AI Analysis Report</title>
|
|
866
|
+
<style>
|
|
867
|
+
body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;margin:0;background:#f7f7f4;color:#171717}
|
|
868
|
+
header,main{max-width:1120px;margin:0 auto;padding:24px}
|
|
869
|
+
header{border-bottom:1px solid #d8d8d2}
|
|
870
|
+
.summary{display:flex;gap:16px;flex-wrap:wrap}
|
|
871
|
+
.pill{border:1px solid #c9c9c1;border-radius:6px;padding:6px 10px;background:white}
|
|
872
|
+
.failure{background:white;border:1px solid #d8d8d2;border-radius:8px;padding:18px;margin:18px 0}
|
|
873
|
+
pre{white-space:pre-wrap;background:#111;color:#f5f5f5;border-radius:6px;padding:12px;overflow:auto}
|
|
874
|
+
code{font-size:.9em}
|
|
875
|
+
</style>
|
|
876
|
+
</head>
|
|
877
|
+
<body>
|
|
878
|
+
<header>
|
|
879
|
+
<h1>Playwright AI Analysis Report</h1>
|
|
880
|
+
<div class="summary"><span class="pill">Run ${escapeHtml(portable.run.id)}</span><span class="pill">Failures ${portable.failures.length}</span><span class="pill">Cache hits ${portable.analyses.filter((analysis) => analysis.source === "cache").length}</span></div>
|
|
881
|
+
</header>
|
|
882
|
+
<main>
|
|
883
|
+
<h2>Groups</h2>
|
|
884
|
+
<ul>${groupSummary}</ul>
|
|
885
|
+
${failures}
|
|
886
|
+
</main>
|
|
887
|
+
</body>
|
|
888
|
+
</html>
|
|
889
|
+
`;
|
|
890
|
+
}
|
|
891
|
+
function mcpSummary(report) {
|
|
892
|
+
const portable = normalizedReport(report);
|
|
893
|
+
return portable.failures.map((failure, index) => {
|
|
894
|
+
const analysis = portable.analyses[index];
|
|
895
|
+
return [
|
|
896
|
+
`failure="${failure.title}"`,
|
|
897
|
+
`file=${failure.file}${failure.line ? `:${failure.line}` : ""}`,
|
|
898
|
+
`category=${analysis?.category || "unknown"}`,
|
|
899
|
+
`risk=${analysis?.risk || "high"}`,
|
|
900
|
+
`source=${analysis?.source || "unknown"}`,
|
|
901
|
+
`needsHumanReview=${analysis?.needsHumanReview ?? true}`
|
|
902
|
+
].join(" ");
|
|
903
|
+
}).join("\n");
|
|
904
|
+
}
|
|
905
|
+
async function writePortableReport(outputDir, report) {
|
|
906
|
+
await mkdir2(outputDir, { recursive: true });
|
|
907
|
+
const portable = normalizedReport(report);
|
|
908
|
+
await writeFile2(path3.join(outputDir, "ai-analysis-report.json"), `${JSON.stringify(portable, null, 2)}
|
|
909
|
+
`, "utf8");
|
|
910
|
+
await writeFile2(path3.join(outputDir, "ai-analysis-report.md"), markdownReport(portable), "utf8");
|
|
911
|
+
await writeFile2(path3.join(outputDir, "ai-analysis-report.html"), htmlReport(portable), "utf8");
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
export {
|
|
915
|
+
failureSignature,
|
|
916
|
+
classifyFailure,
|
|
917
|
+
DeterministicAnalyzer,
|
|
918
|
+
analyzeFailures,
|
|
919
|
+
evaluatePatchPolicy,
|
|
920
|
+
generatePatchSuggestions,
|
|
921
|
+
SQLiteStore,
|
|
922
|
+
FailureGrouper,
|
|
923
|
+
markdownReport,
|
|
924
|
+
htmlReport,
|
|
925
|
+
mcpSummary,
|
|
926
|
+
writePortableReport
|
|
927
|
+
};
|