@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.
- package/dist/internal/capture/attachments.d.ts +1 -0
- package/dist/internal/capture/attachments.js +1 -0
- package/dist/internal/capture/capture-fixtures.d.ts +102 -1
- package/dist/internal/capture/capture-fixtures.js +365 -17
- package/dist/internal/capture/locator-healing.d.ts +60 -1
- package/dist/internal/capture/locator-healing.js +131 -24
- 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 +1 -1
|
@@ -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,
|
|
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
|
|
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
|
|
72
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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;
|
|
104
|
-
* test failed) while the last active page is still open.
|
|
105
|
-
* wrappers just before a close that would take that page
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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,
|
|
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
|
-
/**
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 —
|
|
726
|
-
//
|
|
727
|
-
|
|
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,
|
package/dist/public/options.d.ts
CHANGED
|
@@ -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. */
|
package/dist/types/wire.d.ts
CHANGED
|
@@ -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