@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.
@@ -1,12 +1,55 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.dashboardFixtures = 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
- exports.extendDashboardFixtures = extendDashboardFixtures;
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: [],
@@ -16,6 +59,10 @@ function createSink() {
16
59
  capturePromises: [],
17
60
  failedLocators: [],
18
61
  lastActivePage: null,
62
+ testInfo: null,
63
+ stashedWebVitals: null,
64
+ stashedPageState: null,
65
+ stashedAria: null,
19
66
  };
20
67
  }
21
68
  /**
@@ -25,6 +72,251 @@ function createSink() {
25
72
  * test (auth setup, teardown) is intentionally not captured.
26
73
  */
27
74
  let currentSink = null;
75
+ /**
76
+ * Element probes whose protocol call is still in flight. Closing a page,
77
+ * context, or browser while a probe is mid-flight makes Playwright's
78
+ * connection dispatcher throw a global "Object with guid handle@… was not
79
+ * bound in the connection" error, which fails whichever test happens to be
80
+ * running. The close wrappers drain this set (bounded) before closing.
81
+ */
82
+ const PENDING_PROBES = new Set();
83
+ async function drainPendingProbes(capMs) {
84
+ if (PENDING_PROBES.size === 0)
85
+ return;
86
+ let cap;
87
+ await Promise.race([
88
+ Promise.allSettled(PENDING_PROBES),
89
+ new Promise((resolve) => {
90
+ cap = setTimeout(resolve, capMs);
91
+ }),
92
+ ]);
93
+ clearTimeout(cap);
94
+ }
95
+ function isPageClosed(page) {
96
+ try {
97
+ return typeof page.isClosed === 'function' ? page.isClosed() : false;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ function pageContext(page) {
104
+ try {
105
+ return typeof page.context === 'function' ? page.context() : null;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ /** Read navigation/paint timings and core-vitals entries — null when unavailable or the page is gone. */
112
+ async function readWebVitals(page) {
113
+ try {
114
+ // Runs in the browser, so the perf-entry reads stay `any` (no DOM lib);
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 () => {
118
+ const navEntries = performance.getEntriesByType('navigation');
119
+ const paintEntries = performance.getEntriesByType('paint');
120
+ const nav = navEntries[0];
121
+ const navigation = nav
122
+ ? {
123
+ url: nav.name,
124
+ ttfb: Math.round(nav.responseStart - nav.fetchStart),
125
+ domInteractive: Math.round(nav.domInteractive - nav.fetchStart),
126
+ domContentLoaded: Math.round(nav.domContentLoadedEventEnd - nav.fetchStart),
127
+ loadComplete: Math.round(nav.loadEventEnd - nav.fetchStart),
128
+ transferSize: nav.transferSize || 0,
129
+ encodedBodySize: nav.encodedBodySize || 0,
130
+ decodedBodySize: nav.decodedBodySize || 0,
131
+ }
132
+ : null;
133
+ const paint = {};
134
+ for (const entry of paintEntries) {
135
+ const key = entry.name.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
136
+ paint[key] = Math.round(entry.startTime);
137
+ }
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
+ };
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);
287
+ }
288
+ catch {
289
+ return null;
290
+ }
291
+ }
292
+ /**
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.
297
+ */
298
+ async function stashPageState(sink, closing) {
299
+ const page = sink.lastActivePage;
300
+ if (!page || isPageClosed(page))
301
+ return;
302
+ const belongsToClosing = closing.page === page || (closing.context !== undefined && pageContext(page) === closing.context);
303
+ if (!belongsToClosing)
304
+ return;
305
+ const vitals = await readWebVitals(page);
306
+ if (vitals)
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
+ }
313
+ const status = sink.testInfo?.status;
314
+ if (status === 'failed' || status === 'timedOut' || status === 'interrupted') {
315
+ const aria = await ariaSnapshotBestEffort(page.locator(':root'), 1000);
316
+ if (aria)
317
+ sink.stashedAria = aria;
318
+ }
319
+ }
28
320
  // Idempotency guards: a page/context/browser can be reached through several
29
321
  // paths (browser patch, context patch, the `page` fixture, popup events), and
30
322
  // must be wrapped exactly once.
@@ -37,9 +329,15 @@ const PATCHED_BROWSERS = new WeakSet();
37
329
  const CHAIN_METHOD_SET = new Set(locator_healing_js_1.CHAIN_METHODS);
38
330
  const ACTION_METHOD_SET = new Set(locator_healing_js_1.ACTION_METHODS);
39
331
  const FORM_FIELD_TAGS = new Set(['input', 'select', 'textarea']);
40
- // CAPTURED_ATTRIBUTES is passed verbatim into evaluate() on every action — copy
41
- // it once instead of spreading a fresh array per call.
42
- 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
+ };
43
341
  /**
44
342
  * ARIA snapshot that tolerates every Playwright version the reporter supports,
45
343
  * returning null instead of throwing so a capture can never fail the test. The
@@ -84,7 +382,8 @@ async function ariaSnapshotBestEffort(target, timeout) {
84
382
  * `el` is browser-context (no DOM lib in this Node package), hence `any`.
85
383
  * Exported for unit testing; still passed directly to `evaluate()` below.
86
384
  */
87
- function probeElementAttrs(el, keep) {
385
+ function probeElementAttrs(el, arg) {
386
+ const { keep, tagRoles, inputRoles, roleSources } = arg;
88
387
  const attrMap = {};
89
388
  for (const key of keep) {
90
389
  const v = el.getAttribute(key) ?? el[key];
@@ -131,6 +430,136 @@ function probeElementAttrs(el, keep) {
131
430
  catch {
132
431
  // Uniqueness probing is best-effort — never fail the capture.
133
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
+ }
134
563
  return {
135
564
  tagName: el.tagName?.toLowerCase?.() ?? 'unknown',
136
565
  attributes: attrMap,
@@ -143,6 +572,8 @@ function probeElementAttrs(el, keep) {
143
572
  },
144
573
  hasLabel: !!(el.labels && el.labels.length > 0),
145
574
  selectorCounts,
575
+ rolePosition,
576
+ ancestors,
146
577
  };
147
578
  }
148
579
  // Chain methods that take args and define a new locator scope (not just narrow).
@@ -204,14 +635,23 @@ function wrapLocator(page, locator, originMethod, originArgs) {
204
635
  sink.failedLocators.push({ method: originMethod, args: originArgs });
205
636
  throw error;
206
637
  }
207
- // Fire-and-forget: capture element data without blocking the test.
208
- // evaluate() can hang when page navigates (element detaches), so
209
- // race it against a 500ms deadline and never throw.
638
+ // Fire-and-forget: capture element data without blocking the test. The
639
+ // snapshot wait below is bounded by a 500ms deadline (evaluate can hang
640
+ // when the page navigates), but the probe's underlying protocol call is
641
+ // tracked in PENDING_PROBES so the close wrappers can drain it — and in
642
+ // capturePromises so flushSink outwaits it — even when the deadline
643
+ // abandons it. An evaluate still in flight when its page closes crashes
644
+ // the connection dispatcher with a global "not bound" error.
645
+ const probe = target.evaluate(probeElementAttrs, exports.CAPTURED_ATTRS_ARG);
646
+ const settledProbe = probe.then(() => undefined, () => undefined);
647
+ PENDING_PROBES.add(settledProbe);
648
+ settledProbe.then(() => PENDING_PROBES.delete(settledProbe));
649
+ sink.capturePromises.push(settledProbe);
210
650
  const resolveAttrs = (async () => {
211
651
  let deadline;
212
652
  try {
213
653
  const attrs = await Promise.race([
214
- target.evaluate(probeElementAttrs, CAPTURED_ATTRS_ARG),
654
+ probe,
215
655
  new Promise((_, reject) => {
216
656
  deadline = setTimeout(() => reject(new Error('locator capture timeout')), 500);
217
657
  }),
@@ -232,13 +672,17 @@ function wrapLocator(page, locator, originMethod, originArgs) {
232
672
  location: callerLocation,
233
673
  used,
234
674
  // hasLabel/selectorCounts inform alternative generation only —
235
- // 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.
236
678
  element: {
237
679
  tagName: attrs.tagName,
238
680
  attributes: attrs.attributes,
239
681
  textContent: attrs.textContent,
240
682
  accessibleName,
241
683
  center: attrs.center,
684
+ ...(attrs.rolePosition ? { rolePosition: attrs.rolePosition } : {}),
685
+ ...(attrs.ancestors && attrs.ancestors.length > 0 ? { ancestors: attrs.ancestors } : {}),
242
686
  },
243
687
  alternatives: (0, locator_healing_js_1.generateAlternatives)({ ...attrs, accessibleName }),
244
688
  };
@@ -265,6 +709,24 @@ function instrumentPage(page) {
265
709
  if (!page || INSTRUMENTED_PAGES.has(page))
266
710
  return;
267
711
  INSTRUMENTED_PAGES.add(page);
712
+ // A page reached through the `page` fixture safety net may live in a context
713
+ // the browser patch never saw — instrument it so its close is wrapped too.
714
+ const ctx = pageContext(page);
715
+ if (ctx)
716
+ instrumentContext(ctx);
717
+ // Drain in-flight probes before a user-initiated close (the guard tolerates
718
+ // page-like test fakes without a close method), and preserve the
719
+ // page-dependent teardown reads while the page can still serve them.
720
+ if (typeof page.close === 'function') {
721
+ const originalClose = page.close.bind(page);
722
+ page.close = async (...args) => {
723
+ await drainPendingProbes(1000);
724
+ const sink = currentSink;
725
+ if (sink)
726
+ await stashPageState(sink, { page });
727
+ return originalClose(...args);
728
+ };
729
+ }
268
730
  // Opt-out: skipped when PIWI_CAPTURE_LOCATORS=false (set automatically when
269
731
  // the reporter's collectPerformanceMetrics / captureLocators is disabled),
270
732
  // so the per-action DOM read + ARIA snapshot cost is never paid when unused.
@@ -363,6 +825,21 @@ function instrumentContext(context) {
363
825
  instrumentPage(page);
364
826
  return page;
365
827
  };
828
+ // The built-in context fixture closes here at test teardown — BEFORE the
829
+ // auto capture fixture flushes. Drain in-flight probes (an evaluate crossing
830
+ // the close crashes the connection with a global "not bound" error) and take
831
+ // the page-dependent reads (web vitals, failure ARIA snapshot) while the
832
+ // test's page is still open.
833
+ if (typeof context.close === 'function') {
834
+ const originalClose = context.close.bind(context);
835
+ context.close = async (...args) => {
836
+ await drainPendingProbes(1000);
837
+ const sink = currentSink;
838
+ if (sink)
839
+ await stashPageState(sink, { context });
840
+ return originalClose(...args);
841
+ };
842
+ }
366
843
  // Popups and pages the context opens on its own (idempotent with the above).
367
844
  context.on('page', (page) => instrumentPage(page));
368
845
  }
@@ -389,6 +866,15 @@ function patchBrowser(browser) {
389
866
  instrumentContext(context);
390
867
  return context;
391
868
  };
869
+ // Worker shutdown closes the browser; a probe still in flight would crash
870
+ // the connection dispatcher.
871
+ if (typeof browser.close === 'function') {
872
+ const originalClose = browser.close.bind(browser);
873
+ browser.close = async (...args) => {
874
+ await drainPendingProbes(1000);
875
+ return originalClose(...args);
876
+ };
877
+ }
392
878
  }
393
879
  /**
394
880
  * Drain in-flight capture work and attach the collected `piwi-*` data
@@ -422,9 +908,13 @@ async function flushSink(sink, testInfo) {
422
908
  });
423
909
  }
424
910
  const page = sink.lastActivePage;
425
- if (page && testInfo.status !== 'passed' && testInfo.status !== 'skipped') {
911
+ const pageReadable = page !== null && !isPageClosed(page);
912
+ if (testInfo.status !== 'passed' && testInfo.status !== 'skipped') {
426
913
  try {
427
- const snapshot = await ariaSnapshotBestEffort(page.locator(':root'));
914
+ // Prefer a live read; fall back to the snapshot the close wrappers
915
+ // stashed — the standard test page is already closed when this auto
916
+ // fixture tears down.
917
+ const snapshot = (pageReadable ? await ariaSnapshotBestEffort(page.locator(':root')) : null) ?? sink.stashedAria;
428
918
  if (snapshot) {
429
919
  await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.ariaSnapshot, {
430
920
  contentType: 'text/plain',
@@ -465,44 +955,23 @@ async function flushSink(sink, testInfo) {
465
955
  body: Buffer.from(JSON.stringify(sink.networkRequests)),
466
956
  });
467
957
  }
468
- if (page) {
469
- try {
470
- // Runs in the browser, so the perf-entry reads stay `any` (no DOM lib);
471
- // the callback return type pins `webVitals` to WebVitals.
472
- const webVitals = await page.evaluate(() => {
473
- const navEntries = performance.getEntriesByType('navigation');
474
- const paintEntries = performance.getEntriesByType('paint');
475
- const nav = navEntries[0];
476
- const navigation = nav
477
- ? {
478
- url: nav.name,
479
- ttfb: Math.round(nav.responseStart - nav.fetchStart),
480
- domInteractive: Math.round(nav.domInteractive - nav.fetchStart),
481
- domContentLoaded: Math.round(nav.domContentLoadedEventEnd - nav.fetchStart),
482
- loadComplete: Math.round(nav.loadEventEnd - nav.fetchStart),
483
- transferSize: nav.transferSize || 0,
484
- encodedBodySize: nav.encodedBodySize || 0,
485
- decodedBodySize: nav.decodedBodySize || 0,
486
- }
487
- : null;
488
- const paint = {};
489
- for (const entry of paintEntries) {
490
- const key = entry.name.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
491
- paint[key] = Math.round(entry.startTime);
492
- }
493
- if (!navigation && Object.keys(paint).length === 0)
494
- return null;
495
- return { navigation, paint };
958
+ // Live read when the page still exists (e.g. a browser.newPage the test left
959
+ // open); otherwise the vitals the close wrappers stashed before the page went.
960
+ const webVitals = (pageReadable ? await readWebVitals(page) : null) ?? sink.stashedWebVitals;
961
+ if (webVitals) {
962
+ await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.webVitals, {
963
+ contentType: 'application/json',
964
+ body: Buffer.from(JSON.stringify(webVitals)),
965
+ });
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)),
496
974
  });
497
- if (webVitals) {
498
- await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.webVitals, {
499
- contentType: 'application/json',
500
- body: Buffer.from(JSON.stringify(webVitals)),
501
- });
502
- }
503
- }
504
- catch {
505
- /* ignore */
506
975
  }
507
976
  }
508
977
  }
@@ -515,7 +984,7 @@ async function flushSink(sink, testInfo) {
515
984
  * `browser.newContext()`. Collected data is attached as `piwi-*`
516
985
  * test-info attachments which the Piwi Dashboard reporter parses on `onTestEnd`.
517
986
  */
518
- exports.dashboardFixtures = {
987
+ exports.piwiFixtures = {
519
988
  // Worker-scoped: patch the shared browser so every page/context created from
520
989
  // it — including by user fixtures that take `browser` directly — is captured.
521
990
  browser: [
@@ -535,9 +1004,10 @@ exports.dashboardFixtures = {
535
1004
  // Auto, test-scoped: open a capture sink for the running test and flush it
536
1005
  // (attach the collected data) at teardown. Runs for every test without being
537
1006
  // requested, so suites that never destructure `page` are still captured.
538
- piwiDashboardCapture: [
1007
+ piwiCapture: [
539
1008
  async ({}, use, testInfo) => {
540
1009
  const sink = createSink();
1010
+ sink.testInfo = testInfo;
541
1011
  currentSink = sink;
542
1012
  try {
543
1013
  await use();
@@ -551,7 +1021,8 @@ exports.dashboardFixtures = {
551
1021
  ],
552
1022
  };
553
1023
  /**
554
- * Extend a Playwright `test` object with Piwi Dashboard fixtures.
1024
+ * Extend a Playwright `test` object with the Piwi capture fixtures. The
1025
+ * returned `test` carries the existing fixtures plus {@link PiwiFixtures}.
555
1026
  *
556
1027
  * Use this instead of importing `@playwright/test` directly from this package
557
1028
  * to avoid the "Requiring @playwright/test second time" error caused by
@@ -560,11 +1031,11 @@ exports.dashboardFixtures = {
560
1031
  * @example
561
1032
  * ```ts
562
1033
  * import { test as base } from '@playwright/test';
563
- * import { extendDashboardFixtures } from '@piwitests/reporter';
1034
+ * import { extendPiwiFixtures } from '@piwitests/reporter';
564
1035
  *
565
- * export const test = extendDashboardFixtures(base);
1036
+ * export const test = extendPiwiFixtures(base);
566
1037
  * ```
567
1038
  */
568
- function extendDashboardFixtures(test) {
569
- return test.extend(exports.dashboardFixtures);
1039
+ function extendPiwiFixtures(test) {
1040
+ return test.extend(exports.piwiFixtures);
570
1041
  }