@sentinelqa/playwright-reporter 0.1.53 → 0.1.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -146
- package/dist/localReport.js +360 -32
- package/dist/quickDiagnosis.d.ts +42 -2
- package/dist/quickDiagnosis.js +1326 -112
- package/dist/reporter.js +60 -25
- package/dist/runHistory.d.ts +3 -0
- package/dist/runHistory.js +11 -1
- package/dist/terminalSummary.js +44 -12
- package/package.json +2 -2
package/dist/quickDiagnosis.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.buildQuickDiagnosis = exports.summarizeSignal = exports.buildSimilarityKey = exports.buildDebugSummary = exports.collectFailureFacts = exports.parseFailureFacts = exports.describeFailure = void 0;
|
|
6
|
+
exports.buildQuickDiagnosis = exports.buildQuickDiagnosisStructured = exports.summarizeSignal = exports.buildSimilarityKey = exports.buildDebugSummary = exports.collectFailureFacts = exports.parseFailureFacts = exports.describeFailure = void 0;
|
|
7
7
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
8
|
const node_path_1 = __importDefault(require("node:path"));
|
|
9
9
|
const node_child_process_1 = require("node:child_process");
|
|
@@ -250,20 +250,30 @@ const toMessage = (result) => {
|
|
|
250
250
|
};
|
|
251
251
|
const classifySignal = (message) => {
|
|
252
252
|
const lower = message.toLowerCase();
|
|
253
|
+
if (/playwright test did not expect test\.describe\(\) to be called here/.test(lower) ||
|
|
254
|
+
/cannot find module ['"]@playwright\/test['"]/.test(lower) ||
|
|
255
|
+
/cannot find package ['"]@playwright\/test['"]/.test(lower) ||
|
|
256
|
+
/browsertype\.launch: executable doesn't exist/.test(lower) ||
|
|
257
|
+
/please run the following command to download new browsers/.test(lower) ||
|
|
258
|
+
/npx playwright install/.test(lower) ||
|
|
259
|
+
/error: no tests found/.test(lower) ||
|
|
260
|
+
/unknown command ['"]test['"]/.test(lower)) {
|
|
261
|
+
return "setup";
|
|
262
|
+
}
|
|
253
263
|
if (/browser has been closed|target page, context or browser has been closed|crash|page crashed|browser disconnected/.test(lower)) {
|
|
254
264
|
return "infra";
|
|
255
265
|
}
|
|
256
|
-
if (/
|
|
257
|
-
return "assertion_mismatch";
|
|
258
|
-
}
|
|
259
|
-
if (/timeout|timed out|waiting for/.test(lower))
|
|
260
|
-
return "timeout";
|
|
261
|
-
if (/resolved to 0 elements|locator.*not found|never appeared|strict mode violation/.test(lower)) {
|
|
266
|
+
if (/resolved to 0 elements|locator.*not found|never appeared/.test(lower)) {
|
|
262
267
|
return "locator_not_found";
|
|
263
268
|
}
|
|
264
|
-
if (/not visible|not enabled|not stable|intercepts pointer events|not actionable/.test(lower)) {
|
|
269
|
+
if (/not visible|not enabled|not stable|intercepts pointer events|not actionable|strict mode violation/.test(lower)) {
|
|
265
270
|
return "actionability";
|
|
266
271
|
}
|
|
272
|
+
if (/expected substring|expected string|received string|tohavetext|tocontaintext|tohavevalue|tobechecked|showed ".+" instead of ".+"/.test(lower)) {
|
|
273
|
+
return "assertion_mismatch";
|
|
274
|
+
}
|
|
275
|
+
if (/timeout|timed out|waiting for/.test(lower))
|
|
276
|
+
return "timeout";
|
|
267
277
|
if (/status\s*[45]\d{2}|net::|failed to fetch|network|request failed|socket hang up|econnreset|503|502|500/.test(lower)) {
|
|
268
278
|
return "network";
|
|
269
279
|
}
|
|
@@ -277,20 +287,265 @@ const extractLocator = (message) => {
|
|
|
277
287
|
const locatorLine = message.match(/Locator:\s*(.+)/i);
|
|
278
288
|
if (locatorLine?.[1])
|
|
279
289
|
return locatorLine[1].trim();
|
|
280
|
-
const callLine = message.match(/(getByTestId|getByRole|getByText|locator)\([^)]+\)/);
|
|
290
|
+
const callLine = message.match(/(getByTestId|getByRole|getByText|getByLabel|getByPlaceholder|getByTitle|locator)\([^)]+\)/);
|
|
281
291
|
return callLine?.[0] || null;
|
|
282
292
|
};
|
|
293
|
+
const normalizedUrlPath = (value) => {
|
|
294
|
+
if (!value)
|
|
295
|
+
return null;
|
|
296
|
+
try {
|
|
297
|
+
const parsed = new URL(value);
|
|
298
|
+
return parsed.pathname || null;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return value.replace(/^https?:\/\/[^/]+/i, "").split("?")[0] || value;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
const canonicalLocatorForFailure = (failure) => failure.domCapture?.normalizedLocator ||
|
|
305
|
+
failure.locator ||
|
|
306
|
+
failure.codeContext?.locator ||
|
|
307
|
+
null;
|
|
308
|
+
const pageErrorFingerprint = (failure) => normalizeMessageFingerprint(failure.domCapture?.recentPageErrors?.[0] ||
|
|
309
|
+
failure.domCapture?.recentConsoleErrors?.[0]?.text ||
|
|
310
|
+
"");
|
|
311
|
+
const requestFingerprint = (failure) => {
|
|
312
|
+
const request = failedRequestFromDomCapture(failure.domCapture);
|
|
313
|
+
if (!request)
|
|
314
|
+
return null;
|
|
315
|
+
const method = request.method || "REQUEST";
|
|
316
|
+
const url = normalizedUrlPath(request.url) || request.url || "unknown-request";
|
|
317
|
+
const status = typeof request.status === "number" ? String(request.status) : request.failure || "failed";
|
|
318
|
+
return `${method} ${url} ${status}`;
|
|
319
|
+
};
|
|
283
320
|
const extractExpected = (message) => {
|
|
284
321
|
const match = message.match(/Expected substring:\s*"([^"]+)"/i) ||
|
|
285
322
|
message.match(/Expected string:\s*"([^"]+)"/i) ||
|
|
286
|
-
message.match(/Expected:\s*"([^"]+)"/i)
|
|
323
|
+
message.match(/Expected:\s*"([^"]+)"/i) ||
|
|
324
|
+
message.match(/Expected:\s*`([^`]+)`/i) ||
|
|
325
|
+
message.match(/instead of\s+"([^"]+)"/i);
|
|
287
326
|
return match?.[1] || null;
|
|
288
327
|
};
|
|
289
328
|
const extractReceived = (message) => {
|
|
290
329
|
const match = message.match(/Received string:\s*"([^"]+)"/i) ||
|
|
291
|
-
message.match(/Received:\s*"([^"]+)"/i)
|
|
330
|
+
message.match(/Received:\s*"([^"]+)"/i) ||
|
|
331
|
+
message.match(/Received:\s*`([^`]+)`/i) ||
|
|
332
|
+
message.match(/showed\s+"([^"]+)"/i);
|
|
292
333
|
return match?.[1] || null;
|
|
293
334
|
};
|
|
335
|
+
const extractExpectedFromAssertionLine = (value) => {
|
|
336
|
+
if (!value)
|
|
337
|
+
return null;
|
|
338
|
+
const line = value.trim();
|
|
339
|
+
if (/\.toBeHidden\(\s*\)/i.test(line))
|
|
340
|
+
return "hidden";
|
|
341
|
+
if (/\.toBeVisible\(\s*\)/i.test(line))
|
|
342
|
+
return "visible";
|
|
343
|
+
if (/\.toBeDisabled\(\s*\)/i.test(line))
|
|
344
|
+
return "disabled";
|
|
345
|
+
if (/\.toBeEnabled\(\s*\)/i.test(line))
|
|
346
|
+
return "enabled";
|
|
347
|
+
if (/\.toBeChecked\(\s*\)/i.test(line))
|
|
348
|
+
return "checked";
|
|
349
|
+
if (/\.not\.toBeChecked\(\s*\)/i.test(line))
|
|
350
|
+
return "unchecked";
|
|
351
|
+
const countMatch = line.match(/toHaveCount\(\s*(\d+)\s*\)/i);
|
|
352
|
+
if (countMatch?.[1])
|
|
353
|
+
return `count ${countMatch[1]}`;
|
|
354
|
+
const titleMatch = line.match(/toHaveTitle\(\s*["'`]([^"'`]+)["'`]\s*\)/i);
|
|
355
|
+
if (titleMatch?.[1])
|
|
356
|
+
return titleMatch[1];
|
|
357
|
+
const attrMatch = line.match(/toHaveAttribute\(\s*["'`]([^"'`]+)["'`]\s*,\s*["'`]([^"'`]+)["'`]\s*\)/i);
|
|
358
|
+
if (attrMatch?.[1] && attrMatch?.[2])
|
|
359
|
+
return `${attrMatch[1]}=${attrMatch[2]}`;
|
|
360
|
+
const quoted = line.match(/toHaveText\(\s*["'`]([^"'`]+)["'`]\s*\)/) ||
|
|
361
|
+
line.match(/toContainText\(\s*["'`]([^"'`]+)["'`]\s*\)/) ||
|
|
362
|
+
line.match(/toHaveValue\(\s*["'`]([^"'`]+)["'`]\s*\)/) ||
|
|
363
|
+
line.match(/toHaveURL\(\s*["'`]([^"'`]+)["'`]\s*\)/) ||
|
|
364
|
+
line.match(/toHaveTitle\(\s*["'`]([^"'`]+)["'`]\s*\)/);
|
|
365
|
+
return quoted?.[1] || null;
|
|
366
|
+
};
|
|
367
|
+
const extractAssertionKind = (value) => {
|
|
368
|
+
if (!value)
|
|
369
|
+
return "unknown";
|
|
370
|
+
const line = value.trim();
|
|
371
|
+
if (/toBeHidden\(\s*\)|toBeVisible\(\s*\)/i.test(line))
|
|
372
|
+
return "visibility";
|
|
373
|
+
if (/toBeDisabled\(\s*\)|toBeEnabled\(\s*\)/i.test(line))
|
|
374
|
+
return "enabled";
|
|
375
|
+
if (/toBeChecked\(\s*\)|not\.toBeChecked\(\s*\)/i.test(line))
|
|
376
|
+
return "checked";
|
|
377
|
+
if (/toHaveCount\(\s*\d+\s*\)/i.test(line))
|
|
378
|
+
return "count";
|
|
379
|
+
if (/toHaveURL\(/i.test(line))
|
|
380
|
+
return "url";
|
|
381
|
+
if (/toHaveTitle\(/i.test(line))
|
|
382
|
+
return "title";
|
|
383
|
+
if (/toHaveAttribute\(/i.test(line))
|
|
384
|
+
return "attribute";
|
|
385
|
+
if (/toHaveText\(|toContainText\(|toHaveValue\(/i.test(line))
|
|
386
|
+
return "text";
|
|
387
|
+
return "unknown";
|
|
388
|
+
};
|
|
389
|
+
const extractObservedDomText = (domCapture) => {
|
|
390
|
+
if (!domCapture)
|
|
391
|
+
return null;
|
|
392
|
+
if (domCapture.observedText)
|
|
393
|
+
return domCapture.observedText;
|
|
394
|
+
if (domCapture.textContent)
|
|
395
|
+
return domCapture.textContent;
|
|
396
|
+
const matchedText = domCapture.matchedElements
|
|
397
|
+
?.map((item) => item?.text || null)
|
|
398
|
+
.find((value) => typeof value === "string" && value.trim().length > 0);
|
|
399
|
+
return matchedText || null;
|
|
400
|
+
};
|
|
401
|
+
const normalizedAriaSnapshot = (domCapture) => (domCapture?.ariaSnapshot || "").replace(/\s+/g, " ").trim();
|
|
402
|
+
const firstMatchedElement = (domCapture) => domCapture?.matchedElements?.find(Boolean) || null;
|
|
403
|
+
const parseBooleanToken = (value, token) => {
|
|
404
|
+
if (!value)
|
|
405
|
+
return null;
|
|
406
|
+
const normalized = value.toLowerCase();
|
|
407
|
+
if (new RegExp(`${token}\\s*[:=]\\s*(true|false)`, "i").test(normalized) ||
|
|
408
|
+
new RegExp(`${token}\\s*=\\s*"(true|false)"`, "i").test(normalized) ||
|
|
409
|
+
new RegExp(`${token}\\s*=\\s*'(true|false)'`, "i").test(normalized)) {
|
|
410
|
+
const match = normalized.match(new RegExp(`${token}\\s*[:=]\\s*(true|false)`, "i")) ||
|
|
411
|
+
normalized.match(new RegExp(`${token}\\s*=\\s*"(true|false)"`, "i")) ||
|
|
412
|
+
normalized.match(new RegExp(`${token}\\s*=\\s*'(true|false)'`, "i"));
|
|
413
|
+
if (match?.[1] === "true")
|
|
414
|
+
return true;
|
|
415
|
+
if (match?.[1] === "false")
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
if (new RegExp(`\\[${token}\\]`, "i").test(normalized) || new RegExp(`\\b${token}\\b`, "i").test(normalized)) {
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
};
|
|
423
|
+
const ariaVisibilityState = (domCapture) => {
|
|
424
|
+
const snapshot = normalizedAriaSnapshot(domCapture);
|
|
425
|
+
const hidden = parseBooleanToken(snapshot, "aria-hidden") ??
|
|
426
|
+
parseBooleanToken(snapshot, "hidden");
|
|
427
|
+
if (hidden === true)
|
|
428
|
+
return "hidden";
|
|
429
|
+
if (hidden === false)
|
|
430
|
+
return "visible";
|
|
431
|
+
return null;
|
|
432
|
+
};
|
|
433
|
+
const ariaEnabledState = (domCapture) => {
|
|
434
|
+
const snapshot = normalizedAriaSnapshot(domCapture);
|
|
435
|
+
const disabled = parseBooleanToken(snapshot, "aria-disabled") ??
|
|
436
|
+
parseBooleanToken(snapshot, "disabled");
|
|
437
|
+
if (disabled === true)
|
|
438
|
+
return "disabled";
|
|
439
|
+
if (disabled === false)
|
|
440
|
+
return "enabled";
|
|
441
|
+
return null;
|
|
442
|
+
};
|
|
443
|
+
const ariaCheckedState = (domCapture) => {
|
|
444
|
+
const snapshot = normalizedAriaSnapshot(domCapture);
|
|
445
|
+
const explicit = snapshot.match(/aria-checked\s*[:=]\s*(true|false|mixed)/i) ||
|
|
446
|
+
snapshot.match(/checked\s*[:=]\s*(true|false|mixed)/i) ||
|
|
447
|
+
snapshot.match(/aria-checked\s*=\s*"(true|false|mixed)"/i) ||
|
|
448
|
+
snapshot.match(/aria-checked\s*=\s*'(true|false|mixed)'/i);
|
|
449
|
+
if (explicit?.[1]) {
|
|
450
|
+
if (explicit[1].toLowerCase() === "true" || explicit[1].toLowerCase() === "mixed")
|
|
451
|
+
return "checked";
|
|
452
|
+
if (explicit[1].toLowerCase() === "false")
|
|
453
|
+
return "unchecked";
|
|
454
|
+
}
|
|
455
|
+
if (/\[checked\]|\bchecked\b/i.test(snapshot) && !/\bunchecked\b/i.test(snapshot))
|
|
456
|
+
return "checked";
|
|
457
|
+
if (/\bunchecked\b/i.test(snapshot))
|
|
458
|
+
return "unchecked";
|
|
459
|
+
return null;
|
|
460
|
+
};
|
|
461
|
+
const extractObservedAssertionState = (domCapture, expected) => {
|
|
462
|
+
if (!domCapture || !expected)
|
|
463
|
+
return null;
|
|
464
|
+
const matched = firstMatchedElement(domCapture);
|
|
465
|
+
switch (expected) {
|
|
466
|
+
case "hidden":
|
|
467
|
+
case "visible":
|
|
468
|
+
if (domCapture.visible === true)
|
|
469
|
+
return "visible";
|
|
470
|
+
if (domCapture.visible === false)
|
|
471
|
+
return "hidden";
|
|
472
|
+
if (matched?.visible === true)
|
|
473
|
+
return "visible";
|
|
474
|
+
if (matched?.visible === false)
|
|
475
|
+
return "hidden";
|
|
476
|
+
return ariaVisibilityState(domCapture);
|
|
477
|
+
case "checked":
|
|
478
|
+
case "unchecked": {
|
|
479
|
+
const checked = ariaCheckedState(domCapture);
|
|
480
|
+
if (checked)
|
|
481
|
+
return checked;
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
case "enabled":
|
|
485
|
+
case "disabled":
|
|
486
|
+
if (domCapture.enabled === true)
|
|
487
|
+
return "enabled";
|
|
488
|
+
if (domCapture.enabled === false)
|
|
489
|
+
return "disabled";
|
|
490
|
+
if (matched?.enabled === true)
|
|
491
|
+
return "enabled";
|
|
492
|
+
if (matched?.enabled === false)
|
|
493
|
+
return "disabled";
|
|
494
|
+
return ariaEnabledState(domCapture);
|
|
495
|
+
default:
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
const extractObservedAssertionValue = (domCapture, expected, assertionKind) => {
|
|
500
|
+
if (!domCapture)
|
|
501
|
+
return null;
|
|
502
|
+
if (assertionKind === "visibility" || assertionKind === "enabled" || assertionKind === "checked") {
|
|
503
|
+
return extractObservedAssertionState(domCapture, expected);
|
|
504
|
+
}
|
|
505
|
+
if (assertionKind === "count" && typeof domCapture.matchedCount === "number") {
|
|
506
|
+
return `count ${domCapture.matchedCount}`;
|
|
507
|
+
}
|
|
508
|
+
return null;
|
|
509
|
+
};
|
|
510
|
+
const isStateExpectation = (value) => value === "hidden" || value === "visible" || value === "enabled" || value === "disabled" || value === "checked" || value === "unchecked";
|
|
511
|
+
const describeAssertionDifference = (selector, expected, received) => {
|
|
512
|
+
if (isStateExpectation(expected) && isStateExpectation(received)) {
|
|
513
|
+
return `${selector} was ${truncateValue(received, 24)} instead of ${truncateValue(expected, 24)}`;
|
|
514
|
+
}
|
|
515
|
+
if (/^count \d+$/i.test(expected) && /^count \d+$/i.test(received)) {
|
|
516
|
+
return `${selector} had ${truncateValue(received, 24)} instead of ${truncateValue(expected, 24)}`;
|
|
517
|
+
}
|
|
518
|
+
return `${selector} showed "${truncateValue(received, 72)}" instead of "${truncateValue(expected, 40)}"`;
|
|
519
|
+
};
|
|
520
|
+
const describeExpectedState = (expected) => {
|
|
521
|
+
if (!expected)
|
|
522
|
+
return null;
|
|
523
|
+
if (/^count \d+$/i.test(expected))
|
|
524
|
+
return expected;
|
|
525
|
+
if (isStateExpectation(expected))
|
|
526
|
+
return expected;
|
|
527
|
+
return `"${truncateValue(expected, 40)}"`;
|
|
528
|
+
};
|
|
529
|
+
const assertionWaitCondition = (selector, expected) => {
|
|
530
|
+
const expectedState = describeExpectedState(expected);
|
|
531
|
+
if (!expectedState)
|
|
532
|
+
return `${selector} to reach the expected state`;
|
|
533
|
+
if (expected === "hidden")
|
|
534
|
+
return `${selector} to become hidden`;
|
|
535
|
+
if (expected === "visible")
|
|
536
|
+
return `${selector} to become visible`;
|
|
537
|
+
if (expected === "enabled")
|
|
538
|
+
return `${selector} to become enabled`;
|
|
539
|
+
if (expected === "disabled")
|
|
540
|
+
return `${selector} to become disabled`;
|
|
541
|
+
if (expected === "checked")
|
|
542
|
+
return `${selector} to become checked`;
|
|
543
|
+
if (expected === "unchecked")
|
|
544
|
+
return `${selector} to become unchecked`;
|
|
545
|
+
if (/^count \d+$/i.test(expected))
|
|
546
|
+
return `${selector} to reach ${expectedState}`;
|
|
547
|
+
return `${selector} to show ${expectedState}`;
|
|
548
|
+
};
|
|
294
549
|
const extractTimeoutMs = (message) => {
|
|
295
550
|
const match = message.match(/Timeout:\s*(\d+)\s*ms/i) ||
|
|
296
551
|
message.match(/timeout(?: of)?\s*(\d+)\s*ms/i) ||
|
|
@@ -313,6 +568,85 @@ const extractApiHint = (message, codeContext) => {
|
|
|
313
568
|
const apiMatch = message.match(/\/(api|graphql|rest)\/[^\s)"']+/i);
|
|
314
569
|
return apiMatch?.[0] || null;
|
|
315
570
|
};
|
|
571
|
+
const recentPageErrorHint = (domCapture) => truncateValue(domCapture?.recentPageErrors?.[0], 140);
|
|
572
|
+
const recentConsoleErrorHint = (domCapture) => truncateValue(domCapture?.recentConsoleErrors?.[0]?.text, 140);
|
|
573
|
+
const ariaSnapshotHint = (domCapture) => truncateValue(domCapture?.ariaSnapshot || null, 140);
|
|
574
|
+
const failedRequestFromDomCapture = (domCapture) => {
|
|
575
|
+
const requests = domCapture?.recentRequests || [];
|
|
576
|
+
return requests.find((request) => {
|
|
577
|
+
const status = typeof request.status === "number" ? request.status : null;
|
|
578
|
+
return Boolean(request.failure) || (status !== null && status >= 400);
|
|
579
|
+
}) || null;
|
|
580
|
+
};
|
|
581
|
+
const formatRequestLabel = (request) => {
|
|
582
|
+
const method = request.method || "REQUEST";
|
|
583
|
+
const url = request.url || "unknown request";
|
|
584
|
+
const status = typeof request.status === "number" ? ` -> ${request.status}` : request.failure ? ` -> ${request.failure}` : "";
|
|
585
|
+
return `${method} ${truncateValue(url, 48)}${status}`;
|
|
586
|
+
};
|
|
587
|
+
const isUrlLike = (value) => Boolean(value && (/^https?:\/\//i.test(value) || /^\//.test(value)));
|
|
588
|
+
const navigationVerdictForFailure = (failure) => {
|
|
589
|
+
const expected = failure.expected || null;
|
|
590
|
+
const received = failure.received || failure.lastUrl || null;
|
|
591
|
+
const message = stripAnsi(failure.message).toLowerCase();
|
|
592
|
+
if (isUrlLike(expected) || isUrlLike(received)) {
|
|
593
|
+
if ((received || "").toLowerCase().includes("/login") && expected && !expected.toLowerCase().includes("/login")) {
|
|
594
|
+
return { kind: "auth_redirect", expected, received };
|
|
595
|
+
}
|
|
596
|
+
if (expected && received && expected !== received) {
|
|
597
|
+
return { kind: "wrong_route", expected, received };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (/navigation timeout|page\.goto: timeout|waitforurl.*timeout|waiting for navigation/i.test(message)) {
|
|
601
|
+
return { kind: "navigation_timeout", expected, received };
|
|
602
|
+
}
|
|
603
|
+
if (/about:blank|blank page/i.test(message) || ((received || "").toLowerCase() === "about:blank")) {
|
|
604
|
+
return { kind: "blank_page", expected, received };
|
|
605
|
+
}
|
|
606
|
+
if (/cross-origin|popup|new page/i.test(message)) {
|
|
607
|
+
return { kind: "popup_origin", expected, received };
|
|
608
|
+
}
|
|
609
|
+
return null;
|
|
610
|
+
};
|
|
611
|
+
const blockedActionSubtype = (failure, verdict) => {
|
|
612
|
+
const blocker = verdict?.blocker || "";
|
|
613
|
+
const message = stripAnsi(failure.message).toLowerCase();
|
|
614
|
+
if (/strict mode/i.test(blocker) || /strict mode violation/i.test(message))
|
|
615
|
+
return "strict_mode";
|
|
616
|
+
if (/detached/i.test(blocker) || /detached from the dom/i.test(message))
|
|
617
|
+
return "detached";
|
|
618
|
+
if (/scrolling or viewport issue/i.test(blocker) || /viewport|scrolled into view/.test(message))
|
|
619
|
+
return "offscreen";
|
|
620
|
+
if (/focus or editability issue/i.test(blocker) || /not editable|not focused/.test(message))
|
|
621
|
+
return "focus";
|
|
622
|
+
if (/intercepts pointer events|overlay|obscures/.test(blocker) || /intercepts pointer events|overlay/.test(message))
|
|
623
|
+
return "overlay";
|
|
624
|
+
if (verdict?.targetState === "hidden")
|
|
625
|
+
return "hidden";
|
|
626
|
+
if (verdict?.targetState === "disabled")
|
|
627
|
+
return "disabled";
|
|
628
|
+
return "generic";
|
|
629
|
+
};
|
|
630
|
+
const blockerGroupingKey = (failure, verdict) => {
|
|
631
|
+
const subtype = blockedActionSubtype(failure, verdict);
|
|
632
|
+
const blocker = (verdict?.blocker || "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
633
|
+
if (!blocker)
|
|
634
|
+
return null;
|
|
635
|
+
return `${subtype}|${blocker}`;
|
|
636
|
+
};
|
|
637
|
+
const sharedBlockedClusterSubtype = (cluster) => {
|
|
638
|
+
const subtypes = cluster.failures
|
|
639
|
+
.map((failure) => blockedActionSubtype(failure, blockedVerdictForFailure(failure)))
|
|
640
|
+
.filter(Boolean);
|
|
641
|
+
return dominantValue(subtypes) || null;
|
|
642
|
+
};
|
|
643
|
+
const isMultiSelectorBlockedCluster = (cluster) => {
|
|
644
|
+
const selectors = new Set(cluster.failures.map((item) => compactLocator(item.locator)).filter(Boolean));
|
|
645
|
+
if (selectors.size <= 1)
|
|
646
|
+
return false;
|
|
647
|
+
const subtype = sharedBlockedClusterSubtype(cluster);
|
|
648
|
+
return subtype === "overlay" || subtype === "strict_mode" || subtype === "detached" || subtype === "offscreen" || subtype === "focus";
|
|
649
|
+
};
|
|
316
650
|
const extractStackLocation = (message) => {
|
|
317
651
|
const lines = stripAnsi(message).split(/\r?\n/);
|
|
318
652
|
for (const line of lines) {
|
|
@@ -372,6 +706,35 @@ const describeDomState = (failure) => {
|
|
|
372
706
|
}
|
|
373
707
|
return null;
|
|
374
708
|
};
|
|
709
|
+
const extractBlockerFromMessage = (message) => {
|
|
710
|
+
const plain = stripAnsi(message);
|
|
711
|
+
const interceptMatch = plain.match(/-\s*(<[^>]+>|[^.\n]+?)\s+intercepts pointer events/i);
|
|
712
|
+
if (interceptMatch?.[1]) {
|
|
713
|
+
const blocker = interceptMatch[1].replace(/\s+/g, " ").trim();
|
|
714
|
+
return truncateValue(blocker, 72);
|
|
715
|
+
}
|
|
716
|
+
const obscuredMatch = plain.match(/(?:another element|element)\s+(.+?)\s+(?:would receive the click|obscures|obscured)/i);
|
|
717
|
+
if (obscuredMatch?.[1]) {
|
|
718
|
+
return truncateValue(`${obscuredMatch[1].replace(/\s+/g, " ").trim()} obscures the target`, 72);
|
|
719
|
+
}
|
|
720
|
+
const overlayMatch = plain.match(/\b([.#]?[A-Za-z0-9_-]*overlay[A-Za-z0-9_.-]*)\b/i);
|
|
721
|
+
if (overlayMatch?.[1]) {
|
|
722
|
+
return overlayMatch[1];
|
|
723
|
+
}
|
|
724
|
+
if (/strict mode violation/i.test(plain)) {
|
|
725
|
+
return "strict mode matched multiple elements";
|
|
726
|
+
}
|
|
727
|
+
if (/element is not attached to the dom|detached from dom|detached from the dom/i.test(plain)) {
|
|
728
|
+
return "element detached from the DOM";
|
|
729
|
+
}
|
|
730
|
+
if (/outside of the viewport|scroll(?:ing)? into view|could not be scrolled into view/i.test(plain)) {
|
|
731
|
+
return "scrolling or viewport issue blocked the target";
|
|
732
|
+
}
|
|
733
|
+
if (/not focused|did not receive focus|is not editable|element is not editable/i.test(plain)) {
|
|
734
|
+
return "focus or editability issue blocked the target";
|
|
735
|
+
}
|
|
736
|
+
return null;
|
|
737
|
+
};
|
|
375
738
|
const dominantValue = (values) => {
|
|
376
739
|
const counts = new Map();
|
|
377
740
|
for (const value of values) {
|
|
@@ -400,6 +763,22 @@ const timeoutState = (failure) => {
|
|
|
400
763
|
return "present";
|
|
401
764
|
return "unknown";
|
|
402
765
|
};
|
|
766
|
+
const inferMissingTargetFromMessage = (failure) => {
|
|
767
|
+
if (failure.signal !== "timeout")
|
|
768
|
+
return false;
|
|
769
|
+
const message = stripAnsi(failure.message);
|
|
770
|
+
if (!failure.locator || !failure.codeContext?.action)
|
|
771
|
+
return false;
|
|
772
|
+
if (!["click", "fill", "press"].includes(failure.codeContext.action))
|
|
773
|
+
return false;
|
|
774
|
+
if (/intercepts pointer events|not visible|not enabled|not stable|strict mode violation|detached from the dom|outside of the viewport|not editable|not focused/i.test(message)) {
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
if (/locator\.(click|fill|press):/i.test(message) && /Call log:\s*[\s\S]*waiting for /i.test(message)) {
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
780
|
+
return false;
|
|
781
|
+
};
|
|
403
782
|
const sharedTimeoutEvidence = (failures) => {
|
|
404
783
|
const timeoutFailures = failures.filter((failure) => failure.signal === "timeout");
|
|
405
784
|
return {
|
|
@@ -422,6 +801,147 @@ const timeoutStateLabel = (state) => {
|
|
|
422
801
|
return null;
|
|
423
802
|
}
|
|
424
803
|
};
|
|
804
|
+
const capitalize = (value) => {
|
|
805
|
+
if (!value)
|
|
806
|
+
return null;
|
|
807
|
+
return value.length > 1 ? `${value[0].toUpperCase()}${value.slice(1)}` : value.toUpperCase();
|
|
808
|
+
};
|
|
809
|
+
const humanizeAction = (value) => {
|
|
810
|
+
if (!value)
|
|
811
|
+
return null;
|
|
812
|
+
switch (value) {
|
|
813
|
+
case "expect_text":
|
|
814
|
+
return "expect text";
|
|
815
|
+
case "expect_visible":
|
|
816
|
+
return "expect visible";
|
|
817
|
+
case "assert":
|
|
818
|
+
return "assert";
|
|
819
|
+
default:
|
|
820
|
+
return value.replace(/_/g, " ");
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
const timeoutWaitConditionForFailure = (failure) => {
|
|
824
|
+
const verdict = blockedVerdictForFailure(failure);
|
|
825
|
+
const locator = failure.locator ? compactLocator(failure.locator) : "the target";
|
|
826
|
+
const action = failure.codeContext?.action || null;
|
|
827
|
+
if ((action === "expect_text" || action === "assert") && failure.expected && failure.locator) {
|
|
828
|
+
return assertionWaitCondition(locator, failure.expected);
|
|
829
|
+
}
|
|
830
|
+
if (verdict?.targetState === "missing")
|
|
831
|
+
return `${locator} to appear`;
|
|
832
|
+
if (verdict?.targetState === "hidden")
|
|
833
|
+
return `${locator} to become visible`;
|
|
834
|
+
if (verdict?.targetState === "disabled")
|
|
835
|
+
return `${locator} to become enabled`;
|
|
836
|
+
if (action === "click" || verdict?.targetState === "visible_blocked")
|
|
837
|
+
return `${locator} to become clickable`;
|
|
838
|
+
if (action === "fill")
|
|
839
|
+
return `${locator} to become editable`;
|
|
840
|
+
if (action === "press")
|
|
841
|
+
return `${locator} to become ready for keyboard input`;
|
|
842
|
+
if (failure.expected && failure.locator) {
|
|
843
|
+
return assertionWaitCondition(locator, failure.expected);
|
|
844
|
+
}
|
|
845
|
+
if (failure.locator)
|
|
846
|
+
return `${locator} to be ready`;
|
|
847
|
+
return "the expected state change to complete";
|
|
848
|
+
};
|
|
849
|
+
const timeoutWaitConditionForCluster = (cluster) => {
|
|
850
|
+
const failure = cluster.sample;
|
|
851
|
+
const verdict = blockedVerdictForCluster(cluster);
|
|
852
|
+
const locator = verdict?.locator ? compactLocator(verdict.locator) : failure.locator ? compactLocator(failure.locator) : "the target";
|
|
853
|
+
const action = verdict?.action || failure.codeContext?.action || null;
|
|
854
|
+
const expected = representativeClusterExpected(cluster) || failure.expected;
|
|
855
|
+
if ((action === "expect_text" || action === "assert") && expected && locator !== "the target") {
|
|
856
|
+
return assertionWaitCondition(locator, expected);
|
|
857
|
+
}
|
|
858
|
+
if (verdict?.targetState === "missing")
|
|
859
|
+
return `${locator} to appear`;
|
|
860
|
+
if (verdict?.targetState === "hidden")
|
|
861
|
+
return `${locator} to become visible`;
|
|
862
|
+
if (verdict?.targetState === "disabled")
|
|
863
|
+
return `${locator} to become enabled`;
|
|
864
|
+
if (action === "click" || verdict?.targetState === "visible_blocked")
|
|
865
|
+
return `${locator} to become clickable`;
|
|
866
|
+
if (action === "fill")
|
|
867
|
+
return `${locator} to become editable`;
|
|
868
|
+
if (action === "press")
|
|
869
|
+
return `${locator} to become ready for keyboard input`;
|
|
870
|
+
if (expected && locator !== "the target") {
|
|
871
|
+
return assertionWaitCondition(locator, expected);
|
|
872
|
+
}
|
|
873
|
+
if (locator !== "the target")
|
|
874
|
+
return `${locator} to be ready`;
|
|
875
|
+
return "the expected state change to complete";
|
|
876
|
+
};
|
|
877
|
+
const blockedVerdictForFailure = (failure) => {
|
|
878
|
+
if (!["timeout", "actionability", "locator_not_found"].includes(failure.signal))
|
|
879
|
+
return null;
|
|
880
|
+
const blocker = extractBlockerFromMessage(failure.message);
|
|
881
|
+
const state = timeoutState(failure);
|
|
882
|
+
if (state === "missing") {
|
|
883
|
+
return {
|
|
884
|
+
action: failure.codeContext?.action || null,
|
|
885
|
+
locator: failure.locator || null,
|
|
886
|
+
blocker: null,
|
|
887
|
+
targetState: "missing"
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
if (state === "hidden") {
|
|
891
|
+
return {
|
|
892
|
+
action: failure.codeContext?.action || null,
|
|
893
|
+
locator: failure.locator || null,
|
|
894
|
+
blocker: null,
|
|
895
|
+
targetState: "hidden"
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
if (state === "disabled") {
|
|
899
|
+
return {
|
|
900
|
+
action: failure.codeContext?.action || null,
|
|
901
|
+
locator: failure.locator || null,
|
|
902
|
+
blocker: null,
|
|
903
|
+
targetState: "disabled"
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
if (blocker || (state === "present" && failure.codeContext?.action)) {
|
|
907
|
+
return {
|
|
908
|
+
action: failure.codeContext?.action || null,
|
|
909
|
+
locator: failure.locator || null,
|
|
910
|
+
blocker,
|
|
911
|
+
targetState: "visible_blocked"
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
if (inferMissingTargetFromMessage(failure)) {
|
|
915
|
+
return {
|
|
916
|
+
action: failure.codeContext?.action || null,
|
|
917
|
+
locator: failure.locator || null,
|
|
918
|
+
blocker: null,
|
|
919
|
+
targetState: "missing"
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
return {
|
|
923
|
+
action: failure.codeContext?.action || null,
|
|
924
|
+
locator: failure.locator || null,
|
|
925
|
+
blocker: null,
|
|
926
|
+
targetState: "unknown"
|
|
927
|
+
};
|
|
928
|
+
};
|
|
929
|
+
const blockedVerdictForCluster = (cluster) => {
|
|
930
|
+
if (!["timeout", "actionability", "locator_not_found"].includes(cluster.sample.signal))
|
|
931
|
+
return null;
|
|
932
|
+
const verdicts = cluster.failures
|
|
933
|
+
.map((failure) => blockedVerdictForFailure(failure))
|
|
934
|
+
.filter((value) => Boolean(value));
|
|
935
|
+
if (!verdicts.length)
|
|
936
|
+
return null;
|
|
937
|
+
const targetState = dominantValue(verdicts.map((verdict) => verdict.targetState)) || "unknown";
|
|
938
|
+
return {
|
|
939
|
+
action: dominantValue(verdicts.map((verdict) => verdict.action)),
|
|
940
|
+
locator: dominantValue(verdicts.map((verdict) => verdict.locator)),
|
|
941
|
+
blocker: dominantValue(verdicts.map((verdict) => verdict.blocker)),
|
|
942
|
+
targetState
|
|
943
|
+
};
|
|
944
|
+
};
|
|
425
945
|
const buildTouchedFileReason = (label, files) => `${label}: ${files.slice(0, 2).map((file) => basename(file)).join(", ")}`;
|
|
426
946
|
const commitTouchedFailure = (commit, failure) => {
|
|
427
947
|
const reasons = [];
|
|
@@ -549,6 +1069,25 @@ const flattenFailedCases = (node, titlePath = []) => {
|
|
|
549
1069
|
return failures;
|
|
550
1070
|
};
|
|
551
1071
|
const checkFirst = (failure) => {
|
|
1072
|
+
if (failure.signal === "setup") {
|
|
1073
|
+
const lower = failure.message.toLowerCase();
|
|
1074
|
+
if (/browsertype\.launch: executable doesn't exist|please run the following command to download new browsers|npx playwright install/.test(lower)) {
|
|
1075
|
+
return "run npx playwright install before rerunning";
|
|
1076
|
+
}
|
|
1077
|
+
if (/cannot find module ['"]@playwright\/test['"]|cannot find package ['"]@playwright\/test['"]/.test(lower)) {
|
|
1078
|
+
return "install @playwright/test in this project before rerunning";
|
|
1079
|
+
}
|
|
1080
|
+
if (/playwright test did not expect test\.describe\(\) to be called here/.test(lower)) {
|
|
1081
|
+
return "fix the Playwright package or test runner setup before rerunning";
|
|
1082
|
+
}
|
|
1083
|
+
if (/error: no tests found/.test(lower)) {
|
|
1084
|
+
return "fix the Playwright command, grep, or test discovery settings before rerunning";
|
|
1085
|
+
}
|
|
1086
|
+
if (/unknown command ['"]test['"]/.test(lower)) {
|
|
1087
|
+
return "install Playwright or run the local @playwright/test CLI before rerunning";
|
|
1088
|
+
}
|
|
1089
|
+
return "fix the Playwright setup error before rerunning";
|
|
1090
|
+
}
|
|
552
1091
|
if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
|
|
553
1092
|
const file = basename(failure.codeContext?.file || failure.likelyFile);
|
|
554
1093
|
const line = failure.codeContext?.line;
|
|
@@ -613,6 +1152,8 @@ const alternateCommitLine = (match) => {
|
|
|
613
1152
|
};
|
|
614
1153
|
const rootCauseLabel = (failure) => {
|
|
615
1154
|
switch (failure.signal) {
|
|
1155
|
+
case "setup":
|
|
1156
|
+
return "Playwright setup error";
|
|
616
1157
|
case "assertion_mismatch":
|
|
617
1158
|
return "UI assertion mismatch";
|
|
618
1159
|
case "locator_not_found":
|
|
@@ -632,16 +1173,62 @@ const rootCauseLabel = (failure) => {
|
|
|
632
1173
|
}
|
|
633
1174
|
};
|
|
634
1175
|
const describeFailure = (failure) => {
|
|
1176
|
+
if (failure.signal === "setup") {
|
|
1177
|
+
return `${setupErrorSummary(failure)}.`;
|
|
1178
|
+
}
|
|
1179
|
+
const navigation = navigationVerdictForFailure(failure);
|
|
1180
|
+
const pageError = recentPageErrorHint(failure.domCapture);
|
|
1181
|
+
const consoleError = recentConsoleErrorHint(failure.domCapture);
|
|
1182
|
+
if (navigation?.kind === "auth_redirect" && navigation.received) {
|
|
1183
|
+
return `The app redirected to "${truncateValue(navigation.received, 48)}" instead of continuing to the expected route.`;
|
|
1184
|
+
}
|
|
1185
|
+
if (navigation?.kind === "wrong_route" && navigation.expected && navigation.received) {
|
|
1186
|
+
return `The app opened "${truncateValue(navigation.received, 48)}" instead of "${truncateValue(navigation.expected, 48)}".`;
|
|
1187
|
+
}
|
|
1188
|
+
if (navigation?.kind === "navigation_timeout") {
|
|
1189
|
+
return `Navigation did not complete before timeout.`;
|
|
1190
|
+
}
|
|
1191
|
+
if (navigation?.kind === "blank_page") {
|
|
1192
|
+
return `The page ended on a blank route instead of the expected app screen.`;
|
|
1193
|
+
}
|
|
1194
|
+
const failedRequest = failedRequestFromDomCapture(failure.domCapture);
|
|
635
1195
|
if (failure.signal === "assertion_mismatch" && failure.locator && failure.expected && failure.received) {
|
|
636
|
-
return `${compactLocator(failure.locator)
|
|
1196
|
+
return `${describeAssertionDifference(compactLocator(failure.locator), failure.expected, failure.received)}.`;
|
|
1197
|
+
}
|
|
1198
|
+
if (failure.signal === "assertion_mismatch" && failure.locator && failure.expected) {
|
|
1199
|
+
const actionHint = assertionStateTransitionHint(failure);
|
|
1200
|
+
if (actionHint) {
|
|
1201
|
+
return `${compactLocator(failure.locator)} did not reach ${describeExpectedState(failure.expected) || "the expected state"} after ${actionHint}.`;
|
|
1202
|
+
}
|
|
1203
|
+
return `${compactLocator(failure.locator)} did not reach ${describeExpectedState(failure.expected) || "the expected state"}.`;
|
|
637
1204
|
}
|
|
638
1205
|
if (failure.signal === "locator_not_found" && failure.locator) {
|
|
639
1206
|
return `${compactLocator(failure.locator)} was not found when the test expected it to be available.`;
|
|
640
1207
|
}
|
|
641
1208
|
if (failure.signal === "actionability" && failure.locator) {
|
|
1209
|
+
const verdict = blockedVerdictForFailure(failure);
|
|
1210
|
+
const subtype = blockedActionSubtype(failure, verdict);
|
|
1211
|
+
if (subtype === "overlay" && verdict?.blocker) {
|
|
1212
|
+
return `${verdict.blocker} blocked ${failure.codeContext?.action || "the action"} on ${compactLocator(failure.locator)}.`;
|
|
1213
|
+
}
|
|
1214
|
+
if (subtype === "strict_mode") {
|
|
1215
|
+
return `${compactLocator(failure.locator)} matched multiple elements, so Playwright could not pick a single target.`;
|
|
1216
|
+
}
|
|
1217
|
+
if (subtype === "detached") {
|
|
1218
|
+
return `${compactLocator(failure.locator)} detached from the DOM before ${failure.codeContext?.action || "the action"} completed.`;
|
|
1219
|
+
}
|
|
1220
|
+
if (subtype === "offscreen") {
|
|
1221
|
+
return `${compactLocator(failure.locator)} never became reachable in the viewport for ${failure.codeContext?.action || "the action"}.`;
|
|
1222
|
+
}
|
|
1223
|
+
if (subtype === "focus") {
|
|
1224
|
+
return `${compactLocator(failure.locator)} was present but could not receive focus or input when the interaction ran.`;
|
|
1225
|
+
}
|
|
642
1226
|
return `${compactLocator(failure.locator)} was found but was not actionable when the interaction ran.`;
|
|
643
1227
|
}
|
|
644
1228
|
if (failure.signal === "network") {
|
|
1229
|
+
if (failedRequest) {
|
|
1230
|
+
return `${formatRequestLabel(failedRequest)} blocked the test flow before the expected UI state loaded.`;
|
|
1231
|
+
}
|
|
645
1232
|
return failure.apiHint
|
|
646
1233
|
? `A network or API request around ${failure.apiHint} did not complete successfully.`
|
|
647
1234
|
: `A network or API request did not complete successfully.`;
|
|
@@ -650,6 +1237,12 @@ const describeFailure = (failure) => {
|
|
|
650
1237
|
const domState = describeDomState(failure);
|
|
651
1238
|
if (domState)
|
|
652
1239
|
return `${domState}.`;
|
|
1240
|
+
const waitCondition = timeoutWaitConditionForFailure(failure);
|
|
1241
|
+
if (waitCondition) {
|
|
1242
|
+
return failure.timeoutMs
|
|
1243
|
+
? `Playwright timed out after ${failure.timeoutMs}ms while waiting for ${waitCondition}.`
|
|
1244
|
+
: `Playwright timed out while waiting for ${waitCondition}.`;
|
|
1245
|
+
}
|
|
653
1246
|
return failure.timeoutMs
|
|
654
1247
|
? `The expected UI or network condition did not complete before the ${failure.timeoutMs}ms timeout.`
|
|
655
1248
|
: `The expected UI or network condition did not complete before timeout.`;
|
|
@@ -658,6 +1251,12 @@ const describeFailure = (failure) => {
|
|
|
658
1251
|
return `The test code threw a retry or flaky guard error before the app flow completed.`;
|
|
659
1252
|
}
|
|
660
1253
|
if (failure.signal === "runtime") {
|
|
1254
|
+
if (pageError) {
|
|
1255
|
+
return `Page error "${pageError}" interrupted the flow before the expected state was reached.`;
|
|
1256
|
+
}
|
|
1257
|
+
if (consoleError) {
|
|
1258
|
+
return `Console error "${consoleError}" interrupted the flow before the expected state was reached.`;
|
|
1259
|
+
}
|
|
661
1260
|
return `A runtime error interrupted the test flow before the expected state was reached.`;
|
|
662
1261
|
}
|
|
663
1262
|
if (failure.signal === "infra") {
|
|
@@ -800,15 +1399,23 @@ const buildClusterLocationLine = (failures) => {
|
|
|
800
1399
|
};
|
|
801
1400
|
const buildEvidenceLines = (failure) => {
|
|
802
1401
|
const lines = [];
|
|
1402
|
+
const blockedVerdict = blockedVerdictForFailure(failure);
|
|
803
1403
|
const locationLine = buildLocationLine(failure);
|
|
1404
|
+
const pageError = recentPageErrorHint(failure.domCapture);
|
|
1405
|
+
const consoleError = recentConsoleErrorHint(failure.domCapture);
|
|
1406
|
+
const ariaHint = ariaSnapshotHint(failure.domCapture);
|
|
804
1407
|
if (locationLine)
|
|
805
1408
|
lines.push(locationLine);
|
|
806
|
-
if (failure.codeContext?.focusLine)
|
|
807
|
-
lines.push(`Failing code: ${failure.codeContext.focusLine.trim()}`);
|
|
808
1409
|
if (failure.codeContext?.action)
|
|
809
1410
|
lines.push(`Failing step: ${failure.codeContext.action}`);
|
|
810
1411
|
if (failure.locator)
|
|
811
1412
|
lines.push(`Selector: ${failure.locator}`);
|
|
1413
|
+
if (blockedVerdict?.blocker)
|
|
1414
|
+
lines.push(`Blocker: ${blockedVerdict.blocker}`);
|
|
1415
|
+
if (pageError)
|
|
1416
|
+
lines.push(`Page error: ${pageError}`);
|
|
1417
|
+
else if (consoleError)
|
|
1418
|
+
lines.push(`Console: ${consoleError}`);
|
|
812
1419
|
if (failure.signal === "timeout" && failure.domCapture) {
|
|
813
1420
|
if (failure.domCapture.targetFound === false || failure.domCapture.matchedCount === 0) {
|
|
814
1421
|
lines.push(`Target state: locator never appeared`);
|
|
@@ -823,6 +1430,9 @@ const buildEvidenceLines = (failure) => {
|
|
|
823
1430
|
lines.push(`Target state: found and visible before timeout`);
|
|
824
1431
|
}
|
|
825
1432
|
}
|
|
1433
|
+
if (!pageError && !consoleError && ariaHint && (failure.signal === "assertion_mismatch" || failure.signal === "locator_not_found")) {
|
|
1434
|
+
lines.push(`ARIA: ${ariaHint}`);
|
|
1435
|
+
}
|
|
826
1436
|
if (failure.apiHint)
|
|
827
1437
|
lines.push(`API: ${failure.apiHint}`);
|
|
828
1438
|
return lines.slice(0, 4);
|
|
@@ -840,29 +1450,97 @@ const compactLocator = (value) => {
|
|
|
840
1450
|
const compact = truncateValue(value, 40);
|
|
841
1451
|
return compact || "target element";
|
|
842
1452
|
};
|
|
1453
|
+
const setupErrorSummary = (failure) => {
|
|
1454
|
+
const message = failure.message.toLowerCase();
|
|
1455
|
+
if (/cannot find module ['"]@playwright\/test['"]|cannot find package ['"]@playwright\/test['"]/.test(message)) {
|
|
1456
|
+
return "@playwright/test is not installed";
|
|
1457
|
+
}
|
|
1458
|
+
if (/browsertype\.launch: executable doesn't exist|please run the following command to download new browsers|npx playwright install/.test(message)) {
|
|
1459
|
+
return "Playwright browsers are not installed";
|
|
1460
|
+
}
|
|
1461
|
+
if (/playwright test did not expect test\.describe\(\) to be called here/.test(message)) {
|
|
1462
|
+
return "Playwright loaded the test file outside the expected test runner context";
|
|
1463
|
+
}
|
|
1464
|
+
if (/error: no tests found/.test(message)) {
|
|
1465
|
+
return "No tests were discovered by Playwright";
|
|
1466
|
+
}
|
|
1467
|
+
if (/unknown command ['"]test['"]/.test(message)) {
|
|
1468
|
+
return "The Playwright CLI entrypoint is not available in this environment";
|
|
1469
|
+
}
|
|
1470
|
+
return compactErrorLine(failure) || "Playwright setup error";
|
|
1471
|
+
};
|
|
843
1472
|
const buildSecondaryEvidenceLines = (failure) => buildEvidenceLines(failure)
|
|
844
1473
|
.filter((line) => !line.startsWith("Error location:") && !line.startsWith("Likely file:"))
|
|
845
1474
|
.slice(0, 3);
|
|
846
1475
|
const compactRootCauseSummary = (cluster) => {
|
|
847
1476
|
const failure = cluster.sample;
|
|
1477
|
+
const navigation = navigationVerdictForFailure(failure);
|
|
1478
|
+
const failedRequest = failedRequestFromDomCapture(failure.domCapture);
|
|
1479
|
+
if (failure.signal === "setup") {
|
|
1480
|
+
return "Same Playwright setup error blocked these tests";
|
|
1481
|
+
}
|
|
848
1482
|
if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
|
|
849
1483
|
return "Same test-side throw before the app flow completed";
|
|
850
1484
|
}
|
|
851
1485
|
if (failure.signal === "network") {
|
|
1486
|
+
if (failedRequest) {
|
|
1487
|
+
return `${formatRequestLabel(failedRequest)} broke these tests`;
|
|
1488
|
+
}
|
|
852
1489
|
return failure.apiHint
|
|
853
1490
|
? `Same network/API failure around ${failure.apiHint}`
|
|
854
1491
|
: "Same network/API failure across these tests";
|
|
855
1492
|
}
|
|
856
1493
|
if (failure.signal === "timeout") {
|
|
857
|
-
|
|
1494
|
+
const verdict = blockedVerdictForCluster(cluster);
|
|
1495
|
+
const locator = verdict?.locator ? compactLocator(verdict.locator) : null;
|
|
1496
|
+
if (verdict?.targetState === "visible_blocked" && verdict.action && locator) {
|
|
1497
|
+
return `Same blocked ${verdict.action} on ${locator}`;
|
|
1498
|
+
}
|
|
1499
|
+
if (verdict?.targetState === "missing" && locator) {
|
|
1500
|
+
return `${locator} never appeared before the waiting step`;
|
|
1501
|
+
}
|
|
1502
|
+
if (verdict?.targetState === "hidden" && locator) {
|
|
1503
|
+
return `${locator} stayed hidden before the action completed`;
|
|
1504
|
+
}
|
|
1505
|
+
if (verdict?.targetState === "disabled" && locator) {
|
|
1506
|
+
return `${locator} stayed disabled before the action completed`;
|
|
1507
|
+
}
|
|
1508
|
+
return `Playwright timed out while waiting for ${timeoutWaitConditionForCluster(cluster)}`;
|
|
858
1509
|
}
|
|
859
1510
|
if (failure.signal === "assertion_mismatch") {
|
|
1511
|
+
if (navigation?.kind === "auth_redirect" && navigation.received) {
|
|
1512
|
+
return `The app redirected to "${truncateValue(navigation.received, 40)}" instead of the expected route`;
|
|
1513
|
+
}
|
|
1514
|
+
if (navigation?.kind === "wrong_route" && navigation.expected && navigation.received) {
|
|
1515
|
+
return `The app opened "${truncateValue(navigation.received, 40)}" instead of "${truncateValue(navigation.expected, 40)}"`;
|
|
1516
|
+
}
|
|
1517
|
+
const expected = representativeClusterExpected(cluster);
|
|
1518
|
+
const received = representativeClusterReceived(cluster);
|
|
1519
|
+
const selector = representativeClusterSelector(cluster);
|
|
1520
|
+
const actionHint = assertionStateTransitionHint(failure, cluster);
|
|
1521
|
+
if (selector && expected && received) {
|
|
1522
|
+
return `${selector} showed "${truncateValue(received, 56)}" instead of "${truncateValue(expected, 40)}"`;
|
|
1523
|
+
}
|
|
1524
|
+
if (selector && expected && actionHint) {
|
|
1525
|
+
return `${selector} did not reach "${truncateValue(expected, 40)}" after ${actionHint}`;
|
|
1526
|
+
}
|
|
860
1527
|
return "Same UI assertion mismatch across these tests";
|
|
861
1528
|
}
|
|
862
1529
|
if (failure.signal === "locator_not_found") {
|
|
863
1530
|
return "Same missing or changed locator across these tests";
|
|
864
1531
|
}
|
|
865
1532
|
if (failure.signal === "actionability") {
|
|
1533
|
+
const verdict = blockedVerdictForCluster(cluster);
|
|
1534
|
+
const subtype = blockedActionSubtype(failure, verdict);
|
|
1535
|
+
if (subtype === "overlay" && verdict?.blocker && verdict?.locator) {
|
|
1536
|
+
return `${verdict.blocker} blocked ${verdict.action || "the action"} on ${compactLocator(verdict.locator)}`;
|
|
1537
|
+
}
|
|
1538
|
+
if (subtype === "strict_mode" && verdict?.locator) {
|
|
1539
|
+
return `${compactLocator(verdict.locator)} matched multiple elements in each failure`;
|
|
1540
|
+
}
|
|
1541
|
+
if (subtype === "detached" && verdict?.locator) {
|
|
1542
|
+
return `${compactLocator(verdict.locator)} detached before the action completed`;
|
|
1543
|
+
}
|
|
866
1544
|
return "Same element actionability problem across these tests";
|
|
867
1545
|
}
|
|
868
1546
|
if (failure.signal === "infra") {
|
|
@@ -873,17 +1551,76 @@ const compactRootCauseSummary = (cluster) => {
|
|
|
873
1551
|
const compactIssueTitle = (cluster) => {
|
|
874
1552
|
const failure = cluster.sample;
|
|
875
1553
|
const locator = compactLocator(failure.locator);
|
|
1554
|
+
const blockedVerdict = blockedVerdictForCluster(cluster);
|
|
1555
|
+
const navigation = navigationVerdictForFailure(failure);
|
|
1556
|
+
const multiSelectorBlocked = isMultiSelectorBlockedCluster(cluster);
|
|
876
1557
|
switch (failure.signal) {
|
|
1558
|
+
case "setup":
|
|
1559
|
+
return "Playwright setup error";
|
|
877
1560
|
case "assertion_mismatch":
|
|
1561
|
+
if (navigation?.kind === "auth_redirect") {
|
|
1562
|
+
return "Redirected to login";
|
|
1563
|
+
}
|
|
1564
|
+
if (navigation?.kind === "wrong_route") {
|
|
1565
|
+
return "Wrong route opened";
|
|
1566
|
+
}
|
|
878
1567
|
return failure.locator ? `Assertion mismatch (${locator})` : "Assertion mismatch";
|
|
879
1568
|
case "locator_not_found":
|
|
880
1569
|
return failure.locator ? `Missing locator (${locator})` : "Missing locator";
|
|
881
1570
|
case "actionability":
|
|
1571
|
+
if (blockedVerdict?.locator) {
|
|
1572
|
+
const subtype = blockedActionSubtype(failure, blockedVerdict);
|
|
1573
|
+
if (multiSelectorBlocked) {
|
|
1574
|
+
if (subtype === "overlay")
|
|
1575
|
+
return "Overlay blocked interactions";
|
|
1576
|
+
if (subtype === "strict_mode")
|
|
1577
|
+
return "Strict-mode locator conflicts";
|
|
1578
|
+
if (subtype === "detached")
|
|
1579
|
+
return "Detached targets before interaction";
|
|
1580
|
+
if (subtype === "offscreen")
|
|
1581
|
+
return "Offscreen targets before interaction";
|
|
1582
|
+
if (subtype === "focus")
|
|
1583
|
+
return "Input targets blocked";
|
|
1584
|
+
return "Blocked interactions";
|
|
1585
|
+
}
|
|
1586
|
+
if (subtype === "strict_mode")
|
|
1587
|
+
return `Strict-mode locator conflict (${compactLocator(blockedVerdict.locator)})`;
|
|
1588
|
+
if (subtype === "detached")
|
|
1589
|
+
return `Detached target before ${blockedVerdict.action || "action"} (${compactLocator(blockedVerdict.locator)})`;
|
|
1590
|
+
if (subtype === "offscreen")
|
|
1591
|
+
return `Offscreen target before ${blockedVerdict.action || "action"} (${compactLocator(blockedVerdict.locator)})`;
|
|
1592
|
+
if (subtype === "focus")
|
|
1593
|
+
return `Input target blocked (${compactLocator(blockedVerdict.locator)})`;
|
|
1594
|
+
return `Blocked interaction (${compactLocator(blockedVerdict.locator)})`;
|
|
1595
|
+
}
|
|
882
1596
|
return failure.locator ? `Blocked interaction (${locator})` : "Blocked interaction";
|
|
883
1597
|
case "network":
|
|
884
|
-
return failure.apiHint ? `
|
|
1598
|
+
return failure.apiHint ? `Backend/API failure (${truncateValue(failure.apiHint, 28)})` : "Backend/API failure";
|
|
885
1599
|
case "timeout":
|
|
886
|
-
|
|
1600
|
+
if (navigation?.kind === "navigation_timeout")
|
|
1601
|
+
return "Navigation timeout";
|
|
1602
|
+
if (navigation?.kind === "blank_page")
|
|
1603
|
+
return "Blank page after navigation";
|
|
1604
|
+
if (multiSelectorBlocked) {
|
|
1605
|
+
const subtype = blockedActionSubtype(failure, blockedVerdict);
|
|
1606
|
+
if (subtype === "overlay")
|
|
1607
|
+
return "Overlay blocked interactions";
|
|
1608
|
+
if (subtype === "strict_mode")
|
|
1609
|
+
return "Strict-mode locator conflicts";
|
|
1610
|
+
}
|
|
1611
|
+
if (blockedVerdict?.targetState === "visible_blocked" && blockedVerdict.action && blockedVerdict.locator) {
|
|
1612
|
+
return `Blocked ${blockedVerdict.action} on ${compactLocator(blockedVerdict.locator)}`;
|
|
1613
|
+
}
|
|
1614
|
+
if (blockedVerdict?.targetState === "missing" && blockedVerdict.locator) {
|
|
1615
|
+
return `Timeout while waiting for ${compactLocator(blockedVerdict.locator)} to appear`;
|
|
1616
|
+
}
|
|
1617
|
+
if (blockedVerdict?.targetState === "hidden" && blockedVerdict.locator) {
|
|
1618
|
+
return `Timeout while waiting for ${compactLocator(blockedVerdict.locator)} to become visible`;
|
|
1619
|
+
}
|
|
1620
|
+
if (blockedVerdict?.targetState === "disabled" && blockedVerdict.locator) {
|
|
1621
|
+
return `Timeout while waiting for ${compactLocator(blockedVerdict.locator)} to become enabled`;
|
|
1622
|
+
}
|
|
1623
|
+
return `Timeout while waiting for ${timeoutWaitConditionForCluster(cluster)}`;
|
|
887
1624
|
case "runtime":
|
|
888
1625
|
return /retry|flaky/i.test(failure.message) ? "Test-side throw" : "Runtime error";
|
|
889
1626
|
case "infra":
|
|
@@ -894,9 +1631,31 @@ const compactIssueTitle = (cluster) => {
|
|
|
894
1631
|
};
|
|
895
1632
|
const clusterCauseLine = (cluster) => {
|
|
896
1633
|
const failure = cluster.sample;
|
|
1634
|
+
const blockedVerdict = blockedVerdictForCluster(cluster);
|
|
1635
|
+
const navigation = navigationVerdictForFailure(failure);
|
|
1636
|
+
const failedRequest = failedRequestFromDomCapture(failure.domCapture);
|
|
1637
|
+
if (failure.signal === "setup") {
|
|
1638
|
+
return setupErrorSummary(failure);
|
|
1639
|
+
}
|
|
897
1640
|
if (failure.signal === "assertion_mismatch") {
|
|
898
|
-
|
|
899
|
-
|
|
1641
|
+
if (navigation?.kind === "auth_redirect" && navigation.received) {
|
|
1642
|
+
return `The app redirected to "${truncateValue(navigation.received, 48)}" instead of the expected route`;
|
|
1643
|
+
}
|
|
1644
|
+
if (navigation?.kind === "wrong_route" && navigation.expected && navigation.received) {
|
|
1645
|
+
return `The app opened "${truncateValue(navigation.received, 48)}" instead of "${truncateValue(navigation.expected, 48)}"`;
|
|
1646
|
+
}
|
|
1647
|
+
const selector = representativeClusterSelector(cluster) || (failure.locator ? compactLocator(failure.locator) : null);
|
|
1648
|
+
const expected = representativeClusterExpected(cluster) || failure.expected;
|
|
1649
|
+
const received = representativeClusterReceived(cluster) || failure.received;
|
|
1650
|
+
const actionHint = assertionStateTransitionHint(failure, cluster);
|
|
1651
|
+
if (selector && expected && received) {
|
|
1652
|
+
return describeAssertionDifference(selector, expected, received);
|
|
1653
|
+
}
|
|
1654
|
+
if (selector && expected && actionHint) {
|
|
1655
|
+
return `${selector} did not reach ${describeExpectedState(expected) || "the expected state"} after ${actionHint}`;
|
|
1656
|
+
}
|
|
1657
|
+
return selector
|
|
1658
|
+
? `Same assertion mismatch on ${selector}`
|
|
900
1659
|
: "Same assertion mismatch across these tests";
|
|
901
1660
|
}
|
|
902
1661
|
if (failure.signal === "locator_not_found") {
|
|
@@ -905,28 +1664,218 @@ const clusterCauseLine = (cluster) => {
|
|
|
905
1664
|
: "Same missing or changed locator across these tests";
|
|
906
1665
|
}
|
|
907
1666
|
if (failure.signal === "actionability") {
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1667
|
+
const subtype = blockedActionSubtype(failure, blockedVerdict);
|
|
1668
|
+
if (isMultiSelectorBlockedCluster(cluster)) {
|
|
1669
|
+
if (subtype === "overlay" && blockedVerdict?.blocker) {
|
|
1670
|
+
return `${blockedVerdict.blocker} blocked interactions across these tests`;
|
|
1671
|
+
}
|
|
1672
|
+
if (subtype === "strict_mode") {
|
|
1673
|
+
return "The shared locator pattern matched multiple elements across these tests";
|
|
1674
|
+
}
|
|
1675
|
+
if (subtype === "detached") {
|
|
1676
|
+
return "Targets detached from the DOM before the interaction completed across these tests";
|
|
1677
|
+
}
|
|
1678
|
+
if (subtype === "offscreen") {
|
|
1679
|
+
return "Targets never became reachable in the viewport across these tests";
|
|
1680
|
+
}
|
|
1681
|
+
if (subtype === "focus") {
|
|
1682
|
+
return "Targets could not receive focus or input when the interaction ran";
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
if (subtype === "overlay" && blockedVerdict?.blocker && blockedVerdict?.locator) {
|
|
1686
|
+
return `${blockedVerdict.blocker} blocked ${blockedVerdict.action || "the action"} on ${compactLocator(blockedVerdict.locator)}`;
|
|
1687
|
+
}
|
|
1688
|
+
if (subtype === "strict_mode" && blockedVerdict?.locator) {
|
|
1689
|
+
return `${compactLocator(blockedVerdict.locator)} matched multiple elements, so Playwright could not pick one target`;
|
|
1690
|
+
}
|
|
1691
|
+
if (subtype === "detached" && blockedVerdict?.locator) {
|
|
1692
|
+
return `${compactLocator(blockedVerdict.locator)} detached from the DOM before the action completed`;
|
|
1693
|
+
}
|
|
1694
|
+
if (subtype === "offscreen" && blockedVerdict?.locator) {
|
|
1695
|
+
return `${compactLocator(blockedVerdict.locator)} never became reachable in the viewport`;
|
|
1696
|
+
}
|
|
1697
|
+
if (subtype === "focus" && blockedVerdict?.locator) {
|
|
1698
|
+
return `${compactLocator(blockedVerdict.locator)} could not receive focus or input when the action ran`;
|
|
1699
|
+
}
|
|
1700
|
+
return blockedVerdict?.locator
|
|
1701
|
+
? `${compactLocator(blockedVerdict.locator)} is present but blocked in each failure`
|
|
1702
|
+
: failure.locator
|
|
1703
|
+
? `${compactLocator(failure.locator)} is present but blocked in each failure`
|
|
1704
|
+
: "Same actionability problem across these tests";
|
|
1705
|
+
}
|
|
1706
|
+
if (failure.signal === "network") {
|
|
1707
|
+
if (failedRequest) {
|
|
1708
|
+
return `${formatRequestLabel(failedRequest)} blocked the expected UI flow`;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
if (failure.signal === "timeout") {
|
|
1712
|
+
if (navigation?.kind === "navigation_timeout") {
|
|
1713
|
+
return `The route change or page load never completed before timeout`;
|
|
1714
|
+
}
|
|
1715
|
+
if (navigation?.kind === "blank_page") {
|
|
1716
|
+
return `The app ended on a blank page instead of the expected screen`;
|
|
1717
|
+
}
|
|
1718
|
+
if (isMultiSelectorBlockedCluster(cluster)) {
|
|
1719
|
+
const subtype = blockedActionSubtype(failure, blockedVerdict);
|
|
1720
|
+
if (subtype === "overlay" && blockedVerdict?.blocker) {
|
|
1721
|
+
return `${blockedVerdict.blocker} blocked interactions across these tests before the expected state changed`;
|
|
1722
|
+
}
|
|
1723
|
+
if (subtype === "strict_mode") {
|
|
1724
|
+
return "The shared locator pattern matched multiple elements across these tests";
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
if (blockedVerdict?.targetState === "visible_blocked" && blockedVerdict.action && blockedVerdict.locator) {
|
|
1728
|
+
if (blockedVerdict.blocker) {
|
|
1729
|
+
return `${blockedVerdict.blocker} blocked ${blockedVerdict.action} on ${compactLocator(blockedVerdict.locator)}`;
|
|
1730
|
+
}
|
|
1731
|
+
return `${blockedVerdict.action} on ${compactLocator(blockedVerdict.locator)} was blocked before the state changed`;
|
|
1732
|
+
}
|
|
1733
|
+
if (blockedVerdict?.targetState === "missing" && blockedVerdict.locator) {
|
|
1734
|
+
return `Playwright kept waiting for ${compactLocator(blockedVerdict.locator)} to appear, but it never rendered before timeout`;
|
|
1735
|
+
}
|
|
1736
|
+
if (blockedVerdict?.targetState === "hidden" && blockedVerdict.locator) {
|
|
1737
|
+
return `Playwright kept waiting for ${compactLocator(blockedVerdict.locator)} to become visible, but it stayed hidden`;
|
|
1738
|
+
}
|
|
1739
|
+
if (blockedVerdict?.targetState === "disabled" && blockedVerdict.locator) {
|
|
1740
|
+
return `Playwright kept waiting for ${compactLocator(blockedVerdict.locator)} to become enabled, but it stayed disabled`;
|
|
1741
|
+
}
|
|
1742
|
+
return `Playwright timed out while waiting for ${timeoutWaitConditionForCluster(cluster)}`;
|
|
911
1743
|
}
|
|
912
1744
|
return compactRootCauseSummary(cluster);
|
|
913
1745
|
};
|
|
914
1746
|
const strongerClusterNext = (cluster) => {
|
|
915
1747
|
const failure = cluster.sample;
|
|
1748
|
+
const blockedVerdict = blockedVerdictForCluster(cluster);
|
|
1749
|
+
const navigation = navigationVerdictForFailure(failure);
|
|
1750
|
+
const failedRequest = failedRequestFromDomCapture(failure.domCapture);
|
|
1751
|
+
if (failure.signal === "setup") {
|
|
1752
|
+
return checkFirst(failure);
|
|
1753
|
+
}
|
|
916
1754
|
if (failure.signal === "assertion_mismatch") {
|
|
1755
|
+
if (navigation?.kind === "auth_redirect") {
|
|
1756
|
+
return "check auth/session setup or redirect guards before this route loads";
|
|
1757
|
+
}
|
|
1758
|
+
if (navigation?.kind === "wrong_route" && navigation.expected) {
|
|
1759
|
+
return `check the route change or redirect logic so the app lands on "${truncateValue(navigation.expected, 40)}"`;
|
|
1760
|
+
}
|
|
1761
|
+
const selector = representativeClusterSelector(cluster) || (failure.locator ? compactLocator(failure.locator) : null);
|
|
1762
|
+
const expected = representativeClusterExpected(cluster) || failure.expected;
|
|
1763
|
+
const received = representativeClusterReceived(cluster) || failure.received;
|
|
1764
|
+
const actionHint = assertionStateTransitionHint(failure, cluster);
|
|
917
1765
|
if (failure.locator && failure.apiHint) {
|
|
918
1766
|
return `check ${compactLocator(failure.locator)} or the data returned by ${truncateValue(failure.apiHint, 36)}`;
|
|
919
1767
|
}
|
|
920
|
-
if (
|
|
921
|
-
|
|
1768
|
+
if (selector && expected && received && actionHint) {
|
|
1769
|
+
if (isStateExpectation(expected) && isStateExpectation(received)) {
|
|
1770
|
+
return `fix the UI state after ${actionHint} so ${selector} is ${truncateValue(expected, 24)} instead of ${truncateValue(received, 24)}`;
|
|
1771
|
+
}
|
|
1772
|
+
if (/^count \d+$/i.test(expected) && /^count \d+$/i.test(received)) {
|
|
1773
|
+
return `fix the UI state after ${actionHint} so ${selector} reaches ${expected} instead of ${received}`;
|
|
1774
|
+
}
|
|
1775
|
+
return `fix the UI state after ${actionHint} so ${selector} shows "${truncateValue(expected, 28)}" instead of "${truncateValue(received, 28)}"`;
|
|
1776
|
+
}
|
|
1777
|
+
if (selector && expected && received) {
|
|
1778
|
+
if (isStateExpectation(expected) && isStateExpectation(received)) {
|
|
1779
|
+
return `fix the UI state behind ${selector} so it is ${truncateValue(expected, 24)} instead of ${truncateValue(received, 24)}`;
|
|
1780
|
+
}
|
|
1781
|
+
if (/^count \d+$/i.test(expected) && /^count \d+$/i.test(received)) {
|
|
1782
|
+
return `fix the UI state behind ${selector} so it reaches ${expected} instead of ${received}`;
|
|
1783
|
+
}
|
|
1784
|
+
return `fix the UI state behind ${selector} so it shows "${truncateValue(expected, 28)}" instead of "${truncateValue(received, 28)}"`;
|
|
1785
|
+
}
|
|
1786
|
+
if (selector && expected && actionHint) {
|
|
1787
|
+
return `fix the UI state after ${actionHint} so ${assertionWaitCondition(selector, expected)}`;
|
|
1788
|
+
}
|
|
1789
|
+
if (selector && received) {
|
|
1790
|
+
return `fix the state update behind ${selector} so it no longer shows "${truncateValue(received, 28)}"`;
|
|
1791
|
+
}
|
|
1792
|
+
if (selector) {
|
|
1793
|
+
return `check ${selector} or the data source behind it`;
|
|
922
1794
|
}
|
|
923
1795
|
}
|
|
924
1796
|
if (failure.signal === "locator_not_found" && failure.locator) {
|
|
925
1797
|
return `check whether ${compactLocator(failure.locator)} changed or no longer renders`;
|
|
926
1798
|
}
|
|
927
1799
|
if (failure.signal === "actionability" && failure.locator) {
|
|
1800
|
+
const subtype = blockedActionSubtype(failure, blockedVerdict);
|
|
1801
|
+
if (isMultiSelectorBlockedCluster(cluster)) {
|
|
1802
|
+
if (subtype === "overlay" && blockedVerdict?.blocker) {
|
|
1803
|
+
return `remove or dismiss ${blockedVerdict.blocker} before these interactions run`;
|
|
1804
|
+
}
|
|
1805
|
+
if (subtype === "strict_mode") {
|
|
1806
|
+
return "narrow the shared locator pattern so each interaction matches exactly one element";
|
|
1807
|
+
}
|
|
1808
|
+
if (subtype === "detached") {
|
|
1809
|
+
return "stabilize the DOM so the targets do not detach before the interactions run";
|
|
1810
|
+
}
|
|
1811
|
+
if (subtype === "offscreen") {
|
|
1812
|
+
return "make the targets reachable in the viewport before the interactions run";
|
|
1813
|
+
}
|
|
1814
|
+
if (subtype === "focus") {
|
|
1815
|
+
return "make the targets focusable/editable before the interactions run";
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
if (subtype === "strict_mode" && blockedVerdict?.locator) {
|
|
1819
|
+
return `narrow ${compactLocator(blockedVerdict.locator)} so it matches exactly one element`;
|
|
1820
|
+
}
|
|
1821
|
+
if (subtype === "detached" && blockedVerdict?.locator) {
|
|
1822
|
+
return `stabilize the DOM so ${compactLocator(blockedVerdict.locator)} does not detach before the action runs`;
|
|
1823
|
+
}
|
|
1824
|
+
if (subtype === "offscreen" && blockedVerdict?.locator) {
|
|
1825
|
+
return `make ${compactLocator(blockedVerdict.locator)} reachable in the viewport before the action`;
|
|
1826
|
+
}
|
|
1827
|
+
if (subtype === "focus" && blockedVerdict?.locator) {
|
|
1828
|
+
return `make ${compactLocator(blockedVerdict.locator)} focusable/editable before the action`;
|
|
1829
|
+
}
|
|
1830
|
+
if (blockedVerdict?.targetState === "visible_blocked" && blockedVerdict.locator) {
|
|
1831
|
+
return `remove what blocks ${compactLocator(blockedVerdict.locator)} from becoming actionable`;
|
|
1832
|
+
}
|
|
928
1833
|
return `check what blocks ${compactLocator(failure.locator)} from becoming actionable`;
|
|
929
1834
|
}
|
|
1835
|
+
if (failure.signal === "network") {
|
|
1836
|
+
if (failedRequest?.url) {
|
|
1837
|
+
return `fix the failing response for ${truncateValue(failedRequest.url, 44)} before debugging the UI`;
|
|
1838
|
+
}
|
|
1839
|
+
return failure.apiHint ? `fix the failing backend response around ${truncateValue(failure.apiHint, 44)}` : "fix the failing request or backend response";
|
|
1840
|
+
}
|
|
1841
|
+
if (failure.signal === "timeout") {
|
|
1842
|
+
if (navigation?.kind === "navigation_timeout") {
|
|
1843
|
+
return "check route loading, redirects, or slow backend responses before the page transition";
|
|
1844
|
+
}
|
|
1845
|
+
if (navigation?.kind === "blank_page") {
|
|
1846
|
+
return "check why the app ended on a blank route after navigation";
|
|
1847
|
+
}
|
|
1848
|
+
if (isMultiSelectorBlockedCluster(cluster)) {
|
|
1849
|
+
const subtype = blockedActionSubtype(failure, blockedVerdict);
|
|
1850
|
+
if (subtype === "overlay" && blockedVerdict?.blocker) {
|
|
1851
|
+
return `remove or dismiss ${blockedVerdict.blocker} before these interactions run`;
|
|
1852
|
+
}
|
|
1853
|
+
if (subtype === "strict_mode") {
|
|
1854
|
+
return "narrow the shared locator pattern so each interaction matches exactly one element";
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
if (blockedVerdict?.targetState === "visible_blocked" && blockedVerdict.action && blockedVerdict.locator) {
|
|
1858
|
+
return blockedVerdict.blocker
|
|
1859
|
+
? `remove or dismiss what blocks ${blockedVerdict.action} on ${compactLocator(blockedVerdict.locator)}`
|
|
1860
|
+
: `fix what blocks ${blockedVerdict.action} on ${compactLocator(blockedVerdict.locator)}`;
|
|
1861
|
+
}
|
|
1862
|
+
if (blockedVerdict?.targetState === "missing" && blockedVerdict.locator) {
|
|
1863
|
+
const actionLabel = capitalize(blockedVerdict.action) || "this step";
|
|
1864
|
+
return `make ${compactLocator(blockedVerdict.locator)} render before ${actionLabel.toLowerCase()} runs`;
|
|
1865
|
+
}
|
|
1866
|
+
if (blockedVerdict?.targetState === "hidden" && blockedVerdict.locator) {
|
|
1867
|
+
return `remove the condition keeping ${compactLocator(blockedVerdict.locator)} hidden before the action`;
|
|
1868
|
+
}
|
|
1869
|
+
if (blockedVerdict?.targetState === "disabled" && blockedVerdict.locator) {
|
|
1870
|
+
return `enable ${compactLocator(blockedVerdict.locator)} before the action runs`;
|
|
1871
|
+
}
|
|
1872
|
+
if (failure.locator && failure.codeContext?.action === "click") {
|
|
1873
|
+
return `check why ${compactLocator(failure.locator)} did not become clickable before timeout`;
|
|
1874
|
+
}
|
|
1875
|
+
if (failure.locator && failure.expected) {
|
|
1876
|
+
return `check why ${compactLocator(failure.locator)} did not reach "${truncateValue(failure.expected, 28)}" before timeout`;
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
930
1879
|
return clusterCheckFirst(cluster);
|
|
931
1880
|
};
|
|
932
1881
|
const compactErrorLine = (failure) => {
|
|
@@ -934,8 +1883,24 @@ const compactErrorLine = (failure) => {
|
|
|
934
1883
|
return null;
|
|
935
1884
|
return withoutPrefix(failure.firstErrorLine, "Error:");
|
|
936
1885
|
};
|
|
1886
|
+
const extractAssertionLineFromMessage = (message) => {
|
|
1887
|
+
if (!message)
|
|
1888
|
+
return null;
|
|
1889
|
+
const lines = stripAnsi(message).split(/\r?\n/);
|
|
1890
|
+
const markedAssertion = lines.find((line) => /^\s*>\s*\d+\s*\|.*\b(await\s+)?expect\(/.test(line));
|
|
1891
|
+
if (markedAssertion) {
|
|
1892
|
+
return markedAssertion
|
|
1893
|
+
.replace(/^\s*>\s*/, "")
|
|
1894
|
+
.replace(/^\s*\d+\s*\|\s*/, "")
|
|
1895
|
+
.trim() || null;
|
|
1896
|
+
}
|
|
1897
|
+
const assertionLine = lines.find((line) => /\b(await\s+)?expect\(/.test(line));
|
|
1898
|
+
return assertionLine?.trim() || null;
|
|
1899
|
+
};
|
|
937
1900
|
const parseFailureFacts = (title, titlePath, message, status, file = null, options) => {
|
|
938
|
-
|
|
1901
|
+
let signal = classifySignal(message);
|
|
1902
|
+
const assertionLine = extractAssertionLineFromMessage(message);
|
|
1903
|
+
const assertionKind = extractAssertionKind(assertionLine);
|
|
939
1904
|
const fallbackLocation = options?.errorLocation || extractStackLocation(message);
|
|
940
1905
|
const fallbackCodeContext = fallbackLocation || options?.errorSnippet
|
|
941
1906
|
? {
|
|
@@ -947,9 +1912,9 @@ const parseFailureFacts = (title, titlePath, message, status, file = null, optio
|
|
|
947
1912
|
expectedText: null,
|
|
948
1913
|
timeoutMs: null,
|
|
949
1914
|
apiCall: null,
|
|
950
|
-
assertion:
|
|
1915
|
+
assertion: assertionLine,
|
|
951
1916
|
methodName: null,
|
|
952
|
-
focusLine: extractFocusLineFromSnippet(options?.errorSnippet),
|
|
1917
|
+
focusLine: assertionLine || extractFocusLineFromSnippet(options?.errorSnippet),
|
|
953
1918
|
previousActionLine: null,
|
|
954
1919
|
found: Boolean(fallbackLocation?.file || options?.errorSnippet)
|
|
955
1920
|
}
|
|
@@ -961,13 +1926,30 @@ const parseFailureFacts = (title, titlePath, message, status, file = null, optio
|
|
|
961
1926
|
file: options.codeContext.file || fallbackCodeContext?.file || null,
|
|
962
1927
|
line: options.codeContext.line ?? fallbackCodeContext?.line ?? null,
|
|
963
1928
|
column: options.codeContext.column ?? fallbackCodeContext?.column ?? null,
|
|
964
|
-
|
|
1929
|
+
assertion: assertionLine || options.codeContext.assertion || fallbackCodeContext?.assertion || null,
|
|
1930
|
+
focusLine: signal === "assertion_mismatch"
|
|
1931
|
+
? assertionLine ||
|
|
1932
|
+
options.codeContext.focusLine ||
|
|
1933
|
+
fallbackCodeContext?.focusLine ||
|
|
1934
|
+
options.codeContext.assertion ||
|
|
1935
|
+
fallbackCodeContext?.assertion ||
|
|
1936
|
+
options.codeContext.focusLine ||
|
|
1937
|
+
extractFocusLineFromMessage(message) ||
|
|
1938
|
+
null
|
|
1939
|
+
: options.codeContext.focusLine || fallbackCodeContext?.focusLine || extractFocusLineFromMessage(message) || null,
|
|
965
1940
|
found: options.codeContext.found ?? fallbackCodeContext?.found ?? false
|
|
966
1941
|
}
|
|
967
1942
|
: fallbackCodeContext
|
|
968
1943
|
? {
|
|
969
1944
|
...fallbackCodeContext,
|
|
970
|
-
|
|
1945
|
+
assertion: assertionLine || fallbackCodeContext.assertion || null,
|
|
1946
|
+
focusLine: signal === "assertion_mismatch"
|
|
1947
|
+
? assertionLine ||
|
|
1948
|
+
fallbackCodeContext.focusLine ||
|
|
1949
|
+
fallbackCodeContext.assertion ||
|
|
1950
|
+
extractFocusLineFromMessage(message) ||
|
|
1951
|
+
null
|
|
1952
|
+
: fallbackCodeContext.focusLine || extractFocusLineFromMessage(message) || null
|
|
971
1953
|
}
|
|
972
1954
|
: {
|
|
973
1955
|
file: fallbackLocation?.file || file || null,
|
|
@@ -978,19 +1960,64 @@ const parseFailureFacts = (title, titlePath, message, status, file = null, optio
|
|
|
978
1960
|
expectedText: null,
|
|
979
1961
|
timeoutMs: null,
|
|
980
1962
|
apiCall: null,
|
|
981
|
-
assertion:
|
|
1963
|
+
assertion: assertionLine,
|
|
982
1964
|
methodName: null,
|
|
983
|
-
focusLine:
|
|
1965
|
+
focusLine: signal === "assertion_mismatch"
|
|
1966
|
+
? assertionLine || extractFocusLineFromMessage(message)
|
|
1967
|
+
: extractFocusLineFromMessage(message),
|
|
984
1968
|
previousActionLine: null,
|
|
985
|
-
found: Boolean(fallbackLocation?.file || extractFocusLineFromMessage(message))
|
|
1969
|
+
found: Boolean(fallbackLocation?.file || assertionLine || extractFocusLineFromMessage(message))
|
|
986
1970
|
};
|
|
987
1971
|
const domCapture = options?.domCapture || null;
|
|
988
|
-
const locator =
|
|
989
|
-
|
|
990
|
-
|
|
1972
|
+
const locator = domCapture?.normalizedLocator ||
|
|
1973
|
+
extractLocator(message) ||
|
|
1974
|
+
codeContext?.locator ||
|
|
1975
|
+
domCapture?.locator ||
|
|
1976
|
+
null;
|
|
1977
|
+
const extractedExpected = extractExpected(message);
|
|
1978
|
+
const extractedReceived = extractReceived(message);
|
|
1979
|
+
const expected = extractedExpected ||
|
|
1980
|
+
codeContext?.expectedText ||
|
|
1981
|
+
((signal === "assertion_mismatch" || assertionKind !== "unknown")
|
|
1982
|
+
? extractExpectedFromAssertionLine(codeContext?.assertion || codeContext?.focusLine || null)
|
|
1983
|
+
: null) ||
|
|
1984
|
+
domCapture?.expectedText ||
|
|
1985
|
+
null;
|
|
1986
|
+
const received = extractedReceived ||
|
|
1987
|
+
extractObservedAssertionValue(domCapture, expected, assertionKind) ||
|
|
1988
|
+
extractObservedDomText(domCapture) ||
|
|
1989
|
+
null;
|
|
1990
|
+
if (signal === "timeout" &&
|
|
1991
|
+
expected &&
|
|
1992
|
+
(received ||
|
|
1993
|
+
codeContext?.action === "expect_text" ||
|
|
1994
|
+
codeContext?.action === "assert" ||
|
|
1995
|
+
Boolean(codeContext?.assertion) ||
|
|
1996
|
+
message.toLowerCase().includes("expect") ||
|
|
1997
|
+
message.toLowerCase().includes("tohavetext") ||
|
|
1998
|
+
message.toLowerCase().includes("tocontaintext") ||
|
|
1999
|
+
message.toLowerCase().includes("received string"))) {
|
|
2000
|
+
signal = "assertion_mismatch";
|
|
2001
|
+
}
|
|
991
2002
|
const likelyFile = inferLikelyFile(file, codeContext);
|
|
992
2003
|
const likelyModule = inferLikelyModule(file, locator, codeContext);
|
|
993
|
-
const
|
|
2004
|
+
const failedRequest = failedRequestFromDomCapture(domCapture);
|
|
2005
|
+
const apiHint = extractApiHint(message, codeContext) || failedRequest?.url || null;
|
|
2006
|
+
if (signal === "timeout" &&
|
|
2007
|
+
/request failed|failed to fetch|socket hang up|econnreset|status\s*[45]\d{2}|net::|response timed out|api/i.test(message.toLowerCase()) &&
|
|
2008
|
+
apiHint) {
|
|
2009
|
+
signal = "network";
|
|
2010
|
+
}
|
|
2011
|
+
if ((signal === "timeout" || signal === "assertion_mismatch") &&
|
|
2012
|
+
failedRequest &&
|
|
2013
|
+
((typeof failedRequest.status === "number" && failedRequest.status >= 400) || failedRequest.failure)) {
|
|
2014
|
+
signal = "network";
|
|
2015
|
+
}
|
|
2016
|
+
if ((signal === "timeout" || signal === "assertion_mismatch" || signal === "unknown") &&
|
|
2017
|
+
!failedRequest &&
|
|
2018
|
+
(recentPageErrorHint(domCapture) || recentConsoleErrorHint(domCapture))) {
|
|
2019
|
+
signal = "runtime";
|
|
2020
|
+
}
|
|
994
2021
|
return {
|
|
995
2022
|
title,
|
|
996
2023
|
titlePath,
|
|
@@ -1042,48 +2069,69 @@ const buildDebugSummary = (failure) => {
|
|
|
1042
2069
|
exports.buildDebugSummary = buildDebugSummary;
|
|
1043
2070
|
const buildSimilarityKey = (failure) => {
|
|
1044
2071
|
const locationKey = failure.codeContext?.line ? `${basename(failure.codeContext.file)}:${failure.codeContext.line}` : basename(failure.likelyFile) || "unknown-file";
|
|
2072
|
+
const canonicalLocator = canonicalLocatorForFailure(failure);
|
|
2073
|
+
const requestKey = requestFingerprint(failure);
|
|
2074
|
+
const pageErrorKey = pageErrorFingerprint(failure);
|
|
1045
2075
|
if (failure.signal === 'runtime' || failure.signal === 'unknown') {
|
|
1046
2076
|
return [
|
|
1047
2077
|
failure.signal,
|
|
1048
|
-
normalizeMessageFingerprint(failure.message),
|
|
1049
|
-
locationKey
|
|
2078
|
+
pageErrorKey || normalizeMessageFingerprint(failure.message),
|
|
2079
|
+
canonicalLocator || locationKey
|
|
1050
2080
|
].join('|');
|
|
1051
2081
|
}
|
|
1052
2082
|
if (failure.signal === "assertion_mismatch") {
|
|
2083
|
+
const assertionKind = extractAssertionKind(failure.codeContext?.assertion || failure.codeContext?.focusLine || null);
|
|
1053
2084
|
return [
|
|
1054
2085
|
failure.signal,
|
|
1055
|
-
|
|
1056
|
-
|
|
2086
|
+
canonicalLocator || failure.likelyModule || basename(failure.likelyFile) || "unknown-target",
|
|
2087
|
+
assertionKind,
|
|
2088
|
+
failure.expected || "unknown-expected",
|
|
2089
|
+
failure.received || pageErrorKey || "unknown-received"
|
|
1057
2090
|
].join("|");
|
|
1058
2091
|
}
|
|
2092
|
+
if (failure.signal === "setup") {
|
|
2093
|
+
return [failure.signal, normalizeMessageFingerprint(failure.message)].join("|");
|
|
2094
|
+
}
|
|
1059
2095
|
if (failure.signal === "timeout" || failure.signal === "actionability" || failure.signal === "locator_not_found") {
|
|
2096
|
+
const verdict = blockedVerdictForFailure(failure);
|
|
2097
|
+
const blockerKey = blockerGroupingKey(failure, verdict);
|
|
2098
|
+
const subtype = blockedActionSubtype(failure, verdict);
|
|
2099
|
+
if (blockerKey && (subtype === "overlay" || subtype === "strict_mode" || subtype === "detached" || subtype === "offscreen" || subtype === "focus")) {
|
|
2100
|
+
return [
|
|
2101
|
+
"blocked_action_family",
|
|
2102
|
+
blockerKey
|
|
2103
|
+
].join("|");
|
|
2104
|
+
}
|
|
1060
2105
|
return [
|
|
1061
2106
|
failure.signal,
|
|
1062
|
-
|
|
1063
|
-
|
|
2107
|
+
canonicalLocator || failure.codeContext?.action || failure.likelyModule || "unknown-target",
|
|
2108
|
+
failure.codeContext?.action || "unknown-action",
|
|
2109
|
+
pageErrorKey || locationKey
|
|
1064
2110
|
].join("|");
|
|
1065
2111
|
}
|
|
1066
2112
|
if (failure.signal === "network") {
|
|
1067
2113
|
return [
|
|
1068
2114
|
failure.signal,
|
|
1069
|
-
failure.apiHint || failure.likelyModule || "unknown-api",
|
|
1070
|
-
basename(failure.likelyFile) || "unknown-file"
|
|
2115
|
+
requestKey || normalizedUrlPath(failure.apiHint) || failure.likelyModule || "unknown-api",
|
|
2116
|
+
pageErrorKey || basename(failure.likelyFile) || "unknown-file"
|
|
1071
2117
|
].join("|");
|
|
1072
2118
|
}
|
|
1073
2119
|
if (failure.locator || failure.expected || failure.received || failure.apiHint) {
|
|
1074
2120
|
return [
|
|
1075
2121
|
failure.signal,
|
|
1076
|
-
|
|
2122
|
+
canonicalLocator || normalizedUrlPath(failure.apiHint) || "unknown-target",
|
|
1077
2123
|
failure.expected || "unknown-expected",
|
|
1078
|
-
failure.received || "unknown-received",
|
|
1079
|
-
locationKey
|
|
2124
|
+
failure.received || requestKey || pageErrorKey || "unknown-received",
|
|
2125
|
+
pageErrorKey || locationKey
|
|
1080
2126
|
].join("|");
|
|
1081
2127
|
}
|
|
1082
|
-
return `${failure.signal}|${normalizeMessageFingerprint(failure.message)}|${locationKey}`;
|
|
2128
|
+
return `${failure.signal}|${pageErrorKey || normalizeMessageFingerprint(failure.message)}|${canonicalLocator || locationKey}`;
|
|
1083
2129
|
};
|
|
1084
2130
|
exports.buildSimilarityKey = buildSimilarityKey;
|
|
1085
2131
|
const summarizeSignal = (signal) => {
|
|
1086
2132
|
switch (signal) {
|
|
2133
|
+
case "setup":
|
|
2134
|
+
return "Playwright setup or bootstrap error";
|
|
1087
2135
|
case "timeout":
|
|
1088
2136
|
return "timeout while waiting for UI or network conditions";
|
|
1089
2137
|
case "assertion_mismatch":
|
|
@@ -1116,7 +2164,212 @@ const buildClusterSuspects = (clusterFailures, window) => {
|
|
|
1116
2164
|
.sort((a, b) => b.score - a.score)
|
|
1117
2165
|
.slice(0, 2);
|
|
1118
2166
|
};
|
|
1119
|
-
const
|
|
2167
|
+
const locationValueForFailure = (failure) => {
|
|
2168
|
+
const primaryLocation = buildLocationLine(failure);
|
|
2169
|
+
return primaryLocation
|
|
2170
|
+
? withoutPrefix(withoutPrefix(primaryLocation, "Error location:"), "Likely file:")
|
|
2171
|
+
: null;
|
|
2172
|
+
};
|
|
2173
|
+
const locationValueForCluster = (cluster) => {
|
|
2174
|
+
const clusterLocation = buildClusterLocationLine(cluster.failures);
|
|
2175
|
+
return clusterLocation
|
|
2176
|
+
? withoutPrefix(clusterLocation, clusterLocation.startsWith("Error locations:") ? "Error locations:" : "Error location:")
|
|
2177
|
+
: null;
|
|
2178
|
+
};
|
|
2179
|
+
const representativeClusterValue = (failures, pick) => dominantValue(failures.map((failure) => pick(failure)));
|
|
2180
|
+
const representativeClusterExpected = (cluster) => representativeClusterValue(cluster.failures, (failure) => failure.expected);
|
|
2181
|
+
const representativeClusterReceived = (cluster) => representativeClusterValue(cluster.failures, (failure) => failure.received);
|
|
2182
|
+
const representativeClusterSelector = (cluster) => {
|
|
2183
|
+
const evidenceSelector = buildClusterEvidenceLines(cluster)
|
|
2184
|
+
.find((line) => line.startsWith("Selector:"));
|
|
2185
|
+
if (evidenceSelector)
|
|
2186
|
+
return withoutPrefix(evidenceSelector, "Selector:");
|
|
2187
|
+
return representativeClusterValue(cluster.failures, (failure) => canonicalLocatorForFailure(failure));
|
|
2188
|
+
};
|
|
2189
|
+
const representativeClusterBlocker = (cluster) => {
|
|
2190
|
+
const evidenceBlocker = buildClusterEvidenceLines(cluster)
|
|
2191
|
+
.find((line) => line.startsWith("Blocker:"));
|
|
2192
|
+
if (evidenceBlocker)
|
|
2193
|
+
return withoutPrefix(evidenceBlocker, "Blocker:");
|
|
2194
|
+
return representativeClusterValue(cluster.failures, (failure) => blockedVerdictForFailure(failure)?.blocker);
|
|
2195
|
+
};
|
|
2196
|
+
const representativeClusterTargetState = (cluster) => {
|
|
2197
|
+
const evidenceState = buildClusterEvidenceLines(cluster)
|
|
2198
|
+
.find((line) => line.startsWith("Target state:"));
|
|
2199
|
+
if (evidenceState)
|
|
2200
|
+
return withoutPrefix(evidenceState, "Target state:");
|
|
2201
|
+
const verdict = blockedVerdictForCluster(cluster);
|
|
2202
|
+
if (!verdict)
|
|
2203
|
+
return null;
|
|
2204
|
+
switch (verdict.targetState) {
|
|
2205
|
+
case "visible_blocked":
|
|
2206
|
+
return "found and visible before timeout";
|
|
2207
|
+
case "missing":
|
|
2208
|
+
return "locator never appeared";
|
|
2209
|
+
case "hidden":
|
|
2210
|
+
return "found but hidden";
|
|
2211
|
+
case "disabled":
|
|
2212
|
+
return "found but disabled";
|
|
2213
|
+
default:
|
|
2214
|
+
return null;
|
|
2215
|
+
}
|
|
2216
|
+
};
|
|
2217
|
+
const representativeClusterFailingCode = (cluster) => representativeClusterValue(cluster.failures, (failure) => failure.codeContext?.focusLine || null);
|
|
2218
|
+
const representativeClusterFailingStep = (cluster) => representativeClusterValue(cluster.failures, (failure) => failure.codeContext?.action || null);
|
|
2219
|
+
const representativeClusterPreviousAction = (cluster) => representativeClusterValue(cluster.failures, (failure) => failure.codeContext?.previousActionLine || null);
|
|
2220
|
+
const compactActionLine = (value) => truncateValue(value, 84);
|
|
2221
|
+
const assertionStateTransitionHint = (failure, cluster) => {
|
|
2222
|
+
const previousAction = cluster
|
|
2223
|
+
? representativeClusterPreviousAction(cluster)
|
|
2224
|
+
: failure.codeContext?.previousActionLine || null;
|
|
2225
|
+
if (!previousAction)
|
|
2226
|
+
return null;
|
|
2227
|
+
const compact = compactActionLine(previousAction);
|
|
2228
|
+
if (!compact)
|
|
2229
|
+
return null;
|
|
2230
|
+
return compact;
|
|
2231
|
+
};
|
|
2232
|
+
const buildSingleFailureIssue = (failed, top) => ({
|
|
2233
|
+
title: (() => {
|
|
2234
|
+
const clusterLike = {
|
|
2235
|
+
key: "single",
|
|
2236
|
+
count: 1,
|
|
2237
|
+
sample: failed,
|
|
2238
|
+
titles: [failed.title],
|
|
2239
|
+
suspects: top ? [top] : [],
|
|
2240
|
+
failures: [failed]
|
|
2241
|
+
};
|
|
2242
|
+
return compactIssueTitle(clusterLike);
|
|
2243
|
+
})(),
|
|
2244
|
+
cause: (() => {
|
|
2245
|
+
const clusterLike = {
|
|
2246
|
+
key: "single",
|
|
2247
|
+
count: 1,
|
|
2248
|
+
sample: failed,
|
|
2249
|
+
titles: [failed.title],
|
|
2250
|
+
suspects: top ? [top] : [],
|
|
2251
|
+
failures: [failed]
|
|
2252
|
+
};
|
|
2253
|
+
return failed.signal === "assertion_mismatch" || failed.signal === "timeout" || failed.signal === "locator_not_found" || failed.signal === "actionability"
|
|
2254
|
+
? clusterCauseLine(clusterLike)
|
|
2255
|
+
: (0, exports.describeFailure)(failed);
|
|
2256
|
+
})(),
|
|
2257
|
+
affectedTitles: [failed.title],
|
|
2258
|
+
where: locationValueForFailure(failed),
|
|
2259
|
+
failingCode: failed.codeContext?.focusLine?.trim() || null,
|
|
2260
|
+
failingStep: failed.codeContext?.action || null,
|
|
2261
|
+
selector: failed.locator ? compactLocator(failed.locator) : null,
|
|
2262
|
+
blocker: blockedVerdictForFailure(failed)?.blocker || null,
|
|
2263
|
+
targetState: (() => {
|
|
2264
|
+
const verdict = blockedVerdictForFailure(failed);
|
|
2265
|
+
if (!verdict)
|
|
2266
|
+
return null;
|
|
2267
|
+
if (verdict.targetState === "visible_blocked")
|
|
2268
|
+
return "found and visible before timeout";
|
|
2269
|
+
if (verdict.targetState === "missing")
|
|
2270
|
+
return "locator never appeared";
|
|
2271
|
+
if (verdict.targetState === "hidden")
|
|
2272
|
+
return "found but hidden";
|
|
2273
|
+
if (verdict.targetState === "disabled")
|
|
2274
|
+
return "found but disabled";
|
|
2275
|
+
return null;
|
|
2276
|
+
})(),
|
|
2277
|
+
expected: failed.expected ? truncateValue(failed.expected) : null,
|
|
2278
|
+
received: failed.received ? truncateValue(failed.received) : null,
|
|
2279
|
+
whatChanged: top && top.score >= 0.62 ? top.commit.message : null,
|
|
2280
|
+
reason: top && top.score >= 0.62 && top.reasons.length ? `${compactWhyLine(top)}.` : null,
|
|
2281
|
+
next: (() => {
|
|
2282
|
+
const clusterLike = {
|
|
2283
|
+
key: "single",
|
|
2284
|
+
count: 1,
|
|
2285
|
+
sample: failed,
|
|
2286
|
+
titles: [failed.title],
|
|
2287
|
+
suspects: top ? [top] : [],
|
|
2288
|
+
failures: [failed]
|
|
2289
|
+
};
|
|
2290
|
+
return strongerClusterNext(clusterLike);
|
|
2291
|
+
})(),
|
|
2292
|
+
impact: "1 test failing with this root cause",
|
|
2293
|
+
clears: "fixing this likely clears 1 of 1 failures"
|
|
2294
|
+
});
|
|
2295
|
+
const buildClusterIssue = (cluster, totalFailures) => {
|
|
2296
|
+
const top = cluster.suspects[0];
|
|
2297
|
+
const rootCause = cluster.count === 1 ? (0, exports.describeFailure)(cluster.sample) : clusterCauseLine(cluster);
|
|
2298
|
+
const hideSelectorSpecificEvidence = isMultiSelectorBlockedCluster(cluster);
|
|
2299
|
+
return {
|
|
2300
|
+
title: `${compactIssueTitle(cluster)} (${cluster.count} test${cluster.count === 1 ? "" : "s"})`,
|
|
2301
|
+
cause: rootCause,
|
|
2302
|
+
affectedTitles: cluster.titles,
|
|
2303
|
+
where: locationValueForCluster(cluster),
|
|
2304
|
+
failingCode: hideSelectorSpecificEvidence ? null : representativeClusterFailingCode(cluster)?.trim() || null,
|
|
2305
|
+
failingStep: hideSelectorSpecificEvidence ? null : representativeClusterFailingStep(cluster)?.trim() || null,
|
|
2306
|
+
selector: hideSelectorSpecificEvidence ? null : representativeClusterSelector(cluster)?.trim() || null,
|
|
2307
|
+
blocker: representativeClusterBlocker(cluster)?.trim() || null,
|
|
2308
|
+
targetState: representativeClusterTargetState(cluster)?.trim() || null,
|
|
2309
|
+
expected: hideSelectorSpecificEvidence ? null : representativeClusterExpected(cluster) ? truncateValue(representativeClusterExpected(cluster) || null) : null,
|
|
2310
|
+
received: representativeClusterReceived(cluster) ? truncateValue(representativeClusterReceived(cluster) || null) : null,
|
|
2311
|
+
whatChanged: top && top.score >= 0.62 ? top.commit.message : null,
|
|
2312
|
+
reason: top && top.score >= 0.62 && top.reasons.length ? `${compactWhyLine(top)}.` : null,
|
|
2313
|
+
next: strongerClusterNext(cluster),
|
|
2314
|
+
impact: `${cluster.count} test${cluster.count === 1 ? "" : "s"} failing with ${cluster.count === 1 ? "this" : "same"} root cause`,
|
|
2315
|
+
clears: `fixing this likely clears ${cluster.count} of ${totalFailures} failures`
|
|
2316
|
+
};
|
|
2317
|
+
};
|
|
2318
|
+
const renderDiagnosisSummary = (summary) => {
|
|
2319
|
+
const lines = [];
|
|
2320
|
+
if (summary.headline)
|
|
2321
|
+
lines.push(summary.headline);
|
|
2322
|
+
if (summary.headline)
|
|
2323
|
+
lines.push("");
|
|
2324
|
+
lines.push(summary.failureCountLine);
|
|
2325
|
+
if (summary.collapseLine)
|
|
2326
|
+
lines.push(summary.collapseLine);
|
|
2327
|
+
if (summary.issues.length)
|
|
2328
|
+
lines.push("");
|
|
2329
|
+
for (const [index, issue] of summary.issues.entries()) {
|
|
2330
|
+
lines.push(`${summary.issues.length === 1 ? "Issue" : `Issue ${index + 1}`}: ${issue.title}`);
|
|
2331
|
+
lines.push(` Cause: ${issue.cause}`);
|
|
2332
|
+
const isSetupIssue = issue.title.startsWith("Playwright setup error");
|
|
2333
|
+
if (!isSetupIssue) {
|
|
2334
|
+
if (issue.where)
|
|
2335
|
+
lines.push(` Where: ${issue.where}`);
|
|
2336
|
+
if (issue.failingStep && issue.selector) {
|
|
2337
|
+
const readableStep = humanizeAction(issue.failingStep) || issue.failingStep;
|
|
2338
|
+
const combinedStep = readableStep.length > 1
|
|
2339
|
+
? `${readableStep[0].toUpperCase()}${readableStep.slice(1)} on ${issue.selector} selector`
|
|
2340
|
+
: `${readableStep.toUpperCase()} on ${issue.selector} selector`;
|
|
2341
|
+
lines.push(` Failing step: ${combinedStep}`);
|
|
2342
|
+
}
|
|
2343
|
+
else {
|
|
2344
|
+
if (issue.failingStep)
|
|
2345
|
+
lines.push(` Failing step: ${humanizeAction(issue.failingStep) || issue.failingStep}`);
|
|
2346
|
+
if (issue.selector)
|
|
2347
|
+
lines.push(` Selector: ${issue.selector}`);
|
|
2348
|
+
}
|
|
2349
|
+
if (issue.blocker)
|
|
2350
|
+
lines.push(` Blocker: ${issue.blocker}`);
|
|
2351
|
+
if (issue.targetState)
|
|
2352
|
+
lines.push(` Target state: ${issue.targetState}`);
|
|
2353
|
+
if (issue.expected)
|
|
2354
|
+
lines.push(` Expected: ${issue.expected}`);
|
|
2355
|
+
if (issue.received)
|
|
2356
|
+
lines.push(` Received: ${issue.received}`);
|
|
2357
|
+
if (issue.whatChanged)
|
|
2358
|
+
lines.push(` Changes since last run: + ${issue.whatChanged}`);
|
|
2359
|
+
if (issue.reason)
|
|
2360
|
+
lines.push(` Reason: ${issue.reason}`);
|
|
2361
|
+
lines.push(` Likely fix: ${issue.next}.`);
|
|
2362
|
+
lines.push(` Impact: ${issue.impact}`);
|
|
2363
|
+
}
|
|
2364
|
+
if (index < summary.issues.length - 1)
|
|
2365
|
+
lines.push("");
|
|
2366
|
+
}
|
|
2367
|
+
return {
|
|
2368
|
+
lines,
|
|
2369
|
+
footer: summary.footer
|
|
2370
|
+
};
|
|
2371
|
+
};
|
|
2372
|
+
const buildQuickDiagnosisStructured = (playwrightJsonPath) => {
|
|
1120
2373
|
const failures = (0, exports.collectFailureFacts)(playwrightJsonPath);
|
|
1121
2374
|
if (!failures.length)
|
|
1122
2375
|
return null;
|
|
@@ -1125,29 +2378,13 @@ const buildQuickDiagnosis = (playwrightJsonPath) => {
|
|
|
1125
2378
|
const failed = failures[0];
|
|
1126
2379
|
const suspects = commitWindow.trusted ? rankCommitsForFailure(failed, commitWindow) : [];
|
|
1127
2380
|
const top = suspects[0];
|
|
1128
|
-
const lines = [`What broke: ${shortenTitle(failed.title)}`, `Why: ${(0, exports.describeFailure)(failed)}`];
|
|
1129
|
-
const primaryLocation = buildLocationLine(failed);
|
|
1130
|
-
const confidence = top ? confidenceLabel(top.score).toLowerCase() : "medium";
|
|
1131
|
-
if (primaryLocation)
|
|
1132
|
-
lines.push(`Where: ${withoutPrefix(withoutPrefix(primaryLocation, "Error location:"), "Likely file:")}`);
|
|
1133
|
-
if (failed.codeContext?.action)
|
|
1134
|
-
lines.push(`Failing step: ${failed.codeContext.action}`);
|
|
1135
|
-
if (failed.expected)
|
|
1136
|
-
lines.push(`Expected: ${truncateValue(failed.expected)}`);
|
|
1137
|
-
if (failed.received)
|
|
1138
|
-
lines.push(`Received: ${truncateValue(failed.received)}`);
|
|
1139
|
-
if (top && top.score >= 0.62) {
|
|
1140
|
-
lines.push(`What changed: "${top.commit.message}"`);
|
|
1141
|
-
if (top.reasons.length) {
|
|
1142
|
-
lines.push(`Reason: ${compactWhyLine(top)}.`);
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
lines.push(`Confidence: ${confidence}`);
|
|
1146
|
-
lines.push("Next:");
|
|
1147
|
-
lines.push(`- ${checkFirst(failed)}`);
|
|
1148
2381
|
return {
|
|
1149
|
-
|
|
1150
|
-
|
|
2382
|
+
mode: "failure",
|
|
2383
|
+
headline: null,
|
|
2384
|
+
failureCountLine: "❌ 1 failure",
|
|
2385
|
+
collapseLine: null,
|
|
2386
|
+
issues: [buildSingleFailureIssue(failed, top)],
|
|
2387
|
+
footer: top ? [`Confidence: ${confidenceLabel(top.score).toLowerCase()}`] : []
|
|
1151
2388
|
};
|
|
1152
2389
|
}
|
|
1153
2390
|
const clusterMap = new Map();
|
|
@@ -1168,50 +2405,27 @@ const buildQuickDiagnosis = (playwrightJsonPath) => {
|
|
|
1168
2405
|
}))
|
|
1169
2406
|
.sort((a, b) => b.count - a.count ||
|
|
1170
2407
|
((b.suspects[0]?.score || 0) - (a.suspects[0]?.score || 0)) ||
|
|
1171
|
-
(b.sample.signal === "assertion_mismatch" ? 1 : 0) - (a.sample.signal === "assertion_mismatch" ? 1 : 0))
|
|
1172
|
-
.slice(0, 2);
|
|
2408
|
+
(b.sample.signal === "assertion_mismatch" ? 1 : 0) - (a.sample.signal === "assertion_mismatch" ? 1 : 0));
|
|
1173
2409
|
const topCluster = clusters[0];
|
|
1174
|
-
const
|
|
1175
|
-
`${failures.length} tests failed`,
|
|
1176
|
-
`Collapsed into ${clusters.length} real issue${clusters.length === 1 ? "" : "s"}`
|
|
1177
|
-
];
|
|
1178
|
-
for (const [index, cluster] of clusters.entries()) {
|
|
1179
|
-
const clusterFailures = clusterMap.get(cluster.key) || [cluster.sample];
|
|
1180
|
-
const clusterLocation = buildClusterLocationLine(clusterFailures);
|
|
1181
|
-
const top = cluster.suspects[0];
|
|
1182
|
-
const locationValue = clusterLocation
|
|
1183
|
-
? withoutPrefix(clusterLocation, clusterLocation.startsWith("Error locations:") ? "Error locations:" : "Error location:")
|
|
1184
|
-
: null;
|
|
1185
|
-
const rootCause = cluster.count === 1 ? (0, exports.describeFailure)(cluster.sample) : clusterCauseLine(cluster);
|
|
1186
|
-
lines.push(`Issue ${index + 1}: ${compactIssueTitle(cluster)} (${cluster.count} test${cluster.count === 1 ? "" : "s"})`);
|
|
1187
|
-
lines.push(` Cause: ${rootCause}`);
|
|
1188
|
-
if (locationValue) {
|
|
1189
|
-
lines.push(` Where: ${locationValue}`);
|
|
1190
|
-
}
|
|
1191
|
-
for (const evidenceLine of buildClusterEvidenceLines(cluster)) {
|
|
1192
|
-
lines.push(` ${evidenceLine}`);
|
|
1193
|
-
}
|
|
1194
|
-
if (cluster.sample.expected) {
|
|
1195
|
-
lines.push(` Expected: ${truncateValue(cluster.sample.expected)}`);
|
|
1196
|
-
}
|
|
1197
|
-
if (cluster.sample.received) {
|
|
1198
|
-
lines.push(` Received: ${truncateValue(cluster.sample.received)}`);
|
|
1199
|
-
}
|
|
1200
|
-
if (top && top.score >= 0.62) {
|
|
1201
|
-
lines.push(` What changed: "${top.commit.message}"`);
|
|
1202
|
-
if (top.reasons.length) {
|
|
1203
|
-
lines.push(` Reason: ${compactWhyLine(top)}.`);
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
lines.push(` Next: ${strongerClusterNext(cluster)}.`);
|
|
1207
|
-
lines.push(` Impact: ${cluster.count} test${cluster.count === 1 ? "" : "s"} failing with ${cluster.count === 1 ? "this" : "same"} root cause`);
|
|
1208
|
-
lines.push(` Clears: fixing this likely clears ${cluster.count} of ${failures.length} failures`);
|
|
1209
|
-
if (index < clusters.length - 1)
|
|
1210
|
-
lines.push("");
|
|
1211
|
-
}
|
|
2410
|
+
const visibleClusters = clusters.slice(0, 3);
|
|
1212
2411
|
return {
|
|
1213
|
-
|
|
2412
|
+
mode: "failure",
|
|
2413
|
+
headline: null,
|
|
2414
|
+
failureCountLine: clusters.length < failures.length
|
|
2415
|
+
? `❌ ${failures.length} failures (grouped)`
|
|
2416
|
+
: `❌ ${failures.length} failures`,
|
|
2417
|
+
collapseLine: clusters.length < failures.length
|
|
2418
|
+
? clusters.length > visibleClusters.length
|
|
2419
|
+
? `Collapsed into ${clusters.length} real issues (showing top ${visibleClusters.length})`
|
|
2420
|
+
: `Collapsed into ${clusters.length} real issue${clusters.length === 1 ? "" : "s"}`
|
|
2421
|
+
: null,
|
|
2422
|
+
issues: visibleClusters.map((cluster) => buildClusterIssue(cluster, failures.length)),
|
|
1214
2423
|
footer: topCluster?.suspects[0] ? [`Confidence: ${confidenceLabel(topCluster.suspects[0].score).toLowerCase()}`] : []
|
|
1215
2424
|
};
|
|
1216
2425
|
};
|
|
2426
|
+
exports.buildQuickDiagnosisStructured = buildQuickDiagnosisStructured;
|
|
2427
|
+
const buildQuickDiagnosis = (playwrightJsonPath) => {
|
|
2428
|
+
const summary = (0, exports.buildQuickDiagnosisStructured)(playwrightJsonPath);
|
|
2429
|
+
return summary ? renderDiagnosisSummary(summary) : null;
|
|
2430
|
+
};
|
|
1217
2431
|
exports.buildQuickDiagnosis = buildQuickDiagnosis;
|