@piwitests/reporter 0.56.0 → 0.57.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
@@ -1137,13 +1137,18 @@ var ASSERTION_MATCHERS = [
1137
1137
  "toBeHidden",
1138
1138
  "toBeEnabled",
1139
1139
  "toBeDisabled",
1140
- "toHaveURL"
1140
+ "toHaveURL",
1141
+ "toContainText",
1142
+ "toHaveCount",
1143
+ "toBeChecked"
1141
1144
  ];
1142
1145
  var VALUE_MATCHERS = /* @__PURE__ */ new Set([
1143
1146
  "toHaveText",
1144
1147
  "toHaveValue",
1145
1148
  "toHaveAccessibleName",
1146
- "toHaveURL"
1149
+ "toHaveURL",
1150
+ "toContainText",
1151
+ "toHaveCount"
1147
1152
  ]);
1148
1153
 
1149
1154
  // ../core/src/function-match.ts
@@ -1833,7 +1838,7 @@ function renderAssertStep(step, index, ctx) {
1833
1838
  });
1834
1839
  return [` // Step ${index + 1}: ${matcher} without an expected value.`];
1835
1840
  }
1836
- arg = quote(matcher === "toHaveURL" ? urlForCode(assertion.expected, ctx) : assertion.expected);
1841
+ arg = matcher === "toHaveCount" ? String(Number.parseInt(assertion.expected, 10) || 0) : quote(matcher === "toHaveURL" ? urlForCode(assertion.expected, ctx) : assertion.expected);
1837
1842
  }
1838
1843
  const subject = matcher === "toHaveURL" ? ctx.page : locatorForStep(step, index, ctx);
1839
1844
  let line = ` await expect(${subject})${not}.${matcher}(${arg});`;
@@ -1846,10 +1851,25 @@ function renderAssertStep(step, index, ctx) {
1846
1851
  function fileNames(value) {
1847
1852
  return (value ?? "").split("\n").map((name) => name.trim().replace(/^.*[\\/]/, "")).filter(Boolean);
1848
1853
  }
1854
+ function renderManualStep(step, index, ctx) {
1855
+ const description = commentText(step.manual?.description ?? "") || "a step to do by hand";
1856
+ ctx.warnings.push({
1857
+ step: index,
1858
+ code: "manual-step",
1859
+ detail: description,
1860
+ message: `Step ${index + 1} cannot be written as code: ${description}`
1861
+ });
1862
+ const lines = [` // Manual step: ${description}`];
1863
+ const call = step.manual?.call ? commentText(step.manual.call) : "";
1864
+ const location = step.manual?.location ? commentText(step.manual.location) : "";
1865
+ if (call || location) lines.push(` // ${[call, location ? `at ${location}` : ""].filter(Boolean).join(" ")}`);
1866
+ return lines;
1867
+ }
1849
1868
  function renderRawStep(step, index, ctx) {
1850
1869
  const { page } = ctx;
1851
1870
  if (step.action === "goto") return [` await ${page}.goto(${quote(urlForCode(step.value ?? step.pageUrl, ctx))});`];
1852
1871
  if (step.action === "assert" || step.action === "assertVisible") return renderAssertStep(step, index, ctx);
1872
+ if (step.action === "manual") return renderManualStep(step, index, ctx);
1853
1873
  if (step.action === "press" && !step.target) {
1854
1874
  return [` await ${page}.keyboard.press(${quote(step.value ?? "Enter")});`];
1855
1875
  }
@@ -2060,6 +2080,7 @@ function expectFailLine(options) {
2060
2080
  return reason ? ` test.fail(); // ${reason}` : " test.fail();";
2061
2081
  }
2062
2082
  function samePage(a, b) {
2083
+ if (!a || !b) return true;
2063
2084
  const ka = pageKey(a);
2064
2085
  const kb = pageKey(b);
2065
2086
  return ka === null || kb === null || ka === kb;
@@ -2111,7 +2132,7 @@ function renderSpec(session, options = {}) {
2111
2132
  const pageStarts = [];
2112
2133
  const enterPageOf = (index) => {
2113
2134
  const url = steps[index]?.pageUrl;
2114
- if (options.testSteps !== "page" || url == null) return;
2135
+ if (options.testSteps !== "page" || !url) return;
2115
2136
  const last = pageStarts[pageStarts.length - 1];
2116
2137
  if (!last || !samePage(last.url, url)) pageStarts.push({ line: bodyLines.length, url });
2117
2138
  };
@@ -2123,7 +2144,7 @@ function renderSpec(session, options = {}) {
2123
2144
  const pattern = pageUrlPattern(next.pageUrl);
2124
2145
  enterPageOf(last + 1);
2125
2146
  if (pattern) bodyLines.push(` await expect(${page}).toHaveURL(${pattern});`);
2126
- const acts = next.action !== "assert" && next.action !== "assertVisible";
2147
+ const acts = next.action !== "assert" && next.action !== "assertVisible" && next.action !== "manual";
2127
2148
  const chosen = acts && next.target ? chooseLocator(next.target, options) : null;
2128
2149
  if (chosen) bodyLines.push(` await expect(${page}.${chosen.text}).toHaveCount(1);`);
2129
2150
  };
@@ -2225,7 +2246,11 @@ function renderSpec(session, options = {}) {
2225
2246
  }
2226
2247
 
2227
2248
  // ../core/src/steps.ts
2228
- var STEPS_VERSION = 1;
2249
+ var STEPS_VERSION = 2;
2250
+ var VERSION_2_MATCHERS = /* @__PURE__ */ new Set(["toContainText", "toHaveCount", "toBeChecked"]);
2251
+ function stepsVersionFor(steps) {
2252
+ return steps.some((s) => s.action === "manual" || !!s.assertion && VERSION_2_MATCHERS.has(s.assertion.matcher)) ? 2 : 1;
2253
+ }
2229
2254
  var STEPS_LIMITS = {
2230
2255
  steps: 200,
2231
2256
  alternatives: 10,
@@ -2252,7 +2277,8 @@ var ACTIONS = /* @__PURE__ */ new Set([
2252
2277
  "setInputFiles",
2253
2278
  "dragTo",
2254
2279
  "assertVisible",
2255
- "assert"
2280
+ "assert",
2281
+ "manual"
2256
2282
  ]);
2257
2283
  function originOf2(url) {
2258
2284
  try {
@@ -2277,7 +2303,7 @@ function toAbsolute(url, origin) {
2277
2303
  function toStepsDocument(session, meta = {}) {
2278
2304
  const origin = originOf2(session.startUrl) ?? originOf2(session.steps[0]?.pageUrl ?? "");
2279
2305
  return {
2280
- v: STEPS_VERSION,
2306
+ v: stepsVersionFor(session.steps),
2281
2307
  title: meta.title ?? null,
2282
2308
  origin,
2283
2309
  recordedAt: session.startedAt,
@@ -2386,6 +2412,9 @@ function checkAssertion(c, v, path24) {
2386
2412
  return void 0;
2387
2413
  }
2388
2414
  const expected = c.text(v.expected, `${path24}.expected`, STEPS_LIMITS.valueLength, !VALUE_MATCHERS.has(matcher));
2415
+ if (matcher === "toHaveCount" && expected != null && !/^\d{1,6}$/.test(expected)) {
2416
+ c.fail(`${path24}.expected`, "must be a whole number of elements on a toHaveCount assertion");
2417
+ }
2389
2418
  return {
2390
2419
  matcher,
2391
2420
  expected: VALUE_MATCHERS.has(matcher) ? expected : null,
@@ -2394,6 +2423,19 @@ function checkAssertion(c, v, path24) {
2394
2423
  note: c.text(v.note, `${path24}.note`, STEPS_LIMITS.valueLength, true)
2395
2424
  };
2396
2425
  }
2426
+ function checkManual(c, v, path24) {
2427
+ if (!isObject(v)) {
2428
+ c.fail(path24, "is required on a manual step");
2429
+ return void 0;
2430
+ }
2431
+ const description = c.text(v.description, `${path24}.description`, STEPS_LIMITS.valueLength, false);
2432
+ if (description != null && !description.trim()) c.fail(`${path24}.description`, "must not be empty");
2433
+ return {
2434
+ description: description ?? "",
2435
+ location: c.text(v.location, `${path24}.location`, STEPS_LIMITS.textLength, true),
2436
+ call: c.text(v.call, `${path24}.call`, STEPS_LIMITS.textLength, true)
2437
+ };
2438
+ }
2397
2439
  function checkViewports(c, v, stepCount) {
2398
2440
  if (v == null) return [];
2399
2441
  if (!Array.isArray(v)) {
@@ -2466,6 +2508,9 @@ function checkStep(c, v, path24) {
2466
2508
  step.assertion = { matcher: "toBeVisible", expected: null, actual: null, negated: false, note: null };
2467
2509
  } else if (action === "assert") {
2468
2510
  step.assertion = checkAssertion(c, v.assertion, `${path24}.assertion`);
2511
+ } else if (action === "manual") {
2512
+ step.value = null;
2513
+ step.manual = checkManual(c, v.manual, `${path24}.manual`);
2469
2514
  }
2470
2515
  return step;
2471
2516
  }
@@ -2481,8 +2526,8 @@ function parseSteps(input) {
2481
2526
  if (!isObject(value)) return { ok: false, errors: ["a steps document must be a JSON object"] };
2482
2527
  const c = new Checker();
2483
2528
  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)}`] };
2529
+ if (!legacy && value.v !== 1 && value.v !== STEPS_VERSION) {
2530
+ return { ok: false, errors: [`v: this reads versions 1 to ${STEPS_VERSION}, not ${JSON.stringify(value.v)}`] };
2486
2531
  }
2487
2532
  if (!Array.isArray(value.steps)) return { ok: false, errors: ["steps: must be a list"] };
2488
2533
  if (value.steps.length > STEPS_LIMITS.steps) {
@@ -2505,7 +2550,7 @@ function parseSteps(input) {
2505
2550
  if (originText && !origin) c.fail("origin", "must be an http(s) origin");
2506
2551
  const viewports = checkViewports(c, value.viewports, steps.length);
2507
2552
  doc = {
2508
- v: STEPS_VERSION,
2553
+ v: stepsVersionFor(steps),
2509
2554
  title: c.text(value.title, "title", STEPS_LIMITS.textLength, true),
2510
2555
  origin,
2511
2556
  recordedAt: value.recordedAt == null ? 0 : c.number(value.recordedAt, "recordedAt"),
package/dist/index.js CHANGED
@@ -4549,6 +4549,30 @@ function runFilesOf(answer) {
4549
4549
  }
4550
4550
  return files;
4551
4551
  }
4552
+ function instanceOf(answer) {
4553
+ const instance = isRecord2(answer) ? answer.instance : null;
4554
+ if (!isRecord2(instance) || typeof instance.id !== "string" || !REQUEST_ID.test(instance.id)) return null;
4555
+ const startedAt = typeof instance.startedAt === "string" && !Number.isNaN(Date.parse(instance.startedAt)) ? new Date(instance.startedAt).toISOString() : null;
4556
+ return { id: instance.id, startedAt };
4557
+ }
4558
+ function processKeyOf(id) {
4559
+ const dot = id.lastIndexOf(".");
4560
+ return dot > 0 ? id.slice(0, dot) : null;
4561
+ }
4562
+ function withTestCoverage(files, tests) {
4563
+ if (!tests || tests.files.length === 0) return files;
4564
+ const byKey = new Map(files.map((file) => [`${file.runtime}\0${file.path}`, { ...file }]));
4565
+ for (const ran of tests.files) {
4566
+ const key = `${ran.runtime}\0${ran.path}`;
4567
+ const file = byKey.get(key) ?? { path: ran.path, runtime: ran.runtime };
4568
+ const functions = normalizeFunctionRanges([...file.functions ?? [], ...ran.functions ?? []]);
4569
+ const lines = normalizeLineRanges([...file.lines ?? [], ...ran.lines ?? []]);
4570
+ if (functions.length > 0) file.functions = functions;
4571
+ if (lines.length > 0) file.lines = lines;
4572
+ byKey.set(key, file);
4573
+ }
4574
+ return [...byKey.values()];
4575
+ }
4552
4576
  function isCollectAnswer(answer) {
4553
4577
  return isRecord2(answer) && answer.v === 1 && Array.isArray(answer.groups);
4554
4578
  }
@@ -4600,11 +4624,16 @@ var ServerCoverageCollector = class {
4600
4624
  }
4601
4625
  /**
4602
4626
  * 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.
4627
+ * lines at `lines` granularity, and the lines and functions the tests'
4628
+ * coverage named added to it. The gap says why part of it is missing: the
4629
+ * backend restarted during the run (its answer names another process than
4630
+ * the run's first request ids), or it did not answer with run-level coverage.
4604
4631
  */
4605
4632
  async fetchRun(origin) {
4606
4633
  const state = this.stateOf(origin);
4607
4634
  const query = `?granularity=${encodeURIComponent(this.options.granularity)}`;
4635
+ let answer = null;
4636
+ let failure = null;
4608
4637
  for (const base of this.pathBases(origin)) {
4609
4638
  const outcome = await this.call(
4610
4639
  `${origin}${base}${RUN_PATH}${query}`,
@@ -4612,21 +4641,47 @@ var ServerCoverageCollector = class {
4612
4641
  this.limits.runTimeoutMs
4613
4642
  );
4614
4643
  if (outcome.kind === "failed") {
4615
- this.reportFailure(state, `Could not fetch the run's server coverage from ${origin}: ${outcome.reason}.`);
4616
- return null;
4644
+ failure = outcome.reason;
4645
+ break;
4617
4646
  }
4618
4647
  const answered = outcome.ok ? runFilesOf(outcome.json) : null;
4619
4648
  if (answered) {
4620
4649
  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;
4650
+ answer = { files: answered, json: outcome.json };
4651
+ break;
4626
4652
  }
4627
4653
  }
4628
- this.reportFailure(state, `Could not fetch the run's server coverage: ${this.noEndpoint(origin, RUN_PATH)}.`);
4629
- return null;
4654
+ const gap = answer ? this.restartGap(origin, state, instanceOf(answer.json)) : { origin, reason: "unreachable" };
4655
+ if (!answer) failure ??= this.noEndpoint(origin, RUN_PATH);
4656
+ if (gap && !this.closer.signal.aborted) this.logger.warn(this.gapMessage(gap, failure));
4657
+ const checksums = fileChecksums(answer?.json.files ?? null, new Map(state.testChecksums));
4658
+ const files = await this.verifyChecksums(withTestCoverage(answer?.files ?? [], state.testCoverage), checksums);
4659
+ let out = files.files;
4660
+ try {
4661
+ if (this.options.blobs) out = await this.options.blobs.withBlobs(out);
4662
+ } catch {
4663
+ }
4664
+ return { files: out, gap };
4665
+ }
4666
+ /**
4667
+ * The gap of an origin whose run-level coverage `instance` answered: a
4668
+ * restart when it, or a collection answer, names another process than the
4669
+ * run's first request ids; null when the backend names no process.
4670
+ */
4671
+ restartGap(origin, state, instance) {
4672
+ if (!instance) return null;
4673
+ const keys = [...state.processKeys];
4674
+ const others = [...state.instances.values()].filter((known) => known.id !== instance.id);
4675
+ if (keys.every((key) => key === instance.id) && others.length === 0) return null;
4676
+ const at = keys[0] === instance.id ? others[others.length - 1]?.startedAt ?? null : instance.startedAt;
4677
+ return { origin, reason: "restarted", ...at ? { at } : {} };
4678
+ }
4679
+ gapMessage(gap, failure) {
4680
+ if (gap.reason === "unreachable") {
4681
+ 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.`;
4682
+ }
4683
+ const when = gap.at ? ` at about ${gap.at}` : "";
4684
+ 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
4685
  }
4631
4686
  /** Abort every call in flight and fail every later one; the tests still waiting resolve incomplete. */
4632
4687
  close() {
@@ -4648,7 +4703,11 @@ var ServerCoverageCollector = class {
4648
4703
  timer: null,
4649
4704
  base: null,
4650
4705
  limit: createLimiter(this.limits.concurrency),
4651
- warned: false
4706
+ warned: false,
4707
+ processKeys: /* @__PURE__ */ new Set(),
4708
+ instances: /* @__PURE__ */ new Map(),
4709
+ testCoverage: null,
4710
+ testChecksums: /* @__PURE__ */ new Map()
4652
4711
  };
4653
4712
  this.originStates.set(origin, state);
4654
4713
  }
@@ -4718,6 +4777,13 @@ var ServerCoverageCollector = class {
4718
4777
  }
4719
4778
  /** Send one call and hand each group's answer to its test. */
4720
4779
  async send(origin, groups) {
4780
+ const state = this.stateOf(origin);
4781
+ for (const group of groups) {
4782
+ for (const id of group.ids) {
4783
+ const key = processKeyOf(id);
4784
+ if (key !== null) state.processKeys.add(key);
4785
+ }
4786
+ }
4721
4787
  let answer = null;
4722
4788
  try {
4723
4789
  const body = JSON.stringify({
@@ -4728,6 +4794,8 @@ var ServerCoverageCollector = class {
4728
4794
  } catch (error) {
4729
4795
  this.logger.debug(`Server coverage collection from ${origin} failed: ${errorMessage(error)}`);
4730
4796
  }
4797
+ const instance = instanceOf(answer);
4798
+ if (instance && !state.instances.has(instance.id)) state.instances.set(instance.id, instance);
4731
4799
  const answered = /* @__PURE__ */ new Map();
4732
4800
  for (const group of answer?.groups ?? []) {
4733
4801
  if (isRecord2(group) && typeof group.key === "string") answered.set(group.key, group);
@@ -4739,9 +4807,11 @@ var ServerCoverageCollector = class {
4739
4807
  } else {
4740
4808
  if (Array.isArray(result.missing) && result.missing.length > 0) group.entry.incomplete = true;
4741
4809
  fileChecksums(result.files, group.entry.checksums);
4810
+ fileChecksums(result.files, state.testChecksums);
4742
4811
  const capture = sanitizeCoverage({ v: 1, files: result.files });
4743
4812
  if (capture) {
4744
4813
  group.entry.captures.push(capture);
4814
+ state.testCoverage = mergeCoverage(state.testCoverage, capture);
4745
4815
  if (capture.incomplete) group.entry.incomplete = true;
4746
4816
  }
4747
4817
  }
@@ -10496,17 +10566,26 @@ var CoverageCollection = class {
10496
10566
  }
10497
10567
  /**
10498
10568
  * 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.
10569
+ * dashboard run, in requests within its limits; the first names the origins
10570
+ * whose server coverage is incomplete (`incomplete`), and is sent for them
10571
+ * alone when no file is. A failure is logged once, never thrown.
10501
10572
  */
10502
10573
  async sendRunCoverage(httpClient, runId, auth) {
10503
10574
  if (!this.collector && !this.browserBlobs) return;
10504
10575
  this.fetchRunCoverage();
10505
- const server = (await Promise.all(this.runFiles.values())).flatMap((result) => result ?? []);
10576
+ const runs = await Promise.all(this.runFiles.values());
10577
+ const server = runs.flatMap((result) => result?.files ?? []);
10578
+ const gaps = runs.flatMap((result) => result?.gap ? [result.gap] : []);
10506
10579
  const files = [...server, ...await this.browserRunFiles()];
10507
- for (const chunk of chunkRunCoverage(files)) {
10580
+ const chunks = chunkRunCoverage(files);
10581
+ if (chunks.length === 0 && gaps.length > 0) chunks.push([]);
10582
+ for (const [i, chunk] of chunks.entries()) {
10508
10583
  try {
10509
- await httpClient.putJSON(`/api/test-runs/${runId}/coverage`, { v: 1, files: chunk }, auth);
10584
+ await httpClient.putJSON(
10585
+ `/api/test-runs/${runId}/coverage`,
10586
+ { v: 1, files: chunk, ...i === 0 && gaps.length > 0 ? { incomplete: gaps } : {} },
10587
+ auth
10588
+ );
10510
10589
  } catch (error) {
10511
10590
  this.logger.warn(`Could not send the run's coverage to the dashboard: ${errorMessage(error)}`);
10512
10591
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piwitests/reporter",
3
- "version": "0.56.0",
3
+ "version": "0.57.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",