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