@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
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,1750 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __export = (target, all) => {
|
|
10
|
+
for (var name in all)
|
|
11
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
12
|
+
};
|
|
13
|
+
var __copyProps = (to, from, except, desc) => {
|
|
14
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
|
+
for (let key of __getOwnPropNames(from))
|
|
16
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
17
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
18
|
+
}
|
|
19
|
+
return to;
|
|
20
|
+
};
|
|
21
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
22
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
23
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
24
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
25
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
26
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
27
|
+
mod
|
|
28
|
+
));
|
|
29
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
30
|
+
|
|
31
|
+
// src/cli.ts
|
|
32
|
+
var cli_exports = {};
|
|
33
|
+
__export(cli_exports, {
|
|
34
|
+
main: () => main
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(cli_exports);
|
|
37
|
+
var import_promises4 = require("fs/promises");
|
|
38
|
+
var import_node_path5 = __toESM(require("path"), 1);
|
|
39
|
+
|
|
40
|
+
// src/signature.ts
|
|
41
|
+
var import_node_crypto = require("crypto");
|
|
42
|
+
function normalizeErrorForSignature(error) {
|
|
43
|
+
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) => {
|
|
44
|
+
const normalizedValue = value.replace(/\d+/g, "N");
|
|
45
|
+
return `${quote}${normalizedValue}${quote}`;
|
|
46
|
+
}).replace(/\/[\w./-]+/g, "PATH").slice(0, 500);
|
|
47
|
+
}
|
|
48
|
+
function failureSignature(failure) {
|
|
49
|
+
return (0, import_node_crypto.createHash)("sha256").update(normalizeErrorForSignature(failure.error)).digest("hex");
|
|
50
|
+
}
|
|
51
|
+
function readablePattern(error) {
|
|
52
|
+
const firstLine2 = error.split("\n")[0] || "Unknown failure";
|
|
53
|
+
return firstLine2.length > 120 ? `${firstLine2.slice(0, 117)}...` : firstLine2;
|
|
54
|
+
}
|
|
55
|
+
function extractSelectors(error) {
|
|
56
|
+
const selectors = /* @__PURE__ */ new Set();
|
|
57
|
+
const patterns = [
|
|
58
|
+
/locator\((['"`])(.+?)\1\)/g,
|
|
59
|
+
/selector\s+(['"`])(.+?)\1/g,
|
|
60
|
+
/(?:getByTestId|data-testid)[^\n'"`]*(['"`])(.+?)\1/g
|
|
61
|
+
];
|
|
62
|
+
for (const pattern of patterns) {
|
|
63
|
+
for (const match of error.matchAll(pattern)) {
|
|
64
|
+
const value = match[2]?.trim();
|
|
65
|
+
if (value) {
|
|
66
|
+
selectors.add(value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...selectors];
|
|
71
|
+
}
|
|
72
|
+
function extractUrls(error) {
|
|
73
|
+
const urls = /* @__PURE__ */ new Set();
|
|
74
|
+
for (const match of error.matchAll(/https?:\/\/[^\s)'"]+|\/api\/[^\s)'"]+/g)) {
|
|
75
|
+
urls.add(match[0]);
|
|
76
|
+
}
|
|
77
|
+
return [...urls];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/taxonomy.ts
|
|
81
|
+
function firstLine(text) {
|
|
82
|
+
return text.split("\n")[0] || text;
|
|
83
|
+
}
|
|
84
|
+
function evidence(failure) {
|
|
85
|
+
const items = [firstLine(failure.error)];
|
|
86
|
+
for (const item of failure.consoleErrors || []) {
|
|
87
|
+
items.push(`console:${item.level}:${item.text}`);
|
|
88
|
+
}
|
|
89
|
+
for (const item of failure.networkFailures || []) {
|
|
90
|
+
items.push(`network:${item.method || "GET"}:${item.status || item.errorText}:${item.url}`);
|
|
91
|
+
}
|
|
92
|
+
for (const item of failure.lastActions || []) {
|
|
93
|
+
items.push(`action:${item.name}${item.selector ? `:${item.selector}` : ""}`);
|
|
94
|
+
}
|
|
95
|
+
return items.filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
function evidenceRefs(failure) {
|
|
98
|
+
const refs = ["error"];
|
|
99
|
+
if (failure.consoleErrors?.length) refs.push("consoleErrors[0]");
|
|
100
|
+
if (failure.networkFailures?.length) refs.push("networkFailures[0]");
|
|
101
|
+
if (failure.lastActions?.length) refs.push("lastActions[-1]");
|
|
102
|
+
if (failure.pageSnapshot) refs.push("pageSnapshot");
|
|
103
|
+
if (failure.artifactRefs?.length || failure.artifacts?.length) refs.push("artifactRefs");
|
|
104
|
+
return refs;
|
|
105
|
+
}
|
|
106
|
+
function matchFailure(failure) {
|
|
107
|
+
const text = [
|
|
108
|
+
failure.error,
|
|
109
|
+
...(failure.consoleErrors || []).map((item) => item.text),
|
|
110
|
+
...(failure.networkFailures || []).map((item) => `${item.status || ""} ${item.errorText || ""} ${item.url}`)
|
|
111
|
+
].join("\n").toLowerCase();
|
|
112
|
+
if (/strict mode violation|resolved to \d+ elements|locator\(.+\) resolved/.test(text)) {
|
|
113
|
+
return {
|
|
114
|
+
category: "selector",
|
|
115
|
+
risk: "medium",
|
|
116
|
+
side: "test_bug",
|
|
117
|
+
rootCause: "A locator is ambiguous or no longer points to the intended element.",
|
|
118
|
+
suggestions: ["Make the locator specific to the intended role, label, text, or test id."],
|
|
119
|
+
confidence: 94,
|
|
120
|
+
humanReview: false,
|
|
121
|
+
reason: "Playwright reported strict locator matching evidence."
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (/not receiving pointer events|intercepts pointer events|element is covered|element is detached|not enabled|not editable/.test(text)) {
|
|
125
|
+
return {
|
|
126
|
+
category: "interaction",
|
|
127
|
+
risk: "medium",
|
|
128
|
+
side: "test_bug",
|
|
129
|
+
rootCause: "The page changed the interaction state before Playwright could complete the action.",
|
|
130
|
+
suggestions: ["Wait for the actionable UI state and inspect overlays, disabled states, or animations."],
|
|
131
|
+
confidence: 88,
|
|
132
|
+
humanReview: true,
|
|
133
|
+
reason: "The failure describes an element actionability or interaction problem."
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (/screenshot|snapshot|pixel|visual|image comparison|tohavescreenshot/.test(text)) {
|
|
137
|
+
return {
|
|
138
|
+
category: "visual",
|
|
139
|
+
risk: "medium",
|
|
140
|
+
side: "unknown",
|
|
141
|
+
rootCause: "A visual assertion differs from the stored baseline or expected snapshot.",
|
|
142
|
+
suggestions: ["Inspect the screenshot diff and update the baseline only if the product change is intended."],
|
|
143
|
+
confidence: 86,
|
|
144
|
+
humanReview: true,
|
|
145
|
+
reason: "The error contains visual comparison evidence."
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (/no test data|fixture|seed|factory|test data|not found for .*user|missing data/.test(text)) {
|
|
149
|
+
return {
|
|
150
|
+
category: "test_data",
|
|
151
|
+
risk: "medium",
|
|
152
|
+
side: "test_bug",
|
|
153
|
+
rootCause: "The test appears to depend on missing or inconsistent data setup.",
|
|
154
|
+
suggestions: ["Make the data fixture explicit and validate setup before the user journey starts."],
|
|
155
|
+
confidence: 84,
|
|
156
|
+
humanReview: false,
|
|
157
|
+
reason: "The failure references missing seeded data or fixture setup."
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (/net::err_|econnrefused|enotfound|timed out connecting|500|502|503|504|internal server error|service unavailable|fetch failed/.test(text)) {
|
|
161
|
+
return {
|
|
162
|
+
category: "environment",
|
|
163
|
+
risk: "high",
|
|
164
|
+
side: "env_issue",
|
|
165
|
+
rootCause: "A backend, network, or environment dependency failed during the test.",
|
|
166
|
+
suggestions: ["Check dependency health, request logs, and CI environment configuration for the failing endpoint."],
|
|
167
|
+
confidence: 86,
|
|
168
|
+
humanReview: true,
|
|
169
|
+
reason: "Network or server failure evidence was found."
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (/typeerror|referenceerror|cannot read properties|is not defined|uncaught|unhandled/.test(text)) {
|
|
173
|
+
return {
|
|
174
|
+
category: "runtime",
|
|
175
|
+
risk: "high",
|
|
176
|
+
side: "app_bug",
|
|
177
|
+
rootCause: "The application or test runtime threw an exception during the scenario.",
|
|
178
|
+
suggestions: ["Inspect the console stack, failing page state, and recent runtime changes."],
|
|
179
|
+
confidence: 88,
|
|
180
|
+
humanReview: true,
|
|
181
|
+
reason: "Runtime exception evidence was found in the error or console output."
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (/timeout|waiting for locator|waiting for selector|tobevisible|to be visible|waiting for .*state/.test(text)) {
|
|
185
|
+
return {
|
|
186
|
+
category: "timing",
|
|
187
|
+
risk: "medium",
|
|
188
|
+
side: "test_bug",
|
|
189
|
+
rootCause: "A Playwright wait timed out before the expected element or state appeared.",
|
|
190
|
+
suggestions: [
|
|
191
|
+
"Verify the expected UI state is still correct.",
|
|
192
|
+
"Wait for the user-visible state with an assertion before acting.",
|
|
193
|
+
"Check whether navigation or data setup leaves the element hidden."
|
|
194
|
+
],
|
|
195
|
+
confidence: 90,
|
|
196
|
+
humanReview: false,
|
|
197
|
+
reason: "Playwright reported a timeout while waiting for an expected state."
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
category: "unknown",
|
|
202
|
+
risk: "high",
|
|
203
|
+
side: "unknown",
|
|
204
|
+
rootCause: "No deterministic taxonomy rule matched this failure.",
|
|
205
|
+
suggestions: ["Review trace, screenshot, console output, network output, and recent changes."],
|
|
206
|
+
confidence: 30,
|
|
207
|
+
humanReview: true,
|
|
208
|
+
reason: "No deterministic classifier matched the available evidence."
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function analysisWithDefaults(analysis, failure, source = analysis.source || "unknown") {
|
|
212
|
+
const classified = matchFailure(failure);
|
|
213
|
+
const urls = extractUrls(failure.error);
|
|
214
|
+
return {
|
|
215
|
+
side: analysis.side || classified.side,
|
|
216
|
+
rootCause: analysis.rootCause || classified.rootCause,
|
|
217
|
+
evidence: analysis.evidence?.length ? analysis.evidence : evidence(failure),
|
|
218
|
+
reproductionSteps: analysis.reproductionSteps?.length ? analysis.reproductionSteps : failure.steps?.length ? failure.steps : ["Run the failing Playwright test."],
|
|
219
|
+
suspects: analysis.suspects || {
|
|
220
|
+
selectors: extractSelectors(failure.error),
|
|
221
|
+
pages: urls.filter((url) => !url.startsWith("/api/")),
|
|
222
|
+
endpoints: [
|
|
223
|
+
...urls.filter((url) => url.startsWith("/api/")),
|
|
224
|
+
...(failure.networkFailures || []).map((item) => item.url)
|
|
225
|
+
]
|
|
226
|
+
},
|
|
227
|
+
fixSuggestions: analysis.fixSuggestions?.length ? analysis.fixSuggestions : classified.suggestions,
|
|
228
|
+
confidence: Number(analysis.confidence ?? classified.confidence),
|
|
229
|
+
category: analysis.category || classified.category,
|
|
230
|
+
risk: analysis.risk || classified.risk,
|
|
231
|
+
evidenceRefs: analysis.evidenceRefs?.length ? analysis.evidenceRefs : evidenceRefs(failure),
|
|
232
|
+
confidenceReasons: analysis.confidenceReasons?.length ? analysis.confidenceReasons : [classified.reason],
|
|
233
|
+
needsHumanReview: analysis.needsHumanReview ?? classified.humanReview,
|
|
234
|
+
suggestedFixes: analysis.suggestedFixes,
|
|
235
|
+
source,
|
|
236
|
+
tokenUsage: analysis.tokenUsage
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function classifyFailure(failure) {
|
|
240
|
+
const matched = matchFailure(failure);
|
|
241
|
+
return analysisWithDefaults(
|
|
242
|
+
{
|
|
243
|
+
side: matched.side,
|
|
244
|
+
rootCause: matched.rootCause,
|
|
245
|
+
fixSuggestions: matched.suggestions,
|
|
246
|
+
confidence: matched.confidence,
|
|
247
|
+
category: matched.category,
|
|
248
|
+
risk: matched.risk,
|
|
249
|
+
needsHumanReview: matched.humanReview,
|
|
250
|
+
source: matched.category === "unknown" ? "unknown" : "deterministic"
|
|
251
|
+
},
|
|
252
|
+
failure,
|
|
253
|
+
matched.category === "unknown" ? "unknown" : "deterministic"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/analyzer.ts
|
|
258
|
+
function deterministicAnalysis(failure) {
|
|
259
|
+
const classified = classifyFailure(failure);
|
|
260
|
+
return classified.category === "unknown" ? null : classified;
|
|
261
|
+
}
|
|
262
|
+
function unknownAnalysis(failure) {
|
|
263
|
+
return analysisWithDefaults(
|
|
264
|
+
{
|
|
265
|
+
rootCause: "No deterministic pattern matched and no AI provider was configured.",
|
|
266
|
+
source: "unknown"
|
|
267
|
+
},
|
|
268
|
+
failure,
|
|
269
|
+
"unknown"
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
function contextQuery(failure) {
|
|
273
|
+
return [
|
|
274
|
+
failure.title,
|
|
275
|
+
failure.file,
|
|
276
|
+
...extractSelectors(failure.error),
|
|
277
|
+
...failure.steps || [],
|
|
278
|
+
failure.error.split("\n")[0]
|
|
279
|
+
].filter(Boolean).join(" ").slice(0, 500);
|
|
280
|
+
}
|
|
281
|
+
function queryTokens(query) {
|
|
282
|
+
return [
|
|
283
|
+
...new Set(
|
|
284
|
+
query.toLowerCase().split(/[^a-z0-9_-]+/i).map((item) => item.trim()).filter((item) => item.length > 2)
|
|
285
|
+
)
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
function matchesAnyToken(value, tokens) {
|
|
289
|
+
const text = JSON.stringify(value).toLowerCase();
|
|
290
|
+
return tokens.some((token) => text.includes(token));
|
|
291
|
+
}
|
|
292
|
+
function memorySuggestion(record) {
|
|
293
|
+
const value = record.value || {};
|
|
294
|
+
const summary = typeof value.summary === "string" ? value.summary : typeof value.text === "string" ? value.text : JSON.stringify(value);
|
|
295
|
+
return summary.length > 240 ? `${summary.slice(0, 237)}...` : summary;
|
|
296
|
+
}
|
|
297
|
+
async function contextForFailure(store, failure, options) {
|
|
298
|
+
if (!store) {
|
|
299
|
+
return { memories: [], feedbackReasons: [], similarFailures: [], similarAnalyses: [] };
|
|
300
|
+
}
|
|
301
|
+
const query = contextQuery(failure);
|
|
302
|
+
const tokens = queryTokens(query);
|
|
303
|
+
const namespace = options.projectContext?.namespace || "default";
|
|
304
|
+
const limit = options.similarityLimit ?? 5;
|
|
305
|
+
const memories = [
|
|
306
|
+
...await store.searchMemory({ namespace, limit: 100 }),
|
|
307
|
+
...namespace === "default" ? [] : await store.searchMemory({ namespace: "default", limit: 100 })
|
|
308
|
+
].filter((record) => matchesAnyToken(record, tokens)).slice(0, limit);
|
|
309
|
+
const feedback = (await store.searchFeedback({ limit: 100 })).filter((record) => matchesAnyToken(record, tokens)).slice(0, limit);
|
|
310
|
+
const similarFailures = (await store.findFailures({ limit: 100 })).filter((record) => matchesAnyToken(record, tokens)).slice(0, limit);
|
|
311
|
+
const similarAnalyses = (await Promise.all(similarFailures.map((item) => store.getCachedAnalysis(failureSignature(item))))).filter((item) => Boolean(item));
|
|
312
|
+
return {
|
|
313
|
+
memories,
|
|
314
|
+
feedbackReasons: feedback.map((record) => `feedback:${record.decision}${record.reason ? `:${record.reason}` : ""}`),
|
|
315
|
+
similarFailures,
|
|
316
|
+
similarAnalyses
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function applyContext(analysis, context) {
|
|
320
|
+
const knownFixes = context.memories.filter((record) => ["known_fix", "knowledge", "known_issue", "owner"].includes(record.kind));
|
|
321
|
+
const suggestions = knownFixes.map(memorySuggestion).filter(Boolean);
|
|
322
|
+
const confidenceReasons = [
|
|
323
|
+
...analysis.confidenceReasons,
|
|
324
|
+
...knownFixes.map((record) => `memory:${record.kind}:${record.key}`),
|
|
325
|
+
...context.feedbackReasons
|
|
326
|
+
];
|
|
327
|
+
if (context.similarFailures.length > 0) {
|
|
328
|
+
confidenceReasons.push(
|
|
329
|
+
`history:${context.similarFailures.length} similar failure${context.similarFailures.length === 1 ? "" : "s"}`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const flakyFeedback = context.feedbackReasons.some((reason) => reason.startsWith("feedback:mark_flaky"));
|
|
333
|
+
return {
|
|
334
|
+
...analysis,
|
|
335
|
+
side: flakyFeedback && analysis.side === "unknown" ? "flaky_test" : analysis.side,
|
|
336
|
+
fixSuggestions: [...analysis.fixSuggestions, ...suggestions],
|
|
337
|
+
evidence: analysis.evidence,
|
|
338
|
+
evidenceRefs: analysis.evidenceRefs,
|
|
339
|
+
confidenceReasons: [...new Set(confidenceReasons)],
|
|
340
|
+
needsHumanReview: analysis.needsHumanReview || flakyFeedback || knownFixes.some((record) => record.kind === "known_issue")
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
var DeterministicAnalyzer = class {
|
|
344
|
+
constructor(options = {}) {
|
|
345
|
+
this.provider = options.provider;
|
|
346
|
+
this.outputLanguage = options.outputLanguage || process.env.PW_AI_OUTPUT_LANGUAGE || "ru";
|
|
347
|
+
}
|
|
348
|
+
async analyzeFailure(failure, similarAnalyses = []) {
|
|
349
|
+
const deterministic = deterministicAnalysis(failure);
|
|
350
|
+
if (deterministic) {
|
|
351
|
+
return deterministic;
|
|
352
|
+
}
|
|
353
|
+
if (this.provider) {
|
|
354
|
+
const ai = await this.provider.analyzeFailure({
|
|
355
|
+
failure,
|
|
356
|
+
outputLanguage: this.outputLanguage,
|
|
357
|
+
similarAnalyses
|
|
358
|
+
});
|
|
359
|
+
return analysisWithDefaults(ai, failure, "ai");
|
|
360
|
+
}
|
|
361
|
+
return unknownAnalysis(failure);
|
|
362
|
+
}
|
|
363
|
+
async analyzeFailures(failures) {
|
|
364
|
+
return Promise.all(failures.map((failure) => this.analyzeFailure(failure)));
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
async function analyzeFailures(failures, options = {}) {
|
|
368
|
+
const outputLanguage = options.outputLanguage || process.env.PW_AI_OUTPUT_LANGUAGE || "ru";
|
|
369
|
+
const analyzer = new DeterministicAnalyzer({ provider: options.provider, outputLanguage });
|
|
370
|
+
const results = [];
|
|
371
|
+
for (const failure of failures) {
|
|
372
|
+
const signature = failureSignature(failure);
|
|
373
|
+
const cached = options.store ? await options.store.getCachedAnalysis(signature) : null;
|
|
374
|
+
if (cached) {
|
|
375
|
+
results.push(cached);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const context = await contextForFailure(options.store, failure, options);
|
|
379
|
+
const rawAnalysis = await analyzer.analyzeFailure(failure, context.similarAnalyses);
|
|
380
|
+
let analysis = applyContext(rawAnalysis, context);
|
|
381
|
+
if (options.store) {
|
|
382
|
+
await options.store.saveAnalysis(signature, analysis);
|
|
383
|
+
}
|
|
384
|
+
results.push(analysis);
|
|
385
|
+
}
|
|
386
|
+
return results;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/suggestions.ts
|
|
390
|
+
var import_node_fs = require("fs");
|
|
391
|
+
var import_node_path = __toESM(require("path"), 1);
|
|
392
|
+
function assertionCount(text) {
|
|
393
|
+
return (text.match(/\bexpect\s*\(/g) || []).length;
|
|
394
|
+
}
|
|
395
|
+
function onlyTimeoutChanged(oldSnippet, newSnippet) {
|
|
396
|
+
const oldNormalized = oldSnippet.replace(/\d+/g, "#").replace(/\s+/g, " ").trim();
|
|
397
|
+
const newNormalized = newSnippet.replace(/\d+/g, "#").replace(/\s+/g, " ").trim();
|
|
398
|
+
return oldNormalized === newNormalized && /timeout/i.test(`${oldSnippet}
|
|
399
|
+
${newSnippet}`);
|
|
400
|
+
}
|
|
401
|
+
function testIdLocator(selector) {
|
|
402
|
+
const match = selector.match(/\[data-testid=['"]?([^'"\]]+)['"]?\]/i);
|
|
403
|
+
if (match) return `page.getByTestId("${match[1]}")`;
|
|
404
|
+
return `page.locator(${JSON.stringify(selector)})`;
|
|
405
|
+
}
|
|
406
|
+
function firstSelector(failure) {
|
|
407
|
+
return extractSelectors(failure.error)[0] || failure.lastActions?.find((action) => action.selector)?.selector;
|
|
408
|
+
}
|
|
409
|
+
function sourceLine(failure, options) {
|
|
410
|
+
if (!failure.line) return void 0;
|
|
411
|
+
const filePath = import_node_path.default.isAbsolute(failure.file) ? failure.file : import_node_path.default.resolve(options.workspaceRoot || process.cwd(), failure.file);
|
|
412
|
+
if (!(0, import_node_fs.existsSync)(filePath)) return void 0;
|
|
413
|
+
const lines = (0, import_node_fs.readFileSync)(filePath, "utf8").split(/\r?\n/);
|
|
414
|
+
return lines[failure.line - 1];
|
|
415
|
+
}
|
|
416
|
+
function evaluatePatchPolicy(suggestion) {
|
|
417
|
+
const oldAssertions = assertionCount(suggestion.oldSnippet);
|
|
418
|
+
const newAssertions = assertionCount(suggestion.newSnippet);
|
|
419
|
+
if (oldAssertions > 0 && newAssertions < oldAssertions) {
|
|
420
|
+
return {
|
|
421
|
+
...suggestion,
|
|
422
|
+
policyDecision: "blocked",
|
|
423
|
+
reviewReason: "Blocked: patch weakens or removes an assertion."
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
if (/assertion removed|delete assertion|remove assertion/i.test(suggestion.newSnippet)) {
|
|
427
|
+
return {
|
|
428
|
+
...suggestion,
|
|
429
|
+
policyDecision: "blocked",
|
|
430
|
+
reviewReason: "Blocked: patch appears to remove an assertion."
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
if (onlyTimeoutChanged(suggestion.oldSnippet, suggestion.newSnippet) && suggestion.evidence.length === 0) {
|
|
434
|
+
return {
|
|
435
|
+
...suggestion,
|
|
436
|
+
policyDecision: "blocked",
|
|
437
|
+
reviewReason: "Blocked: timeout-only changes require supporting evidence."
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (/\btest\.skip\b|\breturn;\s*$|TODO:\s*remove check/i.test(suggestion.newSnippet)) {
|
|
441
|
+
return {
|
|
442
|
+
...suggestion,
|
|
443
|
+
policyDecision: "blocked",
|
|
444
|
+
reviewReason: "Blocked: patch changes test intent instead of explaining the failure."
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
...suggestion,
|
|
449
|
+
policyDecision: "review",
|
|
450
|
+
reviewReason: suggestion.reviewReason || "Requires human approval; core never applies patches automatically."
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function generatePatchSuggestions(failure, analysis, options = {}) {
|
|
454
|
+
const evidence2 = [...analysis.evidence || [], ...analysis.evidenceRefs || []].filter(Boolean);
|
|
455
|
+
const selector = firstSelector(failure);
|
|
456
|
+
const locator = selector ? testIdLocator(selector) : 'page.locator("<target>")';
|
|
457
|
+
const lastAction = failure.lastActions?.slice(-1)[0];
|
|
458
|
+
const oldSourceLine = sourceLine(failure, options);
|
|
459
|
+
let suggestion;
|
|
460
|
+
if (analysis.category === "selector") {
|
|
461
|
+
suggestion = {
|
|
462
|
+
filePath: failure.file,
|
|
463
|
+
oldSnippet: `await ${locator}.click();`,
|
|
464
|
+
newSnippet: `await ${locator}.filter({ hasText: "<expected text>" }).click();`,
|
|
465
|
+
explanation: "Narrow the locator to the intended element instead of relying on an ambiguous match.",
|
|
466
|
+
evidence: evidence2,
|
|
467
|
+
risk: analysis.risk,
|
|
468
|
+
policyDecision: "review",
|
|
469
|
+
reviewReason: ""
|
|
470
|
+
};
|
|
471
|
+
} else if (analysis.category === "environment") {
|
|
472
|
+
suggestion = {
|
|
473
|
+
filePath: failure.file,
|
|
474
|
+
oldSnippet: 'await page.goto("<page>");',
|
|
475
|
+
newSnippet: 'const response = await page.goto("<page>");\nawait expect(response, "page request should succeed").toBeOK();',
|
|
476
|
+
explanation: "Expose the failing backend or environment dependency before continuing with UI assertions.",
|
|
477
|
+
evidence: evidence2,
|
|
478
|
+
risk: analysis.risk,
|
|
479
|
+
policyDecision: "review",
|
|
480
|
+
reviewReason: ""
|
|
481
|
+
};
|
|
482
|
+
} else if (analysis.category === "runtime") {
|
|
483
|
+
suggestion = {
|
|
484
|
+
filePath: failure.file,
|
|
485
|
+
oldSnippet: 'await page.goto("<page>");',
|
|
486
|
+
newSnippet: 'const consoleErrors: string[] = [];\npage.on("console", (message) => message.type() === "error" && consoleErrors.push(message.text()));\nawait page.goto("<page>");',
|
|
487
|
+
explanation: "Capture runtime console evidence so the owner can fix the thrown exception with context.",
|
|
488
|
+
evidence: evidence2,
|
|
489
|
+
risk: analysis.risk,
|
|
490
|
+
policyDecision: "review",
|
|
491
|
+
reviewReason: ""
|
|
492
|
+
};
|
|
493
|
+
} else {
|
|
494
|
+
const actionLocator = lastAction?.selector ? testIdLocator(lastAction.selector) : locator;
|
|
495
|
+
suggestion = {
|
|
496
|
+
filePath: failure.file,
|
|
497
|
+
line: failure.line,
|
|
498
|
+
oldSnippet: oldSourceLine || `await ${actionLocator}.click();`,
|
|
499
|
+
newSnippet: oldSourceLine ? `${oldSourceLine.match(/^\s*/)?.[0] || ""}await expect(${actionLocator}).toBeVisible();
|
|
500
|
+
${oldSourceLine}` : `await expect(${actionLocator}).toBeVisible();
|
|
501
|
+
await ${actionLocator}.click();`,
|
|
502
|
+
explanation: "Wait for the user-visible state before the action; do not increase timeouts without evidence.",
|
|
503
|
+
evidence: evidence2,
|
|
504
|
+
risk: analysis.risk,
|
|
505
|
+
policyDecision: "review",
|
|
506
|
+
reviewReason: ""
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
return [evaluatePatchPolicy(suggestion)];
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// src/benchmark.ts
|
|
513
|
+
function roundMetric(value) {
|
|
514
|
+
return Number(value.toFixed(4));
|
|
515
|
+
}
|
|
516
|
+
function benchmarkFailures(fixtures) {
|
|
517
|
+
const seenSignatures = /* @__PURE__ */ new Set();
|
|
518
|
+
const failures = fixtures.map((fixture) => {
|
|
519
|
+
const analysis = classifyFailure(fixture);
|
|
520
|
+
const suggestions = generatePatchSuggestions(fixture, analysis);
|
|
521
|
+
const signature = failureSignature(fixture);
|
|
522
|
+
const cacheHit = seenSignatures.has(signature);
|
|
523
|
+
seenSignatures.add(signature);
|
|
524
|
+
const expectedCategory = fixture.expectedCategory || "unknown";
|
|
525
|
+
return {
|
|
526
|
+
title: fixture.title,
|
|
527
|
+
file: fixture.file,
|
|
528
|
+
expectedCategory,
|
|
529
|
+
actualCategory: analysis.category,
|
|
530
|
+
correct: expectedCategory === analysis.category,
|
|
531
|
+
signature,
|
|
532
|
+
cacheHit,
|
|
533
|
+
analysis,
|
|
534
|
+
suggestions
|
|
535
|
+
};
|
|
536
|
+
});
|
|
537
|
+
const total = failures.length;
|
|
538
|
+
const correct = failures.filter((failure) => failure.correct).length;
|
|
539
|
+
const unknown = failures.filter((failure) => failure.actualCategory === "unknown").length;
|
|
540
|
+
const cacheHits = failures.filter((failure) => failure.cacheHit).length;
|
|
541
|
+
const blocked = failures.reduce(
|
|
542
|
+
(count, failure) => count + failure.suggestions.filter((suggestion) => suggestion.policyDecision === "blocked").length,
|
|
543
|
+
0
|
|
544
|
+
);
|
|
545
|
+
const review = failures.reduce(
|
|
546
|
+
(count, failure) => count + failure.suggestions.filter((suggestion) => suggestion.policyDecision === "review").length,
|
|
547
|
+
0
|
|
548
|
+
);
|
|
549
|
+
return {
|
|
550
|
+
metrics: {
|
|
551
|
+
total,
|
|
552
|
+
correct,
|
|
553
|
+
accuracy: total ? roundMetric(correct / total) : 0,
|
|
554
|
+
unknown,
|
|
555
|
+
unknownRate: total ? roundMetric(unknown / total) : 0,
|
|
556
|
+
cacheHits,
|
|
557
|
+
cacheHitRate: total ? roundMetric(cacheHits / total) : 0,
|
|
558
|
+
suggestions: { blocked, review }
|
|
559
|
+
},
|
|
560
|
+
failures
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// src/generation.ts
|
|
565
|
+
var import_promises = require("fs/promises");
|
|
566
|
+
var import_node_path2 = __toESM(require("path"), 1);
|
|
567
|
+
var import_test = require("@playwright/test");
|
|
568
|
+
var DESTRUCTIVE_PATTERNS = [
|
|
569
|
+
/\bdelete\b/i,
|
|
570
|
+
/\bremove\b/i,
|
|
571
|
+
/\bdestroy\b/i,
|
|
572
|
+
/\bpay\b/i,
|
|
573
|
+
/\bpayment\b/i,
|
|
574
|
+
/\bpurchase\b/i,
|
|
575
|
+
/\bsubmit\s+order\b/i,
|
|
576
|
+
/\bplace\s+order\b/i,
|
|
577
|
+
/\brefund\b/i,
|
|
578
|
+
/\bcancel\s+subscription\b/i
|
|
579
|
+
];
|
|
580
|
+
function safeSegment(value, fallback) {
|
|
581
|
+
const normalized = (value || fallback).trim().replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 100);
|
|
582
|
+
return normalized || fallback;
|
|
583
|
+
}
|
|
584
|
+
function isLocalUrl(rawUrl) {
|
|
585
|
+
try {
|
|
586
|
+
const url = new URL(rawUrl);
|
|
587
|
+
return ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
|
|
588
|
+
} catch {
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function destructiveText(manualCase) {
|
|
593
|
+
return [
|
|
594
|
+
manualCase.title,
|
|
595
|
+
manualCase.description,
|
|
596
|
+
manualCase.preconditions,
|
|
597
|
+
...manualCase.steps.flatMap((step) => [step.action, step.expected])
|
|
598
|
+
].filter(Boolean).join("\n");
|
|
599
|
+
}
|
|
600
|
+
function isDestructiveManualCase(manualCase) {
|
|
601
|
+
const text = destructiveText(manualCase);
|
|
602
|
+
return DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(text));
|
|
603
|
+
}
|
|
604
|
+
function assertBrowserGenerationAllowed(request) {
|
|
605
|
+
if (!isLocalUrl(request.url) && !request.confirmBrowserRun) {
|
|
606
|
+
throw new Error(`Refusing to open external URL ${request.url}. Pass --confirm-browser-run to allow browser exploration.`);
|
|
607
|
+
}
|
|
608
|
+
if (isDestructiveManualCase(request.manualCase) && !request.allowDestructiveActions) {
|
|
609
|
+
throw new Error("Manual test case appears to contain destructive actions. Pass --allow-destructive-actions to override.");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function truncate(value, maxLength) {
|
|
613
|
+
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
|
|
614
|
+
}
|
|
615
|
+
function defaultSource(manualCase) {
|
|
616
|
+
return manualCase.source?.provider || manualCase.source?.project || "manual";
|
|
617
|
+
}
|
|
618
|
+
var TemplateTestGenerationProvider = class {
|
|
619
|
+
async generateTest(request) {
|
|
620
|
+
const title = request.manualCase.title.replace(/'/g, "\\'");
|
|
621
|
+
const steps = request.manualCase.steps.map((step) => ` // Step ${step.index ?? ""}: ${step.action}${step.expected ? ` -> ${step.expected}` : ""}`.trimEnd()).join("\n");
|
|
622
|
+
return {
|
|
623
|
+
code: [
|
|
624
|
+
"import { test, expect } from '@playwright/test';",
|
|
625
|
+
"",
|
|
626
|
+
`test('${title}', async ({ page }) => {`,
|
|
627
|
+
` await page.goto('${request.url}');`,
|
|
628
|
+
steps || " // No manual steps were supplied.",
|
|
629
|
+
" await expect(page).toHaveURL(/./);",
|
|
630
|
+
"});"
|
|
631
|
+
].join("\n"),
|
|
632
|
+
locators: [],
|
|
633
|
+
coverage: request.manualCase.steps.map((step) => ({
|
|
634
|
+
stepIndex: step.index,
|
|
635
|
+
status: "partial",
|
|
636
|
+
notes: "Template provider preserved the manual step as a draft comment."
|
|
637
|
+
})),
|
|
638
|
+
warnings: ["Template provider generated a draft without AI locator reasoning. Review before committing."]
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
async function generatePlaywrightTestDraft(request) {
|
|
643
|
+
assertBrowserGenerationAllowed(request);
|
|
644
|
+
const outputDir = request.outputDir || "generated-tests";
|
|
645
|
+
const source = safeSegment(request.sourceName || defaultSource(request.manualCase), "manual");
|
|
646
|
+
const caseKey = safeSegment(request.manualCase.key || request.manualCase.id, "case");
|
|
647
|
+
const caseDir = import_node_path2.default.join(outputDir, source);
|
|
648
|
+
const filePath = import_node_path2.default.join(caseDir, `${caseKey}.spec.ts`);
|
|
649
|
+
const reportPath = import_node_path2.default.join(caseDir, `${caseKey}.generation-report.json`);
|
|
650
|
+
const screenshotPath = import_node_path2.default.join(caseDir, `${caseKey}.png`);
|
|
651
|
+
await (0, import_promises.mkdir)(caseDir, { recursive: true });
|
|
652
|
+
const browser = await import_test.chromium.launch({ headless: true });
|
|
653
|
+
try {
|
|
654
|
+
const context = await browser.newContext(request.storageState ? { storageState: request.storageState } : {});
|
|
655
|
+
try {
|
|
656
|
+
const page = await context.newPage();
|
|
657
|
+
await page.goto(request.url, { waitUntil: "domcontentloaded" });
|
|
658
|
+
const screenshot = await page.screenshot({ fullPage: true });
|
|
659
|
+
const [title, html, bodyText] = await Promise.all([
|
|
660
|
+
page.title(),
|
|
661
|
+
page.content(),
|
|
662
|
+
page.locator("body").innerText({ timeout: 2e3 }).catch(() => "")
|
|
663
|
+
]);
|
|
664
|
+
await (0, import_promises.writeFile)(screenshotPath, screenshot);
|
|
665
|
+
const browserEvidence = {
|
|
666
|
+
requestedUrl: request.url,
|
|
667
|
+
currentUrl: page.url(),
|
|
668
|
+
title,
|
|
669
|
+
domSummary: truncate(bodyText, 8e3),
|
|
670
|
+
htmlSnippet: truncate(html, 12e3),
|
|
671
|
+
screenshotBase64: screenshot.toString("base64")
|
|
672
|
+
};
|
|
673
|
+
const generated = await request.provider.generateTest({
|
|
674
|
+
manualCase: request.manualCase,
|
|
675
|
+
url: request.url,
|
|
676
|
+
browser: browserEvidence,
|
|
677
|
+
outputLanguage: request.outputLanguage
|
|
678
|
+
});
|
|
679
|
+
const draft = {
|
|
680
|
+
manualCase: request.manualCase,
|
|
681
|
+
filePath,
|
|
682
|
+
reportPath,
|
|
683
|
+
screenshotPath,
|
|
684
|
+
code: generated.code,
|
|
685
|
+
locators: generated.locators || [],
|
|
686
|
+
coverage: generated.coverage || [],
|
|
687
|
+
warnings: generated.warnings || [],
|
|
688
|
+
browser: {
|
|
689
|
+
requestedUrl: browserEvidence.requestedUrl,
|
|
690
|
+
currentUrl: browserEvidence.currentUrl,
|
|
691
|
+
title: browserEvidence.title,
|
|
692
|
+
domSummary: browserEvidence.domSummary,
|
|
693
|
+
htmlSnippet: browserEvidence.htmlSnippet
|
|
694
|
+
},
|
|
695
|
+
needsHumanReview: true
|
|
696
|
+
};
|
|
697
|
+
const report = {
|
|
698
|
+
manualCase: request.manualCase,
|
|
699
|
+
browser: draft.browser,
|
|
700
|
+
locators: draft.locators,
|
|
701
|
+
coverage: draft.coverage,
|
|
702
|
+
warnings: draft.warnings,
|
|
703
|
+
needsHumanReview: draft.needsHumanReview,
|
|
704
|
+
artifacts: { filePath, reportPath, screenshotPath }
|
|
705
|
+
};
|
|
706
|
+
await (0, import_promises.writeFile)(filePath, `${generated.code.trimEnd()}
|
|
707
|
+
`, "utf8");
|
|
708
|
+
await (0, import_promises.writeFile)(reportPath, `${JSON.stringify(report, null, 2)}
|
|
709
|
+
`, "utf8");
|
|
710
|
+
return draft;
|
|
711
|
+
} finally {
|
|
712
|
+
await context.close();
|
|
713
|
+
}
|
|
714
|
+
} finally {
|
|
715
|
+
await browser.close();
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// src/normalize.ts
|
|
720
|
+
function walkSuites(suites, visitor) {
|
|
721
|
+
for (const suite of suites || []) {
|
|
722
|
+
for (const spec of suite.specs || []) {
|
|
723
|
+
visitor(spec, suite);
|
|
724
|
+
}
|
|
725
|
+
walkSuites(suite.suites || [], visitor);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
function resultError(result) {
|
|
729
|
+
return result?.error?.message || result?.error?.stack || result?.errors?.[0]?.message || result?.errors?.[0]?.stack || "Unknown error";
|
|
730
|
+
}
|
|
731
|
+
function normalizeSteps(result) {
|
|
732
|
+
const collected = [];
|
|
733
|
+
const visit = (step, insideUserStep) => {
|
|
734
|
+
if (!step) return;
|
|
735
|
+
const category = step.category;
|
|
736
|
+
const isUserStep = category === "test.step";
|
|
737
|
+
const isExpect = category === "expect";
|
|
738
|
+
if ((isUserStep || isExpect) && !insideUserStep && step.title) {
|
|
739
|
+
collected.push(String(step.title));
|
|
740
|
+
}
|
|
741
|
+
for (const child of step.steps || []) {
|
|
742
|
+
visit(child, insideUserStep || isUserStep);
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
for (const step of result?.steps || []) {
|
|
746
|
+
visit(step, false);
|
|
747
|
+
}
|
|
748
|
+
return collected;
|
|
749
|
+
}
|
|
750
|
+
function attachmentType(contentType, name) {
|
|
751
|
+
if (contentType?.startsWith("image/")) return "screenshot";
|
|
752
|
+
if (contentType?.includes("zip")) return "trace";
|
|
753
|
+
if (contentType?.includes("video")) return "video";
|
|
754
|
+
if (name?.toLowerCase().includes("trace")) return "trace";
|
|
755
|
+
return name || "artifact";
|
|
756
|
+
}
|
|
757
|
+
function normalizeArtifacts(result) {
|
|
758
|
+
return (result?.attachments || []).filter((attachment) => attachment?.path).map((attachment) => ({
|
|
759
|
+
type: attachmentType(attachment.contentType, attachment.name),
|
|
760
|
+
path: String(attachment.path),
|
|
761
|
+
contentType: attachment.contentType
|
|
762
|
+
}));
|
|
763
|
+
}
|
|
764
|
+
function normalizePlaywrightJsonReport(report, options = {}) {
|
|
765
|
+
const failures = [];
|
|
766
|
+
const run = options.runId ? { id: options.runId } : void 0;
|
|
767
|
+
walkSuites(report?.suites || [], (spec) => {
|
|
768
|
+
for (const testCase of spec.tests || []) {
|
|
769
|
+
for (const result of testCase.results || []) {
|
|
770
|
+
if (!["failed", "timedOut", "interrupted"].includes(result.status)) {
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
failures.push({
|
|
774
|
+
title: String(spec.title || testCase.title || "unnamed test"),
|
|
775
|
+
file: String(spec.file || testCase.location?.file || "unknown"),
|
|
776
|
+
line: spec.line || testCase.location?.line,
|
|
777
|
+
project: testCase.projectName,
|
|
778
|
+
status: result.status,
|
|
779
|
+
retry: result.retry || 0,
|
|
780
|
+
durationMs: result.duration || 0,
|
|
781
|
+
error: resultError(result),
|
|
782
|
+
steps: normalizeSteps(result),
|
|
783
|
+
artifacts: normalizeArtifacts(result),
|
|
784
|
+
annotations: (testCase.annotations || []).map((annotation) => ({
|
|
785
|
+
type: String(annotation.type),
|
|
786
|
+
description: annotation.description
|
|
787
|
+
})),
|
|
788
|
+
run
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
return failures;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// src/privacy.ts
|
|
797
|
+
var SECRET_PATTERNS = [
|
|
798
|
+
[/\b(authorization\s*:\s*bearer)\s+[A-Za-z0-9._~+/-]+=*/gi, "$1 [REDACTED]"],
|
|
799
|
+
[/\b(cookie\s*:\s*)[^\n\r]+/gi, "$1[REDACTED]"],
|
|
800
|
+
[/\b(password|passwd|token|api[_-]?key|secret)=([^&\s]+)/gi, "$1=[REDACTED]"],
|
|
801
|
+
[/\b(password|passwd|token|api[_-]?key|secret)\s*:\s*["']?[^"',\s}]+/gi, "$1: [REDACTED]"]
|
|
802
|
+
];
|
|
803
|
+
function redactSensitiveText(text) {
|
|
804
|
+
return SECRET_PATTERNS.reduce((value, [pattern, replacement]) => value.replace(pattern, replacement), text);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// src/providers/openai.ts
|
|
808
|
+
var analysisSchema = {
|
|
809
|
+
type: "object",
|
|
810
|
+
properties: {
|
|
811
|
+
side: { type: "string", enum: ["app_bug", "test_bug", "flaky_test", "env_issue", "unknown"] },
|
|
812
|
+
rootCause: { type: "string" },
|
|
813
|
+
evidence: { type: "array", items: { type: "string" } },
|
|
814
|
+
reproductionSteps: { type: "array", items: { type: "string" } },
|
|
815
|
+
suspects: {
|
|
816
|
+
type: "object",
|
|
817
|
+
properties: {
|
|
818
|
+
selectors: { type: "array", items: { type: "string" } },
|
|
819
|
+
pages: { type: "array", items: { type: "string" } },
|
|
820
|
+
endpoints: { type: "array", items: { type: "string" } }
|
|
821
|
+
},
|
|
822
|
+
required: ["selectors", "pages", "endpoints"],
|
|
823
|
+
additionalProperties: false
|
|
824
|
+
},
|
|
825
|
+
fixSuggestions: { type: "array", items: { type: "string" } },
|
|
826
|
+
confidence: { type: "number", minimum: 0, maximum: 100 },
|
|
827
|
+
category: {
|
|
828
|
+
type: "string",
|
|
829
|
+
enum: ["selector", "timing", "runtime", "test_data", "visual", "interaction", "environment", "unknown"]
|
|
830
|
+
},
|
|
831
|
+
risk: { type: "string", enum: ["low", "medium", "high"] },
|
|
832
|
+
evidenceRefs: { type: "array", items: { type: "string" } },
|
|
833
|
+
confidenceReasons: { type: "array", items: { type: "string" } },
|
|
834
|
+
needsHumanReview: { type: "boolean" }
|
|
835
|
+
},
|
|
836
|
+
required: [
|
|
837
|
+
"side",
|
|
838
|
+
"rootCause",
|
|
839
|
+
"evidence",
|
|
840
|
+
"reproductionSteps",
|
|
841
|
+
"suspects",
|
|
842
|
+
"fixSuggestions",
|
|
843
|
+
"confidence",
|
|
844
|
+
"category",
|
|
845
|
+
"risk",
|
|
846
|
+
"evidenceRefs",
|
|
847
|
+
"confidenceReasons",
|
|
848
|
+
"needsHumanReview"
|
|
849
|
+
],
|
|
850
|
+
additionalProperties: false
|
|
851
|
+
};
|
|
852
|
+
var generationSchema = {
|
|
853
|
+
type: "object",
|
|
854
|
+
properties: {
|
|
855
|
+
code: { type: "string" },
|
|
856
|
+
locators: {
|
|
857
|
+
type: "array",
|
|
858
|
+
items: {
|
|
859
|
+
type: "object",
|
|
860
|
+
properties: {
|
|
861
|
+
stepIndex: { type: "number" },
|
|
862
|
+
selector: { type: "string" },
|
|
863
|
+
reason: { type: "string" }
|
|
864
|
+
},
|
|
865
|
+
required: ["selector", "reason"],
|
|
866
|
+
additionalProperties: false
|
|
867
|
+
}
|
|
868
|
+
},
|
|
869
|
+
coverage: {
|
|
870
|
+
type: "array",
|
|
871
|
+
items: {
|
|
872
|
+
type: "object",
|
|
873
|
+
properties: {
|
|
874
|
+
stepIndex: { type: "number" },
|
|
875
|
+
status: { type: "string", enum: ["covered", "partial", "missing"] },
|
|
876
|
+
notes: { type: "string" }
|
|
877
|
+
},
|
|
878
|
+
required: ["status", "notes"],
|
|
879
|
+
additionalProperties: false
|
|
880
|
+
}
|
|
881
|
+
},
|
|
882
|
+
warnings: { type: "array", items: { type: "string" } }
|
|
883
|
+
},
|
|
884
|
+
required: ["code", "locators", "coverage", "warnings"],
|
|
885
|
+
additionalProperties: false
|
|
886
|
+
};
|
|
887
|
+
function normalizeAnalysis(data, usage) {
|
|
888
|
+
return {
|
|
889
|
+
side: data.side || "unknown",
|
|
890
|
+
rootCause: data.rootCause || data.root_cause || "No root cause returned",
|
|
891
|
+
evidence: data.evidence || [],
|
|
892
|
+
reproductionSteps: data.reproductionSteps || data.reproduction_steps || [],
|
|
893
|
+
suspects: data.suspects || { selectors: [], pages: [], endpoints: [] },
|
|
894
|
+
fixSuggestions: data.fixSuggestions || data.fix || [],
|
|
895
|
+
confidence: Number(data.confidence ?? 0),
|
|
896
|
+
category: data.category || "unknown",
|
|
897
|
+
risk: data.risk || "high",
|
|
898
|
+
evidenceRefs: data.evidenceRefs || [],
|
|
899
|
+
confidenceReasons: data.confidenceReasons || [],
|
|
900
|
+
needsHumanReview: data.needsHumanReview ?? true,
|
|
901
|
+
source: "ai",
|
|
902
|
+
tokenUsage: usage ? {
|
|
903
|
+
inputTokens: usage.input_tokens || usage.inputTokens || 0,
|
|
904
|
+
outputTokens: usage.output_tokens || usage.outputTokens || 0,
|
|
905
|
+
totalTokens: usage.total_tokens || usage.totalTokens || 0
|
|
906
|
+
} : void 0
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
function normalizeGeneratedTest(data) {
|
|
910
|
+
return {
|
|
911
|
+
code: String(data.code || ""),
|
|
912
|
+
locators: Array.isArray(data.locators) ? data.locators : [],
|
|
913
|
+
coverage: Array.isArray(data.coverage) ? data.coverage : [],
|
|
914
|
+
warnings: Array.isArray(data.warnings) ? data.warnings : []
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
function outputText(response) {
|
|
918
|
+
if (response.output_text) return response.output_text;
|
|
919
|
+
for (const item of response.output || []) {
|
|
920
|
+
for (const content of item.content || []) {
|
|
921
|
+
if (content.text) return content.text;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return "";
|
|
925
|
+
}
|
|
926
|
+
function compactFailure(failure) {
|
|
927
|
+
return {
|
|
928
|
+
title: failure.title,
|
|
929
|
+
file: failure.file,
|
|
930
|
+
line: failure.line,
|
|
931
|
+
project: failure.project,
|
|
932
|
+
status: failure.status,
|
|
933
|
+
retry: failure.retry,
|
|
934
|
+
durationMs: failure.durationMs,
|
|
935
|
+
error: failure.error,
|
|
936
|
+
steps: failure.steps || [],
|
|
937
|
+
artifacts: failure.artifacts || [],
|
|
938
|
+
annotations: failure.annotations || [],
|
|
939
|
+
browserContext: failure.browserContext,
|
|
940
|
+
page: failure.page,
|
|
941
|
+
consoleErrors: failure.consoleErrors || [],
|
|
942
|
+
networkFailures: failure.networkFailures || [],
|
|
943
|
+
lastActions: failure.lastActions || [],
|
|
944
|
+
artifactRefs: failure.artifactRefs || failure.artifacts || []
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
var OpenAIResponsesProvider = class {
|
|
948
|
+
constructor(options = {}) {
|
|
949
|
+
this.apiKey = options.apiKey ?? process.env.OPENAI_API_KEY ?? "";
|
|
950
|
+
this.model = options.model ?? process.env.PW_AI_MODEL ?? process.env.AI_MODEL ?? "gpt-4.1-mini";
|
|
951
|
+
this.baseUrl = options.baseUrl ?? "https://api.openai.com/v1/responses";
|
|
952
|
+
this.transport = options.transport ?? ((url, init) => fetch(url, init));
|
|
953
|
+
}
|
|
954
|
+
async analyzeFailure(request) {
|
|
955
|
+
if (!this.apiKey) {
|
|
956
|
+
throw new Error("OPENAI_API_KEY is required for OpenAIResponsesProvider");
|
|
957
|
+
}
|
|
958
|
+
const payload = {
|
|
959
|
+
model: this.model,
|
|
960
|
+
instructions: `You are a generic Playwright failure analyzer. Return concise ${request.outputLanguage} JSON only. Use only the supplied test failure, trace evidence, steps, artifacts, and similar analyses. Every conclusion must point to supplied evidenceRefs.`,
|
|
961
|
+
input: JSON.stringify({
|
|
962
|
+
failure: compactFailure(request.failure),
|
|
963
|
+
similarAnalyses: request.similarAnalyses || []
|
|
964
|
+
}),
|
|
965
|
+
text: {
|
|
966
|
+
format: {
|
|
967
|
+
type: "json_schema",
|
|
968
|
+
name: "playwright_failure_analysis",
|
|
969
|
+
strict: true,
|
|
970
|
+
schema: analysisSchema
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
const response = await this.transport(this.baseUrl, {
|
|
975
|
+
method: "POST",
|
|
976
|
+
headers: {
|
|
977
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
978
|
+
"content-type": "application/json"
|
|
979
|
+
},
|
|
980
|
+
body: JSON.stringify(payload)
|
|
981
|
+
});
|
|
982
|
+
if (!response.ok) {
|
|
983
|
+
throw new Error(`OpenAI Responses request failed with status ${response.status}`);
|
|
984
|
+
}
|
|
985
|
+
const body = await response.json();
|
|
986
|
+
const rawText = outputText(body);
|
|
987
|
+
if (!rawText) {
|
|
988
|
+
throw new Error("OpenAI Responses payload did not contain output text");
|
|
989
|
+
}
|
|
990
|
+
return normalizeAnalysis(JSON.parse(rawText), body.usage);
|
|
991
|
+
}
|
|
992
|
+
async generateTest(request) {
|
|
993
|
+
if (!this.apiKey) {
|
|
994
|
+
throw new Error("OPENAI_API_KEY is required for OpenAIResponsesProvider");
|
|
995
|
+
}
|
|
996
|
+
const browser = {
|
|
997
|
+
requestedUrl: request.browser.requestedUrl,
|
|
998
|
+
currentUrl: request.browser.currentUrl,
|
|
999
|
+
title: request.browser.title,
|
|
1000
|
+
domSummary: request.browser.domSummary,
|
|
1001
|
+
htmlSnippet: request.browser.htmlSnippet
|
|
1002
|
+
};
|
|
1003
|
+
const content = [
|
|
1004
|
+
{
|
|
1005
|
+
type: "input_text",
|
|
1006
|
+
text: JSON.stringify({
|
|
1007
|
+
manualCase: request.manualCase,
|
|
1008
|
+
url: request.url,
|
|
1009
|
+
browser
|
|
1010
|
+
})
|
|
1011
|
+
}
|
|
1012
|
+
];
|
|
1013
|
+
if (request.browser.screenshotBase64) {
|
|
1014
|
+
content.push({
|
|
1015
|
+
type: "input_image",
|
|
1016
|
+
image_url: `data:image/png;base64,${request.browser.screenshotBase64}`
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
const payload = {
|
|
1020
|
+
model: this.model,
|
|
1021
|
+
instructions: `You generate review-only Playwright tests from manual test cases. Return concise ${request.outputLanguage || "ru"} JSON only. Use only the supplied manual steps, browser DOM evidence, URL, and screenshot. Prefer user-facing locators and data-testid selectors when the evidence supports them. Do not weaken assertions, do not invent credentials, and include warnings for any uncertain step.`,
|
|
1022
|
+
input: [{ role: "user", content }],
|
|
1023
|
+
text: {
|
|
1024
|
+
format: {
|
|
1025
|
+
type: "json_schema",
|
|
1026
|
+
name: "playwright_test_generation",
|
|
1027
|
+
strict: true,
|
|
1028
|
+
schema: generationSchema
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
const response = await this.transport(this.baseUrl, {
|
|
1033
|
+
method: "POST",
|
|
1034
|
+
headers: {
|
|
1035
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
1036
|
+
"content-type": "application/json"
|
|
1037
|
+
},
|
|
1038
|
+
body: JSON.stringify(payload)
|
|
1039
|
+
});
|
|
1040
|
+
if (!response.ok) {
|
|
1041
|
+
throw new Error(`OpenAI Responses request failed with status ${response.status}`);
|
|
1042
|
+
}
|
|
1043
|
+
const body = await response.json();
|
|
1044
|
+
const rawText = outputText(body);
|
|
1045
|
+
if (!rawText) {
|
|
1046
|
+
throw new Error("OpenAI Responses payload did not contain output text");
|
|
1047
|
+
}
|
|
1048
|
+
return normalizeGeneratedTest(JSON.parse(rawText));
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
// src/stores/sqlite.ts
|
|
1053
|
+
var import_promises2 = require("fs/promises");
|
|
1054
|
+
var import_node_path3 = __toESM(require("path"), 1);
|
|
1055
|
+
var import_sql = __toESM(require("sql.js"), 1);
|
|
1056
|
+
var sqlModule;
|
|
1057
|
+
function sql() {
|
|
1058
|
+
sqlModule || (sqlModule = (0, import_sql.default)());
|
|
1059
|
+
return sqlModule;
|
|
1060
|
+
}
|
|
1061
|
+
function now() {
|
|
1062
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1063
|
+
}
|
|
1064
|
+
function normalizeTags(tags) {
|
|
1065
|
+
return [...new Set(tags || [])].filter(Boolean).sort();
|
|
1066
|
+
}
|
|
1067
|
+
function parsePayload(value) {
|
|
1068
|
+
return typeof value === "string" ? JSON.parse(value) : value;
|
|
1069
|
+
}
|
|
1070
|
+
function hasSQLiteHeader(buffer) {
|
|
1071
|
+
return buffer.subarray(0, 16).toString("utf8") === "SQLite format 3\0";
|
|
1072
|
+
}
|
|
1073
|
+
var SQLiteStore = class {
|
|
1074
|
+
constructor(options = {}) {
|
|
1075
|
+
this.loaded = false;
|
|
1076
|
+
this.filePath = options.path || import_node_path3.default.join(process.cwd(), ".playwright-ai-tools", "ai-tools.sqlite");
|
|
1077
|
+
this.inMemory = this.filePath === ":memory:";
|
|
1078
|
+
}
|
|
1079
|
+
async migrate() {
|
|
1080
|
+
const db = await this.load();
|
|
1081
|
+
db.run(`
|
|
1082
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
1083
|
+
id TEXT PRIMARY KEY,
|
|
1084
|
+
payload TEXT NOT NULL,
|
|
1085
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
CREATE TABLE IF NOT EXISTS failures (
|
|
1089
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1090
|
+
signature TEXT,
|
|
1091
|
+
file TEXT,
|
|
1092
|
+
title TEXT,
|
|
1093
|
+
run_id TEXT,
|
|
1094
|
+
payload TEXT NOT NULL,
|
|
1095
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
1096
|
+
);
|
|
1097
|
+
|
|
1098
|
+
CREATE TABLE IF NOT EXISTS analyses (
|
|
1099
|
+
signature TEXT PRIMARY KEY,
|
|
1100
|
+
payload TEXT NOT NULL,
|
|
1101
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
1102
|
+
);
|
|
1103
|
+
|
|
1104
|
+
CREATE TABLE IF NOT EXISTS memory_records (
|
|
1105
|
+
id TEXT PRIMARY KEY,
|
|
1106
|
+
namespace TEXT NOT NULL,
|
|
1107
|
+
kind TEXT NOT NULL,
|
|
1108
|
+
key TEXT NOT NULL,
|
|
1109
|
+
tags TEXT NOT NULL,
|
|
1110
|
+
payload TEXT NOT NULL,
|
|
1111
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
1112
|
+
);
|
|
1113
|
+
|
|
1114
|
+
CREATE TABLE IF NOT EXISTS feedback_records (
|
|
1115
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1116
|
+
signature TEXT NOT NULL,
|
|
1117
|
+
decision TEXT NOT NULL,
|
|
1118
|
+
reason TEXT,
|
|
1119
|
+
payload TEXT NOT NULL,
|
|
1120
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
CREATE INDEX IF NOT EXISTS idx_failures_signature ON failures(signature);
|
|
1124
|
+
CREATE INDEX IF NOT EXISTS idx_failures_file ON failures(file);
|
|
1125
|
+
CREATE INDEX IF NOT EXISTS idx_memory_namespace_kind ON memory_records(namespace, kind);
|
|
1126
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_signature ON feedback_records(signature);
|
|
1127
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_decision ON feedback_records(decision);
|
|
1128
|
+
CREATE INDEX IF NOT EXISTS idx_analyses_category ON analyses(json_extract(payload, '$.category'));
|
|
1129
|
+
CREATE INDEX IF NOT EXISTS idx_analyses_risk ON analyses(json_extract(payload, '$.risk'));
|
|
1130
|
+
CREATE INDEX IF NOT EXISTS idx_failures_error ON failures(json_extract(payload, '$.error'));
|
|
1131
|
+
`);
|
|
1132
|
+
await this.persist();
|
|
1133
|
+
}
|
|
1134
|
+
async saveRun(run) {
|
|
1135
|
+
const db = await this.load();
|
|
1136
|
+
db.run(
|
|
1137
|
+
"INSERT OR REPLACE INTO runs (id, payload) VALUES (?, ?)",
|
|
1138
|
+
[run.id, JSON.stringify(run)]
|
|
1139
|
+
);
|
|
1140
|
+
await this.persist();
|
|
1141
|
+
}
|
|
1142
|
+
async saveFailure(failure) {
|
|
1143
|
+
const db = await this.load();
|
|
1144
|
+
const signature = failureSignature(failure);
|
|
1145
|
+
const runId = typeof failure.run?.id === "string" ? failure.run.id : null;
|
|
1146
|
+
db.run(
|
|
1147
|
+
"INSERT INTO failures (signature, file, title, run_id, payload) VALUES (?, ?, ?, ?, ?)",
|
|
1148
|
+
[signature, failure.file, failure.title, runId, JSON.stringify(failure)]
|
|
1149
|
+
);
|
|
1150
|
+
await this.persist();
|
|
1151
|
+
}
|
|
1152
|
+
async saveAnalysis(signature, analysis) {
|
|
1153
|
+
const db = await this.load();
|
|
1154
|
+
db.run(
|
|
1155
|
+
`
|
|
1156
|
+
INSERT INTO analyses (signature, payload, updated_at)
|
|
1157
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
1158
|
+
ON CONFLICT(signature) DO UPDATE SET payload = excluded.payload, updated_at = CURRENT_TIMESTAMP
|
|
1159
|
+
`,
|
|
1160
|
+
[signature, JSON.stringify(analysis)]
|
|
1161
|
+
);
|
|
1162
|
+
await this.persist();
|
|
1163
|
+
}
|
|
1164
|
+
async getCachedAnalysis(signature) {
|
|
1165
|
+
const rows = await this.all("SELECT payload FROM analyses WHERE signature = ? LIMIT 1", [signature]);
|
|
1166
|
+
return rows[0]?.payload ? { ...parsePayload(rows[0].payload), source: "cache" } : null;
|
|
1167
|
+
}
|
|
1168
|
+
async upsertMemory(record) {
|
|
1169
|
+
const db = await this.load();
|
|
1170
|
+
const id = `${record.namespace}:${record.kind}:${record.key}`;
|
|
1171
|
+
const existing = await this.searchMemory({
|
|
1172
|
+
namespace: record.namespace,
|
|
1173
|
+
kind: record.kind,
|
|
1174
|
+
query: record.key,
|
|
1175
|
+
limit: 100
|
|
1176
|
+
});
|
|
1177
|
+
const previous = existing.find((item) => item.key === record.key);
|
|
1178
|
+
const memory = {
|
|
1179
|
+
namespace: record.namespace,
|
|
1180
|
+
kind: record.kind,
|
|
1181
|
+
key: record.key,
|
|
1182
|
+
value: record.value,
|
|
1183
|
+
tags: normalizeTags(record.tags),
|
|
1184
|
+
confidence: record.confidence ?? 1,
|
|
1185
|
+
source: record.source,
|
|
1186
|
+
supersededBy: previous?.supersededBy ?? null,
|
|
1187
|
+
createdAt: previous?.createdAt ?? now(),
|
|
1188
|
+
updatedAt: now()
|
|
1189
|
+
};
|
|
1190
|
+
db.run(
|
|
1191
|
+
`
|
|
1192
|
+
INSERT INTO memory_records (id, namespace, kind, key, tags, payload, updated_at)
|
|
1193
|
+
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
1194
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1195
|
+
namespace = excluded.namespace,
|
|
1196
|
+
kind = excluded.kind,
|
|
1197
|
+
key = excluded.key,
|
|
1198
|
+
tags = excluded.tags,
|
|
1199
|
+
payload = excluded.payload,
|
|
1200
|
+
updated_at = CURRENT_TIMESTAMP
|
|
1201
|
+
`,
|
|
1202
|
+
[id, memory.namespace, memory.kind, memory.key, JSON.stringify(memory.tags), JSON.stringify(memory)]
|
|
1203
|
+
);
|
|
1204
|
+
await this.persist();
|
|
1205
|
+
}
|
|
1206
|
+
async searchMemory(input) {
|
|
1207
|
+
const rows = await this.all("SELECT payload FROM memory_records ORDER BY updated_at DESC");
|
|
1208
|
+
const query = input.query?.toLowerCase().trim();
|
|
1209
|
+
const tags = new Set(input.tags || []);
|
|
1210
|
+
return rows.map((row) => parsePayload(row.payload)).filter((record) => {
|
|
1211
|
+
if (input.namespace && record.namespace !== input.namespace) return false;
|
|
1212
|
+
if (input.kind && record.kind !== input.kind) return false;
|
|
1213
|
+
if (tags.size > 0 && !record.tags.some((tag) => tags.has(tag))) return false;
|
|
1214
|
+
if (query && !JSON.stringify([record.key, record.kind, record.source, record.tags, record.value]).toLowerCase().includes(query)) {
|
|
1215
|
+
return false;
|
|
1216
|
+
}
|
|
1217
|
+
return !record.supersededBy;
|
|
1218
|
+
}).slice(0, input.limit ?? 20);
|
|
1219
|
+
}
|
|
1220
|
+
async recordFeedback(input) {
|
|
1221
|
+
const db = await this.load();
|
|
1222
|
+
const feedback = {
|
|
1223
|
+
signature: input.signature,
|
|
1224
|
+
decision: input.decision,
|
|
1225
|
+
reason: input.reason || "",
|
|
1226
|
+
metadata: input.metadata || {},
|
|
1227
|
+
createdAt: now()
|
|
1228
|
+
};
|
|
1229
|
+
db.run(
|
|
1230
|
+
"INSERT INTO feedback_records (signature, decision, reason, payload) VALUES (?, ?, ?, ?)",
|
|
1231
|
+
[feedback.signature, feedback.decision, feedback.reason, JSON.stringify(feedback)]
|
|
1232
|
+
);
|
|
1233
|
+
await this.persist();
|
|
1234
|
+
}
|
|
1235
|
+
async searchFeedback(input) {
|
|
1236
|
+
const rows = await this.all("SELECT payload FROM feedback_records ORDER BY created_at DESC");
|
|
1237
|
+
const query = input.query?.toLowerCase().trim();
|
|
1238
|
+
return rows.map((row) => parsePayload(row.payload)).filter((record) => {
|
|
1239
|
+
if (input.signature && record.signature !== input.signature) return false;
|
|
1240
|
+
if (input.decision && record.decision !== input.decision) return false;
|
|
1241
|
+
if (query && !JSON.stringify(record).toLowerCase().includes(query)) return false;
|
|
1242
|
+
return true;
|
|
1243
|
+
}).slice(0, input.limit ?? 20);
|
|
1244
|
+
}
|
|
1245
|
+
async findFailures(input) {
|
|
1246
|
+
const rows = await this.all("SELECT payload FROM failures ORDER BY id DESC");
|
|
1247
|
+
const query = input.query?.toLowerCase().trim();
|
|
1248
|
+
return rows.map((row) => parsePayload(row.payload)).filter((failure) => {
|
|
1249
|
+
if (input.signature && failureSignature(failure) !== input.signature) return false;
|
|
1250
|
+
if (input.file && failure.file !== input.file) return false;
|
|
1251
|
+
if (query && !JSON.stringify(failure).toLowerCase().includes(query)) return false;
|
|
1252
|
+
return true;
|
|
1253
|
+
}).slice(0, input.limit ?? 20);
|
|
1254
|
+
}
|
|
1255
|
+
async getRun(id) {
|
|
1256
|
+
const rows = await this.all("SELECT payload FROM runs WHERE id = ? LIMIT 1", [id]);
|
|
1257
|
+
return rows[0]?.payload ? parsePayload(rows[0].payload) : null;
|
|
1258
|
+
}
|
|
1259
|
+
async stats() {
|
|
1260
|
+
const tables = ["runs", "failures", "analyses", "memory_records", "feedback_records"];
|
|
1261
|
+
const stats = {};
|
|
1262
|
+
for (const table of tables) {
|
|
1263
|
+
const rows = await this.all(`SELECT COUNT(*) AS count FROM ${table}`);
|
|
1264
|
+
stats[table === "memory_records" ? "memoryRecords" : table === "feedback_records" ? "feedbackRecords" : table] = Number(rows[0]?.count || 0);
|
|
1265
|
+
}
|
|
1266
|
+
return stats;
|
|
1267
|
+
}
|
|
1268
|
+
async close() {
|
|
1269
|
+
if (!this.db) return;
|
|
1270
|
+
await this.persist();
|
|
1271
|
+
this.db.close();
|
|
1272
|
+
this.db = void 0;
|
|
1273
|
+
this.loaded = false;
|
|
1274
|
+
}
|
|
1275
|
+
async load() {
|
|
1276
|
+
if (this.loaded && this.db) return this.db;
|
|
1277
|
+
this.loaded = true;
|
|
1278
|
+
const SQL = await sql();
|
|
1279
|
+
if (this.inMemory) {
|
|
1280
|
+
this.db = new SQL.Database();
|
|
1281
|
+
await this.migrate();
|
|
1282
|
+
return this.db;
|
|
1283
|
+
}
|
|
1284
|
+
try {
|
|
1285
|
+
const buffer = await (0, import_promises2.readFile)(this.filePath);
|
|
1286
|
+
if (buffer.length === 0) {
|
|
1287
|
+
this.db = new SQL.Database();
|
|
1288
|
+
await this.migrate();
|
|
1289
|
+
} else if (hasSQLiteHeader(buffer)) {
|
|
1290
|
+
this.db = new SQL.Database(buffer);
|
|
1291
|
+
} else {
|
|
1292
|
+
this.db = new SQL.Database();
|
|
1293
|
+
await this.migrate();
|
|
1294
|
+
await this.importSnapshot(JSON.parse(buffer.toString("utf8")));
|
|
1295
|
+
}
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
if (error?.code !== "ENOENT") {
|
|
1298
|
+
throw error;
|
|
1299
|
+
}
|
|
1300
|
+
this.db = new SQL.Database();
|
|
1301
|
+
await this.migrate();
|
|
1302
|
+
}
|
|
1303
|
+
return this.db;
|
|
1304
|
+
}
|
|
1305
|
+
async importSnapshot(snapshot) {
|
|
1306
|
+
for (const run of snapshot.runs || []) {
|
|
1307
|
+
await this.saveRun(run);
|
|
1308
|
+
}
|
|
1309
|
+
for (const failure of snapshot.failures || []) {
|
|
1310
|
+
await this.saveFailure(failure);
|
|
1311
|
+
}
|
|
1312
|
+
for (const [signature, analysis] of snapshot.analyses || []) {
|
|
1313
|
+
await this.saveAnalysis(signature, analysis);
|
|
1314
|
+
}
|
|
1315
|
+
for (const [, memory] of snapshot.memory || []) {
|
|
1316
|
+
await this.upsertMemory(memory);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
async all(sqlText, params = []) {
|
|
1320
|
+
const db = await this.load();
|
|
1321
|
+
const statement = db.prepare(sqlText, params);
|
|
1322
|
+
const rows = [];
|
|
1323
|
+
try {
|
|
1324
|
+
while (statement.step()) {
|
|
1325
|
+
rows.push(statement.getAsObject());
|
|
1326
|
+
}
|
|
1327
|
+
} finally {
|
|
1328
|
+
statement.free();
|
|
1329
|
+
}
|
|
1330
|
+
return rows;
|
|
1331
|
+
}
|
|
1332
|
+
async persist() {
|
|
1333
|
+
if (!this.db || this.inMemory) return;
|
|
1334
|
+
await (0, import_promises2.mkdir)(import_node_path3.default.dirname(this.filePath), { recursive: true });
|
|
1335
|
+
await (0, import_promises2.writeFile)(this.filePath, Buffer.from(this.db.export()));
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
// src/report.ts
|
|
1340
|
+
var import_promises3 = require("fs/promises");
|
|
1341
|
+
var import_node_path4 = __toESM(require("path"), 1);
|
|
1342
|
+
|
|
1343
|
+
// src/grouper.ts
|
|
1344
|
+
var FailureGrouper = class {
|
|
1345
|
+
group(failures) {
|
|
1346
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1347
|
+
for (const failure of failures) {
|
|
1348
|
+
const signature = failureSignature(failure);
|
|
1349
|
+
groups.set(signature, [...groups.get(signature) || [], failure]);
|
|
1350
|
+
}
|
|
1351
|
+
return [...groups.entries()].map(([signature, groupedFailures]) => ({
|
|
1352
|
+
signature,
|
|
1353
|
+
pattern: readablePattern(groupedFailures[0]?.error || ""),
|
|
1354
|
+
failures: groupedFailures
|
|
1355
|
+
})).sort((a, b) => b.failures.length - a.failures.length);
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
// src/report.ts
|
|
1360
|
+
function escapeHtml(value) {
|
|
1361
|
+
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1362
|
+
}
|
|
1363
|
+
function normalizedReport(report) {
|
|
1364
|
+
return {
|
|
1365
|
+
...report,
|
|
1366
|
+
suggestions: report.suggestions || report.failures.map((failure, index) => {
|
|
1367
|
+
const analysis = report.analyses[index];
|
|
1368
|
+
return analysis ? generatePatchSuggestions(failure, analysis) : [];
|
|
1369
|
+
})
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
function markdownReport(report) {
|
|
1373
|
+
const portable = normalizedReport(report);
|
|
1374
|
+
const lines = [
|
|
1375
|
+
"# Playwright AI Analysis Report",
|
|
1376
|
+
"",
|
|
1377
|
+
`Run: ${portable.run.id}`,
|
|
1378
|
+
`Failures: ${portable.failures.length}`,
|
|
1379
|
+
""
|
|
1380
|
+
];
|
|
1381
|
+
portable.failures.forEach((failure, index) => {
|
|
1382
|
+
const analysis = portable.analyses[index];
|
|
1383
|
+
const suggestions = portable.suggestions[index] || [];
|
|
1384
|
+
lines.push(`## ${failure.title}`);
|
|
1385
|
+
lines.push("");
|
|
1386
|
+
lines.push(`- File: ${failure.file}${failure.line ? `:${failure.line}` : ""}`);
|
|
1387
|
+
lines.push(`- Category: ${analysis?.category || "unknown"}`);
|
|
1388
|
+
lines.push(`- Side: ${analysis?.side || "unknown"}`);
|
|
1389
|
+
lines.push(`- Risk: ${analysis?.risk || "high"}`);
|
|
1390
|
+
lines.push(`- Confidence: ${analysis?.confidence ?? 0}`);
|
|
1391
|
+
lines.push(`- Root cause: ${analysis?.rootCause || "n/a"}`);
|
|
1392
|
+
if (analysis?.evidenceRefs?.length) {
|
|
1393
|
+
lines.push(`- Evidence refs: ${analysis.evidenceRefs.join(", ")}`);
|
|
1394
|
+
}
|
|
1395
|
+
if (failure.consoleErrors?.length || failure.networkFailures?.length) {
|
|
1396
|
+
lines.push("- Evidence:");
|
|
1397
|
+
for (const item of failure.consoleErrors || []) lines.push(` - Console ${item.level}: ${item.text}`);
|
|
1398
|
+
for (const item of failure.networkFailures || []) {
|
|
1399
|
+
lines.push(` - Network ${item.status || item.errorText}: ${item.method || "GET"} ${item.url}`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
if (suggestions.length) {
|
|
1403
|
+
lines.push("");
|
|
1404
|
+
lines.push("### Suggested Fixes");
|
|
1405
|
+
for (const suggestion of suggestions) {
|
|
1406
|
+
lines.push(`- ${suggestion.policyDecision}: ${suggestion.explanation} (${suggestion.reviewReason})`);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
lines.push("");
|
|
1410
|
+
});
|
|
1411
|
+
return `${lines.join("\n")}
|
|
1412
|
+
`;
|
|
1413
|
+
}
|
|
1414
|
+
function htmlReport(report) {
|
|
1415
|
+
const portable = normalizedReport(report);
|
|
1416
|
+
const groups = new FailureGrouper().group(portable.failures);
|
|
1417
|
+
const groupSummary = groups.map((group) => `<li><code>${escapeHtml(group.signature)}</code> ${group.failures.length} failure(s)</li>`).join("");
|
|
1418
|
+
const failures = portable.failures.map((failure, index) => {
|
|
1419
|
+
const analysis = portable.analyses[index];
|
|
1420
|
+
const suggestions = portable.suggestions[index] || [];
|
|
1421
|
+
const evidence2 = [
|
|
1422
|
+
...(failure.consoleErrors || []).map((item) => `Console ${item.level}: ${item.text}`),
|
|
1423
|
+
...(failure.networkFailures || []).map(
|
|
1424
|
+
(item) => `Network ${item.status || item.errorText}: ${item.method || "GET"} ${item.url}`
|
|
1425
|
+
),
|
|
1426
|
+
...(failure.lastActions || []).map((item) => `Action ${item.name}${item.selector ? ` ${item.selector}` : ""}`),
|
|
1427
|
+
...(failure.artifactRefs || failure.artifacts || []).map((item) => `Artifact ${item.type}: ${item.path}`)
|
|
1428
|
+
];
|
|
1429
|
+
return `<section class="failure" data-category="${escapeHtml(analysis?.category || "unknown")}">
|
|
1430
|
+
<h2>${escapeHtml(failure.title)}</h2>
|
|
1431
|
+
<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>
|
|
1432
|
+
<p>${escapeHtml(analysis?.rootCause || failure.error)}</p>
|
|
1433
|
+
<h3>Evidence</h3>
|
|
1434
|
+
<ul>${evidence2.map((item) => `<li>${escapeHtml(item)}</li>`).join("") || "<li>No extra evidence collected.</li>"}</ul>
|
|
1435
|
+
<h3>Suggested Fixes</h3>
|
|
1436
|
+
<ul>${suggestions.map(
|
|
1437
|
+
(suggestion) => `<li><strong>${escapeHtml(suggestion.policyDecision)}</strong> ${escapeHtml(suggestion.explanation)}<pre>${escapeHtml(suggestion.newSnippet)}</pre><small>${escapeHtml(suggestion.reviewReason)}</small></li>`
|
|
1438
|
+
).join("") || "<li>No suggestion available.</li>"}</ul>
|
|
1439
|
+
</section>`;
|
|
1440
|
+
}).join("\n");
|
|
1441
|
+
return `<!doctype html>
|
|
1442
|
+
<html lang="en">
|
|
1443
|
+
<head>
|
|
1444
|
+
<meta charset="utf-8">
|
|
1445
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1446
|
+
<title>Playwright AI Analysis Report</title>
|
|
1447
|
+
<style>
|
|
1448
|
+
body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;margin:0;background:#f7f7f4;color:#171717}
|
|
1449
|
+
header,main{max-width:1120px;margin:0 auto;padding:24px}
|
|
1450
|
+
header{border-bottom:1px solid #d8d8d2}
|
|
1451
|
+
.summary{display:flex;gap:16px;flex-wrap:wrap}
|
|
1452
|
+
.pill{border:1px solid #c9c9c1;border-radius:6px;padding:6px 10px;background:white}
|
|
1453
|
+
.failure{background:white;border:1px solid #d8d8d2;border-radius:8px;padding:18px;margin:18px 0}
|
|
1454
|
+
pre{white-space:pre-wrap;background:#111;color:#f5f5f5;border-radius:6px;padding:12px;overflow:auto}
|
|
1455
|
+
code{font-size:.9em}
|
|
1456
|
+
</style>
|
|
1457
|
+
</head>
|
|
1458
|
+
<body>
|
|
1459
|
+
<header>
|
|
1460
|
+
<h1>Playwright AI Analysis Report</h1>
|
|
1461
|
+
<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>
|
|
1462
|
+
</header>
|
|
1463
|
+
<main>
|
|
1464
|
+
<h2>Groups</h2>
|
|
1465
|
+
<ul>${groupSummary}</ul>
|
|
1466
|
+
${failures}
|
|
1467
|
+
</main>
|
|
1468
|
+
</body>
|
|
1469
|
+
</html>
|
|
1470
|
+
`;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// src/cli.ts
|
|
1474
|
+
function argValue(args, name) {
|
|
1475
|
+
const index = args.indexOf(name);
|
|
1476
|
+
return index >= 0 ? args[index + 1] : void 0;
|
|
1477
|
+
}
|
|
1478
|
+
function hasFlag(args, name) {
|
|
1479
|
+
return args.includes(name);
|
|
1480
|
+
}
|
|
1481
|
+
async function commandInit() {
|
|
1482
|
+
await (0, import_promises4.mkdir)(".playwright-ai-tools", { recursive: true });
|
|
1483
|
+
await (0, import_promises4.writeFile)(
|
|
1484
|
+
".playwright-ai-tools/config.json",
|
|
1485
|
+
`${JSON.stringify({ outputLanguage: process.env.PW_AI_OUTPUT_LANGUAGE || "ru", privacy: { redactSecrets: true } }, null, 2)}
|
|
1486
|
+
`,
|
|
1487
|
+
"utf8"
|
|
1488
|
+
);
|
|
1489
|
+
await (0, import_promises4.writeFile)(
|
|
1490
|
+
".playwright-ai-tools/knowledge.md",
|
|
1491
|
+
"# Project testing knowledge\n\nAdd selectors policy, test data rules, known flaky areas, and ownership notes here.\n",
|
|
1492
|
+
"utf8"
|
|
1493
|
+
);
|
|
1494
|
+
console.log("Created .playwright-ai-tools/config.json");
|
|
1495
|
+
}
|
|
1496
|
+
async function writeJson(output, payload) {
|
|
1497
|
+
const text = `${JSON.stringify(payload, null, 2)}
|
|
1498
|
+
`;
|
|
1499
|
+
if (!output) {
|
|
1500
|
+
console.log(text.trimEnd());
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
await (0, import_promises4.mkdir)(import_node_path5.default.dirname(output), { recursive: true });
|
|
1504
|
+
await (0, import_promises4.writeFile)(output, text, "utf8");
|
|
1505
|
+
}
|
|
1506
|
+
async function commandDoctor(args) {
|
|
1507
|
+
const configPath = argValue(args, "--config") || "playwright.config.ts";
|
|
1508
|
+
const output = argValue(args, "--output");
|
|
1509
|
+
let config = "";
|
|
1510
|
+
let configExists = true;
|
|
1511
|
+
try {
|
|
1512
|
+
config = await (0, import_promises4.readFile)(configPath, "utf8");
|
|
1513
|
+
} catch {
|
|
1514
|
+
configExists = false;
|
|
1515
|
+
}
|
|
1516
|
+
const checks = [
|
|
1517
|
+
{
|
|
1518
|
+
id: "playwright-config",
|
|
1519
|
+
status: configExists ? "pass" : "fail",
|
|
1520
|
+
message: configExists ? `Found ${configPath}` : `Missing ${configPath}`
|
|
1521
|
+
},
|
|
1522
|
+
{
|
|
1523
|
+
id: "trace",
|
|
1524
|
+
status: /trace\s*:\s*['"](?:on|on-first-retry|retain-on-failure)['"]/.test(config) ? "pass" : "warn",
|
|
1525
|
+
message: "Enable trace on retry or failure for useful evidence."
|
|
1526
|
+
},
|
|
1527
|
+
{
|
|
1528
|
+
id: "screenshot",
|
|
1529
|
+
status: /screenshot\s*:\s*['"](?:on|only-on-failure)['"]/.test(config) ? "pass" : "warn",
|
|
1530
|
+
message: "Enable screenshots on failure for visual evidence."
|
|
1531
|
+
},
|
|
1532
|
+
{
|
|
1533
|
+
id: "video",
|
|
1534
|
+
status: /video\s*:\s*['"](?:on|retain-on-failure|on-first-retry)['"]/.test(config) ? "pass" : "warn",
|
|
1535
|
+
message: "Enable video on failure when debugging interactions matters."
|
|
1536
|
+
},
|
|
1537
|
+
{
|
|
1538
|
+
id: "privacy",
|
|
1539
|
+
status: "pass",
|
|
1540
|
+
message: "Text knowledge imports and generated summaries redact common secrets by default."
|
|
1541
|
+
}
|
|
1542
|
+
];
|
|
1543
|
+
await writeJson(output, {
|
|
1544
|
+
status: checks.some((check) => check.status === "fail") ? "fail" : "pass",
|
|
1545
|
+
checks
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
async function commandKnowledgeImport(args) {
|
|
1549
|
+
const source = argValue(args, "--source");
|
|
1550
|
+
if (!source) throw new Error("Missing --source <path>");
|
|
1551
|
+
const database = argValue(args, "--database") || import_node_path5.default.join(".playwright-ai-tools", "ai-tools.sqlite");
|
|
1552
|
+
const namespace = argValue(args, "--namespace") || "default";
|
|
1553
|
+
const store = new SQLiteStore({ path: database });
|
|
1554
|
+
await store.migrate();
|
|
1555
|
+
const raw = await (0, import_promises4.readFile)(source, "utf8");
|
|
1556
|
+
const text = redactSensitiveText(raw);
|
|
1557
|
+
await store.upsertMemory({
|
|
1558
|
+
namespace,
|
|
1559
|
+
kind: "knowledge",
|
|
1560
|
+
key: import_node_path5.default.basename(source),
|
|
1561
|
+
value: { source, text },
|
|
1562
|
+
tags: ["knowledge", ...import_node_path5.default.basename(source).replace(/\.[^.]+$/, "").split(/[-_\s]+/).filter(Boolean)],
|
|
1563
|
+
confidence: 1,
|
|
1564
|
+
source: "knowledge-import"
|
|
1565
|
+
});
|
|
1566
|
+
await store.close();
|
|
1567
|
+
console.log(`Imported knowledge from ${source}`);
|
|
1568
|
+
}
|
|
1569
|
+
async function commandFeedbackRecord(args) {
|
|
1570
|
+
const signature = argValue(args, "--signature");
|
|
1571
|
+
const decision = argValue(args, "--decision");
|
|
1572
|
+
if (!signature) throw new Error("Missing --signature <signature>");
|
|
1573
|
+
if (!decision) throw new Error("Missing --decision <approved|rejected|needs_work|mark_flaky>");
|
|
1574
|
+
const database = argValue(args, "--database") || import_node_path5.default.join(".playwright-ai-tools", "ai-tools.sqlite");
|
|
1575
|
+
const reason = argValue(args, "--reason");
|
|
1576
|
+
const store = new SQLiteStore({ path: database });
|
|
1577
|
+
await store.migrate();
|
|
1578
|
+
await store.recordFeedback({ signature, decision, reason, metadata: {} });
|
|
1579
|
+
await store.close();
|
|
1580
|
+
console.log(`Recorded ${decision} feedback for ${signature}`);
|
|
1581
|
+
}
|
|
1582
|
+
async function commandHistoryFind(args) {
|
|
1583
|
+
const database = argValue(args, "--database") || import_node_path5.default.join(".playwright-ai-tools", "ai-tools.sqlite");
|
|
1584
|
+
const query = argValue(args, "--query") || "";
|
|
1585
|
+
const output = argValue(args, "--output");
|
|
1586
|
+
const limit = Number(argValue(args, "--limit") || 20);
|
|
1587
|
+
const store = new SQLiteStore({ path: database });
|
|
1588
|
+
await store.migrate();
|
|
1589
|
+
const payload = {
|
|
1590
|
+
failures: await store.findFailures({ query, limit }),
|
|
1591
|
+
memory: await store.searchMemory({ query, limit }),
|
|
1592
|
+
feedback: await store.searchFeedback({ query, limit })
|
|
1593
|
+
};
|
|
1594
|
+
await store.close();
|
|
1595
|
+
await writeJson(output, payload);
|
|
1596
|
+
}
|
|
1597
|
+
async function commandGithubSummary(args) {
|
|
1598
|
+
const reportPath = argValue(args, "--report");
|
|
1599
|
+
if (!reportPath) throw new Error("Missing --report <path>");
|
|
1600
|
+
const output = argValue(args, "--output");
|
|
1601
|
+
if (!output) throw new Error("Missing --output <path>");
|
|
1602
|
+
const artifactBaseUrl = argValue(args, "--artifact-base-url")?.replace(/\/$/, "");
|
|
1603
|
+
const report = JSON.parse(await (0, import_promises4.readFile)(reportPath, "utf8"));
|
|
1604
|
+
const lines = [
|
|
1605
|
+
"# Playwright AI Tools Summary",
|
|
1606
|
+
"",
|
|
1607
|
+
`Run: ${report.run.id}`,
|
|
1608
|
+
`Failures: ${report.failures.length}`,
|
|
1609
|
+
""
|
|
1610
|
+
];
|
|
1611
|
+
if (artifactBaseUrl) {
|
|
1612
|
+
lines.push(`[Open HTML report](${artifactBaseUrl}/ai-analysis-report.html)`, "");
|
|
1613
|
+
}
|
|
1614
|
+
report.failures.forEach((failure, index) => {
|
|
1615
|
+
const analysis = report.analyses[index];
|
|
1616
|
+
lines.push(
|
|
1617
|
+
`- **${failure.title}**: ${analysis?.category || "unknown"} / ${analysis?.risk || "high"} / confidence ${analysis?.confidence ?? 0}`
|
|
1618
|
+
);
|
|
1619
|
+
});
|
|
1620
|
+
await (0, import_promises4.mkdir)(import_node_path5.default.dirname(output), { recursive: true });
|
|
1621
|
+
await (0, import_promises4.writeFile)(output, `${lines.join("\n")}
|
|
1622
|
+
`, "utf8");
|
|
1623
|
+
console.log(`Wrote GitHub summary to ${output}`);
|
|
1624
|
+
}
|
|
1625
|
+
async function commandAnalyze(args) {
|
|
1626
|
+
const input = argValue(args, "--playwright-json");
|
|
1627
|
+
if (!input) {
|
|
1628
|
+
throw new Error("Missing --playwright-json <path>");
|
|
1629
|
+
}
|
|
1630
|
+
const output = argValue(args, "--output") || "ai-analysis-report.json";
|
|
1631
|
+
const database = argValue(args, "--database") || import_node_path5.default.join(".playwright-ai-tools", "ai-tools.sqlite");
|
|
1632
|
+
const report = JSON.parse(await (0, import_promises4.readFile)(input, "utf8"));
|
|
1633
|
+
const failures = normalizePlaywrightJsonReport(report, { runId: `cli-${Date.now()}` });
|
|
1634
|
+
const store = new SQLiteStore({ path: database });
|
|
1635
|
+
await store.migrate();
|
|
1636
|
+
const analyses = await analyzeFailures(failures, { store });
|
|
1637
|
+
const portableReport = {
|
|
1638
|
+
run: { id: failures[0]?.run?.id || `cli-${Date.now()}`, total: failures.length, failed: failures.length },
|
|
1639
|
+
failures,
|
|
1640
|
+
analyses
|
|
1641
|
+
};
|
|
1642
|
+
await (0, import_promises4.mkdir)(import_node_path5.default.dirname(output), { recursive: true });
|
|
1643
|
+
await (0, import_promises4.writeFile)(output, `${JSON.stringify(portableReport, null, 2)}
|
|
1644
|
+
`, "utf8");
|
|
1645
|
+
await (0, import_promises4.writeFile)(output.replace(/\.json$/i, ".md"), markdownReport(portableReport), "utf8");
|
|
1646
|
+
await (0, import_promises4.writeFile)(output.replace(/\.json$/i, ".html"), htmlReport(portableReport), "utf8");
|
|
1647
|
+
await store.close();
|
|
1648
|
+
console.log(`Analyzed ${failures.length} failure${failures.length === 1 ? "" : "s"}`);
|
|
1649
|
+
}
|
|
1650
|
+
async function commandSuggestFix(args) {
|
|
1651
|
+
const input = argValue(args, "--report");
|
|
1652
|
+
if (!input) {
|
|
1653
|
+
throw new Error("Missing --report <path>");
|
|
1654
|
+
}
|
|
1655
|
+
const output = argValue(args, "--output") || "ai-fix-suggestions.json";
|
|
1656
|
+
const report = JSON.parse(await (0, import_promises4.readFile)(input, "utf8"));
|
|
1657
|
+
const suggestions = report.suggestions || report.failures.map(
|
|
1658
|
+
(failure, index) => report.analyses[index] ? generatePatchSuggestions(failure, report.analyses[index]) : []
|
|
1659
|
+
);
|
|
1660
|
+
await (0, import_promises4.mkdir)(import_node_path5.default.dirname(output), { recursive: true });
|
|
1661
|
+
await (0, import_promises4.writeFile)(output, `${JSON.stringify({ suggestions }, null, 2)}
|
|
1662
|
+
`, "utf8");
|
|
1663
|
+
console.log(`Wrote suggestions for ${suggestions.length} failure${suggestions.length === 1 ? "" : "s"}`);
|
|
1664
|
+
}
|
|
1665
|
+
async function commandBenchmark(args) {
|
|
1666
|
+
const input = argValue(args, "--fixtures");
|
|
1667
|
+
if (!input) {
|
|
1668
|
+
throw new Error("Missing --fixtures <path>");
|
|
1669
|
+
}
|
|
1670
|
+
const output = argValue(args, "--output");
|
|
1671
|
+
const payload = JSON.parse(await (0, import_promises4.readFile)(input, "utf8"));
|
|
1672
|
+
const fixtures = Array.isArray(payload) ? payload : payload.failures || [];
|
|
1673
|
+
const benchmark = benchmarkFailures(fixtures);
|
|
1674
|
+
if (output) {
|
|
1675
|
+
await (0, import_promises4.mkdir)(import_node_path5.default.dirname(output), { recursive: true });
|
|
1676
|
+
await (0, import_promises4.writeFile)(output, `${JSON.stringify(benchmark, null, 2)}
|
|
1677
|
+
`, "utf8");
|
|
1678
|
+
}
|
|
1679
|
+
console.log(
|
|
1680
|
+
`Benchmarked ${benchmark.metrics.total} failure${benchmark.metrics.total === 1 ? "" : "s"}: accuracy=${benchmark.metrics.accuracy}, unknownRate=${benchmark.metrics.unknownRate}`
|
|
1681
|
+
);
|
|
1682
|
+
}
|
|
1683
|
+
function generationProvider() {
|
|
1684
|
+
return process.env.OPENAI_API_KEY ? new OpenAIResponsesProvider() : new TemplateTestGenerationProvider();
|
|
1685
|
+
}
|
|
1686
|
+
async function commandGenerateTest(args) {
|
|
1687
|
+
const manualCasePath = argValue(args, "--manual-case");
|
|
1688
|
+
const url = argValue(args, "--url");
|
|
1689
|
+
if (!manualCasePath) throw new Error("Missing --manual-case <json>");
|
|
1690
|
+
if (!url) throw new Error("Missing --url <url>");
|
|
1691
|
+
const outputDir = argValue(args, "--output-dir") || "generated-tests";
|
|
1692
|
+
const manualCase = JSON.parse(await (0, import_promises4.readFile)(manualCasePath, "utf8"));
|
|
1693
|
+
const draft = await generatePlaywrightTestDraft({
|
|
1694
|
+
manualCase,
|
|
1695
|
+
url,
|
|
1696
|
+
outputDir,
|
|
1697
|
+
provider: generationProvider(),
|
|
1698
|
+
confirmBrowserRun: hasFlag(args, "--confirm-browser-run"),
|
|
1699
|
+
allowDestructiveActions: hasFlag(args, "--allow-destructive-actions"),
|
|
1700
|
+
storageState: argValue(args, "--storage-state"),
|
|
1701
|
+
sourceName: argValue(args, "--source-name"),
|
|
1702
|
+
outputLanguage: argValue(args, "--output-language") || process.env.PW_AI_OUTPUT_LANGUAGE || "ru"
|
|
1703
|
+
});
|
|
1704
|
+
console.log(`Wrote generated test draft to ${draft.filePath}`);
|
|
1705
|
+
console.log(`Wrote generation report to ${draft.reportPath}`);
|
|
1706
|
+
}
|
|
1707
|
+
async function commandMigrate(args) {
|
|
1708
|
+
const database = argValue(args, "--database") || import_node_path5.default.join(".playwright-ai-tools", "ai-tools.sqlite");
|
|
1709
|
+
const store = new SQLiteStore({ path: database });
|
|
1710
|
+
await store.migrate();
|
|
1711
|
+
await store.close();
|
|
1712
|
+
console.log(`Migrated store at ${database}`);
|
|
1713
|
+
}
|
|
1714
|
+
async function commandStats(args) {
|
|
1715
|
+
const database = argValue(args, "--database") || import_node_path5.default.join(".playwright-ai-tools", "ai-tools.sqlite");
|
|
1716
|
+
const store = new SQLiteStore({ path: database });
|
|
1717
|
+
await store.migrate();
|
|
1718
|
+
console.log(JSON.stringify(await store.stats?.(), null, 2));
|
|
1719
|
+
await store.close();
|
|
1720
|
+
}
|
|
1721
|
+
async function main(argv = process.argv.slice(2)) {
|
|
1722
|
+
const [command, subcommand, ...rest] = argv;
|
|
1723
|
+
if (command === "init") return commandInit();
|
|
1724
|
+
if (command === "doctor") return commandDoctor([subcommand, ...rest].filter(Boolean));
|
|
1725
|
+
if (command === "analyze") return commandAnalyze([subcommand, ...rest].filter(Boolean));
|
|
1726
|
+
if (command === "generate-test") return commandGenerateTest([subcommand, ...rest].filter(Boolean));
|
|
1727
|
+
if (command === "suggest-fix") return commandSuggestFix([subcommand, ...rest].filter(Boolean));
|
|
1728
|
+
if (command === "benchmark") return commandBenchmark([subcommand, ...rest].filter(Boolean));
|
|
1729
|
+
if (command === "migrate") return commandMigrate([subcommand, ...rest].filter(Boolean));
|
|
1730
|
+
if (command === "db" && subcommand === "stats") return commandStats(rest);
|
|
1731
|
+
if (command === "knowledge" && subcommand === "import") return commandKnowledgeImport(rest);
|
|
1732
|
+
if (command === "feedback" && subcommand === "record") return commandFeedbackRecord(rest);
|
|
1733
|
+
if (command === "history" && subcommand === "find") return commandHistoryFind(rest);
|
|
1734
|
+
if (command === "ci" && subcommand === "github-summary") return commandGithubSummary(rest);
|
|
1735
|
+
if (hasFlag(argv, "--help")) {
|
|
1736
|
+
console.log("Usage: playwright-ai-tools <init|doctor|analyze|generate-test|suggest-fix|benchmark|migrate|db stats|knowledge import|feedback record|history find|ci github-summary>");
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
throw new Error("Usage: playwright-ai-tools <init|doctor|analyze|generate-test|suggest-fix|benchmark|migrate|db stats|knowledge import|feedback record|history find|ci github-summary>");
|
|
1740
|
+
}
|
|
1741
|
+
if (process.argv[1] && /(?:playwright-ai-tools|cli\.(?:cjs|js|ts))$/.test(import_node_path5.default.basename(process.argv[1]))) {
|
|
1742
|
+
main().catch((error) => {
|
|
1743
|
+
console.error(error.message);
|
|
1744
|
+
process.exit(1);
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1748
|
+
0 && (module.exports = {
|
|
1749
|
+
main
|
|
1750
|
+
});
|