@piwitests/reporter 0.9.1 → 0.12.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.
@@ -23,6 +23,40 @@ export interface SelectorCounts {
23
23
  name?: number;
24
24
  classes?: Record<string, number>;
25
25
  }
26
+ /**
27
+ * The element's position among same-role elements in the document at capture
28
+ * time — a name-independent structural identity. `levelCount` additionally
29
+ * counts same-role elements sharing this element's heading level, so a lone
30
+ * `h1` among many `h2`s still gets a name-free `getByRole('heading', { level: 1 })`.
31
+ */
32
+ export interface RolePosition {
33
+ role: string;
34
+ count: number;
35
+ index: number;
36
+ levelCount?: number;
37
+ }
38
+ /**
39
+ * An anchor-worthy ancestor — one carrying a stable hook (test id, id,
40
+ * explicit role, aria-label, or a landmark tag) that a scoped alternative can
41
+ * chain from. Nearest ancestors first.
42
+ */
43
+ export interface AncestorAnchor {
44
+ tag: string;
45
+ /** Hops from the element (1 = direct parent). */
46
+ depth: number;
47
+ testId: string | null;
48
+ id: string | null;
49
+ role: string | null;
50
+ ariaLabel: string | null;
51
+ /** Same-role matches for the captured element within this ancestor. */
52
+ scopedRoleCount?: number;
53
+ /** Document-wide match count for this ancestor's own data-testid. */
54
+ testIdCount?: number;
55
+ /** Document-wide match count for this ancestor's own id. */
56
+ idCount?: number;
57
+ /** Document-wide count of elements resolving to this ancestor's landmark/explicit role. */
58
+ roleCount?: number;
59
+ }
26
60
  export interface ElementAttributes {
27
61
  tagName: string;
28
62
  attributes: Record<string, string | null>;
@@ -41,6 +75,10 @@ export interface ElementAttributes {
41
75
  hasLabel?: boolean;
42
76
  /** Live-page uniqueness probe results for candidate selectors. */
43
77
  selectorCounts?: SelectorCounts;
78
+ /** Position among same-role elements — powers name-free and renamed-element healing. */
79
+ rolePosition?: RolePosition | null;
80
+ /** Anchor-worthy ancestors, nearest first — power ancestor-scoped alternatives. */
81
+ ancestors?: AncestorAnchor[];
44
82
  }
45
83
  export interface LocatorSnapshot {
46
84
  location: string | null;
@@ -58,6 +96,10 @@ export interface LocatorSnapshot {
58
96
  x: number;
59
97
  y: number;
60
98
  } | null;
99
+ /** Position among same-role elements at capture time. */
100
+ rolePosition?: RolePosition | null;
101
+ /** Anchor-worthy ancestors, nearest first. */
102
+ ancestors?: AncestorAnchor[];
61
103
  } | null;
62
104
  alternatives: RankedLocator[];
63
105
  }
@@ -72,8 +114,9 @@ export interface LocatorSnapshot {
72
114
  export declare function dedupeSnapshotsByLocation(snaps: LocatorSnapshot[]): LocatorSnapshot[];
73
115
  /**
74
116
  * Page-level locator-building methods wrapped by the capture proxy. Imported by
75
- * both `reporter/src/fixtures.ts` and the dogfooding `application/tests/fixtures.ts`
76
- * so the two stay in sync (a prior drift missed `scrollIntoViewIfNeeded`).
117
+ * both the capture fixtures (`capture-fixtures.ts`) and the dogfooding
118
+ * `application/tests/fixtures.ts` so the two stay in sync (a prior drift missed
119
+ * `scrollIntoViewIfNeeded`).
77
120
  */
78
121
  export declare const LOCATOR_METHODS: string[];
79
122
  /**
@@ -92,6 +135,16 @@ export declare const LOCATOR_CREATING_CHAINS: ReadonlySet<string>;
92
135
  * the same attribute set.
93
136
  */
94
137
  export declare const CAPTURED_ATTRIBUTES: string[];
138
+ /**
139
+ * Implicit ARIA role for an HTML tag (when no explicit `role` is set). Exported
140
+ * so the in-page probe (`capture-fixtures.ts#probeElementAttrs`) can receive
141
+ * this same map as an `evaluate` argument instead of re-declaring it — the
142
+ * probe is serialized into the browser and can't reference this module's
143
+ * closure, but the map is pure data and rides in as an argument.
144
+ */
145
+ export declare const TAG_TO_ROLE: Record<string, string>;
146
+ /** Implicit ARIA role for an `<input>` keyed by its `type` attribute. Exported for the probe (see {@link TAG_TO_ROLE}). */
147
+ export declare const INPUT_TYPE_TO_ROLE: Record<string, string>;
95
148
  /**
96
149
  * Resolve the ARIA role for an element. An explicit `role` attribute wins;
97
150
  * otherwise the implicit role is derived from the tag name (and `type` for
@@ -100,6 +153,12 @@ export declare const CAPTURED_ATTRIBUTES: string[];
100
153
  * such elements and other alternatives take over.
101
154
  */
102
155
  export declare function resolveAriaRole(attrs: ElementAttributes): string | null;
156
+ /**
157
+ * Heading level for a `heading`-role element: h1-h6 from the tag, else an
158
+ * explicit `aria-level` attribute. Null when unknown (don't guess the ARIA
159
+ * default) or when the element isn't a heading.
160
+ */
161
+ export declare function headingLevel(attrs: ElementAttributes, role: string | null): number | null;
103
162
  /**
104
163
  * Build a ranked list of alternative locators from the captured element
105
164
  * attributes. The list is sorted descending by stability score.
@@ -160,6 +219,7 @@ export interface LocatorSuggestion {
160
219
  export declare function parseAriaRoleName(ariaSnapshot: string): Array<{
161
220
  role: string;
162
221
  name: string | null;
222
+ level: number | null;
163
223
  }>;
164
224
  /**
165
225
  * Token-set (Dice) similarity, 0-1, case- and punctuation-insensitive.
@@ -195,4 +255,4 @@ export declare function suggestLocatorsFromAria(failed: FailedLocatorInfo, ariaS
195
255
  * Returns null when no user frame can be identified — the snapshot keeps
196
256
  * `location: null` and the server falls back to fingerprint / ARIA lookup.
197
257
  */
198
- export declare function captureCallerLocation(): string | null;
258
+ export declare function captureCallerLocation(stack?: string): string | null;
@@ -33,9 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.CAPTURED_ATTRIBUTES = exports.LOCATOR_CREATING_CHAINS = exports.ACTION_METHODS = exports.CHAIN_METHODS = exports.LOCATOR_METHODS = void 0;
36
+ exports.INPUT_TYPE_TO_ROLE = exports.TAG_TO_ROLE = exports.CAPTURED_ATTRIBUTES = exports.LOCATOR_CREATING_CHAINS = exports.ACTION_METHODS = exports.CHAIN_METHODS = exports.LOCATOR_METHODS = void 0;
37
37
  exports.dedupeSnapshotsByLocation = dedupeSnapshotsByLocation;
38
38
  exports.resolveAriaRole = resolveAriaRole;
39
+ exports.headingLevel = headingLevel;
39
40
  exports.generateAlternatives = generateAlternatives;
40
41
  exports.classifyCssStability = classifyCssStability;
41
42
  exports.isAutoGenerated = isAutoGenerated;
@@ -73,8 +74,9 @@ function dedupeSnapshotsByLocation(snaps) {
73
74
  // ── Playwright method surface (shared with the fixture proxy) ────────────────
74
75
  /**
75
76
  * Page-level locator-building methods wrapped by the capture proxy. Imported by
76
- * both `reporter/src/fixtures.ts` and the dogfooding `application/tests/fixtures.ts`
77
- * so the two stay in sync (a prior drift missed `scrollIntoViewIfNeeded`).
77
+ * both the capture fixtures (`capture-fixtures.ts`) and the dogfooding
78
+ * `application/tests/fixtures.ts` so the two stay in sync (a prior drift missed
79
+ * `scrollIntoViewIfNeeded`).
78
80
  */
79
81
  exports.LOCATOR_METHODS = [
80
82
  'getByRole',
@@ -148,6 +150,9 @@ exports.CAPTURED_ATTRIBUTES = [
148
150
  'alt',
149
151
  'title',
150
152
  'aria-label',
153
+ // `aria-level` carries the heading level for `role="heading"` elements
154
+ // (h1-h6 levels come from the tag itself).
155
+ 'aria-level',
151
156
  'role',
152
157
  'type',
153
158
  'href',
@@ -158,8 +163,14 @@ exports.CAPTURED_ATTRIBUTES = [
158
163
  'multiple',
159
164
  ];
160
165
  // ── ARIA role resolution ─────────────────────────────────────────────────────
161
- /** Implicit ARIA role for an HTML tag (when no explicit `role` is set). */
162
- const TAG_TO_ROLE = {
166
+ /**
167
+ * Implicit ARIA role for an HTML tag (when no explicit `role` is set). Exported
168
+ * so the in-page probe (`capture-fixtures.ts#probeElementAttrs`) can receive
169
+ * this same map as an `evaluate` argument instead of re-declaring it — the
170
+ * probe is serialized into the browser and can't reference this module's
171
+ * closure, but the map is pure data and rides in as an argument.
172
+ */
173
+ exports.TAG_TO_ROLE = {
163
174
  a: 'link',
164
175
  button: 'button',
165
176
  nav: 'navigation',
@@ -190,8 +201,8 @@ const TAG_TO_ROLE = {
190
201
  summary: 'button',
191
202
  search: 'search',
192
203
  };
193
- /** Implicit ARIA role for an `<input>` keyed by its `type` attribute. */
194
- const INPUT_TYPE_TO_ROLE = {
204
+ /** Implicit ARIA role for an `<input>` keyed by its `type` attribute. Exported for the probe (see {@link TAG_TO_ROLE}). */
205
+ exports.INPUT_TYPE_TO_ROLE = {
195
206
  button: 'button',
196
207
  submit: 'button',
197
208
  reset: 'button',
@@ -223,7 +234,7 @@ function resolveAriaRole(attrs) {
223
234
  return null;
224
235
  if (tag === 'input') {
225
236
  const type = (attrs.attributes['type'] ?? 'text').toLowerCase();
226
- return INPUT_TYPE_TO_ROLE[type] ?? 'textbox';
237
+ return exports.INPUT_TYPE_TO_ROLE[type] ?? 'textbox';
227
238
  }
228
239
  // A plain <select> is a combobox; only with `multiple` (or size > 1, not
229
240
  // captured) does it become a listbox. Matches the server's implicitRoleForTag.
@@ -233,7 +244,23 @@ function resolveAriaRole(attrs) {
233
244
  if (tag === 'a') {
234
245
  return attrs.attributes['href'] != null ? 'link' : null;
235
246
  }
236
- return TAG_TO_ROLE[tag] ?? null;
247
+ return exports.TAG_TO_ROLE[tag] ?? null;
248
+ }
249
+ /**
250
+ * Heading level for a `heading`-role element: h1-h6 from the tag, else an
251
+ * explicit `aria-level` attribute. Null when unknown (don't guess the ARIA
252
+ * default) or when the element isn't a heading.
253
+ */
254
+ function headingLevel(attrs, role) {
255
+ if (role !== 'heading')
256
+ return null;
257
+ const tagMatch = attrs.tagName.match(/^h([1-6])$/);
258
+ if (tagMatch)
259
+ return Number(tagMatch[1]);
260
+ const ariaLevel = attrs.attributes['aria-level'];
261
+ if (ariaLevel && /^\d+$/.test(ariaLevel))
262
+ return Number(ariaLevel);
263
+ return null;
237
264
  }
238
265
  // ── Alternative generation ───────────────────────────────────────────────────
239
266
  const attr = (a, key) => a.attributes[key] || null;
@@ -279,12 +306,17 @@ function generateAlternatives(attrs) {
279
306
  score: 100,
280
307
  });
281
308
  }
309
+ // Heading level rides along in every heading getByRole — it survives renames
310
+ // and disambiguates same-named headings at different levels.
311
+ const level = headingLevel(attrs, role);
312
+ const levelPart = level != null ? `, level: ${level}` : '';
313
+ const withLevel = (base) => level != null ? { ...base, level } : base;
282
314
  // 2. role + accessible name from browser ARIA tree (85-95)
283
315
  if (role && accessibleName) {
284
316
  add({
285
- locator: `getByRole('${role}', { name: '${esc(accessibleName)}' })`,
317
+ locator: `getByRole('${role}', { name: '${esc(accessibleName)}'${levelPart} })`,
286
318
  method: 'getByRole',
287
- args: { role, name: accessibleName },
319
+ args: withLevel({ role, name: accessibleName }),
288
320
  score: 90,
289
321
  });
290
322
  }
@@ -292,9 +324,9 @@ function generateAlternatives(attrs) {
292
324
  const ariaLabel = attr(attrs, 'aria-label');
293
325
  if (role && ariaLabel && ariaLabel !== accessibleName) {
294
326
  add({
295
- locator: `getByRole('${role}', { name: '${esc(ariaLabel)}' })`,
327
+ locator: `getByRole('${role}', { name: '${esc(ariaLabel)}'${levelPart} })`,
296
328
  method: 'getByRole',
297
- args: { role, name: ariaLabel },
329
+ args: withLevel({ role, name: ariaLabel }),
298
330
  score: 85,
299
331
  });
300
332
  }
@@ -378,6 +410,67 @@ function generateAlternatives(attrs) {
378
410
  score: 50,
379
411
  });
380
412
  }
413
+ // Structural alternatives (55-72) — name-free, so they survive label/text
414
+ // renames that break every name-derived locator above. Skipped when the
415
+ // element has its own unique data-testid (already the top alternative).
416
+ // Chained alternatives carry the LEAF method with flat args (never a `name`
417
+ // key) so the recommendation's method-family logic and the server's
418
+ // fingerprint reader treat them correctly.
419
+ const hasOwnTestId = !!(testId && isUnique(counts?.testId));
420
+ if (role && !hasOwnTestId) {
421
+ const rolePart = level != null ? `'${role}', { level: ${level} }` : `'${role}'`;
422
+ const leafArgs = withLevel({ role });
423
+ // Ancestor-anchored role locators: nearest anchor of each kind whose own
424
+ // hook is document-unique and that contains exactly one leaf-role match
425
+ // (level-scoped for headings — the probe counts accordingly).
426
+ let testIdAnchorDone = false;
427
+ let idAnchorDone = false;
428
+ let roleAnchorDone = false;
429
+ for (const anc of attrs.ancestors ?? []) {
430
+ if (anc.scopedRoleCount !== 1)
431
+ continue;
432
+ if (!testIdAnchorDone && anc.testId && anc.testIdCount === 1) {
433
+ testIdAnchorDone = true;
434
+ add({
435
+ locator: `getByTestId('${esc(anc.testId)}').getByRole(${rolePart})`,
436
+ method: 'getByRole',
437
+ args: { ...leafArgs, anchorTestId: anc.testId },
438
+ score: 72,
439
+ });
440
+ }
441
+ if (!idAnchorDone && anc.id && !isAutoGenerated(anc.id) && anc.idCount === 1) {
442
+ idAnchorDone = true;
443
+ const anchorSelector = isCssSafeId(anc.id) ? `#${anc.id}` : `[id="${escCssAttrValue(anc.id)}"]`;
444
+ add({
445
+ locator: `locator('${esc(anchorSelector)}').getByRole(${rolePart})`,
446
+ method: 'getByRole',
447
+ args: { ...leafArgs, anchorSelector },
448
+ score: 64,
449
+ });
450
+ }
451
+ const ancestorRole = anc.role || exports.TAG_TO_ROLE[anc.tag] || null;
452
+ if (!roleAnchorDone && ancestorRole && ancestorRole !== role && anc.roleCount === 1) {
453
+ roleAnchorDone = true;
454
+ add({
455
+ locator: `getByRole('${esc(ancestorRole)}').getByRole(${rolePart})`,
456
+ method: 'getByRole',
457
+ args: { ...leafArgs, anchorRole: ancestorRole },
458
+ score: 55,
459
+ });
460
+ }
461
+ }
462
+ // Name-free bare role — the sole element of its role on the page, or the
463
+ // sole heading at its level (a lone h1 among many h2s).
464
+ const pos = attrs.rolePosition;
465
+ if (pos && pos.role === role && (pos.count === 1 || (level != null && pos.levelCount === 1))) {
466
+ add({
467
+ locator: `getByRole(${rolePart})`,
468
+ method: 'getByRole',
469
+ args: leafArgs,
470
+ score: 58,
471
+ });
472
+ }
473
+ }
381
474
  // 11. CSS class-based locators — capped at 3 most stable classes; classes
382
475
  // the uniqueness probe saw on more than one element are dropped outright.
383
476
  const clsStr = attr(attrs, 'class');
@@ -537,7 +630,12 @@ function parseAriaRoleName(ariaSnapshot) {
537
630
  const name = m[2] == null ? null : m[2].replace(/\\(.)/g, '$1');
538
631
  if (!name && (role === 'generic' || role === 'group' || role === 'list' || role === 'paragraph'))
539
632
  continue;
540
- out.push({ role, name });
633
+ // Heading level rides after the name as `[level=N]` (other bracketed
634
+ // markers like `[ref=eN]` are ignored). Scan only past the matched part so
635
+ // brackets inside the quoted name can't fake a level.
636
+ const levelMatch = /\[level=(\d+)\]/.exec(line.slice(m[0].length));
637
+ const level = levelMatch ? Number(levelMatch[1]) : null;
638
+ out.push({ role, name, level });
541
639
  }
542
640
  return out;
543
641
  }
@@ -583,16 +681,17 @@ const SUGG_TEXT_ROLES = new Set([
583
681
  'switch',
584
682
  ]);
585
683
  const SUGG_FIELD_ROLES = new Set(['textbox', 'combobox', 'searchbox', 'spinbutton', 'slider']);
586
- /** Extract the role (for getByRole) and the targeted accessible name from a failed locator's args. */
684
+ /** Extract the role (for getByRole), targeted accessible name, and heading level from a failed locator's args. */
587
685
  function failedNameAndRole(failed) {
588
686
  if (failed.method === 'getByRole') {
589
687
  const role = typeof failed.args[0] === 'string' ? failed.args[0] : null;
590
688
  const opts = failed.args[1];
591
689
  const name = opts && typeof opts.name === 'string' ? opts.name : null;
592
- return { role, name };
690
+ const level = opts && typeof opts.level === 'number' ? opts.level : null;
691
+ return { role, name, level };
593
692
  }
594
693
  const first = failed.args.find((a) => typeof a === 'string');
595
- return { role: null, name: typeof first === 'string' ? first : null };
694
+ return { role: null, name: typeof first === 'string' ? first : null, level: null };
596
695
  }
597
696
  /** Render the failed locator back to source for the annotation message. */
598
697
  function renderFailing(failed) {
@@ -613,7 +712,8 @@ function freshSuggestions(candidate, failedMethod) {
613
712
  if (!out.includes(s))
614
713
  out.push(s);
615
714
  };
616
- const roleLoc = `getByRole('${escAttr(role)}', { name: '${escAttr(name)}' })`;
715
+ const levelPart = candidate.level != null ? `, level: ${candidate.level}` : '';
716
+ const roleLoc = `getByRole('${escAttr(role)}', { name: '${escAttr(name)}'${levelPart} })`;
617
717
  const textLoc = `getByText('${escAttr(name)}')`;
618
718
  const labelLoc = `getByLabel('${escAttr(name)}')`;
619
719
  // Same-style first: a broken getByText is re-suggested as getByText where viable.
@@ -642,14 +742,22 @@ function freshSuggestions(candidate, failedMethod) {
642
742
  function suggestLocatorsFromAria(failed, ariaSnapshot) {
643
743
  if (!ariaSnapshot || !NAME_BASED_METHODS.has(failed.method))
644
744
  return null;
645
- const { role, name } = failedNameAndRole(failed);
745
+ const { role, name, level } = failedNameAndRole(failed);
646
746
  if (!name)
647
747
  return null;
648
748
  const candidates = parseAriaRoleName(ariaSnapshot);
649
749
  if (candidates.length === 0)
650
750
  return null;
651
751
  const sameRole = role ? candidates.filter((c) => c.role === role) : [];
652
- const pool = sameRole.length > 0 ? sameRole : candidates;
752
+ let pool = sameRole.length > 0 ? sameRole : candidates;
753
+ // A targeted heading level narrows the pool further — a lone renamed h1
754
+ // among many h2s becomes the single confident candidate. Fall back to all
755
+ // same-role candidates when none share the level (it may have changed too).
756
+ if (level != null && sameRole.length > 0) {
757
+ const sameLevel = sameRole.filter((c) => c.level === level);
758
+ if (sameLevel.length > 0)
759
+ pool = sameLevel;
760
+ }
653
761
  // The targeted name is still on the page → not a rename, nothing to suggest.
654
762
  if (pool.some((c) => nameSimilarity(c.name, name) >= 0.8))
655
763
  return null;
@@ -666,7 +774,7 @@ function suggestLocatorsFromAria(failed, ariaSnapshot) {
666
774
  return null;
667
775
  if (bestScore < 0.2 && pool.length !== 1)
668
776
  return null;
669
- const suggestions = freshSuggestions({ role: best.role, name: best.name }, failed.method);
777
+ const suggestions = freshSuggestions({ role: best.role, name: best.name, level: best.level }, failed.method);
670
778
  if (suggestions.length === 0)
671
779
  return null;
672
780
  return { failing: renderFailing(failed), suggestions };
@@ -686,8 +794,7 @@ function suggestLocatorsFromAria(failed, ariaSnapshot) {
686
794
  * Returns null when no user frame can be identified — the snapshot keeps
687
795
  * `location: null` and the server falls back to fingerprint / ARIA lookup.
688
796
  */
689
- function captureCallerLocation() {
690
- const stack = new Error().stack ?? '';
797
+ function captureCallerLocation(stack = new Error().stack ?? '') {
691
798
  const lines = stack.split('\n');
692
799
  // The capture machinery's own frames sit at the top of the stack: this module
693
800
  // (locator-healing) then the single fixtures-proxy frame that called it. Skip
@@ -721,9 +828,10 @@ function captureCallerLocation() {
721
828
  prevWasCaptureModule = true;
722
829
  continue;
723
830
  }
724
- // The fixtures proxy that called us — skip only when it directly follows
725
- // this module, so a user's own `fixtures.*` deeper down is not dropped.
726
- if (prevWasCaptureModule && /[\\/]fixtures\.[a-z]+$/i.test(file)) {
831
+ // The fixtures proxy that called us — `capture-fixtures.*` in this
832
+ // package, `fixtures.*` in the dogfood mirror. Skip only when it directly
833
+ // follows this module, so a user's own `fixtures.*` deeper down is kept.
834
+ if (prevWasCaptureModule && /[\\/](?:capture-)?fixtures\.[a-z]+$/i.test(file)) {
727
835
  prevWasCaptureModule = false;
728
836
  continue;
729
837
  }
@@ -22,6 +22,7 @@ export declare const PIWI_ENV_KEYS: {
22
22
  readonly uploadTraces: "PIWI_UPLOAD_TRACES";
23
23
  readonly uploadReport: "PIWI_UPLOAD_REPORT";
24
24
  readonly captureLocators: "PIWI_CAPTURE_LOCATORS";
25
+ readonly capturePageState: "PIWI_CAPTURE_PAGE_STATE";
25
26
  };
26
27
  /**
27
28
  * Merge raw user options with defaults, reading from `PIWI_*` env vars when
@@ -16,6 +16,7 @@ const DEFAULTS = {
16
16
  collectCiInfo: true,
17
17
  collectPerformanceMetrics: true,
18
18
  captureLocators: true,
19
+ capturePageState: true,
19
20
  streaming: true,
20
21
  streamingBatchSize: 5,
21
22
  streamingBatchDelay: 2000,
@@ -47,6 +48,7 @@ exports.PIWI_ENV_KEYS = {
47
48
  uploadTraces: 'PIWI_UPLOAD_TRACES',
48
49
  uploadReport: 'PIWI_UPLOAD_REPORT',
49
50
  captureLocators: 'PIWI_CAPTURE_LOCATORS',
51
+ capturePageState: 'PIWI_CAPTURE_PAGE_STATE',
50
52
  };
51
53
  function readBool(val) {
52
54
  if (val === undefined)
@@ -83,6 +85,7 @@ const ENV_FALLBACK_SPECS = [
83
85
  { option: 'uploadTraces', env: exports.PIWI_ENV_KEYS.uploadTraces, kind: 'bool' },
84
86
  { option: 'uploadReport', env: exports.PIWI_ENV_KEYS.uploadReport, kind: 'bool' },
85
87
  { option: 'captureLocators', env: exports.PIWI_ENV_KEYS.captureLocators, kind: 'bool' },
88
+ { option: 'capturePageState', env: exports.PIWI_ENV_KEYS.capturePageState, kind: 'bool' },
86
89
  ];
87
90
  /**
88
91
  * Merge raw user options with defaults, reading from `PIWI_*` env vars when
@@ -150,4 +153,10 @@ function applyOptionsToEnv(options) {
150
153
  env[exports.PIWI_ENV_KEYS.captureLocators] = 'false';
151
154
  else if (options.captureLocators === true)
152
155
  env[exports.PIWI_ENV_KEYS.captureLocators] = 'true';
156
+ // Page-state capture follows the same bridge: off when either flag disables
157
+ // it, explicit true otherwise (unset keeps the fixture's default-on).
158
+ if (options.capturePageState === false || options.collectPerformanceMetrics === false)
159
+ env[exports.PIWI_ENV_KEYS.capturePageState] = 'false';
160
+ else if (options.capturePageState === true)
161
+ env[exports.PIWI_ENV_KEYS.capturePageState] = 'true';
153
162
  }
@@ -156,6 +156,15 @@ class FileHandler {
156
156
  const aria = find(attachments_js_1.ATTACHMENT_NAMES.ariaSnapshot);
157
157
  if (aria?.body)
158
158
  testCase.ariaSnapshot = aria.body.toString();
159
+ const pageState = find(attachments_js_1.ATTACHMENT_NAMES.pageState);
160
+ if (pageState?.body) {
161
+ try {
162
+ testCase.pageState = JSON.parse(pageState.body.toString());
163
+ }
164
+ catch {
165
+ /* ignore */
166
+ }
167
+ }
159
168
  }
160
169
  /** Compute SHA-256 hash and size for a single test case's trace file. Returns `null` when the case has no trace on disk. */
161
170
  async computeSingleTraceHash(testCase) {
@@ -57,6 +57,7 @@ function toWireTestCase(tc) {
57
57
  wastedTimeMs: rest.performanceMetrics?.waitTotalDuration ?? null,
58
58
  networkRequests: rest.networkRequests || null,
59
59
  webVitals: rest.webVitals || null,
60
+ pageState: rest.pageState || null,
60
61
  consoleLogs: rest.consoleLogs || null,
61
62
  ariaSnapshot: rest.ariaSnapshot || null,
62
63
  testSource: rest.testSource || null,
@@ -41,6 +41,14 @@ export interface PiwiDashboardOptions extends PlaywrightTestConfig {
41
41
  * in that case anyway). Can also be forced off with `PIWI_CAPTURE_LOCATORS=false`.
42
42
  */
43
43
  captureLocators?: boolean;
44
+ /**
45
+ * Capture the page's state at test end (URL, history state, localStorage/
46
+ * sessionStorage key names + value lengths, cookie names + flags). Values of
47
+ * storage entries and cookies are never captured. Defaults to `true`;
48
+ * automatically disabled when `collectPerformanceMetrics` is `false`. Can
49
+ * also be forced off with `PIWI_CAPTURE_PAGE_STATE=false`.
50
+ */
51
+ capturePageState?: boolean;
44
52
  /** Enable live streaming of results (falls back to batch if unsupported). Defaults to `true`. */
45
53
  streaming?: boolean;
46
54
  /** Number of test results to batch before sending during streaming. Defaults to `5`. */
@@ -81,6 +81,7 @@ export interface CollectedTestCase {
81
81
  networkRequests?: unknown;
82
82
  /** Parsed from `piwi-web-vitals` attachments. */
83
83
  webVitals?: unknown;
84
+ pageState?: unknown;
84
85
  /** Parsed from `piwi-console` attachments. */
85
86
  consoleLogs?: unknown;
86
87
  /** Parsed from `piwi-aria-snapshot` attachment. */
@@ -92,6 +92,7 @@ export interface WireTestCase {
92
92
  wastedTimeMs?: number | null;
93
93
  networkRequests?: unknown;
94
94
  webVitals?: unknown;
95
+ pageState?: unknown;
95
96
  consoleLogs?: unknown;
96
97
  ariaSnapshot?: unknown;
97
98
  testSource?: string | null;
@@ -136,6 +137,7 @@ export interface CompleteStreamEvent {
136
137
  slowestStepDuration?: number | null;
137
138
  networkRequests?: unknown;
138
139
  webVitals?: unknown;
140
+ pageState?: unknown;
139
141
  consoleLogs?: unknown;
140
142
  ariaSnapshot?: unknown;
141
143
  testSource?: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piwitests/reporter",
3
- "version": "0.9.1",
3
+ "version": "0.12.0",
4
4
  "description": "Playwright reporter for sending test results to Piwi Dashboard",
5
5
  "url": "https://github.com/PiwiTests/platform",
6
6
  "homepage": "https://piwitests.github.io",
@@ -19,7 +19,8 @@
19
19
  "types": "./dist/index.d.ts",
20
20
  "import": "./dist/index.js",
21
21
  "require": "./dist/index.js"
22
- }
22
+ },
23
+ "./package.json": "./package.json"
23
24
  },
24
25
  "keywords": [
25
26
  "playwright",