@piwitests/reporter 0.36.0 → 0.39.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.
@@ -35,7 +35,7 @@ __export(global_setup_module_exports, {
35
35
  module.exports = __toCommonJS(global_setup_module_exports);
36
36
 
37
37
  // src/public/global-setup.ts
38
- var path4 = __toESM(require("path"));
38
+ var path5 = __toESM(require("path"));
39
39
 
40
40
  // src/internal/support/errors.ts
41
41
  function errorMessage(error) {
@@ -44,7 +44,7 @@ function errorMessage(error) {
44
44
  }
45
45
 
46
46
  // src/public/global-setup.ts
47
- var fs4 = __toESM(require("fs"));
47
+ var fs5 = __toESM(require("fs"));
48
48
 
49
49
  // src/internal/config/desktop.ts
50
50
  var fs = __toESM(require("fs"));
@@ -77,8 +77,12 @@ var DEFAULTS = {
77
77
  collectPerformanceMetrics: true,
78
78
  captureLocators: true,
79
79
  capturePageState: true,
80
+ // Off by default: the page inventory reads control and link names from every
81
+ // visited page, so it stays opt-in until a project turns it on.
82
+ capturePageInventory: false,
80
83
  captureServerTraces: true,
81
84
  sampleAriaOnPass: true,
85
+ uploadManifest: true,
82
86
  defaultCapture: true,
83
87
  streaming: true,
84
88
  streamingBatchSize: 5,
@@ -100,6 +104,7 @@ var PIWI_ENV_KEYS = {
100
104
  environment: "PIWI_ENVIRONMENT",
101
105
  label: "PIWI_LABEL",
102
106
  runLabel: "PIWI_RUN_LABEL",
107
+ keep: "PIWI_KEEP",
103
108
  streaming: "PIWI_STREAMING",
104
109
  streamingBatchSize: "PIWI_STREAMING_BATCH_SIZE",
105
110
  streamingBatchDelay: "PIWI_STREAMING_BATCH_DELAY",
@@ -110,8 +115,10 @@ var PIWI_ENV_KEYS = {
110
115
  uploadReport: "PIWI_UPLOAD_REPORT",
111
116
  captureLocators: "PIWI_CAPTURE_LOCATORS",
112
117
  capturePageState: "PIWI_CAPTURE_PAGE_STATE",
118
+ capturePageInventory: "PIWI_CAPTURE_PAGE_INVENTORY",
113
119
  captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
114
120
  sampleAriaOnPass: "PIWI_SAMPLE_ARIA_ON_PASS",
121
+ uploadManifest: "PIWI_UPLOAD_MANIFEST",
115
122
  defaultCapture: "PIWI_DEFAULT_CAPTURE",
116
123
  inspectOnFailure: "PIWI_INSPECT_ON_FAIL",
117
124
  pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL",
@@ -139,6 +146,7 @@ var ENV_FALLBACK_SPECS = [
139
146
  { option: "environment", env: PIWI_ENV_KEYS.environment, kind: "string" },
140
147
  { option: "label", env: PIWI_ENV_KEYS.label, kind: "string" },
141
148
  { option: "runLabel", env: PIWI_ENV_KEYS.runLabel, kind: "string" },
149
+ { option: "keep", env: PIWI_ENV_KEYS.keep, kind: "bool" },
142
150
  { option: "streaming", env: PIWI_ENV_KEYS.streaming, kind: "bool" },
143
151
  { option: "streamingBatchSize", env: PIWI_ENV_KEYS.streamingBatchSize, kind: "number" },
144
152
  { option: "streamingBatchDelay", env: PIWI_ENV_KEYS.streamingBatchDelay, kind: "number" },
@@ -149,8 +157,10 @@ var ENV_FALLBACK_SPECS = [
149
157
  { option: "uploadReport", env: PIWI_ENV_KEYS.uploadReport, kind: "bool" },
150
158
  { option: "captureLocators", env: PIWI_ENV_KEYS.captureLocators, kind: "bool" },
151
159
  { option: "capturePageState", env: PIWI_ENV_KEYS.capturePageState, kind: "bool" },
160
+ { option: "capturePageInventory", env: PIWI_ENV_KEYS.capturePageInventory, kind: "bool" },
152
161
  { option: "captureServerTraces", env: PIWI_ENV_KEYS.captureServerTraces, kind: "bool" },
153
162
  { option: "sampleAriaOnPass", env: PIWI_ENV_KEYS.sampleAriaOnPass, kind: "bool" },
163
+ { option: "uploadManifest", env: PIWI_ENV_KEYS.uploadManifest, kind: "bool" },
154
164
  { option: "defaultCapture", env: PIWI_ENV_KEYS.defaultCapture, kind: "bool" },
155
165
  { option: "inspectOnFailure", env: PIWI_ENV_KEYS.inspectOnFailure, kind: "bool" },
156
166
  { option: "pickLocatorOnFailure", env: PIWI_ENV_KEYS.pickLocatorOnFailure, kind: "bool" },
@@ -351,6 +361,27 @@ var HttpClient = class {
351
361
  return {};
352
362
  }
353
363
  }
364
+ /** Send a JSON PUT request. Used for the declared-surface manifest upload. */
365
+ async putJSON(pathname, payload, auth) {
366
+ const body = JSON.stringify(payload);
367
+ const res = await this.request("PUT", pathname, {
368
+ headers: {
369
+ "Content-Type": "application/json",
370
+ "Content-Length": Buffer.byteLength(body)
371
+ },
372
+ body,
373
+ auth
374
+ });
375
+ if (res.status < 200 || res.status >= 300) {
376
+ this.logger.debugError(`Response: ${res.text}`);
377
+ throw new HttpError(res.status);
378
+ }
379
+ try {
380
+ return JSON.parse(res.text);
381
+ } catch {
382
+ return {};
383
+ }
384
+ }
354
385
  /** Send a multipart form-data POST request. Used for report and trace uploads. */
355
386
  async postFormData(pathname, form, auth) {
356
387
  const headers = form.getHeaders();
@@ -453,6 +484,36 @@ function detectCiRunLabel() {
453
484
  return null;
454
485
  }
455
486
 
487
+ // src/internal/collect/metadata-collector.ts
488
+ var import_node_child_process = require("child_process");
489
+ var DETACHED_HEAD = "HEAD";
490
+ function resolveScmBranch(env, gitBranch) {
491
+ const pick = (...names) => {
492
+ for (const name of names) {
493
+ const value = env[name]?.trim();
494
+ if (value) return value;
495
+ }
496
+ return void 0;
497
+ };
498
+ const override = env.PIWI_BRANCH?.trim();
499
+ if (override) return override;
500
+ let provider;
501
+ if (env.JENKINS_URL) provider = pick("CHANGE_BRANCH", "BRANCH_NAME");
502
+ else if (env.GITHUB_ACTIONS) provider = pick("GITHUB_HEAD_REF", "GITHUB_REF_NAME");
503
+ else if (env.GITLAB_CI) provider = pick("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME", "CI_COMMIT_REF_NAME");
504
+ else if (env.CIRCLECI) provider = pick("CIRCLE_BRANCH");
505
+ else if (env.TRAVIS) provider = pick("TRAVIS_PULL_REQUEST_BRANCH", "TRAVIS_BRANCH");
506
+ else if (env.TF_BUILD) provider = pick("SYSTEM_PULLREQUEST_SOURCEBRANCH", "BUILD_SOURCEBRANCHNAME");
507
+ else if (env.BITBUCKET_BUILD_NUMBER) provider = pick("BITBUCKET_BRANCH");
508
+ if (provider) return normalizeRef(provider);
509
+ const trimmed = gitBranch?.trim();
510
+ if (trimmed && trimmed !== DETACHED_HEAD) return trimmed;
511
+ return void 0;
512
+ }
513
+ function normalizeRef(ref) {
514
+ return ref.replace(/^refs\/heads\//, "");
515
+ }
516
+
456
517
  // src/internal/support/setup-file.ts
457
518
  var path2 = __toESM(require("path"));
458
519
  var os3 = __toESM(require("os"));
@@ -500,17 +561,83 @@ function isListMode(argv = process.argv) {
500
561
  return rest.some((tok) => PW_LIST_FLAGS.some((flag) => tok === flag || tok.startsWith(`${flag}=`)));
501
562
  }
502
563
 
564
+ // src/internal/manifest/collect.ts
565
+ var fs4 = __toESM(require("fs"));
566
+ var path4 = __toESM(require("path"));
567
+ var COMMITTED_MANIFEST_FILE = "piwi.manifest.json";
568
+ var INSTRUMENTATION_MANIFEST_PATH = "/__piwi/manifest";
569
+ var INSTRUMENTATION_HEADERS = ["x-piwi-logs", "x-piwi-trace"];
570
+ function parseManifestJson(text) {
571
+ let parsed;
572
+ try {
573
+ parsed = JSON.parse(text);
574
+ } catch {
575
+ return null;
576
+ }
577
+ if (!parsed || typeof parsed !== "object") return null;
578
+ const obj = parsed;
579
+ const routes = Array.isArray(obj.routes) ? obj.routes.filter((r) => !!r && typeof r === "object").filter((r) => typeof r.method === "string" && typeof r.pattern === "string").map((r) => ({
580
+ method: r.method,
581
+ pattern: r.pattern,
582
+ handler: typeof r.handler === "string" ? r.handler : void 0,
583
+ responses: Array.isArray(r.responses) ? r.responses.filter((c) => Number.isInteger(c)) : void 0
584
+ })) : void 0;
585
+ const pages = Array.isArray(obj.pages) ? obj.pages.filter((p) => !!p && typeof p === "object").filter((p) => typeof p.pattern === "string").map((p) => ({ pattern: p.pattern, name: typeof p.name === "string" ? p.name : void 0 })) : void 0;
586
+ if (!routes && !pages) return null;
587
+ return { routes, pages };
588
+ }
589
+ function hasInstrumentationHeader(headers) {
590
+ const get = (name) => typeof headers.get === "function" ? headers.get(name) : headers[name];
591
+ return INSTRUMENTATION_HEADERS.some((h) => get(h) != null && get(h) !== "");
592
+ }
593
+ function readCommittedManifest(configDir) {
594
+ const file = path4.join(configDir, COMMITTED_MANIFEST_FILE);
595
+ try {
596
+ if (!fs4.existsSync(file)) return null;
597
+ return parseManifestJson(fs4.readFileSync(file, "utf8"));
598
+ } catch {
599
+ return null;
600
+ }
601
+ }
602
+ function configDirFromConfig(config) {
603
+ if (config?.configFile) return path4.dirname(config.configFile);
604
+ if (config?.rootDir) return config.rootDir;
605
+ return process.cwd();
606
+ }
607
+ function baseUrlFromConfig(config) {
608
+ const top = config?.use?.baseURL;
609
+ if (typeof top === "string" && top) return top;
610
+ for (const project of config?.projects ?? []) {
611
+ const url = project?.use?.baseURL;
612
+ if (typeof url === "string" && url) return url;
613
+ }
614
+ return null;
615
+ }
616
+ var INSTRUMENTATION_FETCH_TIMEOUT_MS = 5e3;
617
+ async function fetchInstrumentationManifest(baseUrl, fetchImpl = fetch) {
618
+ try {
619
+ const probe = await fetchImpl(baseUrl, { signal: AbortSignal.timeout(INSTRUMENTATION_FETCH_TIMEOUT_MS) });
620
+ if (!hasInstrumentationHeader(probe.headers)) return null;
621
+ const manifestUrl = new URL(INSTRUMENTATION_MANIFEST_PATH, baseUrl).toString();
622
+ const res = await fetchImpl(manifestUrl, { signal: AbortSignal.timeout(INSTRUMENTATION_FETCH_TIMEOUT_MS) });
623
+ if (!res.ok) return null;
624
+ return parseManifestJson(await res.text());
625
+ } catch {
626
+ return null;
627
+ }
628
+ }
629
+
503
630
  // src/public/global-setup.ts
504
631
  function createGlobalSetup(options, userSetup) {
505
632
  return async function globalSetupFn(config) {
506
- const piwiReporterPath = path4.resolve(__dirname, "./index.js");
633
+ const piwiReporterPath = path5.resolve(__dirname, "./index.js");
507
634
  let inlineReporterOptions = {};
508
635
  if (Array.isArray(config?.reporter)) {
509
636
  for (const r of config.reporter) {
510
637
  if (!Array.isArray(r) || typeof r[0] !== "string") continue;
511
638
  const isPiwi = r[0].toLowerCase().includes("piwi") || (() => {
512
639
  try {
513
- return path4.resolve(require.resolve(r[0])) === piwiReporterPath;
640
+ return path5.resolve(require.resolve(r[0])) === piwiReporterPath;
514
641
  } catch {
515
642
  return false;
516
643
  }
@@ -542,7 +669,7 @@ function createGlobalSetup(options, userSetup) {
542
669
  if (!Array.isArray(r) || typeof r[0] !== "string") return false;
543
670
  if (r[0].toLowerCase().includes("piwi")) return true;
544
671
  try {
545
- return path4.resolve(require.resolve(r[0])) === piwiReporterPath;
672
+ return path5.resolve(require.resolve(r[0])) === piwiReporterPath;
546
673
  } catch {
547
674
  return false;
548
675
  }
@@ -566,6 +693,7 @@ function createGlobalSetup(options, userSetup) {
566
693
  projectDescription: opts.projectDescription,
567
694
  environment: opts.environment || null,
568
695
  label: opts.label || null,
696
+ keep: opts.keep === true,
569
697
  startTime: (/* @__PURE__ */ new Date()).toISOString(),
570
698
  instanceId: computeInstanceId(opts.projectName, runLabel),
571
699
  shardIndex,
@@ -574,7 +702,7 @@ function createGlobalSetup(options, userSetup) {
574
702
  auth
575
703
  );
576
704
  if (response?.runId && response?.setupToken) {
577
- fs4.writeFileSync(
705
+ fs5.writeFileSync(
578
706
  getSetupFilePath(opts.projectName),
579
707
  JSON.stringify({
580
708
  runId: response.runId,
@@ -585,18 +713,43 @@ function createGlobalSetup(options, userSetup) {
585
713
  logger.debug(`Global setup: initializing run #${response.runId}`);
586
714
  }
587
715
  if (opts.projectName) clearAriaSampleFile(opts.projectName);
588
- if (opts.sampleAriaOnPass !== false && opts.projectName) {
716
+ const wantsAria = opts.sampleAriaOnPass !== false && !!opts.projectName;
717
+ const committedManifest = opts.uploadManifest !== false ? readCommittedManifest(configDirFromConfig(config)) : null;
718
+ const manifestBaseUrl = opts.uploadManifest !== false ? baseUrlFromConfig(config) : null;
719
+ const wantsManifest = opts.uploadManifest !== false && !!opts.projectName && (!!committedManifest || !!manifestBaseUrl);
720
+ let projectId;
721
+ if ((wantsAria || wantsManifest) && opts.projectName) {
589
722
  const menu = await httpClient.getJSON("/api/projects/menu", auth);
590
- const projectId = menu?.items?.find(
723
+ projectId = menu?.items?.find(
591
724
  (p) => p.name.toLowerCase() === opts.projectName.toLowerCase()
592
725
  )?.id;
593
- if (projectId != null) {
594
- const sampling = await httpClient.getJSON(`/api/projects/${projectId}/aria-sampling`, auth);
595
- const tests = Array.isArray(sampling?.tests) ? sampling.tests : null;
596
- if (tests) {
597
- const identities = tests.filter((t) => typeof t.filePath === "string" && typeof t.title === "string").map((t) => ariaSampleIdentity(t.filePath, t.title));
598
- writeAriaSampleFile(opts.projectName, identities);
599
- logger.debug(`Green ARIA sampling: ${identities.length} test(s) due a sample.`);
726
+ }
727
+ if (wantsAria && projectId != null) {
728
+ const sampling = await httpClient.getJSON(`/api/projects/${projectId}/aria-sampling`, auth);
729
+ const tests = Array.isArray(sampling?.tests) ? sampling.tests : null;
730
+ if (tests) {
731
+ const identities = tests.filter((t) => typeof t.filePath === "string" && typeof t.title === "string").map((t) => ariaSampleIdentity(t.filePath, t.title));
732
+ writeAriaSampleFile(opts.projectName, identities);
733
+ logger.debug(`Green ARIA sampling: ${identities.length} test(s) due a sample.`);
734
+ }
735
+ }
736
+ if (wantsManifest && projectId != null) {
737
+ const branch = resolveScmBranch(process.env) ?? null;
738
+ if (committedManifest) {
739
+ await httpClient.putJSON(
740
+ `/api/projects/${projectId}/surface/manifest`,
741
+ { source: "committed", branch, manifest: committedManifest },
742
+ auth
743
+ ).catch((e) => logger.debug(`Manifest upload (committed) skipped: ${errorMessage(e)}`));
744
+ }
745
+ if (manifestBaseUrl) {
746
+ const declared = await fetchInstrumentationManifest(manifestBaseUrl);
747
+ if (declared) {
748
+ await httpClient.putJSON(
749
+ `/api/projects/${projectId}/surface/manifest`,
750
+ { source: "instrumentation", branch, manifest: declared },
751
+ auth
752
+ ).catch((e) => logger.debug(`Manifest upload (instrumentation) skipped: ${errorMessage(e)}`));
600
753
  }
601
754
  }
602
755
  }
package/dist/index.d.ts CHANGED
@@ -48,6 +48,17 @@ interface PiwiDashboardOptions {
48
48
  * also be forced off with `PIWI_CAPTURE_PAGE_STATE=false`.
49
49
  */
50
50
  capturePageState?: boolean;
51
+ /**
52
+ * Capture a lightweight inventory of each visited page on *passing* runs — the
53
+ * interactive controls (role + accessible name) and links (name, with the query
54
+ * and hash stripped from the href) present as the test navigates — so the
55
+ * dashboard can tell which controls and links the suite exposes but never
56
+ * exercises. Only names and hrefs are captured, never field values; a page's
57
+ * controls are read at most once per worker per run. **Defaults to `false`** —
58
+ * opt in with `capturePageInventory: true` (or `PIWI_CAPTURE_PAGE_INVENTORY=true`).
59
+ * Automatically disabled when `collectPerformanceMetrics` is `false`.
60
+ */
61
+ capturePageInventory?: boolean;
51
62
  /**
52
63
  * Capture server-side spans for each API/document request the test makes,
53
64
  * read from the `X-Piwi-Trace` response header emitted by a Piwi
@@ -68,6 +79,15 @@ interface PiwiDashboardOptions {
68
79
  * Set to `false` (or `PIWI_SAMPLE_ARIA_ON_PASS=false`) to never sample on pass.
69
80
  */
70
81
  sampleAriaOnPass?: boolean;
82
+ /**
83
+ * Upload the application's declared surface at run start: a committed
84
+ * `piwi.manifest.json` next to the Playwright config, and — when the base URL's
85
+ * first response carries an instrumentation header — the instrumentation
86
+ * package's `/__piwi/manifest`. A route or page the manifest declares that no
87
+ * test reaches becomes a "declared, never hit" gap. Defaults to `true`; set to
88
+ * `false` (or `PIWI_UPLOAD_MANIFEST=false`) to never upload.
89
+ */
90
+ uploadManifest?: boolean;
71
91
  /**
72
92
  * When installed via `wrapConfig`, default Playwright's own `screenshot` and
73
93
  * `trace` options on the top-level `use` block so a failing test keeps a
@@ -153,6 +173,12 @@ interface PiwiDashboardOptions {
153
173
  environment?: string;
154
174
  /** Optional display label for the test run (e.g. "v2.3.1 release") */
155
175
  label?: string;
176
+ /**
177
+ * Keep this run forever: the dashboard's retention never deletes it. Set it
178
+ * for the runs worth keeping, such as release or tag builds. Can also be set
179
+ * with `PIWI_KEEP=true`.
180
+ */
181
+ keep?: boolean;
156
182
  /** Related issue reference, e.g. `"JIRA-123"` */
157
183
  relatedIssue?: string;
158
184
  /** CI job information */
@@ -332,12 +358,15 @@ declare class PiwiDashboardReporter {
332
358
  onTestBegin(test: TestCase, result: TestResult): void;
333
359
  /** Track suite-level setup steps (beforeAll/afterAll) not tied to any test */
334
360
  private setupSteps;
361
+ /** Step categories streamed live while the run executes. */
362
+ private static readonly LIVE_STEP_CATEGORIES;
335
363
  /**
336
- * Step categories streamed live while the run executes. `pw:assert` is
337
- * excluded: it is the polling noise of `expect()`, not a step a human
338
- * watches; the meaningful readout is the `pw:expect` wrapper around it.
364
+ * Whether a step streams live: its category is in `LIVE_STEP_CATEGORIES`
365
+ * and no ancestor is an `expect` step. The steps inside an assertion are its
366
+ * polling — every `expect.poll` attempt and `toPass` retry — not a step a
367
+ * human watches; the meaningful readout is the assertion around them.
339
368
  */
340
- private static readonly LIVE_STEP_CATEGORIES;
369
+ private static isLiveStep;
341
370
  /** Playwright reporter hook: called when a step (including hook/fixture) begins */
342
371
  onStepBegin(test: TestCase | undefined, _result: TestResult | undefined, step: any): void;
343
372
  /** Playwright reporter hook: called when a step (including hook/fixture) ends */