@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.
- package/README.md +33 -13
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -3
- package/dist/internal/capture/attachments.d.ts +3 -2
- package/dist/internal/capture/attachments.js +3 -2
- package/dist/internal/capture/capture-fixtures.d.ts +123 -7
- package/dist/internal/capture/capture-fixtures.js +528 -57
- package/dist/internal/capture/locator-healing.d.ts +63 -3
- package/dist/internal/capture/locator-healing.js +134 -26
- package/dist/internal/config/env.d.ts +1 -0
- package/dist/internal/config/env.js +9 -0
- package/dist/internal/files/file-handler.js +9 -0
- package/dist/internal/submit/serializer.js +1 -0
- package/dist/public/options.d.ts +8 -0
- package/dist/types/collected.d.ts +1 -0
- package/dist/types/wire.d.ts +2 -0
- package/package.json +3 -2
|
@@ -1,12 +1,55 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
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.
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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,
|
|
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
|
-
//
|
|
209
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
911
|
+
const pageReadable = page !== null && !isPageClosed(page);
|
|
912
|
+
if (testInfo.status !== 'passed' && testInfo.status !== 'skipped') {
|
|
426
913
|
try {
|
|
427
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 {
|
|
1034
|
+
* import { extendPiwiFixtures } from '@piwitests/reporter';
|
|
564
1035
|
*
|
|
565
|
-
* export const test =
|
|
1036
|
+
* export const test = extendPiwiFixtures(base);
|
|
566
1037
|
* ```
|
|
567
1038
|
*/
|
|
568
|
-
function
|
|
569
|
-
return test.extend(exports.
|
|
1039
|
+
function extendPiwiFixtures(test) {
|
|
1040
|
+
return test.extend(exports.piwiFixtures);
|
|
570
1041
|
}
|