@sentinelqa/playwright-reporter 0.1.53 → 0.1.56

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,238 @@ 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
  };
283
293
  const extractExpected = (message) => {
284
294
  const match = message.match(/Expected substring:\s*"([^"]+)"/i) ||
285
295
  message.match(/Expected string:\s*"([^"]+)"/i) ||
286
- message.match(/Expected:\s*"([^"]+)"/i);
296
+ message.match(/Expected:\s*"([^"]+)"/i) ||
297
+ message.match(/Expected:\s*`([^`]+)`/i) ||
298
+ message.match(/instead of\s+"([^"]+)"/i);
287
299
  return match?.[1] || null;
288
300
  };
289
301
  const extractReceived = (message) => {
290
302
  const match = message.match(/Received string:\s*"([^"]+)"/i) ||
291
- message.match(/Received:\s*"([^"]+)"/i);
303
+ message.match(/Received:\s*"([^"]+)"/i) ||
304
+ message.match(/Received:\s*`([^`]+)`/i) ||
305
+ message.match(/showed\s+"([^"]+)"/i);
292
306
  return match?.[1] || null;
293
307
  };
308
+ const extractExpectedFromAssertionLine = (value) => {
309
+ if (!value)
310
+ return null;
311
+ const line = value.trim();
312
+ if (/\.toBeHidden\(\s*\)/i.test(line))
313
+ return "hidden";
314
+ if (/\.toBeVisible\(\s*\)/i.test(line))
315
+ return "visible";
316
+ if (/\.toBeDisabled\(\s*\)/i.test(line))
317
+ return "disabled";
318
+ if (/\.toBeEnabled\(\s*\)/i.test(line))
319
+ return "enabled";
320
+ if (/\.toBeChecked\(\s*\)/i.test(line))
321
+ return "checked";
322
+ if (/\.not\.toBeChecked\(\s*\)/i.test(line))
323
+ return "unchecked";
324
+ const countMatch = line.match(/toHaveCount\(\s*(\d+)\s*\)/i);
325
+ if (countMatch?.[1])
326
+ return `count ${countMatch[1]}`;
327
+ const titleMatch = line.match(/toHaveTitle\(\s*["'`]([^"'`]+)["'`]\s*\)/i);
328
+ if (titleMatch?.[1])
329
+ return titleMatch[1];
330
+ const attrMatch = line.match(/toHaveAttribute\(\s*["'`]([^"'`]+)["'`]\s*,\s*["'`]([^"'`]+)["'`]\s*\)/i);
331
+ if (attrMatch?.[1] && attrMatch?.[2])
332
+ return `${attrMatch[1]}=${attrMatch[2]}`;
333
+ const quoted = line.match(/toHaveText\(\s*["'`]([^"'`]+)["'`]\s*\)/) ||
334
+ line.match(/toContainText\(\s*["'`]([^"'`]+)["'`]\s*\)/) ||
335
+ line.match(/toHaveValue\(\s*["'`]([^"'`]+)["'`]\s*\)/) ||
336
+ line.match(/toHaveURL\(\s*["'`]([^"'`]+)["'`]\s*\)/) ||
337
+ line.match(/toHaveTitle\(\s*["'`]([^"'`]+)["'`]\s*\)/);
338
+ return quoted?.[1] || null;
339
+ };
340
+ const extractAssertionKind = (value) => {
341
+ if (!value)
342
+ return "unknown";
343
+ const line = value.trim();
344
+ if (/toBeHidden\(\s*\)|toBeVisible\(\s*\)/i.test(line))
345
+ return "visibility";
346
+ if (/toBeDisabled\(\s*\)|toBeEnabled\(\s*\)/i.test(line))
347
+ return "enabled";
348
+ if (/toBeChecked\(\s*\)|not\.toBeChecked\(\s*\)/i.test(line))
349
+ return "checked";
350
+ if (/toHaveCount\(\s*\d+\s*\)/i.test(line))
351
+ return "count";
352
+ if (/toHaveURL\(/i.test(line))
353
+ return "url";
354
+ if (/toHaveTitle\(/i.test(line))
355
+ return "title";
356
+ if (/toHaveAttribute\(/i.test(line))
357
+ return "attribute";
358
+ if (/toHaveText\(|toContainText\(|toHaveValue\(/i.test(line))
359
+ return "text";
360
+ return "unknown";
361
+ };
362
+ const extractObservedDomText = (domCapture) => {
363
+ if (!domCapture)
364
+ return null;
365
+ if (domCapture.observedText)
366
+ return domCapture.observedText;
367
+ if (domCapture.textContent)
368
+ return domCapture.textContent;
369
+ const matchedText = domCapture.matchedElements
370
+ ?.map((item) => item?.text || null)
371
+ .find((value) => typeof value === "string" && value.trim().length > 0);
372
+ return matchedText || null;
373
+ };
374
+ const normalizedAriaSnapshot = (domCapture) => (domCapture?.ariaSnapshot || "").replace(/\s+/g, " ").trim();
375
+ const firstMatchedElement = (domCapture) => domCapture?.matchedElements?.find(Boolean) || null;
376
+ const parseBooleanToken = (value, token) => {
377
+ if (!value)
378
+ return null;
379
+ const normalized = value.toLowerCase();
380
+ if (new RegExp(`${token}\\s*[:=]\\s*(true|false)`, "i").test(normalized) ||
381
+ new RegExp(`${token}\\s*=\\s*"(true|false)"`, "i").test(normalized) ||
382
+ new RegExp(`${token}\\s*=\\s*'(true|false)'`, "i").test(normalized)) {
383
+ const match = normalized.match(new RegExp(`${token}\\s*[:=]\\s*(true|false)`, "i")) ||
384
+ normalized.match(new RegExp(`${token}\\s*=\\s*"(true|false)"`, "i")) ||
385
+ normalized.match(new RegExp(`${token}\\s*=\\s*'(true|false)'`, "i"));
386
+ if (match?.[1] === "true")
387
+ return true;
388
+ if (match?.[1] === "false")
389
+ return false;
390
+ }
391
+ if (new RegExp(`\\[${token}\\]`, "i").test(normalized) || new RegExp(`\\b${token}\\b`, "i").test(normalized)) {
392
+ return true;
393
+ }
394
+ return null;
395
+ };
396
+ const ariaVisibilityState = (domCapture) => {
397
+ const snapshot = normalizedAriaSnapshot(domCapture);
398
+ const hidden = parseBooleanToken(snapshot, "aria-hidden") ??
399
+ parseBooleanToken(snapshot, "hidden");
400
+ if (hidden === true)
401
+ return "hidden";
402
+ if (hidden === false)
403
+ return "visible";
404
+ return null;
405
+ };
406
+ const ariaEnabledState = (domCapture) => {
407
+ const snapshot = normalizedAriaSnapshot(domCapture);
408
+ const disabled = parseBooleanToken(snapshot, "aria-disabled") ??
409
+ parseBooleanToken(snapshot, "disabled");
410
+ if (disabled === true)
411
+ return "disabled";
412
+ if (disabled === false)
413
+ return "enabled";
414
+ return null;
415
+ };
416
+ const ariaCheckedState = (domCapture) => {
417
+ const snapshot = normalizedAriaSnapshot(domCapture);
418
+ const explicit = snapshot.match(/aria-checked\s*[:=]\s*(true|false|mixed)/i) ||
419
+ snapshot.match(/checked\s*[:=]\s*(true|false|mixed)/i) ||
420
+ snapshot.match(/aria-checked\s*=\s*"(true|false|mixed)"/i) ||
421
+ snapshot.match(/aria-checked\s*=\s*'(true|false|mixed)'/i);
422
+ if (explicit?.[1]) {
423
+ if (explicit[1].toLowerCase() === "true" || explicit[1].toLowerCase() === "mixed")
424
+ return "checked";
425
+ if (explicit[1].toLowerCase() === "false")
426
+ return "unchecked";
427
+ }
428
+ if (/\[checked\]|\bchecked\b/i.test(snapshot) && !/\bunchecked\b/i.test(snapshot))
429
+ return "checked";
430
+ if (/\bunchecked\b/i.test(snapshot))
431
+ return "unchecked";
432
+ return null;
433
+ };
434
+ const extractObservedAssertionState = (domCapture, expected) => {
435
+ if (!domCapture || !expected)
436
+ return null;
437
+ const matched = firstMatchedElement(domCapture);
438
+ switch (expected) {
439
+ case "hidden":
440
+ case "visible":
441
+ if (domCapture.visible === true)
442
+ return "visible";
443
+ if (domCapture.visible === false)
444
+ return "hidden";
445
+ if (matched?.visible === true)
446
+ return "visible";
447
+ if (matched?.visible === false)
448
+ return "hidden";
449
+ return ariaVisibilityState(domCapture);
450
+ case "checked":
451
+ case "unchecked": {
452
+ const checked = ariaCheckedState(domCapture);
453
+ if (checked)
454
+ return checked;
455
+ return null;
456
+ }
457
+ case "enabled":
458
+ case "disabled":
459
+ if (domCapture.enabled === true)
460
+ return "enabled";
461
+ if (domCapture.enabled === false)
462
+ return "disabled";
463
+ if (matched?.enabled === true)
464
+ return "enabled";
465
+ if (matched?.enabled === false)
466
+ return "disabled";
467
+ return ariaEnabledState(domCapture);
468
+ default:
469
+ return null;
470
+ }
471
+ };
472
+ const extractObservedAssertionValue = (domCapture, expected, assertionKind) => {
473
+ if (!domCapture)
474
+ return null;
475
+ if (assertionKind === "visibility" || assertionKind === "enabled" || assertionKind === "checked") {
476
+ return extractObservedAssertionState(domCapture, expected);
477
+ }
478
+ if (assertionKind === "count" && typeof domCapture.matchedCount === "number") {
479
+ return `count ${domCapture.matchedCount}`;
480
+ }
481
+ return null;
482
+ };
483
+ const isStateExpectation = (value) => value === "hidden" || value === "visible" || value === "enabled" || value === "disabled" || value === "checked" || value === "unchecked";
484
+ const describeAssertionDifference = (selector, expected, received) => {
485
+ if (isStateExpectation(expected) && isStateExpectation(received)) {
486
+ return `${selector} was ${truncateValue(received, 24)} instead of ${truncateValue(expected, 24)}`;
487
+ }
488
+ if (/^count \d+$/i.test(expected) && /^count \d+$/i.test(received)) {
489
+ return `${selector} had ${truncateValue(received, 24)} instead of ${truncateValue(expected, 24)}`;
490
+ }
491
+ return `${selector} showed "${truncateValue(received, 72)}" instead of "${truncateValue(expected, 40)}"`;
492
+ };
493
+ const describeExpectedState = (expected) => {
494
+ if (!expected)
495
+ return null;
496
+ if (/^count \d+$/i.test(expected))
497
+ return expected;
498
+ if (isStateExpectation(expected))
499
+ return expected;
500
+ return `"${truncateValue(expected, 40)}"`;
501
+ };
502
+ const assertionWaitCondition = (selector, expected) => {
503
+ const expectedState = describeExpectedState(expected);
504
+ if (!expectedState)
505
+ return `${selector} to reach the expected state`;
506
+ if (expected === "hidden")
507
+ return `${selector} to become hidden`;
508
+ if (expected === "visible")
509
+ return `${selector} to become visible`;
510
+ if (expected === "enabled")
511
+ return `${selector} to become enabled`;
512
+ if (expected === "disabled")
513
+ return `${selector} to become disabled`;
514
+ if (expected === "checked")
515
+ return `${selector} to become checked`;
516
+ if (expected === "unchecked")
517
+ return `${selector} to become unchecked`;
518
+ if (/^count \d+$/i.test(expected))
519
+ return `${selector} to reach ${expectedState}`;
520
+ return `${selector} to show ${expectedState}`;
521
+ };
294
522
  const extractTimeoutMs = (message) => {
295
523
  const match = message.match(/Timeout:\s*(\d+)\s*ms/i) ||
296
524
  message.match(/timeout(?: of)?\s*(\d+)\s*ms/i) ||
@@ -313,6 +541,65 @@ const extractApiHint = (message, codeContext) => {
313
541
  const apiMatch = message.match(/\/(api|graphql|rest)\/[^\s)"']+/i);
314
542
  return apiMatch?.[0] || null;
315
543
  };
544
+ const recentPageErrorHint = (domCapture) => truncateValue(domCapture?.recentPageErrors?.[0], 140);
545
+ const recentConsoleErrorHint = (domCapture) => truncateValue(domCapture?.recentConsoleErrors?.[0]?.text, 140);
546
+ const ariaSnapshotHint = (domCapture) => truncateValue(domCapture?.ariaSnapshot || null, 140);
547
+ const failedRequestFromDomCapture = (domCapture) => {
548
+ const requests = domCapture?.recentRequests || [];
549
+ return requests.find((request) => {
550
+ const status = typeof request.status === "number" ? request.status : null;
551
+ return Boolean(request.failure) || (status !== null && status >= 400);
552
+ }) || null;
553
+ };
554
+ const formatRequestLabel = (request) => {
555
+ const method = request.method || "REQUEST";
556
+ const url = request.url || "unknown request";
557
+ const status = typeof request.status === "number" ? ` -> ${request.status}` : request.failure ? ` -> ${request.failure}` : "";
558
+ return `${method} ${truncateValue(url, 48)}${status}`;
559
+ };
560
+ const isUrlLike = (value) => Boolean(value && (/^https?:\/\//i.test(value) || /^\//.test(value)));
561
+ const navigationVerdictForFailure = (failure) => {
562
+ const expected = failure.expected || null;
563
+ const received = failure.received || failure.lastUrl || null;
564
+ const message = stripAnsi(failure.message).toLowerCase();
565
+ if (isUrlLike(expected) || isUrlLike(received)) {
566
+ if ((received || "").toLowerCase().includes("/login") && expected && !expected.toLowerCase().includes("/login")) {
567
+ return { kind: "auth_redirect", expected, received };
568
+ }
569
+ if (expected && received && expected !== received) {
570
+ return { kind: "wrong_route", expected, received };
571
+ }
572
+ }
573
+ if (/navigation timeout|page\.goto: timeout|waitforurl.*timeout|waiting for navigation/i.test(message)) {
574
+ return { kind: "navigation_timeout", expected, received };
575
+ }
576
+ if (/about:blank|blank page/i.test(message) || ((received || "").toLowerCase() === "about:blank")) {
577
+ return { kind: "blank_page", expected, received };
578
+ }
579
+ if (/cross-origin|popup|new page/i.test(message)) {
580
+ return { kind: "popup_origin", expected, received };
581
+ }
582
+ return null;
583
+ };
584
+ const blockedActionSubtype = (failure, verdict) => {
585
+ const blocker = verdict?.blocker || "";
586
+ const message = stripAnsi(failure.message).toLowerCase();
587
+ if (/strict mode/i.test(blocker) || /strict mode violation/i.test(message))
588
+ return "strict_mode";
589
+ if (/detached/i.test(blocker) || /detached from the dom/i.test(message))
590
+ return "detached";
591
+ if (/scrolling or viewport issue/i.test(blocker) || /viewport|scrolled into view/.test(message))
592
+ return "offscreen";
593
+ if (/focus or editability issue/i.test(blocker) || /not editable|not focused/.test(message))
594
+ return "focus";
595
+ if (/intercepts pointer events|overlay|obscures/.test(blocker) || /intercepts pointer events|overlay/.test(message))
596
+ return "overlay";
597
+ if (verdict?.targetState === "hidden")
598
+ return "hidden";
599
+ if (verdict?.targetState === "disabled")
600
+ return "disabled";
601
+ return "generic";
602
+ };
316
603
  const extractStackLocation = (message) => {
317
604
  const lines = stripAnsi(message).split(/\r?\n/);
318
605
  for (const line of lines) {
@@ -372,6 +659,35 @@ const describeDomState = (failure) => {
372
659
  }
373
660
  return null;
374
661
  };
662
+ const extractBlockerFromMessage = (message) => {
663
+ const plain = stripAnsi(message);
664
+ const interceptMatch = plain.match(/-\s*(<[^>]+>|[^.\n]+?)\s+intercepts pointer events/i);
665
+ if (interceptMatch?.[1]) {
666
+ const blocker = interceptMatch[1].replace(/\s+/g, " ").trim();
667
+ return truncateValue(blocker, 72);
668
+ }
669
+ const obscuredMatch = plain.match(/(?:another element|element)\s+(.+?)\s+(?:would receive the click|obscures|obscured)/i);
670
+ if (obscuredMatch?.[1]) {
671
+ return truncateValue(`${obscuredMatch[1].replace(/\s+/g, " ").trim()} obscures the target`, 72);
672
+ }
673
+ const overlayMatch = plain.match(/\b([.#]?[A-Za-z0-9_-]*overlay[A-Za-z0-9_.-]*)\b/i);
674
+ if (overlayMatch?.[1]) {
675
+ return overlayMatch[1];
676
+ }
677
+ if (/strict mode violation/i.test(plain)) {
678
+ return "strict mode matched multiple elements";
679
+ }
680
+ if (/element is not attached to the dom|detached from dom|detached from the dom/i.test(plain)) {
681
+ return "element detached from the DOM";
682
+ }
683
+ if (/outside of the viewport|scroll(?:ing)? into view|could not be scrolled into view/i.test(plain)) {
684
+ return "scrolling or viewport issue blocked the target";
685
+ }
686
+ if (/not focused|did not receive focus|is not editable|element is not editable/i.test(plain)) {
687
+ return "focus or editability issue blocked the target";
688
+ }
689
+ return null;
690
+ };
375
691
  const dominantValue = (values) => {
376
692
  const counts = new Map();
377
693
  for (const value of values) {
@@ -400,6 +716,22 @@ const timeoutState = (failure) => {
400
716
  return "present";
401
717
  return "unknown";
402
718
  };
719
+ const inferMissingTargetFromMessage = (failure) => {
720
+ if (failure.signal !== "timeout")
721
+ return false;
722
+ const message = stripAnsi(failure.message);
723
+ if (!failure.locator || !failure.codeContext?.action)
724
+ return false;
725
+ if (!["click", "fill", "press"].includes(failure.codeContext.action))
726
+ return false;
727
+ 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)) {
728
+ return false;
729
+ }
730
+ if (/locator\.(click|fill|press):/i.test(message) && /Call log:\s*[\s\S]*waiting for /i.test(message)) {
731
+ return true;
732
+ }
733
+ return false;
734
+ };
403
735
  const sharedTimeoutEvidence = (failures) => {
404
736
  const timeoutFailures = failures.filter((failure) => failure.signal === "timeout");
405
737
  return {
@@ -422,6 +754,147 @@ const timeoutStateLabel = (state) => {
422
754
  return null;
423
755
  }
424
756
  };
757
+ const capitalize = (value) => {
758
+ if (!value)
759
+ return null;
760
+ return value.length > 1 ? `${value[0].toUpperCase()}${value.slice(1)}` : value.toUpperCase();
761
+ };
762
+ const humanizeAction = (value) => {
763
+ if (!value)
764
+ return null;
765
+ switch (value) {
766
+ case "expect_text":
767
+ return "expect text";
768
+ case "expect_visible":
769
+ return "expect visible";
770
+ case "assert":
771
+ return "assert";
772
+ default:
773
+ return value.replace(/_/g, " ");
774
+ }
775
+ };
776
+ const timeoutWaitConditionForFailure = (failure) => {
777
+ const verdict = blockedVerdictForFailure(failure);
778
+ const locator = failure.locator ? compactLocator(failure.locator) : "the target";
779
+ const action = failure.codeContext?.action || null;
780
+ if ((action === "expect_text" || action === "assert") && failure.expected && failure.locator) {
781
+ return assertionWaitCondition(locator, failure.expected);
782
+ }
783
+ if (verdict?.targetState === "missing")
784
+ return `${locator} to appear`;
785
+ if (verdict?.targetState === "hidden")
786
+ return `${locator} to become visible`;
787
+ if (verdict?.targetState === "disabled")
788
+ return `${locator} to become enabled`;
789
+ if (action === "click" || verdict?.targetState === "visible_blocked")
790
+ return `${locator} to become clickable`;
791
+ if (action === "fill")
792
+ return `${locator} to become editable`;
793
+ if (action === "press")
794
+ return `${locator} to become ready for keyboard input`;
795
+ if (failure.expected && failure.locator) {
796
+ return assertionWaitCondition(locator, failure.expected);
797
+ }
798
+ if (failure.locator)
799
+ return `${locator} to be ready`;
800
+ return "the expected state change to complete";
801
+ };
802
+ const timeoutWaitConditionForCluster = (cluster) => {
803
+ const failure = cluster.sample;
804
+ const verdict = blockedVerdictForCluster(cluster);
805
+ const locator = verdict?.locator ? compactLocator(verdict.locator) : failure.locator ? compactLocator(failure.locator) : "the target";
806
+ const action = verdict?.action || failure.codeContext?.action || null;
807
+ const expected = representativeClusterExpected(cluster) || failure.expected;
808
+ if ((action === "expect_text" || action === "assert") && expected && locator !== "the target") {
809
+ return assertionWaitCondition(locator, expected);
810
+ }
811
+ if (verdict?.targetState === "missing")
812
+ return `${locator} to appear`;
813
+ if (verdict?.targetState === "hidden")
814
+ return `${locator} to become visible`;
815
+ if (verdict?.targetState === "disabled")
816
+ return `${locator} to become enabled`;
817
+ if (action === "click" || verdict?.targetState === "visible_blocked")
818
+ return `${locator} to become clickable`;
819
+ if (action === "fill")
820
+ return `${locator} to become editable`;
821
+ if (action === "press")
822
+ return `${locator} to become ready for keyboard input`;
823
+ if (expected && locator !== "the target") {
824
+ return assertionWaitCondition(locator, expected);
825
+ }
826
+ if (locator !== "the target")
827
+ return `${locator} to be ready`;
828
+ return "the expected state change to complete";
829
+ };
830
+ const blockedVerdictForFailure = (failure) => {
831
+ if (!["timeout", "actionability", "locator_not_found"].includes(failure.signal))
832
+ return null;
833
+ const blocker = extractBlockerFromMessage(failure.message);
834
+ const state = timeoutState(failure);
835
+ if (state === "missing") {
836
+ return {
837
+ action: failure.codeContext?.action || null,
838
+ locator: failure.locator || null,
839
+ blocker: null,
840
+ targetState: "missing"
841
+ };
842
+ }
843
+ if (state === "hidden") {
844
+ return {
845
+ action: failure.codeContext?.action || null,
846
+ locator: failure.locator || null,
847
+ blocker: null,
848
+ targetState: "hidden"
849
+ };
850
+ }
851
+ if (state === "disabled") {
852
+ return {
853
+ action: failure.codeContext?.action || null,
854
+ locator: failure.locator || null,
855
+ blocker: null,
856
+ targetState: "disabled"
857
+ };
858
+ }
859
+ if (blocker || (state === "present" && failure.codeContext?.action)) {
860
+ return {
861
+ action: failure.codeContext?.action || null,
862
+ locator: failure.locator || null,
863
+ blocker,
864
+ targetState: "visible_blocked"
865
+ };
866
+ }
867
+ if (inferMissingTargetFromMessage(failure)) {
868
+ return {
869
+ action: failure.codeContext?.action || null,
870
+ locator: failure.locator || null,
871
+ blocker: null,
872
+ targetState: "missing"
873
+ };
874
+ }
875
+ return {
876
+ action: failure.codeContext?.action || null,
877
+ locator: failure.locator || null,
878
+ blocker: null,
879
+ targetState: "unknown"
880
+ };
881
+ };
882
+ const blockedVerdictForCluster = (cluster) => {
883
+ if (!["timeout", "actionability", "locator_not_found"].includes(cluster.sample.signal))
884
+ return null;
885
+ const verdicts = cluster.failures
886
+ .map((failure) => blockedVerdictForFailure(failure))
887
+ .filter((value) => Boolean(value));
888
+ if (!verdicts.length)
889
+ return null;
890
+ const targetState = dominantValue(verdicts.map((verdict) => verdict.targetState)) || "unknown";
891
+ return {
892
+ action: dominantValue(verdicts.map((verdict) => verdict.action)),
893
+ locator: dominantValue(verdicts.map((verdict) => verdict.locator)),
894
+ blocker: dominantValue(verdicts.map((verdict) => verdict.blocker)),
895
+ targetState
896
+ };
897
+ };
425
898
  const buildTouchedFileReason = (label, files) => `${label}: ${files.slice(0, 2).map((file) => basename(file)).join(", ")}`;
426
899
  const commitTouchedFailure = (commit, failure) => {
427
900
  const reasons = [];
@@ -549,6 +1022,25 @@ const flattenFailedCases = (node, titlePath = []) => {
549
1022
  return failures;
550
1023
  };
551
1024
  const checkFirst = (failure) => {
1025
+ if (failure.signal === "setup") {
1026
+ const lower = failure.message.toLowerCase();
1027
+ if (/browsertype\.launch: executable doesn't exist|please run the following command to download new browsers|npx playwright install/.test(lower)) {
1028
+ return "run npx playwright install before rerunning";
1029
+ }
1030
+ if (/cannot find module ['"]@playwright\/test['"]|cannot find package ['"]@playwright\/test['"]/.test(lower)) {
1031
+ return "install @playwright/test in this project before rerunning";
1032
+ }
1033
+ if (/playwright test did not expect test\.describe\(\) to be called here/.test(lower)) {
1034
+ return "fix the Playwright package or test runner setup before rerunning";
1035
+ }
1036
+ if (/error: no tests found/.test(lower)) {
1037
+ return "fix the Playwright command, grep, or test discovery settings before rerunning";
1038
+ }
1039
+ if (/unknown command ['"]test['"]/.test(lower)) {
1040
+ return "install Playwright or run the local @playwright/test CLI before rerunning";
1041
+ }
1042
+ return "fix the Playwright setup error before rerunning";
1043
+ }
552
1044
  if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
553
1045
  const file = basename(failure.codeContext?.file || failure.likelyFile);
554
1046
  const line = failure.codeContext?.line;
@@ -613,6 +1105,8 @@ const alternateCommitLine = (match) => {
613
1105
  };
614
1106
  const rootCauseLabel = (failure) => {
615
1107
  switch (failure.signal) {
1108
+ case "setup":
1109
+ return "Playwright setup error";
616
1110
  case "assertion_mismatch":
617
1111
  return "UI assertion mismatch";
618
1112
  case "locator_not_found":
@@ -632,16 +1126,62 @@ const rootCauseLabel = (failure) => {
632
1126
  }
633
1127
  };
634
1128
  const describeFailure = (failure) => {
1129
+ if (failure.signal === "setup") {
1130
+ return `${setupErrorSummary(failure)}.`;
1131
+ }
1132
+ const navigation = navigationVerdictForFailure(failure);
1133
+ const pageError = recentPageErrorHint(failure.domCapture);
1134
+ const consoleError = recentConsoleErrorHint(failure.domCapture);
1135
+ if (navigation?.kind === "auth_redirect" && navigation.received) {
1136
+ return `The app redirected to "${truncateValue(navigation.received, 48)}" instead of continuing to the expected route.`;
1137
+ }
1138
+ if (navigation?.kind === "wrong_route" && navigation.expected && navigation.received) {
1139
+ return `The app opened "${truncateValue(navigation.received, 48)}" instead of "${truncateValue(navigation.expected, 48)}".`;
1140
+ }
1141
+ if (navigation?.kind === "navigation_timeout") {
1142
+ return `Navigation did not complete before timeout.`;
1143
+ }
1144
+ if (navigation?.kind === "blank_page") {
1145
+ return `The page ended on a blank route instead of the expected app screen.`;
1146
+ }
1147
+ const failedRequest = failedRequestFromDomCapture(failure.domCapture);
635
1148
  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)}".`;
1149
+ return `${describeAssertionDifference(compactLocator(failure.locator), failure.expected, failure.received)}.`;
1150
+ }
1151
+ if (failure.signal === "assertion_mismatch" && failure.locator && failure.expected) {
1152
+ const actionHint = assertionStateTransitionHint(failure);
1153
+ if (actionHint) {
1154
+ return `${compactLocator(failure.locator)} did not reach ${describeExpectedState(failure.expected) || "the expected state"} after ${actionHint}.`;
1155
+ }
1156
+ return `${compactLocator(failure.locator)} did not reach ${describeExpectedState(failure.expected) || "the expected state"}.`;
637
1157
  }
638
1158
  if (failure.signal === "locator_not_found" && failure.locator) {
639
1159
  return `${compactLocator(failure.locator)} was not found when the test expected it to be available.`;
640
1160
  }
641
1161
  if (failure.signal === "actionability" && failure.locator) {
1162
+ const verdict = blockedVerdictForFailure(failure);
1163
+ const subtype = blockedActionSubtype(failure, verdict);
1164
+ if (subtype === "overlay" && verdict?.blocker) {
1165
+ return `${verdict.blocker} blocked ${failure.codeContext?.action || "the action"} on ${compactLocator(failure.locator)}.`;
1166
+ }
1167
+ if (subtype === "strict_mode") {
1168
+ return `${compactLocator(failure.locator)} matched multiple elements, so Playwright could not pick a single target.`;
1169
+ }
1170
+ if (subtype === "detached") {
1171
+ return `${compactLocator(failure.locator)} detached from the DOM before ${failure.codeContext?.action || "the action"} completed.`;
1172
+ }
1173
+ if (subtype === "offscreen") {
1174
+ return `${compactLocator(failure.locator)} never became reachable in the viewport for ${failure.codeContext?.action || "the action"}.`;
1175
+ }
1176
+ if (subtype === "focus") {
1177
+ return `${compactLocator(failure.locator)} was present but could not receive focus or input when the interaction ran.`;
1178
+ }
642
1179
  return `${compactLocator(failure.locator)} was found but was not actionable when the interaction ran.`;
643
1180
  }
644
1181
  if (failure.signal === "network") {
1182
+ if (failedRequest) {
1183
+ return `${formatRequestLabel(failedRequest)} blocked the test flow before the expected UI state loaded.`;
1184
+ }
645
1185
  return failure.apiHint
646
1186
  ? `A network or API request around ${failure.apiHint} did not complete successfully.`
647
1187
  : `A network or API request did not complete successfully.`;
@@ -650,6 +1190,12 @@ const describeFailure = (failure) => {
650
1190
  const domState = describeDomState(failure);
651
1191
  if (domState)
652
1192
  return `${domState}.`;
1193
+ const waitCondition = timeoutWaitConditionForFailure(failure);
1194
+ if (waitCondition) {
1195
+ return failure.timeoutMs
1196
+ ? `Playwright timed out after ${failure.timeoutMs}ms while waiting for ${waitCondition}.`
1197
+ : `Playwright timed out while waiting for ${waitCondition}.`;
1198
+ }
653
1199
  return failure.timeoutMs
654
1200
  ? `The expected UI or network condition did not complete before the ${failure.timeoutMs}ms timeout.`
655
1201
  : `The expected UI or network condition did not complete before timeout.`;
@@ -658,6 +1204,12 @@ const describeFailure = (failure) => {
658
1204
  return `The test code threw a retry or flaky guard error before the app flow completed.`;
659
1205
  }
660
1206
  if (failure.signal === "runtime") {
1207
+ if (pageError) {
1208
+ return `Page error "${pageError}" interrupted the flow before the expected state was reached.`;
1209
+ }
1210
+ if (consoleError) {
1211
+ return `Console error "${consoleError}" interrupted the flow before the expected state was reached.`;
1212
+ }
661
1213
  return `A runtime error interrupted the test flow before the expected state was reached.`;
662
1214
  }
663
1215
  if (failure.signal === "infra") {
@@ -800,15 +1352,23 @@ const buildClusterLocationLine = (failures) => {
800
1352
  };
801
1353
  const buildEvidenceLines = (failure) => {
802
1354
  const lines = [];
1355
+ const blockedVerdict = blockedVerdictForFailure(failure);
803
1356
  const locationLine = buildLocationLine(failure);
1357
+ const pageError = recentPageErrorHint(failure.domCapture);
1358
+ const consoleError = recentConsoleErrorHint(failure.domCapture);
1359
+ const ariaHint = ariaSnapshotHint(failure.domCapture);
804
1360
  if (locationLine)
805
1361
  lines.push(locationLine);
806
- if (failure.codeContext?.focusLine)
807
- lines.push(`Failing code: ${failure.codeContext.focusLine.trim()}`);
808
1362
  if (failure.codeContext?.action)
809
1363
  lines.push(`Failing step: ${failure.codeContext.action}`);
810
1364
  if (failure.locator)
811
1365
  lines.push(`Selector: ${failure.locator}`);
1366
+ if (blockedVerdict?.blocker)
1367
+ lines.push(`Blocker: ${blockedVerdict.blocker}`);
1368
+ if (pageError)
1369
+ lines.push(`Page error: ${pageError}`);
1370
+ else if (consoleError)
1371
+ lines.push(`Console: ${consoleError}`);
812
1372
  if (failure.signal === "timeout" && failure.domCapture) {
813
1373
  if (failure.domCapture.targetFound === false || failure.domCapture.matchedCount === 0) {
814
1374
  lines.push(`Target state: locator never appeared`);
@@ -823,6 +1383,9 @@ const buildEvidenceLines = (failure) => {
823
1383
  lines.push(`Target state: found and visible before timeout`);
824
1384
  }
825
1385
  }
1386
+ if (!pageError && !consoleError && ariaHint && (failure.signal === "assertion_mismatch" || failure.signal === "locator_not_found")) {
1387
+ lines.push(`ARIA: ${ariaHint}`);
1388
+ }
826
1389
  if (failure.apiHint)
827
1390
  lines.push(`API: ${failure.apiHint}`);
828
1391
  return lines.slice(0, 4);
@@ -840,29 +1403,97 @@ const compactLocator = (value) => {
840
1403
  const compact = truncateValue(value, 40);
841
1404
  return compact || "target element";
842
1405
  };
1406
+ const setupErrorSummary = (failure) => {
1407
+ const message = failure.message.toLowerCase();
1408
+ if (/cannot find module ['"]@playwright\/test['"]|cannot find package ['"]@playwright\/test['"]/.test(message)) {
1409
+ return "@playwright/test is not installed";
1410
+ }
1411
+ if (/browsertype\.launch: executable doesn't exist|please run the following command to download new browsers|npx playwright install/.test(message)) {
1412
+ return "Playwright browsers are not installed";
1413
+ }
1414
+ if (/playwright test did not expect test\.describe\(\) to be called here/.test(message)) {
1415
+ return "Playwright loaded the test file outside the expected test runner context";
1416
+ }
1417
+ if (/error: no tests found/.test(message)) {
1418
+ return "No tests were discovered by Playwright";
1419
+ }
1420
+ if (/unknown command ['"]test['"]/.test(message)) {
1421
+ return "The Playwright CLI entrypoint is not available in this environment";
1422
+ }
1423
+ return compactErrorLine(failure) || "Playwright setup error";
1424
+ };
843
1425
  const buildSecondaryEvidenceLines = (failure) => buildEvidenceLines(failure)
844
1426
  .filter((line) => !line.startsWith("Error location:") && !line.startsWith("Likely file:"))
845
1427
  .slice(0, 3);
846
1428
  const compactRootCauseSummary = (cluster) => {
847
1429
  const failure = cluster.sample;
1430
+ const navigation = navigationVerdictForFailure(failure);
1431
+ const failedRequest = failedRequestFromDomCapture(failure.domCapture);
1432
+ if (failure.signal === "setup") {
1433
+ return "Same Playwright setup error blocked these tests";
1434
+ }
848
1435
  if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
849
1436
  return "Same test-side throw before the app flow completed";
850
1437
  }
851
1438
  if (failure.signal === "network") {
1439
+ if (failedRequest) {
1440
+ return `${formatRequestLabel(failedRequest)} broke these tests`;
1441
+ }
852
1442
  return failure.apiHint
853
1443
  ? `Same network/API failure around ${failure.apiHint}`
854
1444
  : "Same network/API failure across these tests";
855
1445
  }
856
1446
  if (failure.signal === "timeout") {
857
- return "Same blocked state transition timed out across these tests";
1447
+ const verdict = blockedVerdictForCluster(cluster);
1448
+ const locator = verdict?.locator ? compactLocator(verdict.locator) : null;
1449
+ if (verdict?.targetState === "visible_blocked" && verdict.action && locator) {
1450
+ return `Same blocked ${verdict.action} on ${locator}`;
1451
+ }
1452
+ if (verdict?.targetState === "missing" && locator) {
1453
+ return `${locator} never appeared before the waiting step`;
1454
+ }
1455
+ if (verdict?.targetState === "hidden" && locator) {
1456
+ return `${locator} stayed hidden before the action completed`;
1457
+ }
1458
+ if (verdict?.targetState === "disabled" && locator) {
1459
+ return `${locator} stayed disabled before the action completed`;
1460
+ }
1461
+ return `Playwright timed out while waiting for ${timeoutWaitConditionForCluster(cluster)}`;
858
1462
  }
859
1463
  if (failure.signal === "assertion_mismatch") {
1464
+ if (navigation?.kind === "auth_redirect" && navigation.received) {
1465
+ return `The app redirected to "${truncateValue(navigation.received, 40)}" instead of the expected route`;
1466
+ }
1467
+ if (navigation?.kind === "wrong_route" && navigation.expected && navigation.received) {
1468
+ return `The app opened "${truncateValue(navigation.received, 40)}" instead of "${truncateValue(navigation.expected, 40)}"`;
1469
+ }
1470
+ const expected = representativeClusterExpected(cluster);
1471
+ const received = representativeClusterReceived(cluster);
1472
+ const selector = representativeClusterSelector(cluster);
1473
+ const actionHint = assertionStateTransitionHint(failure, cluster);
1474
+ if (selector && expected && received) {
1475
+ return `${selector} showed "${truncateValue(received, 56)}" instead of "${truncateValue(expected, 40)}"`;
1476
+ }
1477
+ if (selector && expected && actionHint) {
1478
+ return `${selector} did not reach "${truncateValue(expected, 40)}" after ${actionHint}`;
1479
+ }
860
1480
  return "Same UI assertion mismatch across these tests";
861
1481
  }
862
1482
  if (failure.signal === "locator_not_found") {
863
1483
  return "Same missing or changed locator across these tests";
864
1484
  }
865
1485
  if (failure.signal === "actionability") {
1486
+ const verdict = blockedVerdictForCluster(cluster);
1487
+ const subtype = blockedActionSubtype(failure, verdict);
1488
+ if (subtype === "overlay" && verdict?.blocker && verdict?.locator) {
1489
+ return `${verdict.blocker} blocked ${verdict.action || "the action"} on ${compactLocator(verdict.locator)}`;
1490
+ }
1491
+ if (subtype === "strict_mode" && verdict?.locator) {
1492
+ return `${compactLocator(verdict.locator)} matched multiple elements in each failure`;
1493
+ }
1494
+ if (subtype === "detached" && verdict?.locator) {
1495
+ return `${compactLocator(verdict.locator)} detached before the action completed`;
1496
+ }
866
1497
  return "Same element actionability problem across these tests";
867
1498
  }
868
1499
  if (failure.signal === "infra") {
@@ -873,17 +1504,55 @@ const compactRootCauseSummary = (cluster) => {
873
1504
  const compactIssueTitle = (cluster) => {
874
1505
  const failure = cluster.sample;
875
1506
  const locator = compactLocator(failure.locator);
1507
+ const blockedVerdict = blockedVerdictForCluster(cluster);
1508
+ const navigation = navigationVerdictForFailure(failure);
876
1509
  switch (failure.signal) {
1510
+ case "setup":
1511
+ return "Playwright setup error";
877
1512
  case "assertion_mismatch":
1513
+ if (navigation?.kind === "auth_redirect") {
1514
+ return "Redirected to login";
1515
+ }
1516
+ if (navigation?.kind === "wrong_route") {
1517
+ return "Wrong route opened";
1518
+ }
878
1519
  return failure.locator ? `Assertion mismatch (${locator})` : "Assertion mismatch";
879
1520
  case "locator_not_found":
880
1521
  return failure.locator ? `Missing locator (${locator})` : "Missing locator";
881
1522
  case "actionability":
1523
+ if (blockedVerdict?.locator) {
1524
+ const subtype = blockedActionSubtype(failure, blockedVerdict);
1525
+ if (subtype === "strict_mode")
1526
+ return `Strict-mode locator conflict (${compactLocator(blockedVerdict.locator)})`;
1527
+ if (subtype === "detached")
1528
+ return `Detached target before ${blockedVerdict.action || "action"} (${compactLocator(blockedVerdict.locator)})`;
1529
+ if (subtype === "offscreen")
1530
+ return `Offscreen target before ${blockedVerdict.action || "action"} (${compactLocator(blockedVerdict.locator)})`;
1531
+ if (subtype === "focus")
1532
+ return `Input target blocked (${compactLocator(blockedVerdict.locator)})`;
1533
+ return `Blocked interaction (${compactLocator(blockedVerdict.locator)})`;
1534
+ }
882
1535
  return failure.locator ? `Blocked interaction (${locator})` : "Blocked interaction";
883
1536
  case "network":
884
- return failure.apiHint ? `Network/API failure (${truncateValue(failure.apiHint, 28)})` : "Network/API failure";
1537
+ return failure.apiHint ? `Backend/API failure (${truncateValue(failure.apiHint, 28)})` : "Backend/API failure";
885
1538
  case "timeout":
886
- return failure.locator ? `Timeout waiting on ${locator}` : "Timeout waiting for state change";
1539
+ if (navigation?.kind === "navigation_timeout")
1540
+ return "Navigation timeout";
1541
+ if (navigation?.kind === "blank_page")
1542
+ return "Blank page after navigation";
1543
+ if (blockedVerdict?.targetState === "visible_blocked" && blockedVerdict.action && blockedVerdict.locator) {
1544
+ return `Blocked ${blockedVerdict.action} on ${compactLocator(blockedVerdict.locator)}`;
1545
+ }
1546
+ if (blockedVerdict?.targetState === "missing" && blockedVerdict.locator) {
1547
+ return `Timeout while waiting for ${compactLocator(blockedVerdict.locator)} to appear`;
1548
+ }
1549
+ if (blockedVerdict?.targetState === "hidden" && blockedVerdict.locator) {
1550
+ return `Timeout while waiting for ${compactLocator(blockedVerdict.locator)} to become visible`;
1551
+ }
1552
+ if (blockedVerdict?.targetState === "disabled" && blockedVerdict.locator) {
1553
+ return `Timeout while waiting for ${compactLocator(blockedVerdict.locator)} to become enabled`;
1554
+ }
1555
+ return `Timeout while waiting for ${timeoutWaitConditionForCluster(cluster)}`;
887
1556
  case "runtime":
888
1557
  return /retry|flaky/i.test(failure.message) ? "Test-side throw" : "Runtime error";
889
1558
  case "infra":
@@ -894,9 +1563,31 @@ const compactIssueTitle = (cluster) => {
894
1563
  };
895
1564
  const clusterCauseLine = (cluster) => {
896
1565
  const failure = cluster.sample;
1566
+ const blockedVerdict = blockedVerdictForCluster(cluster);
1567
+ const navigation = navigationVerdictForFailure(failure);
1568
+ const failedRequest = failedRequestFromDomCapture(failure.domCapture);
1569
+ if (failure.signal === "setup") {
1570
+ return setupErrorSummary(failure);
1571
+ }
897
1572
  if (failure.signal === "assertion_mismatch") {
898
- return failure.locator
899
- ? `Same assertion mismatch on ${compactLocator(failure.locator)}`
1573
+ if (navigation?.kind === "auth_redirect" && navigation.received) {
1574
+ return `The app redirected to "${truncateValue(navigation.received, 48)}" instead of the expected route`;
1575
+ }
1576
+ if (navigation?.kind === "wrong_route" && navigation.expected && navigation.received) {
1577
+ return `The app opened "${truncateValue(navigation.received, 48)}" instead of "${truncateValue(navigation.expected, 48)}"`;
1578
+ }
1579
+ const selector = representativeClusterSelector(cluster) || (failure.locator ? compactLocator(failure.locator) : null);
1580
+ const expected = representativeClusterExpected(cluster) || failure.expected;
1581
+ const received = representativeClusterReceived(cluster) || failure.received;
1582
+ const actionHint = assertionStateTransitionHint(failure, cluster);
1583
+ if (selector && expected && received) {
1584
+ return describeAssertionDifference(selector, expected, received);
1585
+ }
1586
+ if (selector && expected && actionHint) {
1587
+ return `${selector} did not reach ${describeExpectedState(expected) || "the expected state"} after ${actionHint}`;
1588
+ }
1589
+ return selector
1590
+ ? `Same assertion mismatch on ${selector}`
900
1591
  : "Same assertion mismatch across these tests";
901
1592
  }
902
1593
  if (failure.signal === "locator_not_found") {
@@ -905,28 +1596,166 @@ const clusterCauseLine = (cluster) => {
905
1596
  : "Same missing or changed locator across these tests";
906
1597
  }
907
1598
  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";
1599
+ const subtype = blockedActionSubtype(failure, blockedVerdict);
1600
+ if (subtype === "overlay" && blockedVerdict?.blocker && blockedVerdict?.locator) {
1601
+ return `${blockedVerdict.blocker} blocked ${blockedVerdict.action || "the action"} on ${compactLocator(blockedVerdict.locator)}`;
1602
+ }
1603
+ if (subtype === "strict_mode" && blockedVerdict?.locator) {
1604
+ return `${compactLocator(blockedVerdict.locator)} matched multiple elements, so Playwright could not pick one target`;
1605
+ }
1606
+ if (subtype === "detached" && blockedVerdict?.locator) {
1607
+ return `${compactLocator(blockedVerdict.locator)} detached from the DOM before the action completed`;
1608
+ }
1609
+ if (subtype === "offscreen" && blockedVerdict?.locator) {
1610
+ return `${compactLocator(blockedVerdict.locator)} never became reachable in the viewport`;
1611
+ }
1612
+ if (subtype === "focus" && blockedVerdict?.locator) {
1613
+ return `${compactLocator(blockedVerdict.locator)} could not receive focus or input when the action ran`;
1614
+ }
1615
+ return blockedVerdict?.locator
1616
+ ? `${compactLocator(blockedVerdict.locator)} is present but blocked in each failure`
1617
+ : failure.locator
1618
+ ? `${compactLocator(failure.locator)} is present but blocked in each failure`
1619
+ : "Same actionability problem across these tests";
1620
+ }
1621
+ if (failure.signal === "network") {
1622
+ if (failedRequest) {
1623
+ return `${formatRequestLabel(failedRequest)} blocked the expected UI flow`;
1624
+ }
1625
+ }
1626
+ if (failure.signal === "timeout") {
1627
+ if (navigation?.kind === "navigation_timeout") {
1628
+ return `The route change or page load never completed before timeout`;
1629
+ }
1630
+ if (navigation?.kind === "blank_page") {
1631
+ return `The app ended on a blank page instead of the expected screen`;
1632
+ }
1633
+ if (blockedVerdict?.targetState === "visible_blocked" && blockedVerdict.action && blockedVerdict.locator) {
1634
+ if (blockedVerdict.blocker) {
1635
+ return `${blockedVerdict.blocker} blocked ${blockedVerdict.action} on ${compactLocator(blockedVerdict.locator)}`;
1636
+ }
1637
+ return `${blockedVerdict.action} on ${compactLocator(blockedVerdict.locator)} was blocked before the state changed`;
1638
+ }
1639
+ if (blockedVerdict?.targetState === "missing" && blockedVerdict.locator) {
1640
+ return `Playwright kept waiting for ${compactLocator(blockedVerdict.locator)} to appear, but it never rendered before timeout`;
1641
+ }
1642
+ if (blockedVerdict?.targetState === "hidden" && blockedVerdict.locator) {
1643
+ return `Playwright kept waiting for ${compactLocator(blockedVerdict.locator)} to become visible, but it stayed hidden`;
1644
+ }
1645
+ if (blockedVerdict?.targetState === "disabled" && blockedVerdict.locator) {
1646
+ return `Playwright kept waiting for ${compactLocator(blockedVerdict.locator)} to become enabled, but it stayed disabled`;
1647
+ }
1648
+ return `Playwright timed out while waiting for ${timeoutWaitConditionForCluster(cluster)}`;
911
1649
  }
912
1650
  return compactRootCauseSummary(cluster);
913
1651
  };
914
1652
  const strongerClusterNext = (cluster) => {
915
1653
  const failure = cluster.sample;
1654
+ const blockedVerdict = blockedVerdictForCluster(cluster);
1655
+ const navigation = navigationVerdictForFailure(failure);
1656
+ const failedRequest = failedRequestFromDomCapture(failure.domCapture);
1657
+ if (failure.signal === "setup") {
1658
+ return checkFirst(failure);
1659
+ }
916
1660
  if (failure.signal === "assertion_mismatch") {
1661
+ if (navigation?.kind === "auth_redirect") {
1662
+ return "check auth/session setup or redirect guards before this route loads";
1663
+ }
1664
+ if (navigation?.kind === "wrong_route" && navigation.expected) {
1665
+ return `check the route change or redirect logic so the app lands on "${truncateValue(navigation.expected, 40)}"`;
1666
+ }
1667
+ const selector = representativeClusterSelector(cluster) || (failure.locator ? compactLocator(failure.locator) : null);
1668
+ const expected = representativeClusterExpected(cluster) || failure.expected;
1669
+ const received = representativeClusterReceived(cluster) || failure.received;
1670
+ const actionHint = assertionStateTransitionHint(failure, cluster);
917
1671
  if (failure.locator && failure.apiHint) {
918
1672
  return `check ${compactLocator(failure.locator)} or the data returned by ${truncateValue(failure.apiHint, 36)}`;
919
1673
  }
920
- if (failure.locator) {
921
- return `check ${compactLocator(failure.locator)} or the data source behind it`;
1674
+ if (selector && expected && received && actionHint) {
1675
+ if (isStateExpectation(expected) && isStateExpectation(received)) {
1676
+ return `fix the UI state after ${actionHint} so ${selector} is ${truncateValue(expected, 24)} instead of ${truncateValue(received, 24)}`;
1677
+ }
1678
+ if (/^count \d+$/i.test(expected) && /^count \d+$/i.test(received)) {
1679
+ return `fix the UI state after ${actionHint} so ${selector} reaches ${expected} instead of ${received}`;
1680
+ }
1681
+ return `fix the UI state after ${actionHint} so ${selector} shows "${truncateValue(expected, 28)}" instead of "${truncateValue(received, 28)}"`;
1682
+ }
1683
+ if (selector && expected && received) {
1684
+ if (isStateExpectation(expected) && isStateExpectation(received)) {
1685
+ return `fix the UI state behind ${selector} so it is ${truncateValue(expected, 24)} instead of ${truncateValue(received, 24)}`;
1686
+ }
1687
+ if (/^count \d+$/i.test(expected) && /^count \d+$/i.test(received)) {
1688
+ return `fix the UI state behind ${selector} so it reaches ${expected} instead of ${received}`;
1689
+ }
1690
+ return `fix the UI state behind ${selector} so it shows "${truncateValue(expected, 28)}" instead of "${truncateValue(received, 28)}"`;
1691
+ }
1692
+ if (selector && expected && actionHint) {
1693
+ return `fix the UI state after ${actionHint} so ${assertionWaitCondition(selector, expected)}`;
1694
+ }
1695
+ if (selector && received) {
1696
+ return `fix the state update behind ${selector} so it no longer shows "${truncateValue(received, 28)}"`;
1697
+ }
1698
+ if (selector) {
1699
+ return `check ${selector} or the data source behind it`;
922
1700
  }
923
1701
  }
924
1702
  if (failure.signal === "locator_not_found" && failure.locator) {
925
1703
  return `check whether ${compactLocator(failure.locator)} changed or no longer renders`;
926
1704
  }
927
1705
  if (failure.signal === "actionability" && failure.locator) {
1706
+ const subtype = blockedActionSubtype(failure, blockedVerdict);
1707
+ if (subtype === "strict_mode" && blockedVerdict?.locator) {
1708
+ return `narrow ${compactLocator(blockedVerdict.locator)} so it matches exactly one element`;
1709
+ }
1710
+ if (subtype === "detached" && blockedVerdict?.locator) {
1711
+ return `stabilize the DOM so ${compactLocator(blockedVerdict.locator)} does not detach before the action runs`;
1712
+ }
1713
+ if (subtype === "offscreen" && blockedVerdict?.locator) {
1714
+ return `make ${compactLocator(blockedVerdict.locator)} reachable in the viewport before the action`;
1715
+ }
1716
+ if (subtype === "focus" && blockedVerdict?.locator) {
1717
+ return `make ${compactLocator(blockedVerdict.locator)} focusable/editable before the action`;
1718
+ }
1719
+ if (blockedVerdict?.targetState === "visible_blocked" && blockedVerdict.locator) {
1720
+ return `remove what blocks ${compactLocator(blockedVerdict.locator)} from becoming actionable`;
1721
+ }
928
1722
  return `check what blocks ${compactLocator(failure.locator)} from becoming actionable`;
929
1723
  }
1724
+ if (failure.signal === "network") {
1725
+ if (failedRequest?.url) {
1726
+ return `fix the failing response for ${truncateValue(failedRequest.url, 44)} before debugging the UI`;
1727
+ }
1728
+ return failure.apiHint ? `fix the failing backend response around ${truncateValue(failure.apiHint, 44)}` : "fix the failing request or backend response";
1729
+ }
1730
+ if (failure.signal === "timeout") {
1731
+ if (navigation?.kind === "navigation_timeout") {
1732
+ return "check route loading, redirects, or slow backend responses before the page transition";
1733
+ }
1734
+ if (navigation?.kind === "blank_page") {
1735
+ return "check why the app ended on a blank route after navigation";
1736
+ }
1737
+ if (blockedVerdict?.targetState === "visible_blocked" && blockedVerdict.action && blockedVerdict.locator) {
1738
+ return blockedVerdict.blocker
1739
+ ? `remove or dismiss what blocks ${blockedVerdict.action} on ${compactLocator(blockedVerdict.locator)}`
1740
+ : `fix what blocks ${blockedVerdict.action} on ${compactLocator(blockedVerdict.locator)}`;
1741
+ }
1742
+ if (blockedVerdict?.targetState === "missing" && blockedVerdict.locator) {
1743
+ const actionLabel = capitalize(blockedVerdict.action) || "this step";
1744
+ return `make ${compactLocator(blockedVerdict.locator)} render before ${actionLabel.toLowerCase()} runs`;
1745
+ }
1746
+ if (blockedVerdict?.targetState === "hidden" && blockedVerdict.locator) {
1747
+ return `remove the condition keeping ${compactLocator(blockedVerdict.locator)} hidden before the action`;
1748
+ }
1749
+ if (blockedVerdict?.targetState === "disabled" && blockedVerdict.locator) {
1750
+ return `enable ${compactLocator(blockedVerdict.locator)} before the action runs`;
1751
+ }
1752
+ if (failure.locator && failure.codeContext?.action === "click") {
1753
+ return `check why ${compactLocator(failure.locator)} did not become clickable before timeout`;
1754
+ }
1755
+ if (failure.locator && failure.expected) {
1756
+ return `check why ${compactLocator(failure.locator)} did not reach "${truncateValue(failure.expected, 28)}" before timeout`;
1757
+ }
1758
+ }
930
1759
  return clusterCheckFirst(cluster);
931
1760
  };
932
1761
  const compactErrorLine = (failure) => {
@@ -934,8 +1763,24 @@ const compactErrorLine = (failure) => {
934
1763
  return null;
935
1764
  return withoutPrefix(failure.firstErrorLine, "Error:");
936
1765
  };
1766
+ const extractAssertionLineFromMessage = (message) => {
1767
+ if (!message)
1768
+ return null;
1769
+ const lines = stripAnsi(message).split(/\r?\n/);
1770
+ const markedAssertion = lines.find((line) => /^\s*>\s*\d+\s*\|.*\b(await\s+)?expect\(/.test(line));
1771
+ if (markedAssertion) {
1772
+ return markedAssertion
1773
+ .replace(/^\s*>\s*/, "")
1774
+ .replace(/^\s*\d+\s*\|\s*/, "")
1775
+ .trim() || null;
1776
+ }
1777
+ const assertionLine = lines.find((line) => /\b(await\s+)?expect\(/.test(line));
1778
+ return assertionLine?.trim() || null;
1779
+ };
937
1780
  const parseFailureFacts = (title, titlePath, message, status, file = null, options) => {
938
- const signal = classifySignal(message);
1781
+ let signal = classifySignal(message);
1782
+ const assertionLine = extractAssertionLineFromMessage(message);
1783
+ const assertionKind = extractAssertionKind(assertionLine);
939
1784
  const fallbackLocation = options?.errorLocation || extractStackLocation(message);
940
1785
  const fallbackCodeContext = fallbackLocation || options?.errorSnippet
941
1786
  ? {
@@ -947,9 +1792,9 @@ const parseFailureFacts = (title, titlePath, message, status, file = null, optio
947
1792
  expectedText: null,
948
1793
  timeoutMs: null,
949
1794
  apiCall: null,
950
- assertion: null,
1795
+ assertion: assertionLine,
951
1796
  methodName: null,
952
- focusLine: extractFocusLineFromSnippet(options?.errorSnippet),
1797
+ focusLine: assertionLine || extractFocusLineFromSnippet(options?.errorSnippet),
953
1798
  previousActionLine: null,
954
1799
  found: Boolean(fallbackLocation?.file || options?.errorSnippet)
955
1800
  }
@@ -961,13 +1806,30 @@ const parseFailureFacts = (title, titlePath, message, status, file = null, optio
961
1806
  file: options.codeContext.file || fallbackCodeContext?.file || null,
962
1807
  line: options.codeContext.line ?? fallbackCodeContext?.line ?? null,
963
1808
  column: options.codeContext.column ?? fallbackCodeContext?.column ?? null,
964
- focusLine: options.codeContext.focusLine || fallbackCodeContext?.focusLine || extractFocusLineFromMessage(message) || null,
1809
+ assertion: assertionLine || options.codeContext.assertion || fallbackCodeContext?.assertion || null,
1810
+ focusLine: signal === "assertion_mismatch"
1811
+ ? assertionLine ||
1812
+ options.codeContext.focusLine ||
1813
+ fallbackCodeContext?.focusLine ||
1814
+ options.codeContext.assertion ||
1815
+ fallbackCodeContext?.assertion ||
1816
+ options.codeContext.focusLine ||
1817
+ extractFocusLineFromMessage(message) ||
1818
+ null
1819
+ : options.codeContext.focusLine || fallbackCodeContext?.focusLine || extractFocusLineFromMessage(message) || null,
965
1820
  found: options.codeContext.found ?? fallbackCodeContext?.found ?? false
966
1821
  }
967
1822
  : fallbackCodeContext
968
1823
  ? {
969
1824
  ...fallbackCodeContext,
970
- focusLine: fallbackCodeContext.focusLine || extractFocusLineFromMessage(message) || null
1825
+ assertion: assertionLine || fallbackCodeContext.assertion || null,
1826
+ focusLine: signal === "assertion_mismatch"
1827
+ ? assertionLine ||
1828
+ fallbackCodeContext.focusLine ||
1829
+ fallbackCodeContext.assertion ||
1830
+ extractFocusLineFromMessage(message) ||
1831
+ null
1832
+ : fallbackCodeContext.focusLine || extractFocusLineFromMessage(message) || null
971
1833
  }
972
1834
  : {
973
1835
  file: fallbackLocation?.file || file || null,
@@ -978,19 +1840,64 @@ const parseFailureFacts = (title, titlePath, message, status, file = null, optio
978
1840
  expectedText: null,
979
1841
  timeoutMs: null,
980
1842
  apiCall: null,
981
- assertion: null,
1843
+ assertion: assertionLine,
982
1844
  methodName: null,
983
- focusLine: extractFocusLineFromMessage(message),
1845
+ focusLine: signal === "assertion_mismatch"
1846
+ ? assertionLine || extractFocusLineFromMessage(message)
1847
+ : extractFocusLineFromMessage(message),
984
1848
  previousActionLine: null,
985
- found: Boolean(fallbackLocation?.file || extractFocusLineFromMessage(message))
1849
+ found: Boolean(fallbackLocation?.file || assertionLine || extractFocusLineFromMessage(message))
986
1850
  };
987
1851
  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;
1852
+ const locator = extractLocator(message) ||
1853
+ codeContext?.locator ||
1854
+ domCapture?.normalizedLocator ||
1855
+ domCapture?.locator ||
1856
+ null;
1857
+ const extractedExpected = extractExpected(message);
1858
+ const extractedReceived = extractReceived(message);
1859
+ const expected = extractedExpected ||
1860
+ codeContext?.expectedText ||
1861
+ ((signal === "assertion_mismatch" || assertionKind !== "unknown")
1862
+ ? extractExpectedFromAssertionLine(codeContext?.assertion || codeContext?.focusLine || null)
1863
+ : null) ||
1864
+ domCapture?.expectedText ||
1865
+ null;
1866
+ const received = extractedReceived ||
1867
+ extractObservedAssertionValue(domCapture, expected, assertionKind) ||
1868
+ extractObservedDomText(domCapture) ||
1869
+ null;
1870
+ if (signal === "timeout" &&
1871
+ expected &&
1872
+ (received ||
1873
+ codeContext?.action === "expect_text" ||
1874
+ codeContext?.action === "assert" ||
1875
+ Boolean(codeContext?.assertion) ||
1876
+ message.toLowerCase().includes("expect") ||
1877
+ message.toLowerCase().includes("tohavetext") ||
1878
+ message.toLowerCase().includes("tocontaintext") ||
1879
+ message.toLowerCase().includes("received string"))) {
1880
+ signal = "assertion_mismatch";
1881
+ }
991
1882
  const likelyFile = inferLikelyFile(file, codeContext);
992
1883
  const likelyModule = inferLikelyModule(file, locator, codeContext);
993
- const apiHint = extractApiHint(message, codeContext);
1884
+ const failedRequest = failedRequestFromDomCapture(domCapture);
1885
+ const apiHint = extractApiHint(message, codeContext) || failedRequest?.url || null;
1886
+ if (signal === "timeout" &&
1887
+ /request failed|failed to fetch|socket hang up|econnreset|status\s*[45]\d{2}|net::|response timed out|api/i.test(message.toLowerCase()) &&
1888
+ apiHint) {
1889
+ signal = "network";
1890
+ }
1891
+ if ((signal === "timeout" || signal === "assertion_mismatch") &&
1892
+ failedRequest &&
1893
+ ((typeof failedRequest.status === "number" && failedRequest.status >= 400) || failedRequest.failure)) {
1894
+ signal = "network";
1895
+ }
1896
+ if ((signal === "timeout" || signal === "assertion_mismatch" || signal === "unknown") &&
1897
+ !failedRequest &&
1898
+ (recentPageErrorHint(domCapture) || recentConsoleErrorHint(domCapture))) {
1899
+ signal = "runtime";
1900
+ }
994
1901
  return {
995
1902
  title,
996
1903
  titlePath,
@@ -1056,6 +1963,9 @@ const buildSimilarityKey = (failure) => {
1056
1963
  basename(failure.likelyFile) || "unknown-file"
1057
1964
  ].join("|");
1058
1965
  }
1966
+ if (failure.signal === "setup") {
1967
+ return [failure.signal, normalizeMessageFingerprint(failure.message)].join("|");
1968
+ }
1059
1969
  if (failure.signal === "timeout" || failure.signal === "actionability" || failure.signal === "locator_not_found") {
1060
1970
  return [
1061
1971
  failure.signal,
@@ -1084,6 +1994,8 @@ const buildSimilarityKey = (failure) => {
1084
1994
  exports.buildSimilarityKey = buildSimilarityKey;
1085
1995
  const summarizeSignal = (signal) => {
1086
1996
  switch (signal) {
1997
+ case "setup":
1998
+ return "Playwright setup or bootstrap error";
1087
1999
  case "timeout":
1088
2000
  return "timeout while waiting for UI or network conditions";
1089
2001
  case "assertion_mismatch":
@@ -1116,7 +2028,211 @@ const buildClusterSuspects = (clusterFailures, window) => {
1116
2028
  .sort((a, b) => b.score - a.score)
1117
2029
  .slice(0, 2);
1118
2030
  };
1119
- const buildQuickDiagnosis = (playwrightJsonPath) => {
2031
+ const locationValueForFailure = (failure) => {
2032
+ const primaryLocation = buildLocationLine(failure);
2033
+ return primaryLocation
2034
+ ? withoutPrefix(withoutPrefix(primaryLocation, "Error location:"), "Likely file:")
2035
+ : null;
2036
+ };
2037
+ const locationValueForCluster = (cluster) => {
2038
+ const clusterLocation = buildClusterLocationLine(cluster.failures);
2039
+ return clusterLocation
2040
+ ? withoutPrefix(clusterLocation, clusterLocation.startsWith("Error locations:") ? "Error locations:" : "Error location:")
2041
+ : null;
2042
+ };
2043
+ const representativeClusterValue = (failures, pick) => dominantValue(failures.map((failure) => pick(failure)));
2044
+ const representativeClusterExpected = (cluster) => representativeClusterValue(cluster.failures, (failure) => failure.expected);
2045
+ const representativeClusterReceived = (cluster) => representativeClusterValue(cluster.failures, (failure) => failure.received);
2046
+ const representativeClusterSelector = (cluster) => {
2047
+ const evidenceSelector = buildClusterEvidenceLines(cluster)
2048
+ .find((line) => line.startsWith("Selector:"));
2049
+ if (evidenceSelector)
2050
+ return withoutPrefix(evidenceSelector, "Selector:");
2051
+ return representativeClusterValue(cluster.failures, (failure) => failure.locator);
2052
+ };
2053
+ const representativeClusterBlocker = (cluster) => {
2054
+ const evidenceBlocker = buildClusterEvidenceLines(cluster)
2055
+ .find((line) => line.startsWith("Blocker:"));
2056
+ if (evidenceBlocker)
2057
+ return withoutPrefix(evidenceBlocker, "Blocker:");
2058
+ return representativeClusterValue(cluster.failures, (failure) => blockedVerdictForFailure(failure)?.blocker);
2059
+ };
2060
+ const representativeClusterTargetState = (cluster) => {
2061
+ const evidenceState = buildClusterEvidenceLines(cluster)
2062
+ .find((line) => line.startsWith("Target state:"));
2063
+ if (evidenceState)
2064
+ return withoutPrefix(evidenceState, "Target state:");
2065
+ const verdict = blockedVerdictForCluster(cluster);
2066
+ if (!verdict)
2067
+ return null;
2068
+ switch (verdict.targetState) {
2069
+ case "visible_blocked":
2070
+ return "found and visible before timeout";
2071
+ case "missing":
2072
+ return "locator never appeared";
2073
+ case "hidden":
2074
+ return "found but hidden";
2075
+ case "disabled":
2076
+ return "found but disabled";
2077
+ default:
2078
+ return null;
2079
+ }
2080
+ };
2081
+ const representativeClusterFailingCode = (cluster) => representativeClusterValue(cluster.failures, (failure) => failure.codeContext?.focusLine || null);
2082
+ const representativeClusterFailingStep = (cluster) => representativeClusterValue(cluster.failures, (failure) => failure.codeContext?.action || null);
2083
+ const representativeClusterPreviousAction = (cluster) => representativeClusterValue(cluster.failures, (failure) => failure.codeContext?.previousActionLine || null);
2084
+ const compactActionLine = (value) => truncateValue(value, 84);
2085
+ const assertionStateTransitionHint = (failure, cluster) => {
2086
+ const previousAction = cluster
2087
+ ? representativeClusterPreviousAction(cluster)
2088
+ : failure.codeContext?.previousActionLine || null;
2089
+ if (!previousAction)
2090
+ return null;
2091
+ const compact = compactActionLine(previousAction);
2092
+ if (!compact)
2093
+ return null;
2094
+ return compact;
2095
+ };
2096
+ const buildSingleFailureIssue = (failed, top) => ({
2097
+ title: (() => {
2098
+ const clusterLike = {
2099
+ key: "single",
2100
+ count: 1,
2101
+ sample: failed,
2102
+ titles: [failed.title],
2103
+ suspects: top ? [top] : [],
2104
+ failures: [failed]
2105
+ };
2106
+ return compactIssueTitle(clusterLike);
2107
+ })(),
2108
+ cause: (() => {
2109
+ const clusterLike = {
2110
+ key: "single",
2111
+ count: 1,
2112
+ sample: failed,
2113
+ titles: [failed.title],
2114
+ suspects: top ? [top] : [],
2115
+ failures: [failed]
2116
+ };
2117
+ return failed.signal === "assertion_mismatch" || failed.signal === "timeout" || failed.signal === "locator_not_found" || failed.signal === "actionability"
2118
+ ? clusterCauseLine(clusterLike)
2119
+ : (0, exports.describeFailure)(failed);
2120
+ })(),
2121
+ affectedTitles: [failed.title],
2122
+ where: locationValueForFailure(failed),
2123
+ failingCode: failed.codeContext?.focusLine?.trim() || null,
2124
+ failingStep: failed.codeContext?.action || null,
2125
+ selector: failed.locator ? compactLocator(failed.locator) : null,
2126
+ blocker: blockedVerdictForFailure(failed)?.blocker || null,
2127
+ targetState: (() => {
2128
+ const verdict = blockedVerdictForFailure(failed);
2129
+ if (!verdict)
2130
+ return null;
2131
+ if (verdict.targetState === "visible_blocked")
2132
+ return "found and visible before timeout";
2133
+ if (verdict.targetState === "missing")
2134
+ return "locator never appeared";
2135
+ if (verdict.targetState === "hidden")
2136
+ return "found but hidden";
2137
+ if (verdict.targetState === "disabled")
2138
+ return "found but disabled";
2139
+ return null;
2140
+ })(),
2141
+ expected: failed.expected ? truncateValue(failed.expected) : null,
2142
+ received: failed.received ? truncateValue(failed.received) : null,
2143
+ whatChanged: top && top.score >= 0.62 ? top.commit.message : null,
2144
+ reason: top && top.score >= 0.62 && top.reasons.length ? `${compactWhyLine(top)}.` : null,
2145
+ next: (() => {
2146
+ const clusterLike = {
2147
+ key: "single",
2148
+ count: 1,
2149
+ sample: failed,
2150
+ titles: [failed.title],
2151
+ suspects: top ? [top] : [],
2152
+ failures: [failed]
2153
+ };
2154
+ return strongerClusterNext(clusterLike);
2155
+ })(),
2156
+ impact: "1 test failing with this root cause",
2157
+ clears: "fixing this likely clears 1 of 1 failures"
2158
+ });
2159
+ const buildClusterIssue = (cluster, totalFailures) => {
2160
+ const top = cluster.suspects[0];
2161
+ const rootCause = cluster.count === 1 ? (0, exports.describeFailure)(cluster.sample) : clusterCauseLine(cluster);
2162
+ return {
2163
+ title: `${compactIssueTitle(cluster)} (${cluster.count} test${cluster.count === 1 ? "" : "s"})`,
2164
+ cause: rootCause,
2165
+ affectedTitles: cluster.titles,
2166
+ where: locationValueForCluster(cluster),
2167
+ failingCode: representativeClusterFailingCode(cluster)?.trim() || null,
2168
+ failingStep: representativeClusterFailingStep(cluster)?.trim() || null,
2169
+ selector: representativeClusterSelector(cluster)?.trim() || null,
2170
+ blocker: representativeClusterBlocker(cluster)?.trim() || null,
2171
+ targetState: representativeClusterTargetState(cluster)?.trim() || null,
2172
+ expected: representativeClusterExpected(cluster) ? truncateValue(representativeClusterExpected(cluster) || null) : null,
2173
+ received: representativeClusterReceived(cluster) ? truncateValue(representativeClusterReceived(cluster) || null) : null,
2174
+ whatChanged: top && top.score >= 0.62 ? top.commit.message : null,
2175
+ reason: top && top.score >= 0.62 && top.reasons.length ? `${compactWhyLine(top)}.` : null,
2176
+ next: strongerClusterNext(cluster),
2177
+ impact: `${cluster.count} test${cluster.count === 1 ? "" : "s"} failing with ${cluster.count === 1 ? "this" : "same"} root cause`,
2178
+ clears: `fixing this likely clears ${cluster.count} of ${totalFailures} failures`
2179
+ };
2180
+ };
2181
+ const renderDiagnosisSummary = (summary) => {
2182
+ const lines = [];
2183
+ if (summary.headline)
2184
+ lines.push(summary.headline);
2185
+ if (summary.headline)
2186
+ lines.push("");
2187
+ lines.push(summary.failureCountLine);
2188
+ if (summary.collapseLine)
2189
+ lines.push(summary.collapseLine);
2190
+ if (summary.issues.length)
2191
+ lines.push("");
2192
+ for (const [index, issue] of summary.issues.entries()) {
2193
+ lines.push(`${summary.issues.length === 1 ? "Issue" : `Issue ${index + 1}`}: ${issue.title}`);
2194
+ lines.push(` Cause: ${issue.cause}`);
2195
+ const isSetupIssue = issue.title.startsWith("Playwright setup error");
2196
+ if (!isSetupIssue) {
2197
+ if (issue.where)
2198
+ lines.push(` Where: ${issue.where}`);
2199
+ if (issue.failingStep && issue.selector) {
2200
+ const readableStep = humanizeAction(issue.failingStep) || issue.failingStep;
2201
+ const combinedStep = readableStep.length > 1
2202
+ ? `${readableStep[0].toUpperCase()}${readableStep.slice(1)} on ${issue.selector} selector`
2203
+ : `${readableStep.toUpperCase()} on ${issue.selector} selector`;
2204
+ lines.push(` Failing step: ${combinedStep}`);
2205
+ }
2206
+ else {
2207
+ if (issue.failingStep)
2208
+ lines.push(` Failing step: ${humanizeAction(issue.failingStep) || issue.failingStep}`);
2209
+ if (issue.selector)
2210
+ lines.push(` Selector: ${issue.selector}`);
2211
+ }
2212
+ if (issue.blocker)
2213
+ lines.push(` Blocker: ${issue.blocker}`);
2214
+ if (issue.targetState)
2215
+ lines.push(` Target state: ${issue.targetState}`);
2216
+ if (issue.expected)
2217
+ lines.push(` Expected: ${issue.expected}`);
2218
+ if (issue.received)
2219
+ lines.push(` Received: ${issue.received}`);
2220
+ if (issue.whatChanged)
2221
+ lines.push(` Changes since last run: + ${issue.whatChanged}`);
2222
+ if (issue.reason)
2223
+ lines.push(` Reason: ${issue.reason}`);
2224
+ lines.push(` Likely fix: ${issue.next}.`);
2225
+ lines.push(` Impact: ${issue.impact}`);
2226
+ }
2227
+ if (index < summary.issues.length - 1)
2228
+ lines.push("");
2229
+ }
2230
+ return {
2231
+ lines,
2232
+ footer: summary.footer
2233
+ };
2234
+ };
2235
+ const buildQuickDiagnosisStructured = (playwrightJsonPath) => {
1120
2236
  const failures = (0, exports.collectFailureFacts)(playwrightJsonPath);
1121
2237
  if (!failures.length)
1122
2238
  return null;
@@ -1125,29 +2241,13 @@ const buildQuickDiagnosis = (playwrightJsonPath) => {
1125
2241
  const failed = failures[0];
1126
2242
  const suspects = commitWindow.trusted ? rankCommitsForFailure(failed, commitWindow) : [];
1127
2243
  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
2244
  return {
1149
- lines,
1150
- footer: []
2245
+ mode: "failure",
2246
+ headline: null,
2247
+ failureCountLine: "❌ 1 failure",
2248
+ collapseLine: null,
2249
+ issues: [buildSingleFailureIssue(failed, top)],
2250
+ footer: top ? [`Confidence: ${confidenceLabel(top.score).toLowerCase()}`] : []
1151
2251
  };
1152
2252
  }
1153
2253
  const clusterMap = new Map();
@@ -1168,50 +2268,95 @@ const buildQuickDiagnosis = (playwrightJsonPath) => {
1168
2268
  }))
1169
2269
  .sort((a, b) => b.count - a.count ||
1170
2270
  ((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);
2271
+ (b.sample.signal === "assertion_mismatch" ? 1 : 0) - (a.sample.signal === "assertion_mismatch" ? 1 : 0));
1173
2272
  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)}.`);
2273
+ const mergeRelatedIssues = (issues) => {
2274
+ // If multiple clusters share the same actionability blocker, treat them as one root cause.
2275
+ // This matches the user's expectation: the blocker is the canonical evidence, not the target locator.
2276
+ const merged = [];
2277
+ const used = new Set();
2278
+ const norm = (value) => (value || "").replace(/\s+/g, " ").trim().toLowerCase();
2279
+ const isBlockedInteraction = (issue) => issue.title.toLowerCase().startsWith("blocked interaction");
2280
+ const blockerKey = (issue) => {
2281
+ const blocker = norm(issue.blocker);
2282
+ if (!blocker)
2283
+ return null;
2284
+ const state = norm(issue.targetState);
2285
+ return `blocked_interaction::${blocker}::${state}`;
2286
+ };
2287
+ const buildMergedTitle = (blocker, count) => {
2288
+ const b = blocker.toLowerCase();
2289
+ const prefix = b.includes("overlay") ? "Overlay blocked interactions" : "Blocked interaction";
2290
+ return `${prefix} (${count} test${count === 1 ? "" : "s"})`;
2291
+ };
2292
+ for (let i = 0; i < issues.length; i += 1) {
2293
+ if (used.has(i))
2294
+ continue;
2295
+ const base = issues[i];
2296
+ const key = blockerKey(base);
2297
+ if (!key || !isBlockedInteraction(base)) {
2298
+ merged.push(base);
2299
+ used.add(i);
2300
+ continue;
1204
2301
  }
2302
+ const group = [base];
2303
+ used.add(i);
2304
+ for (let j = i + 1; j < issues.length; j += 1) {
2305
+ if (used.has(j))
2306
+ continue;
2307
+ const candidate = issues[j];
2308
+ if (!isBlockedInteraction(candidate))
2309
+ continue;
2310
+ if (blockerKey(candidate) !== key)
2311
+ continue;
2312
+ group.push(candidate);
2313
+ used.add(j);
2314
+ }
2315
+ if (group.length === 1) {
2316
+ merged.push(base);
2317
+ continue;
2318
+ }
2319
+ const allTitles = Array.from(new Set(group.flatMap((issue) => issue.affectedTitles || [])));
2320
+ const count = allTitles.length;
2321
+ const blocker = group.map((issue) => issue.blocker).find(Boolean) || base.blocker || "";
2322
+ const targetState = group.map((issue) => issue.targetState).find(Boolean) || base.targetState || null;
2323
+ const whereParts = group
2324
+ .map((issue) => issue.where)
2325
+ .filter(Boolean)
2326
+ .flatMap((where) => String(where).split(";").map((p) => p.trim()).filter(Boolean));
2327
+ const where = Array.from(new Set(whereParts)).slice(0, 12).join("; ") || null;
2328
+ merged.push({
2329
+ ...base,
2330
+ title: buildMergedTitle(blocker, count),
2331
+ // Canonical, normalized evidence: blocker + state. Avoid sample-selector specificity here.
2332
+ cause: `${blocker} blocked interactions across these tests`,
2333
+ affectedTitles: allTitles,
2334
+ where,
2335
+ selector: null,
2336
+ targetState,
2337
+ impact: `${count} tests failing with same root cause`,
2338
+ clears: `fixing this likely clears ${count} of ${failures.length} failures`
2339
+ });
1205
2340
  }
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
- }
2341
+ return merged;
2342
+ };
2343
+ const mergedIssues = mergeRelatedIssues(clusters.map((cluster) => buildClusterIssue(cluster, failures.length)));
1212
2344
  return {
1213
- lines,
2345
+ mode: "failure",
2346
+ headline: null,
2347
+ failureCountLine: clusters.length < failures.length
2348
+ ? `❌ ${failures.length} failures (grouped)`
2349
+ : `❌ ${failures.length} failures`,
2350
+ collapseLine: clusters.length < failures.length
2351
+ ? `Collapsed into ${clusters.length} real issue${clusters.length === 1 ? "" : "s"}`
2352
+ : null,
2353
+ issues: mergedIssues,
1214
2354
  footer: topCluster?.suspects[0] ? [`Confidence: ${confidenceLabel(topCluster.suspects[0].score).toLowerCase()}`] : []
1215
2355
  };
1216
2356
  };
2357
+ exports.buildQuickDiagnosisStructured = buildQuickDiagnosisStructured;
2358
+ const buildQuickDiagnosis = (playwrightJsonPath) => {
2359
+ const summary = (0, exports.buildQuickDiagnosisStructured)(playwrightJsonPath);
2360
+ return summary ? renderDiagnosisSummary(summary) : null;
2361
+ };
1217
2362
  exports.buildQuickDiagnosis = buildQuickDiagnosis;