@piwitests/reporter 0.41.0 → 0.43.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
@@ -89,6 +89,7 @@ var DEFAULTS = {
89
89
  streamingBatchSize: 5,
90
90
  streamingBatchDelay: 2e3,
91
91
  maxStreamBufferBytes: 100 * 1024 * 1024,
92
+ submitTimeout: 15 * 60 * 1e3,
92
93
  failOnFlakyTests: false,
93
94
  username: null,
94
95
  password: null,
@@ -110,6 +111,7 @@ var PIWI_ENV_KEYS = {
110
111
  streamingBatchSize: "PIWI_STREAMING_BATCH_SIZE",
111
112
  streamingBatchDelay: "PIWI_STREAMING_BATCH_DELAY",
112
113
  maxStreamBufferBytes: "PIWI_MAX_STREAM_BUFFER_BYTES",
114
+ submitTimeout: "PIWI_SUBMIT_TIMEOUT",
113
115
  liveFileUploads: "PIWI_LIVE_FILE_UPLOADS",
114
116
  failOnFlakyTests: "PIWI_FAIL_ON_FLAKY_TESTS",
115
117
  uploadTraces: "PIWI_UPLOAD_TRACES",
@@ -143,6 +145,7 @@ var PIWI_SELECTION_ENV = {
143
145
  hash: "PIWI_SELECTION_HASH",
144
146
  count: "PIWI_SELECTION_COUNT"
145
147
  };
148
+ var PIWI_SHARD_ENV = "PIWI_SHARD";
146
149
  var PIWI_PROBE_ENV = {
147
150
  flag: "PIWI_PROBE",
148
151
  plan: "PIWI_PROBE_PLAN",
@@ -171,6 +174,7 @@ var ENV_FALLBACK_SPECS = [
171
174
  { option: "streamingBatchSize", env: PIWI_ENV_KEYS.streamingBatchSize, kind: "number" },
172
175
  { option: "streamingBatchDelay", env: PIWI_ENV_KEYS.streamingBatchDelay, kind: "number" },
173
176
  { option: "maxStreamBufferBytes", env: PIWI_ENV_KEYS.maxStreamBufferBytes, kind: "number" },
177
+ { option: "submitTimeout", env: PIWI_ENV_KEYS.submitTimeout, kind: "number" },
174
178
  { option: "liveFileUploads", env: PIWI_ENV_KEYS.liveFileUploads, kind: "bool" },
175
179
  { option: "failOnFlakyTests", env: PIWI_ENV_KEYS.failOnFlakyTests, kind: "bool" },
176
180
  { option: "uploadTraces", env: PIWI_ENV_KEYS.uploadTraces, kind: "bool" },
@@ -315,13 +319,17 @@ var HttpClient = class {
315
319
  /**
316
320
  * @param serverUrl Base URL of the Piwi Dashboard server (e.g. `http://localhost:3000`).
317
321
  * @param logger Prefixed logger for verbose diagnostics.
318
- * @param timeout Socket inactivity timeout in ms (default 30s). A hung
319
- * server now fails fast instead of stalling the reporter.
322
+ * @param timeout Socket inactivity timeout in ms (default 30s), so a hung
323
+ * server fails fast rather than stalling the reporter.
320
324
  */
321
325
  constructor(serverUrl, logger = new Logger(), timeout = 3e4) {
322
326
  this.serverUrl = serverUrl;
323
327
  this.logger = logger;
324
328
  this.timeout = timeout;
329
+ /** Requests in flight, so `close` can abort them. */
330
+ this.inFlight = /* @__PURE__ */ new Set();
331
+ /** Set by `close`: every later request fails with this reason. */
332
+ this.closedReason = null;
325
333
  this.warnIfInsecureTransport();
326
334
  }
327
335
  /**
@@ -332,7 +340,7 @@ var HttpClient = class {
332
340
  * endpoint exposes the key and payloads on the wire. Loopback (localhost /
333
341
  * 127.0.0.1 / ::1) is exempt: that traffic never leaves the machine. This
334
342
  * warns rather than refuses so internal HTTP deployments (e.g. behind a VPN)
335
- * still work, but the exposure is no longer silent.
343
+ * work, while the exposure is still reported.
336
344
  */
337
345
  warnIfInsecureTransport() {
338
346
  let url;
@@ -353,6 +361,16 @@ var HttpClient = class {
353
361
  get baseUrl() {
354
362
  return this.serverUrl;
355
363
  }
364
+ /** Whether `close` was called. */
365
+ get closed() {
366
+ return this.closedReason !== null;
367
+ }
368
+ /** Abort every request in flight and fail every later one with `reason`. */
369
+ close(reason) {
370
+ this.closedReason = reason;
371
+ for (const req of this.inFlight) req.destroy(new Error(reason));
372
+ this.inFlight.clear();
373
+ }
356
374
  /**
357
375
  * Resolve an auth credential: prefer `apiKey`, fall back to `username`/`password` login,
358
376
  * or return `null` when neither is configured.
@@ -473,6 +491,10 @@ var HttpClient = class {
473
491
  /** Unified request core: transport, headers, auth, response accumulation, timeout. */
474
492
  request(method, pathname, opts) {
475
493
  return new Promise((resolve6, reject) => {
494
+ if (this.closedReason !== null) {
495
+ reject(new Error(this.closedReason));
496
+ return;
497
+ }
476
498
  const url = new import_node_url.URL(pathname, this.serverUrl);
477
499
  const transport = url.protocol === "https:" ? https : http;
478
500
  const headers = { ...opts.headers };
@@ -501,11 +523,14 @@ var HttpClient = class {
501
523
  });
502
524
  }
503
525
  );
526
+ this.inFlight.add(req);
527
+ req.on("close", () => this.inFlight.delete(req));
504
528
  req.on("error", reject);
505
529
  req.setTimeout(this.timeout, () => {
506
530
  req.destroy(new Error(`Request to ${pathname} timed out after ${this.timeout}ms`));
507
531
  });
508
532
  if (opts.form) {
533
+ opts.form.on("error", (error) => req.destroy(error));
509
534
  opts.form.pipe(req);
510
535
  } else if (opts.body !== void 0) {
511
536
  req.write(opts.body);
@@ -864,19 +889,25 @@ function computeInstanceId(projectName, runLabel) {
864
889
  // src/internal/streaming/stream-buffer.ts
865
890
  var StreamBuffer = class {
866
891
  constructor(projectName) {
867
- this.filePath = path3.join(os3.tmpdir(), `piwi-dashboard-stream-${hashForProject(projectName)}.jsonl`);
892
+ this.filePath = null;
893
+ this.prefix = `piwi-dashboard-stream-${hashForProject(projectName)}`;
894
+ }
895
+ /** Scope the buffer to a run. Until then the buffer is empty and appends are dropped. */
896
+ bindRun(runId) {
897
+ this.filePath = path3.join(os3.tmpdir(), `${this.prefix}-${runId}.jsonl`);
868
898
  }
869
899
  /** Append one or more events to the on-disk buffer */
870
900
  append(events) {
871
- if (events.length === 0) return;
901
+ if (!this.filePath || events.length === 0) return;
872
902
  try {
873
903
  const lines = events.map((e) => JSON.stringify(e) + "\n").join("");
874
904
  fs3.appendFileSync(this.filePath, lines, "utf8");
875
905
  } catch {
876
906
  }
877
907
  }
878
- /** Load all buffered events from disk, clearing the file */
908
+ /** Load all buffered events of the bound run from disk */
879
909
  load() {
910
+ if (!this.filePath) return [];
880
911
  try {
881
912
  if (fs3.existsSync(this.filePath)) {
882
913
  const content = fs3.readFileSync(this.filePath, "utf8");
@@ -886,23 +917,29 @@ var StreamBuffer = class {
886
917
  }
887
918
  return [];
888
919
  }
889
- /** Delete the buffer file from disk */
920
+ /** Delete the bound run's buffer file from disk */
890
921
  clear() {
922
+ if (!this.filePath) return;
891
923
  try {
892
924
  if (fs3.existsSync(this.filePath)) fs3.unlinkSync(this.filePath);
893
925
  } catch {
894
926
  }
895
927
  }
896
- /** Remove the buffer file if it is older than `maxAgeMs` (default 2 hours). Used on startup to discard orphaned data. */
928
+ /** Remove this project's buffer files older than `maxAgeMs` (default 2 hours). Used on startup to discard orphaned data. */
897
929
  clearStale(maxAgeMs = 72e5) {
930
+ const dir = os3.tmpdir();
931
+ let names;
898
932
  try {
899
- if (fs3.existsSync(this.filePath)) {
900
- const stats = fs3.statSync(this.filePath);
901
- if (Date.now() - stats.mtimeMs > maxAgeMs) {
902
- fs3.unlinkSync(this.filePath);
903
- }
904
- }
933
+ names = fs3.readdirSync(dir).filter((name) => name.startsWith(this.prefix));
905
934
  } catch {
935
+ return;
936
+ }
937
+ for (const name of names) {
938
+ try {
939
+ const file = path3.join(dir, name);
940
+ if (Date.now() - fs3.statSync(file).mtimeMs > maxAgeMs) fs3.unlinkSync(file);
941
+ } catch {
942
+ }
906
943
  }
907
944
  }
908
945
  };
@@ -1331,7 +1368,7 @@ var MetadataCollector = class {
1331
1368
  if (options.tags && Array.isArray(options.tags)) metadata.tags = options.tags;
1332
1369
  if (options.customData) metadata.customData = options.customData;
1333
1370
  if (options.collectScmInfo) {
1334
- const scm = this.collectScmInfo(options);
1371
+ const scm = this.collectScmInfo();
1335
1372
  if (scm) metadata.scm = scm;
1336
1373
  }
1337
1374
  if (options.collectCiInfo) {
@@ -1425,7 +1462,7 @@ var MetadataCollector = class {
1425
1462
  }
1426
1463
  return { suitePath, suiteConfig };
1427
1464
  }
1428
- collectScmInfo(_options) {
1465
+ collectScmInfo() {
1429
1466
  const scm = {};
1430
1467
  let gitBranch;
1431
1468
  try {
@@ -1546,7 +1583,7 @@ var CRITICAL_TIER = 2;
1546
1583
  var BoundedEventQueue = class {
1547
1584
  /**
1548
1585
  * @param maxBytes Byte budget for the buffered events. `<= 0` or non-finite
1549
- * disables the bound (unbounded, i.e. the pre-existing behavior).
1586
+ * disables the bound (unbounded).
1550
1587
  */
1551
1588
  constructor(maxBytes) {
1552
1589
  this.maxBytes = maxBytes;
@@ -1709,10 +1746,14 @@ function runUrl(serverUrl, runId) {
1709
1746
  }
1710
1747
 
1711
1748
  // src/internal/streaming/stream-manager.ts
1749
+ var SERVER_MAX_EVENT_BATCH_BYTES = 10 * 1024 * 1024;
1750
+ var EVENT_ENVELOPE_BYTES = 1024;
1751
+ var MAX_EVENT_REQUEST_BYTES = 4 * 1024 * 1024;
1752
+ var MAX_UPLOAD_CONNECTION_FAILURES = 3;
1712
1753
  var StreamManager = class {
1713
1754
  /**
1714
1755
  * @param httpClient HTTP client for server communication.
1715
- * @param streamBuffer On-disk buffer for crash-safe event persistence.
1756
+ * @param streamBuffer On-disk buffer for the run's undelivered events.
1716
1757
  * @param recovery Crash-recovery handler for uploading stale payloads on startup.
1717
1758
  * @param uploader Uploader for per-test-case file uploads.
1718
1759
  * @param fileHandler File-discovery helper for finding traces and attachments.
@@ -1748,6 +1789,8 @@ var StreamManager = class {
1748
1789
  this.heartbeatInterval = 15e3;
1749
1790
  /** Tracks cases whose files have already been uploaded live, so `uploadRemaining` can skip them. */
1750
1791
  this.uploadedCaseFiles = /* @__PURE__ */ new WeakSet();
1792
+ /** Set when a test-result event left the live stream undelivered: too large to send, or still queued when the drain gave up. */
1793
+ this.resultsUndelivered = false;
1751
1794
  this._enabled = false;
1752
1795
  this._runId = null;
1753
1796
  this._token = null;
@@ -1778,13 +1821,15 @@ var StreamManager = class {
1778
1821
  return this._startPromise;
1779
1822
  }
1780
1823
  /**
1781
- * Whether buffer pressure forced a test-result (`complete`) event to be
1782
- * dropped from the live stream. When true the submitter finalizes via the
1783
- * end-of-run batch (which re-sends the full run from the reporter's own
1784
- * memory) instead of `/finish`, so the dropped detail is not lost.
1824
+ * Whether a test-result (`complete`) event never reached the server through
1825
+ * the live stream: shed under buffer pressure, too large for any request the
1826
+ * server accepts, or still undelivered when the drain gave up. When true the
1827
+ * submitter finalizes via the end-of-run batch (which re-sends the full run
1828
+ * from the reporter's own memory) instead of `/finish`, so the missing
1829
+ * results are not lost.
1785
1830
  */
1786
- get bufferLostResults() {
1787
- return this.pendingEvents.lostResults || this.pendingBeginEvents.lostResults;
1831
+ get lostResults() {
1832
+ return this.resultsUndelivered || this.pendingEvents.lostResults || this.pendingBeginEvents.lostResults;
1788
1833
  }
1789
1834
  /** Begin the streaming session after `onBegin` fires. Non-blocking — the actual handshake runs asynchronously. */
1790
1835
  start(startTime, metadata, instanceId, playwrightVersion, reporterVersion, shardInfo, isFullRun, filterDetails, plannedTotal) {
@@ -1877,6 +1922,7 @@ var StreamManager = class {
1877
1922
  this._runId = response.runId;
1878
1923
  this._token = response.streamToken;
1879
1924
  this._enabled = true;
1925
+ this.streamBuffer.bindRun(response.runId);
1880
1926
  this.logger.info(`Streaming enabled. Run ID: ${response.runId}`);
1881
1927
  if (this.options.serverUrl) {
1882
1928
  this.logger.info(`Watch live: ${runUrl(this.options.serverUrl, response.runId)}`);
@@ -1927,21 +1973,78 @@ var StreamManager = class {
1927
1973
  this.flushTimer = null;
1928
1974
  }
1929
1975
  if (this.pendingEvents.isEmpty || !this._enabled || !this._runId) return null;
1930
- const events = this.pendingEvents.takeAll();
1931
- const promise = this.httpClient.postJSON(`/api/test-runs/${this._runId}/events`, { streamToken: this._token, testCases: events }, this._auth).then(
1932
- () => {
1976
+ const promise = this.sendEvents(this.pendingEvents.takeAll());
1977
+ this.flushPromises.push(promise);
1978
+ return promise;
1979
+ }
1980
+ // Post the events in order, in requests of at most `MAX_EVENT_REQUEST_BYTES`.
1981
+ // A request the server refuses as too large (413) is split in half and resent;
1982
+ // a single event it still refuses leaves the live stream. On any other failure
1983
+ // the unsent events go back to the head of the queue and a retry is scheduled.
1984
+ async sendEvents(events) {
1985
+ const chunks = this.chunkEvents(events);
1986
+ while (chunks.length > 0) {
1987
+ const chunk = chunks.shift();
1988
+ try {
1989
+ await this.httpClient.postJSON(
1990
+ `/api/test-runs/${this._runId}/events`,
1991
+ { streamToken: this._token, testCases: chunk },
1992
+ this._auth
1993
+ );
1933
1994
  this.retryCount = 0;
1934
1995
  this.lastActivityAt = Date.now();
1935
- return true;
1936
- },
1937
- (error) => {
1938
- this.pendingEvents.prepend(events);
1996
+ } catch (error) {
1997
+ if (error instanceof HttpError && error.status === 413) {
1998
+ if (chunk.length > 1) {
1999
+ const half = Math.ceil(chunk.length / 2);
2000
+ chunks.unshift(chunk.slice(0, half), chunk.slice(half));
2001
+ } else {
2002
+ this.dropOversized(chunk[0]);
2003
+ }
2004
+ continue;
2005
+ }
2006
+ this.pendingEvents.prepend(chunk.concat(...chunks));
1939
2007
  this.scheduleRetry(errorMessage(error));
1940
2008
  return false;
1941
2009
  }
1942
- );
1943
- this.flushPromises.push(promise);
1944
- return promise;
2010
+ }
2011
+ return true;
2012
+ }
2013
+ // Group events into request-sized chunks, keeping their order. An event
2014
+ // larger than any request the server accepts is dropped here.
2015
+ chunkEvents(events) {
2016
+ const chunks = [];
2017
+ let chunk = [];
2018
+ let chunkBytes = 0;
2019
+ for (const event of events) {
2020
+ const bytes = serializedBytes(event);
2021
+ if (bytes > SERVER_MAX_EVENT_BATCH_BYTES - EVENT_ENVELOPE_BYTES) {
2022
+ this.dropOversized(event);
2023
+ continue;
2024
+ }
2025
+ if (chunk.length > 0 && chunkBytes + bytes > MAX_EVENT_REQUEST_BYTES) {
2026
+ chunks.push(chunk);
2027
+ chunk = [];
2028
+ chunkBytes = 0;
2029
+ }
2030
+ chunk.push(event);
2031
+ chunkBytes += bytes;
2032
+ }
2033
+ if (chunk.length > 0) chunks.push(chunk);
2034
+ return chunks;
2035
+ }
2036
+ // Leave an event the server will not accept out of the live stream. A test
2037
+ // result dropped this way sends the run through the end-of-run batch upload,
2038
+ // which carries the full run (see `lostResults`).
2039
+ dropOversized(event) {
2040
+ if (event.type === "complete") {
2041
+ this.resultsUndelivered = true;
2042
+ this.logger.warn(
2043
+ `The live result of "${event.title}" is too large for the dashboard's event stream \u2014 it is sent with the end-of-run upload instead.`
2044
+ );
2045
+ } else {
2046
+ this.logger.debug(`Dropped a ${event.type} event for "${event.title}" that is too large to stream.`);
2047
+ }
1945
2048
  }
1946
2049
  scheduleRetry(reason) {
1947
2050
  if (this.retryTimer) return;
@@ -2006,7 +2109,7 @@ var StreamManager = class {
2006
2109
  this.retryTimer = null;
2007
2110
  }
2008
2111
  }
2009
- /** Drain all pending and buffered events before the run finishes. Retries up to 10 times with exponential back-off. */
2112
+ /** Drain all pending and buffered events before the run finishes. Retries up to 10 times with exponential back-off, until the HTTP client is closed. */
2010
2113
  async drain() {
2011
2114
  try {
2012
2115
  await this._drain();
@@ -2039,6 +2142,7 @@ var StreamManager = class {
2039
2142
  }
2040
2143
  return;
2041
2144
  }
2145
+ if (this.httpClient.closed) break;
2042
2146
  if (attempt === 0) {
2043
2147
  this.logger.warn(
2044
2148
  `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)...`
@@ -2051,6 +2155,7 @@ var StreamManager = class {
2051
2155
  }
2052
2156
  if (!this.pendingEvents.isEmpty) {
2053
2157
  const remaining = this.pendingEvents.takeAll();
2158
+ if (remaining.some((event) => event.type === "complete")) this.resultsUndelivered = true;
2054
2159
  this.logger.warn(
2055
2160
  `Could not deliver ${remaining.length} live event(s) to the dashboard \u2014 continuing with the end-of-run submit.`
2056
2161
  );
@@ -2060,6 +2165,10 @@ var StreamManager = class {
2060
2165
  this.clearRetryTimer();
2061
2166
  }
2062
2167
  }
2168
+ /** Delete the run's on-disk event buffer. Called once the run's results reached the server. */
2169
+ discardBuffered() {
2170
+ this.streamBuffer.clear();
2171
+ }
2063
2172
  // Emit a single summary when buffer pressure shed events, so a full disk or a
2064
2173
  // stalled server does not silently swallow data. Reported once per drain.
2065
2174
  reportBufferPressure() {
@@ -2124,13 +2233,18 @@ var StreamManager = class {
2124
2233
  })();
2125
2234
  this.liveUploadPromises.push(promise);
2126
2235
  }
2127
- /** Wait for all live uploads to settle, then upload files for any test cases that weren't uploaded live */
2236
+ /**
2237
+ * Wait for all live uploads to settle, then upload files for any test cases
2238
+ * that weren't uploaded live. Stops after `MAX_UPLOAD_CONNECTION_FAILURES`
2239
+ * uploads in a row got no response at all.
2240
+ */
2128
2241
  async uploadRemaining(testCases) {
2129
2242
  if (this.liveUploadPromises.length > 0) {
2130
2243
  await Promise.allSettled(this.liveUploadPromises);
2131
2244
  this.liveUploadPromises = [];
2132
2245
  }
2133
2246
  if (!this._enabled || !this._runId || !this._token) return;
2247
+ let connectionFailures = 0;
2134
2248
  for (const tc of testCases) {
2135
2249
  if (this.uploadedCaseFiles.has(tc)) continue;
2136
2250
  try {
@@ -2143,12 +2257,25 @@ var StreamManager = class {
2143
2257
  this._auth
2144
2258
  );
2145
2259
  if (uploaded) this.uploadedCaseFiles.add(tc);
2260
+ connectionFailures = 0;
2146
2261
  } catch (error) {
2147
2262
  this.logger.warn(`Failed to upload files for "${tc.title}": ${errorMessage(error)}`);
2263
+ connectionFailures = error instanceof HttpError ? 0 : connectionFailures + 1;
2264
+ if (connectionFailures >= MAX_UPLOAD_CONNECTION_FAILURES) {
2265
+ this.logger.warn(`The dashboard stopped responding \u2014 skipping the file uploads of the remaining tests.`);
2266
+ return;
2267
+ }
2148
2268
  }
2149
2269
  }
2150
2270
  }
2151
2271
  };
2272
+ function serializedBytes(event) {
2273
+ try {
2274
+ return Buffer.byteLength(JSON.stringify(event)) + 1;
2275
+ } catch {
2276
+ return 0;
2277
+ }
2278
+ }
2152
2279
 
2153
2280
  // ../core/src/locator-chain.ts
2154
2281
  var LOCATING_METHODS = /* @__PURE__ */ new Set([
@@ -2600,15 +2727,19 @@ function readChain(region) {
2600
2727
  const link = /^\s*\.\s*(\w+)\(/.exec(region.slice(scanned.end));
2601
2728
  return link && CHAIN_LINK_METHODS.has(link[1]) ? null : scanned;
2602
2729
  }
2603
- function extractTopFrame(text) {
2730
+ function extractStackFrames(text) {
2604
2731
  const frameRe = /^\s+at (?:.*? \()?([^()\s][^()]*?):(\d+):(\d+)\)?\s*$/gm;
2732
+ const frames = [];
2605
2733
  let m;
2606
2734
  while ((m = frameRe.exec(text)) !== null) {
2607
2735
  const file = m[1].replace(/\\/g, "/");
2608
2736
  if (file.includes("node_modules") || file.startsWith("node:")) continue;
2609
- return { file, line: Number(m[2]), column: Number(m[3]) };
2737
+ frames.push({ file, line: Number(m[2]), column: Number(m[3]) });
2610
2738
  }
2611
- return null;
2739
+ return frames;
2740
+ }
2741
+ function extractTopFrame(text) {
2742
+ return extractStackFrames(text)[0] ?? null;
2612
2743
  }
2613
2744
  function extractTopFrameFile(text) {
2614
2745
  return extractTopFrame(text)?.file ?? null;
@@ -2857,6 +2988,13 @@ function maskTokenLike(text) {
2857
2988
  return text.replace(DATA_URI_RE, "data:[masked]").replace(JWT_RE, "[masked-token]").replace(LONG_HEX_RE, "[masked-hex]");
2858
2989
  }
2859
2990
 
2991
+ // ../core/src/locator-break.ts
2992
+ function sameFilePath(a, b) {
2993
+ const x = a.replace(/\\/g, "/").replace(/^\.\//, "");
2994
+ const y = b.replace(/\\/g, "/").replace(/^\.\//, "");
2995
+ return x === y || x.endsWith("/" + y) || y.endsWith("/" + x);
2996
+ }
2997
+
2860
2998
  // ../core/src/step-tree.ts
2861
2999
  var SPAN_TOLERANCE_MS = 1;
2862
3000
  var SETUP_CONTAINER_RE = /^before hooks$/i;
@@ -2878,9 +3016,26 @@ function errorMessage2(step) {
2878
3016
  if (typeof error === "string") return error;
2879
3017
  return str(error?.message);
2880
3018
  }
3019
+ function errorLocation(step) {
3020
+ const error = step.error;
3021
+ return typeof error === "string" ? "" : str(error?.location);
3022
+ }
3023
+ function parseLocation(location) {
3024
+ const m = /^(.+):(\d+):(\d+)$/.exec(location.trim());
3025
+ return m ? { file: m[1], line: Number(m[2]), column: Number(m[3]) } : null;
3026
+ }
3027
+ function sameCodeLocation(a, b) {
3028
+ const x = typeof a === "string" ? parseLocation(a) : a;
3029
+ const y = typeof b === "string" ? parseLocation(b) : b;
3030
+ if (!x || !y) return false;
3031
+ return x.line === y.line && x.column === y.column && sameFilePath(x.file, y.file);
3032
+ }
2881
3033
  function isFailedStep(step) {
2882
3034
  return step.failed === true || errorMessage2(step).trim().length > 0;
2883
3035
  }
3036
+ function isRecoveredStep(step) {
3037
+ return step.recovered === true;
3038
+ }
2884
3039
  function isCaptureStep(step) {
2885
3040
  const title = str(step.title).trim();
2886
3041
  if (CAPTURE_ATTACH_RE.test(title) || CAPTURE_FIXTURE_RE.test(title)) return true;
@@ -2935,18 +3090,72 @@ function stepPhases(steps, parents = stepParents(steps)) {
2935
3090
  return "body";
2936
3091
  });
2937
3092
  }
2938
- function failingStepIndex(steps, parents = stepParents(steps)) {
3093
+ function firstLine(message) {
3094
+ return stripAnsi(str(message)).split("\n")[0].trim();
3095
+ }
3096
+ function errorHeads(error) {
3097
+ return error.split(/\n---\n/).map(firstLine).filter((head) => head.length > 0);
3098
+ }
3099
+ function sameError(inner, outer) {
3100
+ const a = firstLine(errorMessage2(inner));
3101
+ const b = firstLine(errorMessage2(outer));
3102
+ if (!a || !b) return true;
3103
+ if (a !== b) return false;
3104
+ const at = errorLocation(inner);
3105
+ const bt = errorLocation(outer);
3106
+ return !at || !bt || sameCodeLocation(at, bt);
3107
+ }
3108
+ function lastByPhase(indices, phases) {
3109
+ for (const phase of ["body", "setup", "teardown"]) {
3110
+ const inPhase = indices.filter((i) => phases[i] === phase);
3111
+ if (inPhase.length > 0) return inPhase[inPhase.length - 1];
3112
+ }
3113
+ return null;
3114
+ }
3115
+ function failingChainEnds(steps, parents) {
2939
3116
  const candidates = [];
2940
3117
  steps.forEach((step, i) => {
2941
- if (isFailedStep(step) && !isCaptureStep(step)) candidates.push(i);
3118
+ if (isFailedStep(step) && !isCaptureStep(step) && !isRecoveredStep(step)) candidates.push(i);
2942
3119
  });
2943
- if (candidates.length === 0) return null;
2944
- let current = candidates[0];
2945
- for (; ; ) {
2946
- const inner = candidates.find((c) => c > current && isDescendant(c, current, parents));
2947
- if (inner === void 0) return current;
2948
- current = inner;
3120
+ const isCandidate = new Set(candidates);
3121
+ const hasFailedAncestor = (i) => {
3122
+ for (let p = parents[i] ?? -1; p !== -1; p = parents[p] ?? -1) if (isCandidate.has(p)) return true;
3123
+ return false;
3124
+ };
3125
+ return candidates.filter((root) => !hasFailedAncestor(root)).map((root) => {
3126
+ let current = root;
3127
+ for (; ; ) {
3128
+ const inner = candidates.filter((c) => c > current && isDescendant(c, current, parents));
3129
+ const carrying = inner.filter((c) => sameError(steps[c], steps[current]));
3130
+ if (carrying.length === 0) return current;
3131
+ current = carrying[carrying.length - 1];
3132
+ }
3133
+ });
3134
+ }
3135
+ function failingStepIndex(steps, parents = stepParents(steps), error) {
3136
+ const ends = failingChainEnds(steps, parents);
3137
+ if (ends.length === 0) return null;
3138
+ const phases = stepPhases(steps, parents);
3139
+ if (error) {
3140
+ const heads = errorHeads(error);
3141
+ const headOf = (i) => firstLine(errorMessage2(steps[i]));
3142
+ const frame = extractTopFrame(error);
3143
+ if (frame) {
3144
+ const thrownThere = ends.filter(
3145
+ (i) => heads.includes(headOf(i)) && sameCodeLocation(errorLocation(steps[i]) || str(steps[i].location), frame)
3146
+ );
3147
+ const located = lastByPhase(thrownThere, phases);
3148
+ if (located !== null) return located;
3149
+ }
3150
+ for (const head of heads) {
3151
+ const matching = lastByPhase(
3152
+ ends.filter((i) => headOf(i) === head),
3153
+ phases
3154
+ );
3155
+ if (matching !== null) return matching;
3156
+ }
2949
3157
  }
3158
+ return lastByPhase(ends, phases) ?? ends[ends.length - 1];
2950
3159
  }
2951
3160
  function stepHookName(step) {
2952
3161
  const title = str(step.title).trim();
@@ -2959,11 +3168,8 @@ function stepHookName(step) {
2959
3168
  if (category === "hook" && title && !isPhaseContainer(step)) return `hook "${title}"`;
2960
3169
  return null;
2961
3170
  }
2962
- function firstLine(message) {
2963
- return stripAnsi(str(message)).split("\n")[0].trim();
2964
- }
2965
3171
  function failureHookContext(steps, error, parents = stepParents(steps)) {
2966
- const index = failingStepIndex(steps, parents);
3172
+ const index = failingStepIndex(steps, parents, error);
2967
3173
  if (index === null) return null;
2968
3174
  const phase = stepPhases(steps, parents)[index];
2969
3175
  if (phase === "body") return null;
@@ -3029,6 +3235,28 @@ function normalizeStepParams(raw) {
3029
3235
  }
3030
3236
  return count > 0 ? out : void 0;
3031
3237
  }
3238
+ function locationText(location) {
3239
+ if (!location || typeof location.file !== "string" || !location.file) return void 0;
3240
+ return `${location.file}:${location.line}:${location.column}`;
3241
+ }
3242
+ function markRecoveredSteps(steps, errors) {
3243
+ const fatal = errors.map((error) => ({
3244
+ head: firstErrorLine(error.message),
3245
+ location: locationText(error.location)
3246
+ }));
3247
+ for (const step of steps) {
3248
+ if (!step.error) continue;
3249
+ const head = firstErrorLine(step.error.message);
3250
+ const thrownAt = step.error.location;
3251
+ const isFatal = fatal.some(
3252
+ (error) => error.head === head && (!error.location || !thrownAt || sameCodeLocation(error.location, thrownAt))
3253
+ );
3254
+ if (!isFatal) step.recovered = true;
3255
+ }
3256
+ }
3257
+ function firstErrorLine(message) {
3258
+ return stripAnsi(typeof message === "string" ? message : "").split("\n")[0].trim();
3259
+ }
3032
3260
  function flattenSteps(steps, depth = 0) {
3033
3261
  const result = [];
3034
3262
  for (const step of steps) {
@@ -3043,17 +3271,21 @@ function flattenSteps(steps, depth = 0) {
3043
3271
  if (params) flat.params = params;
3044
3272
  if (step.error?.message) {
3045
3273
  flat.error = { message: step.error.message };
3274
+ const thrownAt = locationText(step.error.location);
3275
+ if (thrownAt) flat.error.location = thrownAt;
3046
3276
  flat.failed = true;
3047
3277
  }
3048
- if (step.location) flat.location = `${step.location.file}:${step.location.line}:${step.location.column}`;
3278
+ const location = locationText(step.location);
3279
+ if (location) flat.location = location;
3049
3280
  if (step.startTime) flat.startTime = step.startTime instanceof Date ? step.startTime.getTime() : step.startTime;
3050
3281
  result.push(flat);
3051
3282
  if (step.steps?.length > 0) result.push(...flattenSteps(step.steps, depth + 1));
3052
3283
  }
3053
3284
  return result;
3054
3285
  }
3055
- function collectStepMetrics(steps) {
3286
+ function collectStepMetrics(steps, errors) {
3056
3287
  const flatSteps = flattenSteps(steps);
3288
+ if (errors) markRecoveredSteps(flatSteps, errors);
3057
3289
  const totalStepDuration = steps.reduce((sum, s) => sum + (s.duration || 0), 0);
3058
3290
  let slowestStep = null;
3059
3291
  for (const s of flatSteps) {
@@ -3292,22 +3524,30 @@ function extractFailingLine(errorText, testFile2, declarationLine) {
3292
3524
  }
3293
3525
 
3294
3526
  // src/internal/support/ci.ts
3295
- function detectCiRunLabel() {
3296
- const env = process.env;
3297
- if (env.GITHUB_ACTIONS && env.GITHUB_RUN_ID) return env.GITHUB_RUN_ID;
3298
- if (env.GITLAB_CI && env.CI_PIPELINE_ID) return env.CI_PIPELINE_ID;
3299
- if (env.CIRCLECI && env.CIRCLE_WORKFLOW_ID) return env.CIRCLE_WORKFLOW_ID;
3300
- if (env.TRAVIS && env.TRAVIS_BUILD_ID) return env.TRAVIS_BUILD_ID;
3301
- if (env.TF_BUILD && env.BUILD_BUILDID) return env.BUILD_BUILDID;
3302
- if (env.JENKINS_URL && env.BUILD_ID) return env.BUILD_ID;
3303
- if (env.BUILDKITE_BUILD_ID) return env.BUILDKITE_BUILD_ID;
3304
- if (env.TEAMCITY_BUILD_ID) return env.TEAMCITY_BUILD_ID;
3305
- if (env.BITBUCKET_BUILD_NUMBER) return env.BITBUCKET_BUILD_NUMBER;
3306
- if (env.SEMAPHORE_WORKFLOW_ID) return env.SEMAPHORE_WORKFLOW_ID;
3307
- if (env.APPVEYOR_BUILD_ID) return env.APPVEYOR_BUILD_ID;
3308
- if (env.DRONE_BUILD_NUMBER) return env.DRONE_BUILD_NUMBER;
3527
+ function detectCiIds(env) {
3528
+ if (env.GITHUB_ACTIONS && env.GITHUB_RUN_ID) {
3529
+ const run = env.GITHUB_RUN_ATTEMPT ? `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}` : env.GITHUB_RUN_ID;
3530
+ return { run, job: env.GITHUB_JOB || null };
3531
+ }
3532
+ if (env.GITLAB_CI && env.CI_PIPELINE_ID) return { run: env.CI_PIPELINE_ID, job: env.CI_JOB_ID || null };
3533
+ if (env.CIRCLECI && env.CIRCLE_WORKFLOW_ID) return { run: env.CIRCLE_WORKFLOW_ID, job: env.CIRCLE_BUILD_NUM || null };
3534
+ if (env.TRAVIS && env.TRAVIS_BUILD_ID) return { run: env.TRAVIS_BUILD_ID, job: env.TRAVIS_JOB_ID || null };
3535
+ if (env.TF_BUILD && env.BUILD_BUILDID) return { run: env.BUILD_BUILDID, job: env.SYSTEM_JOBID || null };
3536
+ if (env.JENKINS_URL && env.BUILD_ID) return { run: env.BUILD_ID, job: null };
3537
+ if (env.BUILDKITE_BUILD_ID) return { run: env.BUILDKITE_BUILD_ID, job: env.BUILDKITE_JOB_ID || null };
3538
+ if (env.TEAMCITY_BUILD_ID) return { run: env.TEAMCITY_BUILD_ID, job: null };
3539
+ if (env.BITBUCKET_BUILD_NUMBER) return { run: env.BITBUCKET_BUILD_NUMBER, job: env.BITBUCKET_STEP_UUID || null };
3540
+ if (env.SEMAPHORE_WORKFLOW_ID) return { run: env.SEMAPHORE_WORKFLOW_ID, job: env.SEMAPHORE_JOB_ID || null };
3541
+ if (env.APPVEYOR_BUILD_ID) return { run: env.APPVEYOR_BUILD_ID, job: env.APPVEYOR_JOB_ID || null };
3542
+ if (env.DRONE_BUILD_NUMBER) return { run: env.DRONE_BUILD_NUMBER, job: null };
3309
3543
  return null;
3310
3544
  }
3545
+ function resolveRunLabel(configured, sharded) {
3546
+ if (configured) return configured;
3547
+ const ci = detectCiIds(process.env);
3548
+ if (!ci) return null;
3549
+ return !sharded && ci.job ? `${ci.run}|${ci.job}` : ci.run;
3550
+ }
3311
3551
 
3312
3552
  // src/internal/support/worker-index.ts
3313
3553
  function workerIndexOf(result) {
@@ -3363,6 +3603,20 @@ function readSelectionStamp(env = process.env) {
3363
3603
  return { key, version, resolvedHash, resolvedCount };
3364
3604
  }
3365
3605
 
3606
+ // src/internal/support/shard-info.ts
3607
+ function parseShardSpec(raw) {
3608
+ const match = raw?.trim().match(/^(\d+)\s*\/\s*(\d+)$/);
3609
+ if (!match) return null;
3610
+ const current = Number(match[1]);
3611
+ const total = Number(match[2]);
3612
+ return current >= 1 && current <= total && total > 1 ? { current, total } : null;
3613
+ }
3614
+ function resolveShardInfo(config, env = process.env) {
3615
+ const pwShard = config.shard;
3616
+ if (pwShard && pwShard.total > 1) return { current: pwShard.current, total: pwShard.total };
3617
+ return parseShardSpec(env[PIWI_SHARD_ENV]);
3618
+ }
3619
+
3366
3620
  // src/internal/support/run-mode.ts
3367
3621
  var PW_UI_FLAGS = ["--ui", "--ui-host", "--ui-port"];
3368
3622
  function isUiMode(argv = process.argv) {
@@ -3553,10 +3807,7 @@ function createGlobalSetup(options, userSetup) {
3553
3807
  const httpClient = new HttpClient(opts.serverUrl, logger);
3554
3808
  try {
3555
3809
  const auth = await httpClient.resolveAuth(opts);
3556
- const runLabel = opts.runLabel || detectCiRunLabel();
3557
- const pwShard = config.shard;
3558
- const shardIndex = pwShard?.current;
3559
- const shardTotal = pwShard?.total;
3810
+ const shard = resolveShardInfo(config);
3560
3811
  const response = await httpClient.postJSON(
3561
3812
  "/api/test-runs/setup",
3562
3813
  {
@@ -3566,9 +3817,9 @@ function createGlobalSetup(options, userSetup) {
3566
3817
  label: opts.label || null,
3567
3818
  keep: opts.keep === true,
3568
3819
  startTime: (/* @__PURE__ */ new Date()).toISOString(),
3569
- instanceId: computeInstanceId(opts.projectName, runLabel),
3570
- shardIndex,
3571
- shardTotal
3820
+ instanceId: computeInstanceId(opts.projectName, resolveRunLabel(opts.runLabel, shard !== null)),
3821
+ shardIndex: shard?.current,
3822
+ shardTotal: shard?.total
3572
3823
  },
3573
3824
  auth
3574
3825
  );
@@ -3745,6 +3996,42 @@ var LONG_HEX_RE2 = /\b[0-9a-f]{8,}\b/gi;
3745
3996
  var SHORT_HEX_RE = /\b(?=[0-9a-f]*[a-f])(?=[0-9a-f]*[0-9])[0-9a-f]{6,7}\b/gi;
3746
3997
  var URL_RE2 = /\bhttps?:\/\/[^\s'"`)]+/gi;
3747
3998
  var EMAIL_RE = /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/gi;
3999
+ var SELECTOR_OPTION_KEY_RE = /\b(name|hasText|hasNotText|has|placeholder|label|title|alt|exact)\s*:\s*(['"`])/gi;
4000
+ var LINE_TERMINATORS = /* @__PURE__ */ new Set(["\n", "\r", "\u2028", "\u2029"]);
4001
+ function closingQuoteIndex(text, quote) {
4002
+ const close = new Int32Array(text.length + 1).fill(-1);
4003
+ for (let p = text.length - 1; p >= 0; p--) {
4004
+ const ch = text[p];
4005
+ if (ch === quote) {
4006
+ close[p] = p;
4007
+ continue;
4008
+ }
4009
+ const escaped = ch === "\\" && p + 1 < text.length && !LINE_TERMINATORS.has(text[p + 1]) ? close[p + 2] : -1;
4010
+ close[p] = escaped !== -1 ? escaped : close[p + 1];
4011
+ }
4012
+ return close;
4013
+ }
4014
+ function maskSelectorOptions(text) {
4015
+ const closers = /* @__PURE__ */ new Map();
4016
+ let out = "";
4017
+ let copied = 0;
4018
+ SELECTOR_OPTION_KEY_RE.lastIndex = 0;
4019
+ let match;
4020
+ while (match = SELECTOR_OPTION_KEY_RE.exec(text)) {
4021
+ const quote = match[2];
4022
+ let close = closers.get(quote);
4023
+ if (!close) closers.set(quote, close = closingQuoteIndex(text, quote));
4024
+ const end = close[SELECTOR_OPTION_KEY_RE.lastIndex];
4025
+ if (end === -1) {
4026
+ SELECTOR_OPTION_KEY_RE.lastIndex = match.index + 1;
4027
+ continue;
4028
+ }
4029
+ out += `${text.slice(copied, match.index)}${match[1]}: <STR>`;
4030
+ copied = end + 1;
4031
+ SELECTOR_OPTION_KEY_RE.lastIndex = copied;
4032
+ }
4033
+ return out + text.slice(copied);
4034
+ }
3748
4035
  function classifyError(text) {
3749
4036
  if (/strict mode violation/i.test(text)) return "strict-mode";
3750
4037
  if (/\bexpect\(|\bexpect\.|Expected (?:string|substring|pattern|value)|\.toHave|\.toBe|\.toContain|\.toEqual/.test(text))
@@ -3761,7 +4048,7 @@ function maskVolatile(text) {
3761
4048
  function extractErrorSignature(rawError) {
3762
4049
  const text = stripAnsi(rawError);
3763
4050
  const errorType = classifyError(text);
3764
- const normalizedMessage = maskVolatile(extractMessageHead(text));
4051
+ const normalizedMessage = maskVolatile(maskSelectorOptions(extractMessageHead(text)));
3765
4052
  const selector = extractSelector(text);
3766
4053
  const topFrameFile = extractTopFrameFile(text);
3767
4054
  const signature = (normalizedMessage.split("\n")[0] || "").slice(0, 200) || "Unknown error";
@@ -4788,6 +5075,7 @@ function ciBuildUrlFromMetadata(metadata) {
4788
5075
  }
4789
5076
 
4790
5077
  // src/internal/submit/run-submitter.ts
5078
+ var MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
4791
5079
  var RunSubmitter = class {
4792
5080
  /**
4793
5081
  * @param httpClient HTTP client for auth resolution and the finish call.
@@ -4805,8 +5093,28 @@ var RunSubmitter = class {
4805
5093
  this.logger = logger;
4806
5094
  this.failureLinks = failureLinks;
4807
5095
  }
4808
- /** Run the fallback ladder for a completed test run. */
5096
+ /**
5097
+ * Run the fallback ladder for a completed test run, within the
5098
+ * `submitTimeout` budget. When the budget runs out, every request in flight
5099
+ * and every later one fails, so the ladder falls through to saving the
5100
+ * recovery copy.
5101
+ */
4809
5102
  async submit(run, result) {
5103
+ const budgetMs = run.options.submitTimeout ?? 0;
5104
+ const timer = budgetMs > 0 && Number.isFinite(budgetMs) ? setTimeout(() => this.expireBudget(budgetMs), Math.min(budgetMs, MAX_TIMER_DELAY_MS)) : null;
5105
+ try {
5106
+ await this.submitRun(run, result);
5107
+ } finally {
5108
+ if (timer) clearTimeout(timer);
5109
+ }
5110
+ }
5111
+ expireBudget(budgetMs) {
5112
+ this.logger.warn(
5113
+ `The dashboard did not take the run within ${Math.round(budgetMs / 1e3)}s (submitTimeout) \u2014 stopping the upload and saving the results locally.`
5114
+ );
5115
+ this.httpClient.close(`the end-of-run time budget (submitTimeout: ${budgetMs} ms) ran out`);
5116
+ }
5117
+ async submitRun(run, result) {
4810
5118
  const endTime = (/* @__PURE__ */ new Date()).toISOString();
4811
5119
  const duration = new Date(endTime).getTime() - new Date(run.startTime).getTime();
4812
5120
  const counts = computeDistinctRunCounts(run.testCases);
@@ -4843,10 +5151,10 @@ var RunSubmitter = class {
4843
5151
  }
4844
5152
  if (!sm) await this.recovery.tryUpload(this.httpClient, auth);
4845
5153
  let outcome = { done: false, output: null };
4846
- if (sm?.enabled && sm?.runId != null && !sm.bufferLostResults) {
5154
+ if (sm?.enabled && sm?.runId != null && !sm.lostResults) {
4847
5155
  outcome = await this.tryFinishStreaming(run, overallStatus, duration, auth);
4848
5156
  }
4849
- if (!outcome.done && (this.hasReports(run) || run.options.uploadTraces)) {
5157
+ if (!outcome.done && !this.httpClient.closed && (this.hasReports(run) || run.options.uploadTraces)) {
4850
5158
  outcome = await this.tryUploadWithFiles(run, overallStatus, duration, auth);
4851
5159
  }
4852
5160
  if (!outcome.done) {
@@ -4941,7 +5249,7 @@ var RunSubmitter = class {
4941
5249
  }
4942
5250
  await this.httpClient.postJSON(`/api/test-runs/${sm.runId}/finish`, finishBody, auth);
4943
5251
  this.logger.info(`Successfully finalized streaming run #${sm.runId}`);
4944
- this.recovery.clear();
5252
+ this.dropLocalCopies();
4945
5253
  if (this.hasReports(run)) {
4946
5254
  try {
4947
5255
  await this.uploader.uploadReportsForStreamingRun(
@@ -4966,7 +5274,7 @@ var RunSubmitter = class {
4966
5274
  const payload = this.buildRunPayload(run, overallStatus, duration);
4967
5275
  try {
4968
5276
  const response = await this.uploader.uploadWithFiles(payload, this.reportOptions(run), auth);
4969
- this.recovery.clear();
5277
+ this.dropLocalCopies();
4970
5278
  return { done: true, output: this.buildOutput(response?.runId, response?.projectId, run, overallStatus) };
4971
5279
  } catch (error) {
4972
5280
  if (error instanceof HttpError && error.status === 401 && !auth) {
@@ -4983,7 +5291,7 @@ var RunSubmitter = class {
4983
5291
  const payload = this.buildRunPayload(run, overallStatus, duration);
4984
5292
  try {
4985
5293
  const response = await this.uploader.uploadJSON(payload, auth);
4986
- this.recovery.clear();
5294
+ this.dropLocalCopies();
4987
5295
  return { done: true, output: this.buildOutput(response?.runId, response?.projectId, run, overallStatus) };
4988
5296
  } catch (error) {
4989
5297
  if (error instanceof HttpError && error.status === 401 && !auth) {
@@ -4999,6 +5307,11 @@ var RunSubmitter = class {
4999
5307
  return { done: true, output: null };
5000
5308
  }
5001
5309
  }
5310
+ /** The run reached the server: drop the recovery copy and the run's buffered live events. */
5311
+ dropLocalCopies() {
5312
+ this.recovery.clear();
5313
+ this.streamManager?.discardBuffered();
5314
+ }
5002
5315
  /**
5003
5316
  * Persist the wire-serialized payload (no raw attachments / internal fields)
5004
5317
  * so a later run can retry the submit.
@@ -5176,7 +5489,7 @@ function countNoun(locator, count) {
5176
5489
  }
5177
5490
  function textOfGetByText(locator) {
5178
5491
  if (!locator) return null;
5179
- const m = /^getByText\(\s*(['"`])((?:\\.|(?!\1).)*)\1\s*(?:,\s*\{[^}]*\})?\s*\)$/.exec(locator);
5492
+ const m = /^getByText\(\s*(['"`])((?:\\.|(?!\1)[^\\\n\r\u2028\u2029])*)\1\s*(?:,\s*\{[^}]*\})?\s*\)$/.exec(locator);
5180
5493
  return m ? m[2] : null;
5181
5494
  }
5182
5495
  function receivedDisplay(parsed) {
@@ -5466,10 +5779,10 @@ function describeFailureText(raw, ctx) {
5466
5779
  if (!raw || !raw.trim()) return null;
5467
5780
  return describeFailure(parsePlaywrightError(raw, { stepParams: ctx?.stepParams }), ctx);
5468
5781
  }
5469
- function headlineStep(steps) {
5782
+ function headlineStep(steps, error) {
5470
5783
  if (!steps || steps.length === 0) return null;
5471
5784
  const parents = stepParents(steps);
5472
- const failed = failingStepIndex(steps, parents);
5785
+ const failed = failingStepIndex(steps, parents, error);
5473
5786
  if (failed !== null) return steps[failed];
5474
5787
  const phases = stepPhases(steps, parents);
5475
5788
  for (let i = steps.length - 1; i >= 0; i--) {
@@ -5478,17 +5791,17 @@ function headlineStep(steps) {
5478
5791
  }
5479
5792
  return steps[steps.length - 1];
5480
5793
  }
5481
- function lastStepTitle(steps) {
5482
- const title = headlineStep(steps)?.title;
5794
+ function lastStepTitle(steps, error) {
5795
+ const title = headlineStep(steps, error)?.title;
5483
5796
  return typeof title === "string" ? title : null;
5484
5797
  }
5485
- function failingStepParams(steps) {
5486
- return headlineStep(steps)?.params ?? null;
5798
+ function failingStepParams(steps, error) {
5799
+ return headlineStep(steps, error)?.params ?? null;
5487
5800
  }
5488
5801
  function stepHeadlineContext(steps, error) {
5489
5802
  return {
5490
- lastStepTitle: lastStepTitle(steps),
5491
- stepParams: failingStepParams(steps),
5803
+ lastStepTitle: lastStepTitle(steps, error),
5804
+ stepParams: failingStepParams(steps, error),
5492
5805
  failedIn: steps && steps.length > 0 ? failureHookContext(steps, error ?? null) : null
5493
5806
  };
5494
5807
  }
@@ -5561,7 +5874,8 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
5561
5874
  this.reportedTestIds = /* @__PURE__ */ new Set();
5562
5875
  /** Per-test attempt history, keyed by `test.id`; snapshotted onto every attempt's payload. */
5563
5876
  this.attemptsByTest = /* @__PURE__ */ new Map();
5564
- this.runLabel = null;
5877
+ /** Derived in `onBegin`, once the shard is known. */
5878
+ this.instanceId = "";
5565
5879
  this.shardInfo = null;
5566
5880
  this.metadata = {};
5567
5881
  this.isFullRun = true;
@@ -5575,8 +5889,6 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
5575
5889
  this.enabled = this.options.enabled !== false && !!this.options.serverUrl;
5576
5890
  this.listMode = isListMode();
5577
5891
  this.viaDesktopApp = usedDesktopDiscovery();
5578
- this.runLabel = this.options.runLabel || detectCiRunLabel();
5579
- this.instanceId = computeInstanceId(this.options.projectName, this.runLabel);
5580
5892
  const logger = new Logger(this.options.verbose ?? false);
5581
5893
  this.logger = logger;
5582
5894
  this.httpClient = new HttpClient(this.options.serverUrl ?? "http://localhost:3000", logger);
@@ -5670,11 +5982,14 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
5670
5982
  this.metadata = flakeRunMetadata(stamp, this.metadata);
5671
5983
  }
5672
5984
  this.plannedTests = suite.allTests();
5673
- const pwShard = config.shard;
5674
- if (pwShard?.total && pwShard.total > 1) {
5675
- this.shardInfo = { current: pwShard.current, total: pwShard.total };
5985
+ this.shardInfo = resolveShardInfo(config);
5986
+ if (this.shardInfo) {
5676
5987
  this.logger.info(`Shard ${this.shardInfo.current}/${this.shardInfo.total} detected`);
5677
5988
  }
5989
+ this.instanceId = computeInstanceId(
5990
+ this.options.projectName,
5991
+ resolveRunLabel(this.options.runLabel, this.shardInfo !== null)
5992
+ );
5678
5993
  this.streamManager?.start(
5679
5994
  this.startTime,
5680
5995
  this.metadata,
@@ -5827,7 +6142,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
5827
6142
  if (frames.length) testCase.testSourceFrames = frames;
5828
6143
  }
5829
6144
  if (this.options.collectPerformanceMetrics && result.steps?.length > 0) {
5830
- testCase.performanceMetrics = collectStepMetrics(result.steps);
6145
+ testCase.performanceMetrics = collectStepMetrics(result.steps, result.errors);
5831
6146
  const stepEvents = extractTestStepEvents(result.steps, result.startTime);
5832
6147
  const waitEvents = extractWaitEvents(result.steps);
5833
6148
  const allEvents = [...stepEvents, ...waitEvents];
@@ -6398,7 +6713,7 @@ function installPickerOverlay(arg) {
6398
6713
  banner.style.cssText = `position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:${Z + 2};background:#111827;color:#f9fafb;font:13px/1.5 system-ui,sans-serif;border:1px solid #312e81;padding:10px 16px;border-radius:10px;box-shadow:0 4px 24px rgba(0,0,0,.5);max-width:min(680px,86vw);`;
6399
6714
  const hlTokens = (expr) => {
6400
6715
  const escHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
6401
- const re = /('(?:\\.|[^'])*'|"(?:\\.|[^"])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
6716
+ const re = /('(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
6402
6717
  let html = "";
6403
6718
  let last = 0;
6404
6719
  let m;
@@ -6941,7 +7256,7 @@ function showPickerChoices(arg) {
6941
7256
  panel.style.cssText = "background:#111827;color:#f9fafb;border-radius:10px;padding:20px;max-width:640px;width:90vw;max-height:70vh;overflow:auto;box-shadow:0 8px 40px rgba(0,0,0,.5);";
6942
7257
  const hlLocator = (expr) => {
6943
7258
  const escHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
6944
- const re = /('(?:\\.|[^'])*'|"(?:\\.|[^"])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
7259
+ const re = /('(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
6945
7260
  let html = "";
6946
7261
  let last = 0;
6947
7262
  let m;
@@ -8030,7 +8345,7 @@ function inspectionGateFromTestInfo(testInfo, enabled = process.env.PIWI_INSPECT
8030
8345
 
8031
8346
  // src/internal/capture/pick-on-failure.ts
8032
8347
  var path20 = __toESM(require("path"));
8033
- var ANSI_RE2 = /\[[0-9;]*m/g;
8348
+ var ANSI_RE2 = /\x1b\[[0-9;]*m/g;
8034
8349
  function parseLeafLocatorExpression(rawExpr) {
8035
8350
  const leaf = parseLeafLocatorCall(rawExpr);
8036
8351
  return leaf ? { method: leaf.method, args: locatorCallValues(leaf) } : null;
@@ -9938,15 +10253,15 @@ function entryPath(params) {
9938
10253
  const name = `${slug(params.testTitle)}.${slug(params.template)}.${hash}.json`;
9939
10254
  return path25.join(entryDir(params.specFile, params.dir), name);
9940
10255
  }
9941
- function parseLocation(location) {
10256
+ function parseLocation2(location) {
9942
10257
  const match = /:(\d+):(\d+)$/.exec(location);
9943
10258
  if (!match) return null;
9944
10259
  return { line: Number(match[1]), col: Number(match[2]) };
9945
10260
  }
9946
10261
  function orderByPosition(locations) {
9947
10262
  return [...new Set(locations)].sort((a, b) => {
9948
- const pa = parseLocation(a);
9949
- const pb = parseLocation(b);
10263
+ const pa = parseLocation2(a);
10264
+ const pb = parseLocation2(b);
9950
10265
  if (!pa || !pb) return a < b ? -1 : a > b ? 1 : 0;
9951
10266
  return pa.line - pb.line || pa.col - pb.col;
9952
10267
  });