@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.
@@ -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 (/expected substring|expected string|received string|tohavetext|tocontaintext|tohavevalue|tobechecked/.test(lower)) {
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)} showed "${truncateValue(failure.received, 72)}" instead of "${truncateValue(failure.expected, 40)}".`;
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
- return "Same blocked state transition timed out across these tests";
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 ? `Network/API failure (${truncateValue(failure.apiHint, 28)})` : "Network/API failure";
1598
+ return failure.apiHint ? `Backend/API failure (${truncateValue(failure.apiHint, 28)})` : "Backend/API failure";
885
1599
  case "timeout":
886
- return failure.locator ? `Timeout waiting on ${locator}` : "Timeout waiting for state change";
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
- return failure.locator
899
- ? `Same assertion mismatch on ${compactLocator(failure.locator)}`
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
- return failure.locator
909
- ? `${compactLocator(failure.locator)} is present but blocked in each failure`
910
- : "Same actionability problem across these tests";
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 (failure.locator) {
921
- return `check ${compactLocator(failure.locator)} or the data source behind it`;
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
- const signal = classifySignal(message);
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: null,
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
- focusLine: options.codeContext.focusLine || fallbackCodeContext?.focusLine || extractFocusLineFromMessage(message) || null,
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
- focusLine: fallbackCodeContext.focusLine || extractFocusLineFromMessage(message) || null
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: null,
1963
+ assertion: assertionLine,
982
1964
  methodName: null,
983
- focusLine: extractFocusLineFromMessage(message),
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 = extractLocator(message) || codeContext?.locator || domCapture?.locator || null;
989
- const expected = extractExpected(message) || codeContext?.expectedText || domCapture?.expectedText || null;
990
- const received = extractReceived(message) || domCapture?.observedText || domCapture?.textContent || null;
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 apiHint = extractApiHint(message, codeContext);
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
- failure.locator || failure.likelyModule || basename(failure.likelyFile) || "unknown-target",
1056
- basename(failure.likelyFile) || "unknown-file"
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
- failure.locator || failure.codeContext?.action || failure.likelyModule || "unknown-target",
1063
- basename(failure.likelyFile) || "unknown-file"
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
- failure.locator || failure.apiHint || "unknown-target",
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 buildQuickDiagnosis = (playwrightJsonPath) => {
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
- lines,
1150
- footer: []
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 lines = [
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
- lines,
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;