@piwitests/reporter 0.11.0 → 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.
@@ -12,6 +12,7 @@ export declare const ATTACHMENT_NAMES: {
12
12
  readonly network: "piwi-network";
13
13
  readonly webVitals: "piwi-web-vitals";
14
14
  readonly locatorSuggestion: "piwi-locator-suggestion";
15
+ readonly pageState: "piwi-page-state";
15
16
  };
16
17
  /** Set of every internal attachment name — used to skip them when collecting user attachments. */
17
18
  export declare const INTERNAL_ATTACHMENT_NAMES: ReadonlySet<string>;
@@ -15,6 +15,7 @@ exports.ATTACHMENT_NAMES = {
15
15
  network: 'piwi-network',
16
16
  webVitals: 'piwi-web-vitals',
17
17
  locatorSuggestion: 'piwi-locator-suggestion',
18
+ pageState: 'piwi-page-state',
18
19
  };
19
20
  /** Set of every internal attachment name — used to skip them when collecting user attachments. */
20
21
  exports.INTERNAL_ATTACHMENT_NAMES = new Set(Object.values(exports.ATTACHMENT_NAMES));
@@ -20,7 +20,108 @@ interface CapturedAttrs {
20
20
  name?: number;
21
21
  classes?: Record<string, number>;
22
22
  };
23
+ /** Position among same-role elements, document-wide (null when the element has no role). */
24
+ rolePosition: {
25
+ role: string;
26
+ count: number;
27
+ index: number;
28
+ levelCount?: number;
29
+ } | null;
30
+ /** Anchor-worthy ancestors, nearest first (empty when none found or probing failed). */
31
+ ancestors: Array<{
32
+ tag: string;
33
+ depth: number;
34
+ testId: string | null;
35
+ id: string | null;
36
+ role: string | null;
37
+ ariaLabel: string | null;
38
+ scopedRoleCount?: number;
39
+ testIdCount?: number;
40
+ idCount?: number;
41
+ roleCount?: number;
42
+ }>;
23
43
  }
44
+ /** Plain-object projection of a performance entry, shipped out of the page. */
45
+ export interface RawVitalEntry {
46
+ startTime?: number;
47
+ value?: number;
48
+ hadRecentInput?: boolean;
49
+ interactionId?: number;
50
+ duration?: number;
51
+ }
52
+ /**
53
+ * Aggregate buffered performance entries into LCP/CLS/INP. Pure and Node-side
54
+ * so it is unit-testable; the in-page evaluate only ships raw entry projections.
55
+ * A null entry list means the entry type is unsupported (non-Chromium) — the
56
+ * metric is null rather than 0 so absence is distinguishable from "no shifts".
57
+ */
58
+ export declare function computeCoreVitals(lcpEntries: RawVitalEntry[] | null, shiftEntries: RawVitalEntry[] | null, eventEntries: RawVitalEntry[] | null): {
59
+ lcp: number | null;
60
+ cls: number | null;
61
+ inp: number | null;
62
+ } | null;
63
+ /** Page state captured at test end. Storage values and cookie values are NEVER included. */
64
+ export interface PageState {
65
+ url: string;
66
+ hash: string | null;
67
+ /** `history.state` as JSON, capped and token-masked. */
68
+ historyState: string | null;
69
+ /** Key names + value lengths only. */
70
+ localStorage: Array<{
71
+ key: string;
72
+ length: number;
73
+ }>;
74
+ sessionStorage: Array<{
75
+ key: string;
76
+ length: number;
77
+ }>;
78
+ /** Cookie names + flags only (values are never read). */
79
+ cookies: Array<{
80
+ name: string;
81
+ domain: string;
82
+ path: string;
83
+ httpOnly: boolean;
84
+ secure: boolean;
85
+ sameSite?: string;
86
+ expires?: number;
87
+ }>;
88
+ }
89
+ /** Raw in-page reads shipped out of the evaluate (see `readPageState`). */
90
+ export interface RawPageState {
91
+ url: string;
92
+ hash: string | null;
93
+ historyState: string | null;
94
+ localStorage: Array<{
95
+ key: string;
96
+ length: number;
97
+ }>;
98
+ sessionStorage: Array<{
99
+ key: string;
100
+ length: number;
101
+ }>;
102
+ }
103
+ /**
104
+ * Assemble the wire page-state from the in-page reads and the context cookies.
105
+ * Pure and Node-side so the sanitization (token masking, caps, value-free
106
+ * cookies) is unit-testable.
107
+ */
108
+ export declare function buildPageState(raw: RawPageState, cookies: Array<Record<string, unknown>> | null): PageState;
109
+ /**
110
+ * Everything the in-page probe needs, serialized into the browser on every
111
+ * action. `tagRoles`/`inputRoles` are the shared role maps (single source of
112
+ * truth in `locator-healing.ts`), and `roleSources` is the CSS selector for
113
+ * every element the probe can resolve a role for — all derived from the map so
114
+ * nothing is hand-maintained twice. Exported so the dogfood mirror
115
+ * (`application/tests/fixtures.ts`) reuses the same assembled object.
116
+ */
117
+ export interface ProbeArg {
118
+ keep: string[];
119
+ tagRoles: Record<string, string>;
120
+ inputRoles: Record<string, string>;
121
+ roleSources: string;
122
+ }
123
+ /** Built once — passed verbatim into evaluate() on every action. */
124
+ export declare const CAPTURED_ATTRS_ARG: ProbeArg;
24
125
  /**
25
126
  * ARIA snapshot that tolerates every Playwright version the reporter supports,
26
127
  * returning null instead of throwing so a capture can never fail the test. The
@@ -43,7 +144,7 @@ export declare function ariaSnapshotBestEffort(target: Locator, timeout?: number
43
144
  * `el` is browser-context (no DOM lib in this Node package), hence `any`.
44
145
  * Exported for unit testing; still passed directly to `evaluate()` below.
45
146
  */
46
- export declare function probeElementAttrs(el: any, keep: string[]): CapturedAttrs;
147
+ export declare function probeElementAttrs(el: any, arg: ProbeArg): CapturedAttrs;
47
148
  /**
48
149
  * The fixtures `piwiFixtures` / `extendPiwiFixtures` contribute. The single
49
150
  * added fixture is `piwiCapture`: an auto, test-scoped teardown hook that
@@ -1,12 +1,55 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.piwiFixtures = void 0;
3
+ exports.piwiFixtures = exports.CAPTURED_ATTRS_ARG = void 0;
4
+ exports.computeCoreVitals = computeCoreVitals;
5
+ exports.buildPageState = buildPageState;
4
6
  exports.ariaSnapshotBestEffort = ariaSnapshotBestEffort;
5
7
  exports.probeElementAttrs = probeElementAttrs;
6
8
  exports.extendPiwiFixtures = extendPiwiFixtures;
7
9
  const node_zlib_1 = require("node:zlib");
8
10
  const locator_healing_js_1 = require("./locator-healing.js");
9
11
  const attachments_js_1 = require("./attachments.js");
12
+ /**
13
+ * Aggregate buffered performance entries into LCP/CLS/INP. Pure and Node-side
14
+ * so it is unit-testable; the in-page evaluate only ships raw entry projections.
15
+ * A null entry list means the entry type is unsupported (non-Chromium) — the
16
+ * metric is null rather than 0 so absence is distinguishable from "no shifts".
17
+ */
18
+ function computeCoreVitals(lcpEntries, shiftEntries, eventEntries) {
19
+ // The last LCP candidate is the final LCP.
20
+ const lastLcp = lcpEntries && lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : null;
21
+ const lcp = lastLcp && typeof lastLcp.startTime === 'number' ? Math.round(lastLcp.startTime) : null;
22
+ // Simple sum over shifts without recent input. The spec's session-window
23
+ // grouping matters for long sessions; a test's page lifetime is short enough
24
+ // that the plain sum tracks it closely.
25
+ let cls = null;
26
+ if (shiftEntries) {
27
+ const sum = shiftEntries.reduce((acc, e) => acc + (e.hadRecentInput ? 0 : typeof e.value === 'number' ? e.value : 0), 0);
28
+ cls = Math.round(sum * 10000) / 10000;
29
+ }
30
+ // Worst interaction latency: max duration per interactionId, then the p98
31
+ // interaction when there are many (mirrors the INP definition, simplified).
32
+ let inp = null;
33
+ if (eventEntries && eventEntries.length > 0) {
34
+ const byInteraction = new Map();
35
+ for (const e of eventEntries) {
36
+ if (typeof e.interactionId !== 'number' || e.interactionId <= 0)
37
+ continue;
38
+ const duration = typeof e.duration === 'number' ? e.duration : 0;
39
+ const prev = byInteraction.get(e.interactionId) ?? 0;
40
+ if (duration > prev)
41
+ byInteraction.set(e.interactionId, duration);
42
+ }
43
+ const durations = [...byInteraction.values()].sort((a, b) => a - b);
44
+ if (durations.length > 0) {
45
+ const index = durations.length > 50 ? Math.floor(durations.length * 0.98) : durations.length - 1;
46
+ inp = Math.round(durations[Math.min(index, durations.length - 1)]);
47
+ }
48
+ }
49
+ if (lcp === null && cls === null && inp === null)
50
+ return null;
51
+ return { lcp, cls, inp };
52
+ }
10
53
  function createSink() {
11
54
  return {
12
55
  networkRequests: [],
@@ -18,6 +61,7 @@ function createSink() {
18
61
  lastActivePage: null,
19
62
  testInfo: null,
20
63
  stashedWebVitals: null,
64
+ stashedPageState: null,
21
65
  stashedAria: null,
22
66
  };
23
67
  }
@@ -64,12 +108,13 @@ function pageContext(page) {
64
108
  return null;
65
109
  }
66
110
  }
67
- /** Read navigation/paint timings from a page — null when unavailable or the page is gone. */
111
+ /** Read navigation/paint timings and core-vitals entries — null when unavailable or the page is gone. */
68
112
  async function readWebVitals(page) {
69
113
  try {
70
114
  // Runs in the browser, so the perf-entry reads stay `any` (no DOM lib);
71
- // the callback return type pins the result to WebVitals.
72
- return await page.evaluate(() => {
115
+ // the callback return type pins the result. Aggregation happens Node-side
116
+ // in computeCoreVitals so the in-page code stays a thin projection.
117
+ const probe = await page.evaluate(async () => {
73
118
  const navEntries = performance.getEntriesByType('navigation');
74
119
  const paintEntries = performance.getEntriesByType('paint');
75
120
  const nav = navEntries[0];
@@ -90,20 +135,165 @@ async function readWebVitals(page) {
90
135
  const key = entry.name.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
91
136
  paint[key] = Math.round(entry.startTime);
92
137
  }
93
- if (!navigation && Object.keys(paint).length === 0)
94
- return null;
95
- return { navigation, paint };
138
+ // Buffered-observer read of an entry type. Returns null when the type is
139
+ // unsupported (non-Chromium); [] when supported but nothing recorded.
140
+ // Buffered entries are dispatched in a queued task, so wait one macrotask
141
+ // before draining with takeRecords().
142
+ const readBuffered = (type, extra) => new Promise((resolve) => {
143
+ try {
144
+ const PO = globalThis.PerformanceObserver;
145
+ if (!PO || !(PO.supportedEntryTypes || []).includes(type))
146
+ return resolve(null);
147
+ const out = [];
148
+ const po = new PO((list) => out.push(...list.getEntries()));
149
+ po.observe({ type, buffered: true, ...extra });
150
+ setTimeout(() => {
151
+ try {
152
+ out.push(...po.takeRecords());
153
+ po.disconnect();
154
+ }
155
+ catch {
156
+ // Entries gathered so far still count.
157
+ }
158
+ resolve(out);
159
+ }, 0);
160
+ }
161
+ catch {
162
+ resolve(null);
163
+ }
164
+ });
165
+ const [lcpRaw, shiftRaw, eventRaw, firstInputRaw] = await Promise.all([
166
+ readBuffered('largest-contentful-paint'),
167
+ readBuffered('layout-shift'),
168
+ // durationThreshold 40 mirrors the web-vitals library — captures every
169
+ // interaction slow enough to matter without flooding the buffer.
170
+ readBuffered('event', { durationThreshold: 40 }),
171
+ readBuffered('first-input'),
172
+ ]);
173
+ const project = (entries) => entries === null
174
+ ? null
175
+ : entries.map((e) => ({
176
+ startTime: e.startTime,
177
+ value: e.value,
178
+ hadRecentInput: e.hadRecentInput,
179
+ interactionId: e.interactionId,
180
+ duration: e.duration,
181
+ }));
182
+ const interactionEntries = eventRaw === null && firstInputRaw === null ? null : [...(eventRaw ?? []), ...(firstInputRaw ?? [])];
183
+ return {
184
+ navigation,
185
+ paint,
186
+ lcpEntries: project(lcpRaw),
187
+ shiftEntries: project(shiftRaw),
188
+ eventEntries: project(interactionEntries),
189
+ };
190
+ });
191
+ if (!probe)
192
+ return null;
193
+ const vitals = computeCoreVitals(probe.lcpEntries, probe.shiftEntries, probe.eventEntries);
194
+ if (!probe.navigation && Object.keys(probe.paint).length === 0 && !vitals)
195
+ return null;
196
+ return { navigation: probe.navigation, paint: probe.paint, vitals };
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ }
202
+ const PAGE_STATE_MAX_STORAGE_KEYS = 50;
203
+ const PAGE_STATE_MAX_COOKIES = 30;
204
+ const PAGE_STATE_HISTORY_CAP = 2048;
205
+ const TOKEN_MASK_RES = [/\beyJ[\w-]{10,}\.[\w-]{5,}\.[\w-]{5,}\b/g, /\b[0-9a-f]{32,}\b/gi];
206
+ /**
207
+ * Assemble the wire page-state from the in-page reads and the context cookies.
208
+ * Pure and Node-side so the sanitization (token masking, caps, value-free
209
+ * cookies) is unit-testable.
210
+ */
211
+ function buildPageState(raw, cookies) {
212
+ let historyState = raw.historyState;
213
+ if (historyState) {
214
+ for (const re of TOKEN_MASK_RES)
215
+ historyState = historyState.replace(re, '[masked]');
216
+ if (historyState.length > PAGE_STATE_HISTORY_CAP) {
217
+ historyState = historyState.slice(0, PAGE_STATE_HISTORY_CAP) + '…';
218
+ }
219
+ }
220
+ const capStorage = (entries) => (Array.isArray(entries) ? entries : []).slice(0, PAGE_STATE_MAX_STORAGE_KEYS).map((e) => ({
221
+ key: String(e.key).slice(0, 200),
222
+ length: typeof e.length === 'number' ? e.length : 0,
223
+ }));
224
+ return {
225
+ url: raw.url,
226
+ hash: raw.hash || null,
227
+ historyState: historyState || null,
228
+ localStorage: capStorage(raw.localStorage),
229
+ sessionStorage: capStorage(raw.sessionStorage),
230
+ cookies: (cookies ?? []).slice(0, PAGE_STATE_MAX_COOKIES).map((c) => ({
231
+ name: String(c.name ?? ''),
232
+ domain: String(c.domain ?? ''),
233
+ path: String(c.path ?? ''),
234
+ httpOnly: Boolean(c.httpOnly),
235
+ secure: Boolean(c.secure),
236
+ ...(c.sameSite !== undefined ? { sameSite: String(c.sameSite) } : {}),
237
+ ...(typeof c.expires === 'number' ? { expires: c.expires } : {}),
238
+ })),
239
+ };
240
+ }
241
+ /** Read the page's state — null when unavailable or the page is gone. */
242
+ async function readPageState(page) {
243
+ try {
244
+ const raw = await page.evaluate(() => {
245
+ // Key names + value lengths only — values never leave the page.
246
+ const listStorage = (s) => {
247
+ const out = [];
248
+ try {
249
+ for (let i = 0; i < s.length; i++) {
250
+ const key = s.key(i);
251
+ if (key != null)
252
+ out.push({ key, length: (s.getItem(key) ?? '').length });
253
+ }
254
+ }
255
+ catch {
256
+ // Storage access can throw in sandboxed/opaque-origin pages.
257
+ }
258
+ return out;
259
+ };
260
+ const g = globalThis;
261
+ let historyState = null;
262
+ try {
263
+ historyState = g.history?.state == null ? null : JSON.stringify(g.history.state);
264
+ }
265
+ catch {
266
+ // Unserializable history state.
267
+ }
268
+ return {
269
+ url: g.location.href,
270
+ hash: g.location.hash || null,
271
+ historyState,
272
+ localStorage: listStorage(globalThis.localStorage),
273
+ sessionStorage: listStorage(globalThis.sessionStorage),
274
+ };
96
275
  });
276
+ if (!raw)
277
+ return null;
278
+ // Cookie flags are only reachable from the context API, never document.cookie.
279
+ let cookies = null;
280
+ try {
281
+ cookies = (await pageContext(page)?.cookies()) ?? null;
282
+ }
283
+ catch {
284
+ cookies = null;
285
+ }
286
+ return buildPageState(raw, cookies);
97
287
  }
98
288
  catch {
99
289
  return null;
100
290
  }
101
291
  }
102
292
  /**
103
- * Take the page-dependent teardown reads (web vitals; ARIA snapshot when the
104
- * test failed) while the last active page is still open. Called by the close
105
- * wrappers just before a close that would take that page with it — flushSink
106
- * runs too late for a live read on the standard test page.
293
+ * Take the page-dependent teardown reads (web vitals; page state; ARIA
294
+ * snapshot when the test failed) while the last active page is still open.
295
+ * Called by the close wrappers just before a close that would take that page
296
+ * with it — flushSink runs too late for a live read on the standard test page.
107
297
  */
108
298
  async function stashPageState(sink, closing) {
109
299
  const page = sink.lastActivePage;
@@ -115,6 +305,11 @@ async function stashPageState(sink, closing) {
115
305
  const vitals = await readWebVitals(page);
116
306
  if (vitals)
117
307
  sink.stashedWebVitals = vitals;
308
+ if (process.env.PIWI_CAPTURE_PAGE_STATE !== 'false') {
309
+ const pageState = await readPageState(page);
310
+ if (pageState)
311
+ sink.stashedPageState = pageState;
312
+ }
118
313
  const status = sink.testInfo?.status;
119
314
  if (status === 'failed' || status === 'timedOut' || status === 'interrupted') {
120
315
  const aria = await ariaSnapshotBestEffort(page.locator(':root'), 1000);
@@ -134,9 +329,15 @@ const PATCHED_BROWSERS = new WeakSet();
134
329
  const CHAIN_METHOD_SET = new Set(locator_healing_js_1.CHAIN_METHODS);
135
330
  const ACTION_METHOD_SET = new Set(locator_healing_js_1.ACTION_METHODS);
136
331
  const FORM_FIELD_TAGS = new Set(['input', 'select', 'textarea']);
137
- // CAPTURED_ATTRIBUTES is passed verbatim into evaluate() on every action — copy
138
- // it once instead of spreading a fresh array per call.
139
- const CAPTURED_ATTRS_ARG = [...locator_healing_js_1.CAPTURED_ATTRIBUTES];
332
+ /** Built once passed verbatim into evaluate() on every action. */
333
+ exports.CAPTURED_ATTRS_ARG = {
334
+ keep: [...locator_healing_js_1.CAPTURED_ATTRIBUTES],
335
+ tagRoles: locator_healing_js_1.TAG_TO_ROLE,
336
+ inputRoles: locator_healing_js_1.INPUT_TYPE_TO_ROLE,
337
+ // '[role]' plus every tag the maps can resolve (input/select are handled by
338
+ // special-cased logic in the probe, so add them explicitly).
339
+ roleSources: [...new Set(['[role]', 'input', 'select', ...Object.keys(locator_healing_js_1.TAG_TO_ROLE)])].join(','),
340
+ };
140
341
  /**
141
342
  * ARIA snapshot that tolerates every Playwright version the reporter supports,
142
343
  * returning null instead of throwing so a capture can never fail the test. The
@@ -181,7 +382,8 @@ async function ariaSnapshotBestEffort(target, timeout) {
181
382
  * `el` is browser-context (no DOM lib in this Node package), hence `any`.
182
383
  * Exported for unit testing; still passed directly to `evaluate()` below.
183
384
  */
184
- function probeElementAttrs(el, keep) {
385
+ function probeElementAttrs(el, arg) {
386
+ const { keep, tagRoles, inputRoles, roleSources } = arg;
185
387
  const attrMap = {};
186
388
  for (const key of keep) {
187
389
  const v = el.getAttribute(key) ?? el[key];
@@ -228,6 +430,136 @@ function probeElementAttrs(el, keep) {
228
430
  catch {
229
431
  // Uniqueness probing is best-effort — never fail the capture.
230
432
  }
433
+ // Structural probe: the element's position among same-role elements plus
434
+ // anchor-worthy ancestors — powers name-free and ancestor-scoped
435
+ // alternatives that survive accessible-name renames. Role resolution reuses
436
+ // the shared maps passed in via `arg` (see roleOf below).
437
+ let rolePosition = null;
438
+ const ancestors = [];
439
+ try {
440
+ const doc = el.ownerDocument;
441
+ const cssEsc = (s) => doc.defaultView.CSS.escape(s);
442
+ const count = (sel) => {
443
+ try {
444
+ return doc.querySelectorAll(sel).length;
445
+ }
446
+ catch {
447
+ return undefined;
448
+ }
449
+ };
450
+ // Role resolution on live DOM nodes, mirroring the Node-side resolveAriaRole
451
+ // branching. The role maps are passed in (arg.tagRoles/inputRoles) so this
452
+ // serialized-into-page function shares the single source of truth in
453
+ // locator-healing.ts rather than re-declaring it.
454
+ const roleOf = (n) => {
455
+ const explicit = n.getAttribute('role');
456
+ if (explicit)
457
+ return explicit;
458
+ const tag = (n.tagName || '').toLowerCase();
459
+ if (tag === 'input')
460
+ return inputRoles[(n.getAttribute('type') || 'text').toLowerCase()] ?? 'textbox';
461
+ if (tag === 'select')
462
+ return n.getAttribute('multiple') != null ? 'listbox' : 'combobox';
463
+ if (tag === 'a')
464
+ return n.getAttribute('href') != null ? 'link' : null;
465
+ return tagRoles[tag] ?? null;
466
+ };
467
+ const levelOf = (n) => {
468
+ const m = /^h([1-6])$/.exec((n.tagName || '').toLowerCase());
469
+ if (m)
470
+ return Number(m[1]);
471
+ const al = n.getAttribute('aria-level');
472
+ return al && /^\d+$/.test(al) ? Number(al) : null;
473
+ };
474
+ const targetRole = roleOf(el);
475
+ const targetLevel = targetRole === 'heading' ? levelOf(el) : null;
476
+ if (targetRole) {
477
+ const nodes = doc.querySelectorAll(roleSources);
478
+ // A truncated scan would produce wrong counts/indexes — skip instead.
479
+ if (nodes.length <= 4000) {
480
+ let roleCountAll = 0;
481
+ let index = -1;
482
+ let levelCount = 0;
483
+ for (let i = 0; i < nodes.length; i++) {
484
+ const n = nodes[i];
485
+ if (roleOf(n) !== targetRole)
486
+ continue;
487
+ if (n === el)
488
+ index = roleCountAll;
489
+ roleCountAll++;
490
+ if (targetLevel != null && levelOf(n) === targetLevel)
491
+ levelCount++;
492
+ }
493
+ if (index !== -1) {
494
+ rolePosition = {
495
+ role: targetRole,
496
+ count: roleCountAll,
497
+ index,
498
+ ...(targetLevel != null ? { levelCount } : {}),
499
+ };
500
+ }
501
+ // Anchor-worthy ancestors: a stable hook (test id, id, explicit role,
502
+ // aria-label) or a container/landmark tag, nearest first. Counts are
503
+ // computed here so alternative generation never guesses uniqueness.
504
+ const CONTAINER_TAGS = ['form', 'nav', 'main', 'article', 'section', 'dialog', 'table'];
505
+ const docRoleCount = (role) => {
506
+ let c = 0;
507
+ for (let i = 0; i < nodes.length; i++)
508
+ if (roleOf(nodes[i]) === role)
509
+ c++;
510
+ return c;
511
+ };
512
+ let node = el.parentElement;
513
+ let depth = 0;
514
+ while (node && depth < 12 && ancestors.length < 4) {
515
+ depth++;
516
+ const tag = (node.tagName || '').toLowerCase();
517
+ if (tag === 'body' || tag === 'html')
518
+ break;
519
+ const testId = node.getAttribute('data-testid');
520
+ const id = node.getAttribute('id');
521
+ const explicitRole = node.getAttribute('role');
522
+ const ariaLabel = node.getAttribute('aria-label');
523
+ const anchorRole = explicitRole || (CONTAINER_TAGS.includes(tag) ? tagRoles[tag] : null) || null;
524
+ if (testId || id || anchorRole || ariaLabel) {
525
+ // The leaf-role match count within this ancestor (level-scoped for
526
+ // headings — the emitted chained locator is level-scoped too).
527
+ const scoped = node.querySelectorAll(roleSources);
528
+ let scopedRoleCount = 0;
529
+ if (scoped.length <= 2000) {
530
+ for (let i = 0; i < scoped.length; i++) {
531
+ const n = scoped[i];
532
+ if (roleOf(n) !== targetRole)
533
+ continue;
534
+ if (targetLevel != null && levelOf(n) !== targetLevel)
535
+ continue;
536
+ scopedRoleCount++;
537
+ }
538
+ }
539
+ else {
540
+ scopedRoleCount = -1; // truncated — unusable
541
+ }
542
+ ancestors.push({
543
+ tag,
544
+ depth,
545
+ testId: testId || null,
546
+ id: id || null,
547
+ role: explicitRole || null,
548
+ ariaLabel: ariaLabel || null,
549
+ ...(scopedRoleCount >= 0 ? { scopedRoleCount } : {}),
550
+ ...(testId ? { testIdCount: count(`[data-testid=${JSON.stringify(testId)}]`) } : {}),
551
+ ...(id ? { idCount: count(`#${cssEsc(id)}`) } : {}),
552
+ ...(anchorRole ? { roleCount: docRoleCount(anchorRole) } : {}),
553
+ });
554
+ }
555
+ node = node.parentElement;
556
+ }
557
+ }
558
+ }
559
+ }
560
+ catch {
561
+ // Structural probing is best-effort — never fail the capture.
562
+ }
231
563
  return {
232
564
  tagName: el.tagName?.toLowerCase?.() ?? 'unknown',
233
565
  attributes: attrMap,
@@ -240,6 +572,8 @@ function probeElementAttrs(el, keep) {
240
572
  },
241
573
  hasLabel: !!(el.labels && el.labels.length > 0),
242
574
  selectorCounts,
575
+ rolePosition,
576
+ ancestors,
243
577
  };
244
578
  }
245
579
  // Chain methods that take args and define a new locator scope (not just narrow).
@@ -308,7 +642,7 @@ function wrapLocator(page, locator, originMethod, originArgs) {
308
642
  // capturePromises so flushSink outwaits it — even when the deadline
309
643
  // abandons it. An evaluate still in flight when its page closes crashes
310
644
  // the connection dispatcher with a global "not bound" error.
311
- const probe = target.evaluate(probeElementAttrs, CAPTURED_ATTRS_ARG);
645
+ const probe = target.evaluate(probeElementAttrs, exports.CAPTURED_ATTRS_ARG);
312
646
  const settledProbe = probe.then(() => undefined, () => undefined);
313
647
  PENDING_PROBES.add(settledProbe);
314
648
  settledProbe.then(() => PENDING_PROBES.delete(settledProbe));
@@ -338,13 +672,17 @@ function wrapLocator(page, locator, originMethod, originArgs) {
338
672
  location: callerLocation,
339
673
  used,
340
674
  // hasLabel/selectorCounts inform alternative generation only —
341
- // keep the stored element to the wire shape.
675
+ // keep the stored element to the wire shape. rolePosition and
676
+ // ancestors ARE wire fields: the server's renamed-element match
677
+ // uses them at heal time.
342
678
  element: {
343
679
  tagName: attrs.tagName,
344
680
  attributes: attrs.attributes,
345
681
  textContent: attrs.textContent,
346
682
  accessibleName,
347
683
  center: attrs.center,
684
+ ...(attrs.rolePosition ? { rolePosition: attrs.rolePosition } : {}),
685
+ ...(attrs.ancestors && attrs.ancestors.length > 0 ? { ancestors: attrs.ancestors } : {}),
348
686
  },
349
687
  alternatives: (0, locator_healing_js_1.generateAlternatives)({ ...attrs, accessibleName }),
350
688
  };
@@ -626,6 +964,16 @@ async function flushSink(sink, testInfo) {
626
964
  body: Buffer.from(JSON.stringify(webVitals)),
627
965
  });
628
966
  }
967
+ // Page state at test end (pass AND fail — the pass side is the diff baseline).
968
+ if (process.env.PIWI_CAPTURE_PAGE_STATE !== 'false') {
969
+ const pageState = (pageReadable ? await readPageState(page) : null) ?? sink.stashedPageState;
970
+ if (pageState) {
971
+ await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.pageState, {
972
+ contentType: 'application/json',
973
+ body: Buffer.from(JSON.stringify(pageState)),
974
+ });
975
+ }
976
+ }
629
977
  }
630
978
  /**
631
979
  * Playwright fixtures that collect network requests, console entries,
@@ -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
  }
@@ -93,6 +135,16 @@ export declare const LOCATOR_CREATING_CHAINS: ReadonlySet<string>;
93
135
  * the same attribute set.
94
136
  */
95
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>;
96
148
  /**
97
149
  * Resolve the ARIA role for an element. An explicit `role` attribute wins;
98
150
  * otherwise the implicit role is derived from the tag name (and `type` for
@@ -101,6 +153,12 @@ export declare const CAPTURED_ATTRIBUTES: string[];
101
153
  * such elements and other alternatives take over.
102
154
  */
103
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;
104
162
  /**
105
163
  * Build a ranked list of alternative locators from the captured element
106
164
  * attributes. The list is sorted descending by stability score.
@@ -161,6 +219,7 @@ export interface LocatorSuggestion {
161
219
  export declare function parseAriaRoleName(ariaSnapshot: string): Array<{
162
220
  role: string;
163
221
  name: string | null;
222
+ level: number | null;
164
223
  }>;
165
224
  /**
166
225
  * Token-set (Dice) similarity, 0-1, case- and punctuation-insensitive.
@@ -196,4 +255,4 @@ export declare function suggestLocatorsFromAria(failed: FailedLocatorInfo, ariaS
196
255
  * Returns null when no user frame can be identified — the snapshot keeps
197
256
  * `location: null` and the server falls back to fingerprint / ARIA lookup.
198
257
  */
199
- 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;
@@ -149,6 +150,9 @@ exports.CAPTURED_ATTRIBUTES = [
149
150
  'alt',
150
151
  'title',
151
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',
152
156
  'role',
153
157
  'type',
154
158
  'href',
@@ -159,8 +163,14 @@ exports.CAPTURED_ATTRIBUTES = [
159
163
  'multiple',
160
164
  ];
161
165
  // ── ARIA role resolution ─────────────────────────────────────────────────────
162
- /** Implicit ARIA role for an HTML tag (when no explicit `role` is set). */
163
- 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 = {
164
174
  a: 'link',
165
175
  button: 'button',
166
176
  nav: 'navigation',
@@ -191,8 +201,8 @@ const TAG_TO_ROLE = {
191
201
  summary: 'button',
192
202
  search: 'search',
193
203
  };
194
- /** Implicit ARIA role for an `<input>` keyed by its `type` attribute. */
195
- 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 = {
196
206
  button: 'button',
197
207
  submit: 'button',
198
208
  reset: 'button',
@@ -224,7 +234,7 @@ function resolveAriaRole(attrs) {
224
234
  return null;
225
235
  if (tag === 'input') {
226
236
  const type = (attrs.attributes['type'] ?? 'text').toLowerCase();
227
- return INPUT_TYPE_TO_ROLE[type] ?? 'textbox';
237
+ return exports.INPUT_TYPE_TO_ROLE[type] ?? 'textbox';
228
238
  }
229
239
  // A plain <select> is a combobox; only with `multiple` (or size > 1, not
230
240
  // captured) does it become a listbox. Matches the server's implicitRoleForTag.
@@ -234,7 +244,23 @@ function resolveAriaRole(attrs) {
234
244
  if (tag === 'a') {
235
245
  return attrs.attributes['href'] != null ? 'link' : null;
236
246
  }
237
- 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;
238
264
  }
239
265
  // ── Alternative generation ───────────────────────────────────────────────────
240
266
  const attr = (a, key) => a.attributes[key] || null;
@@ -280,12 +306,17 @@ function generateAlternatives(attrs) {
280
306
  score: 100,
281
307
  });
282
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;
283
314
  // 2. role + accessible name from browser ARIA tree (85-95)
284
315
  if (role && accessibleName) {
285
316
  add({
286
- locator: `getByRole('${role}', { name: '${esc(accessibleName)}' })`,
317
+ locator: `getByRole('${role}', { name: '${esc(accessibleName)}'${levelPart} })`,
287
318
  method: 'getByRole',
288
- args: { role, name: accessibleName },
319
+ args: withLevel({ role, name: accessibleName }),
289
320
  score: 90,
290
321
  });
291
322
  }
@@ -293,9 +324,9 @@ function generateAlternatives(attrs) {
293
324
  const ariaLabel = attr(attrs, 'aria-label');
294
325
  if (role && ariaLabel && ariaLabel !== accessibleName) {
295
326
  add({
296
- locator: `getByRole('${role}', { name: '${esc(ariaLabel)}' })`,
327
+ locator: `getByRole('${role}', { name: '${esc(ariaLabel)}'${levelPart} })`,
297
328
  method: 'getByRole',
298
- args: { role, name: ariaLabel },
329
+ args: withLevel({ role, name: ariaLabel }),
299
330
  score: 85,
300
331
  });
301
332
  }
@@ -379,6 +410,67 @@ function generateAlternatives(attrs) {
379
410
  score: 50,
380
411
  });
381
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
+ }
382
474
  // 11. CSS class-based locators — capped at 3 most stable classes; classes
383
475
  // the uniqueness probe saw on more than one element are dropped outright.
384
476
  const clsStr = attr(attrs, 'class');
@@ -538,7 +630,12 @@ function parseAriaRoleName(ariaSnapshot) {
538
630
  const name = m[2] == null ? null : m[2].replace(/\\(.)/g, '$1');
539
631
  if (!name && (role === 'generic' || role === 'group' || role === 'list' || role === 'paragraph'))
540
632
  continue;
541
- 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 });
542
639
  }
543
640
  return out;
544
641
  }
@@ -584,16 +681,17 @@ const SUGG_TEXT_ROLES = new Set([
584
681
  'switch',
585
682
  ]);
586
683
  const SUGG_FIELD_ROLES = new Set(['textbox', 'combobox', 'searchbox', 'spinbutton', 'slider']);
587
- /** 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. */
588
685
  function failedNameAndRole(failed) {
589
686
  if (failed.method === 'getByRole') {
590
687
  const role = typeof failed.args[0] === 'string' ? failed.args[0] : null;
591
688
  const opts = failed.args[1];
592
689
  const name = opts && typeof opts.name === 'string' ? opts.name : null;
593
- return { role, name };
690
+ const level = opts && typeof opts.level === 'number' ? opts.level : null;
691
+ return { role, name, level };
594
692
  }
595
693
  const first = failed.args.find((a) => typeof a === 'string');
596
- return { role: null, name: typeof first === 'string' ? first : null };
694
+ return { role: null, name: typeof first === 'string' ? first : null, level: null };
597
695
  }
598
696
  /** Render the failed locator back to source for the annotation message. */
599
697
  function renderFailing(failed) {
@@ -614,7 +712,8 @@ function freshSuggestions(candidate, failedMethod) {
614
712
  if (!out.includes(s))
615
713
  out.push(s);
616
714
  };
617
- 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} })`;
618
717
  const textLoc = `getByText('${escAttr(name)}')`;
619
718
  const labelLoc = `getByLabel('${escAttr(name)}')`;
620
719
  // Same-style first: a broken getByText is re-suggested as getByText where viable.
@@ -643,14 +742,22 @@ function freshSuggestions(candidate, failedMethod) {
643
742
  function suggestLocatorsFromAria(failed, ariaSnapshot) {
644
743
  if (!ariaSnapshot || !NAME_BASED_METHODS.has(failed.method))
645
744
  return null;
646
- const { role, name } = failedNameAndRole(failed);
745
+ const { role, name, level } = failedNameAndRole(failed);
647
746
  if (!name)
648
747
  return null;
649
748
  const candidates = parseAriaRoleName(ariaSnapshot);
650
749
  if (candidates.length === 0)
651
750
  return null;
652
751
  const sameRole = role ? candidates.filter((c) => c.role === role) : [];
653
- 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
+ }
654
761
  // The targeted name is still on the page → not a rename, nothing to suggest.
655
762
  if (pool.some((c) => nameSimilarity(c.name, name) >= 0.8))
656
763
  return null;
@@ -667,7 +774,7 @@ function suggestLocatorsFromAria(failed, ariaSnapshot) {
667
774
  return null;
668
775
  if (bestScore < 0.2 && pool.length !== 1)
669
776
  return null;
670
- 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);
671
778
  if (suggestions.length === 0)
672
779
  return null;
673
780
  return { failing: renderFailing(failed), suggestions };
@@ -687,8 +794,7 @@ function suggestLocatorsFromAria(failed, ariaSnapshot) {
687
794
  * Returns null when no user frame can be identified — the snapshot keeps
688
795
  * `location: null` and the server falls back to fingerprint / ARIA lookup.
689
796
  */
690
- function captureCallerLocation() {
691
- const stack = new Error().stack ?? '';
797
+ function captureCallerLocation(stack = new Error().stack ?? '') {
692
798
  const lines = stack.split('\n');
693
799
  // The capture machinery's own frames sit at the top of the stack: this module
694
800
  // (locator-healing) then the single fixtures-proxy frame that called it. Skip
@@ -722,9 +828,10 @@ function captureCallerLocation() {
722
828
  prevWasCaptureModule = true;
723
829
  continue;
724
830
  }
725
- // The fixtures proxy that called us — skip only when it directly follows
726
- // this module, so a user's own `fixtures.*` deeper down is not dropped.
727
- 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)) {
728
835
  prevWasCaptureModule = false;
729
836
  continue;
730
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.11.0",
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",