@sentinelqa/playwright-reporter 0.1.51 → 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");
@@ -162,6 +162,23 @@ const extractFocusLineFromSnippet = (snippet) => {
162
162
  .replace(/^\s*\d+\s*\|\s*/, "")
163
163
  .trim() || null;
164
164
  };
165
+ const extractFocusLineFromMessage = (message) => {
166
+ if (!message)
167
+ return null;
168
+ const lines = stripAnsi(message).split(/\r?\n/);
169
+ const marked = lines.find((line) => /^\s*>\s*\d+\s*\|/.test(line));
170
+ if (marked) {
171
+ return marked
172
+ .replace(/^\s*>\s*/, "")
173
+ .replace(/^\s*\d+\s*\|\s*/, "")
174
+ .trim() || null;
175
+ }
176
+ const expectLine = lines.find((line) => /\b(await\s+)?expect\(/.test(line));
177
+ if (expectLine)
178
+ return expectLine.trim();
179
+ const stepLine = lines.find((line) => /\b(getBy|locator\(|page\.)/.test(line));
180
+ return stepLine?.trim() || null;
181
+ };
165
182
  const parseCommitLine = (line) => {
166
183
  const [sha, author, message] = line.split("\u001f");
167
184
  if (!sha)
@@ -208,29 +225,55 @@ const normalizeStatus = (status) => {
208
225
  return "passed";
209
226
  return "skipped";
210
227
  };
228
+ const errorRichness = (error) => {
229
+ if (!error)
230
+ return -1;
231
+ const message = String(error.message || error.stack || error.value || "");
232
+ let score = message.length;
233
+ if (error.location?.file)
234
+ score += 200;
235
+ if (/locator\.|Call log:|intercepts pointer events|waiting for|getBy|at .*:\d+:\d+/i.test(message))
236
+ score += 400;
237
+ if (/Test timeout of \d+ms exceeded\.$/i.test(stripAnsi(message).trim()))
238
+ score -= 300;
239
+ return score;
240
+ };
241
+ const selectBestError = (result) => {
242
+ const candidates = [result.error, ...(result.errors || [])].filter(Boolean);
243
+ if (!candidates.length)
244
+ return null;
245
+ return candidates.sort((a, b) => errorRichness(b) - errorRichness(a))[0] || null;
246
+ };
211
247
  const toMessage = (result) => {
212
- const direct = result.error?.message || result.error?.stack || result.error?.value || null;
213
- if (direct)
214
- return stripAnsi(String(direct));
215
- const first = result.errors?.find(Boolean);
216
- return first ? stripAnsi(String(first.message || first.stack || first.value || "")) : "";
248
+ const best = selectBestError(result);
249
+ return best ? stripAnsi(String(best.message || best.stack || best.value || "")) : "";
217
250
  };
218
251
  const classifySignal = (message) => {
219
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
+ }
220
263
  if (/browser has been closed|target page, context or browser has been closed|crash|page crashed|browser disconnected/.test(lower)) {
221
264
  return "infra";
222
265
  }
223
- if (/expected substring|expected string|received string|tohavetext|tocontaintext|tohavevalue|tobechecked/.test(lower)) {
224
- return "assertion_mismatch";
225
- }
226
- if (/timeout|timed out|waiting for/.test(lower))
227
- return "timeout";
228
- 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)) {
229
267
  return "locator_not_found";
230
268
  }
231
- 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)) {
232
270
  return "actionability";
233
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";
234
277
  if (/status\s*[45]\d{2}|net::|failed to fetch|network|request failed|socket hang up|econnreset|503|502|500/.test(lower)) {
235
278
  return "network";
236
279
  }
@@ -244,20 +287,265 @@ const extractLocator = (message) => {
244
287
  const locatorLine = message.match(/Locator:\s*(.+)/i);
245
288
  if (locatorLine?.[1])
246
289
  return locatorLine[1].trim();
247
- const callLine = message.match(/(getByTestId|getByRole|getByText|locator)\([^)]+\)/);
290
+ const callLine = message.match(/(getByTestId|getByRole|getByText|getByLabel|getByPlaceholder|getByTitle|locator)\([^)]+\)/);
248
291
  return callLine?.[0] || null;
249
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
+ };
250
320
  const extractExpected = (message) => {
251
321
  const match = message.match(/Expected substring:\s*"([^"]+)"/i) ||
252
322
  message.match(/Expected string:\s*"([^"]+)"/i) ||
253
- message.match(/Expected:\s*"([^"]+)"/i);
323
+ message.match(/Expected:\s*"([^"]+)"/i) ||
324
+ message.match(/Expected:\s*`([^`]+)`/i) ||
325
+ message.match(/instead of\s+"([^"]+)"/i);
254
326
  return match?.[1] || null;
255
327
  };
256
328
  const extractReceived = (message) => {
257
329
  const match = message.match(/Received string:\s*"([^"]+)"/i) ||
258
- message.match(/Received:\s*"([^"]+)"/i);
330
+ message.match(/Received:\s*"([^"]+)"/i) ||
331
+ message.match(/Received:\s*`([^`]+)`/i) ||
332
+ message.match(/showed\s+"([^"]+)"/i);
259
333
  return match?.[1] || null;
260
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
+ };
261
549
  const extractTimeoutMs = (message) => {
262
550
  const match = message.match(/Timeout:\s*(\d+)\s*ms/i) ||
263
551
  message.match(/timeout(?: of)?\s*(\d+)\s*ms/i) ||
@@ -280,6 +568,85 @@ const extractApiHint = (message, codeContext) => {
280
568
  const apiMatch = message.match(/\/(api|graphql|rest)\/[^\s)"']+/i);
281
569
  return apiMatch?.[0] || null;
282
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
+ };
283
650
  const extractStackLocation = (message) => {
284
651
  const lines = stripAnsi(message).split(/\r?\n/);
285
652
  for (const line of lines) {
@@ -339,6 +706,242 @@ const describeDomState = (failure) => {
339
706
  }
340
707
  return null;
341
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
+ };
738
+ const dominantValue = (values) => {
739
+ const counts = new Map();
740
+ for (const value of values) {
741
+ const normalized = (value || "").trim();
742
+ if (!normalized)
743
+ continue;
744
+ counts.set(normalized, (counts.get(normalized) || 0) + 1);
745
+ }
746
+ const sorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
747
+ if (!sorted.length)
748
+ return null;
749
+ return sorted[0][0];
750
+ };
751
+ const dominantFocusLine = (failures) => dominantValue(failures.map((failure) => failure.codeContext?.focusLine || null));
752
+ const timeoutState = (failure) => {
753
+ const dom = failure.domCapture;
754
+ if (!dom)
755
+ return "unknown";
756
+ if (dom.targetFound === false || dom.matchedCount === 0)
757
+ return "missing";
758
+ if (dom.visible === false)
759
+ return "hidden";
760
+ if (dom.enabled === false)
761
+ return "disabled";
762
+ if (dom.targetFound === true && dom.visible === true)
763
+ return "present";
764
+ return "unknown";
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
+ };
782
+ const sharedTimeoutEvidence = (failures) => {
783
+ const timeoutFailures = failures.filter((failure) => failure.signal === "timeout");
784
+ return {
785
+ action: dominantValue(timeoutFailures.map((failure) => failure.codeContext?.action || null)),
786
+ locator: dominantValue(timeoutFailures.map((failure) => failure.locator || null)),
787
+ state: dominantValue(timeoutFailures.map((failure) => timeoutState(failure))) || "unknown"
788
+ };
789
+ };
790
+ const timeoutStateLabel = (state) => {
791
+ switch (state) {
792
+ case "missing":
793
+ return "locator never appeared";
794
+ case "hidden":
795
+ return "found but hidden";
796
+ case "disabled":
797
+ return "found but disabled";
798
+ case "present":
799
+ return "found and visible before timeout";
800
+ default:
801
+ return null;
802
+ }
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
+ };
342
945
  const buildTouchedFileReason = (label, files) => `${label}: ${files.slice(0, 2).map((file) => basename(file)).join(", ")}`;
343
946
  const commitTouchedFailure = (commit, failure) => {
344
947
  const reasons = [];
@@ -455,13 +1058,36 @@ const flattenFailedCases = (node, titlePath = []) => {
455
1058
  timeoutBudgetMs: typeof test.timeout === "number" ? test.timeout : null,
456
1059
  codeContext: loadCodeContext(finalResult),
457
1060
  domCapture: loadDomCapture(finalResult),
458
- errorLocation: finalResult.error?.location || finalResult.errors?.find(Boolean)?.location || null,
459
- errorSnippet: finalResult.error?.snippet || null
1061
+ errorLocation: selectBestError(finalResult)?.location ||
1062
+ finalResult.error?.location ||
1063
+ finalResult.errors?.find((item) => item?.location)?.location ||
1064
+ test.location ||
1065
+ null,
1066
+ errorSnippet: selectBestError(finalResult)?.snippet || finalResult.error?.snippet || null
460
1067
  }));
461
1068
  }
462
1069
  return failures;
463
1070
  };
464
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
+ }
465
1091
  if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
466
1092
  const file = basename(failure.codeContext?.file || failure.likelyFile);
467
1093
  const line = failure.codeContext?.line;
@@ -526,6 +1152,8 @@ const alternateCommitLine = (match) => {
526
1152
  };
527
1153
  const rootCauseLabel = (failure) => {
528
1154
  switch (failure.signal) {
1155
+ case "setup":
1156
+ return "Playwright setup error";
529
1157
  case "assertion_mismatch":
530
1158
  return "UI assertion mismatch";
531
1159
  case "locator_not_found":
@@ -545,16 +1173,62 @@ const rootCauseLabel = (failure) => {
545
1173
  }
546
1174
  };
547
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);
548
1195
  if (failure.signal === "assertion_mismatch" && failure.locator && failure.expected && failure.received) {
549
- return `${failure.locator} showed "${failure.received}" instead of "${failure.expected}".`;
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"}.`;
550
1204
  }
551
1205
  if (failure.signal === "locator_not_found" && failure.locator) {
552
- return `${failure.locator} was not found when the test expected it to be available.`;
1206
+ return `${compactLocator(failure.locator)} was not found when the test expected it to be available.`;
553
1207
  }
554
1208
  if (failure.signal === "actionability" && failure.locator) {
555
- return `${failure.locator} was found but was not actionable when the interaction ran.`;
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
+ }
1226
+ return `${compactLocator(failure.locator)} was found but was not actionable when the interaction ran.`;
556
1227
  }
557
1228
  if (failure.signal === "network") {
1229
+ if (failedRequest) {
1230
+ return `${formatRequestLabel(failedRequest)} blocked the test flow before the expected UI state loaded.`;
1231
+ }
558
1232
  return failure.apiHint
559
1233
  ? `A network or API request around ${failure.apiHint} did not complete successfully.`
560
1234
  : `A network or API request did not complete successfully.`;
@@ -563,6 +1237,12 @@ const describeFailure = (failure) => {
563
1237
  const domState = describeDomState(failure);
564
1238
  if (domState)
565
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
+ }
566
1246
  return failure.timeoutMs
567
1247
  ? `The expected UI or network condition did not complete before the ${failure.timeoutMs}ms timeout.`
568
1248
  : `The expected UI or network condition did not complete before timeout.`;
@@ -571,6 +1251,12 @@ const describeFailure = (failure) => {
571
1251
  return `The test code threw a retry or flaky guard error before the app flow completed.`;
572
1252
  }
573
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
+ }
574
1260
  return `A runtime error interrupted the test flow before the expected state was reached.`;
575
1261
  }
576
1262
  if (failure.signal === "infra") {
@@ -593,7 +1279,25 @@ const describeCluster = (cluster) => {
593
1279
  return `${cluster.count} tests failed behind the same network or API signal.`;
594
1280
  }
595
1281
  if (cluster.count > 1 && failure.signal === "timeout") {
596
- return `${cluster.count} tests timed out behind the same blocked state transition.`;
1282
+ const evidence = sharedTimeoutEvidence(cluster.failures);
1283
+ const action = evidence.action ? evidence.action.trim() : "the expected interaction";
1284
+ const locator = evidence.locator ? compactLocator(evidence.locator) : "the target element";
1285
+ if (evidence.state === "missing") {
1286
+ return `${cluster.count} tests timed out because ${locator} never appeared before ${action}.`;
1287
+ }
1288
+ if (evidence.state === "hidden") {
1289
+ return `${cluster.count} tests timed out because ${locator} stayed hidden and blocked ${action}.`;
1290
+ }
1291
+ if (evidence.state === "disabled") {
1292
+ return `${cluster.count} tests timed out because ${locator} stayed disabled and blocked ${action}.`;
1293
+ }
1294
+ if (evidence.state === "present" && evidence.action) {
1295
+ return `${cluster.count} tests timed out because ${action} on ${locator} never completed even though the target was present.`;
1296
+ }
1297
+ if (evidence.action && evidence.locator) {
1298
+ return `${cluster.count} tests timed out on the same ${action} step for ${locator}.`;
1299
+ }
1300
+ return `${cluster.count} tests timed out while waiting for the same UI state change to complete.`;
597
1301
  }
598
1302
  if (cluster.count > 1 && failure.signal === "infra") {
599
1303
  return `${cluster.count} tests failed behind the same browser or CI instability signal.`;
@@ -610,8 +1314,57 @@ const clusterCheckFirst = (cluster) => {
610
1314
  }
611
1315
  return "inspect the shared retry/flaky helper or intentional throw in these tests before opening the trace";
612
1316
  }
1317
+ if (failure.signal === "timeout") {
1318
+ const evidence = sharedTimeoutEvidence(cluster.failures);
1319
+ const locator = evidence.locator ? compactLocator(evidence.locator) : "the target element";
1320
+ if (evidence.state === "missing") {
1321
+ return `make ${locator} render before the blocked step`;
1322
+ }
1323
+ if (evidence.state === "hidden") {
1324
+ return `remove the condition keeping ${locator} hidden before the action`;
1325
+ }
1326
+ if (evidence.state === "disabled") {
1327
+ return `enable ${locator} before the action runs`;
1328
+ }
1329
+ if (evidence.state === "present" && evidence.action && evidence.locator) {
1330
+ return `fix what blocks ${evidence.action} on ${compactLocator(evidence.locator)} (overlay or pointer interception is likely)`;
1331
+ }
1332
+ if (failure.codeContext?.focusLine) {
1333
+ return `inspect the waiting assertion or step: ${failure.codeContext.focusLine.trim()}`;
1334
+ }
1335
+ if (failure.likelyFile) {
1336
+ return `inspect the waiting assertion or blocked state transition in ${basename(failure.likelyFile)}`;
1337
+ }
1338
+ }
613
1339
  return checkFirst(failure);
614
1340
  };
1341
+ const buildClusterEvidenceLines = (cluster) => {
1342
+ const failure = cluster.sample;
1343
+ const lines = [];
1344
+ if (failure.signal === "timeout") {
1345
+ const evidence = sharedTimeoutEvidence(cluster.failures);
1346
+ const focusLine = dominantFocusLine(cluster.failures);
1347
+ if (focusLine)
1348
+ lines.push(`Failing code: ${focusLine.trim()}`);
1349
+ if (evidence.action)
1350
+ lines.push(`Failing step: ${evidence.action}`);
1351
+ if (evidence.locator)
1352
+ lines.push(`Selector: ${compactLocator(evidence.locator)}`);
1353
+ const stateLabel = timeoutStateLabel(evidence.state);
1354
+ if (stateLabel)
1355
+ lines.push(`Target state: ${stateLabel}`);
1356
+ return lines.slice(0, 4);
1357
+ }
1358
+ const focusLine = dominantFocusLine(cluster.failures);
1359
+ if (focusLine)
1360
+ lines.push(`Failing code: ${focusLine.trim()}`);
1361
+ for (const line of buildSecondaryEvidenceLines(failure)) {
1362
+ if (lines.includes(line))
1363
+ continue;
1364
+ lines.push(line);
1365
+ }
1366
+ return lines.slice(0, 4);
1367
+ };
615
1368
  const formatAffectedTests = (titles) => {
616
1369
  const unique = Array.from(new Set(titles.map((title) => shortenTitle(title)))).slice(0, 3);
617
1370
  if (!unique.length)
@@ -646,15 +1399,23 @@ const buildClusterLocationLine = (failures) => {
646
1399
  };
647
1400
  const buildEvidenceLines = (failure) => {
648
1401
  const lines = [];
1402
+ const blockedVerdict = blockedVerdictForFailure(failure);
649
1403
  const locationLine = buildLocationLine(failure);
1404
+ const pageError = recentPageErrorHint(failure.domCapture);
1405
+ const consoleError = recentConsoleErrorHint(failure.domCapture);
1406
+ const ariaHint = ariaSnapshotHint(failure.domCapture);
650
1407
  if (locationLine)
651
1408
  lines.push(locationLine);
652
- if (failure.codeContext?.focusLine)
653
- lines.push(`Failing code: ${failure.codeContext.focusLine.trim()}`);
654
1409
  if (failure.codeContext?.action)
655
1410
  lines.push(`Failing step: ${failure.codeContext.action}`);
656
1411
  if (failure.locator)
657
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}`);
658
1419
  if (failure.signal === "timeout" && failure.domCapture) {
659
1420
  if (failure.domCapture.targetFound === false || failure.domCapture.matchedCount === 0) {
660
1421
  lines.push(`Target state: locator never appeared`);
@@ -669,34 +1430,117 @@ const buildEvidenceLines = (failure) => {
669
1430
  lines.push(`Target state: found and visible before timeout`);
670
1431
  }
671
1432
  }
1433
+ if (!pageError && !consoleError && ariaHint && (failure.signal === "assertion_mismatch" || failure.signal === "locator_not_found")) {
1434
+ lines.push(`ARIA: ${ariaHint}`);
1435
+ }
672
1436
  if (failure.apiHint)
673
1437
  lines.push(`API: ${failure.apiHint}`);
674
1438
  return lines.slice(0, 4);
675
1439
  };
676
1440
  const withoutPrefix = (value, prefix) => value.startsWith(prefix) ? value.slice(prefix.length).trim() : value;
1441
+ const truncateValue = (value, max = 96) => {
1442
+ if (!value)
1443
+ return null;
1444
+ const compact = value.replace(/\s+/g, " ").trim();
1445
+ if (compact.length <= max)
1446
+ return compact;
1447
+ return `${compact.slice(0, max - 1)}…`;
1448
+ };
1449
+ const compactLocator = (value) => {
1450
+ const compact = truncateValue(value, 40);
1451
+ return compact || "target element";
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
+ };
677
1472
  const buildSecondaryEvidenceLines = (failure) => buildEvidenceLines(failure)
678
1473
  .filter((line) => !line.startsWith("Error location:") && !line.startsWith("Likely file:"))
679
1474
  .slice(0, 3);
680
1475
  const compactRootCauseSummary = (cluster) => {
681
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
+ }
682
1482
  if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
683
1483
  return "Same test-side throw before the app flow completed";
684
1484
  }
685
1485
  if (failure.signal === "network") {
1486
+ if (failedRequest) {
1487
+ return `${formatRequestLabel(failedRequest)} broke these tests`;
1488
+ }
686
1489
  return failure.apiHint
687
1490
  ? `Same network/API failure around ${failure.apiHint}`
688
1491
  : "Same network/API failure across these tests";
689
1492
  }
690
1493
  if (failure.signal === "timeout") {
691
- 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)}`;
692
1509
  }
693
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
+ }
694
1527
  return "Same UI assertion mismatch across these tests";
695
1528
  }
696
1529
  if (failure.signal === "locator_not_found") {
697
1530
  return "Same missing or changed locator across these tests";
698
1531
  }
699
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
+ }
700
1544
  return "Same element actionability problem across these tests";
701
1545
  }
702
1546
  if (failure.signal === "infra") {
@@ -704,13 +1548,359 @@ const compactRootCauseSummary = (cluster) => {
704
1548
  }
705
1549
  return describeCluster(cluster);
706
1550
  };
1551
+ const compactIssueTitle = (cluster) => {
1552
+ const failure = cluster.sample;
1553
+ const locator = compactLocator(failure.locator);
1554
+ const blockedVerdict = blockedVerdictForCluster(cluster);
1555
+ const navigation = navigationVerdictForFailure(failure);
1556
+ const multiSelectorBlocked = isMultiSelectorBlockedCluster(cluster);
1557
+ switch (failure.signal) {
1558
+ case "setup":
1559
+ return "Playwright setup error";
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
+ }
1567
+ return failure.locator ? `Assertion mismatch (${locator})` : "Assertion mismatch";
1568
+ case "locator_not_found":
1569
+ return failure.locator ? `Missing locator (${locator})` : "Missing locator";
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
+ }
1596
+ return failure.locator ? `Blocked interaction (${locator})` : "Blocked interaction";
1597
+ case "network":
1598
+ return failure.apiHint ? `Backend/API failure (${truncateValue(failure.apiHint, 28)})` : "Backend/API failure";
1599
+ case "timeout":
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)}`;
1624
+ case "runtime":
1625
+ return /retry|flaky/i.test(failure.message) ? "Test-side throw" : "Runtime error";
1626
+ case "infra":
1627
+ return "Browser/CI instability";
1628
+ default:
1629
+ return rootCauseLabel(failure);
1630
+ }
1631
+ };
1632
+ const clusterCauseLine = (cluster) => {
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
+ }
1640
+ if (failure.signal === "assertion_mismatch") {
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}`
1659
+ : "Same assertion mismatch across these tests";
1660
+ }
1661
+ if (failure.signal === "locator_not_found") {
1662
+ return failure.locator
1663
+ ? `${compactLocator(failure.locator)} is missing or changed in each failure`
1664
+ : "Same missing or changed locator across these tests";
1665
+ }
1666
+ if (failure.signal === "actionability") {
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)}`;
1743
+ }
1744
+ return compactRootCauseSummary(cluster);
1745
+ };
1746
+ const strongerClusterNext = (cluster) => {
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
+ }
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);
1765
+ if (failure.locator && failure.apiHint) {
1766
+ return `check ${compactLocator(failure.locator)} or the data returned by ${truncateValue(failure.apiHint, 36)}`;
1767
+ }
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`;
1794
+ }
1795
+ }
1796
+ if (failure.signal === "locator_not_found" && failure.locator) {
1797
+ return `check whether ${compactLocator(failure.locator)} changed or no longer renders`;
1798
+ }
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
+ }
1833
+ return `check what blocks ${compactLocator(failure.locator)} from becoming actionable`;
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
+ }
1879
+ return clusterCheckFirst(cluster);
1880
+ };
707
1881
  const compactErrorLine = (failure) => {
708
1882
  if (!failure.firstErrorLine)
709
1883
  return null;
710
1884
  return withoutPrefix(failure.firstErrorLine, "Error:");
711
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
+ };
712
1900
  const parseFailureFacts = (title, titlePath, message, status, file = null, options) => {
713
- const signal = classifySignal(message);
1901
+ let signal = classifySignal(message);
1902
+ const assertionLine = extractAssertionLineFromMessage(message);
1903
+ const assertionKind = extractAssertionKind(assertionLine);
714
1904
  const fallbackLocation = options?.errorLocation || extractStackLocation(message);
715
1905
  const fallbackCodeContext = fallbackLocation || options?.errorSnippet
716
1906
  ? {
@@ -722,9 +1912,9 @@ const parseFailureFacts = (title, titlePath, message, status, file = null, optio
722
1912
  expectedText: null,
723
1913
  timeoutMs: null,
724
1914
  apiCall: null,
725
- assertion: null,
1915
+ assertion: assertionLine,
726
1916
  methodName: null,
727
- focusLine: extractFocusLineFromSnippet(options?.errorSnippet),
1917
+ focusLine: assertionLine || extractFocusLineFromSnippet(options?.errorSnippet),
728
1918
  previousActionLine: null,
729
1919
  found: Boolean(fallbackLocation?.file || options?.errorSnippet)
730
1920
  }
@@ -736,17 +1926,98 @@ const parseFailureFacts = (title, titlePath, message, status, file = null, optio
736
1926
  file: options.codeContext.file || fallbackCodeContext?.file || null,
737
1927
  line: options.codeContext.line ?? fallbackCodeContext?.line ?? null,
738
1928
  column: options.codeContext.column ?? fallbackCodeContext?.column ?? null,
739
- focusLine: options.codeContext.focusLine || fallbackCodeContext?.focusLine || 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,
740
1940
  found: options.codeContext.found ?? fallbackCodeContext?.found ?? false
741
1941
  }
742
- : fallbackCodeContext || null;
1942
+ : fallbackCodeContext
1943
+ ? {
1944
+ ...fallbackCodeContext,
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
1953
+ }
1954
+ : {
1955
+ file: fallbackLocation?.file || file || null,
1956
+ line: typeof fallbackLocation?.line === "number" ? fallbackLocation.line : null,
1957
+ column: typeof fallbackLocation?.column === "number" ? fallbackLocation.column : null,
1958
+ action: null,
1959
+ locator: null,
1960
+ expectedText: null,
1961
+ timeoutMs: null,
1962
+ apiCall: null,
1963
+ assertion: assertionLine,
1964
+ methodName: null,
1965
+ focusLine: signal === "assertion_mismatch"
1966
+ ? assertionLine || extractFocusLineFromMessage(message)
1967
+ : extractFocusLineFromMessage(message),
1968
+ previousActionLine: null,
1969
+ found: Boolean(fallbackLocation?.file || assertionLine || extractFocusLineFromMessage(message))
1970
+ };
743
1971
  const domCapture = options?.domCapture || null;
744
- const locator = extractLocator(message) || codeContext?.locator || domCapture?.locator || null;
745
- const expected = extractExpected(message) || codeContext?.expectedText || domCapture?.expectedText || null;
746
- 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
+ }
747
2002
  const likelyFile = inferLikelyFile(file, codeContext);
748
2003
  const likelyModule = inferLikelyModule(file, locator, codeContext);
749
- 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
+ }
750
2021
  return {
751
2022
  title,
752
2023
  titlePath,
@@ -798,27 +2069,69 @@ const buildDebugSummary = (failure) => {
798
2069
  exports.buildDebugSummary = buildDebugSummary;
799
2070
  const buildSimilarityKey = (failure) => {
800
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);
801
2075
  if (failure.signal === 'runtime' || failure.signal === 'unknown') {
802
2076
  return [
803
2077
  failure.signal,
804
- normalizeMessageFingerprint(failure.message),
805
- locationKey
2078
+ pageErrorKey || normalizeMessageFingerprint(failure.message),
2079
+ canonicalLocator || locationKey
806
2080
  ].join('|');
807
2081
  }
2082
+ if (failure.signal === "assertion_mismatch") {
2083
+ const assertionKind = extractAssertionKind(failure.codeContext?.assertion || failure.codeContext?.focusLine || null);
2084
+ return [
2085
+ failure.signal,
2086
+ canonicalLocator || failure.likelyModule || basename(failure.likelyFile) || "unknown-target",
2087
+ assertionKind,
2088
+ failure.expected || "unknown-expected",
2089
+ failure.received || pageErrorKey || "unknown-received"
2090
+ ].join("|");
2091
+ }
2092
+ if (failure.signal === "setup") {
2093
+ return [failure.signal, normalizeMessageFingerprint(failure.message)].join("|");
2094
+ }
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
+ }
2105
+ return [
2106
+ failure.signal,
2107
+ canonicalLocator || failure.codeContext?.action || failure.likelyModule || "unknown-target",
2108
+ failure.codeContext?.action || "unknown-action",
2109
+ pageErrorKey || locationKey
2110
+ ].join("|");
2111
+ }
2112
+ if (failure.signal === "network") {
2113
+ return [
2114
+ failure.signal,
2115
+ requestKey || normalizedUrlPath(failure.apiHint) || failure.likelyModule || "unknown-api",
2116
+ pageErrorKey || basename(failure.likelyFile) || "unknown-file"
2117
+ ].join("|");
2118
+ }
808
2119
  if (failure.locator || failure.expected || failure.received || failure.apiHint) {
809
2120
  return [
810
2121
  failure.signal,
811
- failure.locator || failure.apiHint || "unknown-target",
2122
+ canonicalLocator || normalizedUrlPath(failure.apiHint) || "unknown-target",
812
2123
  failure.expected || "unknown-expected",
813
- failure.received || "unknown-received",
814
- locationKey
2124
+ failure.received || requestKey || pageErrorKey || "unknown-received",
2125
+ pageErrorKey || locationKey
815
2126
  ].join("|");
816
2127
  }
817
- return `${failure.signal}|${normalizeMessageFingerprint(failure.message)}|${locationKey}`;
2128
+ return `${failure.signal}|${pageErrorKey || normalizeMessageFingerprint(failure.message)}|${canonicalLocator || locationKey}`;
818
2129
  };
819
2130
  exports.buildSimilarityKey = buildSimilarityKey;
820
2131
  const summarizeSignal = (signal) => {
821
2132
  switch (signal) {
2133
+ case "setup":
2134
+ return "Playwright setup or bootstrap error";
822
2135
  case "timeout":
823
2136
  return "timeout while waiting for UI or network conditions";
824
2137
  case "assertion_mismatch":
@@ -851,7 +2164,212 @@ const buildClusterSuspects = (clusterFailures, window) => {
851
2164
  .sort((a, b) => b.score - a.score)
852
2165
  .slice(0, 2);
853
2166
  };
854
- 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) => {
855
2373
  const failures = (0, exports.collectFailureFacts)(playwrightJsonPath);
856
2374
  if (!failures.length)
857
2375
  return null;
@@ -860,25 +2378,13 @@ const buildQuickDiagnosis = (playwrightJsonPath) => {
860
2378
  const failed = failures[0];
861
2379
  const suspects = commitWindow.trusted ? rankCommitsForFailure(failed, commitWindow) : [];
862
2380
  const top = suspects[0];
863
- const lines = [`Test: ${shortenTitle(failed.title)}`, `Likely cause: ${(0, exports.describeFailure)(failed)}`];
864
- const primaryLocation = buildLocationLine(failed);
865
- const confidence = top ? confidenceLabel(top.score).toLowerCase() : "medium";
866
- lines.push(`Confidence: ${confidence}`);
867
- if (top && top.score >= 0.62) {
868
- lines.push(`Likely introduced in: "${top.commit.message}"`);
869
- if (top.reasons.length) {
870
- lines.push(`Reason: ${compactWhyLine(top)}.`);
871
- }
872
- }
873
- lines.push("Check first:");
874
- lines.push(`- ${checkFirst(failed)}`);
875
- if (primaryLocation)
876
- lines.push(`Where: ${withoutPrefix(withoutPrefix(primaryLocation, "Error location:"), "Likely file:")}`);
877
- if (failed.codeContext?.action)
878
- lines.push(`Failing step: ${failed.codeContext.action}`);
879
2381
  return {
880
- lines,
881
- 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()}`] : []
882
2388
  };
883
2389
  }
884
2390
  const clusterMap = new Map();
@@ -894,40 +2400,32 @@ const buildQuickDiagnosis = (playwrightJsonPath) => {
894
2400
  count: clusterFailures.length,
895
2401
  sample: clusterFailures[0],
896
2402
  titles: clusterFailures.map((item) => item.title),
897
- suspects: commitWindow.trusted ? buildClusterSuspects(clusterFailures, commitWindow) : []
2403
+ suspects: commitWindow.trusted ? buildClusterSuspects(clusterFailures, commitWindow) : [],
2404
+ failures: clusterFailures
898
2405
  }))
899
- .sort((a, b) => b.count - a.count)
900
- .slice(0, 2);
2406
+ .sort((a, b) => b.count - a.count ||
2407
+ ((b.suspects[0]?.score || 0) - (a.suspects[0]?.score || 0)) ||
2408
+ (b.sample.signal === "assertion_mismatch" ? 1 : 0) - (a.sample.signal === "assertion_mismatch" ? 1 : 0));
901
2409
  const topCluster = clusters[0];
902
- const lines = [
903
- `${failures.length} tests failed.`,
904
- `Collapsed into ${clusters.length} likely root cause${clusters.length === 1 ? "" : "s"}:`
905
- ];
906
- for (const [index, cluster] of clusters.entries()) {
907
- const clusterFailures = clusterMap.get(cluster.key) || [cluster.sample];
908
- const clusterLocation = buildClusterLocationLine(clusterFailures);
909
- const top = cluster.suspects[0];
910
- const locationValue = clusterLocation
911
- ? withoutPrefix(clusterLocation, clusterLocation.startsWith("Error locations:") ? "Error locations:" : "Error location:")
912
- : null;
913
- const rootCause = cluster.count === 1 ? (0, exports.describeFailure)(cluster.sample) : compactRootCauseSummary(cluster);
914
- lines.push(`[${index + 1}] ${rootCauseLabel(cluster.sample)} (${cluster.count} tests)`);
915
- lines.push(` Likely cause: ${rootCause}`);
916
- if (top && top.score >= 0.62) {
917
- lines.push(` Likely introduced in: "${top.commit.message}"`);
918
- if (top.reasons.length) {
919
- lines.push(` Reason: ${compactWhyLine(top)}.`);
920
- }
921
- }
922
- lines.push(` Check first: ${clusterCheckFirst(cluster)}.`);
923
- if (locationValue) {
924
- lines.push(` Where: ${locationValue}`);
925
- }
926
- lines.push(` Shared impact: ${cluster.count} test${cluster.count === 1 ? "" : "s"} failing with ${cluster.count === 1 ? "this" : "same"} root cause`);
927
- }
2410
+ const visibleClusters = clusters.slice(0, 3);
928
2411
  return {
929
- 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)),
930
2423
  footer: topCluster?.suspects[0] ? [`Confidence: ${confidenceLabel(topCluster.suspects[0].score).toLowerCase()}`] : []
931
2424
  };
932
2425
  };
2426
+ exports.buildQuickDiagnosisStructured = buildQuickDiagnosisStructured;
2427
+ const buildQuickDiagnosis = (playwrightJsonPath) => {
2428
+ const summary = (0, exports.buildQuickDiagnosisStructured)(playwrightJsonPath);
2429
+ return summary ? renderDiagnosisSummary(summary) : null;
2430
+ };
933
2431
  exports.buildQuickDiagnosis = buildQuickDiagnosis;