@piwitests/reporter 0.26.1 → 0.28.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,9 +77,12 @@ 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,
85
+ maxStreamBufferBytes: 100 * 1024 * 1024,
83
86
  failOnFlakyTests: false,
84
87
  username: null,
85
88
  password: null,
@@ -99,6 +102,7 @@ var PIWI_ENV_KEYS = {
99
102
  streaming: "PIWI_STREAMING",
100
103
  streamingBatchSize: "PIWI_STREAMING_BATCH_SIZE",
101
104
  streamingBatchDelay: "PIWI_STREAMING_BATCH_DELAY",
105
+ maxStreamBufferBytes: "PIWI_MAX_STREAM_BUFFER_BYTES",
102
106
  liveFileUploads: "PIWI_LIVE_FILE_UPLOADS",
103
107
  failOnFlakyTests: "PIWI_FAIL_ON_FLAKY_TESTS",
104
108
  uploadTraces: "PIWI_UPLOAD_TRACES",
@@ -106,6 +110,8 @@ var PIWI_ENV_KEYS = {
106
110
  captureLocators: "PIWI_CAPTURE_LOCATORS",
107
111
  capturePageState: "PIWI_CAPTURE_PAGE_STATE",
108
112
  captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
113
+ sampleAriaOnPass: "PIWI_SAMPLE_ARIA_ON_PASS",
114
+ defaultCapture: "PIWI_DEFAULT_CAPTURE",
109
115
  inspectOnFailure: "PIWI_INSPECT_ON_FAIL",
110
116
  pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL",
111
117
  outputFile: "PIWI_OUTPUT_FILE",
@@ -119,6 +125,7 @@ var PIWI_ENV_KEYS = {
119
125
  aiScreenshotFallback: "PIWI_AI_SCREENSHOT_FALLBACK"
120
126
  };
121
127
  var PIWI_DESKTOP_CONFIG_ENV = "PIWI_DESKTOP_CONFIG";
128
+ var PIWI_DEFAULTED_CAPTURE_ENV = "PIWI_DEFAULTED_CAPTURE";
122
129
  var PIWI_SELECTION_ENV = {
123
130
  key: "PIWI_SELECTION",
124
131
  version: "PIWI_SELECTION_VERSION",
@@ -141,6 +148,7 @@ var ENV_FALLBACK_SPECS = [
141
148
  { option: "streaming", env: PIWI_ENV_KEYS.streaming, kind: "bool" },
142
149
  { option: "streamingBatchSize", env: PIWI_ENV_KEYS.streamingBatchSize, kind: "number" },
143
150
  { option: "streamingBatchDelay", env: PIWI_ENV_KEYS.streamingBatchDelay, kind: "number" },
151
+ { option: "maxStreamBufferBytes", env: PIWI_ENV_KEYS.maxStreamBufferBytes, kind: "number" },
144
152
  { option: "liveFileUploads", env: PIWI_ENV_KEYS.liveFileUploads, kind: "bool" },
145
153
  { option: "failOnFlakyTests", env: PIWI_ENV_KEYS.failOnFlakyTests, kind: "bool" },
146
154
  { option: "uploadTraces", env: PIWI_ENV_KEYS.uploadTraces, kind: "bool" },
@@ -148,6 +156,8 @@ var ENV_FALLBACK_SPECS = [
148
156
  { option: "captureLocators", env: PIWI_ENV_KEYS.captureLocators, kind: "bool" },
149
157
  { option: "capturePageState", env: PIWI_ENV_KEYS.capturePageState, kind: "bool" },
150
158
  { option: "captureServerTraces", env: PIWI_ENV_KEYS.captureServerTraces, kind: "bool" },
159
+ { option: "sampleAriaOnPass", env: PIWI_ENV_KEYS.sampleAriaOnPass, kind: "bool" },
160
+ { option: "defaultCapture", env: PIWI_ENV_KEYS.defaultCapture, kind: "bool" },
151
161
  { option: "inspectOnFailure", env: PIWI_ENV_KEYS.inspectOnFailure, kind: "bool" },
152
162
  { option: "pickLocatorOnFailure", env: PIWI_ENV_KEYS.pickLocatorOnFailure, kind: "bool" },
153
163
  { option: "outputFile", env: PIWI_ENV_KEYS.outputFile, kind: "string" }
@@ -203,6 +213,8 @@ function applyOptionsToEnv(options) {
203
213
  if (options.captureServerTraces === false || options.collectPerformanceMetrics === false)
204
214
  env[PIWI_ENV_KEYS.captureServerTraces] = "false";
205
215
  else if (options.captureServerTraces === true) env[PIWI_ENV_KEYS.captureServerTraces] = "true";
216
+ if (options.sampleAriaOnPass === false) env[PIWI_ENV_KEYS.sampleAriaOnPass] = "false";
217
+ else if (options.sampleAriaOnPass === true) env[PIWI_ENV_KEYS.sampleAriaOnPass] = "true";
206
218
  if (options.inspectOnFailure !== void 0) env[PIWI_ENV_KEYS.inspectOnFailure] = String(options.inspectOnFailure);
207
219
  if (options.pickLocatorOnFailure !== void 0)
208
220
  env[PIWI_ENV_KEYS.pickLocatorOnFailure] = String(options.pickLocatorOnFailure);
@@ -342,6 +354,29 @@ var HttpClient = class {
342
354
  this.logger.debug("Logged in successfully");
343
355
  return cookie;
344
356
  }
357
+ /**
358
+ * Send a JSON GET request, returning the parsed body, or `null` on any non-2xx
359
+ * status or parse failure. Unlike `postJSON` this never throws — its callers
360
+ * treat a missing or unreachable endpoint as "feature unavailable".
361
+ */
362
+ async getJSON(pathname, auth) {
363
+ let res;
364
+ try {
365
+ res = await this.request("GET", pathname, { auth });
366
+ } catch (error) {
367
+ this.logger.debug(`GET ${pathname} failed: ${error.message}`);
368
+ return null;
369
+ }
370
+ if (res.status < 200 || res.status >= 300) {
371
+ this.logger.debug(`GET ${pathname} returned ${res.status}`);
372
+ return null;
373
+ }
374
+ try {
375
+ return JSON.parse(res.text);
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
345
380
  /** Send a JSON POST request. `auth` can be an API key (prefix `pd_`) or a session cookie string. */
346
381
  async postJSON(pathname, payload, auth) {
347
382
  const body = JSON.stringify(payload);
@@ -392,7 +427,7 @@ var HttpClient = class {
392
427
  {
393
428
  hostname: url.hostname,
394
429
  port: url.port || (url.protocol === "https:" ? 443 : 80),
395
- path: url.pathname,
430
+ path: url.pathname + url.search,
396
431
  method,
397
432
  headers
398
433
  },
@@ -404,6 +439,12 @@ var HttpClient = class {
404
439
  res.on("end", () => {
405
440
  resolve5({ status: res.statusCode ?? 0, text: data, headers: res.headers });
406
441
  });
442
+ res.on("error", reject);
443
+ res.on("close", () => {
444
+ if (!res.complete) {
445
+ reject(new Error(`Connection to ${pathname} closed before the response completed`));
446
+ }
447
+ });
407
448
  }
408
449
  );
409
450
  req.on("error", reject);
@@ -472,7 +513,9 @@ function toWireTestCase(tc) {
472
513
  pageState: rest.pageState || null,
473
514
  aiUsage: rest.aiUsage || null,
474
515
  consoleLogs: rest.consoleLogs || null,
516
+ dialogs: rest.dialogs || null,
475
517
  ariaSnapshot: rest.ariaSnapshot || null,
518
+ ariaSnapshotJson: rest.ariaSnapshotJson || null,
476
519
  testSource: rest.testSource || null,
477
520
  testSourceFrames: rest.testSourceFrames || null,
478
521
  browser: rest.browser || null,
@@ -480,6 +523,7 @@ function toWireTestCase(tc) {
480
523
  suiteConfig: rest.suiteConfig ?? null,
481
524
  testAnnotations: rest.testAnnotations ?? null,
482
525
  tags: rest.tags ?? null,
526
+ locks: rest.locks ?? null,
483
527
  testMeta: rest.testMeta ?? null,
484
528
  locatorSnapshots: rest.locatorSnapshots || null,
485
529
  didNotRunReason: rest.didNotRunReason ?? null,
@@ -831,6 +875,7 @@ var CrashRecovery = class {
831
875
 
832
876
  // src/internal/files/file-handler.ts
833
877
  var fs6 = __toESM(require("fs"));
878
+ var os5 = __toESM(require("os"));
834
879
  var path6 = __toESM(require("path"));
835
880
  var crypto2 = __toESM(require("crypto"));
836
881
 
@@ -867,7 +912,9 @@ async function compressDirectory(sourceDir) {
867
912
  var ATTACHMENT_NAMES = {
868
913
  locators: "piwi-locators",
869
914
  ariaSnapshot: "piwi-aria-snapshot",
915
+ ariaSnapshotJson: "piwi-aria-snapshot-json",
870
916
  console: "piwi-console",
917
+ dialogs: "piwi-dialogs",
871
918
  network: "piwi-network",
872
919
  webVitals: "piwi-web-vitals",
873
920
  locatorSuggestion: "piwi-locator-suggestion",
@@ -881,9 +928,30 @@ var LOCATOR_SUGGESTION_ANNOTATION = ATTACHMENT_NAMES.locatorSuggestion;
881
928
  var USER_PICK_ANNOTATION = ATTACHMENT_NAMES.userPick;
882
929
 
883
930
  // src/internal/files/file-handler.ts
931
+ var MAX_ATTACHMENT_BYTES = 500 * 1024 * 1024;
932
+ var BODY_EXTENSIONS = {
933
+ "application/json": ".json",
934
+ "application/pdf": ".pdf",
935
+ "application/zip": ".zip",
936
+ "image/jpeg": ".jpg",
937
+ "image/png": ".png",
938
+ "image/svg+xml": ".svg",
939
+ "image/webp": ".webp",
940
+ "text/csv": ".csv",
941
+ "text/html": ".html",
942
+ "text/markdown": ".md",
943
+ "text/plain": ".txt"
944
+ };
884
945
  var FileHandler = class {
885
- constructor(logger = new Logger()) {
946
+ constructor(logger = new Logger(), maxAttachmentBytes = MAX_ATTACHMENT_BYTES) {
886
947
  this.logger = logger;
948
+ this.maxAttachmentBytes = maxAttachmentBytes;
949
+ /** Temp files written for body-only attachments, keyed by attachment so repeated lookups reuse one file. */
950
+ this.bodyFiles = /* @__PURE__ */ new WeakMap();
951
+ this.bodyDir = null;
952
+ this.bodyFileCount = 0;
953
+ /** Attachments already reported as oversized, so each one warns once per run. */
954
+ this.oversizedWarned = /* @__PURE__ */ new Set();
887
955
  }
888
956
  /** Locate a Playwright HTML report directory containing `index.html`. Optionally override the search path. */
889
957
  findHTMLReportDirectory(customDir) {
@@ -922,25 +990,83 @@ var FileHandler = class {
922
990
  }
923
991
  return Array.from(set);
924
992
  }
925
- /** Return all non-trace, non-internal attachments from a test case. Skips `trace` and `piwi-*` attachments. */
993
+ /**
994
+ * Return all non-trace, non-internal attachments from a test case as files
995
+ * on disk. Skips `trace` and `piwi-*` attachments. A body-only attachment
996
+ * (`testInfo.attach(name, { body })`) is written to a temp file under
997
+ * `os.tmpdir()` once and reused by later calls; anything above
998
+ * `maxAttachmentBytes` is skipped with a single warning.
999
+ */
926
1000
  findAllAttachments(testCase) {
927
1001
  const result = [];
928
1002
  if (testCase.attachments) {
929
1003
  for (const a of testCase.attachments) {
930
1004
  if (a.name === "trace") continue;
931
1005
  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
- }
1006
+ const resolved = a.path ? this.resolvePathAttachment(a, testCase) : this.resolveBodyAttachment(a, testCase);
1007
+ if (resolved) result.push(resolved);
940
1008
  }
941
1009
  }
942
1010
  return result;
943
1011
  }
1012
+ /** Delete the temp files written for body-only attachments. Call once the run's uploads are done. */
1013
+ cleanupBodyAttachments() {
1014
+ if (!this.bodyDir) return;
1015
+ try {
1016
+ fs6.rmSync(this.bodyDir, { recursive: true, force: true });
1017
+ } catch (error) {
1018
+ this.logger.debug(`Could not remove temp attachments at ${this.bodyDir}: ${errorMessage(error)}`);
1019
+ }
1020
+ this.bodyDir = null;
1021
+ }
1022
+ resolvePathAttachment(a, testCase) {
1023
+ if (!a.path || !fs6.existsSync(a.path)) return null;
1024
+ const size = fs6.statSync(a.path).size;
1025
+ if (size > this.maxAttachmentBytes) {
1026
+ this.warnOversized(a, testCase, size);
1027
+ return null;
1028
+ }
1029
+ return {
1030
+ name: a.name || "attachment",
1031
+ path: path6.resolve(a.path),
1032
+ contentType: a.contentType || "application/octet-stream",
1033
+ originalName: path6.basename(a.path)
1034
+ };
1035
+ }
1036
+ resolveBodyAttachment(a, testCase) {
1037
+ if (!a.body) return null;
1038
+ const body = Buffer.isBuffer(a.body) ? a.body : Buffer.from(a.body);
1039
+ if (body.length > this.maxAttachmentBytes) {
1040
+ this.warnOversized(a, testCase, body.length);
1041
+ return null;
1042
+ }
1043
+ const contentType = a.contentType || "application/octet-stream";
1044
+ const originalName = `${safeFileName(a.name || "attachment")}${BODY_EXTENSIONS[contentType] ?? ""}`;
1045
+ let filePath = this.bodyFiles.get(a);
1046
+ if (!filePath) {
1047
+ try {
1048
+ filePath = path6.join(this.bodyAttachmentDir(), `${++this.bodyFileCount}-${originalName}`);
1049
+ fs6.writeFileSync(filePath, body);
1050
+ this.bodyFiles.set(a, filePath);
1051
+ } catch (error) {
1052
+ this.logger.warn(`Could not stage attachment "${a.name}" for upload: ${errorMessage(error)}`);
1053
+ return null;
1054
+ }
1055
+ }
1056
+ return { name: a.name || "attachment", path: filePath, contentType, originalName };
1057
+ }
1058
+ bodyAttachmentDir() {
1059
+ if (!this.bodyDir) this.bodyDir = fs6.mkdtempSync(path6.join(os5.tmpdir(), "piwi-dashboard-attachments-"));
1060
+ return this.bodyDir;
1061
+ }
1062
+ warnOversized(a, testCase, size) {
1063
+ const key = `${testCase.location}\0${a.name}`;
1064
+ if (this.oversizedWarned.has(key)) return;
1065
+ this.oversizedWarned.add(key);
1066
+ this.logger.warn(
1067
+ `Skipping attachment "${a.name}" on "${testCase.title}": ${formatMiB(size)} exceeds the ${formatMiB(this.maxAttachmentBytes)} upload limit.`
1068
+ );
1069
+ }
944
1070
  /** Mapping of well-known report type names to their default output directories */
945
1071
  getDefaultReportDirs() {
946
1072
  return {
@@ -950,7 +1076,7 @@ var FileHandler = class {
950
1076
  blob: "blob-report"
951
1077
  };
952
1078
  }
953
- /** Parse Piwi-internal attachment bodies (`piwi-network`, `piwi-web-vitals`, `piwi-console`, `piwi-aria-snapshot`) into structured fields on the test case */
1079
+ /** 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
1080
  parsePerformanceAttachments(testCase, attachments) {
955
1081
  const find = (name) => attachments.find((a) => a.name === name);
956
1082
  const net = find(ATTACHMENT_NAMES.network);
@@ -974,8 +1100,17 @@ var FileHandler = class {
974
1100
  } catch {
975
1101
  }
976
1102
  }
1103
+ const dialogs = find(ATTACHMENT_NAMES.dialogs);
1104
+ if (dialogs?.body) {
1105
+ try {
1106
+ testCase.dialogs = JSON.parse(dialogs.body.toString());
1107
+ } catch {
1108
+ }
1109
+ }
977
1110
  const aria = find(ATTACHMENT_NAMES.ariaSnapshot);
978
1111
  if (aria?.body) testCase.ariaSnapshot = aria.body.toString();
1112
+ const ariaJson = find(ATTACHMENT_NAMES.ariaSnapshotJson);
1113
+ if (ariaJson?.body) testCase.ariaSnapshotJson = ariaJson.body.toString();
979
1114
  const pageState = find(ATTACHMENT_NAMES.pageState);
980
1115
  if (pageState?.body) {
981
1116
  try {
@@ -1022,6 +1157,12 @@ var FileHandler = class {
1022
1157
  }
1023
1158
  }
1024
1159
  };
1160
+ function safeFileName(name) {
1161
+ return name.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "attachment";
1162
+ }
1163
+ function formatMiB(bytes) {
1164
+ return `${Math.round(bytes / (1024 * 1024) * 10) / 10} MiB`;
1165
+ }
1025
1166
 
1026
1167
  // src/internal/collect/metadata-collector.ts
1027
1168
  var import_node_child_process = require("child_process");
@@ -1055,6 +1196,20 @@ function resolveScmPrNumber(env) {
1055
1196
  const trimmed = raw?.trim();
1056
1197
  return trimmed && /^\d+$/.test(trimmed) ? trimmed : void 0;
1057
1198
  }
1199
+ function resolveScmBaseBranch(env) {
1200
+ const override = env.PIWI_BASE_BRANCH?.trim();
1201
+ if (override) return override;
1202
+ let target;
1203
+ if (env.JENKINS_URL) target = env.CHANGE_TARGET;
1204
+ else if (env.GITHUB_ACTIONS) target = env.GITHUB_BASE_REF;
1205
+ else if (env.GITLAB_CI) target = env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME;
1206
+ else if (env.TRAVIS)
1207
+ target = env.TRAVIS_PULL_REQUEST && env.TRAVIS_PULL_REQUEST !== "false" ? env.TRAVIS_BRANCH : void 0;
1208
+ else if (env.TF_BUILD) target = env.SYSTEM_PULLREQUEST_TARGETBRANCH;
1209
+ else if (env.BITBUCKET_BUILD_NUMBER) target = env.BITBUCKET_PR_DESTINATION_BRANCH;
1210
+ const trimmed = target?.trim();
1211
+ return trimmed ? normalizeRef(trimmed) : void 0;
1212
+ }
1058
1213
  function normalizeRef(ref) {
1059
1214
  return ref.replace(/^refs\/heads\//, "");
1060
1215
  }
@@ -1122,6 +1277,7 @@ var MetadataCollector = class {
1122
1277
  if (use.colorScheme) config.colorScheme = use.colorScheme;
1123
1278
  if (use.reducedMotion) config.reducedMotion = use.reducedMotion;
1124
1279
  if (use.forcedColors) config.forcedColors = use.forcedColors;
1280
+ if (use.contrast) config.contrast = use.contrast;
1125
1281
  if (use.offline) config.offline = use.offline;
1126
1282
  if (use.bypassCSP) config.bypassCSP = use.bypassCSP;
1127
1283
  if (use.javaScriptEnabled === false) config.javaScriptEnabled = false;
@@ -1186,6 +1342,8 @@ var MetadataCollector = class {
1186
1342
  if (branch) scm.branch = branch;
1187
1343
  const prNumber = resolveScmPrNumber(process.env);
1188
1344
  if (prNumber) scm.prNumber = prNumber;
1345
+ const baseBranch = resolveScmBaseBranch(process.env);
1346
+ if (baseBranch) scm.baseBranch = baseBranch;
1189
1347
  return Object.keys(scm).length > 0 ? scm : void 0;
1190
1348
  }
1191
1349
  collectCiInfo() {
@@ -1264,6 +1422,132 @@ var MetadataCollector = class {
1264
1422
  // src/internal/streaming/stream-manager.ts
1265
1423
  var fs8 = __toESM(require("fs"));
1266
1424
 
1425
+ // src/internal/streaming/bounded-queue.ts
1426
+ var DROP_TIER = {
1427
+ "step-begin": 0,
1428
+ "step-end": 0,
1429
+ begin: 1,
1430
+ complete: 2
1431
+ };
1432
+ var CRITICAL_TIER = 2;
1433
+ var BoundedEventQueue = class {
1434
+ /**
1435
+ * @param maxBytes Byte budget for the buffered events. `<= 0` or non-finite
1436
+ * disables the bound (unbounded, i.e. the pre-existing behavior).
1437
+ */
1438
+ constructor(maxBytes) {
1439
+ this.maxBytes = maxBytes;
1440
+ this.items = [];
1441
+ this._bytes = 0;
1442
+ this._peakBytes = 0;
1443
+ this._dropped = 0;
1444
+ this._droppedByType = {};
1445
+ this._lostResults = false;
1446
+ }
1447
+ /** Number of buffered events. */
1448
+ get length() {
1449
+ return this.items.length;
1450
+ }
1451
+ /** Whether the queue holds no events. */
1452
+ get isEmpty() {
1453
+ return this.items.length === 0;
1454
+ }
1455
+ /** Approximate byte size of the buffered events. */
1456
+ get bytes() {
1457
+ return this._bytes;
1458
+ }
1459
+ /** Highest byte size the queue reached over its lifetime. */
1460
+ get peakBytes() {
1461
+ return this._peakBytes;
1462
+ }
1463
+ /** How many events have been dropped by eviction. */
1464
+ get droppedCount() {
1465
+ return this._dropped;
1466
+ }
1467
+ /** Dropped-event counts broken down by event type. */
1468
+ get droppedByType() {
1469
+ return this._droppedByType;
1470
+ }
1471
+ /** True once a test-result (`complete`) event had to be shed — real live-data loss. */
1472
+ get lostResults() {
1473
+ return this._lostResults;
1474
+ }
1475
+ /** Append one event to the back of the queue, then evict if over budget. */
1476
+ enqueue(event) {
1477
+ const size = estimateSize(event);
1478
+ this.items.push({ event, size });
1479
+ this._bytes += size;
1480
+ this.afterGrow();
1481
+ }
1482
+ /**
1483
+ * Put events back at the front (oldest position), preserving their order, then
1484
+ * evict. Used to re-queue a failed flush ahead of anything that arrived while
1485
+ * it was in flight, and to replay events reloaded from the on-disk buffer.
1486
+ */
1487
+ prepend(events) {
1488
+ if (events.length === 0) return;
1489
+ const head = [];
1490
+ let added = 0;
1491
+ for (const event of events) {
1492
+ const size = estimateSize(event);
1493
+ head.push({ event, size });
1494
+ added += size;
1495
+ }
1496
+ this.items = head.concat(this.items);
1497
+ this._bytes += added;
1498
+ this.afterGrow();
1499
+ }
1500
+ /** Remove and return every queued event, resetting the size counter. */
1501
+ takeAll() {
1502
+ const out = this.items.map((it) => it.event);
1503
+ this.items = [];
1504
+ this._bytes = 0;
1505
+ return out;
1506
+ }
1507
+ /** Read the queued events without removing them. */
1508
+ snapshot() {
1509
+ return this.items.map((it) => it.event);
1510
+ }
1511
+ /** Drop every queued event without counting it as an eviction. */
1512
+ clear() {
1513
+ this.items = [];
1514
+ this._bytes = 0;
1515
+ }
1516
+ afterGrow() {
1517
+ if (this._bytes > this._peakBytes) this._peakBytes = this._bytes;
1518
+ this.evict();
1519
+ }
1520
+ // Shed events tier by tier, oldest-first within a tier, until the buffer fits
1521
+ // its budget. A single event larger than the whole budget is kept rather than
1522
+ // leaving the queue empty, so the most recent result always survives.
1523
+ evict() {
1524
+ if (this.maxBytes <= 0 || !Number.isFinite(this.maxBytes)) return;
1525
+ for (let tier = 0; tier <= CRITICAL_TIER && this._bytes > this.maxBytes; tier++) {
1526
+ let i = 0;
1527
+ while (i < this.items.length && this._bytes > this.maxBytes) {
1528
+ if (tier === CRITICAL_TIER && this.items.length <= 1) break;
1529
+ const it = this.items[i];
1530
+ if (DROP_TIER[it.event.type] === tier) {
1531
+ this.items.splice(i, 1);
1532
+ this._bytes -= it.size;
1533
+ this._dropped++;
1534
+ this._droppedByType[it.event.type] = (this._droppedByType[it.event.type] ?? 0) + 1;
1535
+ if (tier === CRITICAL_TIER) this._lostResults = true;
1536
+ } else {
1537
+ i++;
1538
+ }
1539
+ }
1540
+ }
1541
+ }
1542
+ };
1543
+ function estimateSize(event) {
1544
+ try {
1545
+ return JSON.stringify(event).length;
1546
+ } catch {
1547
+ return 0;
1548
+ }
1549
+ }
1550
+
1267
1551
  // src/internal/support/limiter.ts
1268
1552
  function createLimiter(maxConcurrent) {
1269
1553
  const limitValue = Math.max(1, Math.floor(maxConcurrent));
@@ -1288,10 +1572,10 @@ function createLimiter(maxConcurrent) {
1288
1572
 
1289
1573
  // src/internal/support/setup-file.ts
1290
1574
  var path7 = __toESM(require("path"));
1291
- var os5 = __toESM(require("os"));
1575
+ var os6 = __toESM(require("os"));
1292
1576
  var fs7 = __toESM(require("fs"));
1293
1577
  function getSetupFilePath(projectName) {
1294
- return path7.join(os5.tmpdir(), `piwi-dashboard-setup-${hashForProject(projectName)}.json`);
1578
+ return path7.join(os6.tmpdir(), `piwi-dashboard-setup-${hashForProject(projectName)}.json`);
1295
1579
  }
1296
1580
  function readSetupInfo(projectName) {
1297
1581
  const setupFile = getSetupFilePath(projectName);
@@ -1330,8 +1614,8 @@ var StreamManager = class {
1330
1614
  this.fileHandler = fileHandler;
1331
1615
  this.options = options;
1332
1616
  this.logger = logger;
1333
- this.pendingEvents = [];
1334
- this.pendingBeginEvents = [];
1617
+ /** Guards `reportBufferPressure` so its summary is logged at most once. */
1618
+ this.bufferPressureReported = false;
1335
1619
  this.flushTimer = null;
1336
1620
  this.flushPromises = [];
1337
1621
  this.liveUploadPromises = [];
@@ -1356,6 +1640,9 @@ var StreamManager = class {
1356
1640
  this._token = null;
1357
1641
  this._auth = null;
1358
1642
  this._startPromise = null;
1643
+ const maxBytes = this.options.maxStreamBufferBytes ?? 0;
1644
+ this.pendingEvents = new BoundedEventQueue(maxBytes);
1645
+ this.pendingBeginEvents = new BoundedEventQueue(maxBytes);
1359
1646
  }
1360
1647
  /** Whether the streaming session is active */
1361
1648
  get enabled() {
@@ -1377,6 +1664,15 @@ var StreamManager = class {
1377
1664
  get startPromise() {
1378
1665
  return this._startPromise;
1379
1666
  }
1667
+ /**
1668
+ * Whether buffer pressure forced a test-result (`complete`) event to be
1669
+ * dropped from the live stream. When true the submitter finalizes via the
1670
+ * end-of-run batch (which re-sends the full run from the reporter's own
1671
+ * memory) instead of `/finish`, so the dropped detail is not lost.
1672
+ */
1673
+ get bufferLostResults() {
1674
+ return this.pendingEvents.lostResults || this.pendingBeginEvents.lostResults;
1675
+ }
1380
1676
  /** Begin the streaming session after `onBegin` fires. Non-blocking — the actual handshake runs asynchronously. */
1381
1677
  start(startTime, metadata, instanceId, playwrightVersion, reporterVersion, shardInfo, isFullRun, filterDetails) {
1382
1678
  this._startPromise = this._doStart(
@@ -1470,8 +1766,7 @@ var StreamManager = class {
1470
1766
  this.lastActivityAt = Date.now();
1471
1767
  this.scheduleHeartbeat();
1472
1768
  if (this.pendingBeginEvents.length > 0) {
1473
- this.pendingEvents = [...this.pendingBeginEvents, ...this.pendingEvents];
1474
- this.pendingBeginEvents = [];
1769
+ this.pendingEvents.prepend(this.pendingBeginEvents.takeAll());
1475
1770
  }
1476
1771
  if (this.pendingEvents.length > 0) this.flush();
1477
1772
  }
@@ -1481,7 +1776,9 @@ var StreamManager = class {
1481
1776
  `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
1777
  );
1483
1778
  } else {
1484
- this.logger.debug(`Streaming not available: ${errorMessage(error)}. Will use batch mode.`);
1779
+ this.logger.warn(
1780
+ `Live streaming is unavailable (${errorMessage(error)}) \u2014 results will be submitted in one batch when the run finishes.`
1781
+ );
1485
1782
  }
1486
1783
  this._enabled = false;
1487
1784
  }
@@ -1493,12 +1790,12 @@ var StreamManager = class {
1493
1790
  if (this._enabled && this._runId) {
1494
1791
  this.queueEvent(event);
1495
1792
  } else {
1496
- this.pendingBeginEvents.push(event);
1793
+ this.pendingBeginEvents.enqueue(event);
1497
1794
  }
1498
1795
  }
1499
1796
  /** Queue a test-case event. Triggers an immediate flush when the batch size is reached, otherwise schedules a timer-based flush. */
1500
1797
  queueEvent(event) {
1501
- this.pendingEvents.push(event);
1798
+ this.pendingEvents.enqueue(event);
1502
1799
  if (this.pendingEvents.length >= this.options.streamingBatchSize) {
1503
1800
  this.flush();
1504
1801
  } else if (!this.flushTimer) {
@@ -1511,26 +1808,29 @@ var StreamManager = class {
1511
1808
  clearTimeout(this.flushTimer);
1512
1809
  this.flushTimer = null;
1513
1810
  }
1514
- if (this.pendingEvents.length === 0 || !this._enabled || !this._runId) return null;
1515
- const events = this.pendingEvents.splice(0);
1811
+ if (this.pendingEvents.isEmpty || !this._enabled || !this._runId) return null;
1812
+ const events = this.pendingEvents.takeAll();
1516
1813
  const promise = this.httpClient.postJSON(`/api/test-runs/${this._runId}/events`, { streamToken: this._token, testCases: events }, this._auth).then(
1517
1814
  () => {
1518
1815
  this.retryCount = 0;
1519
1816
  this.lastActivityAt = Date.now();
1520
1817
  return true;
1521
1818
  },
1522
- () => {
1523
- this.pendingEvents = events.concat(this.pendingEvents);
1524
- this.scheduleRetry();
1819
+ (error) => {
1820
+ this.pendingEvents.prepend(events);
1821
+ this.scheduleRetry(errorMessage(error));
1525
1822
  return false;
1526
1823
  }
1527
1824
  );
1528
1825
  this.flushPromises.push(promise);
1529
1826
  return promise;
1530
1827
  }
1531
- scheduleRetry() {
1828
+ scheduleRetry(reason) {
1532
1829
  if (this.retryTimer) return;
1533
1830
  this.retryCount++;
1831
+ if (this.retryCount === 1) {
1832
+ this.logger.warn(`Streaming to the dashboard was interrupted (${reason}) \u2014 events are buffered and retried.`);
1833
+ }
1534
1834
  const delay = Math.min(1e3 * Math.pow(2, this.retryCount - 1), this.maxRetryDelay);
1535
1835
  this.logger.debug(`Will retry streaming flush in ${delay}ms (attempt ${this.retryCount})`);
1536
1836
  this.retryTimer = setTimeout(() => {
@@ -1538,7 +1838,7 @@ var StreamManager = class {
1538
1838
  const buffered = this.streamBuffer.load();
1539
1839
  if (buffered.length > 0) {
1540
1840
  this.streamBuffer.clear();
1541
- this.pendingEvents = buffered.concat(this.pendingEvents);
1841
+ this.pendingEvents.prepend(buffered);
1542
1842
  }
1543
1843
  if (this.pendingEvents.length > 0) this.flush();
1544
1844
  }, delay);
@@ -1581,38 +1881,89 @@ var StreamManager = class {
1581
1881
  this.heartbeatTimer = null;
1582
1882
  }
1583
1883
  }
1884
+ /** Clear a scheduled flush retry so its timer cannot fire after the run wraps up. */
1885
+ clearRetryTimer() {
1886
+ if (this.retryTimer) {
1887
+ clearTimeout(this.retryTimer);
1888
+ this.retryTimer = null;
1889
+ }
1890
+ }
1584
1891
  /** Drain all pending and buffered events before the run finishes. Retries up to 10 times with exponential back-off. */
1585
1892
  async drain() {
1893
+ try {
1894
+ await this._drain();
1895
+ } finally {
1896
+ this.reportBufferPressure();
1897
+ }
1898
+ }
1899
+ async _drain() {
1586
1900
  this.stopHeartbeat();
1901
+ this.clearRetryTimer();
1587
1902
  if (!this._enabled) {
1588
- this.pendingEvents = [];
1903
+ this.pendingEvents.clear();
1589
1904
  this.flushPromises = [];
1590
1905
  return;
1591
1906
  }
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;
1907
+ try {
1908
+ const MAX_ATTEMPTS = 10;
1909
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
1910
+ if (this._enabled && !this.pendingEvents.isEmpty) this.flush();
1911
+ if (this.flushPromises.length > 0) {
1912
+ await Promise.allSettled(this.flushPromises);
1913
+ this.flushPromises = [];
1605
1914
  }
1606
- return;
1915
+ if (this.pendingEvents.isEmpty) {
1916
+ const buffered = this.streamBuffer.load();
1917
+ if (buffered.length > 0) {
1918
+ this.streamBuffer.clear();
1919
+ this.pendingEvents.prepend(buffered);
1920
+ continue;
1921
+ }
1922
+ return;
1923
+ }
1924
+ if (attempt === 0) {
1925
+ this.logger.warn(
1926
+ `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)...`
1927
+ );
1928
+ }
1929
+ this.logger.debugError(
1930
+ `${this.pendingEvents.length} events pending, retrying (attempt ${attempt + 1}/${MAX_ATTEMPTS})...`
1931
+ );
1932
+ await new Promise((resolve5) => setTimeout(resolve5, Math.min(1e3 * Math.pow(2, attempt), 1e4)));
1933
+ }
1934
+ if (!this.pendingEvents.isEmpty) {
1935
+ const remaining = this.pendingEvents.takeAll();
1936
+ this.logger.warn(
1937
+ `Could not deliver ${remaining.length} live event(s) to the dashboard \u2014 continuing with the end-of-run submit.`
1938
+ );
1939
+ this.streamBuffer.append(remaining);
1607
1940
  }
1608
- this.logger.debugError(
1609
- `${this.pendingEvents.length} events pending, retrying (attempt ${attempt + 1}/${MAX_ATTEMPTS})...`
1941
+ } finally {
1942
+ this.clearRetryTimer();
1943
+ }
1944
+ }
1945
+ // Emit a single summary when buffer pressure shed events, so a full disk or a
1946
+ // stalled server does not silently swallow data. Reported once per drain.
1947
+ reportBufferPressure() {
1948
+ if (this.bufferPressureReported) return;
1949
+ const dropped = this.pendingEvents.droppedCount + this.pendingBeginEvents.droppedCount;
1950
+ if (dropped === 0) return;
1951
+ this.bufferPressureReported = true;
1952
+ const byType = this.pendingEvents.droppedByType;
1953
+ const beginByType = this.pendingBeginEvents.droppedByType;
1954
+ const count = (type) => (byType[type] ?? 0) + (beginByType[type] ?? 0);
1955
+ const steps = count("step-begin") + count("step-end");
1956
+ const begins = count("begin");
1957
+ const results = count("complete");
1958
+ const limitMb = ((this.options.maxStreamBufferBytes ?? 0) / (1024 * 1024)).toFixed(0);
1959
+ if (results > 0) {
1960
+ this.logger.warn(
1961
+ `Stream buffer hit its ${limitMb} MB limit \u2014 dropped ${dropped} live event(s), including ${results} test result(s) that will not show live on the dashboard (the run's final counts stay accurate, and the end-of-run submit still carries the full run). Raise \`maxStreamBufferBytes\` or check the dashboard connection.`
1962
+ );
1963
+ } else {
1964
+ this.logger.warn(
1965
+ `Stream buffer hit its ${limitMb} MB limit \u2014 dropped ${dropped} live progress event(s) (${steps} step, ${begins} begin) to stay within budget. No test results were lost.`
1610
1966
  );
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 = [];
1616
1967
  }
1617
1968
  }
1618
1969
  /** 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 +2032,26 @@ var StreamManager = class {
1681
2032
  }
1682
2033
  };
1683
2034
 
2035
+ // ../core/src/mask.ts
2036
+ var DATA_URI_RE = /\bdata:[a-z0-9.+-]+\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/gi;
2037
+ var JWT_RE = /\beyJ[\w-]{10,}\.[\w-]{5,}\.[\w-]{5,}\b/g;
2038
+ var LONG_HEX_RE = /\b[0-9a-f]{32,}\b/gi;
2039
+ function maskTokenLike(text) {
2040
+ return text.replace(DATA_URI_RE, "data:[masked]").replace(JWT_RE, "[masked-token]").replace(LONG_HEX_RE, "[masked-hex]");
2041
+ }
2042
+
1684
2043
  // ../core/src/step-analysis.ts
1685
- function categorizeStep(title, pwCategory) {
2044
+ var MAX_STEP_PARAM_KEYS = 20;
2045
+ var MAX_STEP_PARAM_VALUE_CHARS = 200;
2046
+ function categorizeStep(title, pwCategory, params) {
1686
2047
  if (!title) return "other";
1687
2048
  if (pwCategory === "hook" || pwCategory === "fixture") return pwCategory;
1688
2049
  if (pwCategory === "expect") return "assertion";
1689
2050
  const lower = title.toLowerCase();
1690
2051
  if (lower.startsWith("wait for") || lower.startsWith("locator.waitfor") || lower.startsWith("page.waitfor") || lower.startsWith("frame.waitfor"))
1691
2052
  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"))
2053
+ if (typeof params?.url === "string" && pwCategory !== "expect") return "navigation";
2054
+ 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
2055
  return "navigation";
1694
2056
  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
2057
  return "action";
@@ -1701,14 +2063,39 @@ function categorizeStep(title, pwCategory) {
1701
2063
  if (lower === "before hooks" || lower === "after hooks" || lower.startsWith("fixture:")) return "hook";
1702
2064
  return "other";
1703
2065
  }
2066
+ function normalizeStepParams(raw) {
2067
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
2068
+ const out = {};
2069
+ let count = 0;
2070
+ for (const [key, value] of Object.entries(raw)) {
2071
+ if (count >= MAX_STEP_PARAM_KEYS) break;
2072
+ if (typeof value === "number" || typeof value === "boolean") {
2073
+ out[key] = value;
2074
+ count++;
2075
+ } else if (typeof value === "string") {
2076
+ out[key] = maskTokenLike(value).slice(0, MAX_STEP_PARAM_VALUE_CHARS);
2077
+ count++;
2078
+ } else if (value != null) {
2079
+ try {
2080
+ out[key] = maskTokenLike(JSON.stringify(value)).slice(0, MAX_STEP_PARAM_VALUE_CHARS);
2081
+ count++;
2082
+ } catch {
2083
+ }
2084
+ }
2085
+ }
2086
+ return count > 0 ? out : void 0;
2087
+ }
1704
2088
  function flattenSteps(steps) {
1705
2089
  const result = [];
1706
2090
  for (const step of steps) {
1707
2091
  const flat = {
1708
2092
  title: step.title,
1709
2093
  duration: step.duration,
1710
- category: categorizeStep(step.title, step.category)
2094
+ category: categorizeStep(step.title, step.category, step.params)
1711
2095
  };
2096
+ if (typeof step.subtitle === "string" && step.subtitle.length > 0) flat.subtitle = maskTokenLike(step.subtitle);
2097
+ const params = normalizeStepParams(step.params);
2098
+ if (params) flat.params = params;
1712
2099
  if (step.error?.message) {
1713
2100
  flat.error = { message: step.error.message };
1714
2101
  flat.failed = true;
@@ -1864,7 +2251,7 @@ function readSourceSnippet(file, declLine, context, failingLine) {
1864
2251
  return null;
1865
2252
  }
1866
2253
  }
1867
- function collectSourceFrames(errorText, testFile, declLine, opts = {}) {
2254
+ function collectSourceFrames(errorText, testFile2, declLine, opts = {}) {
1868
2255
  const projectRoot = opts.projectRoot ?? process.cwd();
1869
2256
  const context = opts.context ?? 8;
1870
2257
  const maxFrames = opts.maxFrames ?? 4;
@@ -1893,9 +2280,9 @@ function collectSourceFrames(errorText, testFile, declLine, opts = {}) {
1893
2280
  if (!isNaN(line)) add(abs, line);
1894
2281
  }
1895
2282
  }
1896
- const absTest = path9.resolve(testFile);
2283
+ const absTest = path9.resolve(testFile2);
1897
2284
  if (!picked.some((p) => p.absFile === absTest)) {
1898
- add(absTest, extractFailingLine(errorText, testFile, declLine));
2285
+ add(absTest, extractFailingLine(errorText, testFile2, declLine));
1899
2286
  }
1900
2287
  const frames = [];
1901
2288
  for (const p of picked.slice(0, maxFrames)) {
@@ -1906,9 +2293,9 @@ function collectSourceFrames(errorText, testFile, declLine, opts = {}) {
1906
2293
  }
1907
2294
  return frames;
1908
2295
  }
1909
- function extractFailingLine(errorText, testFile, declarationLine) {
2296
+ function extractFailingLine(errorText, testFile2, declarationLine) {
1910
2297
  if (!errorText) return declarationLine;
1911
- const expectedFile = path9.resolve(testFile);
2298
+ const expectedFile = path9.resolve(testFile2);
1912
2299
  const stackRe = /^\s+at (?:[^(]*\()?(.+?):(\d+):\d+\)?\s*$/gm;
1913
2300
  let m;
1914
2301
  while ((m = stackRe.exec(errorText)) !== null) {
@@ -1996,10 +2383,6 @@ function readSelectionStamp(env = process.env) {
1996
2383
  return { key, version, resolvedHash, resolvedCount };
1997
2384
  }
1998
2385
 
1999
- // src/public/global-setup.ts
2000
- var path10 = __toESM(require("path"));
2001
- var fs11 = __toESM(require("fs"));
2002
-
2003
2386
  // src/internal/support/run-mode.ts
2004
2387
  var PW_UI_FLAGS = ["--ui", "--ui-host", "--ui-port"];
2005
2388
  function isUiMode(argv = process.argv) {
@@ -2008,18 +2391,78 @@ function isUiMode(argv = process.argv) {
2008
2391
  const rest = testIdx >= 0 ? args.slice(testIdx + 1) : args;
2009
2392
  return rest.some((tok) => PW_UI_FLAGS.some((flag) => tok === flag || tok.startsWith(`${flag}=`)));
2010
2393
  }
2394
+ var PW_LIST_FLAGS = ["--list"];
2395
+ function isListMode(argv = process.argv) {
2396
+ const args = argv.slice(2);
2397
+ const testIdx = args.indexOf("test");
2398
+ const rest = testIdx >= 0 ? args.slice(testIdx + 1) : args;
2399
+ return rest.some((tok) => PW_LIST_FLAGS.some((flag) => tok === flag || tok.startsWith(`${flag}=`)));
2400
+ }
2401
+
2402
+ // src/public/global-setup.ts
2403
+ var path11 = __toESM(require("path"));
2404
+ var fs12 = __toESM(require("fs"));
2405
+
2406
+ // src/internal/support/aria-sampling.ts
2407
+ var path10 = __toESM(require("path"));
2408
+ var os7 = __toESM(require("os"));
2409
+ var fs11 = __toESM(require("fs"));
2410
+ function ariaSampleIdentity(filePath, title) {
2411
+ return `${filePath}\0${title}`;
2412
+ }
2413
+ function getAriaSampleFilePath(projectName) {
2414
+ return path10.join(os7.tmpdir(), `piwi-dashboard-aria-sample-${hashForProject(projectName)}.json`);
2415
+ }
2416
+ function writeAriaSampleFile(projectName, identities) {
2417
+ try {
2418
+ fs11.writeFileSync(getAriaSampleFilePath(projectName), JSON.stringify({ projectName, identities }));
2419
+ } catch {
2420
+ }
2421
+ }
2422
+ function clearAriaSampleFile(projectName) {
2423
+ try {
2424
+ fs11.rmSync(getAriaSampleFilePath(projectName), { force: true });
2425
+ } catch {
2426
+ }
2427
+ }
2428
+ var cachedSets = /* @__PURE__ */ new Map();
2429
+ function loadAriaSampleSet(projectName) {
2430
+ if (cachedSets.has(projectName)) return cachedSets.get(projectName);
2431
+ let set = null;
2432
+ try {
2433
+ const raw = fs11.readFileSync(getAriaSampleFilePath(projectName), "utf8");
2434
+ const parsed = JSON.parse(raw);
2435
+ if (parsed.projectName === projectName && Array.isArray(parsed.identities)) {
2436
+ set = new Set(parsed.identities.filter((x) => typeof x === "string"));
2437
+ }
2438
+ } catch {
2439
+ set = null;
2440
+ }
2441
+ cachedSets.set(projectName, set);
2442
+ return set;
2443
+ }
2444
+ function relativeTestFile(file) {
2445
+ return path10.relative(process.cwd(), file).split(path10.sep).join("/");
2446
+ }
2447
+ function isDueForAriaSample(testInfo) {
2448
+ const projectName = process.env.PIWI_PROJECT_NAME;
2449
+ if (!projectName) return false;
2450
+ const set = loadAriaSampleSet(projectName);
2451
+ if (!set || set.size === 0) return false;
2452
+ return set.has(ariaSampleIdentity(relativeTestFile(testInfo.file), testInfo.title));
2453
+ }
2011
2454
 
2012
2455
  // src/public/global-setup.ts
2013
2456
  function createGlobalSetup(options, userSetup) {
2014
2457
  return async function globalSetupFn(config) {
2015
- const piwiReporterPath = path10.resolve(__dirname, "./index.js");
2458
+ const piwiReporterPath = path11.resolve(__dirname, "./index.js");
2016
2459
  let inlineReporterOptions = {};
2017
2460
  if (Array.isArray(config?.reporter)) {
2018
2461
  for (const r of config.reporter) {
2019
2462
  if (!Array.isArray(r) || typeof r[0] !== "string") continue;
2020
2463
  const isPiwi = r[0].toLowerCase().includes("piwi") || (() => {
2021
2464
  try {
2022
- return path10.resolve(require.resolve(r[0])) === piwiReporterPath;
2465
+ return path11.resolve(require.resolve(r[0])) === piwiReporterPath;
2023
2466
  } catch {
2024
2467
  return false;
2025
2468
  }
@@ -2037,6 +2480,11 @@ function createGlobalSetup(options, userSetup) {
2037
2480
  if (userSetup) return userSetup(config);
2038
2481
  return;
2039
2482
  }
2483
+ if (isListMode()) {
2484
+ logger.debug("List mode detected \u2014 skipping run registration.");
2485
+ if (userSetup) return userSetup(config);
2486
+ return;
2487
+ }
2040
2488
  if (opts.enabled === false || !opts.serverUrl) {
2041
2489
  logger.info("Not enabled \u2014 set PIWI_DASHBOARD_URL or serverUrl to enable.");
2042
2490
  if (userSetup) return userSetup(config);
@@ -2046,7 +2494,7 @@ function createGlobalSetup(options, userSetup) {
2046
2494
  if (!Array.isArray(r) || typeof r[0] !== "string") return false;
2047
2495
  if (r[0].toLowerCase().includes("piwi")) return true;
2048
2496
  try {
2049
- return path10.resolve(require.resolve(r[0])) === piwiReporterPath;
2497
+ return path11.resolve(require.resolve(r[0])) === piwiReporterPath;
2050
2498
  } catch {
2051
2499
  return false;
2052
2500
  }
@@ -2078,7 +2526,7 @@ function createGlobalSetup(options, userSetup) {
2078
2526
  auth
2079
2527
  );
2080
2528
  if (response?.runId && response?.setupToken) {
2081
- fs11.writeFileSync(
2529
+ fs12.writeFileSync(
2082
2530
  getSetupFilePath(opts.projectName),
2083
2531
  JSON.stringify({
2084
2532
  runId: response.runId,
@@ -2088,6 +2536,22 @@ function createGlobalSetup(options, userSetup) {
2088
2536
  );
2089
2537
  logger.debug(`Global setup: initializing run #${response.runId}`);
2090
2538
  }
2539
+ if (opts.projectName) clearAriaSampleFile(opts.projectName);
2540
+ if (opts.sampleAriaOnPass !== false && opts.projectName) {
2541
+ const menu = await httpClient.getJSON("/api/projects/menu", auth);
2542
+ const projectId = menu?.items?.find(
2543
+ (p) => p.name.toLowerCase() === opts.projectName.toLowerCase()
2544
+ )?.id;
2545
+ if (projectId != null) {
2546
+ const sampling = await httpClient.getJSON(`/api/projects/${projectId}/aria-sampling`, auth);
2547
+ const tests = Array.isArray(sampling?.tests) ? sampling.tests : null;
2548
+ if (tests) {
2549
+ const identities = tests.filter((t) => typeof t.filePath === "string" && typeof t.title === "string").map((t) => ariaSampleIdentity(t.filePath, t.title));
2550
+ writeAriaSampleFile(opts.projectName, identities);
2551
+ logger.debug(`Green ARIA sampling: ${identities.length} test(s) due a sample.`);
2552
+ }
2553
+ }
2554
+ }
2091
2555
  } catch (error) {
2092
2556
  logger.warn(`Could not register global setup: ${errorMessage(error)}`);
2093
2557
  }
@@ -2096,9 +2560,56 @@ function createGlobalSetup(options, userSetup) {
2096
2560
  }
2097
2561
 
2098
2562
  // src/public/config-wrapper.ts
2099
- var path11 = __toESM(require("path"));
2100
- var fs12 = __toESM(require("fs"));
2563
+ var path12 = __toESM(require("path"));
2564
+ var fs13 = __toESM(require("fs"));
2101
2565
  var PIWI_MODULE = "@piwitests/reporter";
2566
+ var CAPTURE_DEFAULTS = {
2567
+ screenshot: "only-on-failure",
2568
+ trace: "retain-on-failure"
2569
+ };
2570
+ var TRACE_SNAPSHOTS_MIN = { major: 1, minor: 63 };
2571
+ function installedPlaywrightVersion() {
2572
+ try {
2573
+ const nodeRequire = require;
2574
+ return nodeRequire("@playwright/test/package.json").version;
2575
+ } catch {
2576
+ return void 0;
2577
+ }
2578
+ }
2579
+ var readPlaywrightVersion = installedPlaywrightVersion;
2580
+ function supportsTraceSnapshots(version) {
2581
+ const match = version ? /^(\d+)\.(\d+)/.exec(version) : null;
2582
+ if (!match) return false;
2583
+ const major = Number(match[1]);
2584
+ const minor = Number(match[2]);
2585
+ return major > TRACE_SNAPSHOTS_MIN.major || major === TRACE_SNAPSHOTS_MIN.major && minor >= TRACE_SNAPSHOTS_MIN.minor;
2586
+ }
2587
+ function applyCaptureDefaults(use, piwiOptions) {
2588
+ delete process.env[PIWI_DEFAULTED_CAPTURE_ENV];
2589
+ const enabled = piwiOptions?.defaultCapture ?? readBool(process.env[PIWI_ENV_KEYS.defaultCapture]) ?? true;
2590
+ if (!enabled) return use;
2591
+ const ariaSnapshots = supportsTraceSnapshots(readPlaywrightVersion());
2592
+ const traceValue = ariaSnapshots ? { mode: CAPTURE_DEFAULTS.trace, snapshots: { dom: true, aria: true } } : CAPTURE_DEFAULTS.trace;
2593
+ const defaults = [
2594
+ { key: "screenshot", value: CAPTURE_DEFAULTS.screenshot, display: `screenshot: '${CAPTURE_DEFAULTS.screenshot}'` },
2595
+ {
2596
+ key: "trace",
2597
+ value: traceValue,
2598
+ display: ariaSnapshots ? `trace: '${CAPTURE_DEFAULTS.trace}' with dom and aria snapshots` : `trace: '${CAPTURE_DEFAULTS.trace}'`
2599
+ }
2600
+ ];
2601
+ const next = { ...use };
2602
+ const applied = [];
2603
+ for (const { key, value, display } of defaults) {
2604
+ if (use?.[key] === void 0) {
2605
+ next[key] = value;
2606
+ applied.push(display);
2607
+ }
2608
+ }
2609
+ if (applied.length === 0) return use;
2610
+ process.env[PIWI_DEFAULTED_CAPTURE_ENV] = applied.join(", ");
2611
+ return next;
2612
+ }
2102
2613
  function isPiwiReporterEntry(entry) {
2103
2614
  if (typeof entry === "string") return entry.toLowerCase().includes("piwi");
2104
2615
  if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0].toLowerCase().includes("piwi");
@@ -2115,28 +2626,29 @@ function injectReporter(reporter, piwiOptions) {
2115
2626
  }
2116
2627
  function resolveSetupModule() {
2117
2628
  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")
2629
+ path12.join(__dirname, "global-setup-module.js"),
2630
+ path12.join(__dirname, "..", "global-setup-module.js"),
2631
+ path12.join(__dirname, "..", "global-setup-module.ts")
2121
2632
  ];
2122
- return candidates.find((candidate) => fs12.existsSync(candidate)) ?? candidates[0];
2633
+ return candidates.find((candidate) => fs13.existsSync(candidate)) ?? candidates[0];
2123
2634
  }
2124
2635
  function wrapConfig(config, piwiOptions) {
2125
2636
  if (piwiOptions) applyOptionsToEnv(piwiOptions);
2126
- const globalSetupModules = [];
2637
+ const globalSetupModules = [resolveSetupModule()];
2127
2638
  if (config.globalSetup) {
2128
2639
  const orig = Array.isArray(config.globalSetup) ? config.globalSetup : [config.globalSetup];
2129
2640
  globalSetupModules.push(...orig);
2130
2641
  }
2131
- globalSetupModules.push(resolveSetupModule());
2132
2642
  const forwarded = {};
2133
2643
  const failOnFlaky = piwiOptions?.failOnFlakyTests ?? readBool(process.env[PIWI_ENV_KEYS.failOnFlakyTests]);
2134
2644
  if (failOnFlaky === true) forwarded.failOnFlakyTests = true;
2645
+ const use = applyCaptureDefaults(config.use, piwiOptions);
2135
2646
  return {
2136
2647
  ...config,
2137
2648
  ...forwarded,
2138
2649
  reporter: injectReporter(config.reporter, piwiOptions),
2139
- globalSetup: globalSetupModules.length === 1 ? globalSetupModules[0] : globalSetupModules
2650
+ globalSetup: globalSetupModules.length === 1 ? globalSetupModules[0] : globalSetupModules,
2651
+ ...use === config.use ? {} : { use }
2140
2652
  };
2141
2653
  }
2142
2654
 
@@ -2204,6 +2716,8 @@ var PIWI_ANNOTATION_PREFIX = "piwi:";
2204
2716
  var TEST_PRIORITIES = ["critical", "high", "medium", "low"];
2205
2717
  var MAX_TEST_TAGS = 20;
2206
2718
  var MAX_TEST_TAG_CHARS = 60;
2719
+ var MAX_TEST_LOCKS = 20;
2720
+ var MAX_TEST_LOCK_CHARS = 100;
2207
2721
  var MAX_TEST_META_CHARS = 120;
2208
2722
  var MAX_TEST_LINK_CHARS = 500;
2209
2723
  var PRIORITY_SET = new Set(TEST_PRIORITIES);
@@ -2227,6 +2741,20 @@ function normalizeTestTags(raw) {
2227
2741
  }
2228
2742
  return out;
2229
2743
  }
2744
+ function normalizeTestLocks(raw) {
2745
+ if (!Array.isArray(raw)) return [];
2746
+ const seen = /* @__PURE__ */ new Set();
2747
+ const out = [];
2748
+ for (const entry of raw) {
2749
+ if (typeof entry !== "string") continue;
2750
+ const lock = entry.trim().slice(0, MAX_TEST_LOCK_CHARS);
2751
+ if (!lock || seen.has(lock)) continue;
2752
+ seen.add(lock);
2753
+ out.push(lock);
2754
+ if (out.length >= MAX_TEST_LOCKS) break;
2755
+ }
2756
+ return out;
2757
+ }
2230
2758
  function normalizeLink(value) {
2231
2759
  const raw = cleanString(value, MAX_TEST_LINK_CHARS);
2232
2760
  if (!raw) return void 0;
@@ -2279,12 +2807,16 @@ function parseTestMetadata(annotations) {
2279
2807
  function collectTestTags(test) {
2280
2808
  return normalizeTestTags(test.tags);
2281
2809
  }
2810
+ function collectTestLocks(test) {
2811
+ const locks = test?._locks;
2812
+ return normalizeTestLocks(locks);
2813
+ }
2282
2814
  function collectTestMetadata(annotations) {
2283
2815
  return parseTestMetadata(annotations);
2284
2816
  }
2285
2817
 
2286
2818
  // src/internal/collect/error-text.ts
2287
- var path12 = __toESM(require("path"));
2819
+ var path13 = __toESM(require("path"));
2288
2820
 
2289
2821
  // ../core/src/error-text.ts
2290
2822
  function joinErrorMessages(errors) {
@@ -2313,13 +2845,14 @@ function buildErrorText(result) {
2313
2845
  if (!text) return null;
2314
2846
  const loc = result.error?.location;
2315
2847
  if (!loc?.file) return text;
2316
- const rel = path12.relative(process.cwd(), loc.file).split(path12.sep).join("/");
2848
+ const rel = path13.relative(process.cwd(), loc.file).split(path13.sep).join("/");
2317
2849
  return appendErrorLocation(text, { file: rel, line: loc.line, column: loc.column });
2318
2850
  }
2319
2851
 
2320
2852
  // src/internal/support/ci-output.ts
2321
- var fs13 = __toESM(require("fs"));
2322
- var path13 = __toESM(require("path"));
2853
+ var fs14 = __toESM(require("fs"));
2854
+ var path14 = __toESM(require("path"));
2855
+ var SUMMARY_MAX_FAILURES = 20;
2323
2856
  function emitRunOutputs(output, logger, outputFile, env = process.env) {
2324
2857
  logger.info(`View run: ${output.runUrl}`);
2325
2858
  if (outputFile) writeOutputFile(outputFile, output, logger);
@@ -2328,9 +2861,9 @@ function emitRunOutputs(output, logger, outputFile, env = process.env) {
2328
2861
  }
2329
2862
  function writeOutputFile(file, output, logger) {
2330
2863
  try {
2331
- const dir = path13.dirname(file);
2332
- if (dir && dir !== ".") fs13.mkdirSync(dir, { recursive: true });
2333
- fs13.writeFileSync(
2864
+ const dir = path14.dirname(file);
2865
+ if (dir && dir !== ".") fs14.mkdirSync(dir, { recursive: true });
2866
+ fs14.writeFileSync(
2334
2867
  file,
2335
2868
  JSON.stringify(
2336
2869
  {
@@ -2339,7 +2872,9 @@ function writeOutputFile(file, output, logger) {
2339
2872
  projectId: output.projectId ?? null,
2340
2873
  projectName: output.projectName,
2341
2874
  status: output.status,
2342
- ciBuildUrl: output.ciBuildUrl ?? null
2875
+ ciBuildUrl: output.ciBuildUrl ?? null,
2876
+ failedCount: output.failures.length,
2877
+ failures: output.failures
2343
2878
  },
2344
2879
  null,
2345
2880
  2
@@ -2354,7 +2889,8 @@ function emitGitHubActions(output, env, logger) {
2354
2889
  const pairs = [
2355
2890
  ["piwi_run_url", output.runUrl],
2356
2891
  ["piwi_run_id", String(output.runId)],
2357
- ["piwi_run_status", output.status]
2892
+ ["piwi_run_status", output.status],
2893
+ ["piwi_failed_count", String(output.failures.length)]
2358
2894
  ];
2359
2895
  if (output.projectId != null) pairs.push(["piwi_project_id", String(output.projectId)]);
2360
2896
  if (env.GITHUB_OUTPUT) {
@@ -2368,7 +2904,13 @@ function emitGitHubActions(output, env, logger) {
2368
2904
  if (env.GITHUB_STEP_SUMMARY) {
2369
2905
  appendFileLines(
2370
2906
  env.GITHUB_STEP_SUMMARY,
2371
- ["### Piwi test run", "", `[View run](${output.runUrl}) \u2014 **${output.status}**`, ""],
2907
+ [
2908
+ "### Piwi test run",
2909
+ "",
2910
+ `[View run](${output.runUrl}) \u2014 **${output.status}**`,
2911
+ "",
2912
+ ...summaryFailureLines(output)
2913
+ ],
2372
2914
  logger,
2373
2915
  "step summary"
2374
2916
  );
@@ -2376,13 +2918,32 @@ function emitGitHubActions(output, env, logger) {
2376
2918
  process.stdout.write(`::notice title=Piwi test run::${output.runUrl}
2377
2919
  `);
2378
2920
  }
2921
+ function summaryFailureLines(output) {
2922
+ if (output.failures.length === 0) return [];
2923
+ const lines = output.failures.slice(0, SUMMARY_MAX_FAILURES).map((f) => {
2924
+ const headline = f.headline ? ` \u2014 ${escapeMarkdown(f.headline)}` : "";
2925
+ return `- \u274C [${escapeMarkdown(f.title)}](${f.url})${headline} \u2014 \`${f.file}\``;
2926
+ });
2927
+ const hidden = output.failures.length - SUMMARY_MAX_FAILURES;
2928
+ if (hidden > 0) lines.push(`- +${hidden} more`);
2929
+ lines.push("");
2930
+ return lines;
2931
+ }
2932
+ function escapeMarkdown(text) {
2933
+ return text.replace(/[\\`*_[\]]/g, (ch) => `\\${ch}`);
2934
+ }
2379
2935
  function emitGitLabDotenv(output, env, logger) {
2380
2936
  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}`];
2937
+ const lines = [
2938
+ `PIWI_RUN_URL=${output.runUrl}`,
2939
+ `PIWI_RUN_ID=${output.runId}`,
2940
+ `PIWI_RUN_STATUS=${output.status}`,
2941
+ `PIWI_FAILED_COUNT=${output.failures.length}`
2942
+ ];
2382
2943
  if (output.projectId != null) lines.push(`PIWI_PROJECT_ID=${output.projectId}`);
2383
2944
  if (output.ciBuildUrl) lines.push(`PIWI_CI_BUILD_URL=${output.ciBuildUrl}`);
2384
2945
  try {
2385
- fs13.writeFileSync(file, lines.join("\n") + "\n");
2946
+ fs14.writeFileSync(file, lines.join("\n") + "\n");
2386
2947
  logger.info(`Wrote GitLab dotenv report to ${file} (declare it as artifacts:reports:dotenv)`);
2387
2948
  } catch (error) {
2388
2949
  logger.warn(`Failed to write GitLab dotenv file '${file}': ${errorMessage(error)}`);
@@ -2390,7 +2951,7 @@ function emitGitLabDotenv(output, env, logger) {
2390
2951
  }
2391
2952
  function appendFileLines(file, lines, logger, label) {
2392
2953
  try {
2393
- fs13.appendFileSync(file, lines.join("\n") + "\n");
2954
+ fs14.appendFileSync(file, lines.join("\n") + "\n");
2394
2955
  } catch (error) {
2395
2956
  logger.warn(`Failed to write GitHub Actions ${label}: ${errorMessage(error)}`);
2396
2957
  }
@@ -2409,13 +2970,15 @@ var RunSubmitter = class {
2409
2970
  * @param recovery Crash-recovery persistence.
2410
2971
  * @param streamManager Streaming session (may be `null` when streaming is disabled).
2411
2972
  * @param logger Prefixed logger.
2973
+ * @param failureLinks Failed tests collected during the run, for the per-failure links.
2412
2974
  */
2413
- constructor(httpClient, uploader, recovery, streamManager, logger = new Logger()) {
2975
+ constructor(httpClient, uploader, recovery, streamManager, logger = new Logger(), failureLinks = null) {
2414
2976
  this.httpClient = httpClient;
2415
2977
  this.uploader = uploader;
2416
2978
  this.recovery = recovery;
2417
2979
  this.streamManager = streamManager;
2418
2980
  this.logger = logger;
2981
+ this.failureLinks = failureLinks;
2419
2982
  }
2420
2983
  /** Run the fallback ladder for a completed test run. */
2421
2984
  async submit(run, result) {
@@ -2443,10 +3006,12 @@ var RunSubmitter = class {
2443
3006
  auth = sm?.auth ?? await this.httpClient.resolveAuth(run.options);
2444
3007
  } catch (error) {
2445
3008
  this.logger.error(`Authentication failed: ${errorMessage(error)}`);
3009
+ this.saveRecovery(this.buildRunPayload(run, overallStatus, duration));
2446
3010
  throw error;
2447
3011
  }
3012
+ if (!sm) await this.recovery.tryUpload(this.httpClient, auth);
2448
3013
  let outcome = { done: false, output: null };
2449
- if (sm?.enabled && sm?.runId != null) {
3014
+ if (sm?.enabled && sm?.runId != null && !sm.bufferLostResults) {
2450
3015
  outcome = await this.tryFinishStreaming(run, overallStatus, duration, auth);
2451
3016
  }
2452
3017
  if (!outcome.done && (this.hasReports(run) || run.options.uploadTraces)) {
@@ -2455,7 +3020,10 @@ var RunSubmitter = class {
2455
3020
  if (!outcome.done) {
2456
3021
  outcome = await this.tryUploadJSON(run, overallStatus, duration, auth);
2457
3022
  }
2458
- if (outcome.output) emitRunOutputs(outcome.output, this.logger, run.options.outputFile);
3023
+ if (outcome.output) {
3024
+ this.failureLinks?.printPending(outcome.output.runId);
3025
+ emitRunOutputs(outcome.output, this.logger, run.options.outputFile);
3026
+ }
2459
3027
  }
2460
3028
  /** Assemble a CI-facing run output, or `null` when the server returned no run id. */
2461
3029
  buildOutput(runId, projectId, run, status) {
@@ -2466,7 +3034,8 @@ var RunSubmitter = class {
2466
3034
  projectId,
2467
3035
  projectName: run.options.projectName,
2468
3036
  status,
2469
- ciBuildUrl: ciBuildUrlFromMetadata(run.metadata)
3037
+ ciBuildUrl: ciBuildUrlFromMetadata(run.metadata),
3038
+ failures: this.failureLinks?.resolve(runId) ?? []
2470
3039
  };
2471
3040
  }
2472
3041
  hasReports(run) {
@@ -2560,17 +3129,15 @@ var RunSubmitter = class {
2560
3129
  }
2561
3130
  }
2562
3131
  async tryUploadWithFiles(run, overallStatus, duration, auth) {
3132
+ const payload = this.buildRunPayload(run, overallStatus, duration);
2563
3133
  try {
2564
- const response = await this.uploader.uploadWithFiles(
2565
- this.buildRunPayload(run, overallStatus, duration),
2566
- this.reportOptions(run),
2567
- auth
2568
- );
3134
+ const response = await this.uploader.uploadWithFiles(payload, this.reportOptions(run), auth);
2569
3135
  this.recovery.clear();
2570
3136
  return { done: true, output: this.buildOutput(response?.runId, response?.projectId, run, overallStatus) };
2571
3137
  } catch (error) {
2572
3138
  if (error instanceof HttpError && error.status === 401 && !auth) {
2573
3139
  this.logAuthRequired(run.options.serverUrl);
3140
+ this.saveRecovery(payload);
2574
3141
  throw error;
2575
3142
  }
2576
3143
  this.logger.warn(`Failed to upload with files: ${errorMessage(error)}`);
@@ -2587,16 +3154,24 @@ var RunSubmitter = class {
2587
3154
  } catch (error) {
2588
3155
  if (error instanceof HttpError && error.status === 401 && !auth) {
2589
3156
  this.logAuthRequired(run.options.serverUrl);
3157
+ this.saveRecovery(payload);
2590
3158
  throw error;
2591
3159
  }
2592
3160
  this.logger.error(`All upload methods failed: ${errorMessage(error)}`);
2593
3161
  this.logger.info(
2594
3162
  `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
3163
  );
2596
- this.recovery.save(serializeRun(payload, { includeTestCases: true }));
3164
+ this.saveRecovery(payload);
2597
3165
  return { done: true, output: null };
2598
3166
  }
2599
3167
  }
3168
+ /**
3169
+ * Persist the wire-serialized payload (no raw attachments / internal fields)
3170
+ * so a later run can retry the submit.
3171
+ */
3172
+ saveRecovery(payload) {
3173
+ this.recovery.save(serializeRun(payload, { includeTestCases: true }));
3174
+ }
2600
3175
  /** Log one actionable line explaining how to fix a 401 caused by a missing credential. */
2601
3176
  logAuthRequired(serverUrl) {
2602
3177
  this.logger.error(
@@ -2605,10 +3180,833 @@ var RunSubmitter = class {
2605
3180
  }
2606
3181
  };
2607
3182
 
3183
+ // ../core/src/locator-methods.ts
3184
+ var LOCATOR_BUILDER_METHODS = [
3185
+ "getByRole",
3186
+ "getByTestId",
3187
+ "getByText",
3188
+ "getByLabel",
3189
+ "getByPlaceholder",
3190
+ "getByAltText",
3191
+ "getByTitle",
3192
+ "locator"
3193
+ ];
3194
+
3195
+ // ../core/src/error-parse.ts
3196
+ var ANSI_RE = new RegExp("\\u001B\\[[0-9;]*m", "g");
3197
+ function stripAnsi(text) {
3198
+ return text.replace(ANSI_RE, "");
3199
+ }
3200
+ var SELECTOR_FN_RE = /\b(?:locator|frameLocator|getByRole|getByTestId|getByText|getByLabel|getByPlaceholder|getByAltText|getByTitle)\(/;
3201
+ var CHAIN_LINK_METHODS = /* @__PURE__ */ new Set([
3202
+ ...LOCATOR_BUILDER_METHODS,
3203
+ "frameLocator",
3204
+ "contentFrame",
3205
+ "filter",
3206
+ "first",
3207
+ "last",
3208
+ "nth",
3209
+ "and",
3210
+ "or",
3211
+ "describe"
3212
+ ]);
3213
+ function extractMessageHead(text) {
3214
+ let head = text;
3215
+ const callLogIdx = head.indexOf("\nCall log:");
3216
+ if (callLogIdx !== -1) head = head.slice(0, callLogIdx);
3217
+ const stackIdx = head.search(/\n\s+at /);
3218
+ if (stackIdx !== -1) head = head.slice(0, stackIdx);
3219
+ const lines = head.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
3220
+ return lines.slice(0, 5).join("\n");
3221
+ }
3222
+ function extractLeafSelector(text) {
3223
+ const first = SELECTOR_FN_RE.exec(text);
3224
+ if (!first) return null;
3225
+ const nl = text.indexOf("\n", first.index);
3226
+ const region = text.slice(first.index, nl === -1 ? void 0 : nl);
3227
+ let depth = 0;
3228
+ let leafStart = -1;
3229
+ for (let i = 0; i < region.length; i++) {
3230
+ const ch = region[i];
3231
+ if (ch === "(") {
3232
+ depth++;
3233
+ continue;
3234
+ }
3235
+ if (ch === ")") {
3236
+ if (depth > 0) depth--;
3237
+ continue;
3238
+ }
3239
+ if (depth !== 0) continue;
3240
+ const prev = region[i - 1];
3241
+ if (prev && /\w/.test(prev)) continue;
3242
+ for (const method of LOCATOR_BUILDER_METHODS) {
3243
+ if (region.startsWith(method, i) && region[i + method.length] === "(") {
3244
+ leafStart = i;
3245
+ break;
3246
+ }
3247
+ }
3248
+ }
3249
+ if (leafStart === -1) return null;
3250
+ depth = 0;
3251
+ for (let i = leafStart; i < region.length; i++) {
3252
+ const ch = region[i];
3253
+ if (ch === "(") {
3254
+ depth++;
3255
+ } else if (ch === ")") {
3256
+ depth--;
3257
+ if (depth === 0) return region.slice(leafStart, i + 1);
3258
+ }
3259
+ }
3260
+ return region.slice(leafStart, leafStart + 80);
3261
+ }
3262
+ function extractLocatorChain(text) {
3263
+ const first = SELECTOR_FN_RE.exec(text);
3264
+ if (!first) return null;
3265
+ const nl = text.indexOf("\n", first.index);
3266
+ const region = text.slice(first.index, nl === -1 ? void 0 : nl);
3267
+ let depth = 0;
3268
+ let end = -1;
3269
+ for (let i = 0; i < region.length; i++) {
3270
+ const ch = region[i];
3271
+ if (ch === "(") {
3272
+ depth++;
3273
+ } else if (ch === ")") {
3274
+ if (depth > 0) depth--;
3275
+ if (depth === 0) {
3276
+ end = i + 1;
3277
+ const link = /^\.(\w+)\(/.exec(region.slice(end));
3278
+ if (!link || !CHAIN_LINK_METHODS.has(link[1])) break;
3279
+ i = end + link[0].length - 2;
3280
+ }
3281
+ }
3282
+ }
3283
+ if (end === -1) return region.slice(0, 80);
3284
+ return region.slice(0, end);
3285
+ }
3286
+ function extractTopFrame(text) {
3287
+ const frameRe = /^\s+at (?:.*? \()?([^()\s][^()]*?):(\d+):(\d+)\)?\s*$/gm;
3288
+ let m;
3289
+ while ((m = frameRe.exec(text)) !== null) {
3290
+ const file = m[1].replace(/\\/g, "/");
3291
+ if (file.includes("node_modules") || file.startsWith("node:")) continue;
3292
+ return { file, line: Number(m[2]), column: Number(m[3]) };
3293
+ }
3294
+ return null;
3295
+ }
3296
+ 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";
3297
+ var CALL_RE = new RegExp(`\\b(${SUBJECTS})\\.(\\w+): `);
3298
+ var ERROR_NAME_RE = /^((?:[A-Z]\w*)?(?:Error|Exception))\b:?/;
3299
+ var MATCHER_RE = /\bexpect(?:\.soft)?\((?:[^()]|\([^()]*\))*\)(\.not)?\.(to\w+)/;
3300
+ var MATCHER_SHORT_RE = /\bexpect(?:\.soft)?\.(to\w+)\b/;
3301
+ var TIMED_OUT_EXPECT_RE = /Timed out (\d+)ms waiting for expect\(/;
3302
+ var NAVIGATION_RE = /\b(?:page|frame)\.(?:goto|waitForURL|waitForNavigation|reload|goBack|goForward)\b|net::ERR_|NS_ERROR_|Navigation failed|navigating to "/i;
3303
+ var NETWORK_CODE_RE = /\b(net::ERR_[A-Z0-9_]+|NS_ERROR_[A-Z0-9_]+)\b/;
3304
+ 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;
3305
+ var TEST_TIMEOUT_RE = /\bTest timeout of (\d+)ms exceeded(?: while (?:running "(\w+)" hook|tearing down "(\w+)"))?/;
3306
+ var STRICT_RE = /strict mode violation: (.+?) resolved to (\d+) elements/;
3307
+ var URL_RE = /https?:\/\/[^\s'"`)]+/;
3308
+ var CALL_LOG_LINE_RE = /^\s*-\s+(?:(\d+) × )?(.*)$/;
3309
+ var RETRY_COUNT_LINE_RE = /^\s+\d+ × (.+)$/;
3310
+ function withoutStackFrames(text) {
3311
+ return text.split("\n").filter((line) => !/^\s+at /.test(line)).join("\n");
3312
+ }
3313
+ function callLogLines(text) {
3314
+ const start = text.indexOf("Call log:");
3315
+ const region = start === -1 ? text : text.slice(start + "Call log:".length);
3316
+ const lines = [];
3317
+ for (const line of region.split("\n")) {
3318
+ const bulleted = CALL_LOG_LINE_RE.exec(line);
3319
+ if (bulleted) {
3320
+ lines.push(bulleted[2].trim());
3321
+ continue;
3322
+ }
3323
+ const retry = RETRY_COUNT_LINE_RE.exec(line);
3324
+ if (retry) lines.push(retry[1].trim());
3325
+ }
3326
+ return lines;
3327
+ }
3328
+ function readCallLog(text) {
3329
+ const lines = callLogLines(text);
3330
+ const read = {
3331
+ state: "unknown",
3332
+ count: null,
3333
+ lastLine: null,
3334
+ stateLine: null,
3335
+ url: null,
3336
+ timeoutMs: null,
3337
+ matcher: null
3338
+ };
3339
+ if (lines.length === 0) return read;
3340
+ read.lastLine = lines[lines.length - 1];
3341
+ let sawWaiting = false;
3342
+ let waitingLine = null;
3343
+ for (const line of lines) {
3344
+ const before = read.state;
3345
+ const lower = line.toLowerCase();
3346
+ const count = /^locator resolved to (\d+) elements?/.exec(line);
3347
+ const expectLine = /^Expect "(\w+)" with timeout (\d+)ms/.exec(line);
3348
+ const navTo = /navigat(?:ing|ion|ed) to "([^"]+)"/.exec(line);
3349
+ if (expectLine) {
3350
+ read.matcher = expectLine[1];
3351
+ read.timeoutMs = Number(expectLine[2]);
3352
+ continue;
3353
+ }
3354
+ if (navTo) {
3355
+ read.url ??= navTo[1];
3356
+ read.state = "navigating";
3357
+ continue;
3358
+ }
3359
+ if (/^waiting for (?:navigation|page to navigate)/.test(line)) {
3360
+ read.state = "navigating";
3361
+ continue;
3362
+ }
3363
+ if (line.startsWith("waiting for ") && SELECTOR_FN_RE.test(line)) {
3364
+ sawWaiting = true;
3365
+ waitingLine ??= line;
3366
+ continue;
3367
+ }
3368
+ if (count) {
3369
+ read.state = "resolved-count";
3370
+ read.count = Number(count[1]);
3371
+ continue;
3372
+ }
3373
+ if (line.startsWith("locator resolved to hidden <") || line.startsWith('unexpected value "hidden"')) {
3374
+ read.state = "hidden";
3375
+ continue;
3376
+ }
3377
+ if (/^locator resolved to (?:visible )?</.test(line)) {
3378
+ read.state = "resolved";
3379
+ continue;
3380
+ }
3381
+ if (lower.startsWith("element is not visible")) read.state = "not-visible";
3382
+ else if (lower.startsWith("element is not enabled")) read.state = "not-enabled";
3383
+ else if (lower.startsWith("element is not editable")) read.state = "not-editable";
3384
+ else if (lower.startsWith("element is not stable")) read.state = "not-stable";
3385
+ else if (lower.startsWith("element is outside of the viewport")) read.state = "outside-viewport";
3386
+ else if (lower.startsWith("element is not attached") || lower.includes("element was detached"))
3387
+ read.state = "detached";
3388
+ else if (lower.includes("intercepts pointer events")) read.state = "intercepts-pointer";
3389
+ else if (lower.startsWith("element is visible, enabled and stable")) read.state = "resolved";
3390
+ else if (/^(?:attempting|performing) \w+ action/.test(lower) && (read.state === "unknown" || read.state === "not-found"))
3391
+ read.state = "resolved";
3392
+ if (read.state !== before || /^(?:locator resolved to|unexpected value|element is)/.test(lower)) {
3393
+ read.stateLine = line;
3394
+ }
3395
+ }
3396
+ if (read.state === "unknown" && sawWaiting) {
3397
+ read.state = "not-found";
3398
+ read.stateLine = waitingLine;
3399
+ }
3400
+ return read;
3401
+ }
3402
+ function headerValue(text, label) {
3403
+ const re = new RegExp(`^\\s*${label}(?: string| pattern| substring| value)?:[ \\t]*(.*)$`, "m");
3404
+ const m = re.exec(text);
3405
+ if (!m) return null;
3406
+ const value = m[1].trim();
3407
+ return value.length > 0 ? value : null;
3408
+ }
3409
+ function readLocator(text, strict) {
3410
+ const header = /^\s*Locator:[ \t]*(.+)$/m.exec(text);
3411
+ if (header) return header[1].trim();
3412
+ if (strict) return strict[1].trim();
3413
+ return extractLocatorChain(text);
3414
+ }
3415
+ function readTimeout(text, callLog) {
3416
+ const patterns = [
3417
+ /^\s*Timeout:[ \t]*(\d+)ms/m,
3418
+ /\bTimeout (\d+)ms exceeded/,
3419
+ TEST_TIMEOUT_RE,
3420
+ TIMED_OUT_EXPECT_RE,
3421
+ /\bTimed out (\d+)ms/
3422
+ ];
3423
+ for (const re of patterns) {
3424
+ const m = re.exec(text);
3425
+ if (m) return Number(m[1]);
3426
+ }
3427
+ return callLog.timeoutMs;
3428
+ }
3429
+ function readUrl(text, callLog, received) {
3430
+ if (callLog.url) return callLog.url;
3431
+ const at = /\b(?:net::ERR_[A-Z0-9_]+|NS_ERROR_[A-Z0-9_]+) at (\S+)/.exec(text);
3432
+ if (at) return at[1];
3433
+ const head = extractMessageHead(text);
3434
+ const inHead = URL_RE.exec(head.replace(/^\s*Received.*$/gm, ""));
3435
+ if (inHead) return inHead[0];
3436
+ if (received) {
3437
+ const inReceived = URL_RE.exec(received);
3438
+ if (inReceived) return inReceived[0];
3439
+ }
3440
+ return null;
3441
+ }
3442
+ function parsePlaywrightError(raw, context) {
3443
+ const clean = stripAnsi(raw ?? "").replace(/\r\n?/g, "\n");
3444
+ const text = withoutStackFrames(clean);
3445
+ const messageHead = extractMessageHead(clean);
3446
+ const firstLine = messageHead.split("\n")[0] ?? "";
3447
+ const errorName = ERROR_NAME_RE.exec(firstLine)?.[1] ?? null;
3448
+ const strict = STRICT_RE.exec(text);
3449
+ const call = CALL_RE.exec(text);
3450
+ const callLog = readCallLog(text);
3451
+ const testTimeout = TEST_TIMEOUT_RE.exec(text);
3452
+ let subject2 = null;
3453
+ let action = null;
3454
+ let assertion = null;
3455
+ let negated = false;
3456
+ if (call) {
3457
+ subject2 = call[1];
3458
+ if (subject2 === "expect") assertion = call[2];
3459
+ else action = call[2];
3460
+ }
3461
+ const matcher = MATCHER_RE.exec(text);
3462
+ const matcherShort = MATCHER_SHORT_RE.exec(text);
3463
+ if (matcher) {
3464
+ assertion = matcher[2];
3465
+ negated = Boolean(matcher[1]);
3466
+ } else if (!assertion && matcherShort) {
3467
+ assertion = matcherShort[1];
3468
+ } else if (!assertion && callLog.matcher) {
3469
+ assertion = callLog.matcher;
3470
+ }
3471
+ if (/\bexpect\((?:[^()]|\([^()]*\))*\)\.not\./.test(text)) negated = true;
3472
+ const isAssertion = assertion !== null || /\bexpect\(|\bexpect\.|Expected (?:string|substring|pattern|value)/.test(text);
3473
+ const isNavigationFailure = NAVIGATION_RE.test(text);
3474
+ const networkErrorCode = NETWORK_CODE_RE.exec(text)?.[1] ?? null;
3475
+ let kind;
3476
+ if (strict) kind = "strict-mode";
3477
+ else if (testTimeout) kind = "test-timeout";
3478
+ else if (CRASH_RE.test(text)) kind = "crash";
3479
+ else if (isAssertion) {
3480
+ const retrying = /^\s*Timeout:[ \t]*\d+ms/m.test(text) || TIMED_OUT_EXPECT_RE.test(text) || callLog.matcher !== null;
3481
+ kind = retrying ? "assertion-timeout" : "assertion";
3482
+ } else if (isNavigationFailure) kind = "navigation";
3483
+ else if (/\bTimeout \d+ms exceeded/.test(text) || errorName === "TimeoutError") kind = "action-timeout";
3484
+ else kind = "unknown";
3485
+ const paramsLocator = typeof context?.stepParams?.locator === "string" ? context.stepParams.locator : null;
3486
+ const paramsUrl = typeof context?.stepParams?.url === "string" ? context.stepParams.url : null;
3487
+ const locator = readLocator(text, strict) ?? paramsLocator;
3488
+ const leafLocator = locator ? extractLeafSelector(locator) : null;
3489
+ const expected = headerValue(text, "Expected");
3490
+ const received = headerValue(text, "Received");
3491
+ const timeoutMs = readTimeout(text, callLog);
3492
+ const url = readUrl(text, callLog, received) ?? paramsUrl;
3493
+ const frame = extractTopFrame(clean);
3494
+ const resolvedCount = strict ? Number(strict[2]) : callLog.count;
3495
+ const lastState = strict ? "resolved-count" : callLog.state;
3496
+ const isLocatorResolutionFailure = kind === "strict-mode" || locator !== null && (lastState === "not-found" || lastState === "resolved-count" && resolvedCount === 0);
3497
+ return {
3498
+ kind,
3499
+ errorName,
3500
+ subject: subject2,
3501
+ action,
3502
+ assertion,
3503
+ negated,
3504
+ locator,
3505
+ leafLocator,
3506
+ expected,
3507
+ received,
3508
+ timeoutMs,
3509
+ url,
3510
+ networkErrorCode,
3511
+ timeoutPhase: testTimeout ? testTimeout[2] ?? testTimeout[3] ?? null : null,
3512
+ lastState,
3513
+ resolvedCount,
3514
+ lastCallLogLine: callLog.lastLine,
3515
+ lastStateLine: callLog.stateLine,
3516
+ messageHead,
3517
+ topFrame: frame ? `${frame.file}:${frame.line}` : null,
3518
+ isNavigationFailure,
3519
+ isLocatorResolutionFailure
3520
+ };
3521
+ }
3522
+
3523
+ // ../core/src/describe-failure.ts
3524
+ var HEADLINE_MAX_CHARS = 120;
3525
+ var VALUE_MAX_CHARS = 40;
3526
+ var SHORT_VALUE_MAX_CHARS = 20;
3527
+ var MASK_TOKEN_RE = /<(?:N|VALUE|URL|STR|UUID|HASH|EMAIL)>/g;
3528
+ var ACTION_VERBS = {
3529
+ click: "click",
3530
+ dblclick: "double-click",
3531
+ fill: "fill",
3532
+ type: "type",
3533
+ press: "press",
3534
+ pressSequentially: "type",
3535
+ check: "check",
3536
+ uncheck: "uncheck",
3537
+ hover: "hover",
3538
+ tap: "tap",
3539
+ focus: "focus",
3540
+ blur: "blur",
3541
+ clear: "clear",
3542
+ selectOption: "select",
3543
+ selectText: "select text",
3544
+ setInputFiles: "file upload",
3545
+ setChecked: "check",
3546
+ dragTo: "drag",
3547
+ dragAndDrop: "drag",
3548
+ scrollIntoViewIfNeeded: "scroll",
3549
+ screenshot: "screenshot",
3550
+ waitFor: "wait",
3551
+ waitForSelector: "waitForSelector",
3552
+ waitForLoadState: "waitForLoadState",
3553
+ waitForFunction: "waitForFunction",
3554
+ waitForResponse: "waitForResponse",
3555
+ waitForRequest: "waitForRequest",
3556
+ waitForEvent: "waitForEvent",
3557
+ waitForTimeout: "waitForTimeout",
3558
+ innerText: "read text",
3559
+ textContent: "read text",
3560
+ inputValue: "read value",
3561
+ getAttribute: "read attribute",
3562
+ isVisible: "visibility check",
3563
+ isEnabled: "enabled check",
3564
+ isChecked: "checked check",
3565
+ boundingBox: "measure",
3566
+ evaluate: "evaluate",
3567
+ goto: "navigation"
3568
+ };
3569
+ var ACTION_GERUNDS = {
3570
+ click: "clicking",
3571
+ dblclick: "double-clicking",
3572
+ fill: "filling",
3573
+ type: "typing into",
3574
+ press: "pressing a key on",
3575
+ pressSequentially: "typing into",
3576
+ check: "checking",
3577
+ uncheck: "unchecking",
3578
+ hover: "hovering",
3579
+ tap: "tapping",
3580
+ focus: "focusing",
3581
+ clear: "clearing",
3582
+ selectOption: "selecting an option in",
3583
+ setInputFiles: "uploading a file to",
3584
+ dragTo: "dragging",
3585
+ waitFor: "waiting for",
3586
+ waitForSelector: "waiting for",
3587
+ evaluate: "evaluating on"
3588
+ };
3589
+ var STATE_PHRASES = {
3590
+ "not-found": "was not found on the page",
3591
+ hidden: "never became visible",
3592
+ "not-visible": "never became visible",
3593
+ "not-enabled": "never became enabled",
3594
+ "not-editable": "never became editable",
3595
+ "not-stable": "never stopped moving",
3596
+ "outside-viewport": "stayed outside the viewport",
3597
+ "intercepts-pointer": "was covered by another element",
3598
+ detached: "was detached from the DOM",
3599
+ navigating: "was still navigating"
3600
+ };
3601
+ var STATE_MATCHERS = {
3602
+ toBeVisible: { met: "visible", unmet: "never became visible" },
3603
+ toBeHidden: { met: "hidden", unmet: "never became hidden" },
3604
+ toBeEnabled: { met: "enabled", unmet: "never became enabled" },
3605
+ toBeDisabled: { met: "disabled", unmet: "never became disabled" },
3606
+ toBeChecked: { met: "checked", unmet: "never became checked" },
3607
+ toBeEditable: { met: "editable", unmet: "never became editable" },
3608
+ toBeFocused: { met: "focused", unmet: "never received focus" },
3609
+ toBeAttached: { met: "attached", unmet: "never appeared in the DOM" },
3610
+ toBeDetached: { met: "detached", unmet: "never left the DOM" },
3611
+ toBeInViewport: { met: "in the viewport", unmet: "never entered the viewport" },
3612
+ toBeEmpty: { met: "empty", unmet: "never became empty" }
3613
+ };
3614
+ var VALUE_MATCHERS = {
3615
+ toHaveText: "text",
3616
+ toContainText: "text containing",
3617
+ toHaveValue: "value",
3618
+ toHaveValues: "values",
3619
+ toHaveAttribute: "attribute",
3620
+ toHaveClass: "class",
3621
+ toContainClass: "class",
3622
+ toHaveId: "id",
3623
+ toHaveCSS: "CSS",
3624
+ toHaveJSProperty: "property",
3625
+ toHaveAccessibleName: "accessible name",
3626
+ toHaveAccessibleDescription: "accessible description",
3627
+ toHaveRole: "role",
3628
+ toHaveURL: "URL",
3629
+ toHaveTitle: "title",
3630
+ toHaveScreenshot: "screenshot",
3631
+ toMatchAriaSnapshot: "ARIA snapshot"
3632
+ };
3633
+ var NETWORK_ERRORS = {
3634
+ ERR_CONNECTION_REFUSED: "Connection refused loading",
3635
+ ERR_CONNECTION_RESET: "Connection reset loading",
3636
+ ERR_CONNECTION_CLOSED: "Connection closed loading",
3637
+ ERR_CONNECTION_TIMED_OUT: "Connection timed out loading",
3638
+ ERR_TIMED_OUT: "Connection timed out loading",
3639
+ ERR_NAME_NOT_RESOLVED: "DNS lookup failed for",
3640
+ ERR_INTERNET_DISCONNECTED: "No network while loading",
3641
+ ERR_ADDRESS_UNREACHABLE: "Address unreachable loading",
3642
+ ERR_ABORTED: "Navigation aborted loading",
3643
+ ERR_EMPTY_RESPONSE: "Empty response loading",
3644
+ ERR_TOO_MANY_REDIRECTS: "Too many redirects loading",
3645
+ ERR_SSL_PROTOCOL_ERROR: "TLS error loading",
3646
+ ERR_CERT_AUTHORITY_INVALID: "Untrusted certificate loading",
3647
+ ERR_CERT_COMMON_NAME_INVALID: "Certificate name mismatch loading",
3648
+ ERR_CERT_DATE_INVALID: "Expired certificate loading",
3649
+ ERR_BLOCKED_BY_CLIENT: "Request blocked loading",
3650
+ ERR_FAILED: "Request failed loading",
3651
+ ERR_HTTP_RESPONSE_CODE_FAILURE: "HTTP error loading",
3652
+ NS_ERROR_CONNECTION_REFUSED: "Connection refused loading",
3653
+ NS_ERROR_UNKNOWN_HOST: "DNS lookup failed for",
3654
+ NS_ERROR_NET_TIMEOUT: "Connection timed out loading",
3655
+ NS_ERROR_OFFLINE: "No network while loading",
3656
+ NS_ERROR_ABORT: "Navigation aborted loading",
3657
+ NS_BINDING_ABORTED: "Navigation aborted loading"
3658
+ };
3659
+ function formatTimeout(ms) {
3660
+ if (ms < 1e3) return `${ms} ms`;
3661
+ const seconds = ms / 1e3;
3662
+ const rounded = Math.round(seconds * 10) / 10;
3663
+ return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded} s`;
3664
+ }
3665
+ function routeOf(url) {
3666
+ try {
3667
+ const parsed = new URL(url);
3668
+ return `${parsed.pathname}${parsed.search}` || "/";
3669
+ } catch {
3670
+ return url;
3671
+ }
3672
+ }
3673
+ function truncateValue(value, max) {
3674
+ const flat = value.replace(/\s+/g, " ").trim();
3675
+ return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
3676
+ }
3677
+ function countNoun(locator, count) {
3678
+ const role = locator ? /getByRole\(\s*['"]([a-z]+)['"]/.exec(locator)?.[1] : null;
3679
+ const noun = role ?? "element";
3680
+ if (count === 1) return noun;
3681
+ return noun.endsWith("s") ? noun : noun.endsWith("x") || noun.endsWith("ch") ? `${noun}es` : `${noun}s`;
3682
+ }
3683
+ function textOfGetByText(locator) {
3684
+ if (!locator) return null;
3685
+ const m = /^getByText\(\s*(['"`])((?:\\.|(?!\1).)*)\1\s*(?:,\s*\{[^}]*\})?\s*\)$/.exec(locator);
3686
+ return m ? m[2] : null;
3687
+ }
3688
+ function receivedDisplay(parsed) {
3689
+ const received = parsed.received ?? "";
3690
+ if (parsed.assertion !== "toHaveURL") return received;
3691
+ const m = /^"?(https?:\/\/[^"\s]+)"?$/.exec(received);
3692
+ return m ? `"${routeOf(m[1])}"` : received;
3693
+ }
3694
+ function firstLineFallback(parsed) {
3695
+ const line = (parsed.messageHead.split("\n")[0] ?? "").replace(/^Error:\s*/, "").replace(MASK_TOKEN_RE, "\u2026");
3696
+ return truncateValue(line || "Unknown error", HEADLINE_MAX_CHARS);
3697
+ }
3698
+ var Line = class {
3699
+ constructor() {
3700
+ this.parts = [];
3701
+ }
3702
+ text(text) {
3703
+ if (!text) return this;
3704
+ const last = this.parts[this.parts.length - 1];
3705
+ if (last && last.kind === "text") last.text += text;
3706
+ else this.parts.push({ kind: "text", text });
3707
+ return this;
3708
+ }
3709
+ locator(text) {
3710
+ this.parts.push({ kind: "locator", text });
3711
+ return this;
3712
+ }
3713
+ value(text) {
3714
+ this.parts.push({ kind: "value", text });
3715
+ return this;
3716
+ }
3717
+ toString() {
3718
+ return this.parts.map((p) => p.text).join("");
3719
+ }
3720
+ };
3721
+ function subject(line, locator, opts) {
3722
+ const text = textOfGetByText(locator);
3723
+ if (text !== null) return line.text("Text ").value(`"${truncateValue(text, opts.valueMax)}"`);
3724
+ return line.locator(opts.locator ?? locator ?? "");
3725
+ }
3726
+ function timeoutSuffix(timeoutMs) {
3727
+ return timeoutMs !== null ? ` after ${formatTimeout(timeoutMs)}` : "";
3728
+ }
3729
+ function timeoutParen(timeoutMs) {
3730
+ return timeoutMs !== null ? ` (${formatTimeout(timeoutMs)})` : "";
3731
+ }
3732
+ function buildActionTimeout(parsed, opts) {
3733
+ const line = new Line();
3734
+ const verb = ACTION_VERBS[parsed.action ?? ""] ?? parsed.action ?? "action";
3735
+ const state = STATE_PHRASES[parsed.lastState];
3736
+ if (parsed.locator && state) {
3737
+ return subject(line, parsed.locator, opts).text(` ${state} \u2014 ${verb} timed out`).text(timeoutSuffix(parsed.timeoutMs));
3738
+ }
3739
+ if (parsed.locator && parsed.lastState === "resolved-count" && parsed.resolvedCount !== null) {
3740
+ const count = parsed.resolvedCount;
3741
+ return subject(line, parsed.locator, opts).text(` matched ${count === 0 ? "no" : count} ${countNoun(parsed.locator, count)} \u2014 ${verb} timed out`).text(timeoutSuffix(parsed.timeoutMs));
3742
+ }
3743
+ if (parsed.locator) {
3744
+ return line.text(`${verb} on `).locator(opts.locator ?? parsed.locator).text(" timed out").text(timeoutSuffix(parsed.timeoutMs));
3745
+ }
3746
+ const call = parsed.subject && parsed.action ? `${parsed.subject}.${parsed.action}` : verb;
3747
+ return line.text(`${call} timed out`).text(timeoutSuffix(parsed.timeoutMs));
3748
+ }
3749
+ function buildAssertion(parsed, opts) {
3750
+ const line = new Line();
3751
+ const matcher = parsed.assertion ?? "expect";
3752
+ const notFound = parsed.lastState === "not-found" || parsed.lastState === "resolved-count" && parsed.resolvedCount === 0 || /element\(s\) not found/.test(parsed.received ?? "");
3753
+ const expected = parsed.expected ? truncateValue(parsed.expected, opts.valueMax) : null;
3754
+ const received = parsed.received ? truncateValue(receivedDisplay(parsed), opts.valueMax) : null;
3755
+ if (matcher === "toHaveCount" && parsed.locator) {
3756
+ const want = Number(parsed.expected);
3757
+ const got = notFound ? 0 : Number(parsed.received);
3758
+ const wantText = Number.isFinite(want) ? String(want) : expected ?? "?";
3759
+ const noun2 = countNoun(parsed.locator, Number.isFinite(want) ? want : 2);
3760
+ const gotText = Number.isFinite(got) ? got === 0 ? "none" : String(got) : received ?? "none";
3761
+ return line.text(`Expected ${wantText} ${noun2}, found ${gotText} \u2014 `).locator(opts.locator ?? parsed.locator).text(" toHaveCount");
3762
+ }
3763
+ const state = STATE_MATCHERS[matcher];
3764
+ if (state) {
3765
+ const unmet = parsed.negated ? `stayed ${state.met}` : state.unmet;
3766
+ if (!parsed.locator) return line.text(`Expected ${state.met}, page ${unmet}`).text(timeoutParen(parsed.timeoutMs));
3767
+ if (notFound && matcher !== "toBeVisible" && matcher !== "toBeAttached") {
3768
+ return subject(line, parsed.locator, opts).text(` was not found on the page \u2014 expected ${state.met}`).text(timeoutParen(parsed.timeoutMs));
3769
+ }
3770
+ return subject(line, parsed.locator, opts).text(` ${unmet}`).text(timeoutParen(parsed.timeoutMs));
3771
+ }
3772
+ const noun = VALUE_MATCHERS[matcher];
3773
+ if (noun) {
3774
+ if (parsed.locator && notFound) {
3775
+ return subject(line, parsed.locator, opts).text(" was not found on the page \u2014 expected ").text(`${noun} `).value(expected ?? "").text(timeoutParen(parsed.timeoutMs));
3776
+ }
3777
+ line.text(`Expected ${noun} `);
3778
+ if (expected) line.value(expected);
3779
+ else line.text("to match");
3780
+ if (received) line.text(", got ").value(received);
3781
+ if (parsed.locator)
3782
+ line.text(" \u2014 ").locator(opts.locator ?? parsed.locator).text(` ${matcher}`);
3783
+ else if (matcher === "toHaveURL" || matcher === "toHaveTitle") line.text(` \u2014 page ${matcher}`);
3784
+ return line;
3785
+ }
3786
+ if (matcher === "toPass") return line.text("expect.toPass never passed").text(timeoutParen(parsed.timeoutMs));
3787
+ if (expected || received) {
3788
+ line.text("Expected ");
3789
+ if (expected) line.value(expected);
3790
+ else line.text("a different value");
3791
+ if (received) line.text(", got ").value(received);
3792
+ line.text(` \u2014 ${matcher}`);
3793
+ if (parsed.locator) line.text(" on ").locator(opts.locator ?? parsed.locator);
3794
+ return line;
3795
+ }
3796
+ if (parsed.locator) {
3797
+ return subject(line, parsed.locator, opts).text(` failed ${matcher}`).text(timeoutParen(parsed.timeoutMs));
3798
+ }
3799
+ return line.text(`${matcher} assertion failed`);
3800
+ }
3801
+ function buildStrictMode(parsed, opts) {
3802
+ const line = new Line();
3803
+ const count = parsed.resolvedCount ?? 0;
3804
+ if (!parsed.locator) return line.text(`Locator matched ${count} elements \u2014 strict mode`);
3805
+ return line.locator(opts.locator ?? parsed.locator).text(` matched ${count} ${countNoun(parsed.locator, count)} \u2014 strict mode`);
3806
+ }
3807
+ function buildNavigation(parsed, opts) {
3808
+ const line = new Line();
3809
+ const code = parsed.networkErrorCode?.replace(/^net::/, "") ?? null;
3810
+ if (code) {
3811
+ const phrase = NETWORK_ERRORS[code] ?? `${code.replace(/^(?:ERR|NS_ERROR)_/, "").replace(/_/g, " ").toLowerCase().replace(/^\w/, (c) => c.toUpperCase())} loading`;
3812
+ line.text(phrase);
3813
+ if (parsed.url) line.text(" ").value(truncateValue(parsed.url, opts.valueMax * 2));
3814
+ return line;
3815
+ }
3816
+ const route = parsed.url ? routeOf(parsed.url) : null;
3817
+ const timedOut = parsed.timeoutMs !== null || parsed.errorName === "TimeoutError";
3818
+ if (parsed.action === "waitForURL" || parsed.action === "waitForNavigation") {
3819
+ line.text("Never navigated to ");
3820
+ if (route) line.value(truncateValue(route, opts.valueMax));
3821
+ else line.text("the expected URL");
3822
+ return line.text(` \u2014 ${parsed.action} timed out`).text(timeoutSuffix(parsed.timeoutMs));
3823
+ }
3824
+ line.text("Navigation");
3825
+ if (route) line.text(" to ").value(truncateValue(route, opts.valueMax));
3826
+ if (timedOut) return line.text(" timed out").text(timeoutSuffix(parsed.timeoutMs));
3827
+ return line.text(" failed");
3828
+ }
3829
+ function buildTestTimeout(parsed, opts, ctx) {
3830
+ const line = new Line().text("Test timed out").text(timeoutSuffix(parsed.timeoutMs));
3831
+ if (parsed.timeoutPhase) {
3832
+ const phase = parsed.timeoutPhase;
3833
+ const isHook = /^(?:before|after)(?:Each|All)$/.test(phase);
3834
+ return line.text(isHook ? ` in the "${phase}" hook` : ` while tearing down "${phase}"`);
3835
+ }
3836
+ if (parsed.isNavigationFailure && parsed.url) {
3837
+ return line.text(" while navigating to ").value(truncateValue(routeOf(parsed.url), opts.valueMax));
3838
+ }
3839
+ if (parsed.assertion && parsed.locator) {
3840
+ const state = STATE_MATCHERS[parsed.assertion];
3841
+ line.text(state ? ` while waiting for ` : " while expecting ");
3842
+ subject(line, parsed.locator, opts);
3843
+ return line.text(state ? ` to be ${state.met}` : ` ${parsed.assertion}`);
3844
+ }
3845
+ if (parsed.action && parsed.locator) {
3846
+ const gerund = ACTION_GERUNDS[parsed.action] ?? `${ACTION_VERBS[parsed.action] ?? parsed.action} on`;
3847
+ line.text(` while ${gerund} `);
3848
+ return subject(line, parsed.locator, opts);
3849
+ }
3850
+ if (parsed.subject && parsed.action) return line.text(` during ${parsed.subject}.${parsed.action}`);
3851
+ const step = ctx?.lastStepTitle?.trim();
3852
+ if (step) return line.text(" while ").value(`"${truncateValue(step, opts.valueMax * 1.5)}"`);
3853
+ return line;
3854
+ }
3855
+ function buildCrash(parsed, opts) {
3856
+ const line = new Line();
3857
+ const what = /Page crashed/i.test(parsed.messageHead) ? "Page crashed" : "Page or browser closed";
3858
+ if (parsed.action) {
3859
+ const verb = ACTION_VERBS[parsed.action] ?? parsed.action;
3860
+ line.text(`${what} during ${verb}`);
3861
+ if (parsed.locator) line.text(" on ").locator(opts.locator ?? parsed.locator);
3862
+ return line;
3863
+ }
3864
+ if (parsed.assertion && parsed.locator) {
3865
+ return line.text(`${what} while expecting `).locator(opts.locator ?? parsed.locator).text(` ${parsed.assertion}`);
3866
+ }
3867
+ return line.text(what);
3868
+ }
3869
+ function build(parsed, opts, ctx) {
3870
+ switch (parsed.kind) {
3871
+ case "action-timeout":
3872
+ return buildActionTimeout(parsed, opts);
3873
+ case "assertion":
3874
+ case "assertion-timeout":
3875
+ return buildAssertion(parsed, opts);
3876
+ case "strict-mode":
3877
+ return buildStrictMode(parsed, opts);
3878
+ case "navigation":
3879
+ return buildNavigation(parsed, opts);
3880
+ case "test-timeout":
3881
+ return buildTestTimeout(parsed, opts, ctx);
3882
+ case "crash":
3883
+ return buildCrash(parsed, opts);
3884
+ default:
3885
+ return new Line().text(firstLineFallback(parsed));
3886
+ }
3887
+ }
3888
+ function detailOf(parsed, headline) {
3889
+ switch (parsed.kind) {
3890
+ case "test-timeout":
3891
+ case "action-timeout": {
3892
+ const state = parsed.lastStateLine;
3893
+ if (!state || parsed.lastState === "not-found" || headline.includes(state)) return null;
3894
+ return truncateValue(state, HEADLINE_MAX_CHARS);
3895
+ }
3896
+ case "assertion":
3897
+ case "assertion-timeout": {
3898
+ const received = parsed.received;
3899
+ if (received && !/element\(s\) not found/.test(received)) {
3900
+ const shown = truncateValue(receivedDisplay(parsed), SHORT_VALUE_MAX_CHARS).slice(0, 8);
3901
+ if (!headline.includes(shown)) return truncateValue(`Received: ${received}`, HEADLINE_MAX_CHARS);
3902
+ }
3903
+ const state = parsed.lastStateLine;
3904
+ if (!state || /^(?:waiting for |unexpected value )/.test(state)) return null;
3905
+ return truncateValue(state, HEADLINE_MAX_CHARS);
3906
+ }
3907
+ case "navigation":
3908
+ return parsed.networkErrorCode ?? parsed.lastStateLine;
3909
+ case "strict-mode":
3910
+ case "crash":
3911
+ return null;
3912
+ default:
3913
+ return null;
3914
+ }
3915
+ }
3916
+ function describeFailure(parsed, ctx) {
3917
+ const attempts = [
3918
+ { locator: parsed.locator, valueMax: VALUE_MAX_CHARS },
3919
+ { locator: parsed.leafLocator ?? parsed.locator, valueMax: VALUE_MAX_CHARS },
3920
+ { locator: parsed.leafLocator ?? parsed.locator, valueMax: SHORT_VALUE_MAX_CHARS }
3921
+ ];
3922
+ let line = build(parsed, attempts[0], ctx);
3923
+ for (const opts of attempts.slice(1)) {
3924
+ if (line.toString().length <= HEADLINE_MAX_CHARS) break;
3925
+ line = build(parsed, opts, ctx);
3926
+ }
3927
+ let parts = line.parts.map((p) => ({ ...p, text: p.text.replace(MASK_TOKEN_RE, "\u2026") }));
3928
+ let headline = parts.map((p) => p.text).join("");
3929
+ if (headline.length > HEADLINE_MAX_CHARS) {
3930
+ headline = `${headline.slice(0, HEADLINE_MAX_CHARS - 1)}\u2026`;
3931
+ parts = clipParts(parts, HEADLINE_MAX_CHARS - 1);
3932
+ parts.push({ kind: "text", text: "\u2026" });
3933
+ }
3934
+ if (!headline.trim()) {
3935
+ headline = firstLineFallback(parsed);
3936
+ parts = [{ kind: "text", text: headline }];
3937
+ }
3938
+ return { headline, detail: detailOf(parsed, headline), parts };
3939
+ }
3940
+ function clipParts(parts, max) {
3941
+ const out = [];
3942
+ let used = 0;
3943
+ for (const part of parts) {
3944
+ if (used >= max) break;
3945
+ const text = part.text.slice(0, max - used);
3946
+ if (text) out.push({ kind: part.kind, text });
3947
+ used += text.length;
3948
+ }
3949
+ return out;
3950
+ }
3951
+ function describeFailureText(raw, ctx) {
3952
+ if (!raw || !raw.trim()) return null;
3953
+ return describeFailure(parsePlaywrightError(raw, { stepParams: ctx?.stepParams }), ctx);
3954
+ }
3955
+ function lastStepTitle(steps) {
3956
+ if (!steps || steps.length === 0) return null;
3957
+ const failed = steps.find((s) => s.failed);
3958
+ return (failed ?? steps[steps.length - 1])?.title ?? null;
3959
+ }
3960
+
3961
+ // src/internal/support/failure-links.ts
3962
+ function failureHeadline(error, steps) {
3963
+ return describeFailureText(error, { lastStepTitle: lastStepTitle(steps) })?.headline ?? null;
3964
+ }
3965
+ function caseLocateUrl(serverUrl, runId, test) {
3966
+ const params = [
3967
+ `file=${encodeURIComponent(test.file)}`,
3968
+ `title=${encodeURIComponent(test.title)}`,
3969
+ `retry=${test.retry}`
3970
+ ];
3971
+ if (test.browser) params.push(`browser=${encodeURIComponent(test.browser)}`);
3972
+ return `${serverUrl.replace(/\/+$/, "")}/test-runs/${runId}/locate?${params.join("&")}`;
3973
+ }
3974
+ function formatFailureLine(link) {
3975
+ const headline = link.headline ? ` \u2014 ${link.headline}` : "";
3976
+ return `\u2717 ${link.title}${headline} \u2192 ${link.url}`;
3977
+ }
3978
+ var FailureLinks = class {
3979
+ constructor(serverUrl, logger) {
3980
+ this.serverUrl = serverUrl;
3981
+ this.logger = logger;
3982
+ this.failures = [];
3983
+ this.printed = 0;
3984
+ }
3985
+ /** Number of failed tests recorded so far. */
3986
+ get count() {
3987
+ return this.failures.length;
3988
+ }
3989
+ add(test) {
3990
+ this.failures.push(test);
3991
+ }
3992
+ /** Every recorded failure with its link under `runId`. */
3993
+ resolve(runId) {
3994
+ return this.failures.map((test) => ({ ...test, url: caseLocateUrl(this.serverUrl, runId, test) }));
3995
+ }
3996
+ /** Print the lines that have not been printed yet. */
3997
+ printPending(runId) {
3998
+ const links = this.resolve(runId);
3999
+ for (const link of links.slice(this.printed)) this.logger.info(formatFailureLine(link));
4000
+ this.printed = links.length;
4001
+ }
4002
+ };
4003
+
2608
4004
  // src/public/reporter.ts
2609
4005
  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}`;
4006
+ return `${testFile(test)}:${test.location.line}:${test.location.column}`;
4007
+ }
4008
+ function testFile(test) {
4009
+ return path15.relative(process.cwd(), test.location.file).split(path15.sep).join("/");
2612
4010
  }
2613
4011
  var PiwiDashboardReporter = class _PiwiDashboardReporter {
2614
4012
  constructor(rawOptions = {}) {
@@ -2640,6 +4038,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2640
4038
  this.setupSteps = [];
2641
4039
  this.options = resolveOptions(rawOptions);
2642
4040
  this.enabled = this.options.enabled !== false && !!this.options.serverUrl;
4041
+ this.listMode = isListMode();
2643
4042
  this.viaDesktopApp = usedDesktopDiscovery();
2644
4043
  this.runLabel = this.options.runLabel || detectCiRunLabel();
2645
4044
  this.instanceId = computeInstanceId(this.options.projectName, this.runLabel);
@@ -2650,6 +4049,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2650
4049
  this.uploader = new Uploader(this.httpClient, this.fileHandler, logger);
2651
4050
  this.recovery = new CrashRecovery(this.options.projectName, logger);
2652
4051
  this.metadataCollector = new MetadataCollector(logger);
4052
+ this.failureLinks = new FailureLinks(this.httpClient.baseUrl, logger);
2653
4053
  const streamBuffer = new StreamBuffer(this.options.projectName);
2654
4054
  streamBuffer.clearStale();
2655
4055
  if (this.options.streaming) {
@@ -2663,7 +4063,14 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2663
4063
  logger
2664
4064
  );
2665
4065
  }
2666
- this.submitter = new RunSubmitter(this.httpClient, this.uploader, this.recovery, this.streamManager, logger);
4066
+ this.submitter = new RunSubmitter(
4067
+ this.httpClient,
4068
+ this.uploader,
4069
+ this.recovery,
4070
+ this.streamManager,
4071
+ logger,
4072
+ this.failureLinks
4073
+ );
2667
4074
  }
2668
4075
  static {
2669
4076
  this.wrapConfig = wrapConfig;
@@ -2673,6 +4080,10 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2673
4080
  }
2674
4081
  /** Playwright reporter hook: called once at the start of the test run */
2675
4082
  onBegin(config, suite) {
4083
+ if (this.listMode) {
4084
+ this.logger.debug("List mode (--list) detected \u2014 no run registered or report uploaded.");
4085
+ return;
4086
+ }
2676
4087
  if (!this.enabled) {
2677
4088
  this.logger.info("Not enabled \u2014 set PIWI_DASHBOARD_URL or serverUrl to enable.");
2678
4089
  return;
@@ -2686,6 +4097,12 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2686
4097
  this.logger.info(
2687
4098
  `Starting test run for project: ${this.options.projectName} (Playwright v${this.playwrightVersion})`
2688
4099
  );
4100
+ const defaulted = process.env[PIWI_DEFAULTED_CAPTURE_ENV];
4101
+ if (defaulted) {
4102
+ this.logger.info(
4103
+ `Defaulted Playwright ${defaulted} for failure evidence (set defaultCapture: false to opt out).`
4104
+ );
4105
+ }
2689
4106
  const rawConfig = config;
2690
4107
  const grepRe = rawConfig.grep instanceof RegExp ? rawConfig.grep : void 0;
2691
4108
  const grepInvertRe = rawConfig.grepInvert instanceof RegExp ? rawConfig.grepInvert : void 0;
@@ -2756,6 +4173,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2756
4173
  const event = {
2757
4174
  type: "step-begin",
2758
4175
  title: step.title,
4176
+ subtitle: typeof step.subtitle === "string" && step.subtitle.length > 0 ? step.subtitle : null,
2759
4177
  location: step.location ? `${step.location.file}:${step.location.line}:${step.location.column}` : "unknown",
2760
4178
  stepCategory: cat,
2761
4179
  parentTitle: test?.title || null,
@@ -2774,6 +4192,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2774
4192
  const event = {
2775
4193
  type: "step-end",
2776
4194
  title: step.title,
4195
+ subtitle: typeof step.subtitle === "string" && step.subtitle.length > 0 ? step.subtitle : null,
2777
4196
  location: step.location ? `${step.location.file}:${step.location.line}:${step.location.column}` : "unknown",
2778
4197
  status: step.error ? "failed" : "passed",
2779
4198
  duration: step.duration || 0,
@@ -2803,6 +4222,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2803
4222
  const annotations = mergeAnnotations(test, result);
2804
4223
  const status = classifyStatus(result.status, annotations);
2805
4224
  const tags = collectTestTags(test);
4225
+ const locks = collectTestLocks(test);
2806
4226
  const attempts = this.attemptsByTest.get(test.id) ?? [];
2807
4227
  attempts.push({
2808
4228
  retry: result.retry,
@@ -2832,6 +4252,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2832
4252
  suiteConfig,
2833
4253
  testAnnotations: annotations.length ? annotations : null,
2834
4254
  tags: tags.length ? tags : null,
4255
+ locks: locks.length ? locks : null,
2835
4256
  testMeta: collectTestMetadata(annotations),
2836
4257
  // An annotation-less skip reclassified to `didnotrun` is a serial-group
2837
4258
  // cascade: an earlier test failed and Playwright skipped the rest.
@@ -2880,6 +4301,18 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2880
4301
  }
2881
4302
  this.testCases.push(testCase);
2882
4303
  if (status === "didnotrun") linkBlockedTests(this.testCases);
4304
+ const isFailure = status === "failed" || status === "timedOut";
4305
+ if (isFailure && result.retry >= (test.retries ?? 0)) {
4306
+ this.failureLinks.add({
4307
+ title: test.title,
4308
+ file: testFile(test),
4309
+ retry: result.retry,
4310
+ browser: typeof testCase.browser?.projectName === "string" ? testCase.browser.projectName : null,
4311
+ headline: failureHeadline(testCase.error, testCase.performanceMetrics?.steps)
4312
+ });
4313
+ }
4314
+ const liveRunId = this.streamManager?.runId;
4315
+ if (liveRunId != null) this.failureLinks.printPending(liveRunId);
2883
4316
  if (this.streamManager) {
2884
4317
  this.streamManager.queueEvent(toWireTestCase(testCase));
2885
4318
  if (this.options.liveFileUploads) this.streamManager.scheduleLiveUpload(testCase);
@@ -2899,6 +4332,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2899
4332
  const { suitePath, suiteConfig } = this.metadataCollector.getSuiteInfo(test);
2900
4333
  const declaredAnnotations = test.annotations ?? [];
2901
4334
  const tags = collectTestTags(test);
4335
+ const locks = collectTestLocks(test);
2902
4336
  const testCase = {
2903
4337
  type: "complete",
2904
4338
  title: test.title,
@@ -2917,6 +4351,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2917
4351
  suiteConfig,
2918
4352
  testAnnotations: declaredAnnotations.length ? declaredAnnotations : null,
2919
4353
  tags: tags.length ? tags : null,
4354
+ locks: locks.length ? locks : null,
2920
4355
  testMeta: collectTestMetadata(declaredAnnotations),
2921
4356
  didNotRunReason: reason
2922
4357
  };
@@ -2930,34 +4365,38 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2930
4365
  }
2931
4366
  /** Playwright reporter hook: called when the full test run finishes */
2932
4367
  async onEnd(result) {
2933
- if (!this.enabled) return;
4368
+ if (this.listMode || !this.enabled) return;
2934
4369
  const unrunReason = resolveUnrunReason(result?.status, {
2935
4370
  maxFailures: this.maxFailures,
2936
4371
  failures: this.failedTests + this.timedOutTests
2937
4372
  });
2938
4373
  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
- );
4374
+ try {
4375
+ await this.submitter.submit(
4376
+ {
4377
+ options: this.options,
4378
+ testCases: this.testCases,
4379
+ startTime: this.startTime,
4380
+ playwrightVersion: this.playwrightVersion,
4381
+ reporterVersion: this.reporterVersion,
4382
+ totalTests: this.totalTests,
4383
+ passedTests: this.passedTests,
4384
+ failedTests: this.failedTests,
4385
+ skippedTests: this.skippedTests,
4386
+ timedOutTests: this.timedOutTests,
4387
+ didNotRunTests: this.didNotRunTests,
4388
+ metadata: this.metadata,
4389
+ instanceId: this.instanceId,
4390
+ shardInfo: this.shardInfo,
4391
+ setupSteps: this.setupSteps,
4392
+ isFullRun: this.isFullRun,
4393
+ filterDetails: this.filterDetails
4394
+ },
4395
+ result
4396
+ );
4397
+ } finally {
4398
+ this.fileHandler.cleanupBodyAttachments();
4399
+ }
2961
4400
  }
2962
4401
  };
2963
4402
 
@@ -3165,15 +4604,24 @@ function probeElementAttrs(el, arg) {
3165
4604
  let index = -1;
3166
4605
  let levelCount = 0;
3167
4606
  let roleNameCount = 0;
4607
+ let visibleRoleNameCount = 0;
3168
4608
  for (let i = 0; i < nodes.length; i++) {
3169
4609
  const n = nodes[i];
3170
4610
  if (roleOf(n) !== targetRole) continue;
3171
4611
  if (n === el) index = roleCountAll;
3172
4612
  roleCountAll++;
3173
4613
  if (targetLevel != null && levelOf(n) === targetLevel) levelCount++;
3174
- if (targetName != null && nameOf(n) === targetName) roleNameCount++;
4614
+ if (targetName != null && nameOf(n) === targetName) {
4615
+ roleNameCount++;
4616
+ const node = n;
4617
+ const box = typeof node.getBoundingClientRect === "function" ? node.getBoundingClientRect() : null;
4618
+ if (node.offsetParent != null || !!box && box.width > 0 && box.height > 0) visibleRoleNameCount++;
4619
+ }
4620
+ }
4621
+ if (targetName != null) {
4622
+ selectorCounts.roleName = roleNameCount;
4623
+ selectorCounts.visibleRoleName = visibleRoleNameCount;
3175
4624
  }
3176
- if (targetName != null) selectorCounts.roleName = roleNameCount;
3177
4625
  if (index !== -1) {
3178
4626
  rolePosition = {
3179
4627
  role: targetRole,
@@ -4384,7 +5832,7 @@ function mergeCandidates(base, extra) {
4384
5832
  }
4385
5833
 
4386
5834
  // src/internal/capture/locator-healing.ts
4387
- var path15 = __toESM(require("path"));
5835
+ var path16 = __toESM(require("path"));
4388
5836
 
4389
5837
  // ../core/src/locator-fingerprint.ts
4390
5838
  var ELEMENT_MATCH_SCORES = { role: 60, text: 55, label: 50 };
@@ -4508,18 +5956,6 @@ function freshLocatorsFromCandidate(c) {
4508
5956
  return out;
4509
5957
  }
4510
5958
 
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
5959
  // src/internal/capture/locator-healing.ts
4524
5960
  function dedupeSnapshotsByLocation(snaps) {
4525
5961
  const lastWithElement = /* @__PURE__ */ new Map();
@@ -4540,6 +5976,7 @@ var CHAIN_METHODS = [
4540
5976
  "nth",
4541
5977
  "last",
4542
5978
  "filter",
5979
+ "visible",
4543
5980
  "and",
4544
5981
  "or",
4545
5982
  "locator",
@@ -4765,10 +6202,10 @@ function captureCallerLocation(stack = new Error().stack ?? "") {
4765
6202
  }
4766
6203
  let rel = file;
4767
6204
  try {
4768
- rel = path15.relative(process.cwd(), file);
6205
+ rel = path16.relative(process.cwd(), file);
4769
6206
  } catch {
4770
6207
  }
4771
- rel = rel.split(path15.sep).join("/");
6208
+ rel = rel.split(path16.sep).join("/");
4772
6209
  if (rel.startsWith("./")) rel = rel.slice(2);
4773
6210
  return `${rel}:${m[3]}:${m[4]}`;
4774
6211
  }
@@ -4811,8 +6248,8 @@ function inspectionGateFromTestInfo(testInfo, enabled = process.env.PIWI_INSPECT
4811
6248
  }
4812
6249
 
4813
6250
  // src/internal/capture/pick-on-failure.ts
4814
- var path16 = __toESM(require("path"));
4815
- var ANSI_RE = /\[[0-9;]*m/g;
6251
+ var path17 = __toESM(require("path"));
6252
+ var ANSI_RE2 = /\[[0-9;]*m/g;
4816
6253
  function endOfString(s, start) {
4817
6254
  const q = s[start];
4818
6255
  for (let i = start + 1; i < s.length; i++) {
@@ -4899,13 +6336,13 @@ function deriveFailedLocator(testInfo) {
4899
6336
  const errors = info.errors && info.errors.length > 0 ? info.errors : info.error ? [info.error] : [];
4900
6337
  for (const err of errors) {
4901
6338
  const text = `${err.message ?? ""}
4902
- ${err.stack ?? ""}`.replace(ANSI_RE, "");
6339
+ ${err.stack ?? ""}`.replace(ANSI_RE2, "");
4903
6340
  const line = /^\s*Locator:\s*(.+)$/m.exec(text);
4904
6341
  if (!line) continue;
4905
6342
  const parsed = parseLeafLocatorExpression(line[1].trim());
4906
6343
  if (!parsed) continue;
4907
6344
  const loc = err.location;
4908
- const location = loc ? `${path16.relative(process.cwd(), loc.file).split(path16.sep).join("/")}:${loc.line}:${loc.column}` : null;
6345
+ const location = loc ? `${path17.relative(process.cwd(), loc.file).split(path17.sep).join("/")}:${loc.line}:${loc.column}` : null;
4909
6346
  return { method: parsed.method, args: parsed.args, location };
4910
6347
  }
4911
6348
  return null;
@@ -5087,6 +6524,7 @@ function createSink() {
5087
6524
  return {
5088
6525
  networkRequests: [],
5089
6526
  consoleEntries: [],
6527
+ dialogs: [],
5090
6528
  pendingHandlers: [],
5091
6529
  capturedLocators: [],
5092
6530
  capturePromises: [],
@@ -5097,6 +6535,7 @@ function createSink() {
5097
6535
  stashedWebVitals: null,
5098
6536
  stashedPageState: null,
5099
6537
  stashedAria: null,
6538
+ stashedAriaJson: null,
5100
6539
  pickOffered: false,
5101
6540
  userPick: null
5102
6541
  };
@@ -5285,9 +6724,17 @@ async function stashPageState(sink, closing) {
5285
6724
  if (pageState) sink.stashedPageState = pageState;
5286
6725
  }
5287
6726
  const status = sink.testInfo?.status;
5288
- if (status === "failed" || status === "timedOut" || status === "interrupted") {
5289
- const aria = await ariaSnapshotBestEffort(page.locator(":root"), 1e3);
6727
+ const sampleAria = async () => {
6728
+ const root = page.locator(":root");
6729
+ const aria = await ariaSnapshotBestEffort(root, 1e3);
5290
6730
  if (aria) sink.stashedAria = aria;
6731
+ const ariaJson = await ariaSnapshotJSONBestEffort(root, 1e3);
6732
+ if (ariaJson) sink.stashedAriaJson = ariaJson;
6733
+ };
6734
+ if (status === "failed" || status === "timedOut" || status === "interrupted") {
6735
+ await sampleAria();
6736
+ } else if (status === "passed" && process.env.PIWI_SAMPLE_ARIA_ON_PASS !== "false" && sink.testInfo && isDueForAriaSample(sink.testInfo)) {
6737
+ await sampleAria();
5291
6738
  }
5292
6739
  }
5293
6740
  async function maybeOpenPicker(sink, closing) {
@@ -5318,6 +6765,7 @@ var INSTRUMENTED_CONTEXTS = /* @__PURE__ */ new WeakSet();
5318
6765
  var PATCHED_BROWSERS = /* @__PURE__ */ new WeakSet();
5319
6766
  var CHAIN_METHOD_SET = new Set(CHAIN_METHODS);
5320
6767
  var ACTION_METHOD_SET = new Set(ACTION_METHODS);
6768
+ var LOCATOR_METHOD_SET = new Set(LOCATOR_METHODS);
5321
6769
  var CAPTURED_ATTRS_ARG = {
5322
6770
  keep: [...CAPTURED_ATTRIBUTES],
5323
6771
  tagRoles: TAG_TO_ROLE,
@@ -5370,6 +6818,16 @@ async function ariaSnapshotBestEffort(target, timeout) {
5370
6818
  }
5371
6819
  }
5372
6820
  }
6821
+ async function ariaSnapshotJSONBestEffort(target, timeout) {
6822
+ const fn = target.ariaSnapshotJSON;
6823
+ if (typeof fn !== "function") return null;
6824
+ try {
6825
+ const tree = await fn.call(target, timeout != null ? { timeout } : {});
6826
+ return tree == null ? null : JSON.stringify(tree);
6827
+ } catch {
6828
+ return null;
6829
+ }
6830
+ }
5373
6831
  function startElementCapture(sink, page, target, seq, callerLocation, used) {
5374
6832
  const probe = probeElement(page, target);
5375
6833
  const settledProbe = probe.then(
@@ -5504,6 +6962,20 @@ function wrapLocator(page, locator, originMethod, originArgs) {
5504
6962
  }
5505
6963
  });
5506
6964
  }
6965
+ function wrapFrameLocator(page, frameLocator) {
6966
+ return new Proxy(frameLocator, {
6967
+ get(target, prop) {
6968
+ const original = Reflect.get(target, prop);
6969
+ if (typeof original !== "function") return original;
6970
+ const fn = original;
6971
+ if (!LOCATOR_METHOD_SET.has(prop)) return original;
6972
+ return (...args) => {
6973
+ if (currentSink) currentSink.lastActivePage = page;
6974
+ return wrapLocator(page, fn.apply(target, args), String(prop), args);
6975
+ };
6976
+ }
6977
+ });
6978
+ }
5507
6979
  function instrumentPage(page) {
5508
6980
  if (!page || INSTRUMENTED_PAGES.has(page)) return;
5509
6981
  INSTRUMENTED_PAGES.add(page);
@@ -5531,6 +7003,13 @@ function instrumentPage(page) {
5531
7003
  return wrapLocator(page, original(...args), method, args);
5532
7004
  };
5533
7005
  }
7006
+ const originalFrameLocator = typeof page.frameLocator === "function" ? page.frameLocator.bind(page) : null;
7007
+ if (originalFrameLocator) {
7008
+ page.frameLocator = (...args) => {
7009
+ const frame = originalFrameLocator(...args);
7010
+ return args.length === 0 ? wrapFrameLocator(page, frame) : frame;
7011
+ };
7012
+ }
5534
7013
  if (typeof page.on === "function") {
5535
7014
  page.on("framenavigated", () => PROBE_UNSEEDED_PAGES.delete(page));
5536
7015
  }
@@ -5549,6 +7028,24 @@ function instrumentPage(page) {
5549
7028
  });
5550
7029
  }
5551
7030
  });
7031
+ if (typeof page.on === "function") {
7032
+ try {
7033
+ page.on("dialogclosed", (dialog) => {
7034
+ const sink = currentSink;
7035
+ if (!sink) return;
7036
+ try {
7037
+ sink.dialogs.push({
7038
+ type: typeof dialog.type === "function" ? dialog.type() : null,
7039
+ message: typeof dialog.message === "function" ? dialog.message() : null,
7040
+ defaultValue: typeof dialog.defaultValue === "function" ? dialog.defaultValue() || null : null,
7041
+ closedAt: Date.now()
7042
+ });
7043
+ } catch {
7044
+ }
7045
+ });
7046
+ } catch {
7047
+ }
7048
+ }
5552
7049
  page.on("requestfinished", (request) => {
5553
7050
  const sink = currentSink;
5554
7051
  if (!sink) return;
@@ -5675,6 +7172,13 @@ async function flushSink(sink, testInfo) {
5675
7172
  contentType: "text/plain",
5676
7173
  body: snapshot
5677
7174
  });
7175
+ const snapshotJson = (pageReadable ? await ariaSnapshotJSONBestEffort(page.locator(":root")) : null) ?? sink.stashedAriaJson;
7176
+ if (snapshotJson) {
7177
+ await testInfo.attach(ATTACHMENT_NAMES.ariaSnapshotJson, {
7178
+ contentType: "application/json",
7179
+ body: snapshotJson
7180
+ });
7181
+ }
5678
7182
  const failed = sink.failedLocators[sink.failedLocators.length - 1];
5679
7183
  const suggestion = failed ? suggestLocatorsFromAria(failed, snapshot) : null;
5680
7184
  if (suggestion) {
@@ -5691,6 +7195,25 @@ async function flushSink(sink, testInfo) {
5691
7195
  } catch {
5692
7196
  }
5693
7197
  }
7198
+ if (testInfo.status === "passed" && process.env.PIWI_SAMPLE_ARIA_ON_PASS !== "false" && isDueForAriaSample(testInfo)) {
7199
+ try {
7200
+ const snapshot = (pageReadable ? await ariaSnapshotBestEffort(page.locator(":root")) : null) ?? sink.stashedAria;
7201
+ if (snapshot) {
7202
+ await testInfo.attach(ATTACHMENT_NAMES.ariaSnapshot, {
7203
+ contentType: "text/plain",
7204
+ body: snapshot
7205
+ });
7206
+ const snapshotJson = (pageReadable ? await ariaSnapshotJSONBestEffort(page.locator(":root")) : null) ?? sink.stashedAriaJson;
7207
+ if (snapshotJson) {
7208
+ await testInfo.attach(ATTACHMENT_NAMES.ariaSnapshotJson, {
7209
+ contentType: "application/json",
7210
+ body: snapshotJson
7211
+ });
7212
+ }
7213
+ }
7214
+ } catch {
7215
+ }
7216
+ }
5694
7217
  if (sink.userPick) {
5695
7218
  const pick = sink.userPick;
5696
7219
  testInfo.annotations.push({
@@ -5714,6 +7237,12 @@ async function flushSink(sink, testInfo) {
5714
7237
  body: Buffer.from(JSON.stringify(sink.consoleEntries))
5715
7238
  });
5716
7239
  }
7240
+ if (sink.dialogs.length > 0) {
7241
+ await testInfo.attach(ATTACHMENT_NAMES.dialogs, {
7242
+ contentType: "application/json",
7243
+ body: Buffer.from(JSON.stringify(sink.dialogs))
7244
+ });
7245
+ }
5717
7246
  if (sink.networkRequests.length > 0) {
5718
7247
  await testInfo.attach(ATTACHMENT_NAMES.network, {
5719
7248
  contentType: "application/json",
@@ -5777,15 +7306,15 @@ function extendPiwiFixtures(test) {
5777
7306
  }
5778
7307
 
5779
7308
  // src/internal/ai/ai-fixtures.ts
5780
- var fs15 = __toESM(require("fs"));
5781
- var path19 = __toESM(require("path"));
7309
+ var fs16 = __toESM(require("fs"));
7310
+ var path20 = __toESM(require("path"));
5782
7311
  var import_test = require("@playwright/test");
5783
7312
 
5784
7313
  // src/internal/ai/artifact.ts
5785
- var fs14 = __toESM(require("fs"));
5786
- var path17 = __toESM(require("path"));
7314
+ var fs15 = __toESM(require("fs"));
7315
+ var path18 = __toESM(require("path"));
5787
7316
  var ARTIFACT_VERSION = 1;
5788
- var LOCATOR_METHOD_SET = new Set(LOCATOR_METHODS);
7317
+ var LOCATOR_METHOD_SET2 = new Set(LOCATOR_METHODS);
5789
7318
  var ACTION_METHOD_SET2 = new Set(ACTION_METHODS);
5790
7319
  var POSTCONDITION_ASSERTS = /* @__PURE__ */ new Set([
5791
7320
  "visible",
@@ -5818,7 +7347,7 @@ function validateStructuredLocator(value, where) {
5818
7347
  assert(value !== null && typeof value === "object", `${where}: locator must be an object`);
5819
7348
  const loc = value;
5820
7349
  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`);
7350
+ assert(LOCATOR_METHOD_SET2.has(loc.method), `${where}: locator method "${String(loc.method)}" is not allowlisted`);
5822
7351
  assert(Array.isArray(loc.args), `${where}: locator.args must be an array`);
5823
7352
  if (loc.chain !== void 0) {
5824
7353
  assert(Array.isArray(loc.chain), `${where}: locator.chain must be an array`);
@@ -5876,7 +7405,7 @@ function parseEntry(text) {
5876
7405
  function readEntry(file) {
5877
7406
  let text;
5878
7407
  try {
5879
- text = fs14.readFileSync(file, "utf8");
7408
+ text = fs15.readFileSync(file, "utf8");
5880
7409
  } catch (error) {
5881
7410
  if (error.code === "ENOENT") return null;
5882
7411
  throw error;
@@ -5886,28 +7415,28 @@ function readEntry(file) {
5886
7415
  function writeEntry(file, entry) {
5887
7416
  const canonical = serializeEntry(entry);
5888
7417
  return withEntryLock(file, () => {
5889
- if (fs14.existsSync(file) && fs14.readFileSync(file, "utf8") === canonical) return { written: false };
5890
- fs14.mkdirSync(path17.dirname(file), { recursive: true });
7418
+ if (fs15.existsSync(file) && fs15.readFileSync(file, "utf8") === canonical) return { written: false };
7419
+ fs15.mkdirSync(path18.dirname(file), { recursive: true });
5891
7420
  const tmp = `${file}.${process.pid}.tmp`;
5892
- fs14.writeFileSync(tmp, canonical);
5893
- fs14.renameSync(tmp, file);
7421
+ fs15.writeFileSync(tmp, canonical);
7422
+ fs15.renameSync(tmp, file);
5894
7423
  return { written: true };
5895
7424
  });
5896
7425
  }
5897
7426
  function withEntryLock(file, fn) {
5898
7427
  const lock = `${file}.lock`;
5899
- fs14.mkdirSync(path17.dirname(file), { recursive: true });
7428
+ fs15.mkdirSync(path18.dirname(file), { recursive: true });
5900
7429
  const deadline = Date.now() + 5e3;
5901
7430
  for (; ; ) {
5902
7431
  try {
5903
- const fd = fs14.openSync(lock, "wx");
5904
- fs14.closeSync(fd);
7432
+ const fd = fs15.openSync(lock, "wx");
7433
+ fs15.closeSync(fd);
5905
7434
  break;
5906
7435
  } catch (error) {
5907
7436
  if (error.code !== "EEXIST") throw error;
5908
7437
  if (Date.now() > deadline) {
5909
7438
  try {
5910
- fs14.unlinkSync(lock);
7439
+ fs15.unlinkSync(lock);
5911
7440
  } catch {
5912
7441
  }
5913
7442
  }
@@ -5917,7 +7446,7 @@ function withEntryLock(file, fn) {
5917
7446
  return fn();
5918
7447
  } finally {
5919
7448
  try {
5920
- fs14.unlinkSync(lock);
7449
+ fs15.unlinkSync(lock);
5921
7450
  } catch {
5922
7451
  }
5923
7452
  }
@@ -5925,7 +7454,7 @@ function withEntryLock(file, fn) {
5925
7454
 
5926
7455
  // src/internal/ai/keys.ts
5927
7456
  var crypto3 = __toESM(require("crypto"));
5928
- var path18 = __toESM(require("path"));
7457
+ var path19 = __toESM(require("path"));
5929
7458
  var DEFAULT_AI_DIR = "__piwi__";
5930
7459
  function normalizeTemplate(template) {
5931
7460
  return template.trim().replace(/\s+/g, " ").toLowerCase();
@@ -5939,13 +7468,13 @@ function slug(text, maxLength = 40) {
5939
7468
  return (base || "x").slice(0, maxLength).replace(/-+$/g, "") || "x";
5940
7469
  }
5941
7470
  function entryDir(specFile, dir = DEFAULT_AI_DIR) {
5942
- return path18.join(path18.dirname(specFile), dir, path18.basename(specFile));
7471
+ return path19.join(path19.dirname(specFile), dir, path19.basename(specFile));
5943
7472
  }
5944
7473
  function entryPath(params) {
5945
7474
  const basis = `${normalizeTemplate(params.testTitle)}::${normalizeTemplate(params.template)}`;
5946
7475
  const hash = hashTemplate(basis, params.ordinal ?? 0);
5947
7476
  const name = `${slug(params.testTitle)}.${slug(params.template)}.${hash}.json`;
5948
- return path18.join(entryDir(params.specFile, params.dir), name);
7477
+ return path19.join(entryDir(params.specFile, params.dir), name);
5949
7478
  }
5950
7479
  function parseLocation(location) {
5951
7480
  const match = /:(\d+):(\d+)$/.exec(location);
@@ -6036,7 +7565,7 @@ function isParametric(template, compiledText) {
6036
7565
  }
6037
7566
 
6038
7567
  // src/internal/ai/interpreter.ts
6039
- var LOCATOR_METHOD_SET2 = new Set(LOCATOR_METHODS);
7568
+ var LOCATOR_METHOD_SET3 = new Set(LOCATOR_METHODS);
6040
7569
  var ACTION_METHOD_SET3 = new Set(ACTION_METHODS);
6041
7570
  var StepDriftError = class extends Error {
6042
7571
  constructor(step, fingerprint) {
@@ -6074,7 +7603,7 @@ function describePostcondition(post) {
6074
7603
  return post.assert === "url" ? `url \u2192 ${post.url ?? ""}` : `${describeLocator(post.locator)} ${post.assert}`;
6075
7604
  }
6076
7605
  function buildLocator(root, structured, params = {}) {
6077
- if (!LOCATOR_METHOD_SET2.has(structured.method)) {
7606
+ if (!LOCATOR_METHOD_SET3.has(structured.method)) {
6078
7607
  throw new Error(`piwi AI: locator method "${structured.method}" is not allowlisted`);
6079
7608
  }
6080
7609
  const args = substituteArgs(structured.args, params);
@@ -6399,7 +7928,7 @@ function testIdentity(testInfo) {
6399
7928
  return titles.length > 0 ? titles.join(" \u203A ") : testInfo.title;
6400
7929
  }
6401
7930
  function relativeToCwd(file) {
6402
- return path19.relative(process.cwd(), file).split(path19.sep).join("/");
7931
+ return path20.relative(process.cwd(), file).split(path20.sep).join("/");
6403
7932
  }
6404
7933
  function recordIntents(intents, entry) {
6405
7934
  const add = (locator, kind) => {
@@ -6426,7 +7955,7 @@ function createAiApi(page, testInfo, config, used, intents) {
6426
7955
  const readSource = () => {
6427
7956
  if (source === null) {
6428
7957
  try {
6429
- source = fs15.readFileSync(testInfo.file, "utf8");
7958
+ source = fs16.readFileSync(testInfo.file, "utf8");
6430
7959
  } catch {
6431
7960
  source = "";
6432
7961
  }