@piwitests/reporter 0.26.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -43,7 +43,7 @@ __export(index_exports, {
43
43
  module.exports = __toCommonJS(index_exports);
44
44
 
45
45
  // src/public/reporter.ts
46
- var path14 = __toESM(require("path"));
46
+ var path15 = __toESM(require("path"));
47
47
 
48
48
  // src/internal/config/desktop.ts
49
49
  var fs = __toESM(require("fs"));
@@ -77,6 +77,8 @@ var DEFAULTS = {
77
77
  captureLocators: true,
78
78
  capturePageState: true,
79
79
  captureServerTraces: true,
80
+ sampleAriaOnPass: true,
81
+ defaultCapture: true,
80
82
  streaming: true,
81
83
  streamingBatchSize: 5,
82
84
  streamingBatchDelay: 2e3,
@@ -106,6 +108,8 @@ var PIWI_ENV_KEYS = {
106
108
  captureLocators: "PIWI_CAPTURE_LOCATORS",
107
109
  capturePageState: "PIWI_CAPTURE_PAGE_STATE",
108
110
  captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
111
+ sampleAriaOnPass: "PIWI_SAMPLE_ARIA_ON_PASS",
112
+ defaultCapture: "PIWI_DEFAULT_CAPTURE",
109
113
  inspectOnFailure: "PIWI_INSPECT_ON_FAIL",
110
114
  pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL",
111
115
  outputFile: "PIWI_OUTPUT_FILE",
@@ -119,6 +123,7 @@ var PIWI_ENV_KEYS = {
119
123
  aiScreenshotFallback: "PIWI_AI_SCREENSHOT_FALLBACK"
120
124
  };
121
125
  var PIWI_DESKTOP_CONFIG_ENV = "PIWI_DESKTOP_CONFIG";
126
+ var PIWI_DEFAULTED_CAPTURE_ENV = "PIWI_DEFAULTED_CAPTURE";
122
127
  var PIWI_SELECTION_ENV = {
123
128
  key: "PIWI_SELECTION",
124
129
  version: "PIWI_SELECTION_VERSION",
@@ -148,6 +153,8 @@ var ENV_FALLBACK_SPECS = [
148
153
  { option: "captureLocators", env: PIWI_ENV_KEYS.captureLocators, kind: "bool" },
149
154
  { option: "capturePageState", env: PIWI_ENV_KEYS.capturePageState, kind: "bool" },
150
155
  { option: "captureServerTraces", env: PIWI_ENV_KEYS.captureServerTraces, kind: "bool" },
156
+ { option: "sampleAriaOnPass", env: PIWI_ENV_KEYS.sampleAriaOnPass, kind: "bool" },
157
+ { option: "defaultCapture", env: PIWI_ENV_KEYS.defaultCapture, kind: "bool" },
151
158
  { option: "inspectOnFailure", env: PIWI_ENV_KEYS.inspectOnFailure, kind: "bool" },
152
159
  { option: "pickLocatorOnFailure", env: PIWI_ENV_KEYS.pickLocatorOnFailure, kind: "bool" },
153
160
  { option: "outputFile", env: PIWI_ENV_KEYS.outputFile, kind: "string" }
@@ -203,6 +210,8 @@ function applyOptionsToEnv(options) {
203
210
  if (options.captureServerTraces === false || options.collectPerformanceMetrics === false)
204
211
  env[PIWI_ENV_KEYS.captureServerTraces] = "false";
205
212
  else if (options.captureServerTraces === true) env[PIWI_ENV_KEYS.captureServerTraces] = "true";
213
+ if (options.sampleAriaOnPass === false) env[PIWI_ENV_KEYS.sampleAriaOnPass] = "false";
214
+ else if (options.sampleAriaOnPass === true) env[PIWI_ENV_KEYS.sampleAriaOnPass] = "true";
206
215
  if (options.inspectOnFailure !== void 0) env[PIWI_ENV_KEYS.inspectOnFailure] = String(options.inspectOnFailure);
207
216
  if (options.pickLocatorOnFailure !== void 0)
208
217
  env[PIWI_ENV_KEYS.pickLocatorOnFailure] = String(options.pickLocatorOnFailure);
@@ -342,6 +351,29 @@ var HttpClient = class {
342
351
  this.logger.debug("Logged in successfully");
343
352
  return cookie;
344
353
  }
354
+ /**
355
+ * Send a JSON GET request, returning the parsed body, or `null` on any non-2xx
356
+ * status or parse failure. Unlike `postJSON` this never throws — its callers
357
+ * treat a missing or unreachable endpoint as "feature unavailable".
358
+ */
359
+ async getJSON(pathname, auth) {
360
+ let res;
361
+ try {
362
+ res = await this.request("GET", pathname, { auth });
363
+ } catch (error) {
364
+ this.logger.debug(`GET ${pathname} failed: ${error.message}`);
365
+ return null;
366
+ }
367
+ if (res.status < 200 || res.status >= 300) {
368
+ this.logger.debug(`GET ${pathname} returned ${res.status}`);
369
+ return null;
370
+ }
371
+ try {
372
+ return JSON.parse(res.text);
373
+ } catch {
374
+ return null;
375
+ }
376
+ }
345
377
  /** Send a JSON POST request. `auth` can be an API key (prefix `pd_`) or a session cookie string. */
346
378
  async postJSON(pathname, payload, auth) {
347
379
  const body = JSON.stringify(payload);
@@ -392,7 +424,7 @@ var HttpClient = class {
392
424
  {
393
425
  hostname: url.hostname,
394
426
  port: url.port || (url.protocol === "https:" ? 443 : 80),
395
- path: url.pathname,
427
+ path: url.pathname + url.search,
396
428
  method,
397
429
  headers
398
430
  },
@@ -472,7 +504,9 @@ function toWireTestCase(tc) {
472
504
  pageState: rest.pageState || null,
473
505
  aiUsage: rest.aiUsage || null,
474
506
  consoleLogs: rest.consoleLogs || null,
507
+ dialogs: rest.dialogs || null,
475
508
  ariaSnapshot: rest.ariaSnapshot || null,
509
+ ariaSnapshotJson: rest.ariaSnapshotJson || null,
476
510
  testSource: rest.testSource || null,
477
511
  testSourceFrames: rest.testSourceFrames || null,
478
512
  browser: rest.browser || null,
@@ -480,6 +514,7 @@ function toWireTestCase(tc) {
480
514
  suiteConfig: rest.suiteConfig ?? null,
481
515
  testAnnotations: rest.testAnnotations ?? null,
482
516
  tags: rest.tags ?? null,
517
+ locks: rest.locks ?? null,
483
518
  testMeta: rest.testMeta ?? null,
484
519
  locatorSnapshots: rest.locatorSnapshots || null,
485
520
  didNotRunReason: rest.didNotRunReason ?? null,
@@ -831,6 +866,7 @@ var CrashRecovery = class {
831
866
 
832
867
  // src/internal/files/file-handler.ts
833
868
  var fs6 = __toESM(require("fs"));
869
+ var os5 = __toESM(require("os"));
834
870
  var path6 = __toESM(require("path"));
835
871
  var crypto2 = __toESM(require("crypto"));
836
872
 
@@ -867,7 +903,9 @@ async function compressDirectory(sourceDir) {
867
903
  var ATTACHMENT_NAMES = {
868
904
  locators: "piwi-locators",
869
905
  ariaSnapshot: "piwi-aria-snapshot",
906
+ ariaSnapshotJson: "piwi-aria-snapshot-json",
870
907
  console: "piwi-console",
908
+ dialogs: "piwi-dialogs",
871
909
  network: "piwi-network",
872
910
  webVitals: "piwi-web-vitals",
873
911
  locatorSuggestion: "piwi-locator-suggestion",
@@ -881,9 +919,30 @@ var LOCATOR_SUGGESTION_ANNOTATION = ATTACHMENT_NAMES.locatorSuggestion;
881
919
  var USER_PICK_ANNOTATION = ATTACHMENT_NAMES.userPick;
882
920
 
883
921
  // src/internal/files/file-handler.ts
922
+ var MAX_ATTACHMENT_BYTES = 500 * 1024 * 1024;
923
+ var BODY_EXTENSIONS = {
924
+ "application/json": ".json",
925
+ "application/pdf": ".pdf",
926
+ "application/zip": ".zip",
927
+ "image/jpeg": ".jpg",
928
+ "image/png": ".png",
929
+ "image/svg+xml": ".svg",
930
+ "image/webp": ".webp",
931
+ "text/csv": ".csv",
932
+ "text/html": ".html",
933
+ "text/markdown": ".md",
934
+ "text/plain": ".txt"
935
+ };
884
936
  var FileHandler = class {
885
- constructor(logger = new Logger()) {
937
+ constructor(logger = new Logger(), maxAttachmentBytes = MAX_ATTACHMENT_BYTES) {
886
938
  this.logger = logger;
939
+ this.maxAttachmentBytes = maxAttachmentBytes;
940
+ /** Temp files written for body-only attachments, keyed by attachment so repeated lookups reuse one file. */
941
+ this.bodyFiles = /* @__PURE__ */ new WeakMap();
942
+ this.bodyDir = null;
943
+ this.bodyFileCount = 0;
944
+ /** Attachments already reported as oversized, so each one warns once per run. */
945
+ this.oversizedWarned = /* @__PURE__ */ new Set();
887
946
  }
888
947
  /** Locate a Playwright HTML report directory containing `index.html`. Optionally override the search path. */
889
948
  findHTMLReportDirectory(customDir) {
@@ -922,25 +981,83 @@ var FileHandler = class {
922
981
  }
923
982
  return Array.from(set);
924
983
  }
925
- /** Return all non-trace, non-internal attachments from a test case. Skips `trace` and `piwi-*` attachments. */
984
+ /**
985
+ * Return all non-trace, non-internal attachments from a test case as files
986
+ * on disk. Skips `trace` and `piwi-*` attachments. A body-only attachment
987
+ * (`testInfo.attach(name, { body })`) is written to a temp file under
988
+ * `os.tmpdir()` once and reused by later calls; anything above
989
+ * `maxAttachmentBytes` is skipped with a single warning.
990
+ */
926
991
  findAllAttachments(testCase) {
927
992
  const result = [];
928
993
  if (testCase.attachments) {
929
994
  for (const a of testCase.attachments) {
930
995
  if (a.name === "trace") continue;
931
996
  if (a.name && INTERNAL_ATTACHMENT_NAMES.has(a.name)) continue;
932
- if (a.path && fs6.existsSync(a.path)) {
933
- result.push({
934
- name: a.name || "attachment",
935
- path: path6.resolve(a.path),
936
- contentType: a.contentType || "application/octet-stream",
937
- originalName: path6.basename(a.path)
938
- });
939
- }
997
+ const resolved = a.path ? this.resolvePathAttachment(a, testCase) : this.resolveBodyAttachment(a, testCase);
998
+ if (resolved) result.push(resolved);
940
999
  }
941
1000
  }
942
1001
  return result;
943
1002
  }
1003
+ /** Delete the temp files written for body-only attachments. Call once the run's uploads are done. */
1004
+ cleanupBodyAttachments() {
1005
+ if (!this.bodyDir) return;
1006
+ try {
1007
+ fs6.rmSync(this.bodyDir, { recursive: true, force: true });
1008
+ } catch (error) {
1009
+ this.logger.debug(`Could not remove temp attachments at ${this.bodyDir}: ${errorMessage(error)}`);
1010
+ }
1011
+ this.bodyDir = null;
1012
+ }
1013
+ resolvePathAttachment(a, testCase) {
1014
+ if (!a.path || !fs6.existsSync(a.path)) return null;
1015
+ const size = fs6.statSync(a.path).size;
1016
+ if (size > this.maxAttachmentBytes) {
1017
+ this.warnOversized(a, testCase, size);
1018
+ return null;
1019
+ }
1020
+ return {
1021
+ name: a.name || "attachment",
1022
+ path: path6.resolve(a.path),
1023
+ contentType: a.contentType || "application/octet-stream",
1024
+ originalName: path6.basename(a.path)
1025
+ };
1026
+ }
1027
+ resolveBodyAttachment(a, testCase) {
1028
+ if (!a.body) return null;
1029
+ const body = Buffer.isBuffer(a.body) ? a.body : Buffer.from(a.body);
1030
+ if (body.length > this.maxAttachmentBytes) {
1031
+ this.warnOversized(a, testCase, body.length);
1032
+ return null;
1033
+ }
1034
+ const contentType = a.contentType || "application/octet-stream";
1035
+ const originalName = `${safeFileName(a.name || "attachment")}${BODY_EXTENSIONS[contentType] ?? ""}`;
1036
+ let filePath = this.bodyFiles.get(a);
1037
+ if (!filePath) {
1038
+ try {
1039
+ filePath = path6.join(this.bodyAttachmentDir(), `${++this.bodyFileCount}-${originalName}`);
1040
+ fs6.writeFileSync(filePath, body);
1041
+ this.bodyFiles.set(a, filePath);
1042
+ } catch (error) {
1043
+ this.logger.warn(`Could not stage attachment "${a.name}" for upload: ${errorMessage(error)}`);
1044
+ return null;
1045
+ }
1046
+ }
1047
+ return { name: a.name || "attachment", path: filePath, contentType, originalName };
1048
+ }
1049
+ bodyAttachmentDir() {
1050
+ if (!this.bodyDir) this.bodyDir = fs6.mkdtempSync(path6.join(os5.tmpdir(), "piwi-dashboard-attachments-"));
1051
+ return this.bodyDir;
1052
+ }
1053
+ warnOversized(a, testCase, size) {
1054
+ const key = `${testCase.location}\0${a.name}`;
1055
+ if (this.oversizedWarned.has(key)) return;
1056
+ this.oversizedWarned.add(key);
1057
+ this.logger.warn(
1058
+ `Skipping attachment "${a.name}" on "${testCase.title}": ${formatMiB(size)} exceeds the ${formatMiB(this.maxAttachmentBytes)} upload limit.`
1059
+ );
1060
+ }
944
1061
  /** Mapping of well-known report type names to their default output directories */
945
1062
  getDefaultReportDirs() {
946
1063
  return {
@@ -950,7 +1067,7 @@ var FileHandler = class {
950
1067
  blob: "blob-report"
951
1068
  };
952
1069
  }
953
- /** Parse Piwi-internal attachment bodies (`piwi-network`, `piwi-web-vitals`, `piwi-console`, `piwi-aria-snapshot`) into structured fields on the test case */
1070
+ /** Parse Piwi-internal attachment bodies (`piwi-network`, `piwi-web-vitals`, `piwi-console`, `piwi-aria-snapshot`, `piwi-aria-snapshot-json`) into structured fields on the test case */
954
1071
  parsePerformanceAttachments(testCase, attachments) {
955
1072
  const find = (name) => attachments.find((a) => a.name === name);
956
1073
  const net = find(ATTACHMENT_NAMES.network);
@@ -974,8 +1091,17 @@ var FileHandler = class {
974
1091
  } catch {
975
1092
  }
976
1093
  }
1094
+ const dialogs = find(ATTACHMENT_NAMES.dialogs);
1095
+ if (dialogs?.body) {
1096
+ try {
1097
+ testCase.dialogs = JSON.parse(dialogs.body.toString());
1098
+ } catch {
1099
+ }
1100
+ }
977
1101
  const aria = find(ATTACHMENT_NAMES.ariaSnapshot);
978
1102
  if (aria?.body) testCase.ariaSnapshot = aria.body.toString();
1103
+ const ariaJson = find(ATTACHMENT_NAMES.ariaSnapshotJson);
1104
+ if (ariaJson?.body) testCase.ariaSnapshotJson = ariaJson.body.toString();
979
1105
  const pageState = find(ATTACHMENT_NAMES.pageState);
980
1106
  if (pageState?.body) {
981
1107
  try {
@@ -1022,6 +1148,12 @@ var FileHandler = class {
1022
1148
  }
1023
1149
  }
1024
1150
  };
1151
+ function safeFileName(name) {
1152
+ return name.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "attachment";
1153
+ }
1154
+ function formatMiB(bytes) {
1155
+ return `${Math.round(bytes / (1024 * 1024) * 10) / 10} MiB`;
1156
+ }
1025
1157
 
1026
1158
  // src/internal/collect/metadata-collector.ts
1027
1159
  var import_node_child_process = require("child_process");
@@ -1122,6 +1254,7 @@ var MetadataCollector = class {
1122
1254
  if (use.colorScheme) config.colorScheme = use.colorScheme;
1123
1255
  if (use.reducedMotion) config.reducedMotion = use.reducedMotion;
1124
1256
  if (use.forcedColors) config.forcedColors = use.forcedColors;
1257
+ if (use.contrast) config.contrast = use.contrast;
1125
1258
  if (use.offline) config.offline = use.offline;
1126
1259
  if (use.bypassCSP) config.bypassCSP = use.bypassCSP;
1127
1260
  if (use.javaScriptEnabled === false) config.javaScriptEnabled = false;
@@ -1288,10 +1421,10 @@ function createLimiter(maxConcurrent) {
1288
1421
 
1289
1422
  // src/internal/support/setup-file.ts
1290
1423
  var path7 = __toESM(require("path"));
1291
- var os5 = __toESM(require("os"));
1424
+ var os6 = __toESM(require("os"));
1292
1425
  var fs7 = __toESM(require("fs"));
1293
1426
  function getSetupFilePath(projectName) {
1294
- return path7.join(os5.tmpdir(), `piwi-dashboard-setup-${hashForProject(projectName)}.json`);
1427
+ return path7.join(os6.tmpdir(), `piwi-dashboard-setup-${hashForProject(projectName)}.json`);
1295
1428
  }
1296
1429
  function readSetupInfo(projectName) {
1297
1430
  const setupFile = getSetupFilePath(projectName);
@@ -1481,7 +1614,9 @@ var StreamManager = class {
1481
1614
  `Live streaming could not start: the dashboard at ${this.options.serverUrl} requires authentication. Set the reporter's \`apiKey\` option (or PIWI_API_KEY) \u2014 create a key under Settings \u2192 Users on the dashboard. Falling back to batch upload at the end of the run.`
1482
1615
  );
1483
1616
  } else {
1484
- this.logger.debug(`Streaming not available: ${errorMessage(error)}. Will use batch mode.`);
1617
+ this.logger.warn(
1618
+ `Live streaming is unavailable (${errorMessage(error)}) \u2014 results will be submitted in one batch when the run finishes.`
1619
+ );
1485
1620
  }
1486
1621
  this._enabled = false;
1487
1622
  }
@@ -1519,18 +1654,21 @@ var StreamManager = class {
1519
1654
  this.lastActivityAt = Date.now();
1520
1655
  return true;
1521
1656
  },
1522
- () => {
1657
+ (error) => {
1523
1658
  this.pendingEvents = events.concat(this.pendingEvents);
1524
- this.scheduleRetry();
1659
+ this.scheduleRetry(errorMessage(error));
1525
1660
  return false;
1526
1661
  }
1527
1662
  );
1528
1663
  this.flushPromises.push(promise);
1529
1664
  return promise;
1530
1665
  }
1531
- scheduleRetry() {
1666
+ scheduleRetry(reason) {
1532
1667
  if (this.retryTimer) return;
1533
1668
  this.retryCount++;
1669
+ if (this.retryCount === 1) {
1670
+ this.logger.warn(`Streaming to the dashboard was interrupted (${reason}) \u2014 events are buffered and retried.`);
1671
+ }
1534
1672
  const delay = Math.min(1e3 * Math.pow(2, this.retryCount - 1), this.maxRetryDelay);
1535
1673
  this.logger.debug(`Will retry streaming flush in ${delay}ms (attempt ${this.retryCount})`);
1536
1674
  this.retryTimer = setTimeout(() => {
@@ -1581,38 +1719,58 @@ var StreamManager = class {
1581
1719
  this.heartbeatTimer = null;
1582
1720
  }
1583
1721
  }
1722
+ /** Clear a scheduled flush retry so its timer cannot fire after the run wraps up. */
1723
+ clearRetryTimer() {
1724
+ if (this.retryTimer) {
1725
+ clearTimeout(this.retryTimer);
1726
+ this.retryTimer = null;
1727
+ }
1728
+ }
1584
1729
  /** Drain all pending and buffered events before the run finishes. Retries up to 10 times with exponential back-off. */
1585
1730
  async drain() {
1586
1731
  this.stopHeartbeat();
1732
+ this.clearRetryTimer();
1587
1733
  if (!this._enabled) {
1588
1734
  this.pendingEvents = [];
1589
1735
  this.flushPromises = [];
1590
1736
  return;
1591
1737
  }
1592
- const MAX_ATTEMPTS = 10;
1593
- for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
1594
- if (this._enabled && this.pendingEvents.length > 0) this.flush();
1595
- if (this.flushPromises.length > 0) {
1596
- await Promise.allSettled(this.flushPromises);
1597
- this.flushPromises = [];
1598
- }
1599
- if (this.pendingEvents.length === 0) {
1600
- const buffered = this.streamBuffer.load();
1601
- if (buffered.length > 0) {
1602
- this.pendingEvents = buffered;
1603
- this.streamBuffer.clear();
1604
- continue;
1738
+ try {
1739
+ const MAX_ATTEMPTS = 10;
1740
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
1741
+ if (this._enabled && this.pendingEvents.length > 0) this.flush();
1742
+ if (this.flushPromises.length > 0) {
1743
+ await Promise.allSettled(this.flushPromises);
1744
+ this.flushPromises = [];
1605
1745
  }
1606
- return;
1746
+ if (this.pendingEvents.length === 0) {
1747
+ const buffered = this.streamBuffer.load();
1748
+ if (buffered.length > 0) {
1749
+ this.pendingEvents = buffered;
1750
+ this.streamBuffer.clear();
1751
+ continue;
1752
+ }
1753
+ return;
1754
+ }
1755
+ if (attempt === 0) {
1756
+ this.logger.warn(
1757
+ `The dashboard has not accepted ${this.pendingEvents.length} live event(s) yet \u2014 retrying delivery before the final submit (this can take a few minutes)...`
1758
+ );
1759
+ }
1760
+ this.logger.debugError(
1761
+ `${this.pendingEvents.length} events pending, retrying (attempt ${attempt + 1}/${MAX_ATTEMPTS})...`
1762
+ );
1763
+ await new Promise((resolve5) => setTimeout(resolve5, Math.min(1e3 * Math.pow(2, attempt), 1e4)));
1607
1764
  }
1608
- this.logger.debugError(
1609
- `${this.pendingEvents.length} events pending, retrying (attempt ${attempt + 1}/${MAX_ATTEMPTS})...`
1610
- );
1611
- await new Promise((resolve5) => setTimeout(resolve5, Math.min(1e3 * Math.pow(2, attempt), 1e4)));
1612
- }
1613
- if (this.pendingEvents.length > 0) {
1614
- this.streamBuffer.append(this.pendingEvents);
1615
- this.pendingEvents = [];
1765
+ if (this.pendingEvents.length > 0) {
1766
+ this.logger.warn(
1767
+ `Could not deliver ${this.pendingEvents.length} live event(s) to the dashboard \u2014 continuing with the end-of-run submit.`
1768
+ );
1769
+ this.streamBuffer.append(this.pendingEvents);
1770
+ this.pendingEvents = [];
1771
+ }
1772
+ } finally {
1773
+ this.clearRetryTimer();
1616
1774
  }
1617
1775
  }
1618
1776
  /** Schedule a live upload of trace and attachment files for a test case. Skips cases with no files. Concurrency is limited to 2 simultaneous uploads. */
@@ -1681,15 +1839,26 @@ var StreamManager = class {
1681
1839
  }
1682
1840
  };
1683
1841
 
1842
+ // ../core/src/mask.ts
1843
+ var DATA_URI_RE = /\bdata:[a-z0-9.+-]+\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/gi;
1844
+ var JWT_RE = /\beyJ[\w-]{10,}\.[\w-]{5,}\.[\w-]{5,}\b/g;
1845
+ var LONG_HEX_RE = /\b[0-9a-f]{32,}\b/gi;
1846
+ function maskTokenLike(text) {
1847
+ return text.replace(DATA_URI_RE, "data:[masked]").replace(JWT_RE, "[masked-token]").replace(LONG_HEX_RE, "[masked-hex]");
1848
+ }
1849
+
1684
1850
  // ../core/src/step-analysis.ts
1685
- function categorizeStep(title, pwCategory) {
1851
+ var MAX_STEP_PARAM_KEYS = 20;
1852
+ var MAX_STEP_PARAM_VALUE_CHARS = 200;
1853
+ function categorizeStep(title, pwCategory, params) {
1686
1854
  if (!title) return "other";
1687
1855
  if (pwCategory === "hook" || pwCategory === "fixture") return pwCategory;
1688
1856
  if (pwCategory === "expect") return "assertion";
1689
1857
  const lower = title.toLowerCase();
1690
1858
  if (lower.startsWith("wait for") || lower.startsWith("locator.waitfor") || lower.startsWith("page.waitfor") || lower.startsWith("frame.waitfor"))
1691
1859
  return "wait";
1692
- if (lower.startsWith("navigate to") || lower.startsWith("go back") || lower.startsWith("go forward") || lower.startsWith("reload") || lower.startsWith("page.goto") || lower.startsWith("page.reload") || lower.startsWith("page.goback") || lower.startsWith("page.goforward"))
1860
+ if (typeof params?.url === "string" && pwCategory !== "expect") return "navigation";
1861
+ if (lower.startsWith("navigate") || lower.startsWith("go back") || lower.startsWith("go forward") || lower.startsWith("reload") || lower.startsWith("page.goto") || lower.startsWith("page.reload") || lower.startsWith("page.goback") || lower.startsWith("page.goforward"))
1693
1862
  return "navigation";
1694
1863
  if (lower.startsWith("click") || lower.startsWith("double click") || lower.startsWith("check") || lower.startsWith("uncheck") || lower.startsWith("tap") || lower.startsWith("hover") || lower.startsWith("select option") || lower.startsWith("drag") || lower.startsWith("locator.click") || lower.startsWith("locator.dblclick") || lower.startsWith("locator.check") || lower.startsWith("locator.uncheck") || lower.startsWith("locator.selectoption") || lower.startsWith("locator.tap"))
1695
1864
  return "action";
@@ -1701,14 +1870,39 @@ function categorizeStep(title, pwCategory) {
1701
1870
  if (lower === "before hooks" || lower === "after hooks" || lower.startsWith("fixture:")) return "hook";
1702
1871
  return "other";
1703
1872
  }
1873
+ function normalizeStepParams(raw) {
1874
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
1875
+ const out = {};
1876
+ let count = 0;
1877
+ for (const [key, value] of Object.entries(raw)) {
1878
+ if (count >= MAX_STEP_PARAM_KEYS) break;
1879
+ if (typeof value === "number" || typeof value === "boolean") {
1880
+ out[key] = value;
1881
+ count++;
1882
+ } else if (typeof value === "string") {
1883
+ out[key] = maskTokenLike(value).slice(0, MAX_STEP_PARAM_VALUE_CHARS);
1884
+ count++;
1885
+ } else if (value != null) {
1886
+ try {
1887
+ out[key] = maskTokenLike(JSON.stringify(value)).slice(0, MAX_STEP_PARAM_VALUE_CHARS);
1888
+ count++;
1889
+ } catch {
1890
+ }
1891
+ }
1892
+ }
1893
+ return count > 0 ? out : void 0;
1894
+ }
1704
1895
  function flattenSteps(steps) {
1705
1896
  const result = [];
1706
1897
  for (const step of steps) {
1707
1898
  const flat = {
1708
1899
  title: step.title,
1709
1900
  duration: step.duration,
1710
- category: categorizeStep(step.title, step.category)
1901
+ category: categorizeStep(step.title, step.category, step.params)
1711
1902
  };
1903
+ if (typeof step.subtitle === "string" && step.subtitle.length > 0) flat.subtitle = maskTokenLike(step.subtitle);
1904
+ const params = normalizeStepParams(step.params);
1905
+ if (params) flat.params = params;
1712
1906
  if (step.error?.message) {
1713
1907
  flat.error = { message: step.error.message };
1714
1908
  flat.failed = true;
@@ -1864,7 +2058,7 @@ function readSourceSnippet(file, declLine, context, failingLine) {
1864
2058
  return null;
1865
2059
  }
1866
2060
  }
1867
- function collectSourceFrames(errorText, testFile, declLine, opts = {}) {
2061
+ function collectSourceFrames(errorText, testFile2, declLine, opts = {}) {
1868
2062
  const projectRoot = opts.projectRoot ?? process.cwd();
1869
2063
  const context = opts.context ?? 8;
1870
2064
  const maxFrames = opts.maxFrames ?? 4;
@@ -1893,9 +2087,9 @@ function collectSourceFrames(errorText, testFile, declLine, opts = {}) {
1893
2087
  if (!isNaN(line)) add(abs, line);
1894
2088
  }
1895
2089
  }
1896
- const absTest = path9.resolve(testFile);
2090
+ const absTest = path9.resolve(testFile2);
1897
2091
  if (!picked.some((p) => p.absFile === absTest)) {
1898
- add(absTest, extractFailingLine(errorText, testFile, declLine));
2092
+ add(absTest, extractFailingLine(errorText, testFile2, declLine));
1899
2093
  }
1900
2094
  const frames = [];
1901
2095
  for (const p of picked.slice(0, maxFrames)) {
@@ -1906,9 +2100,9 @@ function collectSourceFrames(errorText, testFile, declLine, opts = {}) {
1906
2100
  }
1907
2101
  return frames;
1908
2102
  }
1909
- function extractFailingLine(errorText, testFile, declarationLine) {
2103
+ function extractFailingLine(errorText, testFile2, declarationLine) {
1910
2104
  if (!errorText) return declarationLine;
1911
- const expectedFile = path9.resolve(testFile);
2105
+ const expectedFile = path9.resolve(testFile2);
1912
2106
  const stackRe = /^\s+at (?:[^(]*\()?(.+?):(\d+):\d+\)?\s*$/gm;
1913
2107
  let m;
1914
2108
  while ((m = stackRe.exec(errorText)) !== null) {
@@ -1997,8 +2191,57 @@ function readSelectionStamp(env = process.env) {
1997
2191
  }
1998
2192
 
1999
2193
  // src/public/global-setup.ts
2194
+ var path11 = __toESM(require("path"));
2195
+ var fs12 = __toESM(require("fs"));
2196
+
2197
+ // src/internal/support/aria-sampling.ts
2000
2198
  var path10 = __toESM(require("path"));
2199
+ var os7 = __toESM(require("os"));
2001
2200
  var fs11 = __toESM(require("fs"));
2201
+ function ariaSampleIdentity(filePath, title) {
2202
+ return `${filePath}\0${title}`;
2203
+ }
2204
+ function getAriaSampleFilePath(projectName) {
2205
+ return path10.join(os7.tmpdir(), `piwi-dashboard-aria-sample-${hashForProject(projectName)}.json`);
2206
+ }
2207
+ function writeAriaSampleFile(projectName, identities) {
2208
+ try {
2209
+ fs11.writeFileSync(getAriaSampleFilePath(projectName), JSON.stringify({ projectName, identities }));
2210
+ } catch {
2211
+ }
2212
+ }
2213
+ function clearAriaSampleFile(projectName) {
2214
+ try {
2215
+ fs11.rmSync(getAriaSampleFilePath(projectName), { force: true });
2216
+ } catch {
2217
+ }
2218
+ }
2219
+ var cachedSets = /* @__PURE__ */ new Map();
2220
+ function loadAriaSampleSet(projectName) {
2221
+ if (cachedSets.has(projectName)) return cachedSets.get(projectName);
2222
+ let set = null;
2223
+ try {
2224
+ const raw = fs11.readFileSync(getAriaSampleFilePath(projectName), "utf8");
2225
+ const parsed = JSON.parse(raw);
2226
+ if (parsed.projectName === projectName && Array.isArray(parsed.identities)) {
2227
+ set = new Set(parsed.identities.filter((x) => typeof x === "string"));
2228
+ }
2229
+ } catch {
2230
+ set = null;
2231
+ }
2232
+ cachedSets.set(projectName, set);
2233
+ return set;
2234
+ }
2235
+ function relativeTestFile(file) {
2236
+ return path10.relative(process.cwd(), file).split(path10.sep).join("/");
2237
+ }
2238
+ function isDueForAriaSample(testInfo) {
2239
+ const projectName = process.env.PIWI_PROJECT_NAME;
2240
+ if (!projectName) return false;
2241
+ const set = loadAriaSampleSet(projectName);
2242
+ if (!set || set.size === 0) return false;
2243
+ return set.has(ariaSampleIdentity(relativeTestFile(testInfo.file), testInfo.title));
2244
+ }
2002
2245
 
2003
2246
  // src/internal/support/run-mode.ts
2004
2247
  var PW_UI_FLAGS = ["--ui", "--ui-host", "--ui-port"];
@@ -2012,14 +2255,14 @@ function isUiMode(argv = process.argv) {
2012
2255
  // src/public/global-setup.ts
2013
2256
  function createGlobalSetup(options, userSetup) {
2014
2257
  return async function globalSetupFn(config) {
2015
- const piwiReporterPath = path10.resolve(__dirname, "./index.js");
2258
+ const piwiReporterPath = path11.resolve(__dirname, "./index.js");
2016
2259
  let inlineReporterOptions = {};
2017
2260
  if (Array.isArray(config?.reporter)) {
2018
2261
  for (const r of config.reporter) {
2019
2262
  if (!Array.isArray(r) || typeof r[0] !== "string") continue;
2020
2263
  const isPiwi = r[0].toLowerCase().includes("piwi") || (() => {
2021
2264
  try {
2022
- return path10.resolve(require.resolve(r[0])) === piwiReporterPath;
2265
+ return path11.resolve(require.resolve(r[0])) === piwiReporterPath;
2023
2266
  } catch {
2024
2267
  return false;
2025
2268
  }
@@ -2046,7 +2289,7 @@ function createGlobalSetup(options, userSetup) {
2046
2289
  if (!Array.isArray(r) || typeof r[0] !== "string") return false;
2047
2290
  if (r[0].toLowerCase().includes("piwi")) return true;
2048
2291
  try {
2049
- return path10.resolve(require.resolve(r[0])) === piwiReporterPath;
2292
+ return path11.resolve(require.resolve(r[0])) === piwiReporterPath;
2050
2293
  } catch {
2051
2294
  return false;
2052
2295
  }
@@ -2078,7 +2321,7 @@ function createGlobalSetup(options, userSetup) {
2078
2321
  auth
2079
2322
  );
2080
2323
  if (response?.runId && response?.setupToken) {
2081
- fs11.writeFileSync(
2324
+ fs12.writeFileSync(
2082
2325
  getSetupFilePath(opts.projectName),
2083
2326
  JSON.stringify({
2084
2327
  runId: response.runId,
@@ -2088,6 +2331,22 @@ function createGlobalSetup(options, userSetup) {
2088
2331
  );
2089
2332
  logger.debug(`Global setup: initializing run #${response.runId}`);
2090
2333
  }
2334
+ if (opts.projectName) clearAriaSampleFile(opts.projectName);
2335
+ if (opts.sampleAriaOnPass !== false && opts.projectName) {
2336
+ const menu = await httpClient.getJSON("/api/projects/menu", auth);
2337
+ const projectId = menu?.items?.find(
2338
+ (p) => p.name.toLowerCase() === opts.projectName.toLowerCase()
2339
+ )?.id;
2340
+ if (projectId != null) {
2341
+ const sampling = await httpClient.getJSON(`/api/projects/${projectId}/aria-sampling`, auth);
2342
+ const tests = Array.isArray(sampling?.tests) ? sampling.tests : null;
2343
+ if (tests) {
2344
+ const identities = tests.filter((t) => typeof t.filePath === "string" && typeof t.title === "string").map((t) => ariaSampleIdentity(t.filePath, t.title));
2345
+ writeAriaSampleFile(opts.projectName, identities);
2346
+ logger.debug(`Green ARIA sampling: ${identities.length} test(s) due a sample.`);
2347
+ }
2348
+ }
2349
+ }
2091
2350
  } catch (error) {
2092
2351
  logger.warn(`Could not register global setup: ${errorMessage(error)}`);
2093
2352
  }
@@ -2096,9 +2355,56 @@ function createGlobalSetup(options, userSetup) {
2096
2355
  }
2097
2356
 
2098
2357
  // src/public/config-wrapper.ts
2099
- var path11 = __toESM(require("path"));
2100
- var fs12 = __toESM(require("fs"));
2358
+ var path12 = __toESM(require("path"));
2359
+ var fs13 = __toESM(require("fs"));
2101
2360
  var PIWI_MODULE = "@piwitests/reporter";
2361
+ var CAPTURE_DEFAULTS = {
2362
+ screenshot: "only-on-failure",
2363
+ trace: "retain-on-failure"
2364
+ };
2365
+ var TRACE_SNAPSHOTS_MIN = { major: 1, minor: 63 };
2366
+ function installedPlaywrightVersion() {
2367
+ try {
2368
+ const nodeRequire = require;
2369
+ return nodeRequire("@playwright/test/package.json").version;
2370
+ } catch {
2371
+ return void 0;
2372
+ }
2373
+ }
2374
+ var readPlaywrightVersion = installedPlaywrightVersion;
2375
+ function supportsTraceSnapshots(version) {
2376
+ const match = version ? /^(\d+)\.(\d+)/.exec(version) : null;
2377
+ if (!match) return false;
2378
+ const major = Number(match[1]);
2379
+ const minor = Number(match[2]);
2380
+ return major > TRACE_SNAPSHOTS_MIN.major || major === TRACE_SNAPSHOTS_MIN.major && minor >= TRACE_SNAPSHOTS_MIN.minor;
2381
+ }
2382
+ function applyCaptureDefaults(use, piwiOptions) {
2383
+ delete process.env[PIWI_DEFAULTED_CAPTURE_ENV];
2384
+ const enabled = piwiOptions?.defaultCapture ?? readBool(process.env[PIWI_ENV_KEYS.defaultCapture]) ?? true;
2385
+ if (!enabled) return use;
2386
+ const ariaSnapshots = supportsTraceSnapshots(readPlaywrightVersion());
2387
+ const traceValue = ariaSnapshots ? { mode: CAPTURE_DEFAULTS.trace, snapshots: { dom: true, aria: true } } : CAPTURE_DEFAULTS.trace;
2388
+ const defaults = [
2389
+ { key: "screenshot", value: CAPTURE_DEFAULTS.screenshot, display: `screenshot: '${CAPTURE_DEFAULTS.screenshot}'` },
2390
+ {
2391
+ key: "trace",
2392
+ value: traceValue,
2393
+ display: ariaSnapshots ? `trace: '${CAPTURE_DEFAULTS.trace}' with dom and aria snapshots` : `trace: '${CAPTURE_DEFAULTS.trace}'`
2394
+ }
2395
+ ];
2396
+ const next = { ...use };
2397
+ const applied = [];
2398
+ for (const { key, value, display } of defaults) {
2399
+ if (use?.[key] === void 0) {
2400
+ next[key] = value;
2401
+ applied.push(display);
2402
+ }
2403
+ }
2404
+ if (applied.length === 0) return use;
2405
+ process.env[PIWI_DEFAULTED_CAPTURE_ENV] = applied.join(", ");
2406
+ return next;
2407
+ }
2102
2408
  function isPiwiReporterEntry(entry) {
2103
2409
  if (typeof entry === "string") return entry.toLowerCase().includes("piwi");
2104
2410
  if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0].toLowerCase().includes("piwi");
@@ -2115,11 +2421,11 @@ function injectReporter(reporter, piwiOptions) {
2115
2421
  }
2116
2422
  function resolveSetupModule() {
2117
2423
  const candidates = [
2118
- path11.join(__dirname, "global-setup-module.js"),
2119
- path11.join(__dirname, "..", "global-setup-module.js"),
2120
- path11.join(__dirname, "..", "global-setup-module.ts")
2424
+ path12.join(__dirname, "global-setup-module.js"),
2425
+ path12.join(__dirname, "..", "global-setup-module.js"),
2426
+ path12.join(__dirname, "..", "global-setup-module.ts")
2121
2427
  ];
2122
- return candidates.find((candidate) => fs12.existsSync(candidate)) ?? candidates[0];
2428
+ return candidates.find((candidate) => fs13.existsSync(candidate)) ?? candidates[0];
2123
2429
  }
2124
2430
  function wrapConfig(config, piwiOptions) {
2125
2431
  if (piwiOptions) applyOptionsToEnv(piwiOptions);
@@ -2132,11 +2438,13 @@ function wrapConfig(config, piwiOptions) {
2132
2438
  const forwarded = {};
2133
2439
  const failOnFlaky = piwiOptions?.failOnFlakyTests ?? readBool(process.env[PIWI_ENV_KEYS.failOnFlakyTests]);
2134
2440
  if (failOnFlaky === true) forwarded.failOnFlakyTests = true;
2441
+ const use = applyCaptureDefaults(config.use, piwiOptions);
2135
2442
  return {
2136
2443
  ...config,
2137
2444
  ...forwarded,
2138
2445
  reporter: injectReporter(config.reporter, piwiOptions),
2139
- globalSetup: globalSetupModules.length === 1 ? globalSetupModules[0] : globalSetupModules
2446
+ globalSetup: globalSetupModules.length === 1 ? globalSetupModules[0] : globalSetupModules,
2447
+ ...use === config.use ? {} : { use }
2140
2448
  };
2141
2449
  }
2142
2450
 
@@ -2204,6 +2512,8 @@ var PIWI_ANNOTATION_PREFIX = "piwi:";
2204
2512
  var TEST_PRIORITIES = ["critical", "high", "medium", "low"];
2205
2513
  var MAX_TEST_TAGS = 20;
2206
2514
  var MAX_TEST_TAG_CHARS = 60;
2515
+ var MAX_TEST_LOCKS = 20;
2516
+ var MAX_TEST_LOCK_CHARS = 100;
2207
2517
  var MAX_TEST_META_CHARS = 120;
2208
2518
  var MAX_TEST_LINK_CHARS = 500;
2209
2519
  var PRIORITY_SET = new Set(TEST_PRIORITIES);
@@ -2227,6 +2537,20 @@ function normalizeTestTags(raw) {
2227
2537
  }
2228
2538
  return out;
2229
2539
  }
2540
+ function normalizeTestLocks(raw) {
2541
+ if (!Array.isArray(raw)) return [];
2542
+ const seen = /* @__PURE__ */ new Set();
2543
+ const out = [];
2544
+ for (const entry of raw) {
2545
+ if (typeof entry !== "string") continue;
2546
+ const lock = entry.trim().slice(0, MAX_TEST_LOCK_CHARS);
2547
+ if (!lock || seen.has(lock)) continue;
2548
+ seen.add(lock);
2549
+ out.push(lock);
2550
+ if (out.length >= MAX_TEST_LOCKS) break;
2551
+ }
2552
+ return out;
2553
+ }
2230
2554
  function normalizeLink(value) {
2231
2555
  const raw = cleanString(value, MAX_TEST_LINK_CHARS);
2232
2556
  if (!raw) return void 0;
@@ -2279,12 +2603,16 @@ function parseTestMetadata(annotations) {
2279
2603
  function collectTestTags(test) {
2280
2604
  return normalizeTestTags(test.tags);
2281
2605
  }
2606
+ function collectTestLocks(test) {
2607
+ const locks = test?._locks;
2608
+ return normalizeTestLocks(locks);
2609
+ }
2282
2610
  function collectTestMetadata(annotations) {
2283
2611
  return parseTestMetadata(annotations);
2284
2612
  }
2285
2613
 
2286
2614
  // src/internal/collect/error-text.ts
2287
- var path12 = __toESM(require("path"));
2615
+ var path13 = __toESM(require("path"));
2288
2616
 
2289
2617
  // ../core/src/error-text.ts
2290
2618
  function joinErrorMessages(errors) {
@@ -2313,13 +2641,14 @@ function buildErrorText(result) {
2313
2641
  if (!text) return null;
2314
2642
  const loc = result.error?.location;
2315
2643
  if (!loc?.file) return text;
2316
- const rel = path12.relative(process.cwd(), loc.file).split(path12.sep).join("/");
2644
+ const rel = path13.relative(process.cwd(), loc.file).split(path13.sep).join("/");
2317
2645
  return appendErrorLocation(text, { file: rel, line: loc.line, column: loc.column });
2318
2646
  }
2319
2647
 
2320
2648
  // src/internal/support/ci-output.ts
2321
- var fs13 = __toESM(require("fs"));
2322
- var path13 = __toESM(require("path"));
2649
+ var fs14 = __toESM(require("fs"));
2650
+ var path14 = __toESM(require("path"));
2651
+ var SUMMARY_MAX_FAILURES = 20;
2323
2652
  function emitRunOutputs(output, logger, outputFile, env = process.env) {
2324
2653
  logger.info(`View run: ${output.runUrl}`);
2325
2654
  if (outputFile) writeOutputFile(outputFile, output, logger);
@@ -2328,9 +2657,9 @@ function emitRunOutputs(output, logger, outputFile, env = process.env) {
2328
2657
  }
2329
2658
  function writeOutputFile(file, output, logger) {
2330
2659
  try {
2331
- const dir = path13.dirname(file);
2332
- if (dir && dir !== ".") fs13.mkdirSync(dir, { recursive: true });
2333
- fs13.writeFileSync(
2660
+ const dir = path14.dirname(file);
2661
+ if (dir && dir !== ".") fs14.mkdirSync(dir, { recursive: true });
2662
+ fs14.writeFileSync(
2334
2663
  file,
2335
2664
  JSON.stringify(
2336
2665
  {
@@ -2339,7 +2668,9 @@ function writeOutputFile(file, output, logger) {
2339
2668
  projectId: output.projectId ?? null,
2340
2669
  projectName: output.projectName,
2341
2670
  status: output.status,
2342
- ciBuildUrl: output.ciBuildUrl ?? null
2671
+ ciBuildUrl: output.ciBuildUrl ?? null,
2672
+ failedCount: output.failures.length,
2673
+ failures: output.failures
2343
2674
  },
2344
2675
  null,
2345
2676
  2
@@ -2354,7 +2685,8 @@ function emitGitHubActions(output, env, logger) {
2354
2685
  const pairs = [
2355
2686
  ["piwi_run_url", output.runUrl],
2356
2687
  ["piwi_run_id", String(output.runId)],
2357
- ["piwi_run_status", output.status]
2688
+ ["piwi_run_status", output.status],
2689
+ ["piwi_failed_count", String(output.failures.length)]
2358
2690
  ];
2359
2691
  if (output.projectId != null) pairs.push(["piwi_project_id", String(output.projectId)]);
2360
2692
  if (env.GITHUB_OUTPUT) {
@@ -2368,7 +2700,13 @@ function emitGitHubActions(output, env, logger) {
2368
2700
  if (env.GITHUB_STEP_SUMMARY) {
2369
2701
  appendFileLines(
2370
2702
  env.GITHUB_STEP_SUMMARY,
2371
- ["### Piwi test run", "", `[View run](${output.runUrl}) \u2014 **${output.status}**`, ""],
2703
+ [
2704
+ "### Piwi test run",
2705
+ "",
2706
+ `[View run](${output.runUrl}) \u2014 **${output.status}**`,
2707
+ "",
2708
+ ...summaryFailureLines(output)
2709
+ ],
2372
2710
  logger,
2373
2711
  "step summary"
2374
2712
  );
@@ -2376,13 +2714,32 @@ function emitGitHubActions(output, env, logger) {
2376
2714
  process.stdout.write(`::notice title=Piwi test run::${output.runUrl}
2377
2715
  `);
2378
2716
  }
2717
+ function summaryFailureLines(output) {
2718
+ if (output.failures.length === 0) return [];
2719
+ const lines = output.failures.slice(0, SUMMARY_MAX_FAILURES).map((f) => {
2720
+ const headline = f.headline ? ` \u2014 ${escapeMarkdown(f.headline)}` : "";
2721
+ return `- \u274C [${escapeMarkdown(f.title)}](${f.url})${headline} \u2014 \`${f.file}\``;
2722
+ });
2723
+ const hidden = output.failures.length - SUMMARY_MAX_FAILURES;
2724
+ if (hidden > 0) lines.push(`- +${hidden} more`);
2725
+ lines.push("");
2726
+ return lines;
2727
+ }
2728
+ function escapeMarkdown(text) {
2729
+ return text.replace(/[\\`*_[\]]/g, (ch) => `\\${ch}`);
2730
+ }
2379
2731
  function emitGitLabDotenv(output, env, logger) {
2380
2732
  const file = env.PIWI_DOTENV_FILE || "piwi.env";
2381
- const lines = [`PIWI_RUN_URL=${output.runUrl}`, `PIWI_RUN_ID=${output.runId}`, `PIWI_RUN_STATUS=${output.status}`];
2733
+ const lines = [
2734
+ `PIWI_RUN_URL=${output.runUrl}`,
2735
+ `PIWI_RUN_ID=${output.runId}`,
2736
+ `PIWI_RUN_STATUS=${output.status}`,
2737
+ `PIWI_FAILED_COUNT=${output.failures.length}`
2738
+ ];
2382
2739
  if (output.projectId != null) lines.push(`PIWI_PROJECT_ID=${output.projectId}`);
2383
2740
  if (output.ciBuildUrl) lines.push(`PIWI_CI_BUILD_URL=${output.ciBuildUrl}`);
2384
2741
  try {
2385
- fs13.writeFileSync(file, lines.join("\n") + "\n");
2742
+ fs14.writeFileSync(file, lines.join("\n") + "\n");
2386
2743
  logger.info(`Wrote GitLab dotenv report to ${file} (declare it as artifacts:reports:dotenv)`);
2387
2744
  } catch (error) {
2388
2745
  logger.warn(`Failed to write GitLab dotenv file '${file}': ${errorMessage(error)}`);
@@ -2390,7 +2747,7 @@ function emitGitLabDotenv(output, env, logger) {
2390
2747
  }
2391
2748
  function appendFileLines(file, lines, logger, label) {
2392
2749
  try {
2393
- fs13.appendFileSync(file, lines.join("\n") + "\n");
2750
+ fs14.appendFileSync(file, lines.join("\n") + "\n");
2394
2751
  } catch (error) {
2395
2752
  logger.warn(`Failed to write GitHub Actions ${label}: ${errorMessage(error)}`);
2396
2753
  }
@@ -2409,13 +2766,15 @@ var RunSubmitter = class {
2409
2766
  * @param recovery Crash-recovery persistence.
2410
2767
  * @param streamManager Streaming session (may be `null` when streaming is disabled).
2411
2768
  * @param logger Prefixed logger.
2769
+ * @param failureLinks Failed tests collected during the run, for the per-failure links.
2412
2770
  */
2413
- constructor(httpClient, uploader, recovery, streamManager, logger = new Logger()) {
2771
+ constructor(httpClient, uploader, recovery, streamManager, logger = new Logger(), failureLinks = null) {
2414
2772
  this.httpClient = httpClient;
2415
2773
  this.uploader = uploader;
2416
2774
  this.recovery = recovery;
2417
2775
  this.streamManager = streamManager;
2418
2776
  this.logger = logger;
2777
+ this.failureLinks = failureLinks;
2419
2778
  }
2420
2779
  /** Run the fallback ladder for a completed test run. */
2421
2780
  async submit(run, result) {
@@ -2443,8 +2802,10 @@ var RunSubmitter = class {
2443
2802
  auth = sm?.auth ?? await this.httpClient.resolveAuth(run.options);
2444
2803
  } catch (error) {
2445
2804
  this.logger.error(`Authentication failed: ${errorMessage(error)}`);
2805
+ this.saveRecovery(this.buildRunPayload(run, overallStatus, duration));
2446
2806
  throw error;
2447
2807
  }
2808
+ if (!sm) await this.recovery.tryUpload(this.httpClient, auth);
2448
2809
  let outcome = { done: false, output: null };
2449
2810
  if (sm?.enabled && sm?.runId != null) {
2450
2811
  outcome = await this.tryFinishStreaming(run, overallStatus, duration, auth);
@@ -2455,7 +2816,10 @@ var RunSubmitter = class {
2455
2816
  if (!outcome.done) {
2456
2817
  outcome = await this.tryUploadJSON(run, overallStatus, duration, auth);
2457
2818
  }
2458
- if (outcome.output) emitRunOutputs(outcome.output, this.logger, run.options.outputFile);
2819
+ if (outcome.output) {
2820
+ this.failureLinks?.printPending(outcome.output.runId);
2821
+ emitRunOutputs(outcome.output, this.logger, run.options.outputFile);
2822
+ }
2459
2823
  }
2460
2824
  /** Assemble a CI-facing run output, or `null` when the server returned no run id. */
2461
2825
  buildOutput(runId, projectId, run, status) {
@@ -2466,7 +2830,8 @@ var RunSubmitter = class {
2466
2830
  projectId,
2467
2831
  projectName: run.options.projectName,
2468
2832
  status,
2469
- ciBuildUrl: ciBuildUrlFromMetadata(run.metadata)
2833
+ ciBuildUrl: ciBuildUrlFromMetadata(run.metadata),
2834
+ failures: this.failureLinks?.resolve(runId) ?? []
2470
2835
  };
2471
2836
  }
2472
2837
  hasReports(run) {
@@ -2560,17 +2925,15 @@ var RunSubmitter = class {
2560
2925
  }
2561
2926
  }
2562
2927
  async tryUploadWithFiles(run, overallStatus, duration, auth) {
2928
+ const payload = this.buildRunPayload(run, overallStatus, duration);
2563
2929
  try {
2564
- const response = await this.uploader.uploadWithFiles(
2565
- this.buildRunPayload(run, overallStatus, duration),
2566
- this.reportOptions(run),
2567
- auth
2568
- );
2930
+ const response = await this.uploader.uploadWithFiles(payload, this.reportOptions(run), auth);
2569
2931
  this.recovery.clear();
2570
2932
  return { done: true, output: this.buildOutput(response?.runId, response?.projectId, run, overallStatus) };
2571
2933
  } catch (error) {
2572
2934
  if (error instanceof HttpError && error.status === 401 && !auth) {
2573
2935
  this.logAuthRequired(run.options.serverUrl);
2936
+ this.saveRecovery(payload);
2574
2937
  throw error;
2575
2938
  }
2576
2939
  this.logger.warn(`Failed to upload with files: ${errorMessage(error)}`);
@@ -2587,16 +2950,24 @@ var RunSubmitter = class {
2587
2950
  } catch (error) {
2588
2951
  if (error instanceof HttpError && error.status === 401 && !auth) {
2589
2952
  this.logAuthRequired(run.options.serverUrl);
2953
+ this.saveRecovery(payload);
2590
2954
  throw error;
2591
2955
  }
2592
2956
  this.logger.error(`All upload methods failed: ${errorMessage(error)}`);
2593
2957
  this.logger.info(
2594
2958
  `Saved a local recovery copy \u2014 it will be uploaded automatically on your next test run. If this keeps happening, check that serverUrl (${run.options.serverUrl ?? "not set"}) is correct and reachable.`
2595
2959
  );
2596
- this.recovery.save(serializeRun(payload, { includeTestCases: true }));
2960
+ this.saveRecovery(payload);
2597
2961
  return { done: true, output: null };
2598
2962
  }
2599
2963
  }
2964
+ /**
2965
+ * Persist the wire-serialized payload (no raw attachments / internal fields)
2966
+ * so a later run can retry the submit.
2967
+ */
2968
+ saveRecovery(payload) {
2969
+ this.recovery.save(serializeRun(payload, { includeTestCases: true }));
2970
+ }
2600
2971
  /** Log one actionable line explaining how to fix a 401 caused by a missing credential. */
2601
2972
  logAuthRequired(serverUrl) {
2602
2973
  this.logger.error(
@@ -2605,10 +2976,833 @@ var RunSubmitter = class {
2605
2976
  }
2606
2977
  };
2607
2978
 
2979
+ // ../core/src/locator-methods.ts
2980
+ var LOCATOR_BUILDER_METHODS = [
2981
+ "getByRole",
2982
+ "getByTestId",
2983
+ "getByText",
2984
+ "getByLabel",
2985
+ "getByPlaceholder",
2986
+ "getByAltText",
2987
+ "getByTitle",
2988
+ "locator"
2989
+ ];
2990
+
2991
+ // ../core/src/error-parse.ts
2992
+ var ANSI_RE = new RegExp("\\u001B\\[[0-9;]*m", "g");
2993
+ function stripAnsi(text) {
2994
+ return text.replace(ANSI_RE, "");
2995
+ }
2996
+ var SELECTOR_FN_RE = /\b(?:locator|frameLocator|getByRole|getByTestId|getByText|getByLabel|getByPlaceholder|getByAltText|getByTitle)\(/;
2997
+ var CHAIN_LINK_METHODS = /* @__PURE__ */ new Set([
2998
+ ...LOCATOR_BUILDER_METHODS,
2999
+ "frameLocator",
3000
+ "contentFrame",
3001
+ "filter",
3002
+ "first",
3003
+ "last",
3004
+ "nth",
3005
+ "and",
3006
+ "or",
3007
+ "describe"
3008
+ ]);
3009
+ function extractMessageHead(text) {
3010
+ let head = text;
3011
+ const callLogIdx = head.indexOf("\nCall log:");
3012
+ if (callLogIdx !== -1) head = head.slice(0, callLogIdx);
3013
+ const stackIdx = head.search(/\n\s+at /);
3014
+ if (stackIdx !== -1) head = head.slice(0, stackIdx);
3015
+ const lines = head.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
3016
+ return lines.slice(0, 5).join("\n");
3017
+ }
3018
+ function extractLeafSelector(text) {
3019
+ const first = SELECTOR_FN_RE.exec(text);
3020
+ if (!first) return null;
3021
+ const nl = text.indexOf("\n", first.index);
3022
+ const region = text.slice(first.index, nl === -1 ? void 0 : nl);
3023
+ let depth = 0;
3024
+ let leafStart = -1;
3025
+ for (let i = 0; i < region.length; i++) {
3026
+ const ch = region[i];
3027
+ if (ch === "(") {
3028
+ depth++;
3029
+ continue;
3030
+ }
3031
+ if (ch === ")") {
3032
+ if (depth > 0) depth--;
3033
+ continue;
3034
+ }
3035
+ if (depth !== 0) continue;
3036
+ const prev = region[i - 1];
3037
+ if (prev && /\w/.test(prev)) continue;
3038
+ for (const method of LOCATOR_BUILDER_METHODS) {
3039
+ if (region.startsWith(method, i) && region[i + method.length] === "(") {
3040
+ leafStart = i;
3041
+ break;
3042
+ }
3043
+ }
3044
+ }
3045
+ if (leafStart === -1) return null;
3046
+ depth = 0;
3047
+ for (let i = leafStart; i < region.length; i++) {
3048
+ const ch = region[i];
3049
+ if (ch === "(") {
3050
+ depth++;
3051
+ } else if (ch === ")") {
3052
+ depth--;
3053
+ if (depth === 0) return region.slice(leafStart, i + 1);
3054
+ }
3055
+ }
3056
+ return region.slice(leafStart, leafStart + 80);
3057
+ }
3058
+ function extractLocatorChain(text) {
3059
+ const first = SELECTOR_FN_RE.exec(text);
3060
+ if (!first) return null;
3061
+ const nl = text.indexOf("\n", first.index);
3062
+ const region = text.slice(first.index, nl === -1 ? void 0 : nl);
3063
+ let depth = 0;
3064
+ let end = -1;
3065
+ for (let i = 0; i < region.length; i++) {
3066
+ const ch = region[i];
3067
+ if (ch === "(") {
3068
+ depth++;
3069
+ } else if (ch === ")") {
3070
+ if (depth > 0) depth--;
3071
+ if (depth === 0) {
3072
+ end = i + 1;
3073
+ const link = /^\.(\w+)\(/.exec(region.slice(end));
3074
+ if (!link || !CHAIN_LINK_METHODS.has(link[1])) break;
3075
+ i = end + link[0].length - 2;
3076
+ }
3077
+ }
3078
+ }
3079
+ if (end === -1) return region.slice(0, 80);
3080
+ return region.slice(0, end);
3081
+ }
3082
+ function extractTopFrame(text) {
3083
+ const frameRe = /^\s+at (?:.*? \()?([^()\s][^()]*?):(\d+):(\d+)\)?\s*$/gm;
3084
+ let m;
3085
+ while ((m = frameRe.exec(text)) !== null) {
3086
+ const file = m[1].replace(/\\/g, "/");
3087
+ if (file.includes("node_modules") || file.startsWith("node:")) continue;
3088
+ return { file, line: Number(m[2]), column: Number(m[3]) };
3089
+ }
3090
+ return null;
3091
+ }
3092
+ var SUBJECTS = "locator|page|frame|frameLocator|elementHandle|mouse|keyboard|touchscreen|browserContext|browser|apiRequestContext|request|response|route|download|dialog|fileChooser|expect|worker|jsHandle|clock|tracing|video";
3093
+ var CALL_RE = new RegExp(`\\b(${SUBJECTS})\\.(\\w+): `);
3094
+ var ERROR_NAME_RE = /^((?:[A-Z]\w*)?(?:Error|Exception))\b:?/;
3095
+ var MATCHER_RE = /\bexpect(?:\.soft)?\((?:[^()]|\([^()]*\))*\)(\.not)?\.(to\w+)/;
3096
+ var MATCHER_SHORT_RE = /\bexpect(?:\.soft)?\.(to\w+)\b/;
3097
+ var TIMED_OUT_EXPECT_RE = /Timed out (\d+)ms waiting for expect\(/;
3098
+ var NAVIGATION_RE = /\b(?:page|frame)\.(?:goto|waitForURL|waitForNavigation|reload|goBack|goForward)\b|net::ERR_|NS_ERROR_|Navigation failed|navigating to "/i;
3099
+ var NETWORK_CODE_RE = /\b(net::ERR_[A-Z0-9_]+|NS_ERROR_[A-Z0-9_]+)\b/;
3100
+ var CRASH_RE = /Target page, context or browser has been closed|Target closed|browser has been closed|Browser closed|Page crashed|Navigation failed because page was closed/i;
3101
+ var TEST_TIMEOUT_RE = /\bTest timeout of (\d+)ms exceeded(?: while (?:running "(\w+)" hook|tearing down "(\w+)"))?/;
3102
+ var STRICT_RE = /strict mode violation: (.+?) resolved to (\d+) elements/;
3103
+ var URL_RE = /https?:\/\/[^\s'"`)]+/;
3104
+ var CALL_LOG_LINE_RE = /^\s*-\s+(?:(\d+) × )?(.*)$/;
3105
+ var RETRY_COUNT_LINE_RE = /^\s+\d+ × (.+)$/;
3106
+ function withoutStackFrames(text) {
3107
+ return text.split("\n").filter((line) => !/^\s+at /.test(line)).join("\n");
3108
+ }
3109
+ function callLogLines(text) {
3110
+ const start = text.indexOf("Call log:");
3111
+ const region = start === -1 ? text : text.slice(start + "Call log:".length);
3112
+ const lines = [];
3113
+ for (const line of region.split("\n")) {
3114
+ const bulleted = CALL_LOG_LINE_RE.exec(line);
3115
+ if (bulleted) {
3116
+ lines.push(bulleted[2].trim());
3117
+ continue;
3118
+ }
3119
+ const retry = RETRY_COUNT_LINE_RE.exec(line);
3120
+ if (retry) lines.push(retry[1].trim());
3121
+ }
3122
+ return lines;
3123
+ }
3124
+ function readCallLog(text) {
3125
+ const lines = callLogLines(text);
3126
+ const read = {
3127
+ state: "unknown",
3128
+ count: null,
3129
+ lastLine: null,
3130
+ stateLine: null,
3131
+ url: null,
3132
+ timeoutMs: null,
3133
+ matcher: null
3134
+ };
3135
+ if (lines.length === 0) return read;
3136
+ read.lastLine = lines[lines.length - 1];
3137
+ let sawWaiting = false;
3138
+ let waitingLine = null;
3139
+ for (const line of lines) {
3140
+ const before = read.state;
3141
+ const lower = line.toLowerCase();
3142
+ const count = /^locator resolved to (\d+) elements?/.exec(line);
3143
+ const expectLine = /^Expect "(\w+)" with timeout (\d+)ms/.exec(line);
3144
+ const navTo = /navigat(?:ing|ion|ed) to "([^"]+)"/.exec(line);
3145
+ if (expectLine) {
3146
+ read.matcher = expectLine[1];
3147
+ read.timeoutMs = Number(expectLine[2]);
3148
+ continue;
3149
+ }
3150
+ if (navTo) {
3151
+ read.url ??= navTo[1];
3152
+ read.state = "navigating";
3153
+ continue;
3154
+ }
3155
+ if (/^waiting for (?:navigation|page to navigate)/.test(line)) {
3156
+ read.state = "navigating";
3157
+ continue;
3158
+ }
3159
+ if (line.startsWith("waiting for ") && SELECTOR_FN_RE.test(line)) {
3160
+ sawWaiting = true;
3161
+ waitingLine ??= line;
3162
+ continue;
3163
+ }
3164
+ if (count) {
3165
+ read.state = "resolved-count";
3166
+ read.count = Number(count[1]);
3167
+ continue;
3168
+ }
3169
+ if (line.startsWith("locator resolved to hidden <") || line.startsWith('unexpected value "hidden"')) {
3170
+ read.state = "hidden";
3171
+ continue;
3172
+ }
3173
+ if (/^locator resolved to (?:visible )?</.test(line)) {
3174
+ read.state = "resolved";
3175
+ continue;
3176
+ }
3177
+ if (lower.startsWith("element is not visible")) read.state = "not-visible";
3178
+ else if (lower.startsWith("element is not enabled")) read.state = "not-enabled";
3179
+ else if (lower.startsWith("element is not editable")) read.state = "not-editable";
3180
+ else if (lower.startsWith("element is not stable")) read.state = "not-stable";
3181
+ else if (lower.startsWith("element is outside of the viewport")) read.state = "outside-viewport";
3182
+ else if (lower.startsWith("element is not attached") || lower.includes("element was detached"))
3183
+ read.state = "detached";
3184
+ else if (lower.includes("intercepts pointer events")) read.state = "intercepts-pointer";
3185
+ else if (lower.startsWith("element is visible, enabled and stable")) read.state = "resolved";
3186
+ else if (/^(?:attempting|performing) \w+ action/.test(lower) && (read.state === "unknown" || read.state === "not-found"))
3187
+ read.state = "resolved";
3188
+ if (read.state !== before || /^(?:locator resolved to|unexpected value|element is)/.test(lower)) {
3189
+ read.stateLine = line;
3190
+ }
3191
+ }
3192
+ if (read.state === "unknown" && sawWaiting) {
3193
+ read.state = "not-found";
3194
+ read.stateLine = waitingLine;
3195
+ }
3196
+ return read;
3197
+ }
3198
+ function headerValue(text, label) {
3199
+ const re = new RegExp(`^\\s*${label}(?: string| pattern| substring| value)?:[ \\t]*(.*)$`, "m");
3200
+ const m = re.exec(text);
3201
+ if (!m) return null;
3202
+ const value = m[1].trim();
3203
+ return value.length > 0 ? value : null;
3204
+ }
3205
+ function readLocator(text, strict) {
3206
+ const header = /^\s*Locator:[ \t]*(.+)$/m.exec(text);
3207
+ if (header) return header[1].trim();
3208
+ if (strict) return strict[1].trim();
3209
+ return extractLocatorChain(text);
3210
+ }
3211
+ function readTimeout(text, callLog) {
3212
+ const patterns = [
3213
+ /^\s*Timeout:[ \t]*(\d+)ms/m,
3214
+ /\bTimeout (\d+)ms exceeded/,
3215
+ TEST_TIMEOUT_RE,
3216
+ TIMED_OUT_EXPECT_RE,
3217
+ /\bTimed out (\d+)ms/
3218
+ ];
3219
+ for (const re of patterns) {
3220
+ const m = re.exec(text);
3221
+ if (m) return Number(m[1]);
3222
+ }
3223
+ return callLog.timeoutMs;
3224
+ }
3225
+ function readUrl(text, callLog, received) {
3226
+ if (callLog.url) return callLog.url;
3227
+ const at = /\b(?:net::ERR_[A-Z0-9_]+|NS_ERROR_[A-Z0-9_]+) at (\S+)/.exec(text);
3228
+ if (at) return at[1];
3229
+ const head = extractMessageHead(text);
3230
+ const inHead = URL_RE.exec(head.replace(/^\s*Received.*$/gm, ""));
3231
+ if (inHead) return inHead[0];
3232
+ if (received) {
3233
+ const inReceived = URL_RE.exec(received);
3234
+ if (inReceived) return inReceived[0];
3235
+ }
3236
+ return null;
3237
+ }
3238
+ function parsePlaywrightError(raw, context) {
3239
+ const clean = stripAnsi(raw ?? "").replace(/\r\n?/g, "\n");
3240
+ const text = withoutStackFrames(clean);
3241
+ const messageHead = extractMessageHead(clean);
3242
+ const firstLine = messageHead.split("\n")[0] ?? "";
3243
+ const errorName = ERROR_NAME_RE.exec(firstLine)?.[1] ?? null;
3244
+ const strict = STRICT_RE.exec(text);
3245
+ const call = CALL_RE.exec(text);
3246
+ const callLog = readCallLog(text);
3247
+ const testTimeout = TEST_TIMEOUT_RE.exec(text);
3248
+ let subject2 = null;
3249
+ let action = null;
3250
+ let assertion = null;
3251
+ let negated = false;
3252
+ if (call) {
3253
+ subject2 = call[1];
3254
+ if (subject2 === "expect") assertion = call[2];
3255
+ else action = call[2];
3256
+ }
3257
+ const matcher = MATCHER_RE.exec(text);
3258
+ const matcherShort = MATCHER_SHORT_RE.exec(text);
3259
+ if (matcher) {
3260
+ assertion = matcher[2];
3261
+ negated = Boolean(matcher[1]);
3262
+ } else if (!assertion && matcherShort) {
3263
+ assertion = matcherShort[1];
3264
+ } else if (!assertion && callLog.matcher) {
3265
+ assertion = callLog.matcher;
3266
+ }
3267
+ if (/\bexpect\((?:[^()]|\([^()]*\))*\)\.not\./.test(text)) negated = true;
3268
+ const isAssertion = assertion !== null || /\bexpect\(|\bexpect\.|Expected (?:string|substring|pattern|value)/.test(text);
3269
+ const isNavigationFailure = NAVIGATION_RE.test(text);
3270
+ const networkErrorCode = NETWORK_CODE_RE.exec(text)?.[1] ?? null;
3271
+ let kind;
3272
+ if (strict) kind = "strict-mode";
3273
+ else if (testTimeout) kind = "test-timeout";
3274
+ else if (CRASH_RE.test(text)) kind = "crash";
3275
+ else if (isAssertion) {
3276
+ const retrying = /^\s*Timeout:[ \t]*\d+ms/m.test(text) || TIMED_OUT_EXPECT_RE.test(text) || callLog.matcher !== null;
3277
+ kind = retrying ? "assertion-timeout" : "assertion";
3278
+ } else if (isNavigationFailure) kind = "navigation";
3279
+ else if (/\bTimeout \d+ms exceeded/.test(text) || errorName === "TimeoutError") kind = "action-timeout";
3280
+ else kind = "unknown";
3281
+ const paramsLocator = typeof context?.stepParams?.locator === "string" ? context.stepParams.locator : null;
3282
+ const paramsUrl = typeof context?.stepParams?.url === "string" ? context.stepParams.url : null;
3283
+ const locator = readLocator(text, strict) ?? paramsLocator;
3284
+ const leafLocator = locator ? extractLeafSelector(locator) : null;
3285
+ const expected = headerValue(text, "Expected");
3286
+ const received = headerValue(text, "Received");
3287
+ const timeoutMs = readTimeout(text, callLog);
3288
+ const url = readUrl(text, callLog, received) ?? paramsUrl;
3289
+ const frame = extractTopFrame(clean);
3290
+ const resolvedCount = strict ? Number(strict[2]) : callLog.count;
3291
+ const lastState = strict ? "resolved-count" : callLog.state;
3292
+ const isLocatorResolutionFailure = kind === "strict-mode" || locator !== null && (lastState === "not-found" || lastState === "resolved-count" && resolvedCount === 0);
3293
+ return {
3294
+ kind,
3295
+ errorName,
3296
+ subject: subject2,
3297
+ action,
3298
+ assertion,
3299
+ negated,
3300
+ locator,
3301
+ leafLocator,
3302
+ expected,
3303
+ received,
3304
+ timeoutMs,
3305
+ url,
3306
+ networkErrorCode,
3307
+ timeoutPhase: testTimeout ? testTimeout[2] ?? testTimeout[3] ?? null : null,
3308
+ lastState,
3309
+ resolvedCount,
3310
+ lastCallLogLine: callLog.lastLine,
3311
+ lastStateLine: callLog.stateLine,
3312
+ messageHead,
3313
+ topFrame: frame ? `${frame.file}:${frame.line}` : null,
3314
+ isNavigationFailure,
3315
+ isLocatorResolutionFailure
3316
+ };
3317
+ }
3318
+
3319
+ // ../core/src/describe-failure.ts
3320
+ var HEADLINE_MAX_CHARS = 120;
3321
+ var VALUE_MAX_CHARS = 40;
3322
+ var SHORT_VALUE_MAX_CHARS = 20;
3323
+ var MASK_TOKEN_RE = /<(?:N|VALUE|URL|STR|UUID|HASH|EMAIL)>/g;
3324
+ var ACTION_VERBS = {
3325
+ click: "click",
3326
+ dblclick: "double-click",
3327
+ fill: "fill",
3328
+ type: "type",
3329
+ press: "press",
3330
+ pressSequentially: "type",
3331
+ check: "check",
3332
+ uncheck: "uncheck",
3333
+ hover: "hover",
3334
+ tap: "tap",
3335
+ focus: "focus",
3336
+ blur: "blur",
3337
+ clear: "clear",
3338
+ selectOption: "select",
3339
+ selectText: "select text",
3340
+ setInputFiles: "file upload",
3341
+ setChecked: "check",
3342
+ dragTo: "drag",
3343
+ dragAndDrop: "drag",
3344
+ scrollIntoViewIfNeeded: "scroll",
3345
+ screenshot: "screenshot",
3346
+ waitFor: "wait",
3347
+ waitForSelector: "waitForSelector",
3348
+ waitForLoadState: "waitForLoadState",
3349
+ waitForFunction: "waitForFunction",
3350
+ waitForResponse: "waitForResponse",
3351
+ waitForRequest: "waitForRequest",
3352
+ waitForEvent: "waitForEvent",
3353
+ waitForTimeout: "waitForTimeout",
3354
+ innerText: "read text",
3355
+ textContent: "read text",
3356
+ inputValue: "read value",
3357
+ getAttribute: "read attribute",
3358
+ isVisible: "visibility check",
3359
+ isEnabled: "enabled check",
3360
+ isChecked: "checked check",
3361
+ boundingBox: "measure",
3362
+ evaluate: "evaluate",
3363
+ goto: "navigation"
3364
+ };
3365
+ var ACTION_GERUNDS = {
3366
+ click: "clicking",
3367
+ dblclick: "double-clicking",
3368
+ fill: "filling",
3369
+ type: "typing into",
3370
+ press: "pressing a key on",
3371
+ pressSequentially: "typing into",
3372
+ check: "checking",
3373
+ uncheck: "unchecking",
3374
+ hover: "hovering",
3375
+ tap: "tapping",
3376
+ focus: "focusing",
3377
+ clear: "clearing",
3378
+ selectOption: "selecting an option in",
3379
+ setInputFiles: "uploading a file to",
3380
+ dragTo: "dragging",
3381
+ waitFor: "waiting for",
3382
+ waitForSelector: "waiting for",
3383
+ evaluate: "evaluating on"
3384
+ };
3385
+ var STATE_PHRASES = {
3386
+ "not-found": "was not found on the page",
3387
+ hidden: "never became visible",
3388
+ "not-visible": "never became visible",
3389
+ "not-enabled": "never became enabled",
3390
+ "not-editable": "never became editable",
3391
+ "not-stable": "never stopped moving",
3392
+ "outside-viewport": "stayed outside the viewport",
3393
+ "intercepts-pointer": "was covered by another element",
3394
+ detached: "was detached from the DOM",
3395
+ navigating: "was still navigating"
3396
+ };
3397
+ var STATE_MATCHERS = {
3398
+ toBeVisible: { met: "visible", unmet: "never became visible" },
3399
+ toBeHidden: { met: "hidden", unmet: "never became hidden" },
3400
+ toBeEnabled: { met: "enabled", unmet: "never became enabled" },
3401
+ toBeDisabled: { met: "disabled", unmet: "never became disabled" },
3402
+ toBeChecked: { met: "checked", unmet: "never became checked" },
3403
+ toBeEditable: { met: "editable", unmet: "never became editable" },
3404
+ toBeFocused: { met: "focused", unmet: "never received focus" },
3405
+ toBeAttached: { met: "attached", unmet: "never appeared in the DOM" },
3406
+ toBeDetached: { met: "detached", unmet: "never left the DOM" },
3407
+ toBeInViewport: { met: "in the viewport", unmet: "never entered the viewport" },
3408
+ toBeEmpty: { met: "empty", unmet: "never became empty" }
3409
+ };
3410
+ var VALUE_MATCHERS = {
3411
+ toHaveText: "text",
3412
+ toContainText: "text containing",
3413
+ toHaveValue: "value",
3414
+ toHaveValues: "values",
3415
+ toHaveAttribute: "attribute",
3416
+ toHaveClass: "class",
3417
+ toContainClass: "class",
3418
+ toHaveId: "id",
3419
+ toHaveCSS: "CSS",
3420
+ toHaveJSProperty: "property",
3421
+ toHaveAccessibleName: "accessible name",
3422
+ toHaveAccessibleDescription: "accessible description",
3423
+ toHaveRole: "role",
3424
+ toHaveURL: "URL",
3425
+ toHaveTitle: "title",
3426
+ toHaveScreenshot: "screenshot",
3427
+ toMatchAriaSnapshot: "ARIA snapshot"
3428
+ };
3429
+ var NETWORK_ERRORS = {
3430
+ ERR_CONNECTION_REFUSED: "Connection refused loading",
3431
+ ERR_CONNECTION_RESET: "Connection reset loading",
3432
+ ERR_CONNECTION_CLOSED: "Connection closed loading",
3433
+ ERR_CONNECTION_TIMED_OUT: "Connection timed out loading",
3434
+ ERR_TIMED_OUT: "Connection timed out loading",
3435
+ ERR_NAME_NOT_RESOLVED: "DNS lookup failed for",
3436
+ ERR_INTERNET_DISCONNECTED: "No network while loading",
3437
+ ERR_ADDRESS_UNREACHABLE: "Address unreachable loading",
3438
+ ERR_ABORTED: "Navigation aborted loading",
3439
+ ERR_EMPTY_RESPONSE: "Empty response loading",
3440
+ ERR_TOO_MANY_REDIRECTS: "Too many redirects loading",
3441
+ ERR_SSL_PROTOCOL_ERROR: "TLS error loading",
3442
+ ERR_CERT_AUTHORITY_INVALID: "Untrusted certificate loading",
3443
+ ERR_CERT_COMMON_NAME_INVALID: "Certificate name mismatch loading",
3444
+ ERR_CERT_DATE_INVALID: "Expired certificate loading",
3445
+ ERR_BLOCKED_BY_CLIENT: "Request blocked loading",
3446
+ ERR_FAILED: "Request failed loading",
3447
+ ERR_HTTP_RESPONSE_CODE_FAILURE: "HTTP error loading",
3448
+ NS_ERROR_CONNECTION_REFUSED: "Connection refused loading",
3449
+ NS_ERROR_UNKNOWN_HOST: "DNS lookup failed for",
3450
+ NS_ERROR_NET_TIMEOUT: "Connection timed out loading",
3451
+ NS_ERROR_OFFLINE: "No network while loading",
3452
+ NS_ERROR_ABORT: "Navigation aborted loading",
3453
+ NS_BINDING_ABORTED: "Navigation aborted loading"
3454
+ };
3455
+ function formatTimeout(ms) {
3456
+ if (ms < 1e3) return `${ms} ms`;
3457
+ const seconds = ms / 1e3;
3458
+ const rounded = Math.round(seconds * 10) / 10;
3459
+ return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded} s`;
3460
+ }
3461
+ function routeOf(url) {
3462
+ try {
3463
+ const parsed = new URL(url);
3464
+ return `${parsed.pathname}${parsed.search}` || "/";
3465
+ } catch {
3466
+ return url;
3467
+ }
3468
+ }
3469
+ function truncateValue(value, max) {
3470
+ const flat = value.replace(/\s+/g, " ").trim();
3471
+ return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
3472
+ }
3473
+ function countNoun(locator, count) {
3474
+ const role = locator ? /getByRole\(\s*['"]([a-z]+)['"]/.exec(locator)?.[1] : null;
3475
+ const noun = role ?? "element";
3476
+ if (count === 1) return noun;
3477
+ return noun.endsWith("s") ? noun : noun.endsWith("x") || noun.endsWith("ch") ? `${noun}es` : `${noun}s`;
3478
+ }
3479
+ function textOfGetByText(locator) {
3480
+ if (!locator) return null;
3481
+ const m = /^getByText\(\s*(['"`])((?:\\.|(?!\1).)*)\1\s*(?:,\s*\{[^}]*\})?\s*\)$/.exec(locator);
3482
+ return m ? m[2] : null;
3483
+ }
3484
+ function receivedDisplay(parsed) {
3485
+ const received = parsed.received ?? "";
3486
+ if (parsed.assertion !== "toHaveURL") return received;
3487
+ const m = /^"?(https?:\/\/[^"\s]+)"?$/.exec(received);
3488
+ return m ? `"${routeOf(m[1])}"` : received;
3489
+ }
3490
+ function firstLineFallback(parsed) {
3491
+ const line = (parsed.messageHead.split("\n")[0] ?? "").replace(/^Error:\s*/, "").replace(MASK_TOKEN_RE, "\u2026");
3492
+ return truncateValue(line || "Unknown error", HEADLINE_MAX_CHARS);
3493
+ }
3494
+ var Line = class {
3495
+ constructor() {
3496
+ this.parts = [];
3497
+ }
3498
+ text(text) {
3499
+ if (!text) return this;
3500
+ const last = this.parts[this.parts.length - 1];
3501
+ if (last && last.kind === "text") last.text += text;
3502
+ else this.parts.push({ kind: "text", text });
3503
+ return this;
3504
+ }
3505
+ locator(text) {
3506
+ this.parts.push({ kind: "locator", text });
3507
+ return this;
3508
+ }
3509
+ value(text) {
3510
+ this.parts.push({ kind: "value", text });
3511
+ return this;
3512
+ }
3513
+ toString() {
3514
+ return this.parts.map((p) => p.text).join("");
3515
+ }
3516
+ };
3517
+ function subject(line, locator, opts) {
3518
+ const text = textOfGetByText(locator);
3519
+ if (text !== null) return line.text("Text ").value(`"${truncateValue(text, opts.valueMax)}"`);
3520
+ return line.locator(opts.locator ?? locator ?? "");
3521
+ }
3522
+ function timeoutSuffix(timeoutMs) {
3523
+ return timeoutMs !== null ? ` after ${formatTimeout(timeoutMs)}` : "";
3524
+ }
3525
+ function timeoutParen(timeoutMs) {
3526
+ return timeoutMs !== null ? ` (${formatTimeout(timeoutMs)})` : "";
3527
+ }
3528
+ function buildActionTimeout(parsed, opts) {
3529
+ const line = new Line();
3530
+ const verb = ACTION_VERBS[parsed.action ?? ""] ?? parsed.action ?? "action";
3531
+ const state = STATE_PHRASES[parsed.lastState];
3532
+ if (parsed.locator && state) {
3533
+ return subject(line, parsed.locator, opts).text(` ${state} \u2014 ${verb} timed out`).text(timeoutSuffix(parsed.timeoutMs));
3534
+ }
3535
+ if (parsed.locator && parsed.lastState === "resolved-count" && parsed.resolvedCount !== null) {
3536
+ const count = parsed.resolvedCount;
3537
+ return subject(line, parsed.locator, opts).text(` matched ${count === 0 ? "no" : count} ${countNoun(parsed.locator, count)} \u2014 ${verb} timed out`).text(timeoutSuffix(parsed.timeoutMs));
3538
+ }
3539
+ if (parsed.locator) {
3540
+ return line.text(`${verb} on `).locator(opts.locator ?? parsed.locator).text(" timed out").text(timeoutSuffix(parsed.timeoutMs));
3541
+ }
3542
+ const call = parsed.subject && parsed.action ? `${parsed.subject}.${parsed.action}` : verb;
3543
+ return line.text(`${call} timed out`).text(timeoutSuffix(parsed.timeoutMs));
3544
+ }
3545
+ function buildAssertion(parsed, opts) {
3546
+ const line = new Line();
3547
+ const matcher = parsed.assertion ?? "expect";
3548
+ const notFound = parsed.lastState === "not-found" || parsed.lastState === "resolved-count" && parsed.resolvedCount === 0 || /element\(s\) not found/.test(parsed.received ?? "");
3549
+ const expected = parsed.expected ? truncateValue(parsed.expected, opts.valueMax) : null;
3550
+ const received = parsed.received ? truncateValue(receivedDisplay(parsed), opts.valueMax) : null;
3551
+ if (matcher === "toHaveCount" && parsed.locator) {
3552
+ const want = Number(parsed.expected);
3553
+ const got = notFound ? 0 : Number(parsed.received);
3554
+ const wantText = Number.isFinite(want) ? String(want) : expected ?? "?";
3555
+ const noun2 = countNoun(parsed.locator, Number.isFinite(want) ? want : 2);
3556
+ const gotText = Number.isFinite(got) ? got === 0 ? "none" : String(got) : received ?? "none";
3557
+ return line.text(`Expected ${wantText} ${noun2}, found ${gotText} \u2014 `).locator(opts.locator ?? parsed.locator).text(" toHaveCount");
3558
+ }
3559
+ const state = STATE_MATCHERS[matcher];
3560
+ if (state) {
3561
+ const unmet = parsed.negated ? `stayed ${state.met}` : state.unmet;
3562
+ if (!parsed.locator) return line.text(`Expected ${state.met}, page ${unmet}`).text(timeoutParen(parsed.timeoutMs));
3563
+ if (notFound && matcher !== "toBeVisible" && matcher !== "toBeAttached") {
3564
+ return subject(line, parsed.locator, opts).text(` was not found on the page \u2014 expected ${state.met}`).text(timeoutParen(parsed.timeoutMs));
3565
+ }
3566
+ return subject(line, parsed.locator, opts).text(` ${unmet}`).text(timeoutParen(parsed.timeoutMs));
3567
+ }
3568
+ const noun = VALUE_MATCHERS[matcher];
3569
+ if (noun) {
3570
+ if (parsed.locator && notFound) {
3571
+ return subject(line, parsed.locator, opts).text(" was not found on the page \u2014 expected ").text(`${noun} `).value(expected ?? "").text(timeoutParen(parsed.timeoutMs));
3572
+ }
3573
+ line.text(`Expected ${noun} `);
3574
+ if (expected) line.value(expected);
3575
+ else line.text("to match");
3576
+ if (received) line.text(", got ").value(received);
3577
+ if (parsed.locator)
3578
+ line.text(" \u2014 ").locator(opts.locator ?? parsed.locator).text(` ${matcher}`);
3579
+ else if (matcher === "toHaveURL" || matcher === "toHaveTitle") line.text(` \u2014 page ${matcher}`);
3580
+ return line;
3581
+ }
3582
+ if (matcher === "toPass") return line.text("expect.toPass never passed").text(timeoutParen(parsed.timeoutMs));
3583
+ if (expected || received) {
3584
+ line.text("Expected ");
3585
+ if (expected) line.value(expected);
3586
+ else line.text("a different value");
3587
+ if (received) line.text(", got ").value(received);
3588
+ line.text(` \u2014 ${matcher}`);
3589
+ if (parsed.locator) line.text(" on ").locator(opts.locator ?? parsed.locator);
3590
+ return line;
3591
+ }
3592
+ if (parsed.locator) {
3593
+ return subject(line, parsed.locator, opts).text(` failed ${matcher}`).text(timeoutParen(parsed.timeoutMs));
3594
+ }
3595
+ return line.text(`${matcher} assertion failed`);
3596
+ }
3597
+ function buildStrictMode(parsed, opts) {
3598
+ const line = new Line();
3599
+ const count = parsed.resolvedCount ?? 0;
3600
+ if (!parsed.locator) return line.text(`Locator matched ${count} elements \u2014 strict mode`);
3601
+ return line.locator(opts.locator ?? parsed.locator).text(` matched ${count} ${countNoun(parsed.locator, count)} \u2014 strict mode`);
3602
+ }
3603
+ function buildNavigation(parsed, opts) {
3604
+ const line = new Line();
3605
+ const code = parsed.networkErrorCode?.replace(/^net::/, "") ?? null;
3606
+ if (code) {
3607
+ const phrase = NETWORK_ERRORS[code] ?? `${code.replace(/^(?:ERR|NS_ERROR)_/, "").replace(/_/g, " ").toLowerCase().replace(/^\w/, (c) => c.toUpperCase())} loading`;
3608
+ line.text(phrase);
3609
+ if (parsed.url) line.text(" ").value(truncateValue(parsed.url, opts.valueMax * 2));
3610
+ return line;
3611
+ }
3612
+ const route = parsed.url ? routeOf(parsed.url) : null;
3613
+ const timedOut = parsed.timeoutMs !== null || parsed.errorName === "TimeoutError";
3614
+ if (parsed.action === "waitForURL" || parsed.action === "waitForNavigation") {
3615
+ line.text("Never navigated to ");
3616
+ if (route) line.value(truncateValue(route, opts.valueMax));
3617
+ else line.text("the expected URL");
3618
+ return line.text(` \u2014 ${parsed.action} timed out`).text(timeoutSuffix(parsed.timeoutMs));
3619
+ }
3620
+ line.text("Navigation");
3621
+ if (route) line.text(" to ").value(truncateValue(route, opts.valueMax));
3622
+ if (timedOut) return line.text(" timed out").text(timeoutSuffix(parsed.timeoutMs));
3623
+ return line.text(" failed");
3624
+ }
3625
+ function buildTestTimeout(parsed, opts, ctx) {
3626
+ const line = new Line().text("Test timed out").text(timeoutSuffix(parsed.timeoutMs));
3627
+ if (parsed.timeoutPhase) {
3628
+ const phase = parsed.timeoutPhase;
3629
+ const isHook = /^(?:before|after)(?:Each|All)$/.test(phase);
3630
+ return line.text(isHook ? ` in the "${phase}" hook` : ` while tearing down "${phase}"`);
3631
+ }
3632
+ if (parsed.isNavigationFailure && parsed.url) {
3633
+ return line.text(" while navigating to ").value(truncateValue(routeOf(parsed.url), opts.valueMax));
3634
+ }
3635
+ if (parsed.assertion && parsed.locator) {
3636
+ const state = STATE_MATCHERS[parsed.assertion];
3637
+ line.text(state ? ` while waiting for ` : " while expecting ");
3638
+ subject(line, parsed.locator, opts);
3639
+ return line.text(state ? ` to be ${state.met}` : ` ${parsed.assertion}`);
3640
+ }
3641
+ if (parsed.action && parsed.locator) {
3642
+ const gerund = ACTION_GERUNDS[parsed.action] ?? `${ACTION_VERBS[parsed.action] ?? parsed.action} on`;
3643
+ line.text(` while ${gerund} `);
3644
+ return subject(line, parsed.locator, opts);
3645
+ }
3646
+ if (parsed.subject && parsed.action) return line.text(` during ${parsed.subject}.${parsed.action}`);
3647
+ const step = ctx?.lastStepTitle?.trim();
3648
+ if (step) return line.text(" while ").value(`"${truncateValue(step, opts.valueMax * 1.5)}"`);
3649
+ return line;
3650
+ }
3651
+ function buildCrash(parsed, opts) {
3652
+ const line = new Line();
3653
+ const what = /Page crashed/i.test(parsed.messageHead) ? "Page crashed" : "Page or browser closed";
3654
+ if (parsed.action) {
3655
+ const verb = ACTION_VERBS[parsed.action] ?? parsed.action;
3656
+ line.text(`${what} during ${verb}`);
3657
+ if (parsed.locator) line.text(" on ").locator(opts.locator ?? parsed.locator);
3658
+ return line;
3659
+ }
3660
+ if (parsed.assertion && parsed.locator) {
3661
+ return line.text(`${what} while expecting `).locator(opts.locator ?? parsed.locator).text(` ${parsed.assertion}`);
3662
+ }
3663
+ return line.text(what);
3664
+ }
3665
+ function build(parsed, opts, ctx) {
3666
+ switch (parsed.kind) {
3667
+ case "action-timeout":
3668
+ return buildActionTimeout(parsed, opts);
3669
+ case "assertion":
3670
+ case "assertion-timeout":
3671
+ return buildAssertion(parsed, opts);
3672
+ case "strict-mode":
3673
+ return buildStrictMode(parsed, opts);
3674
+ case "navigation":
3675
+ return buildNavigation(parsed, opts);
3676
+ case "test-timeout":
3677
+ return buildTestTimeout(parsed, opts, ctx);
3678
+ case "crash":
3679
+ return buildCrash(parsed, opts);
3680
+ default:
3681
+ return new Line().text(firstLineFallback(parsed));
3682
+ }
3683
+ }
3684
+ function detailOf(parsed, headline) {
3685
+ switch (parsed.kind) {
3686
+ case "test-timeout":
3687
+ case "action-timeout": {
3688
+ const state = parsed.lastStateLine;
3689
+ if (!state || parsed.lastState === "not-found" || headline.includes(state)) return null;
3690
+ return truncateValue(state, HEADLINE_MAX_CHARS);
3691
+ }
3692
+ case "assertion":
3693
+ case "assertion-timeout": {
3694
+ const received = parsed.received;
3695
+ if (received && !/element\(s\) not found/.test(received)) {
3696
+ const shown = truncateValue(receivedDisplay(parsed), SHORT_VALUE_MAX_CHARS).slice(0, 8);
3697
+ if (!headline.includes(shown)) return truncateValue(`Received: ${received}`, HEADLINE_MAX_CHARS);
3698
+ }
3699
+ const state = parsed.lastStateLine;
3700
+ if (!state || /^(?:waiting for |unexpected value )/.test(state)) return null;
3701
+ return truncateValue(state, HEADLINE_MAX_CHARS);
3702
+ }
3703
+ case "navigation":
3704
+ return parsed.networkErrorCode ?? parsed.lastStateLine;
3705
+ case "strict-mode":
3706
+ case "crash":
3707
+ return null;
3708
+ default:
3709
+ return null;
3710
+ }
3711
+ }
3712
+ function describeFailure(parsed, ctx) {
3713
+ const attempts = [
3714
+ { locator: parsed.locator, valueMax: VALUE_MAX_CHARS },
3715
+ { locator: parsed.leafLocator ?? parsed.locator, valueMax: VALUE_MAX_CHARS },
3716
+ { locator: parsed.leafLocator ?? parsed.locator, valueMax: SHORT_VALUE_MAX_CHARS }
3717
+ ];
3718
+ let line = build(parsed, attempts[0], ctx);
3719
+ for (const opts of attempts.slice(1)) {
3720
+ if (line.toString().length <= HEADLINE_MAX_CHARS) break;
3721
+ line = build(parsed, opts, ctx);
3722
+ }
3723
+ let parts = line.parts.map((p) => ({ ...p, text: p.text.replace(MASK_TOKEN_RE, "\u2026") }));
3724
+ let headline = parts.map((p) => p.text).join("");
3725
+ if (headline.length > HEADLINE_MAX_CHARS) {
3726
+ headline = `${headline.slice(0, HEADLINE_MAX_CHARS - 1)}\u2026`;
3727
+ parts = clipParts(parts, HEADLINE_MAX_CHARS - 1);
3728
+ parts.push({ kind: "text", text: "\u2026" });
3729
+ }
3730
+ if (!headline.trim()) {
3731
+ headline = firstLineFallback(parsed);
3732
+ parts = [{ kind: "text", text: headline }];
3733
+ }
3734
+ return { headline, detail: detailOf(parsed, headline), parts };
3735
+ }
3736
+ function clipParts(parts, max) {
3737
+ const out = [];
3738
+ let used = 0;
3739
+ for (const part of parts) {
3740
+ if (used >= max) break;
3741
+ const text = part.text.slice(0, max - used);
3742
+ if (text) out.push({ kind: part.kind, text });
3743
+ used += text.length;
3744
+ }
3745
+ return out;
3746
+ }
3747
+ function describeFailureText(raw, ctx) {
3748
+ if (!raw || !raw.trim()) return null;
3749
+ return describeFailure(parsePlaywrightError(raw, { stepParams: ctx?.stepParams }), ctx);
3750
+ }
3751
+ function lastStepTitle(steps) {
3752
+ if (!steps || steps.length === 0) return null;
3753
+ const failed = steps.find((s) => s.failed);
3754
+ return (failed ?? steps[steps.length - 1])?.title ?? null;
3755
+ }
3756
+
3757
+ // src/internal/support/failure-links.ts
3758
+ function failureHeadline(error, steps) {
3759
+ return describeFailureText(error, { lastStepTitle: lastStepTitle(steps) })?.headline ?? null;
3760
+ }
3761
+ function caseLocateUrl(serverUrl, runId, test) {
3762
+ const params = [
3763
+ `file=${encodeURIComponent(test.file)}`,
3764
+ `title=${encodeURIComponent(test.title)}`,
3765
+ `retry=${test.retry}`
3766
+ ];
3767
+ if (test.browser) params.push(`browser=${encodeURIComponent(test.browser)}`);
3768
+ return `${serverUrl.replace(/\/+$/, "")}/test-runs/${runId}/locate?${params.join("&")}`;
3769
+ }
3770
+ function formatFailureLine(link) {
3771
+ const headline = link.headline ? ` \u2014 ${link.headline}` : "";
3772
+ return `\u2717 ${link.title}${headline} \u2192 ${link.url}`;
3773
+ }
3774
+ var FailureLinks = class {
3775
+ constructor(serverUrl, logger) {
3776
+ this.serverUrl = serverUrl;
3777
+ this.logger = logger;
3778
+ this.failures = [];
3779
+ this.printed = 0;
3780
+ }
3781
+ /** Number of failed tests recorded so far. */
3782
+ get count() {
3783
+ return this.failures.length;
3784
+ }
3785
+ add(test) {
3786
+ this.failures.push(test);
3787
+ }
3788
+ /** Every recorded failure with its link under `runId`. */
3789
+ resolve(runId) {
3790
+ return this.failures.map((test) => ({ ...test, url: caseLocateUrl(this.serverUrl, runId, test) }));
3791
+ }
3792
+ /** Print the lines that have not been printed yet. */
3793
+ printPending(runId) {
3794
+ const links = this.resolve(runId);
3795
+ for (const link of links.slice(this.printed)) this.logger.info(formatFailureLine(link));
3796
+ this.printed = links.length;
3797
+ }
3798
+ };
3799
+
2608
3800
  // src/public/reporter.ts
2609
3801
  function testLocation(test) {
2610
- const relativeFilePath = path14.relative(process.cwd(), test.location.file).split(path14.sep).join("/");
2611
- return `${relativeFilePath}:${test.location.line}:${test.location.column}`;
3802
+ return `${testFile(test)}:${test.location.line}:${test.location.column}`;
3803
+ }
3804
+ function testFile(test) {
3805
+ return path15.relative(process.cwd(), test.location.file).split(path15.sep).join("/");
2612
3806
  }
2613
3807
  var PiwiDashboardReporter = class _PiwiDashboardReporter {
2614
3808
  constructor(rawOptions = {}) {
@@ -2650,6 +3844,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2650
3844
  this.uploader = new Uploader(this.httpClient, this.fileHandler, logger);
2651
3845
  this.recovery = new CrashRecovery(this.options.projectName, logger);
2652
3846
  this.metadataCollector = new MetadataCollector(logger);
3847
+ this.failureLinks = new FailureLinks(this.httpClient.baseUrl, logger);
2653
3848
  const streamBuffer = new StreamBuffer(this.options.projectName);
2654
3849
  streamBuffer.clearStale();
2655
3850
  if (this.options.streaming) {
@@ -2663,7 +3858,14 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2663
3858
  logger
2664
3859
  );
2665
3860
  }
2666
- this.submitter = new RunSubmitter(this.httpClient, this.uploader, this.recovery, this.streamManager, logger);
3861
+ this.submitter = new RunSubmitter(
3862
+ this.httpClient,
3863
+ this.uploader,
3864
+ this.recovery,
3865
+ this.streamManager,
3866
+ logger,
3867
+ this.failureLinks
3868
+ );
2667
3869
  }
2668
3870
  static {
2669
3871
  this.wrapConfig = wrapConfig;
@@ -2686,6 +3888,12 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2686
3888
  this.logger.info(
2687
3889
  `Starting test run for project: ${this.options.projectName} (Playwright v${this.playwrightVersion})`
2688
3890
  );
3891
+ const defaulted = process.env[PIWI_DEFAULTED_CAPTURE_ENV];
3892
+ if (defaulted) {
3893
+ this.logger.info(
3894
+ `Defaulted Playwright ${defaulted} for failure evidence (set defaultCapture: false to opt out).`
3895
+ );
3896
+ }
2689
3897
  const rawConfig = config;
2690
3898
  const grepRe = rawConfig.grep instanceof RegExp ? rawConfig.grep : void 0;
2691
3899
  const grepInvertRe = rawConfig.grepInvert instanceof RegExp ? rawConfig.grepInvert : void 0;
@@ -2756,6 +3964,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2756
3964
  const event = {
2757
3965
  type: "step-begin",
2758
3966
  title: step.title,
3967
+ subtitle: typeof step.subtitle === "string" && step.subtitle.length > 0 ? step.subtitle : null,
2759
3968
  location: step.location ? `${step.location.file}:${step.location.line}:${step.location.column}` : "unknown",
2760
3969
  stepCategory: cat,
2761
3970
  parentTitle: test?.title || null,
@@ -2774,6 +3983,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2774
3983
  const event = {
2775
3984
  type: "step-end",
2776
3985
  title: step.title,
3986
+ subtitle: typeof step.subtitle === "string" && step.subtitle.length > 0 ? step.subtitle : null,
2777
3987
  location: step.location ? `${step.location.file}:${step.location.line}:${step.location.column}` : "unknown",
2778
3988
  status: step.error ? "failed" : "passed",
2779
3989
  duration: step.duration || 0,
@@ -2803,6 +4013,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2803
4013
  const annotations = mergeAnnotations(test, result);
2804
4014
  const status = classifyStatus(result.status, annotations);
2805
4015
  const tags = collectTestTags(test);
4016
+ const locks = collectTestLocks(test);
2806
4017
  const attempts = this.attemptsByTest.get(test.id) ?? [];
2807
4018
  attempts.push({
2808
4019
  retry: result.retry,
@@ -2832,6 +4043,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2832
4043
  suiteConfig,
2833
4044
  testAnnotations: annotations.length ? annotations : null,
2834
4045
  tags: tags.length ? tags : null,
4046
+ locks: locks.length ? locks : null,
2835
4047
  testMeta: collectTestMetadata(annotations),
2836
4048
  // An annotation-less skip reclassified to `didnotrun` is a serial-group
2837
4049
  // cascade: an earlier test failed and Playwright skipped the rest.
@@ -2880,6 +4092,18 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2880
4092
  }
2881
4093
  this.testCases.push(testCase);
2882
4094
  if (status === "didnotrun") linkBlockedTests(this.testCases);
4095
+ const isFailure = status === "failed" || status === "timedOut";
4096
+ if (isFailure && result.retry >= (test.retries ?? 0)) {
4097
+ this.failureLinks.add({
4098
+ title: test.title,
4099
+ file: testFile(test),
4100
+ retry: result.retry,
4101
+ browser: typeof testCase.browser?.projectName === "string" ? testCase.browser.projectName : null,
4102
+ headline: failureHeadline(testCase.error, testCase.performanceMetrics?.steps)
4103
+ });
4104
+ }
4105
+ const liveRunId = this.streamManager?.runId;
4106
+ if (liveRunId != null) this.failureLinks.printPending(liveRunId);
2883
4107
  if (this.streamManager) {
2884
4108
  this.streamManager.queueEvent(toWireTestCase(testCase));
2885
4109
  if (this.options.liveFileUploads) this.streamManager.scheduleLiveUpload(testCase);
@@ -2899,6 +4123,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2899
4123
  const { suitePath, suiteConfig } = this.metadataCollector.getSuiteInfo(test);
2900
4124
  const declaredAnnotations = test.annotations ?? [];
2901
4125
  const tags = collectTestTags(test);
4126
+ const locks = collectTestLocks(test);
2902
4127
  const testCase = {
2903
4128
  type: "complete",
2904
4129
  title: test.title,
@@ -2917,6 +4142,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2917
4142
  suiteConfig,
2918
4143
  testAnnotations: declaredAnnotations.length ? declaredAnnotations : null,
2919
4144
  tags: tags.length ? tags : null,
4145
+ locks: locks.length ? locks : null,
2920
4146
  testMeta: collectTestMetadata(declaredAnnotations),
2921
4147
  didNotRunReason: reason
2922
4148
  };
@@ -2936,28 +4162,32 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2936
4162
  failures: this.failedTests + this.timedOutTests
2937
4163
  });
2938
4164
  this.materializeUnrunTests(unrunReason);
2939
- await this.submitter.submit(
2940
- {
2941
- options: this.options,
2942
- testCases: this.testCases,
2943
- startTime: this.startTime,
2944
- playwrightVersion: this.playwrightVersion,
2945
- reporterVersion: this.reporterVersion,
2946
- totalTests: this.totalTests,
2947
- passedTests: this.passedTests,
2948
- failedTests: this.failedTests,
2949
- skippedTests: this.skippedTests,
2950
- timedOutTests: this.timedOutTests,
2951
- didNotRunTests: this.didNotRunTests,
2952
- metadata: this.metadata,
2953
- instanceId: this.instanceId,
2954
- shardInfo: this.shardInfo,
2955
- setupSteps: this.setupSteps,
2956
- isFullRun: this.isFullRun,
2957
- filterDetails: this.filterDetails
2958
- },
2959
- result
2960
- );
4165
+ try {
4166
+ await this.submitter.submit(
4167
+ {
4168
+ options: this.options,
4169
+ testCases: this.testCases,
4170
+ startTime: this.startTime,
4171
+ playwrightVersion: this.playwrightVersion,
4172
+ reporterVersion: this.reporterVersion,
4173
+ totalTests: this.totalTests,
4174
+ passedTests: this.passedTests,
4175
+ failedTests: this.failedTests,
4176
+ skippedTests: this.skippedTests,
4177
+ timedOutTests: this.timedOutTests,
4178
+ didNotRunTests: this.didNotRunTests,
4179
+ metadata: this.metadata,
4180
+ instanceId: this.instanceId,
4181
+ shardInfo: this.shardInfo,
4182
+ setupSteps: this.setupSteps,
4183
+ isFullRun: this.isFullRun,
4184
+ filterDetails: this.filterDetails
4185
+ },
4186
+ result
4187
+ );
4188
+ } finally {
4189
+ this.fileHandler.cleanupBodyAttachments();
4190
+ }
2961
4191
  }
2962
4192
  };
2963
4193
 
@@ -3165,15 +4395,24 @@ function probeElementAttrs(el, arg) {
3165
4395
  let index = -1;
3166
4396
  let levelCount = 0;
3167
4397
  let roleNameCount = 0;
4398
+ let visibleRoleNameCount = 0;
3168
4399
  for (let i = 0; i < nodes.length; i++) {
3169
4400
  const n = nodes[i];
3170
4401
  if (roleOf(n) !== targetRole) continue;
3171
4402
  if (n === el) index = roleCountAll;
3172
4403
  roleCountAll++;
3173
4404
  if (targetLevel != null && levelOf(n) === targetLevel) levelCount++;
3174
- if (targetName != null && nameOf(n) === targetName) roleNameCount++;
4405
+ if (targetName != null && nameOf(n) === targetName) {
4406
+ roleNameCount++;
4407
+ const node = n;
4408
+ const box = typeof node.getBoundingClientRect === "function" ? node.getBoundingClientRect() : null;
4409
+ if (node.offsetParent != null || !!box && box.width > 0 && box.height > 0) visibleRoleNameCount++;
4410
+ }
4411
+ }
4412
+ if (targetName != null) {
4413
+ selectorCounts.roleName = roleNameCount;
4414
+ selectorCounts.visibleRoleName = visibleRoleNameCount;
3175
4415
  }
3176
- if (targetName != null) selectorCounts.roleName = roleNameCount;
3177
4416
  if (index !== -1) {
3178
4417
  rolePosition = {
3179
4418
  role: targetRole,
@@ -4384,7 +5623,7 @@ function mergeCandidates(base, extra) {
4384
5623
  }
4385
5624
 
4386
5625
  // src/internal/capture/locator-healing.ts
4387
- var path15 = __toESM(require("path"));
5626
+ var path16 = __toESM(require("path"));
4388
5627
 
4389
5628
  // ../core/src/locator-fingerprint.ts
4390
5629
  var ELEMENT_MATCH_SCORES = { role: 60, text: 55, label: 50 };
@@ -4508,18 +5747,6 @@ function freshLocatorsFromCandidate(c) {
4508
5747
  return out;
4509
5748
  }
4510
5749
 
4511
- // ../core/src/locator-methods.ts
4512
- var LOCATOR_BUILDER_METHODS = [
4513
- "getByRole",
4514
- "getByTestId",
4515
- "getByText",
4516
- "getByLabel",
4517
- "getByPlaceholder",
4518
- "getByAltText",
4519
- "getByTitle",
4520
- "locator"
4521
- ];
4522
-
4523
5750
  // src/internal/capture/locator-healing.ts
4524
5751
  function dedupeSnapshotsByLocation(snaps) {
4525
5752
  const lastWithElement = /* @__PURE__ */ new Map();
@@ -4540,6 +5767,7 @@ var CHAIN_METHODS = [
4540
5767
  "nth",
4541
5768
  "last",
4542
5769
  "filter",
5770
+ "visible",
4543
5771
  "and",
4544
5772
  "or",
4545
5773
  "locator",
@@ -4765,10 +5993,10 @@ function captureCallerLocation(stack = new Error().stack ?? "") {
4765
5993
  }
4766
5994
  let rel = file;
4767
5995
  try {
4768
- rel = path15.relative(process.cwd(), file);
5996
+ rel = path16.relative(process.cwd(), file);
4769
5997
  } catch {
4770
5998
  }
4771
- rel = rel.split(path15.sep).join("/");
5999
+ rel = rel.split(path16.sep).join("/");
4772
6000
  if (rel.startsWith("./")) rel = rel.slice(2);
4773
6001
  return `${rel}:${m[3]}:${m[4]}`;
4774
6002
  }
@@ -4811,8 +6039,8 @@ function inspectionGateFromTestInfo(testInfo, enabled = process.env.PIWI_INSPECT
4811
6039
  }
4812
6040
 
4813
6041
  // src/internal/capture/pick-on-failure.ts
4814
- var path16 = __toESM(require("path"));
4815
- var ANSI_RE = /\[[0-9;]*m/g;
6042
+ var path17 = __toESM(require("path"));
6043
+ var ANSI_RE2 = /\[[0-9;]*m/g;
4816
6044
  function endOfString(s, start) {
4817
6045
  const q = s[start];
4818
6046
  for (let i = start + 1; i < s.length; i++) {
@@ -4899,13 +6127,13 @@ function deriveFailedLocator(testInfo) {
4899
6127
  const errors = info.errors && info.errors.length > 0 ? info.errors : info.error ? [info.error] : [];
4900
6128
  for (const err of errors) {
4901
6129
  const text = `${err.message ?? ""}
4902
- ${err.stack ?? ""}`.replace(ANSI_RE, "");
6130
+ ${err.stack ?? ""}`.replace(ANSI_RE2, "");
4903
6131
  const line = /^\s*Locator:\s*(.+)$/m.exec(text);
4904
6132
  if (!line) continue;
4905
6133
  const parsed = parseLeafLocatorExpression(line[1].trim());
4906
6134
  if (!parsed) continue;
4907
6135
  const loc = err.location;
4908
- const location = loc ? `${path16.relative(process.cwd(), loc.file).split(path16.sep).join("/")}:${loc.line}:${loc.column}` : null;
6136
+ const location = loc ? `${path17.relative(process.cwd(), loc.file).split(path17.sep).join("/")}:${loc.line}:${loc.column}` : null;
4909
6137
  return { method: parsed.method, args: parsed.args, location };
4910
6138
  }
4911
6139
  return null;
@@ -5087,6 +6315,7 @@ function createSink() {
5087
6315
  return {
5088
6316
  networkRequests: [],
5089
6317
  consoleEntries: [],
6318
+ dialogs: [],
5090
6319
  pendingHandlers: [],
5091
6320
  capturedLocators: [],
5092
6321
  capturePromises: [],
@@ -5097,6 +6326,7 @@ function createSink() {
5097
6326
  stashedWebVitals: null,
5098
6327
  stashedPageState: null,
5099
6328
  stashedAria: null,
6329
+ stashedAriaJson: null,
5100
6330
  pickOffered: false,
5101
6331
  userPick: null
5102
6332
  };
@@ -5285,9 +6515,17 @@ async function stashPageState(sink, closing) {
5285
6515
  if (pageState) sink.stashedPageState = pageState;
5286
6516
  }
5287
6517
  const status = sink.testInfo?.status;
5288
- if (status === "failed" || status === "timedOut" || status === "interrupted") {
5289
- const aria = await ariaSnapshotBestEffort(page.locator(":root"), 1e3);
6518
+ const sampleAria = async () => {
6519
+ const root = page.locator(":root");
6520
+ const aria = await ariaSnapshotBestEffort(root, 1e3);
5290
6521
  if (aria) sink.stashedAria = aria;
6522
+ const ariaJson = await ariaSnapshotJSONBestEffort(root, 1e3);
6523
+ if (ariaJson) sink.stashedAriaJson = ariaJson;
6524
+ };
6525
+ if (status === "failed" || status === "timedOut" || status === "interrupted") {
6526
+ await sampleAria();
6527
+ } else if (status === "passed" && process.env.PIWI_SAMPLE_ARIA_ON_PASS !== "false" && sink.testInfo && isDueForAriaSample(sink.testInfo)) {
6528
+ await sampleAria();
5291
6529
  }
5292
6530
  }
5293
6531
  async function maybeOpenPicker(sink, closing) {
@@ -5318,6 +6556,7 @@ var INSTRUMENTED_CONTEXTS = /* @__PURE__ */ new WeakSet();
5318
6556
  var PATCHED_BROWSERS = /* @__PURE__ */ new WeakSet();
5319
6557
  var CHAIN_METHOD_SET = new Set(CHAIN_METHODS);
5320
6558
  var ACTION_METHOD_SET = new Set(ACTION_METHODS);
6559
+ var LOCATOR_METHOD_SET = new Set(LOCATOR_METHODS);
5321
6560
  var CAPTURED_ATTRS_ARG = {
5322
6561
  keep: [...CAPTURED_ATTRIBUTES],
5323
6562
  tagRoles: TAG_TO_ROLE,
@@ -5370,6 +6609,16 @@ async function ariaSnapshotBestEffort(target, timeout) {
5370
6609
  }
5371
6610
  }
5372
6611
  }
6612
+ async function ariaSnapshotJSONBestEffort(target, timeout) {
6613
+ const fn = target.ariaSnapshotJSON;
6614
+ if (typeof fn !== "function") return null;
6615
+ try {
6616
+ const tree = await fn.call(target, timeout != null ? { timeout } : {});
6617
+ return tree == null ? null : JSON.stringify(tree);
6618
+ } catch {
6619
+ return null;
6620
+ }
6621
+ }
5373
6622
  function startElementCapture(sink, page, target, seq, callerLocation, used) {
5374
6623
  const probe = probeElement(page, target);
5375
6624
  const settledProbe = probe.then(
@@ -5504,6 +6753,20 @@ function wrapLocator(page, locator, originMethod, originArgs) {
5504
6753
  }
5505
6754
  });
5506
6755
  }
6756
+ function wrapFrameLocator(page, frameLocator) {
6757
+ return new Proxy(frameLocator, {
6758
+ get(target, prop) {
6759
+ const original = Reflect.get(target, prop);
6760
+ if (typeof original !== "function") return original;
6761
+ const fn = original;
6762
+ if (!LOCATOR_METHOD_SET.has(prop)) return original;
6763
+ return (...args) => {
6764
+ if (currentSink) currentSink.lastActivePage = page;
6765
+ return wrapLocator(page, fn.apply(target, args), String(prop), args);
6766
+ };
6767
+ }
6768
+ });
6769
+ }
5507
6770
  function instrumentPage(page) {
5508
6771
  if (!page || INSTRUMENTED_PAGES.has(page)) return;
5509
6772
  INSTRUMENTED_PAGES.add(page);
@@ -5531,6 +6794,13 @@ function instrumentPage(page) {
5531
6794
  return wrapLocator(page, original(...args), method, args);
5532
6795
  };
5533
6796
  }
6797
+ const originalFrameLocator = typeof page.frameLocator === "function" ? page.frameLocator.bind(page) : null;
6798
+ if (originalFrameLocator) {
6799
+ page.frameLocator = (...args) => {
6800
+ const frame = originalFrameLocator(...args);
6801
+ return args.length === 0 ? wrapFrameLocator(page, frame) : frame;
6802
+ };
6803
+ }
5534
6804
  if (typeof page.on === "function") {
5535
6805
  page.on("framenavigated", () => PROBE_UNSEEDED_PAGES.delete(page));
5536
6806
  }
@@ -5549,6 +6819,24 @@ function instrumentPage(page) {
5549
6819
  });
5550
6820
  }
5551
6821
  });
6822
+ if (typeof page.on === "function") {
6823
+ try {
6824
+ page.on("dialogclosed", (dialog) => {
6825
+ const sink = currentSink;
6826
+ if (!sink) return;
6827
+ try {
6828
+ sink.dialogs.push({
6829
+ type: typeof dialog.type === "function" ? dialog.type() : null,
6830
+ message: typeof dialog.message === "function" ? dialog.message() : null,
6831
+ defaultValue: typeof dialog.defaultValue === "function" ? dialog.defaultValue() || null : null,
6832
+ closedAt: Date.now()
6833
+ });
6834
+ } catch {
6835
+ }
6836
+ });
6837
+ } catch {
6838
+ }
6839
+ }
5552
6840
  page.on("requestfinished", (request) => {
5553
6841
  const sink = currentSink;
5554
6842
  if (!sink) return;
@@ -5675,6 +6963,13 @@ async function flushSink(sink, testInfo) {
5675
6963
  contentType: "text/plain",
5676
6964
  body: snapshot
5677
6965
  });
6966
+ const snapshotJson = (pageReadable ? await ariaSnapshotJSONBestEffort(page.locator(":root")) : null) ?? sink.stashedAriaJson;
6967
+ if (snapshotJson) {
6968
+ await testInfo.attach(ATTACHMENT_NAMES.ariaSnapshotJson, {
6969
+ contentType: "application/json",
6970
+ body: snapshotJson
6971
+ });
6972
+ }
5678
6973
  const failed = sink.failedLocators[sink.failedLocators.length - 1];
5679
6974
  const suggestion = failed ? suggestLocatorsFromAria(failed, snapshot) : null;
5680
6975
  if (suggestion) {
@@ -5691,6 +6986,25 @@ async function flushSink(sink, testInfo) {
5691
6986
  } catch {
5692
6987
  }
5693
6988
  }
6989
+ if (testInfo.status === "passed" && process.env.PIWI_SAMPLE_ARIA_ON_PASS !== "false" && isDueForAriaSample(testInfo)) {
6990
+ try {
6991
+ const snapshot = (pageReadable ? await ariaSnapshotBestEffort(page.locator(":root")) : null) ?? sink.stashedAria;
6992
+ if (snapshot) {
6993
+ await testInfo.attach(ATTACHMENT_NAMES.ariaSnapshot, {
6994
+ contentType: "text/plain",
6995
+ body: snapshot
6996
+ });
6997
+ const snapshotJson = (pageReadable ? await ariaSnapshotJSONBestEffort(page.locator(":root")) : null) ?? sink.stashedAriaJson;
6998
+ if (snapshotJson) {
6999
+ await testInfo.attach(ATTACHMENT_NAMES.ariaSnapshotJson, {
7000
+ contentType: "application/json",
7001
+ body: snapshotJson
7002
+ });
7003
+ }
7004
+ }
7005
+ } catch {
7006
+ }
7007
+ }
5694
7008
  if (sink.userPick) {
5695
7009
  const pick = sink.userPick;
5696
7010
  testInfo.annotations.push({
@@ -5714,6 +7028,12 @@ async function flushSink(sink, testInfo) {
5714
7028
  body: Buffer.from(JSON.stringify(sink.consoleEntries))
5715
7029
  });
5716
7030
  }
7031
+ if (sink.dialogs.length > 0) {
7032
+ await testInfo.attach(ATTACHMENT_NAMES.dialogs, {
7033
+ contentType: "application/json",
7034
+ body: Buffer.from(JSON.stringify(sink.dialogs))
7035
+ });
7036
+ }
5717
7037
  if (sink.networkRequests.length > 0) {
5718
7038
  await testInfo.attach(ATTACHMENT_NAMES.network, {
5719
7039
  contentType: "application/json",
@@ -5777,15 +7097,15 @@ function extendPiwiFixtures(test) {
5777
7097
  }
5778
7098
 
5779
7099
  // src/internal/ai/ai-fixtures.ts
5780
- var fs15 = __toESM(require("fs"));
5781
- var path19 = __toESM(require("path"));
7100
+ var fs16 = __toESM(require("fs"));
7101
+ var path20 = __toESM(require("path"));
5782
7102
  var import_test = require("@playwright/test");
5783
7103
 
5784
7104
  // src/internal/ai/artifact.ts
5785
- var fs14 = __toESM(require("fs"));
5786
- var path17 = __toESM(require("path"));
7105
+ var fs15 = __toESM(require("fs"));
7106
+ var path18 = __toESM(require("path"));
5787
7107
  var ARTIFACT_VERSION = 1;
5788
- var LOCATOR_METHOD_SET = new Set(LOCATOR_METHODS);
7108
+ var LOCATOR_METHOD_SET2 = new Set(LOCATOR_METHODS);
5789
7109
  var ACTION_METHOD_SET2 = new Set(ACTION_METHODS);
5790
7110
  var POSTCONDITION_ASSERTS = /* @__PURE__ */ new Set([
5791
7111
  "visible",
@@ -5818,7 +7138,7 @@ function validateStructuredLocator(value, where) {
5818
7138
  assert(value !== null && typeof value === "object", `${where}: locator must be an object`);
5819
7139
  const loc = value;
5820
7140
  assert(typeof loc.method === "string", `${where}: locator.method must be a string`);
5821
- assert(LOCATOR_METHOD_SET.has(loc.method), `${where}: locator method "${String(loc.method)}" is not allowlisted`);
7141
+ assert(LOCATOR_METHOD_SET2.has(loc.method), `${where}: locator method "${String(loc.method)}" is not allowlisted`);
5822
7142
  assert(Array.isArray(loc.args), `${where}: locator.args must be an array`);
5823
7143
  if (loc.chain !== void 0) {
5824
7144
  assert(Array.isArray(loc.chain), `${where}: locator.chain must be an array`);
@@ -5876,7 +7196,7 @@ function parseEntry(text) {
5876
7196
  function readEntry(file) {
5877
7197
  let text;
5878
7198
  try {
5879
- text = fs14.readFileSync(file, "utf8");
7199
+ text = fs15.readFileSync(file, "utf8");
5880
7200
  } catch (error) {
5881
7201
  if (error.code === "ENOENT") return null;
5882
7202
  throw error;
@@ -5886,28 +7206,28 @@ function readEntry(file) {
5886
7206
  function writeEntry(file, entry) {
5887
7207
  const canonical = serializeEntry(entry);
5888
7208
  return withEntryLock(file, () => {
5889
- if (fs14.existsSync(file) && fs14.readFileSync(file, "utf8") === canonical) return { written: false };
5890
- fs14.mkdirSync(path17.dirname(file), { recursive: true });
7209
+ if (fs15.existsSync(file) && fs15.readFileSync(file, "utf8") === canonical) return { written: false };
7210
+ fs15.mkdirSync(path18.dirname(file), { recursive: true });
5891
7211
  const tmp = `${file}.${process.pid}.tmp`;
5892
- fs14.writeFileSync(tmp, canonical);
5893
- fs14.renameSync(tmp, file);
7212
+ fs15.writeFileSync(tmp, canonical);
7213
+ fs15.renameSync(tmp, file);
5894
7214
  return { written: true };
5895
7215
  });
5896
7216
  }
5897
7217
  function withEntryLock(file, fn) {
5898
7218
  const lock = `${file}.lock`;
5899
- fs14.mkdirSync(path17.dirname(file), { recursive: true });
7219
+ fs15.mkdirSync(path18.dirname(file), { recursive: true });
5900
7220
  const deadline = Date.now() + 5e3;
5901
7221
  for (; ; ) {
5902
7222
  try {
5903
- const fd = fs14.openSync(lock, "wx");
5904
- fs14.closeSync(fd);
7223
+ const fd = fs15.openSync(lock, "wx");
7224
+ fs15.closeSync(fd);
5905
7225
  break;
5906
7226
  } catch (error) {
5907
7227
  if (error.code !== "EEXIST") throw error;
5908
7228
  if (Date.now() > deadline) {
5909
7229
  try {
5910
- fs14.unlinkSync(lock);
7230
+ fs15.unlinkSync(lock);
5911
7231
  } catch {
5912
7232
  }
5913
7233
  }
@@ -5917,7 +7237,7 @@ function withEntryLock(file, fn) {
5917
7237
  return fn();
5918
7238
  } finally {
5919
7239
  try {
5920
- fs14.unlinkSync(lock);
7240
+ fs15.unlinkSync(lock);
5921
7241
  } catch {
5922
7242
  }
5923
7243
  }
@@ -5925,7 +7245,7 @@ function withEntryLock(file, fn) {
5925
7245
 
5926
7246
  // src/internal/ai/keys.ts
5927
7247
  var crypto3 = __toESM(require("crypto"));
5928
- var path18 = __toESM(require("path"));
7248
+ var path19 = __toESM(require("path"));
5929
7249
  var DEFAULT_AI_DIR = "__piwi__";
5930
7250
  function normalizeTemplate(template) {
5931
7251
  return template.trim().replace(/\s+/g, " ").toLowerCase();
@@ -5939,13 +7259,13 @@ function slug(text, maxLength = 40) {
5939
7259
  return (base || "x").slice(0, maxLength).replace(/-+$/g, "") || "x";
5940
7260
  }
5941
7261
  function entryDir(specFile, dir = DEFAULT_AI_DIR) {
5942
- return path18.join(path18.dirname(specFile), dir, path18.basename(specFile));
7262
+ return path19.join(path19.dirname(specFile), dir, path19.basename(specFile));
5943
7263
  }
5944
7264
  function entryPath(params) {
5945
7265
  const basis = `${normalizeTemplate(params.testTitle)}::${normalizeTemplate(params.template)}`;
5946
7266
  const hash = hashTemplate(basis, params.ordinal ?? 0);
5947
7267
  const name = `${slug(params.testTitle)}.${slug(params.template)}.${hash}.json`;
5948
- return path18.join(entryDir(params.specFile, params.dir), name);
7268
+ return path19.join(entryDir(params.specFile, params.dir), name);
5949
7269
  }
5950
7270
  function parseLocation(location) {
5951
7271
  const match = /:(\d+):(\d+)$/.exec(location);
@@ -6036,7 +7356,7 @@ function isParametric(template, compiledText) {
6036
7356
  }
6037
7357
 
6038
7358
  // src/internal/ai/interpreter.ts
6039
- var LOCATOR_METHOD_SET2 = new Set(LOCATOR_METHODS);
7359
+ var LOCATOR_METHOD_SET3 = new Set(LOCATOR_METHODS);
6040
7360
  var ACTION_METHOD_SET3 = new Set(ACTION_METHODS);
6041
7361
  var StepDriftError = class extends Error {
6042
7362
  constructor(step, fingerprint) {
@@ -6074,7 +7394,7 @@ function describePostcondition(post) {
6074
7394
  return post.assert === "url" ? `url \u2192 ${post.url ?? ""}` : `${describeLocator(post.locator)} ${post.assert}`;
6075
7395
  }
6076
7396
  function buildLocator(root, structured, params = {}) {
6077
- if (!LOCATOR_METHOD_SET2.has(structured.method)) {
7397
+ if (!LOCATOR_METHOD_SET3.has(structured.method)) {
6078
7398
  throw new Error(`piwi AI: locator method "${structured.method}" is not allowlisted`);
6079
7399
  }
6080
7400
  const args = substituteArgs(structured.args, params);
@@ -6399,7 +7719,7 @@ function testIdentity(testInfo) {
6399
7719
  return titles.length > 0 ? titles.join(" \u203A ") : testInfo.title;
6400
7720
  }
6401
7721
  function relativeToCwd(file) {
6402
- return path19.relative(process.cwd(), file).split(path19.sep).join("/");
7722
+ return path20.relative(process.cwd(), file).split(path20.sep).join("/");
6403
7723
  }
6404
7724
  function recordIntents(intents, entry) {
6405
7725
  const add = (locator, kind) => {
@@ -6426,7 +7746,7 @@ function createAiApi(page, testInfo, config, used, intents) {
6426
7746
  const readSource = () => {
6427
7747
  if (source === null) {
6428
7748
  try {
6429
- source = fs15.readFileSync(testInfo.file, "utf8");
7749
+ source = fs16.readFileSync(testInfo.file, "utf8");
6430
7750
  } catch {
6431
7751
  source = "";
6432
7752
  }