@healflow/playwright 0.1.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/dist/api-client.d.ts +9 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +74 -0
- package/dist/api-client.js.map +1 -0
- package/dist/artifacts.d.ts +10 -0
- package/dist/artifacts.d.ts.map +1 -0
- package/dist/artifacts.js +37 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/auto.d.ts +9 -0
- package/dist/auto.d.ts.map +1 -0
- package/dist/auto.js +51 -0
- package/dist/auto.js.map +1 -0
- package/dist/client-info.d.ts +7 -0
- package/dist/client-info.d.ts.map +1 -0
- package/dist/client-info.js +30 -0
- package/dist/client-info.js.map +1 -0
- package/dist/heal-store.d.ts +9 -0
- package/dist/heal-store.d.ts.map +1 -0
- package/dist/heal-store.js +105 -0
- package/dist/heal-store.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +706 -0
- package/dist/index.js.map +1 -0
- package/dist/list-reporter.d.ts +31 -0
- package/dist/list-reporter.d.ts.map +1 -0
- package/dist/list-reporter.js +23 -0
- package/dist/list-reporter.js.map +1 -0
- package/dist/register.d.ts +4 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +9 -0
- package/dist/register.js.map +1 -0
- package/dist/report-html.d.ts +7 -0
- package/dist/report-html.d.ts.map +1 -0
- package/dist/report-html.js +862 -0
- package/dist/report-html.js.map +1 -0
- package/dist/reporter.d.ts +34 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +160 -0
- package/dist/reporter.js.map +1 -0
- package/dist/setup-global.d.ts +2 -0
- package/dist/setup-global.d.ts.map +1 -0
- package/dist/setup-global.js +10 -0
- package/dist/setup-global.js.map +1 -0
- package/dist/terminal.d.ts +16 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +158 -0
- package/dist/terminal.js.map +1 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.default = exports.healflowReporter = exports.configToPluginOptions = exports.withHealFlow = exports.registerHealFlow = void 0;
|
|
7
|
+
exports.wrapPage = wrapPage;
|
|
8
|
+
exports.healflowFixture = healflowFixture;
|
|
9
|
+
const node_crypto_1 = require("node:crypto");
|
|
10
|
+
const node_fs_1 = require("node:fs");
|
|
11
|
+
const classification_1 = require("@healflow/classification");
|
|
12
|
+
const shared_1 = require("@healflow/shared");
|
|
13
|
+
const config_1 = require("@healflow/shared/config");
|
|
14
|
+
const api_client_js_1 = require("./api-client.js");
|
|
15
|
+
const heal_store_js_1 = require("./heal-store.js");
|
|
16
|
+
const terminal_js_1 = require("./terminal.js");
|
|
17
|
+
function resolveOptions(options = {}) {
|
|
18
|
+
const fileConfig = options.config ?? (0, config_1.loadHealFlowConfig)();
|
|
19
|
+
return {
|
|
20
|
+
autoHeal: options.autoHeal ?? true,
|
|
21
|
+
maxAttempts: options.maxAttempts ?? 2,
|
|
22
|
+
apiUrl: options.apiUrl ??
|
|
23
|
+
fileConfig.backend?.url ??
|
|
24
|
+
process.env.HEALFLOW_API_URL ??
|
|
25
|
+
'http://localhost:3001',
|
|
26
|
+
organizationId: options.organizationId ??
|
|
27
|
+
fileConfig.backend?.organizationId ??
|
|
28
|
+
process.env.HEALFLOW_ORG_ID ??
|
|
29
|
+
'',
|
|
30
|
+
repositoryId: options.repositoryId ??
|
|
31
|
+
fileConfig.backend?.repositoryId ??
|
|
32
|
+
process.env.HEALFLOW_REPO_ID ??
|
|
33
|
+
'',
|
|
34
|
+
config: fileConfig,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const PAGE_LOCATOR_FACTORIES = new Set([
|
|
38
|
+
'locator',
|
|
39
|
+
'getByRole',
|
|
40
|
+
'getByText',
|
|
41
|
+
'getByLabel',
|
|
42
|
+
'getByPlaceholder',
|
|
43
|
+
'getByAltText',
|
|
44
|
+
'getByTitle',
|
|
45
|
+
'getByTestId',
|
|
46
|
+
'frameLocator',
|
|
47
|
+
]);
|
|
48
|
+
const LOCATOR_CHAIN_METHODS = new Set([
|
|
49
|
+
'locator',
|
|
50
|
+
'getByRole',
|
|
51
|
+
'getByText',
|
|
52
|
+
'getByLabel',
|
|
53
|
+
'getByPlaceholder',
|
|
54
|
+
'getByAltText',
|
|
55
|
+
'getByTitle',
|
|
56
|
+
'getByTestId',
|
|
57
|
+
'frameLocator',
|
|
58
|
+
'first',
|
|
59
|
+
'last',
|
|
60
|
+
'nth',
|
|
61
|
+
'filter',
|
|
62
|
+
'and',
|
|
63
|
+
'or',
|
|
64
|
+
]);
|
|
65
|
+
const HEALABLE_LOCATOR_ACTIONS = new Set([
|
|
66
|
+
'click',
|
|
67
|
+
'dblclick',
|
|
68
|
+
'fill',
|
|
69
|
+
'press',
|
|
70
|
+
'check',
|
|
71
|
+
'uncheck',
|
|
72
|
+
'selectOption',
|
|
73
|
+
'tap',
|
|
74
|
+
'hover',
|
|
75
|
+
'setInputFiles',
|
|
76
|
+
'clear',
|
|
77
|
+
]);
|
|
78
|
+
const HEALABLE_PAGE_ACTIONS = new Set([
|
|
79
|
+
'goto',
|
|
80
|
+
'reload',
|
|
81
|
+
'goBack',
|
|
82
|
+
'goForward',
|
|
83
|
+
'click',
|
|
84
|
+
'dblclick',
|
|
85
|
+
'fill',
|
|
86
|
+
]);
|
|
87
|
+
const HEAL_FIRST_ATTEMPT_TIMEOUT_MS = 3_000;
|
|
88
|
+
const HEAL_RETRY_TIMEOUT_MS = 15_000;
|
|
89
|
+
function withPolicy(base) {
|
|
90
|
+
const { outcome, recommendedAction } = (0, shared_1.resolveFailureOutcome)(base);
|
|
91
|
+
return { ...base, healOutcome: outcome, recommendedAction };
|
|
92
|
+
}
|
|
93
|
+
function classifyRuntimeError(error, action) {
|
|
94
|
+
const message = error.message;
|
|
95
|
+
if (/intercepts pointer events|element is obscured|subtree intercepts/i.test(message)) {
|
|
96
|
+
return withPolicy({
|
|
97
|
+
failureId: 'runtime',
|
|
98
|
+
category: shared_1.FailureCategory.OVERLAY,
|
|
99
|
+
subcategory: 'blocking_overlay',
|
|
100
|
+
confidence: 0.9,
|
|
101
|
+
signals: [
|
|
102
|
+
{ name: 'OVERLAY:pointer_intercept', weight: 0.9, evidence: message.slice(0, 120) },
|
|
103
|
+
],
|
|
104
|
+
isAutomationFixable: true,
|
|
105
|
+
isProductBug: false,
|
|
106
|
+
detectedAt: new Date().toISOString(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (/getByTestId\(['"][^'"]+['"]\)/i.test(message) && /timeout|waiting for/i.test(message)) {
|
|
110
|
+
return withPolicy({
|
|
111
|
+
failureId: 'runtime',
|
|
112
|
+
category: shared_1.FailureCategory.SELECTOR,
|
|
113
|
+
subcategory: 'locator_timeout',
|
|
114
|
+
confidence: 0.8,
|
|
115
|
+
signals: [{ name: 'SELECTOR:locator_timeout', weight: 0.8, evidence: message.slice(0, 120) }],
|
|
116
|
+
isAutomationFixable: true,
|
|
117
|
+
isProductBug: false,
|
|
118
|
+
detectedAt: new Date().toISOString(),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (/timeout \d+ms exceeded/i.test(message) &&
|
|
122
|
+
/waiting for.*to be visible|waiting for.*to be enabled/i.test(message)) {
|
|
123
|
+
return withPolicy({
|
|
124
|
+
failureId: 'runtime',
|
|
125
|
+
category: shared_1.FailureCategory.TIMING,
|
|
126
|
+
subcategory: 'wait_timeout',
|
|
127
|
+
confidence: 0.8,
|
|
128
|
+
signals: [{ name: 'TIMING:wait_timeout', weight: 0.8, evidence: message.slice(0, 120) }],
|
|
129
|
+
isAutomationFixable: true,
|
|
130
|
+
isProductBug: false,
|
|
131
|
+
detectedAt: new Date().toISOString(),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (/frame|iframe|content frame|frameLocator/i.test(message)) {
|
|
135
|
+
return withPolicy({
|
|
136
|
+
failureId: 'runtime',
|
|
137
|
+
category: shared_1.FailureCategory.IFRAME,
|
|
138
|
+
subcategory: 'frame_context',
|
|
139
|
+
confidence: 0.85,
|
|
140
|
+
signals: [{ name: 'IFRAME:frame_context', weight: 0.85, evidence: message.slice(0, 120) }],
|
|
141
|
+
isAutomationFixable: true,
|
|
142
|
+
isProductBug: false,
|
|
143
|
+
detectedAt: new Date().toISOString(),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (/shadow root|pierce|closed shadow/i.test(message)) {
|
|
147
|
+
return withPolicy({
|
|
148
|
+
failureId: 'runtime',
|
|
149
|
+
category: shared_1.FailureCategory.SHADOW_DOM,
|
|
150
|
+
subcategory: 'shadow_access',
|
|
151
|
+
confidence: 0.8,
|
|
152
|
+
signals: [{ name: 'SHADOW_DOM:shadow_access', weight: 0.8, evidence: message.slice(0, 120) }],
|
|
153
|
+
isAutomationFixable: true,
|
|
154
|
+
isProductBug: false,
|
|
155
|
+
detectedAt: new Date().toISOString(),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (/unauthorized|401|403|login required|authentication|redirect.*login/i.test(message)) {
|
|
159
|
+
return withPolicy({
|
|
160
|
+
failureId: 'runtime',
|
|
161
|
+
category: shared_1.FailureCategory.AUTH,
|
|
162
|
+
subcategory: 'auth_failure',
|
|
163
|
+
confidence: 0.85,
|
|
164
|
+
signals: [{ name: 'AUTH:auth_failure', weight: 0.85, evidence: message.slice(0, 120) }],
|
|
165
|
+
isAutomationFixable: true,
|
|
166
|
+
isProductBug: false,
|
|
167
|
+
detectedAt: new Date().toISOString(),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (/net::ERR|ECONNREFUSED|network error|failed to fetch/i.test(message)) {
|
|
171
|
+
return withPolicy({
|
|
172
|
+
failureId: 'runtime',
|
|
173
|
+
category: shared_1.FailureCategory.NETWORK,
|
|
174
|
+
subcategory: 'network_error',
|
|
175
|
+
confidence: 0.75,
|
|
176
|
+
signals: [{ name: 'NETWORK:network_error', weight: 0.75, evidence: message.slice(0, 120) }],
|
|
177
|
+
isAutomationFixable: true,
|
|
178
|
+
isProductBug: false,
|
|
179
|
+
detectedAt: new Date().toISOString(),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
const failure = {
|
|
183
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
184
|
+
organizationId: '',
|
|
185
|
+
repositoryId: '',
|
|
186
|
+
source: shared_1.FailureSource.RUNTIME,
|
|
187
|
+
testFile: 'runtime',
|
|
188
|
+
testTitle: action,
|
|
189
|
+
errorMessage: message,
|
|
190
|
+
errorStack: error.stack,
|
|
191
|
+
artifacts: [],
|
|
192
|
+
metadata: { action },
|
|
193
|
+
occurredAt: new Date().toISOString(),
|
|
194
|
+
fingerprint: '',
|
|
195
|
+
};
|
|
196
|
+
return (0, classification_1.classifyFailure)(failure);
|
|
197
|
+
}
|
|
198
|
+
function categoryKey(category) {
|
|
199
|
+
switch (category) {
|
|
200
|
+
case shared_1.FailureCategory.SELECTOR:
|
|
201
|
+
return 'selector';
|
|
202
|
+
case shared_1.FailureCategory.TIMING:
|
|
203
|
+
return 'timing';
|
|
204
|
+
case shared_1.FailureCategory.OVERLAY:
|
|
205
|
+
return 'overlay';
|
|
206
|
+
case shared_1.FailureCategory.IFRAME:
|
|
207
|
+
return 'iframe';
|
|
208
|
+
case shared_1.FailureCategory.SHADOW_DOM:
|
|
209
|
+
return 'shadowDom';
|
|
210
|
+
case shared_1.FailureCategory.AUTH:
|
|
211
|
+
case shared_1.FailureCategory.SESSION:
|
|
212
|
+
return 'auth';
|
|
213
|
+
case shared_1.FailureCategory.NETWORK:
|
|
214
|
+
return 'network';
|
|
215
|
+
default:
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function canRuntimeHeal(classification) {
|
|
220
|
+
if (classification.healOutcome === shared_1.HealOutcome.BLOCKED)
|
|
221
|
+
return false;
|
|
222
|
+
if ((0, shared_1.isCategoryAutomationFixable)(classification.category))
|
|
223
|
+
return true;
|
|
224
|
+
if (classification.category === shared_1.FailureCategory.NETWORK && classification.confidence >= 0.75) {
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
function emitHeal(opts, result, healContext) {
|
|
230
|
+
const testFile = healContext.testFile;
|
|
231
|
+
const testTitle = healContext.testTitle;
|
|
232
|
+
const record = (0, heal_store_js_1.recordRuntimeHeal)({
|
|
233
|
+
testFile,
|
|
234
|
+
testTitle,
|
|
235
|
+
rootCause: result.rootCause,
|
|
236
|
+
category: result.category,
|
|
237
|
+
oldValue: result.oldValue,
|
|
238
|
+
newValue: result.newValue,
|
|
239
|
+
confidence: result.confidence,
|
|
240
|
+
});
|
|
241
|
+
const fixProposal = (0, heal_store_js_1.recordFixProposal)({
|
|
242
|
+
testFile: testFile ?? 'runtime',
|
|
243
|
+
testTitle: testTitle ?? 'runtime',
|
|
244
|
+
rootCause: result.rootCause,
|
|
245
|
+
category: result.category,
|
|
246
|
+
oldValue: result.oldValue,
|
|
247
|
+
newValue: result.newValue,
|
|
248
|
+
confidence: result.confidence,
|
|
249
|
+
strategy: result.category,
|
|
250
|
+
dryRun: true,
|
|
251
|
+
});
|
|
252
|
+
(0, terminal_js_1.logHeal)(record, opts.config);
|
|
253
|
+
if (opts.organizationId && opts.apiUrl) {
|
|
254
|
+
(0, api_client_js_1.reportRuntimeHeal)({
|
|
255
|
+
apiUrl: opts.apiUrl,
|
|
256
|
+
organizationId: opts.organizationId,
|
|
257
|
+
repositoryId: opts.repositoryId,
|
|
258
|
+
token: opts.config.backend?.token ?? process.env.HEALFLOW_TOKEN,
|
|
259
|
+
}, record, fixProposal);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async function dismissOverlays(rawPage) {
|
|
263
|
+
const candidates = [
|
|
264
|
+
rawPage.getByRole('button', { name: /accept|agree|close|dismiss|got it|ok/i }),
|
|
265
|
+
rawPage.locator('#accept-cookies, #cookie-banner button, .overlay button'),
|
|
266
|
+
rawPage.locator('[data-testid*="accept"], [aria-label*="close" i]'),
|
|
267
|
+
];
|
|
268
|
+
for (const locator of candidates) {
|
|
269
|
+
await locator
|
|
270
|
+
.first()
|
|
271
|
+
.click({ timeout: 2000 })
|
|
272
|
+
.catch(() => { });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function waitForLocator(locator) {
|
|
276
|
+
await locator.waitFor({ state: 'attached', timeout: HEAL_RETRY_TIMEOUT_MS });
|
|
277
|
+
await locator.waitFor({ state: 'visible', timeout: HEAL_RETRY_TIMEOUT_MS });
|
|
278
|
+
}
|
|
279
|
+
async function runLocatorAction(locator, action, args) {
|
|
280
|
+
const fn = locator[action];
|
|
281
|
+
if (typeof fn !== 'function') {
|
|
282
|
+
throw new Error(`Unsupported locator action: ${action}`);
|
|
283
|
+
}
|
|
284
|
+
return fn.apply(locator, args);
|
|
285
|
+
}
|
|
286
|
+
function resolveActionArgs(args, attempt, method) {
|
|
287
|
+
if (method !== 'click' && method !== 'tap' && method !== 'fill')
|
|
288
|
+
return args;
|
|
289
|
+
if (attempt > 0) {
|
|
290
|
+
return withActionTimeout(args, HEAL_RETRY_TIMEOUT_MS, true);
|
|
291
|
+
}
|
|
292
|
+
const hasUserTimeout = args.length > 0 &&
|
|
293
|
+
typeof args[0] === 'object' &&
|
|
294
|
+
args[0] !== null &&
|
|
295
|
+
'timeout' in args[0];
|
|
296
|
+
if (hasUserTimeout)
|
|
297
|
+
return args;
|
|
298
|
+
return withActionTimeout(args, HEAL_FIRST_ATTEMPT_TIMEOUT_MS, true);
|
|
299
|
+
}
|
|
300
|
+
async function healGetByTestIdFailure(rawPage, locator, action, args, error) {
|
|
301
|
+
try {
|
|
302
|
+
await locator.waitFor({ state: 'attached', timeout: 5_000 });
|
|
303
|
+
await locator.waitFor({ state: 'visible', timeout: 5_000 });
|
|
304
|
+
await runLocatorAction(locator, action, withActionTimeout(args, HEAL_RETRY_TIMEOUT_MS, true));
|
|
305
|
+
return {
|
|
306
|
+
type: 'done',
|
|
307
|
+
result: undefined,
|
|
308
|
+
heal: {
|
|
309
|
+
category: shared_1.FailureCategory.TIMING,
|
|
310
|
+
rootCause: 'Timing',
|
|
311
|
+
confidence: 0.85,
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// not a timing issue — try fallback locators for stale test ids
|
|
317
|
+
}
|
|
318
|
+
const healed = await healSelector(rawPage, action, args, error);
|
|
319
|
+
if (healed)
|
|
320
|
+
return { type: 'done', result: undefined, heal: healed };
|
|
321
|
+
return { type: 'abort' };
|
|
322
|
+
}
|
|
323
|
+
function withActionTimeout(args, timeout, force = false) {
|
|
324
|
+
if (args.length === 0)
|
|
325
|
+
return [{ timeout }];
|
|
326
|
+
if (typeof args[0] === 'object' && args[0] !== null && !Array.isArray(args[0])) {
|
|
327
|
+
const options = args[0];
|
|
328
|
+
if (!force && options.timeout !== undefined)
|
|
329
|
+
return args;
|
|
330
|
+
return [{ ...options, timeout: options.timeout ?? timeout }, ...args.slice(1)];
|
|
331
|
+
}
|
|
332
|
+
return [{ timeout }, ...args];
|
|
333
|
+
}
|
|
334
|
+
async function assertUniqueVisibleCandidate(candidate) {
|
|
335
|
+
const count = await candidate.count();
|
|
336
|
+
if (count !== 1)
|
|
337
|
+
return false;
|
|
338
|
+
return candidate
|
|
339
|
+
.first()
|
|
340
|
+
.isVisible()
|
|
341
|
+
.catch(() => false);
|
|
342
|
+
}
|
|
343
|
+
async function healSelector(rawPage, action, args, error) {
|
|
344
|
+
const staleIdMatch = error.message.match(/getByTestId\(['"]([^'"]+)['"]\)/i);
|
|
345
|
+
const staleId = staleIdMatch?.[1];
|
|
346
|
+
const candidates = [];
|
|
347
|
+
if (staleId) {
|
|
348
|
+
const interactive = 'button, input, select, textarea, a[href]';
|
|
349
|
+
candidates.push({
|
|
350
|
+
locator: rawPage.locator(`${interactive}[data-testid*="${staleId}"]`),
|
|
351
|
+
label: `interactive[data-testid*="${staleId}"]`,
|
|
352
|
+
});
|
|
353
|
+
candidates.push({
|
|
354
|
+
locator: rawPage.locator(`[data-testid*="${staleId}"]`),
|
|
355
|
+
label: `[data-testid*="${staleId}"]`,
|
|
356
|
+
});
|
|
357
|
+
if (staleId.length > 4) {
|
|
358
|
+
const partial = staleId.slice(0, Math.ceil(staleId.length / 2));
|
|
359
|
+
candidates.push({
|
|
360
|
+
locator: rawPage.locator(`${interactive}[data-testid*="${partial}"]`),
|
|
361
|
+
label: `interactive[data-testid*="${partial}"]`,
|
|
362
|
+
});
|
|
363
|
+
candidates.push({
|
|
364
|
+
locator: rawPage.locator(`[data-testid*="${partial}"]`),
|
|
365
|
+
label: `[data-testid*="${partial}"]`,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
const roleMatch = error.message.match(/getByRole\(['"]([^'"]+)['"],\s*\{\s*name:\s*['"]([^'"]+)['"]/i);
|
|
369
|
+
if (roleMatch) {
|
|
370
|
+
candidates.push({
|
|
371
|
+
locator: rawPage.getByRole(roleMatch[1], {
|
|
372
|
+
name: new RegExp(roleMatch[2], 'i'),
|
|
373
|
+
}),
|
|
374
|
+
label: `role:${roleMatch[1]}:${roleMatch[2]}`,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
for (const candidate of candidates) {
|
|
379
|
+
const confident = await assertUniqueVisibleCandidate(candidate.locator);
|
|
380
|
+
if (!confident)
|
|
381
|
+
continue;
|
|
382
|
+
try {
|
|
383
|
+
const resolvedLocator = candidate.locator.first();
|
|
384
|
+
await runLocatorAction(resolvedLocator, action, [{ timeout: HEAL_RETRY_TIMEOUT_MS }]);
|
|
385
|
+
const resolvedTestId = await resolvedLocator.getAttribute('data-testid').catch(() => null);
|
|
386
|
+
return {
|
|
387
|
+
oldValue: staleId,
|
|
388
|
+
newValue: resolvedTestId ?? candidate.label,
|
|
389
|
+
category: shared_1.FailureCategory.SELECTOR,
|
|
390
|
+
rootCause: 'Selector Rename',
|
|
391
|
+
confidence: 0.98,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// try next candidate
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
function extractIframeSelector(message) {
|
|
401
|
+
const frameMatch = message.match(/frame(?:Locator)?\(['"]([^'"]+)['"]\)/i);
|
|
402
|
+
if (frameMatch?.[1])
|
|
403
|
+
return frameMatch[1];
|
|
404
|
+
const nameMatch = message.match(/iframe[^'"]*['"]([^'"]+)['"]/i);
|
|
405
|
+
return nameMatch?.[1] ?? null;
|
|
406
|
+
}
|
|
407
|
+
function extractShadowSelector(message) {
|
|
408
|
+
const selectorMatch = message.match(/locator\(['"]([^'"]+)['"]\)/i);
|
|
409
|
+
return selectorMatch?.[1] ?? null;
|
|
410
|
+
}
|
|
411
|
+
async function healIframe(rawPage, locator, action, args, error) {
|
|
412
|
+
const frameSelector = extractIframeSelector(error.message);
|
|
413
|
+
if (!frameSelector)
|
|
414
|
+
return null;
|
|
415
|
+
const frameLocator = rawPage.frameLocator(frameSelector);
|
|
416
|
+
const innerCandidates = [
|
|
417
|
+
frameLocator.locator('[data-testid]'),
|
|
418
|
+
frameLocator.getByRole('button'),
|
|
419
|
+
frameLocator.locator('body'),
|
|
420
|
+
];
|
|
421
|
+
for (const candidate of innerCandidates) {
|
|
422
|
+
const confident = await assertUniqueVisibleCandidate(candidate);
|
|
423
|
+
if (!confident)
|
|
424
|
+
continue;
|
|
425
|
+
try {
|
|
426
|
+
await runLocatorAction(candidate.first(), action, [{ timeout: HEAL_RETRY_TIMEOUT_MS }]);
|
|
427
|
+
return {
|
|
428
|
+
oldValue: error.message.slice(0, 80),
|
|
429
|
+
newValue: `frameLocator('${frameSelector}')`,
|
|
430
|
+
category: shared_1.FailureCategory.IFRAME,
|
|
431
|
+
rootCause: 'Iframe Context',
|
|
432
|
+
confidence: 0.85,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// try next candidate
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
for (const frame of rawPage.frames()) {
|
|
441
|
+
await frame.waitForLoadState('domcontentloaded').catch(() => { });
|
|
442
|
+
}
|
|
443
|
+
await waitForLocator(locator);
|
|
444
|
+
return {
|
|
445
|
+
category: shared_1.FailureCategory.IFRAME,
|
|
446
|
+
rootCause: 'Iframe Context',
|
|
447
|
+
confidence: 0.8,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async function healShadowDom(rawPage, action, args, error) {
|
|
455
|
+
const baseSelector = extractShadowSelector(error.message);
|
|
456
|
+
if (!baseSelector)
|
|
457
|
+
return null;
|
|
458
|
+
const pierced = rawPage.locator(`${baseSelector} >>> *`);
|
|
459
|
+
const confident = await assertUniqueVisibleCandidate(pierced);
|
|
460
|
+
if (!confident)
|
|
461
|
+
return null;
|
|
462
|
+
try {
|
|
463
|
+
await runLocatorAction(pierced.first(), action, [{ timeout: HEAL_RETRY_TIMEOUT_MS }]);
|
|
464
|
+
return {
|
|
465
|
+
oldValue: baseSelector,
|
|
466
|
+
newValue: `${baseSelector} >>> *`,
|
|
467
|
+
category: shared_1.FailureCategory.SHADOW_DOM,
|
|
468
|
+
rootCause: 'Shadow DOM',
|
|
469
|
+
confidence: 0.8,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
async function healAuth(rawPage, opts) {
|
|
477
|
+
const authConfig = opts.config.auth;
|
|
478
|
+
const storageStatePath = authConfig?.storageStatePath;
|
|
479
|
+
if (storageStatePath) {
|
|
480
|
+
try {
|
|
481
|
+
const state = JSON.parse((0, node_fs_1.readFileSync)(storageStatePath, 'utf-8'));
|
|
482
|
+
if (state.cookies?.length) {
|
|
483
|
+
await rawPage.context().addCookies(state.cookies);
|
|
484
|
+
return {
|
|
485
|
+
category: shared_1.FailureCategory.AUTH,
|
|
486
|
+
rootCause: 'Auth Session Restore',
|
|
487
|
+
newValue: storageStatePath,
|
|
488
|
+
confidence: 0.85,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
// fall through to login URL
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const loginUrl = authConfig?.loginUrl;
|
|
497
|
+
if (loginUrl) {
|
|
498
|
+
await rawPage.goto(loginUrl, { waitUntil: 'domcontentloaded', timeout: HEAL_RETRY_TIMEOUT_MS });
|
|
499
|
+
return {
|
|
500
|
+
category: shared_1.FailureCategory.AUTH,
|
|
501
|
+
rootCause: 'Auth Redirect',
|
|
502
|
+
newValue: loginUrl,
|
|
503
|
+
confidence: 0.8,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
async function healNetwork(rawPage) {
|
|
509
|
+
await rawPage.waitForLoadState('networkidle', { timeout: HEAL_RETRY_TIMEOUT_MS }).catch(() => { });
|
|
510
|
+
return {
|
|
511
|
+
category: shared_1.FailureCategory.NETWORK,
|
|
512
|
+
rootCause: 'Network Retry',
|
|
513
|
+
confidence: 0.75,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
async function prepareHeal(rawPage, locator, action, args, error, opts) {
|
|
517
|
+
const classification = classifyRuntimeError(error, action);
|
|
518
|
+
if (!canRuntimeHeal(classification)) {
|
|
519
|
+
return { type: 'abort' };
|
|
520
|
+
}
|
|
521
|
+
const key = categoryKey(classification.category);
|
|
522
|
+
if (key && !(0, config_1.isHealingCategoryEnabled)(opts.config, key)) {
|
|
523
|
+
return { type: 'abort' };
|
|
524
|
+
}
|
|
525
|
+
switch (classification.category) {
|
|
526
|
+
case shared_1.FailureCategory.OVERLAY:
|
|
527
|
+
await dismissOverlays(rawPage);
|
|
528
|
+
return {
|
|
529
|
+
type: 'retry',
|
|
530
|
+
heal: {
|
|
531
|
+
category: shared_1.FailureCategory.OVERLAY,
|
|
532
|
+
rootCause: 'Overlay Blocked',
|
|
533
|
+
confidence: classification.confidence,
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
case shared_1.FailureCategory.TIMING:
|
|
537
|
+
await waitForLocator(locator);
|
|
538
|
+
return {
|
|
539
|
+
type: 'retry',
|
|
540
|
+
heal: {
|
|
541
|
+
category: shared_1.FailureCategory.TIMING,
|
|
542
|
+
rootCause: 'Timing',
|
|
543
|
+
confidence: classification.confidence,
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
case shared_1.FailureCategory.SELECTOR:
|
|
547
|
+
return healGetByTestIdFailure(rawPage, locator, action, args, error);
|
|
548
|
+
case shared_1.FailureCategory.IFRAME: {
|
|
549
|
+
const healed = await healIframe(rawPage, locator, action, args, error);
|
|
550
|
+
if (healed)
|
|
551
|
+
return { type: 'done', result: undefined, heal: healed };
|
|
552
|
+
return {
|
|
553
|
+
type: 'retry',
|
|
554
|
+
heal: {
|
|
555
|
+
category: shared_1.FailureCategory.IFRAME,
|
|
556
|
+
rootCause: 'Iframe Context',
|
|
557
|
+
confidence: classification.confidence,
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
case shared_1.FailureCategory.SHADOW_DOM: {
|
|
562
|
+
const healed = await healShadowDom(rawPage, action, args, error);
|
|
563
|
+
if (healed)
|
|
564
|
+
return { type: 'done', result: undefined, heal: healed };
|
|
565
|
+
return { type: 'abort' };
|
|
566
|
+
}
|
|
567
|
+
case shared_1.FailureCategory.AUTH:
|
|
568
|
+
case shared_1.FailureCategory.SESSION: {
|
|
569
|
+
const healed = await healAuth(rawPage, opts);
|
|
570
|
+
if (healed)
|
|
571
|
+
return { type: 'retry', heal: healed };
|
|
572
|
+
return { type: 'abort' };
|
|
573
|
+
}
|
|
574
|
+
case shared_1.FailureCategory.NETWORK:
|
|
575
|
+
return {
|
|
576
|
+
type: 'retry',
|
|
577
|
+
heal: await healNetwork(rawPage),
|
|
578
|
+
};
|
|
579
|
+
default:
|
|
580
|
+
return { type: 'abort' };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function wrapLocator(locator, rawPage, options, healContext) {
|
|
584
|
+
const handler = {
|
|
585
|
+
get(target, prop, receiver) {
|
|
586
|
+
if (prop === 'constructor')
|
|
587
|
+
return target.constructor;
|
|
588
|
+
const value = Reflect.get(target, prop, receiver);
|
|
589
|
+
if (typeof value !== 'function')
|
|
590
|
+
return value;
|
|
591
|
+
const method = prop;
|
|
592
|
+
if (LOCATOR_CHAIN_METHODS.has(method)) {
|
|
593
|
+
return (...args) => {
|
|
594
|
+
const next = value.apply(target, args);
|
|
595
|
+
return wrapLocator(next, rawPage, options, healContext);
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
if (!HEALABLE_LOCATOR_ACTIONS.has(method)) {
|
|
599
|
+
return value.bind(target);
|
|
600
|
+
}
|
|
601
|
+
const bound = value.bind(target);
|
|
602
|
+
return async (...args) => {
|
|
603
|
+
let lastError;
|
|
604
|
+
for (let attempt = 0; attempt <= options.maxAttempts; attempt++) {
|
|
605
|
+
healContext.attempts = attempt + 1;
|
|
606
|
+
try {
|
|
607
|
+
return await bound(...resolveActionArgs(args, attempt, method));
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
lastError = error;
|
|
611
|
+
if (!options.autoHeal || attempt >= options.maxAttempts)
|
|
612
|
+
break;
|
|
613
|
+
const outcome = await prepareHeal(rawPage, target, method, args, error, options);
|
|
614
|
+
if (outcome.type !== 'abort' && outcome.heal) {
|
|
615
|
+
healContext.healedActions.push(method);
|
|
616
|
+
emitHeal(options, outcome.heal, healContext);
|
|
617
|
+
}
|
|
618
|
+
if (outcome.type === 'done')
|
|
619
|
+
return outcome.result;
|
|
620
|
+
if (outcome.type === 'retry')
|
|
621
|
+
continue;
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
throw lastError;
|
|
626
|
+
};
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
return new Proxy(locator, handler);
|
|
630
|
+
}
|
|
631
|
+
function wrapPage(page, options = {}, healContext) {
|
|
632
|
+
const opts = resolveOptions(options);
|
|
633
|
+
const context = healContext ?? { attempts: 0, healedActions: [] };
|
|
634
|
+
const handler = {
|
|
635
|
+
get(target, prop, receiver) {
|
|
636
|
+
if (prop === 'constructor')
|
|
637
|
+
return target.constructor;
|
|
638
|
+
const value = Reflect.get(target, prop, receiver);
|
|
639
|
+
if (typeof value !== 'function')
|
|
640
|
+
return value;
|
|
641
|
+
const method = prop;
|
|
642
|
+
if (PAGE_LOCATOR_FACTORIES.has(method)) {
|
|
643
|
+
return (...args) => {
|
|
644
|
+
const nextLocator = value.apply(target, args);
|
|
645
|
+
return wrapLocator(nextLocator, target, opts, context);
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
if (!HEALABLE_PAGE_ACTIONS.has(method)) {
|
|
649
|
+
return value.bind(target);
|
|
650
|
+
}
|
|
651
|
+
const bound = value.bind(target);
|
|
652
|
+
return async (...args) => {
|
|
653
|
+
let lastError;
|
|
654
|
+
for (let attempt = 0; attempt <= opts.maxAttempts; attempt++) {
|
|
655
|
+
context.attempts = attempt + 1;
|
|
656
|
+
try {
|
|
657
|
+
return await bound(...args);
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
lastError = error;
|
|
661
|
+
if (!opts.autoHeal || attempt >= opts.maxAttempts)
|
|
662
|
+
break;
|
|
663
|
+
const classification = classifyRuntimeError(error, method);
|
|
664
|
+
if (classification.category === shared_1.FailureCategory.TIMING &&
|
|
665
|
+
(0, config_1.isHealingCategoryEnabled)(opts.config, 'timing')) {
|
|
666
|
+
await target.waitForLoadState('networkidle').catch(() => { });
|
|
667
|
+
emitHeal(opts, {
|
|
668
|
+
category: shared_1.FailureCategory.TIMING,
|
|
669
|
+
rootCause: 'Timing',
|
|
670
|
+
confidence: classification.confidence,
|
|
671
|
+
}, context);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
throw lastError;
|
|
678
|
+
};
|
|
679
|
+
},
|
|
680
|
+
};
|
|
681
|
+
return new Proxy(page, handler);
|
|
682
|
+
}
|
|
683
|
+
function healflowFixture(options = {}) {
|
|
684
|
+
const opts = resolveOptions(options);
|
|
685
|
+
return {
|
|
686
|
+
page: async ({ page }, use, testInfo) => {
|
|
687
|
+
const healContext = {
|
|
688
|
+
attempts: 0,
|
|
689
|
+
healedActions: [],
|
|
690
|
+
testFile: testInfo.file,
|
|
691
|
+
testTitle: testInfo.title,
|
|
692
|
+
};
|
|
693
|
+
await use(wrapPage(page, opts, healContext));
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
var register_js_1 = require("./register.js");
|
|
698
|
+
Object.defineProperty(exports, "registerHealFlow", { enumerable: true, get: function () { return register_js_1.registerHealFlow; } });
|
|
699
|
+
var auto_js_1 = require("./auto.js");
|
|
700
|
+
Object.defineProperty(exports, "withHealFlow", { enumerable: true, get: function () { return auto_js_1.withHealFlow; } });
|
|
701
|
+
Object.defineProperty(exports, "configToPluginOptions", { enumerable: true, get: function () { return auto_js_1.configToPluginOptions; } });
|
|
702
|
+
var reporter_js_1 = require("./reporter.js");
|
|
703
|
+
Object.defineProperty(exports, "healflowReporter", { enumerable: true, get: function () { return reporter_js_1.healflowReporter; } });
|
|
704
|
+
var reporter_js_2 = require("./reporter.js");
|
|
705
|
+
Object.defineProperty(exports, "default", { enumerable: true, get: function () { return __importDefault(reporter_js_2).default; } });
|
|
706
|
+
//# sourceMappingURL=index.js.map
|