@piwitests/reporter 0.57.0 → 0.59.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/cli/index.js CHANGED
@@ -873,6 +873,8 @@ var PIWI_ENV_KEYS = {
873
873
  codeReachRoots: "PIWI_CODE_REACH_ROOTS",
874
874
  coverageBrowser: "PIWI_COVERAGE_BROWSER",
875
875
  coverageServer: "PIWI_COVERAGE_SERVER",
876
+ externalIdPattern: "PIWI_EXTERNAL_ID_PATTERN",
877
+ externalIdOnDuplicate: "PIWI_EXTERNAL_ID_ON_DUPLICATE",
876
878
  captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
877
879
  captureResources: "PIWI_CAPTURE_RESOURCES",
878
880
  leakCheck: "PIWI_LEAK_CHECK",
@@ -4877,7 +4879,7 @@ Connection:
4877
4879
  --server-url <url> Dashboard URL (env PIWI_DASHBOARD_URL)
4878
4880
  --api-key <key> API key (env PIWI_API_KEY)
4879
4881
 
4880
- Policy (at least one is required):
4882
+ Policy (at least one rule or warn-only flag is required):
4881
4883
  --require-tag <tags> Comma-separated; every test carrying the tag must pass
4882
4884
  --max-failed <n> Fail when more than n tests failed
4883
4885
  --max-new-regressions <n> Fail when more than n tests newly started failing
@@ -4890,7 +4892,7 @@ Policy (at least one is required):
4890
4892
  --max-new-leaks <n> Fail when more than n of those were never seen on the base branch
4891
4893
 
4892
4894
  Warn-only (reported, never fails the build):
4893
- --max-uncovered-changes <n> Warn when more than n changed files have no observed test reach
4895
+ --max-uncovered-changes <n> Warn when more than n changed files are uncovered changes: no test reached them
4894
4896
  --min-patch-coverage <percent> Warn when fewer of the changed lines ran, in the commit's combined coverage
4895
4897
 
4896
4898
  Other:
@@ -127,6 +127,8 @@ var PIWI_ENV_KEYS = {
127
127
  codeReachRoots: "PIWI_CODE_REACH_ROOTS",
128
128
  coverageBrowser: "PIWI_COVERAGE_BROWSER",
129
129
  coverageServer: "PIWI_COVERAGE_SERVER",
130
+ externalIdPattern: "PIWI_EXTERNAL_ID_PATTERN",
131
+ externalIdOnDuplicate: "PIWI_EXTERNAL_ID_ON_DUPLICATE",
130
132
  captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
131
133
  captureResources: "PIWI_CAPTURE_RESOURCES",
132
134
  leakCheck: "PIWI_LEAK_CHECK",
@@ -217,6 +219,13 @@ function resolveOptions(raw) {
217
219
  coverage.browser = coverageBrowser;
218
220
  if (coverage.server === void 0 && coverageServer) coverage.server = coverageServer;
219
221
  if (coverage.browser !== void 0 || coverage.server !== void 0) mergedRaw.coverage = coverage;
222
+ const externalId = { ...mergedRaw.externalId };
223
+ const externalIdPattern = env[PIWI_ENV_KEYS.externalIdPattern];
224
+ const externalIdOnDuplicate = env[PIWI_ENV_KEYS.externalIdOnDuplicate];
225
+ if (externalId.pattern === void 0 && externalIdPattern) externalId.pattern = externalIdPattern;
226
+ if (externalId.onDuplicate === void 0 && externalIdOnDuplicate)
227
+ externalId.onDuplicate = externalIdOnDuplicate;
228
+ if (Object.keys(externalId).length > 0) mergedRaw.externalId = externalId;
220
229
  const opts = { ...DEFAULTS, ...mergedRaw };
221
230
  if (env[PIWI_ENV_KEYS.verbose] !== void 0) opts.verbose = env[PIWI_ENV_KEYS.verbose] === "true";
222
231
  return opts;
package/dist/index.d.ts CHANGED
@@ -129,6 +129,33 @@ interface PiwiDashboardOptions {
129
129
  */
130
130
  server?: 'off' | 'files' | 'functions' | 'lines';
131
131
  };
132
+ /**
133
+ * Match each test to its history on the dashboard by an external ID, the ID the test has in your own test
134
+ * management tool, so a test keeps its history when it is renamed or moved. A test declares its ID with a
135
+ * `piwi:id` annotation (`{ type: 'piwi:id', description: '12526' }`), which wins, or the ID is read from its title
136
+ * with `pattern`. Two tests declaring the same ID are reported before the first test runs, and matched by file,
137
+ * describe path and title instead.
138
+ */
139
+ externalId?: {
140
+ /**
141
+ * Where the ID sits in a test's own title (not its describe path): `/^(\d+), /` reads `12526` from
142
+ * `'12526, Log in with Email'`. The ID is the first capture group, or the whole match without a group. A string
143
+ * is compiled with `new RegExp(value)`; one that does not compile turns external IDs off for the run, with an
144
+ * error, but never fails it. Can also be set with `PIWI_EXTERNAL_ID_PATTERN`.
145
+ */
146
+ pattern?: RegExp | string;
147
+ /**
148
+ * Show the title without its ID on the dashboard, next to the ID. The whole match of `pattern` is removed from
149
+ * the title shown; the title stored and matched on stays whole. Defaults to `false`.
150
+ */
151
+ stripFromTitle?: boolean;
152
+ /**
153
+ * What two tests declaring the same ID do: `'report'` lists them in the console and on the run page; `'fail'`
154
+ * also fails the run once its tests have run. Either way the run matches them by file, describe path and title.
155
+ * Defaults to `'report'`. Can also be set with `PIWI_EXTERNAL_ID_ON_DUPLICATE`.
156
+ */
157
+ onDuplicate?: 'report' | 'fail';
158
+ };
132
159
  /**
133
160
  * Capture server-side spans for each API/document request the test makes,
134
161
  * read from the `X-Piwi-Trace` response header emitted by a Piwi
@@ -457,6 +484,8 @@ declare class PiwiDashboardReporter {
457
484
  private instanceId;
458
485
  private shardInfo;
459
486
  private metadata;
487
+ /** Each planned test's external ID and the IDs two tests share, settled in `onBegin`. */
488
+ private externalIds;
460
489
  private enabled;
461
490
  /**
462
491
  * True under `playwright test --list`, where Playwright still constructs this
@@ -515,6 +544,13 @@ declare class PiwiDashboardReporter {
515
544
  printsToStdio(): boolean;
516
545
  /** Playwright reporter hook: called once at the start of the test run */
517
546
  onBegin(config: FullConfig, suite: Suite): void;
547
+ /**
548
+ * Settle each planned test's external ID before any test runs, name the IDs two tests share, and record them on
549
+ * the run so the dashboard lists them too.
550
+ */
551
+ private planExternalIds;
552
+ /** The external ID fields of a test's payload: `null` tells the dashboard the test has none to match on. */
553
+ private externalIdFields;
518
554
  /** Playwright reporter hook: called when an individual test begins */
519
555
  onTestBegin(test: TestCase, result: TestResult): void;
520
556
  /** Track suite-level setup steps (beforeAll/afterAll) not tied to any test */
@@ -554,8 +590,14 @@ declare class PiwiDashboardReporter {
554
590
  * interrupted) is resolved once from the overall run status by the caller.
555
591
  */
556
592
  private materializeUnrunTests;
557
- /** Playwright reporter hook: called when the full test run finishes */
558
- onEnd(result: FullResult): Promise<void>;
593
+ /**
594
+ * Playwright reporter hook: called when the full test run finishes. Fails the run when two tests share an external
595
+ * ID and `externalId.onDuplicate` is `'fail'`.
596
+ */
597
+ onEnd(result: FullResult): Promise<{
598
+ status?: FullResult['status'];
599
+ } | void>;
600
+ private finishRun;
559
601
  }
560
602
 
561
603
  interface ResolveSelectionResult {
package/dist/index.js CHANGED
@@ -128,6 +128,8 @@ var PIWI_ENV_KEYS = {
128
128
  codeReachRoots: "PIWI_CODE_REACH_ROOTS",
129
129
  coverageBrowser: "PIWI_COVERAGE_BROWSER",
130
130
  coverageServer: "PIWI_COVERAGE_SERVER",
131
+ externalIdPattern: "PIWI_EXTERNAL_ID_PATTERN",
132
+ externalIdOnDuplicate: "PIWI_EXTERNAL_ID_ON_DUPLICATE",
131
133
  captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
132
134
  captureResources: "PIWI_CAPTURE_RESOURCES",
133
135
  leakCheck: "PIWI_LEAK_CHECK",
@@ -247,6 +249,13 @@ function resolveOptions(raw) {
247
249
  coverage.browser = coverageBrowser;
248
250
  if (coverage.server === void 0 && coverageServer) coverage.server = coverageServer;
249
251
  if (coverage.browser !== void 0 || coverage.server !== void 0) mergedRaw.coverage = coverage;
252
+ const externalId = { ...mergedRaw.externalId };
253
+ const externalIdPattern = env[PIWI_ENV_KEYS.externalIdPattern];
254
+ const externalIdOnDuplicate = env[PIWI_ENV_KEYS.externalIdOnDuplicate];
255
+ if (externalId.pattern === void 0 && externalIdPattern) externalId.pattern = externalIdPattern;
256
+ if (externalId.onDuplicate === void 0 && externalIdOnDuplicate)
257
+ externalId.onDuplicate = externalIdOnDuplicate;
258
+ if (Object.keys(externalId).length > 0) mergedRaw.externalId = externalId;
250
259
  const opts = { ...DEFAULTS, ...mergedRaw };
251
260
  if (env[PIWI_ENV_KEYS.verbose] !== void 0) opts.verbose = env[PIWI_ENV_KEYS.verbose] === "true";
252
261
  return opts;
@@ -625,6 +634,7 @@ function toWireTestCase(tc) {
625
634
  timeout: rest.timeout ?? null,
626
635
  error: rest.error,
627
636
  retries: rest.retries,
637
+ repeatEachIndex: rest.repeatEachIndex ?? 0,
628
638
  attempts: rest.attempts || null,
629
639
  workerIndex: rest.workerIndex ?? null,
630
640
  shardIndex: rest.shardIndex ?? null,
@@ -652,6 +662,9 @@ function toWireTestCase(tc) {
652
662
  tags: rest.tags ?? null,
653
663
  locks: rest.locks ?? null,
654
664
  testMeta: rest.testMeta ?? null,
665
+ // Kept `undefined` when unset (a `begin` event): `null` would tell the dashboard the test has no ID.
666
+ externalId: rest.externalId,
667
+ displayTitle: rest.displayTitle ?? null,
655
668
  locatorSnapshots: rest.locatorSnapshots || null,
656
669
  locatorPages: rest.locatorPages || null,
657
670
  codeReach: rest.codeReach || null,
@@ -674,7 +687,13 @@ function isFailingStatus(status) {
674
687
  function computeDistinctRunCounts(testCases) {
675
688
  const final = /* @__PURE__ */ new Map();
676
689
  for (const tc of testCases) {
677
- const key = tc.testId ?? [tc.title, tc.location, (tc.suitePath ?? []).join(""), browserProjectName(tc.browser)].join("\0");
690
+ const key = tc.testId ?? [
691
+ tc.title,
692
+ tc.location,
693
+ (tc.suitePath ?? []).join(""),
694
+ browserProjectName(tc.browser),
695
+ tc.repeatEachIndex ?? 0
696
+ ].join("\0");
678
697
  const status = tc.status ?? "";
679
698
  const retries = tc.retries ?? 0;
680
699
  const prev = final.get(key);
@@ -832,6 +851,7 @@ var Uploader = class {
832
851
  title: testCase.title,
833
852
  location: testCase.location,
834
853
  retries: testCase.retries ?? 0,
854
+ repeatEachIndex: testCase.repeatEachIndex ?? 0,
835
855
  suitePath: testCase.suitePath ?? null
836
856
  })
837
857
  );
@@ -6102,6 +6122,82 @@ function parseTestMetadata(annotations) {
6102
6122
  }
6103
6123
  return Object.keys(meta).length > 0 ? meta : null;
6104
6124
  }
6125
+ var MAX_EXTERNAL_ID_CHARS = 120;
6126
+ function normalizeExternalId(value) {
6127
+ if (typeof value !== "string") return void 0;
6128
+ const trimmed = value.trim();
6129
+ if (!trimmed || trimmed.length > MAX_EXTERNAL_ID_CHARS) return void 0;
6130
+ for (let i = 0; i < trimmed.length; i++) {
6131
+ const code = trimmed.charCodeAt(i);
6132
+ if (code < 32 || code === 127) return void 0;
6133
+ }
6134
+ return trimmed;
6135
+ }
6136
+ function parseExternalId(annotations) {
6137
+ if (!Array.isArray(annotations)) return void 0;
6138
+ let id;
6139
+ for (const entry of annotations) {
6140
+ if (!entry || typeof entry !== "object") continue;
6141
+ const { type, description } = entry;
6142
+ if (typeof type !== "string" || type.trim().toLowerCase() !== `${PIWI_ANNOTATION_PREFIX}id`) continue;
6143
+ const value = normalizeExternalId(description);
6144
+ if (value) id = value;
6145
+ }
6146
+ return id;
6147
+ }
6148
+ function compileExternalIdPattern(pattern) {
6149
+ if (typeof pattern === "string") return new RegExp(pattern);
6150
+ return new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
6151
+ }
6152
+ function readExternalIdFromTitle(title, pattern) {
6153
+ const match = pattern.exec(title);
6154
+ if (!match) return null;
6155
+ const externalId = normalizeExternalId(match.length > 1 ? match[1] : match[0]);
6156
+ if (!externalId) return null;
6157
+ const rest = (title.slice(0, match.index) + title.slice(match.index + match[0].length)).trim();
6158
+ return rest ? { externalId, displayTitle: rest } : { externalId };
6159
+ }
6160
+ var MAX_EXTERNAL_ID_DUPLICATES = 50;
6161
+ var MAX_EXTERNAL_ID_DUPLICATE_TESTS = 10;
6162
+ function duplicateTestKey(test) {
6163
+ return `${test.filePath}\0${(test.suitePath ?? []).join("")}\0${test.title}`;
6164
+ }
6165
+ function readDuplicateTest(raw) {
6166
+ if (!raw || typeof raw !== "object") return null;
6167
+ const { filePath: filePath2, suitePath, title, line } = raw;
6168
+ if (typeof filePath2 !== "string" || typeof title !== "string") return null;
6169
+ const test = { filePath: filePath2, title };
6170
+ if (Array.isArray(suitePath) && suitePath.every((s) => typeof s === "string") && suitePath.length > 0)
6171
+ test.suitePath = suitePath;
6172
+ if (typeof line === "number" && Number.isFinite(line)) test.line = line;
6173
+ return test;
6174
+ }
6175
+ function mergeExternalIdDuplicates(...lists) {
6176
+ const byId = /* @__PURE__ */ new Map();
6177
+ for (const list of lists) {
6178
+ if (!Array.isArray(list)) continue;
6179
+ for (const entry of list) {
6180
+ if (!entry || typeof entry !== "object") continue;
6181
+ const externalId = normalizeExternalId(entry.externalId);
6182
+ const tests = entry.tests;
6183
+ if (!externalId || !Array.isArray(tests)) continue;
6184
+ const read = tests.map(readDuplicateTest).filter((test) => test !== null);
6185
+ if (read.length === 0) continue;
6186
+ let known = byId.get(externalId);
6187
+ if (!known) {
6188
+ if (byId.size >= MAX_EXTERNAL_ID_DUPLICATES) continue;
6189
+ known = /* @__PURE__ */ new Map();
6190
+ byId.set(externalId, known);
6191
+ }
6192
+ for (const test of read) {
6193
+ if (known.size >= MAX_EXTERNAL_ID_DUPLICATE_TESTS) break;
6194
+ const key = duplicateTestKey(test);
6195
+ if (!known.has(key)) known.set(key, test);
6196
+ }
6197
+ }
6198
+ }
6199
+ return [...byId].map(([externalId, tests]) => ({ externalId, tests: [...tests.values()] }));
6200
+ }
6105
6201
 
6106
6202
  // src/internal/collect/test-meta.ts
6107
6203
  function collectTestTags(test) {
@@ -6115,6 +6211,84 @@ function collectTestMetadata(annotations) {
6115
6211
  return parseTestMetadata(annotations);
6116
6212
  }
6117
6213
 
6214
+ // src/internal/collect/external-ids.ts
6215
+ function naturalKey(test) {
6216
+ return `${test.filePath}\0${test.suitePath.join("")}\0${test.title}`;
6217
+ }
6218
+ function duplicateTest(test) {
6219
+ return {
6220
+ filePath: test.filePath,
6221
+ ...test.suitePath.length ? { suitePath: test.suitePath } : {},
6222
+ title: test.title,
6223
+ line: test.line
6224
+ };
6225
+ }
6226
+ function planExternalIds(tests, options = {}) {
6227
+ let pattern = null;
6228
+ let patternError;
6229
+ if (options.pattern !== void 0 && options.pattern !== "") {
6230
+ try {
6231
+ pattern = compileExternalIdPattern(options.pattern);
6232
+ } catch (error) {
6233
+ patternError = error instanceof Error ? error.message : String(error);
6234
+ }
6235
+ }
6236
+ const found = /* @__PURE__ */ new Map();
6237
+ const holders = /* @__PURE__ */ new Map();
6238
+ for (const test of tests) {
6239
+ let id = null;
6240
+ const declared = parseExternalId(test.annotations);
6241
+ if (declared) id = { externalId: declared };
6242
+ else if (pattern) {
6243
+ const read = readExternalIdFromTitle(test.title, pattern);
6244
+ if (read) id = options.stripFromTitle ? read : { externalId: read.externalId };
6245
+ }
6246
+ if (!id) continue;
6247
+ found.set(test.testId, id);
6248
+ let keys = holders.get(id.externalId);
6249
+ if (!keys) holders.set(id.externalId, keys = /* @__PURE__ */ new Map());
6250
+ const key = naturalKey(test);
6251
+ if (!keys.has(key)) keys.set(key, test);
6252
+ }
6253
+ const duplicates = [];
6254
+ const duplicateIds = /* @__PURE__ */ new Set();
6255
+ for (const [externalId, keys] of holders) {
6256
+ if (keys.size < 2) continue;
6257
+ duplicateIds.add(externalId);
6258
+ duplicates.push({ externalId, tests: [...keys.values()].map(duplicateTest) });
6259
+ }
6260
+ const byTestId = /* @__PURE__ */ new Map();
6261
+ for (const [testId, id] of found) if (!duplicateIds.has(id.externalId)) byTestId.set(testId, id);
6262
+ return {
6263
+ byTestId,
6264
+ duplicates: mergeExternalIdDuplicates(duplicates),
6265
+ ...patternError ? { patternError } : {}
6266
+ };
6267
+ }
6268
+ function formatExternalIdDuplicates(duplicates, onDuplicate) {
6269
+ if (duplicates.length === 0) return [];
6270
+ const lines = [];
6271
+ for (const { externalId, tests } of duplicates) {
6272
+ lines.push(
6273
+ `External ID ${externalId} is declared by ${tests.length} tests; this run matches them by file, describe path and title:`
6274
+ );
6275
+ for (const test of tests) {
6276
+ const where = test.line != null ? `${test.filePath}:${test.line}` : test.filePath;
6277
+ lines.push(` ${where} ${[...test.suitePath ?? [], test.title].join(" \u203A ")}`);
6278
+ }
6279
+ }
6280
+ lines.push(
6281
+ onDuplicate === "fail" ? "Give each test its own ID: the run fails while two tests share one (externalId.onDuplicate: fail)." : "Give each test its own ID, or set externalId.onDuplicate: 'fail' to fail the run instead."
6282
+ );
6283
+ return lines;
6284
+ }
6285
+ function resolveOnDuplicate(raw) {
6286
+ if (raw === void 0 || raw === null || raw === "") return { onDuplicate: "report" };
6287
+ const value = String(raw).trim().toLowerCase();
6288
+ if (value === "report" || value === "fail") return { onDuplicate: value };
6289
+ return { onDuplicate: "report", invalid: String(raw) };
6290
+ }
6291
+
6118
6292
  // ../core/src/error-text.ts
6119
6293
  function joinErrorMessages(errors) {
6120
6294
  if (!errors || errors.length === 0) return null;
@@ -9375,6 +9549,7 @@ function caseLocateUrl(serverUrl, runId, test) {
9375
9549
  `retry=${test.retry}`
9376
9550
  ];
9377
9551
  if (test.browser) params.push(`browser=${encodeURIComponent(test.browser)}`);
9552
+ if (test.repeatEachIndex) params.push(`repeat=${test.repeatEachIndex}`);
9378
9553
  return `${serverUrl.replace(/\/+$/, "")}/test-runs/${runId}/locate?${params.join("&")}`;
9379
9554
  }
9380
9555
  function formatFailureLine(link) {
@@ -10632,6 +10807,8 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
10632
10807
  this.instanceId = "";
10633
10808
  this.shardInfo = null;
10634
10809
  this.metadata = {};
10810
+ /** Each planned test's external ID and the IDs two tests share, settled in `onBegin`. */
10811
+ this.externalIds = null;
10635
10812
  this.isFullRun = true;
10636
10813
  this.filterDetails = null;
10637
10814
  /** Configured `maxFailures` (0 = unlimited) — disambiguates an interrupted run's unrun reason. */
@@ -10780,6 +10957,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
10780
10957
  this.metadata = flakeRunMetadata(stamp, this.metadata);
10781
10958
  }
10782
10959
  this.plannedTests = suite.allTests();
10960
+ this.planExternalIds();
10783
10961
  this.shardInfo = resolveShardInfo(config);
10784
10962
  if (this.shardInfo) {
10785
10963
  this.logger.info(`Shard ${this.shardInfo.current}/${this.shardInfo.total} detected`);
@@ -10824,6 +11002,44 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
10824
11002
  this.plannedTests.length
10825
11003
  );
10826
11004
  }
11005
+ /**
11006
+ * Settle each planned test's external ID before any test runs, name the IDs two tests share, and record them on
11007
+ * the run so the dashboard lists them too.
11008
+ */
11009
+ planExternalIds() {
11010
+ const options = this.options.externalId ?? {};
11011
+ const plan = planExternalIds(
11012
+ this.plannedTests.map((test) => ({
11013
+ testId: test.id,
11014
+ filePath: testFile(test, this.testPathBase),
11015
+ suitePath: this.metadataCollector.getSuiteInfo(test).suitePath ?? [],
11016
+ title: test.title,
11017
+ line: test.location.line,
11018
+ annotations: test.annotations ?? []
11019
+ })),
11020
+ options
11021
+ );
11022
+ if (plan.patternError) {
11023
+ this.logger.error(
11024
+ `externalId.pattern is not a valid regular expression, so no ID is read from titles: ${plan.patternError}`
11025
+ );
11026
+ }
11027
+ const { onDuplicate, invalid } = resolveOnDuplicate(options.onDuplicate);
11028
+ if (invalid !== void 0) {
11029
+ this.logger.warn(
11030
+ `externalId.onDuplicate is "${invalid}"; expected 'report' or 'fail', so duplicates are reported.`
11031
+ );
11032
+ }
11033
+ for (const line of formatExternalIdDuplicates(plan.duplicates, onDuplicate)) this.logger.warn(line);
11034
+ if (plan.duplicates.length > 0) this.metadata = { ...this.metadata, externalIdDuplicates: plan.duplicates };
11035
+ this.externalIds = plan;
11036
+ }
11037
+ /** The external ID fields of a test's payload: `null` tells the dashboard the test has none to match on. */
11038
+ externalIdFields(test) {
11039
+ if (!this.externalIds) return {};
11040
+ const id = this.externalIds.byTestId.get(test.id);
11041
+ return { externalId: id?.externalId ?? null, displayTitle: id?.displayTitle ?? null };
11042
+ }
10827
11043
  /** Playwright reporter hook: called when an individual test begins */
10828
11044
  onTestBegin(test, result) {
10829
11045
  this.lastTestWatch?.begin(test.id);
@@ -10832,6 +11048,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
10832
11048
  type: "begin",
10833
11049
  title: test.title,
10834
11050
  location: testLocation(test, this.testPathBase),
11051
+ repeatEachIndex: test.repeatEachIndex,
10835
11052
  workerIndex: workerIndexOf(result),
10836
11053
  shardIndex: this.shardInfo?.current ?? null,
10837
11054
  browser: this.metadataCollector.getBrowserConfig(test) || void 0,
@@ -10939,6 +11156,8 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
10939
11156
  // Playwright reports the same line, so synthesize it.
10940
11157
  error: expectedFailureError(result.status, annotations) ?? buildErrorText(result, this.testPathBase),
10941
11158
  retries: result.retry,
11159
+ // Each `--repeat-each` repetition is a test of its own, with its own attempts.
11160
+ repeatEachIndex: test.repeatEachIndex,
10942
11161
  attempts: attempts.map((a) => ({ ...a })),
10943
11162
  workerIndex: workerIndexOf(result),
10944
11163
  shardIndex: this.shardInfo?.current ?? null,
@@ -10951,6 +11170,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
10951
11170
  tags: tags.length ? tags : null,
10952
11171
  locks: locks.length ? locks : null,
10953
11172
  testMeta: collectTestMetadata(annotations),
11173
+ ...this.externalIdFields(test),
10954
11174
  // An annotation-less skip reclassified to `didnotrun` is a serial-group
10955
11175
  // cascade: an earlier test failed and Playwright skipped the rest.
10956
11176
  didNotRunReason: status === "didnotrun" ? "previous-failure" : null,
@@ -11024,6 +11244,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
11024
11244
  file: testFile(test, this.testPathBase),
11025
11245
  retry: result.retry,
11026
11246
  browser: typeof testCase.browser?.projectName === "string" ? testCase.browser.projectName : null,
11247
+ repeatEachIndex: test.repeatEachIndex,
11027
11248
  headline: failureHeadline(testCase.error, testCase.performanceMetrics?.steps)
11028
11249
  });
11029
11250
  }
@@ -11142,6 +11363,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
11142
11363
  timeout: test.timeout ?? null,
11143
11364
  error: null,
11144
11365
  retries: 0,
11366
+ repeatEachIndex: test.repeatEachIndex,
11145
11367
  workerIndex: null,
11146
11368
  shardIndex: this.shardInfo?.current ?? null,
11147
11369
  startedAt: null,
@@ -11153,6 +11375,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
11153
11375
  tags: tags.length ? tags : null,
11154
11376
  locks: locks.length ? locks : null,
11155
11377
  testMeta: collectTestMetadata(declaredAnnotations),
11378
+ ...this.externalIdFields(test),
11156
11379
  didNotRunReason: reason,
11157
11380
  expectedStatus: test.expectedStatus ?? null
11158
11381
  };
@@ -11164,8 +11387,23 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
11164
11387
  }
11165
11388
  }
11166
11389
  }
11167
- /** Playwright reporter hook: called when the full test run finishes */
11390
+ /**
11391
+ * Playwright reporter hook: called when the full test run finishes. Fails the run when two tests share an external
11392
+ * ID and `externalId.onDuplicate` is `'fail'`.
11393
+ */
11168
11394
  async onEnd(result) {
11395
+ const duplicates = this.externalIds?.duplicates.length ?? 0;
11396
+ const failsOnDuplicates = duplicates > 0 && resolveOnDuplicate(this.options.externalId?.onDuplicate).onDuplicate === "fail";
11397
+ const overridden = failsOnDuplicates && result?.status === "passed";
11398
+ await this.finishRun(overridden ? { ...result, status: "failed" } : result);
11399
+ if (failsOnDuplicates) {
11400
+ this.logger.error(
11401
+ `Failing the run: ${duplicates} external ID${duplicates === 1 ? " is" : "s are"} declared by more than one test.`
11402
+ );
11403
+ return { status: "failed" };
11404
+ }
11405
+ }
11406
+ async finishRun(result) {
11169
11407
  if (this.listMode || !this.enabled) return;
11170
11408
  if (this.startTime === null) {
11171
11409
  this.logger.debug("The run stopped before any test began \u2014 nothing to report.");
@@ -11650,10 +11888,11 @@ function installPickerOverlay(arg) {
11650
11888
  }
11651
11889
  const Z = 2147483600;
11652
11890
  const testIdAttr = arg.testIdAttribute || "data-testid";
11891
+ const enterReaches = arg.transport === "global";
11653
11892
  const words = {
11654
11893
  banner: "Piwi inspector \u2014 click any element to generate locators for it",
11655
- keys: "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
11656
- keysHovering: "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
11894
+ keys: enterReaches ? "Enter picks the focused element \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip" : "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
11895
+ keysHovering: enterReaches ? "click or Enter to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip" : "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
11657
11896
  analyzing: "Analyzing element\u2026"
11658
11897
  };
11659
11898
  for (const key of Object.keys(words)) {
@@ -11748,6 +11987,16 @@ function installPickerOverlay(arg) {
11748
11987
  const cls = (el.getAttribute && el.getAttribute("class") || "").split(/\s+/).find((c) => c.length > 1);
11749
11988
  return cls ? `locator(${quoteJs(`.${cls}`)})` : tag;
11750
11989
  };
11990
+ const parentOf = (node) => {
11991
+ if (node.parentElement) return node.parentElement;
11992
+ const root = node.getRootNode ? node.getRootNode() : null;
11993
+ return root && root !== doc && root.host ? root.host : null;
11994
+ };
11995
+ const targetOf = (e) => {
11996
+ const path32 = typeof e.composedPath === "function" ? e.composedPath() : null;
11997
+ const first = path32 && path32.length ? path32[0] : null;
11998
+ return first && first.nodeType === 1 ? first : e.target;
11999
+ };
11751
12000
  const buildChain = (raw) => {
11752
12001
  const chain2 = [];
11753
12002
  let node = raw;
@@ -11755,7 +12004,7 @@ function installPickerOverlay(arg) {
11755
12004
  const tag = (node.tagName || "").toLowerCase();
11756
12005
  if (tag === "body" || tag === "html") break;
11757
12006
  chain2.push(node);
11758
- node = node.parentElement;
12007
+ node = parentOf(node);
11759
12008
  }
11760
12009
  return chain2.length ? chain2 : [raw];
11761
12010
  };
@@ -11846,10 +12095,14 @@ function installPickerOverlay(arg) {
11846
12095
  }
11847
12096
  };
11848
12097
  const onMove = (e) => {
11849
- const raw = e.target;
12098
+ const raw = targetOf(e);
11850
12099
  if (!raw || isOwn(raw)) {
12100
+ chain = [];
12101
+ idx = 0;
12102
+ lastRaw = null;
11851
12103
  highlight.style.display = "none";
11852
12104
  label.style.display = "none";
12105
+ foot.textContent = words.keys;
11853
12106
  return;
11854
12107
  }
11855
12108
  if (raw !== lastRaw) {
@@ -11928,17 +12181,30 @@ function installPickerOverlay(arg) {
11928
12181
  const onClick = (e) => {
11929
12182
  stop(e);
11930
12183
  const el = current();
11931
- if (!el || isOwn(e.target)) return;
12184
+ if (!el || isOwn(targetOf(e))) return;
11932
12185
  reportPicked(el);
11933
12186
  };
12187
+ const focused = () => {
12188
+ let el = doc.activeElement;
12189
+ while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
12190
+ const tag = (el && el.tagName || "").toLowerCase();
12191
+ return !el || tag === "body" || tag === "html" || isOwn(el) ? null : el;
12192
+ };
11934
12193
  const onKey = (e) => {
11935
12194
  if (e.key === "Escape") {
11936
12195
  stop(e);
11937
12196
  reportSkipped();
11938
12197
  return;
11939
12198
  }
12199
+ if (e.key === "Enter" && !e.isComposing) {
12200
+ stop(e);
12201
+ const el = current() || focused();
12202
+ if (el) reportPicked(el);
12203
+ return;
12204
+ }
11940
12205
  if (e.key === "ArrowUp" || e.key === "ArrowDown") {
11941
12206
  stop(e);
12207
+ if (!chain.length) return;
11942
12208
  if (e.key === "ArrowUp") idx = Math.min(idx + 1, chain.length - 1);
11943
12209
  else idx = Math.max(idx - 1, 0);
11944
12210
  refresh();
@@ -12012,12 +12278,43 @@ function showAnchorPicker(arg) {
12012
12278
  const al = n.getAttribute && n.getAttribute("aria-level");
12013
12279
  return al && /^\d+$/.test(al) ? Number(al) : null;
12014
12280
  };
12281
+ const parentOf = (n) => {
12282
+ if (n.parentElement) return n.parentElement;
12283
+ const root = n.getRootNode ? n.getRootNode() : null;
12284
+ return root && root !== doc && root.host ? root.host : null;
12285
+ };
12286
+ const shadowRoots = [];
12287
+ const collectRoots = (root) => {
12288
+ const all = root.querySelectorAll("*");
12289
+ for (let i = 0; i < all.length; i++) {
12290
+ const sr = all[i].shadowRoot;
12291
+ if (sr) {
12292
+ shadowRoots.push(sr);
12293
+ collectRoots(sr);
12294
+ }
12295
+ }
12296
+ };
12297
+ try {
12298
+ collectRoots(doc);
12299
+ } catch {
12300
+ shadowRoots.length = 0;
12301
+ }
12302
+ const within = (node2, scope) => {
12303
+ if (scope === doc) return true;
12304
+ for (let n = node2; n; n = parentOf(n)) if (n === scope) return true;
12305
+ return false;
12306
+ };
12307
+ const queryAll = (scope, sel) => {
12308
+ const found = Array.from(scope.querySelectorAll(sel));
12309
+ for (const sr of shadowRoots) if (within(sr.host, scope)) found.push(...Array.from(sr.querySelectorAll(sel)));
12310
+ return found;
12311
+ };
12015
12312
  const leafCountable = !!leafTestId || roleOf2(el) === leafRole;
12016
12313
  const leafMatches = (scope) => {
12017
12314
  try {
12018
- if (leafTestId) return scope.querySelectorAll(`[${testIdAttr}=${JSON.stringify(leafTestId)}]`).length;
12315
+ if (leafTestId) return queryAll(scope, `[${testIdAttr}=${JSON.stringify(leafTestId)}]`).length;
12019
12316
  if (!leafCountable) return -1;
12020
- const nodes = scope.querySelectorAll(roleSources);
12317
+ const nodes = queryAll(scope, roleSources);
12021
12318
  if (nodes.length > 2e3) return -1;
12022
12319
  let matched = 0;
12023
12320
  for (let i = 0; i < nodes.length; i++) {
@@ -12033,20 +12330,20 @@ function showAnchorPicker(arg) {
12033
12330
  };
12034
12331
  let roleNodes = [];
12035
12332
  try {
12036
- const all = doc.querySelectorAll(roleSources);
12037
- if (all.length <= 4e3) roleNodes = Array.from(all);
12333
+ const all = queryAll(doc, roleSources);
12334
+ if (all.length <= 4e3) roleNodes = all;
12038
12335
  } catch {
12039
12336
  roleNodes = [];
12040
12337
  }
12041
12338
  const count = (sel) => {
12042
12339
  try {
12043
- return doc.querySelectorAll(sel).length;
12340
+ return queryAll(doc, sel).length;
12044
12341
  } catch {
12045
12342
  return void 0;
12046
12343
  }
12047
12344
  };
12048
12345
  const rows = [];
12049
- let node = el.parentElement;
12346
+ let node = parentOf(el);
12050
12347
  let depth = 0;
12051
12348
  while (node && depth < 12) {
12052
12349
  depth++;
@@ -12083,7 +12380,7 @@ function showAnchorPicker(arg) {
12083
12380
  hookLabel,
12084
12381
  selectable: !!(testId || id || role && (ariaLabel || info.roleCount === 1))
12085
12382
  });
12086
- node = node.parentElement;
12383
+ node = parentOf(node);
12087
12384
  }
12088
12385
  if (rows.length === 0) {
12089
12386
  g.__piwiAnchorState = "skipped";
@@ -12112,9 +12409,9 @@ function showAnchorPicker(arg) {
12112
12409
  footer.style.cssText = "margin:10px 0;font-weight:600;";
12113
12410
  const segMatches = (scope, info) => {
12114
12411
  try {
12115
- if (info.testId) return Array.from(scope.querySelectorAll(`[${testIdAttr}=${JSON.stringify(info.testId)}]`));
12116
- if (info.id) return Array.from(scope.querySelectorAll(`#${doc.defaultView.CSS.escape(info.id)}`));
12117
- const nodes = Array.from(scope.querySelectorAll(roleSources));
12412
+ if (info.testId) return queryAll(scope, `[${testIdAttr}=${JSON.stringify(info.testId)}]`);
12413
+ if (info.id) return queryAll(scope, `#${doc.defaultView.CSS.escape(info.id)}`);
12414
+ const nodes = queryAll(scope, roleSources);
12118
12415
  if (nodes.length > 2e3) return [];
12119
12416
  return nodes.filter(
12120
12417
  (n) => roleOf2(n) === info.role && (!info.ariaLabel || n.getAttribute && n.getAttribute("aria-label") === info.ariaLabel)
@@ -412,10 +412,11 @@ function installPickerOverlay(arg) {
412
412
  }
413
413
  const Z = 2147483600;
414
414
  const testIdAttr = arg.testIdAttribute || "data-testid";
415
+ const enterReaches = arg.transport === "global";
415
416
  const words = {
416
417
  banner: "Piwi inspector \u2014 click any element to generate locators for it",
417
- keys: "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
418
- keysHovering: "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
418
+ keys: enterReaches ? "Enter picks the focused element \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip" : "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
419
+ keysHovering: enterReaches ? "click or Enter to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip" : "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
419
420
  analyzing: "Analyzing element\u2026"
420
421
  };
421
422
  for (const key of Object.keys(words)) {
@@ -510,6 +511,16 @@ function installPickerOverlay(arg) {
510
511
  const cls = (el.getAttribute && el.getAttribute("class") || "").split(/\s+/).find((c) => c.length > 1);
511
512
  return cls ? `locator(${quoteJs(`.${cls}`)})` : tag;
512
513
  };
514
+ const parentOf = (node) => {
515
+ if (node.parentElement) return node.parentElement;
516
+ const root = node.getRootNode ? node.getRootNode() : null;
517
+ return root && root !== doc && root.host ? root.host : null;
518
+ };
519
+ const targetOf = (e) => {
520
+ const path13 = typeof e.composedPath === "function" ? e.composedPath() : null;
521
+ const first = path13 && path13.length ? path13[0] : null;
522
+ return first && first.nodeType === 1 ? first : e.target;
523
+ };
513
524
  const buildChain = (raw) => {
514
525
  const chain2 = [];
515
526
  let node = raw;
@@ -517,7 +528,7 @@ function installPickerOverlay(arg) {
517
528
  const tag = (node.tagName || "").toLowerCase();
518
529
  if (tag === "body" || tag === "html") break;
519
530
  chain2.push(node);
520
- node = node.parentElement;
531
+ node = parentOf(node);
521
532
  }
522
533
  return chain2.length ? chain2 : [raw];
523
534
  };
@@ -608,10 +619,14 @@ function installPickerOverlay(arg) {
608
619
  }
609
620
  };
610
621
  const onMove = (e) => {
611
- const raw = e.target;
622
+ const raw = targetOf(e);
612
623
  if (!raw || isOwn(raw)) {
624
+ chain = [];
625
+ idx = 0;
626
+ lastRaw = null;
613
627
  highlight.style.display = "none";
614
628
  label.style.display = "none";
629
+ foot.textContent = words.keys;
615
630
  return;
616
631
  }
617
632
  if (raw !== lastRaw) {
@@ -690,17 +705,30 @@ function installPickerOverlay(arg) {
690
705
  const onClick = (e) => {
691
706
  stop(e);
692
707
  const el = current();
693
- if (!el || isOwn(e.target)) return;
708
+ if (!el || isOwn(targetOf(e))) return;
694
709
  reportPicked(el);
695
710
  };
711
+ const focused = () => {
712
+ let el = doc.activeElement;
713
+ while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
714
+ const tag = (el && el.tagName || "").toLowerCase();
715
+ return !el || tag === "body" || tag === "html" || isOwn(el) ? null : el;
716
+ };
696
717
  const onKey = (e) => {
697
718
  if (e.key === "Escape") {
698
719
  stop(e);
699
720
  reportSkipped();
700
721
  return;
701
722
  }
723
+ if (e.key === "Enter" && !e.isComposing) {
724
+ stop(e);
725
+ const el = current() || focused();
726
+ if (el) reportPicked(el);
727
+ return;
728
+ }
702
729
  if (e.key === "ArrowUp" || e.key === "ArrowDown") {
703
730
  stop(e);
731
+ if (!chain.length) return;
704
732
  if (e.key === "ArrowUp") idx = Math.min(idx + 1, chain.length - 1);
705
733
  else idx = Math.max(idx - 1, 0);
706
734
  refresh();
@@ -774,12 +802,43 @@ function showAnchorPicker(arg) {
774
802
  const al = n.getAttribute && n.getAttribute("aria-level");
775
803
  return al && /^\d+$/.test(al) ? Number(al) : null;
776
804
  };
805
+ const parentOf = (n) => {
806
+ if (n.parentElement) return n.parentElement;
807
+ const root = n.getRootNode ? n.getRootNode() : null;
808
+ return root && root !== doc && root.host ? root.host : null;
809
+ };
810
+ const shadowRoots = [];
811
+ const collectRoots = (root) => {
812
+ const all = root.querySelectorAll("*");
813
+ for (let i = 0; i < all.length; i++) {
814
+ const sr = all[i].shadowRoot;
815
+ if (sr) {
816
+ shadowRoots.push(sr);
817
+ collectRoots(sr);
818
+ }
819
+ }
820
+ };
821
+ try {
822
+ collectRoots(doc);
823
+ } catch {
824
+ shadowRoots.length = 0;
825
+ }
826
+ const within = (node2, scope) => {
827
+ if (scope === doc) return true;
828
+ for (let n = node2; n; n = parentOf(n)) if (n === scope) return true;
829
+ return false;
830
+ };
831
+ const queryAll = (scope, sel) => {
832
+ const found = Array.from(scope.querySelectorAll(sel));
833
+ for (const sr of shadowRoots) if (within(sr.host, scope)) found.push(...Array.from(sr.querySelectorAll(sel)));
834
+ return found;
835
+ };
777
836
  const leafCountable = !!leafTestId || roleOf2(el) === leafRole;
778
837
  const leafMatches = (scope) => {
779
838
  try {
780
- if (leafTestId) return scope.querySelectorAll(`[${testIdAttr}=${JSON.stringify(leafTestId)}]`).length;
839
+ if (leafTestId) return queryAll(scope, `[${testIdAttr}=${JSON.stringify(leafTestId)}]`).length;
781
840
  if (!leafCountable) return -1;
782
- const nodes = scope.querySelectorAll(roleSources);
841
+ const nodes = queryAll(scope, roleSources);
783
842
  if (nodes.length > 2e3) return -1;
784
843
  let matched = 0;
785
844
  for (let i = 0; i < nodes.length; i++) {
@@ -795,20 +854,20 @@ function showAnchorPicker(arg) {
795
854
  };
796
855
  let roleNodes = [];
797
856
  try {
798
- const all = doc.querySelectorAll(roleSources);
799
- if (all.length <= 4e3) roleNodes = Array.from(all);
857
+ const all = queryAll(doc, roleSources);
858
+ if (all.length <= 4e3) roleNodes = all;
800
859
  } catch {
801
860
  roleNodes = [];
802
861
  }
803
862
  const count = (sel) => {
804
863
  try {
805
- return doc.querySelectorAll(sel).length;
864
+ return queryAll(doc, sel).length;
806
865
  } catch {
807
866
  return void 0;
808
867
  }
809
868
  };
810
869
  const rows = [];
811
- let node = el.parentElement;
870
+ let node = parentOf(el);
812
871
  let depth = 0;
813
872
  while (node && depth < 12) {
814
873
  depth++;
@@ -845,7 +904,7 @@ function showAnchorPicker(arg) {
845
904
  hookLabel,
846
905
  selectable: !!(testId || id || role && (ariaLabel || info.roleCount === 1))
847
906
  });
848
- node = node.parentElement;
907
+ node = parentOf(node);
849
908
  }
850
909
  if (rows.length === 0) {
851
910
  g.__piwiAnchorState = "skipped";
@@ -874,9 +933,9 @@ function showAnchorPicker(arg) {
874
933
  footer.style.cssText = "margin:10px 0;font-weight:600;";
875
934
  const segMatches = (scope, info) => {
876
935
  try {
877
- if (info.testId) return Array.from(scope.querySelectorAll(`[${testIdAttr}=${JSON.stringify(info.testId)}]`));
878
- if (info.id) return Array.from(scope.querySelectorAll(`#${doc.defaultView.CSS.escape(info.id)}`));
879
- const nodes = Array.from(scope.querySelectorAll(roleSources));
936
+ if (info.testId) return queryAll(scope, `[${testIdAttr}=${JSON.stringify(info.testId)}]`);
937
+ if (info.id) return queryAll(scope, `#${doc.defaultView.CSS.escape(info.id)}`);
938
+ const nodes = queryAll(scope, roleSources);
880
939
  if (nodes.length > 2e3) return [];
881
940
  return nodes.filter(
882
941
  (n) => roleOf2(n) === info.role && (!info.ariaLabel || n.getAttribute && n.getAttribute("aria-label") === info.ariaLabel)
@@ -2648,6 +2707,8 @@ var PIWI_ENV_KEYS = {
2648
2707
  codeReachRoots: "PIWI_CODE_REACH_ROOTS",
2649
2708
  coverageBrowser: "PIWI_COVERAGE_BROWSER",
2650
2709
  coverageServer: "PIWI_COVERAGE_SERVER",
2710
+ externalIdPattern: "PIWI_EXTERNAL_ID_PATTERN",
2711
+ externalIdOnDuplicate: "PIWI_EXTERNAL_ID_ON_DUPLICATE",
2651
2712
  captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
2652
2713
  captureResources: "PIWI_CAPTURE_RESOURCES",
2653
2714
  leakCheck: "PIWI_LEAK_CHECK",
@@ -379,10 +379,11 @@ function installPickerOverlay(arg) {
379
379
  }
380
380
  const Z = 2147483600;
381
381
  const testIdAttr = arg.testIdAttribute || "data-testid";
382
+ const enterReaches = arg.transport === "global";
382
383
  const words = {
383
384
  banner: "Piwi inspector \u2014 click any element to generate locators for it",
384
- keys: "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
385
- keysHovering: "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
385
+ keys: enterReaches ? "Enter picks the focused element \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip" : "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
386
+ keysHovering: enterReaches ? "click or Enter to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip" : "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
386
387
  analyzing: "Analyzing element\u2026"
387
388
  };
388
389
  for (const key of Object.keys(words)) {
@@ -477,6 +478,16 @@ function installPickerOverlay(arg) {
477
478
  const cls = (el.getAttribute && el.getAttribute("class") || "").split(/\s+/).find((c) => c.length > 1);
478
479
  return cls ? `locator(${quoteJs(`.${cls}`)})` : tag;
479
480
  };
481
+ const parentOf = (node) => {
482
+ if (node.parentElement) return node.parentElement;
483
+ const root = node.getRootNode ? node.getRootNode() : null;
484
+ return root && root !== doc && root.host ? root.host : null;
485
+ };
486
+ const targetOf = (e) => {
487
+ const path3 = typeof e.composedPath === "function" ? e.composedPath() : null;
488
+ const first = path3 && path3.length ? path3[0] : null;
489
+ return first && first.nodeType === 1 ? first : e.target;
490
+ };
480
491
  const buildChain = (raw) => {
481
492
  const chain2 = [];
482
493
  let node = raw;
@@ -484,7 +495,7 @@ function installPickerOverlay(arg) {
484
495
  const tag = (node.tagName || "").toLowerCase();
485
496
  if (tag === "body" || tag === "html") break;
486
497
  chain2.push(node);
487
- node = node.parentElement;
498
+ node = parentOf(node);
488
499
  }
489
500
  return chain2.length ? chain2 : [raw];
490
501
  };
@@ -575,10 +586,14 @@ function installPickerOverlay(arg) {
575
586
  }
576
587
  };
577
588
  const onMove = (e) => {
578
- const raw = e.target;
589
+ const raw = targetOf(e);
579
590
  if (!raw || isOwn(raw)) {
591
+ chain = [];
592
+ idx = 0;
593
+ lastRaw = null;
580
594
  highlight.style.display = "none";
581
595
  label.style.display = "none";
596
+ foot.textContent = words.keys;
582
597
  return;
583
598
  }
584
599
  if (raw !== lastRaw) {
@@ -657,17 +672,30 @@ function installPickerOverlay(arg) {
657
672
  const onClick = (e) => {
658
673
  stop(e);
659
674
  const el = current();
660
- if (!el || isOwn(e.target)) return;
675
+ if (!el || isOwn(targetOf(e))) return;
661
676
  reportPicked(el);
662
677
  };
678
+ const focused = () => {
679
+ let el = doc.activeElement;
680
+ while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
681
+ const tag = (el && el.tagName || "").toLowerCase();
682
+ return !el || tag === "body" || tag === "html" || isOwn(el) ? null : el;
683
+ };
663
684
  const onKey = (e) => {
664
685
  if (e.key === "Escape") {
665
686
  stop(e);
666
687
  reportSkipped();
667
688
  return;
668
689
  }
690
+ if (e.key === "Enter" && !e.isComposing) {
691
+ stop(e);
692
+ const el = current() || focused();
693
+ if (el) reportPicked(el);
694
+ return;
695
+ }
669
696
  if (e.key === "ArrowUp" || e.key === "ArrowDown") {
670
697
  stop(e);
698
+ if (!chain.length) return;
671
699
  if (e.key === "ArrowUp") idx = Math.min(idx + 1, chain.length - 1);
672
700
  else idx = Math.max(idx - 1, 0);
673
701
  refresh();
@@ -741,12 +769,43 @@ function showAnchorPicker(arg) {
741
769
  const al = n.getAttribute && n.getAttribute("aria-level");
742
770
  return al && /^\d+$/.test(al) ? Number(al) : null;
743
771
  };
772
+ const parentOf = (n) => {
773
+ if (n.parentElement) return n.parentElement;
774
+ const root = n.getRootNode ? n.getRootNode() : null;
775
+ return root && root !== doc && root.host ? root.host : null;
776
+ };
777
+ const shadowRoots = [];
778
+ const collectRoots = (root) => {
779
+ const all = root.querySelectorAll("*");
780
+ for (let i = 0; i < all.length; i++) {
781
+ const sr = all[i].shadowRoot;
782
+ if (sr) {
783
+ shadowRoots.push(sr);
784
+ collectRoots(sr);
785
+ }
786
+ }
787
+ };
788
+ try {
789
+ collectRoots(doc);
790
+ } catch {
791
+ shadowRoots.length = 0;
792
+ }
793
+ const within = (node2, scope) => {
794
+ if (scope === doc) return true;
795
+ for (let n = node2; n; n = parentOf(n)) if (n === scope) return true;
796
+ return false;
797
+ };
798
+ const queryAll = (scope, sel) => {
799
+ const found = Array.from(scope.querySelectorAll(sel));
800
+ for (const sr of shadowRoots) if (within(sr.host, scope)) found.push(...Array.from(sr.querySelectorAll(sel)));
801
+ return found;
802
+ };
744
803
  const leafCountable = !!leafTestId || roleOf(el) === leafRole;
745
804
  const leafMatches = (scope) => {
746
805
  try {
747
- if (leafTestId) return scope.querySelectorAll(`[${testIdAttr}=${JSON.stringify(leafTestId)}]`).length;
806
+ if (leafTestId) return queryAll(scope, `[${testIdAttr}=${JSON.stringify(leafTestId)}]`).length;
748
807
  if (!leafCountable) return -1;
749
- const nodes = scope.querySelectorAll(roleSources);
808
+ const nodes = queryAll(scope, roleSources);
750
809
  if (nodes.length > 2e3) return -1;
751
810
  let matched = 0;
752
811
  for (let i = 0; i < nodes.length; i++) {
@@ -762,20 +821,20 @@ function showAnchorPicker(arg) {
762
821
  };
763
822
  let roleNodes = [];
764
823
  try {
765
- const all = doc.querySelectorAll(roleSources);
766
- if (all.length <= 4e3) roleNodes = Array.from(all);
824
+ const all = queryAll(doc, roleSources);
825
+ if (all.length <= 4e3) roleNodes = all;
767
826
  } catch {
768
827
  roleNodes = [];
769
828
  }
770
829
  const count = (sel) => {
771
830
  try {
772
- return doc.querySelectorAll(sel).length;
831
+ return queryAll(doc, sel).length;
773
832
  } catch {
774
833
  return void 0;
775
834
  }
776
835
  };
777
836
  const rows = [];
778
- let node = el.parentElement;
837
+ let node = parentOf(el);
779
838
  let depth = 0;
780
839
  while (node && depth < 12) {
781
840
  depth++;
@@ -812,7 +871,7 @@ function showAnchorPicker(arg) {
812
871
  hookLabel,
813
872
  selectable: !!(testId || id || role && (ariaLabel || info.roleCount === 1))
814
873
  });
815
- node = node.parentElement;
874
+ node = parentOf(node);
816
875
  }
817
876
  if (rows.length === 0) {
818
877
  g.__piwiAnchorState = "skipped";
@@ -841,9 +900,9 @@ function showAnchorPicker(arg) {
841
900
  footer.style.cssText = "margin:10px 0;font-weight:600;";
842
901
  const segMatches = (scope, info) => {
843
902
  try {
844
- if (info.testId) return Array.from(scope.querySelectorAll(`[${testIdAttr}=${JSON.stringify(info.testId)}]`));
845
- if (info.id) return Array.from(scope.querySelectorAll(`#${doc.defaultView.CSS.escape(info.id)}`));
846
- const nodes = Array.from(scope.querySelectorAll(roleSources));
903
+ if (info.testId) return queryAll(scope, `[${testIdAttr}=${JSON.stringify(info.testId)}]`);
904
+ if (info.id) return queryAll(scope, `#${doc.defaultView.CSS.escape(info.id)}`);
905
+ const nodes = queryAll(scope, roleSources);
847
906
  if (nodes.length > 2e3) return [];
848
907
  return nodes.filter(
849
908
  (n) => roleOf(n) === info.role && (!info.ariaLabel || n.getAttribute && n.getAttribute("aria-label") === info.ariaLabel)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piwitests/reporter",
3
- "version": "0.57.0",
3
+ "version": "0.59.0",
4
4
  "description": "Playwright reporter that streams results, traces and HTML reports to a Piwi Dashboard instance",
5
5
  "url": "https://github.com/PiwiTests/platform",
6
6
  "homepage": "https://piwitests.dev",
@@ -21,8 +21,9 @@ view, or the reporter CLI with `PIWI_DASHBOARD_URL` / `PIWI_API_KEY` /
21
21
  ## Steps
22
22
 
23
23
  1. **Find the gaps.** On a change, call `get_change_coverage` for the current
24
- branch first — the uncovered changed files are the gaps that matter now.
25
- Otherwise call `list_scenario_gaps` for the project. Filter to what you are
24
+ branch first — the files whose `reach` is `unreached` (the uncovered
25
+ changes) and the `lines.notRun` of the files it did reach are what the
26
+ change left untested. Otherwise call `list_scenario_gaps` for the project. Filter to what you are
26
27
  working on with `class`, `feature`, `minScore` or `pr`. Read the classes:
27
28
  - **blind-spot** — no trusted test reaches it (a route, a page, a control, a
28
29
  new error path).