@piwitests/reporter 0.56.0 → 0.58.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",
@@ -1137,13 +1139,18 @@ var ASSERTION_MATCHERS = [
1137
1139
  "toBeHidden",
1138
1140
  "toBeEnabled",
1139
1141
  "toBeDisabled",
1140
- "toHaveURL"
1142
+ "toHaveURL",
1143
+ "toContainText",
1144
+ "toHaveCount",
1145
+ "toBeChecked"
1141
1146
  ];
1142
1147
  var VALUE_MATCHERS = /* @__PURE__ */ new Set([
1143
1148
  "toHaveText",
1144
1149
  "toHaveValue",
1145
1150
  "toHaveAccessibleName",
1146
- "toHaveURL"
1151
+ "toHaveURL",
1152
+ "toContainText",
1153
+ "toHaveCount"
1147
1154
  ]);
1148
1155
 
1149
1156
  // ../core/src/function-match.ts
@@ -1833,7 +1840,7 @@ function renderAssertStep(step, index, ctx) {
1833
1840
  });
1834
1841
  return [` // Step ${index + 1}: ${matcher} without an expected value.`];
1835
1842
  }
1836
- arg = quote(matcher === "toHaveURL" ? urlForCode(assertion.expected, ctx) : assertion.expected);
1843
+ arg = matcher === "toHaveCount" ? String(Number.parseInt(assertion.expected, 10) || 0) : quote(matcher === "toHaveURL" ? urlForCode(assertion.expected, ctx) : assertion.expected);
1837
1844
  }
1838
1845
  const subject = matcher === "toHaveURL" ? ctx.page : locatorForStep(step, index, ctx);
1839
1846
  let line = ` await expect(${subject})${not}.${matcher}(${arg});`;
@@ -1846,10 +1853,25 @@ function renderAssertStep(step, index, ctx) {
1846
1853
  function fileNames(value) {
1847
1854
  return (value ?? "").split("\n").map((name) => name.trim().replace(/^.*[\\/]/, "")).filter(Boolean);
1848
1855
  }
1856
+ function renderManualStep(step, index, ctx) {
1857
+ const description = commentText(step.manual?.description ?? "") || "a step to do by hand";
1858
+ ctx.warnings.push({
1859
+ step: index,
1860
+ code: "manual-step",
1861
+ detail: description,
1862
+ message: `Step ${index + 1} cannot be written as code: ${description}`
1863
+ });
1864
+ const lines = [` // Manual step: ${description}`];
1865
+ const call = step.manual?.call ? commentText(step.manual.call) : "";
1866
+ const location = step.manual?.location ? commentText(step.manual.location) : "";
1867
+ if (call || location) lines.push(` // ${[call, location ? `at ${location}` : ""].filter(Boolean).join(" ")}`);
1868
+ return lines;
1869
+ }
1849
1870
  function renderRawStep(step, index, ctx) {
1850
1871
  const { page } = ctx;
1851
1872
  if (step.action === "goto") return [` await ${page}.goto(${quote(urlForCode(step.value ?? step.pageUrl, ctx))});`];
1852
1873
  if (step.action === "assert" || step.action === "assertVisible") return renderAssertStep(step, index, ctx);
1874
+ if (step.action === "manual") return renderManualStep(step, index, ctx);
1853
1875
  if (step.action === "press" && !step.target) {
1854
1876
  return [` await ${page}.keyboard.press(${quote(step.value ?? "Enter")});`];
1855
1877
  }
@@ -2060,6 +2082,7 @@ function expectFailLine(options) {
2060
2082
  return reason ? ` test.fail(); // ${reason}` : " test.fail();";
2061
2083
  }
2062
2084
  function samePage(a, b) {
2085
+ if (!a || !b) return true;
2063
2086
  const ka = pageKey(a);
2064
2087
  const kb = pageKey(b);
2065
2088
  return ka === null || kb === null || ka === kb;
@@ -2111,7 +2134,7 @@ function renderSpec(session, options = {}) {
2111
2134
  const pageStarts = [];
2112
2135
  const enterPageOf = (index) => {
2113
2136
  const url = steps[index]?.pageUrl;
2114
- if (options.testSteps !== "page" || url == null) return;
2137
+ if (options.testSteps !== "page" || !url) return;
2115
2138
  const last = pageStarts[pageStarts.length - 1];
2116
2139
  if (!last || !samePage(last.url, url)) pageStarts.push({ line: bodyLines.length, url });
2117
2140
  };
@@ -2123,7 +2146,7 @@ function renderSpec(session, options = {}) {
2123
2146
  const pattern = pageUrlPattern(next.pageUrl);
2124
2147
  enterPageOf(last + 1);
2125
2148
  if (pattern) bodyLines.push(` await expect(${page}).toHaveURL(${pattern});`);
2126
- const acts = next.action !== "assert" && next.action !== "assertVisible";
2149
+ const acts = next.action !== "assert" && next.action !== "assertVisible" && next.action !== "manual";
2127
2150
  const chosen = acts && next.target ? chooseLocator(next.target, options) : null;
2128
2151
  if (chosen) bodyLines.push(` await expect(${page}.${chosen.text}).toHaveCount(1);`);
2129
2152
  };
@@ -2225,7 +2248,11 @@ function renderSpec(session, options = {}) {
2225
2248
  }
2226
2249
 
2227
2250
  // ../core/src/steps.ts
2228
- var STEPS_VERSION = 1;
2251
+ var STEPS_VERSION = 2;
2252
+ var VERSION_2_MATCHERS = /* @__PURE__ */ new Set(["toContainText", "toHaveCount", "toBeChecked"]);
2253
+ function stepsVersionFor(steps) {
2254
+ return steps.some((s) => s.action === "manual" || !!s.assertion && VERSION_2_MATCHERS.has(s.assertion.matcher)) ? 2 : 1;
2255
+ }
2229
2256
  var STEPS_LIMITS = {
2230
2257
  steps: 200,
2231
2258
  alternatives: 10,
@@ -2252,7 +2279,8 @@ var ACTIONS = /* @__PURE__ */ new Set([
2252
2279
  "setInputFiles",
2253
2280
  "dragTo",
2254
2281
  "assertVisible",
2255
- "assert"
2282
+ "assert",
2283
+ "manual"
2256
2284
  ]);
2257
2285
  function originOf2(url) {
2258
2286
  try {
@@ -2277,7 +2305,7 @@ function toAbsolute(url, origin) {
2277
2305
  function toStepsDocument(session, meta = {}) {
2278
2306
  const origin = originOf2(session.startUrl) ?? originOf2(session.steps[0]?.pageUrl ?? "");
2279
2307
  return {
2280
- v: STEPS_VERSION,
2308
+ v: stepsVersionFor(session.steps),
2281
2309
  title: meta.title ?? null,
2282
2310
  origin,
2283
2311
  recordedAt: session.startedAt,
@@ -2386,6 +2414,9 @@ function checkAssertion(c, v, path24) {
2386
2414
  return void 0;
2387
2415
  }
2388
2416
  const expected = c.text(v.expected, `${path24}.expected`, STEPS_LIMITS.valueLength, !VALUE_MATCHERS.has(matcher));
2417
+ if (matcher === "toHaveCount" && expected != null && !/^\d{1,6}$/.test(expected)) {
2418
+ c.fail(`${path24}.expected`, "must be a whole number of elements on a toHaveCount assertion");
2419
+ }
2389
2420
  return {
2390
2421
  matcher,
2391
2422
  expected: VALUE_MATCHERS.has(matcher) ? expected : null,
@@ -2394,6 +2425,19 @@ function checkAssertion(c, v, path24) {
2394
2425
  note: c.text(v.note, `${path24}.note`, STEPS_LIMITS.valueLength, true)
2395
2426
  };
2396
2427
  }
2428
+ function checkManual(c, v, path24) {
2429
+ if (!isObject(v)) {
2430
+ c.fail(path24, "is required on a manual step");
2431
+ return void 0;
2432
+ }
2433
+ const description = c.text(v.description, `${path24}.description`, STEPS_LIMITS.valueLength, false);
2434
+ if (description != null && !description.trim()) c.fail(`${path24}.description`, "must not be empty");
2435
+ return {
2436
+ description: description ?? "",
2437
+ location: c.text(v.location, `${path24}.location`, STEPS_LIMITS.textLength, true),
2438
+ call: c.text(v.call, `${path24}.call`, STEPS_LIMITS.textLength, true)
2439
+ };
2440
+ }
2397
2441
  function checkViewports(c, v, stepCount) {
2398
2442
  if (v == null) return [];
2399
2443
  if (!Array.isArray(v)) {
@@ -2466,6 +2510,9 @@ function checkStep(c, v, path24) {
2466
2510
  step.assertion = { matcher: "toBeVisible", expected: null, actual: null, negated: false, note: null };
2467
2511
  } else if (action === "assert") {
2468
2512
  step.assertion = checkAssertion(c, v.assertion, `${path24}.assertion`);
2513
+ } else if (action === "manual") {
2514
+ step.value = null;
2515
+ step.manual = checkManual(c, v.manual, `${path24}.manual`);
2469
2516
  }
2470
2517
  return step;
2471
2518
  }
@@ -2481,8 +2528,8 @@ function parseSteps(input) {
2481
2528
  if (!isObject(value)) return { ok: false, errors: ["a steps document must be a JSON object"] };
2482
2529
  const c = new Checker();
2483
2530
  const legacy = value.v == null && typeof value.startUrl === "string";
2484
- if (!legacy && value.v !== STEPS_VERSION) {
2485
- return { ok: false, errors: [`v: this reads version ${STEPS_VERSION}, not ${JSON.stringify(value.v)}`] };
2531
+ if (!legacy && value.v !== 1 && value.v !== STEPS_VERSION) {
2532
+ return { ok: false, errors: [`v: this reads versions 1 to ${STEPS_VERSION}, not ${JSON.stringify(value.v)}`] };
2486
2533
  }
2487
2534
  if (!Array.isArray(value.steps)) return { ok: false, errors: ["steps: must be a list"] };
2488
2535
  if (value.steps.length > STEPS_LIMITS.steps) {
@@ -2505,7 +2552,7 @@ function parseSteps(input) {
2505
2552
  if (originText && !origin) c.fail("origin", "must be an http(s) origin");
2506
2553
  const viewports = checkViewports(c, value.viewports, steps.length);
2507
2554
  doc = {
2508
- v: STEPS_VERSION,
2555
+ v: stepsVersionFor(steps),
2509
2556
  title: c.text(value.title, "title", STEPS_LIMITS.textLength, true),
2510
2557
  origin,
2511
2558
  recordedAt: value.recordedAt == null ? 0 : c.number(value.recordedAt, "recordedAt"),
@@ -4832,7 +4879,7 @@ Connection:
4832
4879
  --server-url <url> Dashboard URL (env PIWI_DASHBOARD_URL)
4833
4880
  --api-key <key> API key (env PIWI_API_KEY)
4834
4881
 
4835
- Policy (at least one is required):
4882
+ Policy (at least one rule or warn-only flag is required):
4836
4883
  --require-tag <tags> Comma-separated; every test carrying the tag must pass
4837
4884
  --max-failed <n> Fail when more than n tests failed
4838
4885
  --max-new-regressions <n> Fail when more than n tests newly started failing
@@ -4845,7 +4892,7 @@ Policy (at least one is required):
4845
4892
  --max-new-leaks <n> Fail when more than n of those were never seen on the base branch
4846
4893
 
4847
4894
  Warn-only (reported, never fails the build):
4848
- --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
4849
4896
  --min-patch-coverage <percent> Warn when fewer of the changed lines ran, in the commit's combined coverage
4850
4897
 
4851
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;
@@ -652,6 +661,9 @@ function toWireTestCase(tc) {
652
661
  tags: rest.tags ?? null,
653
662
  locks: rest.locks ?? null,
654
663
  testMeta: rest.testMeta ?? null,
664
+ // Kept `undefined` when unset (a `begin` event): `null` would tell the dashboard the test has no ID.
665
+ externalId: rest.externalId,
666
+ displayTitle: rest.displayTitle ?? null,
655
667
  locatorSnapshots: rest.locatorSnapshots || null,
656
668
  locatorPages: rest.locatorPages || null,
657
669
  codeReach: rest.codeReach || null,
@@ -4549,6 +4561,30 @@ function runFilesOf(answer) {
4549
4561
  }
4550
4562
  return files;
4551
4563
  }
4564
+ function instanceOf(answer) {
4565
+ const instance = isRecord2(answer) ? answer.instance : null;
4566
+ if (!isRecord2(instance) || typeof instance.id !== "string" || !REQUEST_ID.test(instance.id)) return null;
4567
+ const startedAt = typeof instance.startedAt === "string" && !Number.isNaN(Date.parse(instance.startedAt)) ? new Date(instance.startedAt).toISOString() : null;
4568
+ return { id: instance.id, startedAt };
4569
+ }
4570
+ function processKeyOf(id) {
4571
+ const dot = id.lastIndexOf(".");
4572
+ return dot > 0 ? id.slice(0, dot) : null;
4573
+ }
4574
+ function withTestCoverage(files, tests) {
4575
+ if (!tests || tests.files.length === 0) return files;
4576
+ const byKey = new Map(files.map((file) => [`${file.runtime}\0${file.path}`, { ...file }]));
4577
+ for (const ran of tests.files) {
4578
+ const key = `${ran.runtime}\0${ran.path}`;
4579
+ const file = byKey.get(key) ?? { path: ran.path, runtime: ran.runtime };
4580
+ const functions = normalizeFunctionRanges([...file.functions ?? [], ...ran.functions ?? []]);
4581
+ const lines = normalizeLineRanges([...file.lines ?? [], ...ran.lines ?? []]);
4582
+ if (functions.length > 0) file.functions = functions;
4583
+ if (lines.length > 0) file.lines = lines;
4584
+ byKey.set(key, file);
4585
+ }
4586
+ return [...byKey.values()];
4587
+ }
4552
4588
  function isCollectAnswer(answer) {
4553
4589
  return isRecord2(answer) && answer.v === 1 && Array.isArray(answer.groups);
4554
4590
  }
@@ -4600,11 +4636,16 @@ var ServerCoverageCollector = class {
4600
4636
  }
4601
4637
  /**
4602
4638
  * The run-level coverage of an origin, with each server file's executable
4603
- * lines at `lines` granularity; null when the backend did not answer with it.
4639
+ * lines at `lines` granularity, and the lines and functions the tests'
4640
+ * coverage named added to it. The gap says why part of it is missing: the
4641
+ * backend restarted during the run (its answer names another process than
4642
+ * the run's first request ids), or it did not answer with run-level coverage.
4604
4643
  */
4605
4644
  async fetchRun(origin) {
4606
4645
  const state = this.stateOf(origin);
4607
4646
  const query = `?granularity=${encodeURIComponent(this.options.granularity)}`;
4647
+ let answer = null;
4648
+ let failure = null;
4608
4649
  for (const base of this.pathBases(origin)) {
4609
4650
  const outcome = await this.call(
4610
4651
  `${origin}${base}${RUN_PATH}${query}`,
@@ -4612,21 +4653,47 @@ var ServerCoverageCollector = class {
4612
4653
  this.limits.runTimeoutMs
4613
4654
  );
4614
4655
  if (outcome.kind === "failed") {
4615
- this.reportFailure(state, `Could not fetch the run's server coverage from ${origin}: ${outcome.reason}.`);
4616
- return null;
4656
+ failure = outcome.reason;
4657
+ break;
4617
4658
  }
4618
4659
  const answered = outcome.ok ? runFilesOf(outcome.json) : null;
4619
4660
  if (answered) {
4620
4661
  state.base = base;
4621
- const files = await this.verifyChecksums(
4622
- answered,
4623
- fileChecksums(isRecord2(outcome.json) ? outcome.json.files : null)
4624
- );
4625
- return this.options.blobs ? this.options.blobs.withBlobs(files.files) : files.files;
4662
+ answer = { files: answered, json: outcome.json };
4663
+ break;
4626
4664
  }
4627
4665
  }
4628
- this.reportFailure(state, `Could not fetch the run's server coverage: ${this.noEndpoint(origin, RUN_PATH)}.`);
4629
- return null;
4666
+ const gap = answer ? this.restartGap(origin, state, instanceOf(answer.json)) : { origin, reason: "unreachable" };
4667
+ if (!answer) failure ??= this.noEndpoint(origin, RUN_PATH);
4668
+ if (gap && !this.closer.signal.aborted) this.logger.warn(this.gapMessage(gap, failure));
4669
+ const checksums = fileChecksums(answer?.json.files ?? null, new Map(state.testChecksums));
4670
+ const files = await this.verifyChecksums(withTestCoverage(answer?.files ?? [], state.testCoverage), checksums);
4671
+ let out = files.files;
4672
+ try {
4673
+ if (this.options.blobs) out = await this.options.blobs.withBlobs(out);
4674
+ } catch {
4675
+ }
4676
+ return { files: out, gap };
4677
+ }
4678
+ /**
4679
+ * The gap of an origin whose run-level coverage `instance` answered: a
4680
+ * restart when it, or a collection answer, names another process than the
4681
+ * run's first request ids; null when the backend names no process.
4682
+ */
4683
+ restartGap(origin, state, instance) {
4684
+ if (!instance) return null;
4685
+ const keys = [...state.processKeys];
4686
+ const others = [...state.instances.values()].filter((known) => known.id !== instance.id);
4687
+ if (keys.every((key) => key === instance.id) && others.length === 0) return null;
4688
+ const at = keys[0] === instance.id ? others[others.length - 1]?.startedAt ?? null : instance.startedAt;
4689
+ return { origin, reason: "restarted", ...at ? { at } : {} };
4690
+ }
4691
+ gapMessage(gap, failure) {
4692
+ if (gap.reason === "unreachable") {
4693
+ return `Could not fetch the run's server coverage from ${gap.origin} (${failure}): the run's server coverage is marked incomplete, and keeps the lines the tests' coverage named.`;
4694
+ }
4695
+ const when = gap.at ? ` at about ${gap.at}` : "";
4696
+ return `The backend at ${gap.origin} restarted during the run${when}: the run's server coverage is marked incomplete. It keeps the lines the tests' coverage named; what the earlier process ran outside a request is lost.`;
4630
4697
  }
4631
4698
  /** Abort every call in flight and fail every later one; the tests still waiting resolve incomplete. */
4632
4699
  close() {
@@ -4648,7 +4715,11 @@ var ServerCoverageCollector = class {
4648
4715
  timer: null,
4649
4716
  base: null,
4650
4717
  limit: createLimiter(this.limits.concurrency),
4651
- warned: false
4718
+ warned: false,
4719
+ processKeys: /* @__PURE__ */ new Set(),
4720
+ instances: /* @__PURE__ */ new Map(),
4721
+ testCoverage: null,
4722
+ testChecksums: /* @__PURE__ */ new Map()
4652
4723
  };
4653
4724
  this.originStates.set(origin, state);
4654
4725
  }
@@ -4718,6 +4789,13 @@ var ServerCoverageCollector = class {
4718
4789
  }
4719
4790
  /** Send one call and hand each group's answer to its test. */
4720
4791
  async send(origin, groups) {
4792
+ const state = this.stateOf(origin);
4793
+ for (const group of groups) {
4794
+ for (const id of group.ids) {
4795
+ const key = processKeyOf(id);
4796
+ if (key !== null) state.processKeys.add(key);
4797
+ }
4798
+ }
4721
4799
  let answer = null;
4722
4800
  try {
4723
4801
  const body = JSON.stringify({
@@ -4728,6 +4806,8 @@ var ServerCoverageCollector = class {
4728
4806
  } catch (error) {
4729
4807
  this.logger.debug(`Server coverage collection from ${origin} failed: ${errorMessage(error)}`);
4730
4808
  }
4809
+ const instance = instanceOf(answer);
4810
+ if (instance && !state.instances.has(instance.id)) state.instances.set(instance.id, instance);
4731
4811
  const answered = /* @__PURE__ */ new Map();
4732
4812
  for (const group of answer?.groups ?? []) {
4733
4813
  if (isRecord2(group) && typeof group.key === "string") answered.set(group.key, group);
@@ -4739,9 +4819,11 @@ var ServerCoverageCollector = class {
4739
4819
  } else {
4740
4820
  if (Array.isArray(result.missing) && result.missing.length > 0) group.entry.incomplete = true;
4741
4821
  fileChecksums(result.files, group.entry.checksums);
4822
+ fileChecksums(result.files, state.testChecksums);
4742
4823
  const capture = sanitizeCoverage({ v: 1, files: result.files });
4743
4824
  if (capture) {
4744
4825
  group.entry.captures.push(capture);
4826
+ state.testCoverage = mergeCoverage(state.testCoverage, capture);
4745
4827
  if (capture.incomplete) group.entry.incomplete = true;
4746
4828
  }
4747
4829
  }
@@ -6032,6 +6114,82 @@ function parseTestMetadata(annotations) {
6032
6114
  }
6033
6115
  return Object.keys(meta).length > 0 ? meta : null;
6034
6116
  }
6117
+ var MAX_EXTERNAL_ID_CHARS = 120;
6118
+ function normalizeExternalId(value) {
6119
+ if (typeof value !== "string") return void 0;
6120
+ const trimmed = value.trim();
6121
+ if (!trimmed || trimmed.length > MAX_EXTERNAL_ID_CHARS) return void 0;
6122
+ for (let i = 0; i < trimmed.length; i++) {
6123
+ const code = trimmed.charCodeAt(i);
6124
+ if (code < 32 || code === 127) return void 0;
6125
+ }
6126
+ return trimmed;
6127
+ }
6128
+ function parseExternalId(annotations) {
6129
+ if (!Array.isArray(annotations)) return void 0;
6130
+ let id;
6131
+ for (const entry of annotations) {
6132
+ if (!entry || typeof entry !== "object") continue;
6133
+ const { type, description } = entry;
6134
+ if (typeof type !== "string" || type.trim().toLowerCase() !== `${PIWI_ANNOTATION_PREFIX}id`) continue;
6135
+ const value = normalizeExternalId(description);
6136
+ if (value) id = value;
6137
+ }
6138
+ return id;
6139
+ }
6140
+ function compileExternalIdPattern(pattern) {
6141
+ if (typeof pattern === "string") return new RegExp(pattern);
6142
+ return new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
6143
+ }
6144
+ function readExternalIdFromTitle(title, pattern) {
6145
+ const match = pattern.exec(title);
6146
+ if (!match) return null;
6147
+ const externalId = normalizeExternalId(match.length > 1 ? match[1] : match[0]);
6148
+ if (!externalId) return null;
6149
+ const rest = (title.slice(0, match.index) + title.slice(match.index + match[0].length)).trim();
6150
+ return rest ? { externalId, displayTitle: rest } : { externalId };
6151
+ }
6152
+ var MAX_EXTERNAL_ID_DUPLICATES = 50;
6153
+ var MAX_EXTERNAL_ID_DUPLICATE_TESTS = 10;
6154
+ function duplicateTestKey(test) {
6155
+ return `${test.filePath}\0${(test.suitePath ?? []).join("")}\0${test.title}`;
6156
+ }
6157
+ function readDuplicateTest(raw) {
6158
+ if (!raw || typeof raw !== "object") return null;
6159
+ const { filePath: filePath2, suitePath, title, line } = raw;
6160
+ if (typeof filePath2 !== "string" || typeof title !== "string") return null;
6161
+ const test = { filePath: filePath2, title };
6162
+ if (Array.isArray(suitePath) && suitePath.every((s) => typeof s === "string") && suitePath.length > 0)
6163
+ test.suitePath = suitePath;
6164
+ if (typeof line === "number" && Number.isFinite(line)) test.line = line;
6165
+ return test;
6166
+ }
6167
+ function mergeExternalIdDuplicates(...lists) {
6168
+ const byId = /* @__PURE__ */ new Map();
6169
+ for (const list of lists) {
6170
+ if (!Array.isArray(list)) continue;
6171
+ for (const entry of list) {
6172
+ if (!entry || typeof entry !== "object") continue;
6173
+ const externalId = normalizeExternalId(entry.externalId);
6174
+ const tests = entry.tests;
6175
+ if (!externalId || !Array.isArray(tests)) continue;
6176
+ const read = tests.map(readDuplicateTest).filter((test) => test !== null);
6177
+ if (read.length === 0) continue;
6178
+ let known = byId.get(externalId);
6179
+ if (!known) {
6180
+ if (byId.size >= MAX_EXTERNAL_ID_DUPLICATES) continue;
6181
+ known = /* @__PURE__ */ new Map();
6182
+ byId.set(externalId, known);
6183
+ }
6184
+ for (const test of read) {
6185
+ if (known.size >= MAX_EXTERNAL_ID_DUPLICATE_TESTS) break;
6186
+ const key = duplicateTestKey(test);
6187
+ if (!known.has(key)) known.set(key, test);
6188
+ }
6189
+ }
6190
+ }
6191
+ return [...byId].map(([externalId, tests]) => ({ externalId, tests: [...tests.values()] }));
6192
+ }
6035
6193
 
6036
6194
  // src/internal/collect/test-meta.ts
6037
6195
  function collectTestTags(test) {
@@ -6045,6 +6203,84 @@ function collectTestMetadata(annotations) {
6045
6203
  return parseTestMetadata(annotations);
6046
6204
  }
6047
6205
 
6206
+ // src/internal/collect/external-ids.ts
6207
+ function naturalKey(test) {
6208
+ return `${test.filePath}\0${test.suitePath.join("")}\0${test.title}`;
6209
+ }
6210
+ function duplicateTest(test) {
6211
+ return {
6212
+ filePath: test.filePath,
6213
+ ...test.suitePath.length ? { suitePath: test.suitePath } : {},
6214
+ title: test.title,
6215
+ line: test.line
6216
+ };
6217
+ }
6218
+ function planExternalIds(tests, options = {}) {
6219
+ let pattern = null;
6220
+ let patternError;
6221
+ if (options.pattern !== void 0 && options.pattern !== "") {
6222
+ try {
6223
+ pattern = compileExternalIdPattern(options.pattern);
6224
+ } catch (error) {
6225
+ patternError = error instanceof Error ? error.message : String(error);
6226
+ }
6227
+ }
6228
+ const found = /* @__PURE__ */ new Map();
6229
+ const holders = /* @__PURE__ */ new Map();
6230
+ for (const test of tests) {
6231
+ let id = null;
6232
+ const declared = parseExternalId(test.annotations);
6233
+ if (declared) id = { externalId: declared };
6234
+ else if (pattern) {
6235
+ const read = readExternalIdFromTitle(test.title, pattern);
6236
+ if (read) id = options.stripFromTitle ? read : { externalId: read.externalId };
6237
+ }
6238
+ if (!id) continue;
6239
+ found.set(test.testId, id);
6240
+ let keys = holders.get(id.externalId);
6241
+ if (!keys) holders.set(id.externalId, keys = /* @__PURE__ */ new Map());
6242
+ const key = naturalKey(test);
6243
+ if (!keys.has(key)) keys.set(key, test);
6244
+ }
6245
+ const duplicates = [];
6246
+ const duplicateIds = /* @__PURE__ */ new Set();
6247
+ for (const [externalId, keys] of holders) {
6248
+ if (keys.size < 2) continue;
6249
+ duplicateIds.add(externalId);
6250
+ duplicates.push({ externalId, tests: [...keys.values()].map(duplicateTest) });
6251
+ }
6252
+ const byTestId = /* @__PURE__ */ new Map();
6253
+ for (const [testId, id] of found) if (!duplicateIds.has(id.externalId)) byTestId.set(testId, id);
6254
+ return {
6255
+ byTestId,
6256
+ duplicates: mergeExternalIdDuplicates(duplicates),
6257
+ ...patternError ? { patternError } : {}
6258
+ };
6259
+ }
6260
+ function formatExternalIdDuplicates(duplicates, onDuplicate) {
6261
+ if (duplicates.length === 0) return [];
6262
+ const lines = [];
6263
+ for (const { externalId, tests } of duplicates) {
6264
+ lines.push(
6265
+ `External ID ${externalId} is declared by ${tests.length} tests; this run matches them by file, describe path and title:`
6266
+ );
6267
+ for (const test of tests) {
6268
+ const where = test.line != null ? `${test.filePath}:${test.line}` : test.filePath;
6269
+ lines.push(` ${where} ${[...test.suitePath ?? [], test.title].join(" \u203A ")}`);
6270
+ }
6271
+ }
6272
+ lines.push(
6273
+ 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."
6274
+ );
6275
+ return lines;
6276
+ }
6277
+ function resolveOnDuplicate(raw) {
6278
+ if (raw === void 0 || raw === null || raw === "") return { onDuplicate: "report" };
6279
+ const value = String(raw).trim().toLowerCase();
6280
+ if (value === "report" || value === "fail") return { onDuplicate: value };
6281
+ return { onDuplicate: "report", invalid: String(raw) };
6282
+ }
6283
+
6048
6284
  // ../core/src/error-text.ts
6049
6285
  function joinErrorMessages(errors) {
6050
6286
  if (!errors || errors.length === 0) return null;
@@ -10496,17 +10732,26 @@ var CoverageCollection = class {
10496
10732
  }
10497
10733
  /**
10498
10734
  * Send the run-level coverage of every origin, and the browser's, to the
10499
- * dashboard run, in requests within its limits. A failure is logged once,
10500
- * never thrown.
10735
+ * dashboard run, in requests within its limits; the first names the origins
10736
+ * whose server coverage is incomplete (`incomplete`), and is sent for them
10737
+ * alone when no file is. A failure is logged once, never thrown.
10501
10738
  */
10502
10739
  async sendRunCoverage(httpClient, runId, auth) {
10503
10740
  if (!this.collector && !this.browserBlobs) return;
10504
10741
  this.fetchRunCoverage();
10505
- const server = (await Promise.all(this.runFiles.values())).flatMap((result) => result ?? []);
10742
+ const runs = await Promise.all(this.runFiles.values());
10743
+ const server = runs.flatMap((result) => result?.files ?? []);
10744
+ const gaps = runs.flatMap((result) => result?.gap ? [result.gap] : []);
10506
10745
  const files = [...server, ...await this.browserRunFiles()];
10507
- for (const chunk of chunkRunCoverage(files)) {
10746
+ const chunks = chunkRunCoverage(files);
10747
+ if (chunks.length === 0 && gaps.length > 0) chunks.push([]);
10748
+ for (const [i, chunk] of chunks.entries()) {
10508
10749
  try {
10509
- await httpClient.putJSON(`/api/test-runs/${runId}/coverage`, { v: 1, files: chunk }, auth);
10750
+ await httpClient.putJSON(
10751
+ `/api/test-runs/${runId}/coverage`,
10752
+ { v: 1, files: chunk, ...i === 0 && gaps.length > 0 ? { incomplete: gaps } : {} },
10753
+ auth
10754
+ );
10510
10755
  } catch (error) {
10511
10756
  this.logger.warn(`Could not send the run's coverage to the dashboard: ${errorMessage(error)}`);
10512
10757
  return;
@@ -10553,6 +10798,8 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
10553
10798
  this.instanceId = "";
10554
10799
  this.shardInfo = null;
10555
10800
  this.metadata = {};
10801
+ /** Each planned test's external ID and the IDs two tests share, settled in `onBegin`. */
10802
+ this.externalIds = null;
10556
10803
  this.isFullRun = true;
10557
10804
  this.filterDetails = null;
10558
10805
  /** Configured `maxFailures` (0 = unlimited) — disambiguates an interrupted run's unrun reason. */
@@ -10701,6 +10948,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
10701
10948
  this.metadata = flakeRunMetadata(stamp, this.metadata);
10702
10949
  }
10703
10950
  this.plannedTests = suite.allTests();
10951
+ this.planExternalIds();
10704
10952
  this.shardInfo = resolveShardInfo(config);
10705
10953
  if (this.shardInfo) {
10706
10954
  this.logger.info(`Shard ${this.shardInfo.current}/${this.shardInfo.total} detected`);
@@ -10745,6 +10993,44 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
10745
10993
  this.plannedTests.length
10746
10994
  );
10747
10995
  }
10996
+ /**
10997
+ * Settle each planned test's external ID before any test runs, name the IDs two tests share, and record them on
10998
+ * the run so the dashboard lists them too.
10999
+ */
11000
+ planExternalIds() {
11001
+ const options = this.options.externalId ?? {};
11002
+ const plan = planExternalIds(
11003
+ this.plannedTests.map((test) => ({
11004
+ testId: test.id,
11005
+ filePath: testFile(test, this.testPathBase),
11006
+ suitePath: this.metadataCollector.getSuiteInfo(test).suitePath ?? [],
11007
+ title: test.title,
11008
+ line: test.location.line,
11009
+ annotations: test.annotations ?? []
11010
+ })),
11011
+ options
11012
+ );
11013
+ if (plan.patternError) {
11014
+ this.logger.error(
11015
+ `externalId.pattern is not a valid regular expression, so no ID is read from titles: ${plan.patternError}`
11016
+ );
11017
+ }
11018
+ const { onDuplicate, invalid } = resolveOnDuplicate(options.onDuplicate);
11019
+ if (invalid !== void 0) {
11020
+ this.logger.warn(
11021
+ `externalId.onDuplicate is "${invalid}"; expected 'report' or 'fail', so duplicates are reported.`
11022
+ );
11023
+ }
11024
+ for (const line of formatExternalIdDuplicates(plan.duplicates, onDuplicate)) this.logger.warn(line);
11025
+ if (plan.duplicates.length > 0) this.metadata = { ...this.metadata, externalIdDuplicates: plan.duplicates };
11026
+ this.externalIds = plan;
11027
+ }
11028
+ /** The external ID fields of a test's payload: `null` tells the dashboard the test has none to match on. */
11029
+ externalIdFields(test) {
11030
+ if (!this.externalIds) return {};
11031
+ const id = this.externalIds.byTestId.get(test.id);
11032
+ return { externalId: id?.externalId ?? null, displayTitle: id?.displayTitle ?? null };
11033
+ }
10748
11034
  /** Playwright reporter hook: called when an individual test begins */
10749
11035
  onTestBegin(test, result) {
10750
11036
  this.lastTestWatch?.begin(test.id);
@@ -10872,6 +11158,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
10872
11158
  tags: tags.length ? tags : null,
10873
11159
  locks: locks.length ? locks : null,
10874
11160
  testMeta: collectTestMetadata(annotations),
11161
+ ...this.externalIdFields(test),
10875
11162
  // An annotation-less skip reclassified to `didnotrun` is a serial-group
10876
11163
  // cascade: an earlier test failed and Playwright skipped the rest.
10877
11164
  didNotRunReason: status === "didnotrun" ? "previous-failure" : null,
@@ -11074,6 +11361,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
11074
11361
  tags: tags.length ? tags : null,
11075
11362
  locks: locks.length ? locks : null,
11076
11363
  testMeta: collectTestMetadata(declaredAnnotations),
11364
+ ...this.externalIdFields(test),
11077
11365
  didNotRunReason: reason,
11078
11366
  expectedStatus: test.expectedStatus ?? null
11079
11367
  };
@@ -11085,8 +11373,23 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
11085
11373
  }
11086
11374
  }
11087
11375
  }
11088
- /** Playwright reporter hook: called when the full test run finishes */
11376
+ /**
11377
+ * Playwright reporter hook: called when the full test run finishes. Fails the run when two tests share an external
11378
+ * ID and `externalId.onDuplicate` is `'fail'`.
11379
+ */
11089
11380
  async onEnd(result) {
11381
+ const duplicates = this.externalIds?.duplicates.length ?? 0;
11382
+ const failsOnDuplicates = duplicates > 0 && resolveOnDuplicate(this.options.externalId?.onDuplicate).onDuplicate === "fail";
11383
+ const overridden = failsOnDuplicates && result?.status === "passed";
11384
+ await this.finishRun(overridden ? { ...result, status: "failed" } : result);
11385
+ if (failsOnDuplicates) {
11386
+ this.logger.error(
11387
+ `Failing the run: ${duplicates} external ID${duplicates === 1 ? " is" : "s are"} declared by more than one test.`
11388
+ );
11389
+ return { status: "failed" };
11390
+ }
11391
+ }
11392
+ async finishRun(result) {
11090
11393
  if (this.listMode || !this.enabled) return;
11091
11394
  if (this.startTime === null) {
11092
11395
  this.logger.debug("The run stopped before any test began \u2014 nothing to report.");
@@ -2648,6 +2648,8 @@ var PIWI_ENV_KEYS = {
2648
2648
  codeReachRoots: "PIWI_CODE_REACH_ROOTS",
2649
2649
  coverageBrowser: "PIWI_COVERAGE_BROWSER",
2650
2650
  coverageServer: "PIWI_COVERAGE_SERVER",
2651
+ externalIdPattern: "PIWI_EXTERNAL_ID_PATTERN",
2652
+ externalIdOnDuplicate: "PIWI_EXTERNAL_ID_ON_DUPLICATE",
2651
2653
  captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
2652
2654
  captureResources: "PIWI_CAPTURE_RESOURCES",
2653
2655
  leakCheck: "PIWI_LEAK_CHECK",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piwitests/reporter",
3
- "version": "0.56.0",
3
+ "version": "0.58.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).