@piwitests/reporter 0.12.0 → 0.13.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/global-setup-module.d.ts +2 -1
- package/dist/global-setup-module.js +432 -3
- package/dist/index.d.ts +204 -8
- package/dist/index.js +4724 -23
- package/dist/internal/capture/attachments.d.ts +8 -3
- package/dist/internal/capture/attachments.js +46 -21
- package/dist/internal/capture/capture-fixtures.d.ts +16 -14
- package/dist/internal/capture/capture-fixtures.js +2211 -971
- package/dist/internal/capture/inspect-on-failure.d.ts +50 -0
- package/dist/internal/capture/inspect-on-failure.js +66 -0
- package/dist/internal/capture/locator-healing.d.ts +33 -190
- package/dist/internal/capture/locator-healing.js +599 -815
- package/dist/internal/capture/pick-on-failure.d.ts +198 -0
- package/dist/internal/capture/pick-on-failure.js +1203 -0
- package/package.json +9 -4
- package/dist/internal/collect/error-text.d.ts +0 -18
- package/dist/internal/collect/error-text.js +0 -79
- package/dist/internal/collect/metadata-collector.d.ts +0 -32
- package/dist/internal/collect/metadata-collector.js +0 -246
- package/dist/internal/collect/skip-classify.d.ts +0 -27
- package/dist/internal/collect/skip-classify.js +0 -40
- package/dist/internal/collect/step-analyzer.d.ts +0 -103
- package/dist/internal/collect/step-analyzer.js +0 -221
- package/dist/internal/config/env.d.ts +0 -46
- package/dist/internal/config/env.js +0 -162
- package/dist/internal/files/compression.d.ts +0 -5
- package/dist/internal/files/compression.js +0 -69
- package/dist/internal/files/file-handler.d.ts +0 -38
- package/dist/internal/files/file-handler.js +0 -207
- package/dist/internal/streaming/crash-recovery.d.ts +0 -23
- package/dist/internal/streaming/crash-recovery.js +0 -106
- package/dist/internal/streaming/stream-buffer.d.ts +0 -17
- package/dist/internal/streaming/stream-buffer.js +0 -102
- package/dist/internal/streaming/stream-manager.d.ts +0 -88
- package/dist/internal/streaming/stream-manager.js +0 -395
- package/dist/internal/submit/run-submitter.d.ts +0 -67
- package/dist/internal/submit/run-submitter.js +0 -190
- package/dist/internal/submit/serializer.d.ts +0 -45
- package/dist/internal/submit/serializer.js +0 -108
- package/dist/internal/submit/uploader.d.ts +0 -89
- package/dist/internal/submit/uploader.js +0 -226
- package/dist/internal/support/ci.d.ts +0 -2
- package/dist/internal/support/ci.js +0 -32
- package/dist/internal/support/cli-filters.d.ts +0 -1
- package/dist/internal/support/cli-filters.js +0 -51
- package/dist/internal/support/errors.d.ts +0 -8
- package/dist/internal/support/errors.js +0 -15
- package/dist/internal/support/instance-id.d.ts +0 -4
- package/dist/internal/support/instance-id.js +0 -48
- package/dist/internal/support/limiter.d.ts +0 -2
- package/dist/internal/support/limiter.js +0 -27
- package/dist/internal/support/logger.d.ts +0 -26
- package/dist/internal/support/logger.js +0 -43
- package/dist/internal/support/reporter-version.d.ts +0 -2
- package/dist/internal/support/reporter-version.js +0 -54
- package/dist/internal/support/setup-file.d.ts +0 -13
- package/dist/internal/support/setup-file.js +0 -61
- package/dist/internal/support/source-snippet.d.ts +0 -12
- package/dist/internal/support/source-snippet.js +0 -97
- package/dist/internal/support/worker-index.d.ts +0 -7
- package/dist/internal/support/worker-index.js +0 -14
- package/dist/internal/transport/http-client.d.ts +0 -52
- package/dist/internal/transport/http-client.js +0 -201
- package/dist/public/config-wrapper.d.ts +0 -21
- package/dist/public/config-wrapper.js +0 -64
- package/dist/public/global-setup.d.ts +0 -13
- package/dist/public/global-setup.js +0 -146
- package/dist/public/options.d.ts +0 -86
- package/dist/public/options.js +0 -2
- package/dist/public/reporter.d.ts +0 -68
- package/dist/public/reporter.js +0 -376
- package/dist/types/collected.d.ts +0 -97
- package/dist/types/collected.js +0 -10
- package/dist/types/wire.d.ts +0 -176
- package/dist/types/wire.js +0 -14
- package/dist/types.d.ts +0 -11
- package/dist/types.js +0 -27
|
@@ -1,1041 +1,2281 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/internal/capture/capture-fixtures.ts
|
|
31
|
+
var capture_fixtures_exports = {};
|
|
32
|
+
__export(capture_fixtures_exports, {
|
|
33
|
+
CAPTURED_ATTRS_ARG: () => CAPTURED_ATTRS_ARG,
|
|
34
|
+
ariaSnapshotBestEffort: () => ariaSnapshotBestEffort,
|
|
35
|
+
buildPageState: () => buildPageState,
|
|
36
|
+
computeCoreVitals: () => computeCoreVitals,
|
|
37
|
+
extendPiwiFixtures: () => extendPiwiFixtures,
|
|
38
|
+
piwiFixtures: () => piwiFixtures,
|
|
39
|
+
probeElementAttrs: () => probeElementAttrs
|
|
40
|
+
});
|
|
41
|
+
module.exports = __toCommonJS(capture_fixtures_exports);
|
|
42
|
+
var import_node_zlib = require("zlib");
|
|
43
|
+
|
|
44
|
+
// src/internal/capture/locator-healing.ts
|
|
45
|
+
var path = __toESM(require("path"));
|
|
46
|
+
|
|
47
|
+
// ../packages/core/src/locator-fingerprint.ts
|
|
48
|
+
var PRESENT_SIMILARITY = 0.8;
|
|
49
|
+
var MATCH_SIMILARITY = 0.2;
|
|
50
|
+
function parseAriaCandidates(ariaSnapshot) {
|
|
51
|
+
if (!ariaSnapshot) return [];
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const line of ariaSnapshot.split("\n")) {
|
|
54
|
+
const m = line.match(/^\s*-\s+([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?/i);
|
|
55
|
+
if (!m) continue;
|
|
56
|
+
const role = m[1];
|
|
57
|
+
const name = m[2] != null ? m[2].replace(/\\(.)/g, "$1") : null;
|
|
58
|
+
if (!name && (role === "generic" || role === "group" || role === "list" || role === "paragraph")) continue;
|
|
59
|
+
const levelMatch = line.slice(m[0].length).match(/\[level=(\d+)\]/);
|
|
60
|
+
const level = levelMatch ? Number(levelMatch[1]) : null;
|
|
61
|
+
out.push({ role, name, level });
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
function textSimilarity(a, b) {
|
|
66
|
+
const tok = (s) => new Set(
|
|
67
|
+
(s ?? "").toLowerCase().split(/[^a-z0-9]+/i).filter(Boolean)
|
|
68
|
+
);
|
|
69
|
+
const sa = tok(a);
|
|
70
|
+
const sb = tok(b);
|
|
71
|
+
if (sa.size === 0 && sb.size === 0) return 1;
|
|
72
|
+
if (sa.size === 0 || sb.size === 0) return 0;
|
|
73
|
+
let common = 0;
|
|
74
|
+
for (const t of sa) if (sb.has(t)) common++;
|
|
75
|
+
return 2 * common / (sa.size + sb.size);
|
|
76
|
+
}
|
|
77
|
+
function fingerprintPresent(fp, candidates) {
|
|
78
|
+
if (!fp.name) return false;
|
|
79
|
+
return candidates.some(
|
|
80
|
+
(c) => (!fp.role || c.role === fp.role) && textSimilarity(c.name, fp.name) >= PRESENT_SIMILARITY
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
function matchRenamedElement(fp, candidates) {
|
|
84
|
+
if (candidates.length === 0) return null;
|
|
85
|
+
const sameRole = fp.role ? candidates.filter((c) => c.role === fp.role) : candidates;
|
|
86
|
+
if (sameRole.length === 0) return null;
|
|
87
|
+
let pool = sameRole;
|
|
88
|
+
if (fp.level != null) {
|
|
89
|
+
const sameLevel = sameRole.filter((c) => c.level === fp.level);
|
|
90
|
+
if (sameLevel.length > 0) pool = sameLevel;
|
|
91
|
+
}
|
|
92
|
+
if (pool.length === 1) {
|
|
93
|
+
return { candidate: pool[0], confidence: 0.7 };
|
|
94
|
+
}
|
|
95
|
+
let best = null;
|
|
96
|
+
let bestScore = -1;
|
|
97
|
+
for (const c of pool) {
|
|
98
|
+
const s = textSimilarity(c.name, fp.name);
|
|
99
|
+
if (s > bestScore) {
|
|
100
|
+
bestScore = s;
|
|
101
|
+
best = c;
|
|
29
102
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
103
|
+
}
|
|
104
|
+
if (best && bestScore >= MATCH_SIMILARITY) return { candidate: best, confidence: bestScore };
|
|
105
|
+
const pos = fp.rolePosition;
|
|
106
|
+
if (pos && fp.role && pos.role === fp.role && sameRole.length >= 2 && sameRole.length === pos.count) {
|
|
107
|
+
const byIndex = sameRole[pos.index];
|
|
108
|
+
if (byIndex) return { candidate: byIndex, confidence: 0.5 };
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ../packages/core/src/locator-methods.ts
|
|
114
|
+
var LOCATOR_BUILDER_METHODS = [
|
|
115
|
+
"getByRole",
|
|
116
|
+
"getByTestId",
|
|
117
|
+
"getByText",
|
|
118
|
+
"getByLabel",
|
|
119
|
+
"getByPlaceholder",
|
|
120
|
+
"getByAltText",
|
|
121
|
+
"getByTitle",
|
|
122
|
+
"locator"
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
// ../packages/core/src/locator-generation.ts
|
|
126
|
+
var TAG_TO_ROLE = {
|
|
127
|
+
a: "link",
|
|
128
|
+
button: "button",
|
|
129
|
+
nav: "navigation",
|
|
130
|
+
main: "main",
|
|
131
|
+
article: "article",
|
|
132
|
+
section: "region",
|
|
133
|
+
form: "form",
|
|
134
|
+
img: "img",
|
|
135
|
+
figure: "figure",
|
|
136
|
+
figcaption: "caption",
|
|
137
|
+
blockquote: "blockquote",
|
|
138
|
+
table: "table",
|
|
139
|
+
ul: "list",
|
|
140
|
+
ol: "list",
|
|
141
|
+
li: "listitem",
|
|
142
|
+
dialog: "dialog",
|
|
143
|
+
output: "status",
|
|
144
|
+
progress: "progressbar",
|
|
145
|
+
meter: "meter",
|
|
146
|
+
textarea: "textbox",
|
|
147
|
+
h1: "heading",
|
|
148
|
+
h2: "heading",
|
|
149
|
+
h3: "heading",
|
|
150
|
+
h4: "heading",
|
|
151
|
+
h5: "heading",
|
|
152
|
+
h6: "heading",
|
|
153
|
+
details: "group",
|
|
154
|
+
summary: "button",
|
|
155
|
+
search: "search"
|
|
156
|
+
};
|
|
157
|
+
var INPUT_TYPE_TO_ROLE = {
|
|
158
|
+
button: "button",
|
|
159
|
+
submit: "button",
|
|
160
|
+
reset: "button",
|
|
161
|
+
image: "button",
|
|
162
|
+
checkbox: "checkbox",
|
|
163
|
+
radio: "radio",
|
|
164
|
+
range: "slider",
|
|
165
|
+
search: "searchbox",
|
|
166
|
+
number: "spinbutton",
|
|
167
|
+
text: "textbox",
|
|
168
|
+
email: "textbox",
|
|
169
|
+
tel: "textbox",
|
|
170
|
+
url: "textbox",
|
|
171
|
+
password: "textbox"
|
|
172
|
+
};
|
|
173
|
+
var CAPTURED_ATTRIBUTES = [
|
|
174
|
+
"id",
|
|
175
|
+
"class",
|
|
176
|
+
"name",
|
|
177
|
+
"data-testid",
|
|
178
|
+
"placeholder",
|
|
179
|
+
"alt",
|
|
180
|
+
"title",
|
|
181
|
+
"aria-label",
|
|
182
|
+
"aria-level",
|
|
183
|
+
"role",
|
|
184
|
+
"type",
|
|
185
|
+
"href",
|
|
186
|
+
"multiple"
|
|
187
|
+
];
|
|
188
|
+
function resolveAriaRole(attrs) {
|
|
189
|
+
const explicit = attrs.attributes["role"];
|
|
190
|
+
if (explicit) return explicit;
|
|
191
|
+
const tag = attrs.tagName;
|
|
192
|
+
if (!tag) return null;
|
|
193
|
+
if (tag === "input") {
|
|
194
|
+
const type = (attrs.attributes["type"] ?? "text").toLowerCase();
|
|
195
|
+
return INPUT_TYPE_TO_ROLE[type] ?? "textbox";
|
|
196
|
+
}
|
|
197
|
+
if (tag === "select") {
|
|
198
|
+
return attrs.attributes["multiple"] != null ? "listbox" : "combobox";
|
|
199
|
+
}
|
|
200
|
+
if (tag === "a") {
|
|
201
|
+
return attrs.attributes["href"] != null ? "link" : null;
|
|
202
|
+
}
|
|
203
|
+
return TAG_TO_ROLE[tag] ?? null;
|
|
204
|
+
}
|
|
205
|
+
function headingLevel(attrs, role) {
|
|
206
|
+
if (role !== "heading") return null;
|
|
207
|
+
const tagMatch = attrs.tagName.match(/^h([1-6])$/);
|
|
208
|
+
if (tagMatch) return Number(tagMatch[1]);
|
|
209
|
+
const ariaLevel = attrs.attributes["aria-level"];
|
|
210
|
+
if (ariaLevel && /^\d+$/.test(ariaLevel)) return Number(ariaLevel);
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
var attr = (a, key) => a.attributes[key] || null;
|
|
214
|
+
var esc = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
215
|
+
var escCssAttrValue = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
216
|
+
var isCssSafeId = (id) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(id);
|
|
217
|
+
function classifyCssStability(className) {
|
|
218
|
+
if (/[a-f0-9]{8,}/i.test(className)) return 10;
|
|
219
|
+
if (/^(?:css|sc|emotion|styled)-/i.test(className)) return 15;
|
|
220
|
+
if (/^(bg|text|border|shadow|opacity|font|w-|h-|m[tblrxy]?-|p[tblrxy]?-|flex|grid|gap|rounded|absolute|relative|fixed|sticky|block|inline|hidden|overflow|z-|top-|right-|bottom-|left-|inset-|justify-|items-|self-|content-|order-|col-|row-)/.test(
|
|
221
|
+
className
|
|
222
|
+
))
|
|
223
|
+
return 25;
|
|
224
|
+
if (/__/.test(className) || /--/.test(className)) return 35;
|
|
225
|
+
if (/^[a-z]+(-[a-z]+)+$/.test(className)) return 40;
|
|
226
|
+
if (/^[a-z]+[A-Z][a-zA-Z]+$/.test(className)) return 40;
|
|
227
|
+
if (className.includes("_")) return 15;
|
|
228
|
+
if (/^[a-z]+-[a-z0-9]{5,}$/.test(className) && /[0-9]/.test(className)) return 15;
|
|
229
|
+
return 15;
|
|
230
|
+
}
|
|
231
|
+
function isAutoGenerated(value) {
|
|
232
|
+
if (/^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/i.test(value)) return true;
|
|
233
|
+
if (/^[a-f0-9]{8,}$/i.test(value)) return true;
|
|
234
|
+
if (/^[a-z]+-\d+$/.test(value)) return true;
|
|
235
|
+
if (/^(emotion-|styled-|css-|sc-)/.test(value)) return true;
|
|
236
|
+
if (value.startsWith("ng-")) return true;
|
|
237
|
+
if (/^(radix-|headlessui-|mui-|mantine-|chakra-)/i.test(value)) return true;
|
|
238
|
+
if (/^:r[0-9a-z]+:$/i.test(value) || /^«r[0-9a-z]+»$/i.test(value)) return true;
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
function generateAlternatives(attrs) {
|
|
242
|
+
const alts = [];
|
|
243
|
+
const seen = /* @__PURE__ */ new Set();
|
|
244
|
+
const add = (loc) => {
|
|
245
|
+
if (!seen.has(loc.locator)) {
|
|
246
|
+
seen.add(loc.locator);
|
|
247
|
+
alts.push(loc);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const { accessibleName } = attrs;
|
|
251
|
+
const tag = attrs.tagName;
|
|
252
|
+
const role = resolveAriaRole(attrs);
|
|
253
|
+
const counts = attrs.selectorCounts;
|
|
254
|
+
const isUnique = (n) => n == null || n <= 1;
|
|
255
|
+
const text = attrs.textContent ? attrs.textContent.replace(/\s+/g, " ").trim() : null;
|
|
256
|
+
const testId = attr(attrs, "data-testid");
|
|
257
|
+
if (testId && isUnique(counts?.testId)) {
|
|
258
|
+
add({
|
|
259
|
+
locator: `getByTestId('${esc(testId)}')`,
|
|
260
|
+
method: "getByTestId",
|
|
261
|
+
args: { testId },
|
|
262
|
+
score: 100
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const level = headingLevel(attrs, role);
|
|
266
|
+
const levelPart = level != null ? `, level: ${level}` : "";
|
|
267
|
+
const withLevel = (base) => level != null ? { ...base, level } : base;
|
|
268
|
+
if (role && accessibleName) {
|
|
269
|
+
add({
|
|
270
|
+
locator: `getByRole('${role}', { name: '${esc(accessibleName)}'${levelPart} })`,
|
|
271
|
+
method: "getByRole",
|
|
272
|
+
args: withLevel({ role, name: accessibleName }),
|
|
273
|
+
score: 90
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const ariaLabel = attr(attrs, "aria-label");
|
|
277
|
+
if (role && ariaLabel && ariaLabel !== accessibleName) {
|
|
278
|
+
add({
|
|
279
|
+
locator: `getByRole('${role}', { name: '${esc(ariaLabel)}'${levelPart} })`,
|
|
280
|
+
method: "getByRole",
|
|
281
|
+
args: withLevel({ role, name: ariaLabel }),
|
|
282
|
+
score: 85
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (accessibleName && ["input", "select", "textarea"].includes(tag)) {
|
|
286
|
+
const label = attr(attrs, "aria-label");
|
|
287
|
+
const labelBacked = attrs.hasLabel === void 0 ? true : attrs.hasLabel === true || label === accessibleName;
|
|
288
|
+
if (labelBacked) {
|
|
289
|
+
add({
|
|
290
|
+
locator: `getByLabel('${esc(accessibleName)}')`,
|
|
291
|
+
method: "getByLabel",
|
|
292
|
+
args: { label: accessibleName },
|
|
293
|
+
score: 85
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const placeholder = attr(attrs, "placeholder");
|
|
298
|
+
if (placeholder) {
|
|
299
|
+
add({
|
|
300
|
+
locator: `getByPlaceholder('${esc(placeholder)}')`,
|
|
301
|
+
method: "getByPlaceholder",
|
|
302
|
+
args: { placeholder },
|
|
303
|
+
score: 80
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (text && text.length < 80) {
|
|
307
|
+
add({
|
|
308
|
+
locator: `getByText('${esc(text)}')`,
|
|
309
|
+
method: "getByText",
|
|
310
|
+
args: { text },
|
|
311
|
+
score: 75
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const id = attr(attrs, "id");
|
|
315
|
+
if (id && !isAutoGenerated(id) && isUnique(counts?.id)) {
|
|
316
|
+
const selector = isCssSafeId(id) ? `#${id}` : `[id="${escCssAttrValue(id)}"]`;
|
|
317
|
+
add({
|
|
318
|
+
locator: `locator('${esc(selector)}')`,
|
|
319
|
+
method: "locator",
|
|
320
|
+
args: { selector },
|
|
321
|
+
score: 65
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
const name = attr(attrs, "name");
|
|
325
|
+
if (name && isUnique(counts?.name)) {
|
|
326
|
+
const selector = `[name="${escCssAttrValue(name)}"]`;
|
|
327
|
+
add({
|
|
328
|
+
locator: `locator('${esc(selector)}')`,
|
|
329
|
+
method: "locator",
|
|
330
|
+
args: { selector },
|
|
331
|
+
score: 60
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
const alt = attr(attrs, "alt");
|
|
335
|
+
if (alt) {
|
|
336
|
+
add({
|
|
337
|
+
locator: `getByAltText('${esc(alt)}')`,
|
|
338
|
+
method: "getByAltText",
|
|
339
|
+
args: { text: alt },
|
|
340
|
+
score: 60
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const title = attr(attrs, "title");
|
|
344
|
+
if (title) {
|
|
345
|
+
add({
|
|
346
|
+
locator: `getByTitle('${esc(title)}')`,
|
|
347
|
+
method: "getByTitle",
|
|
348
|
+
args: { title },
|
|
349
|
+
score: 50
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
const hasOwnTestId = !!(testId && isUnique(counts?.testId));
|
|
353
|
+
if (role && !hasOwnTestId) {
|
|
354
|
+
const rolePart = level != null ? `'${role}', { level: ${level} }` : `'${role}'`;
|
|
355
|
+
const leafArgs = withLevel({ role });
|
|
356
|
+
let testIdAnchorDone = false;
|
|
357
|
+
let idAnchorDone = false;
|
|
358
|
+
let roleAnchorDone = false;
|
|
359
|
+
for (const anc of attrs.ancestors ?? []) {
|
|
360
|
+
if (anc.scopedRoleCount !== 1) continue;
|
|
361
|
+
if (!testIdAnchorDone && anc.testId && anc.testIdCount === 1) {
|
|
362
|
+
testIdAnchorDone = true;
|
|
363
|
+
add({
|
|
364
|
+
locator: `getByTestId('${esc(anc.testId)}').getByRole(${rolePart})`,
|
|
365
|
+
method: "getByRole",
|
|
366
|
+
args: { ...leafArgs, anchorTestId: anc.testId },
|
|
367
|
+
score: 72
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
if (!idAnchorDone && anc.id && !isAutoGenerated(anc.id) && anc.idCount === 1) {
|
|
371
|
+
idAnchorDone = true;
|
|
372
|
+
const anchorSelector = isCssSafeId(anc.id) ? `#${anc.id}` : `[id="${escCssAttrValue(anc.id)}"]`;
|
|
373
|
+
add({
|
|
374
|
+
locator: `locator('${esc(anchorSelector)}').getByRole(${rolePart})`,
|
|
375
|
+
method: "getByRole",
|
|
376
|
+
args: { ...leafArgs, anchorSelector },
|
|
377
|
+
score: 64
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
const ancestorRole = anc.role || TAG_TO_ROLE[anc.tag] || null;
|
|
381
|
+
if (!roleAnchorDone && ancestorRole && ancestorRole !== role && anc.roleCount === 1) {
|
|
382
|
+
roleAnchorDone = true;
|
|
383
|
+
add({
|
|
384
|
+
locator: `getByRole('${esc(ancestorRole)}').getByRole(${rolePart})`,
|
|
385
|
+
method: "getByRole",
|
|
386
|
+
args: { ...leafArgs, anchorRole: ancestorRole },
|
|
387
|
+
score: 55
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const pos = attrs.rolePosition;
|
|
392
|
+
if (pos && pos.role === role && (pos.count === 1 || level != null && pos.levelCount === 1)) {
|
|
393
|
+
add({
|
|
394
|
+
locator: `getByRole(${rolePart})`,
|
|
395
|
+
method: "getByRole",
|
|
396
|
+
args: leafArgs,
|
|
397
|
+
score: 58
|
|
398
|
+
});
|
|
48
399
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
400
|
+
}
|
|
401
|
+
const clsStr = attr(attrs, "class");
|
|
402
|
+
if (clsStr) {
|
|
403
|
+
const countsClasses = counts?.classes;
|
|
404
|
+
const classes = clsStr.split(/\s+/).filter((c) => c.length > 1 && /^[A-Za-z_-][A-Za-z0-9_-]*$/.test(c) && isUnique(countsClasses?.[c])).map((cls) => ({ cls, score: classifyCssStability(cls) })).sort((a, b) => b.score - a.score).slice(0, 3);
|
|
405
|
+
for (const { cls, score } of classes) {
|
|
406
|
+
add({
|
|
407
|
+
locator: `locator('.${esc(cls)}')`,
|
|
408
|
+
method: "locator",
|
|
409
|
+
args: { selector: `.${cls}` },
|
|
410
|
+
score
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return alts.sort((a, b) => b.score - a.score);
|
|
52
415
|
}
|
|
53
|
-
function
|
|
416
|
+
function approximateAccessibleName(attrs) {
|
|
417
|
+
const a = attrs.attributes;
|
|
418
|
+
const ariaLabel = a["aria-label"];
|
|
419
|
+
if (ariaLabel) return ariaLabel;
|
|
420
|
+
if (attrs.textContent) return attrs.textContent;
|
|
421
|
+
const title = a["title"];
|
|
422
|
+
if (title) return title;
|
|
423
|
+
const placeholder = a["placeholder"];
|
|
424
|
+
if (placeholder) return placeholder;
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/internal/capture/locator-healing.ts
|
|
429
|
+
function dedupeSnapshotsByLocation(snaps) {
|
|
430
|
+
const lastWithElement = /* @__PURE__ */ new Map();
|
|
431
|
+
const lastAny = /* @__PURE__ */ new Map();
|
|
432
|
+
snaps.forEach((s, i) => {
|
|
433
|
+
if (!s.location) return;
|
|
434
|
+
lastAny.set(s.location, i);
|
|
435
|
+
if (s.element) lastWithElement.set(s.location, i);
|
|
436
|
+
});
|
|
437
|
+
return snaps.filter((s, i) => {
|
|
438
|
+
if (!s.location) return true;
|
|
439
|
+
return (lastWithElement.get(s.location) ?? lastAny.get(s.location)) === i;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
var LOCATOR_METHODS = [...LOCATOR_BUILDER_METHODS];
|
|
443
|
+
var CHAIN_METHODS = [
|
|
444
|
+
"first",
|
|
445
|
+
"nth",
|
|
446
|
+
"last",
|
|
447
|
+
"filter",
|
|
448
|
+
"and",
|
|
449
|
+
"or",
|
|
450
|
+
"locator",
|
|
451
|
+
"getByRole",
|
|
452
|
+
"getByTestId",
|
|
453
|
+
"getByText",
|
|
454
|
+
"getByLabel",
|
|
455
|
+
"getByPlaceholder",
|
|
456
|
+
"getByAltText",
|
|
457
|
+
"getByTitle"
|
|
458
|
+
];
|
|
459
|
+
var ACTION_METHODS = [
|
|
460
|
+
"click",
|
|
461
|
+
"fill",
|
|
462
|
+
"check",
|
|
463
|
+
"uncheck",
|
|
464
|
+
"selectOption",
|
|
465
|
+
"dblclick",
|
|
466
|
+
"tap",
|
|
467
|
+
"hover",
|
|
468
|
+
"press",
|
|
469
|
+
"type",
|
|
470
|
+
"pressSequentially",
|
|
471
|
+
"clear",
|
|
472
|
+
"setInputFiles",
|
|
473
|
+
"dragTo",
|
|
474
|
+
"focus",
|
|
475
|
+
"blur",
|
|
476
|
+
"scrollIntoViewIfNeeded",
|
|
477
|
+
"dispatchEvent",
|
|
478
|
+
"selectText",
|
|
479
|
+
// Not an action, but a successful waitFor proves the element resolved — the
|
|
480
|
+
// closest capture hook available for assertion-style usage of a locator.
|
|
481
|
+
"waitFor"
|
|
482
|
+
];
|
|
483
|
+
var LOCATOR_CREATING_CHAINS = new Set(LOCATOR_METHODS);
|
|
484
|
+
function extractAccessibleName(ariaSnapshot) {
|
|
485
|
+
if (!ariaSnapshot) return null;
|
|
486
|
+
const match = ariaSnapshot.match(/- \w+ "([^"]+)"/);
|
|
487
|
+
if (match) return match[1];
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
var NAME_BASED_METHODS = /* @__PURE__ */ new Set([
|
|
491
|
+
"getByText",
|
|
492
|
+
"getByRole",
|
|
493
|
+
"getByLabel",
|
|
494
|
+
"getByPlaceholder",
|
|
495
|
+
"getByTitle",
|
|
496
|
+
"getByAltText"
|
|
497
|
+
]);
|
|
498
|
+
var escAttr = (s) => s.replaceAll("\\", "\\\\").replaceAll("'", "\\'");
|
|
499
|
+
var SUGG_TEXT_ROLES = /* @__PURE__ */ new Set([
|
|
500
|
+
"button",
|
|
501
|
+
"link",
|
|
502
|
+
"heading",
|
|
503
|
+
"menuitem",
|
|
504
|
+
"tab",
|
|
505
|
+
"option",
|
|
506
|
+
"cell",
|
|
507
|
+
"columnheader",
|
|
508
|
+
"rowheader",
|
|
509
|
+
"gridcell",
|
|
510
|
+
"treeitem",
|
|
511
|
+
"listitem",
|
|
512
|
+
"checkbox",
|
|
513
|
+
"radio",
|
|
514
|
+
"switch"
|
|
515
|
+
]);
|
|
516
|
+
var SUGG_FIELD_ROLES = /* @__PURE__ */ new Set(["textbox", "combobox", "searchbox", "spinbutton", "slider"]);
|
|
517
|
+
function failedNameAndRole(failed) {
|
|
518
|
+
if (failed.method === "getByRole") {
|
|
519
|
+
const role = typeof failed.args[0] === "string" ? failed.args[0] : null;
|
|
520
|
+
const opts = failed.args[1];
|
|
521
|
+
const name = opts && typeof opts.name === "string" ? opts.name : null;
|
|
522
|
+
const level = opts && typeof opts.level === "number" ? opts.level : null;
|
|
523
|
+
return { role, name, level };
|
|
524
|
+
}
|
|
525
|
+
const first = failed.args.find((a) => typeof a === "string");
|
|
526
|
+
return { role: null, name: typeof first === "string" ? first : null, level: null };
|
|
527
|
+
}
|
|
528
|
+
function renderFailing(failed) {
|
|
529
|
+
const { role, name } = failedNameAndRole(failed);
|
|
530
|
+
if (failed.method === "getByRole") {
|
|
531
|
+
return name ? `getByRole('${escAttr(role ?? "")}', { name: '${escAttr(name)}' })` : `getByRole('${escAttr(role ?? "")}')`;
|
|
532
|
+
}
|
|
533
|
+
return `${failed.method}('${escAttr(name ?? "")}')`;
|
|
534
|
+
}
|
|
535
|
+
function freshSuggestions(candidate, failedMethod) {
|
|
536
|
+
const out = [];
|
|
537
|
+
const role = candidate.role;
|
|
538
|
+
const name = candidate.name;
|
|
539
|
+
const push = (s) => {
|
|
540
|
+
if (!out.includes(s)) out.push(s);
|
|
541
|
+
};
|
|
542
|
+
const levelPart = candidate.level != null ? `, level: ${candidate.level}` : "";
|
|
543
|
+
const roleLoc = `getByRole('${escAttr(role)}', { name: '${escAttr(name)}'${levelPart} })`;
|
|
544
|
+
const textLoc = `getByText('${escAttr(name)}')`;
|
|
545
|
+
const labelLoc = `getByLabel('${escAttr(name)}')`;
|
|
546
|
+
if (failedMethod === "getByText" && SUGG_TEXT_ROLES.has(role)) push(textLoc);
|
|
547
|
+
if (failedMethod === "getByLabel" && SUGG_FIELD_ROLES.has(role)) push(labelLoc);
|
|
548
|
+
push(roleLoc);
|
|
549
|
+
if (SUGG_TEXT_ROLES.has(role)) push(textLoc);
|
|
550
|
+
else if (SUGG_FIELD_ROLES.has(role)) push(labelLoc);
|
|
551
|
+
return out;
|
|
552
|
+
}
|
|
553
|
+
function suggestLocatorsFromAria(failed, ariaSnapshot) {
|
|
554
|
+
if (!ariaSnapshot || !NAME_BASED_METHODS.has(failed.method)) return null;
|
|
555
|
+
const { role, name, level } = failedNameAndRole(failed);
|
|
556
|
+
if (!name) return null;
|
|
557
|
+
const candidates = parseAriaCandidates(ariaSnapshot);
|
|
558
|
+
if (candidates.length === 0) return null;
|
|
559
|
+
const fingerprint = { role, name, level };
|
|
560
|
+
if (fingerprintPresent(fingerprint, candidates)) return null;
|
|
561
|
+
const best = matchRenamedElement(fingerprint, candidates)?.candidate;
|
|
562
|
+
if (!best || !best.name) return null;
|
|
563
|
+
const suggestions = freshSuggestions({ role: best.role, name: best.name, level: best.level }, failed.method);
|
|
564
|
+
if (suggestions.length === 0) return null;
|
|
565
|
+
return { failing: renderFailing(failed), suggestions };
|
|
566
|
+
}
|
|
567
|
+
function captureCallerLocation(stack = new Error().stack ?? "") {
|
|
568
|
+
const lines = stack.split("\n");
|
|
569
|
+
let prevWasCaptureModule = false;
|
|
570
|
+
for (let i = 1; i < lines.length; i++) {
|
|
571
|
+
const line = lines[i].trim();
|
|
572
|
+
if (!line.startsWith("at")) continue;
|
|
573
|
+
const m = line.match(/^at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/);
|
|
574
|
+
if (!m) {
|
|
575
|
+
prevWasCaptureModule = false;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
let file = m[2];
|
|
579
|
+
if (!file || file.startsWith("node:")) {
|
|
580
|
+
prevWasCaptureModule = false;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
file = file.replace(/^file:\/\/\/?/, "");
|
|
584
|
+
if (!/\.[a-z]+$/i.test(file)) {
|
|
585
|
+
prevWasCaptureModule = false;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (/[\\/]locator-healing\.[a-z]+$/i.test(file)) {
|
|
589
|
+
prevWasCaptureModule = true;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (prevWasCaptureModule && /[\\/](?:capture-)?fixtures\.[a-z]+$/i.test(file)) {
|
|
593
|
+
prevWasCaptureModule = false;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (/[\\/]node_modules[\\/]/.test(file)) {
|
|
597
|
+
prevWasCaptureModule = false;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
let rel = file;
|
|
601
|
+
try {
|
|
602
|
+
rel = path.relative(process.cwd(), file);
|
|
603
|
+
} catch {
|
|
604
|
+
}
|
|
605
|
+
rel = rel.split(path.sep).join("/");
|
|
606
|
+
if (rel.startsWith("./")) rel = rel.slice(2);
|
|
607
|
+
return `${rel}:${m[3]}:${m[4]}`;
|
|
608
|
+
}
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// src/internal/capture/attachments.ts
|
|
613
|
+
var ATTACHMENT_NAMES = {
|
|
614
|
+
locators: "piwi-locators",
|
|
615
|
+
ariaSnapshot: "piwi-aria-snapshot",
|
|
616
|
+
console: "piwi-console",
|
|
617
|
+
network: "piwi-network",
|
|
618
|
+
webVitals: "piwi-web-vitals",
|
|
619
|
+
locatorSuggestion: "piwi-locator-suggestion",
|
|
620
|
+
pageState: "piwi-page-state",
|
|
621
|
+
userPick: "piwi-user-pick"
|
|
622
|
+
};
|
|
623
|
+
var INTERNAL_ATTACHMENT_NAMES = new Set(Object.values(ATTACHMENT_NAMES));
|
|
624
|
+
var LOCATOR_SUGGESTION_ANNOTATION = ATTACHMENT_NAMES.locatorSuggestion;
|
|
625
|
+
var USER_PICK_ANNOTATION = ATTACHMENT_NAMES.userPick;
|
|
626
|
+
|
|
627
|
+
// src/internal/capture/inspect-on-failure.ts
|
|
628
|
+
function isCi(ci) {
|
|
629
|
+
return ci !== void 0 && ci !== "" && ci !== "false";
|
|
630
|
+
}
|
|
631
|
+
function shouldInspectOnFailure(gate) {
|
|
632
|
+
if (gate.enabled !== "true") return false;
|
|
633
|
+
if (isCi(gate.ci)) return false;
|
|
634
|
+
if (gate.headless !== false) return false;
|
|
635
|
+
if (gate.status !== "failed" && gate.status !== "timedOut") return false;
|
|
636
|
+
if (gate.status === gate.expectedStatus) return false;
|
|
637
|
+
return gate.retry >= gate.retries;
|
|
638
|
+
}
|
|
639
|
+
function environmentalSkipReason(gate) {
|
|
640
|
+
if (gate.enabled !== "true") return null;
|
|
641
|
+
if (gate.status !== "failed" && gate.status !== "timedOut") return null;
|
|
642
|
+
if (gate.status === gate.expectedStatus) return null;
|
|
643
|
+
if (isCi(gate.ci)) return "running under CI \u2014 this is a headed, local-only feature";
|
|
644
|
+
if (gate.headless !== false) {
|
|
645
|
+
return "the browser is headless \u2014 re-run with --headed (or set use: { headless: false })";
|
|
646
|
+
}
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
function inspectionGateFromTestInfo(testInfo, enabled = process.env.PIWI_INSPECT_ON_FAIL) {
|
|
650
|
+
const use = testInfo.project?.use ?? {};
|
|
651
|
+
return {
|
|
652
|
+
enabled,
|
|
653
|
+
ci: process.env.CI,
|
|
654
|
+
status: testInfo.status,
|
|
655
|
+
expectedStatus: testInfo.expectedStatus,
|
|
656
|
+
headless: use.headless,
|
|
657
|
+
retry: testInfo.retry,
|
|
658
|
+
retries: testInfo.project?.retries ?? 0
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// src/internal/capture/pick-on-failure.ts
|
|
663
|
+
var path2 = __toESM(require("path"));
|
|
664
|
+
var ANSI_RE = /\[[0-9;]*m/g;
|
|
665
|
+
function endOfString(s, start) {
|
|
666
|
+
const q = s[start];
|
|
667
|
+
for (let i = start + 1; i < s.length; i++) {
|
|
668
|
+
if (s[i] === "\\") {
|
|
669
|
+
i++;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (s[i] === q) return i;
|
|
673
|
+
}
|
|
674
|
+
return s.length - 1;
|
|
675
|
+
}
|
|
676
|
+
function matchBrace(s, start) {
|
|
677
|
+
let depth = 0;
|
|
678
|
+
for (let i = start; i < s.length; i++) {
|
|
679
|
+
if (s[i] === "{") depth++;
|
|
680
|
+
else if (s[i] === "}" && --depth === 0) return i;
|
|
681
|
+
}
|
|
682
|
+
return s.length - 1;
|
|
683
|
+
}
|
|
684
|
+
function parseOptions(src) {
|
|
685
|
+
const obj = {};
|
|
686
|
+
const re = /(\w+)\s*:\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*"|true|false|-?\d+)/g;
|
|
687
|
+
let m;
|
|
688
|
+
while ((m = re.exec(src)) !== null) {
|
|
689
|
+
const key = m[1];
|
|
690
|
+
const raw = m[2];
|
|
691
|
+
if (raw === "true") obj[key] = true;
|
|
692
|
+
else if (raw === "false") obj[key] = false;
|
|
693
|
+
else if (/^-?\d+$/.test(raw)) obj[key] = Number(raw);
|
|
694
|
+
else obj[key] = raw.slice(1, -1).replace(/\\(.)/g, "$1");
|
|
695
|
+
}
|
|
696
|
+
return obj;
|
|
697
|
+
}
|
|
698
|
+
function parseArgs(inner) {
|
|
699
|
+
const args = [];
|
|
700
|
+
let i = 0;
|
|
701
|
+
while (i < inner.length) {
|
|
702
|
+
const c = inner[i];
|
|
703
|
+
if (c === " " || c === ",") {
|
|
704
|
+
i++;
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
if (c === "'" || c === '"') {
|
|
708
|
+
const end = endOfString(inner, i);
|
|
709
|
+
args.push(inner.slice(i + 1, end).replace(/\\(.)/g, "$1"));
|
|
710
|
+
i = end + 1;
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (c === "{") {
|
|
714
|
+
const end = matchBrace(inner, i);
|
|
715
|
+
args.push(parseOptions(inner.slice(i, end + 1)));
|
|
716
|
+
i = end + 1;
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
i++;
|
|
720
|
+
}
|
|
721
|
+
return args;
|
|
722
|
+
}
|
|
723
|
+
function leafExpression(expr) {
|
|
724
|
+
let depth = 0;
|
|
725
|
+
let leafStart = 0;
|
|
726
|
+
for (let i = 0; i < expr.length - 1; i++) {
|
|
727
|
+
const c = expr[i];
|
|
728
|
+
if (c === "'" || c === '"') {
|
|
729
|
+
i = endOfString(expr, i);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (c === "(") depth++;
|
|
733
|
+
else if (c === ")") {
|
|
734
|
+
depth--;
|
|
735
|
+
if (depth === 0 && expr[i + 1] === ".") leafStart = i + 2;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return expr.slice(leafStart);
|
|
739
|
+
}
|
|
740
|
+
function parseLeafLocatorExpression(rawExpr) {
|
|
741
|
+
const expr = leafExpression(rawExpr.trim());
|
|
742
|
+
const m = /^([A-Za-z]+)\((.*)\)$/s.exec(expr);
|
|
743
|
+
if (!m) return null;
|
|
744
|
+
return { method: m[1], args: parseArgs(m[2].trim()) };
|
|
745
|
+
}
|
|
746
|
+
function deriveFailedLocator(testInfo) {
|
|
747
|
+
const info = testInfo;
|
|
748
|
+
const errors = info.errors && info.errors.length > 0 ? info.errors : info.error ? [info.error] : [];
|
|
749
|
+
for (const err of errors) {
|
|
750
|
+
const text = `${err.message ?? ""}
|
|
751
|
+
${err.stack ?? ""}`.replace(ANSI_RE, "");
|
|
752
|
+
const line = /^\s*Locator:\s*(.+)$/m.exec(text);
|
|
753
|
+
if (!line) continue;
|
|
754
|
+
const parsed = parseLeafLocatorExpression(line[1].trim());
|
|
755
|
+
if (!parsed) continue;
|
|
756
|
+
const loc = err.location;
|
|
757
|
+
const location = loc ? `${path2.relative(process.cwd(), loc.file).split(path2.sep).join("/")}:${loc.line}:${loc.column}` : null;
|
|
758
|
+
return { method: parsed.method, args: parsed.args, location };
|
|
759
|
+
}
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
var escStr = (s) => s.replaceAll("\\", "\\\\").replaceAll("'", "\\'");
|
|
763
|
+
var cssIdSelector = (id) => /^[A-Za-z_][A-Za-z0-9_-]*$/.test(id) ? `#${id}` : `[id="${id.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"]`;
|
|
764
|
+
var ANCHOR_KIND_SCORES = { testid: 80, id: 74, labeledRole: 68, role: 62 };
|
|
765
|
+
function anchorSegment(a) {
|
|
766
|
+
if (a.testId && a.testIdCount === 1) {
|
|
767
|
+
return { code: `getByTestId('${escStr(a.testId)}')`, kind: "testid", flatArgs: { anchorTestId: a.testId } };
|
|
768
|
+
}
|
|
769
|
+
if (a.id && !isAutoGenerated(a.id) && a.idCount === 1) {
|
|
770
|
+
const anchorSelector = cssIdSelector(a.id);
|
|
771
|
+
return { code: `locator('${escStr(anchorSelector)}')`, kind: "id", flatArgs: { anchorSelector } };
|
|
772
|
+
}
|
|
773
|
+
if (a.role && a.ariaLabel && a.labeledRoleCount === 1) {
|
|
54
774
|
return {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
capturedLocators: [],
|
|
59
|
-
capturePromises: [],
|
|
60
|
-
failedLocators: [],
|
|
61
|
-
lastActivePage: null,
|
|
62
|
-
testInfo: null,
|
|
63
|
-
stashedWebVitals: null,
|
|
64
|
-
stashedPageState: null,
|
|
65
|
-
stashedAria: null,
|
|
775
|
+
code: `getByRole('${escStr(a.role)}', { name: '${escStr(a.ariaLabel)}' })`,
|
|
776
|
+
kind: "labeledRole",
|
|
777
|
+
flatArgs: { anchorRole: a.role, anchorName: a.ariaLabel }
|
|
66
778
|
};
|
|
779
|
+
}
|
|
780
|
+
if (a.role && a.roleCount === 1) {
|
|
781
|
+
return { code: `getByRole('${escStr(a.role)}')`, kind: "role", flatArgs: { anchorRole: a.role } };
|
|
782
|
+
}
|
|
783
|
+
return null;
|
|
67
784
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
]);
|
|
93
|
-
|
|
785
|
+
function generateAnchoredAlternatives(leaf, anchors, chainLeafCount) {
|
|
786
|
+
if (!leaf.role || anchors.length === 0) return [];
|
|
787
|
+
const out = [];
|
|
788
|
+
const seen = /* @__PURE__ */ new Set();
|
|
789
|
+
const rolePart = leaf.level != null ? `'${leaf.role}', { level: ${leaf.level} }` : `'${leaf.role}'`;
|
|
790
|
+
const leafArgs = leaf.level != null ? { role: leaf.role, level: leaf.level } : { role: leaf.role };
|
|
791
|
+
const add = (l) => {
|
|
792
|
+
if (!seen.has(l.locator)) {
|
|
793
|
+
seen.add(l.locator);
|
|
794
|
+
out.push(l);
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
for (const anchor of anchors) {
|
|
798
|
+
if (anchor.scopedLeafCount !== 1) continue;
|
|
799
|
+
const seg = anchorSegment(anchor);
|
|
800
|
+
if (!seg) continue;
|
|
801
|
+
add({
|
|
802
|
+
locator: `${seg.code}.getByRole(${rolePart})`,
|
|
803
|
+
method: "getByRole",
|
|
804
|
+
args: { ...leafArgs, ...seg.flatArgs },
|
|
805
|
+
score: ANCHOR_KIND_SCORES[seg.kind]
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
if (anchors.length >= 2 && chainLeafCount === 1) {
|
|
809
|
+
const ordered = [...anchors].sort((a, b) => b.depth - a.depth);
|
|
810
|
+
const segments = ordered.map(anchorSegment);
|
|
811
|
+
if (segments.every((s) => s !== null)) {
|
|
812
|
+
const innermost = segments[segments.length - 1];
|
|
813
|
+
const score = Math.min(...segments.map((s) => ANCHOR_KIND_SCORES[s.kind]));
|
|
814
|
+
add({
|
|
815
|
+
locator: `${segments.map((s) => s.code).join(".")}.getByRole(${rolePart})`,
|
|
816
|
+
method: "getByRole",
|
|
817
|
+
// Flat args describe the innermost anchor (what the healing fingerprint
|
|
818
|
+
// logic understands); the full chain rides along for transparency.
|
|
819
|
+
args: { ...leafArgs, ...innermost.flatArgs, anchorChain: segments.map((s) => s.code) },
|
|
820
|
+
score
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return out.sort((a, b) => b.score - a.score);
|
|
94
825
|
}
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
|
|
826
|
+
function mergeCandidates(base, extra) {
|
|
827
|
+
const seen = new Set(base.map((b) => b.locator));
|
|
828
|
+
const merged = [...base];
|
|
829
|
+
for (const e of extra) {
|
|
830
|
+
if (seen.has(e.locator)) continue;
|
|
831
|
+
seen.add(e.locator);
|
|
832
|
+
merged.push(e);
|
|
833
|
+
}
|
|
834
|
+
return merged.sort((a, b) => b.score - a.score);
|
|
835
|
+
}
|
|
836
|
+
function installPickerOverlay(arg) {
|
|
837
|
+
const g = globalThis;
|
|
838
|
+
const doc = g.document;
|
|
839
|
+
if (!doc || !doc.body) {
|
|
840
|
+
g.__piwiPickState = "skipped";
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const Z = 2147483600;
|
|
844
|
+
const highlight = doc.createElement("div");
|
|
845
|
+
highlight.style.cssText = `position:fixed;pointer-events:none;z-index:${Z};display:none;box-sizing:border-box;border:2px solid #7c3aed;background:rgba(124,58,237,.12);border-radius:3px;`;
|
|
846
|
+
const banner = doc.createElement("div");
|
|
847
|
+
banner.style.cssText = `position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:${Z + 2};background:#111827;color:#f9fafb;font:13px/1.5 system-ui,sans-serif;padding:10px 16px;border-radius:8px;box-shadow:0 4px 24px rgba(0,0,0,.4);max-width:80vw;`;
|
|
848
|
+
const hlLocator = (expr) => {
|
|
849
|
+
const escHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
850
|
+
const re = /('(?:\\.|[^'])*'|"(?:\\.|[^"])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
|
|
851
|
+
let html = "";
|
|
852
|
+
let last = 0;
|
|
853
|
+
let m;
|
|
854
|
+
while ((m = re.exec(expr)) !== null) {
|
|
855
|
+
if (m.index > last) html += escHtml(expr.slice(last, m.index));
|
|
856
|
+
const color = m[1] ? "#4ade80" : m[2] ? "#c084fc" : m[3] ? "#93c5fd" : m[4] ? "#fbbf24" : "#9ca3af";
|
|
857
|
+
html += `<span style="color:${color}">${escHtml(m[0])}</span>`;
|
|
858
|
+
last = re.lastIndex;
|
|
859
|
+
}
|
|
860
|
+
if (last < expr.length) html += escHtml(expr.slice(last));
|
|
861
|
+
return `<code style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace">${html}</code>`;
|
|
862
|
+
};
|
|
863
|
+
const head = doc.createElement("div");
|
|
864
|
+
head.innerHTML = arg.failing ? `Piwi locator picker \u2014 click the element that should replace ${hlLocator(arg.failing)}` : "Piwi inspector \u2014 click any element to generate locators for it";
|
|
865
|
+
const foot = doc.createElement("div");
|
|
866
|
+
foot.style.cssText = "color:#9ca3af;margin-top:3px;";
|
|
867
|
+
foot.textContent = "\u2191 parent \xB7 \u2193 child \xB7 Esc skip";
|
|
868
|
+
banner.appendChild(head);
|
|
869
|
+
banner.appendChild(foot);
|
|
870
|
+
doc.body.appendChild(highlight);
|
|
871
|
+
doc.body.appendChild(banner);
|
|
872
|
+
const escJs = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
873
|
+
const describe = (el) => {
|
|
874
|
+
const tag = (el.tagName || "?").toLowerCase();
|
|
875
|
+
const testId = el.getAttribute && el.getAttribute("data-testid");
|
|
876
|
+
if (testId) return `getByTestId('${escJs(testId)}')`;
|
|
877
|
+
if (el.labels && el.labels.length > 0) {
|
|
878
|
+
const labelText = (el.labels[0].textContent || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
879
|
+
if (labelText) return `getByLabel('${escJs(labelText)}')`;
|
|
880
|
+
}
|
|
881
|
+
const ariaLabel = el.getAttribute && el.getAttribute("aria-label");
|
|
882
|
+
if (ariaLabel) return `getByLabel('${escJs(ariaLabel)}')`;
|
|
883
|
+
const placeholder = el.getAttribute && el.getAttribute("placeholder");
|
|
884
|
+
if (placeholder) return `getByPlaceholder('${escJs(placeholder)}')`;
|
|
885
|
+
const alt = el.getAttribute && el.getAttribute("alt");
|
|
886
|
+
if (alt) return `getByAltText('${escJs(alt)}')`;
|
|
887
|
+
const titleAttr = el.getAttribute && el.getAttribute("title");
|
|
888
|
+
if (titleAttr) return `getByTitle('${escJs(titleAttr)}')`;
|
|
889
|
+
if (el.id) return `locator('#${escJs(el.id)}')`;
|
|
890
|
+
const cls = (el.getAttribute && el.getAttribute("class") || "").split(/\s+/).find((c) => c.length > 1);
|
|
891
|
+
return cls ? `locator('.${escJs(cls)}')` : tag;
|
|
892
|
+
};
|
|
893
|
+
const buildChain = (raw) => {
|
|
894
|
+
const chain2 = [];
|
|
895
|
+
let node = raw;
|
|
896
|
+
while (node && chain2.length < 15) {
|
|
897
|
+
const tag = (node.tagName || "").toLowerCase();
|
|
898
|
+
if (tag === "body" || tag === "html") break;
|
|
899
|
+
chain2.push(node);
|
|
900
|
+
node = node.parentElement;
|
|
98
901
|
}
|
|
99
|
-
|
|
100
|
-
|
|
902
|
+
return chain2.length ? chain2 : [raw];
|
|
903
|
+
};
|
|
904
|
+
const ACTIONABLE_TAGS = ["button", "a", "input", "select", "textarea", "summary", "option"];
|
|
905
|
+
const snapIndex = (chain2) => {
|
|
906
|
+
for (let i = 0; i < Math.min(chain2.length, 4); i++) {
|
|
907
|
+
const el = chain2[i];
|
|
908
|
+
const tag = (el.tagName || "").toLowerCase();
|
|
909
|
+
if (ACTIONABLE_TAGS.includes(tag)) return i;
|
|
910
|
+
if (el.getAttribute && (el.getAttribute("role") || el.getAttribute("data-testid"))) return i;
|
|
101
911
|
}
|
|
912
|
+
return 0;
|
|
913
|
+
};
|
|
914
|
+
let chain = [];
|
|
915
|
+
let idx = 0;
|
|
916
|
+
let lastRaw = null;
|
|
917
|
+
const current = () => chain[idx] ?? null;
|
|
918
|
+
const refresh = () => {
|
|
919
|
+
const el = current();
|
|
920
|
+
if (!el) {
|
|
921
|
+
highlight.style.display = "none";
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const r = el.getBoundingClientRect();
|
|
925
|
+
highlight.style.display = "block";
|
|
926
|
+
highlight.style.left = r.left + "px";
|
|
927
|
+
highlight.style.top = r.top + "px";
|
|
928
|
+
highlight.style.width = r.width + "px";
|
|
929
|
+
highlight.style.height = r.height + "px";
|
|
930
|
+
foot.textContent = `${describe(el)} \u2014 click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip`;
|
|
931
|
+
};
|
|
932
|
+
const stop = (e) => {
|
|
933
|
+
e.preventDefault();
|
|
934
|
+
e.stopImmediatePropagation();
|
|
935
|
+
};
|
|
936
|
+
const isOwn = (el) => el === banner || el === highlight || banner.contains && banner.contains(el);
|
|
937
|
+
let bannerDocked = "top";
|
|
938
|
+
const dockBanner = (side) => {
|
|
939
|
+
if (bannerDocked === side) return;
|
|
940
|
+
bannerDocked = side;
|
|
941
|
+
if (side === "bottom") {
|
|
942
|
+
banner.style.top = "auto";
|
|
943
|
+
banner.style.bottom = "12px";
|
|
944
|
+
} else {
|
|
945
|
+
banner.style.top = "12px";
|
|
946
|
+
banner.style.bottom = "auto";
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
const onMove = (e) => {
|
|
950
|
+
const raw = e.target;
|
|
951
|
+
if (!raw || isOwn(raw)) {
|
|
952
|
+
highlight.style.display = "none";
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
if (raw !== lastRaw) {
|
|
956
|
+
lastRaw = raw;
|
|
957
|
+
chain = buildChain(raw);
|
|
958
|
+
idx = snapIndex(chain);
|
|
959
|
+
}
|
|
960
|
+
refresh();
|
|
961
|
+
const el = current();
|
|
962
|
+
if (el) {
|
|
963
|
+
const r = el.getBoundingClientRect();
|
|
964
|
+
const br = banner.getBoundingClientRect();
|
|
965
|
+
const margin = 8;
|
|
966
|
+
if (r.left < br.right + margin && r.right > br.left - margin && r.top < br.bottom + margin && r.bottom > br.top - margin) {
|
|
967
|
+
dockBanner(bannerDocked === "top" ? "bottom" : "top");
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
const onClick = (e) => {
|
|
972
|
+
stop(e);
|
|
973
|
+
const el = current();
|
|
974
|
+
if (!el || isOwn(e.target)) return;
|
|
975
|
+
g.__piwiPickedElement = el;
|
|
976
|
+
g.__piwiPickState = "picked";
|
|
977
|
+
removeListeners();
|
|
978
|
+
highlight.style.display = "none";
|
|
979
|
+
foot.textContent = "Analyzing element\u2026";
|
|
980
|
+
};
|
|
981
|
+
const onKey = (e) => {
|
|
982
|
+
if (e.key === "Escape") {
|
|
983
|
+
stop(e);
|
|
984
|
+
g.__piwiPickState = "skipped";
|
|
985
|
+
cleanup();
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
|
989
|
+
stop(e);
|
|
990
|
+
if (e.key === "ArrowUp") idx = Math.min(idx + 1, chain.length - 1);
|
|
991
|
+
else idx = Math.max(idx - 1, 0);
|
|
992
|
+
refresh();
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
const suppressed = ["mousedown", "mouseup", "pointerdown", "pointerup", "auxclick", "dblclick"];
|
|
996
|
+
const removeListeners = () => {
|
|
997
|
+
doc.removeEventListener("mousemove", onMove, true);
|
|
998
|
+
doc.removeEventListener("click", onClick, true);
|
|
999
|
+
doc.removeEventListener("keydown", onKey, true);
|
|
1000
|
+
for (const t of suppressed) doc.removeEventListener(t, stop, true);
|
|
1001
|
+
};
|
|
1002
|
+
const cleanup = () => {
|
|
1003
|
+
removeListeners();
|
|
1004
|
+
highlight.remove();
|
|
1005
|
+
banner.remove();
|
|
1006
|
+
};
|
|
1007
|
+
g.__piwiPickCleanup = cleanup;
|
|
1008
|
+
doc.addEventListener("mousemove", onMove, true);
|
|
1009
|
+
doc.addEventListener("click", onClick, true);
|
|
1010
|
+
doc.addEventListener("keydown", onKey, true);
|
|
1011
|
+
for (const t of suppressed) doc.addEventListener(t, stop, true);
|
|
102
1012
|
}
|
|
103
|
-
function
|
|
1013
|
+
function showAnchorPicker(arg) {
|
|
1014
|
+
const g = globalThis;
|
|
1015
|
+
const doc = g.document;
|
|
1016
|
+
const el = g.__piwiPickedElement;
|
|
1017
|
+
if (!doc || !doc.body || !el) {
|
|
1018
|
+
g.__piwiAnchorState = "skipped";
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const Z = 2147483600;
|
|
1022
|
+
const { tagRoles, inputRoles, roleSources, leafRole, leafLevel, leafTestId } = arg;
|
|
1023
|
+
const roleOf = (n) => {
|
|
1024
|
+
const explicit = n.getAttribute && n.getAttribute("role");
|
|
1025
|
+
if (explicit) return explicit;
|
|
1026
|
+
const tag = (n.tagName || "").toLowerCase();
|
|
1027
|
+
if (tag === "input") return inputRoles[(n.getAttribute("type") || "text").toLowerCase()] ?? "textbox";
|
|
1028
|
+
if (tag === "select") return n.getAttribute("multiple") != null ? "listbox" : "combobox";
|
|
1029
|
+
if (tag === "a") return n.getAttribute("href") != null ? "link" : null;
|
|
1030
|
+
return tagRoles[tag] ?? null;
|
|
1031
|
+
};
|
|
1032
|
+
const levelOf = (n) => {
|
|
1033
|
+
const m = /^h([1-6])$/.exec((n.tagName || "").toLowerCase());
|
|
1034
|
+
if (m) return Number(m[1]);
|
|
1035
|
+
const al = n.getAttribute && n.getAttribute("aria-level");
|
|
1036
|
+
return al && /^\d+$/.test(al) ? Number(al) : null;
|
|
1037
|
+
};
|
|
1038
|
+
const leafMatches = (scope) => {
|
|
104
1039
|
try {
|
|
105
|
-
|
|
1040
|
+
if (leafTestId) return scope.querySelectorAll(`[data-testid=${JSON.stringify(leafTestId)}]`).length;
|
|
1041
|
+
const nodes = scope.querySelectorAll(roleSources);
|
|
1042
|
+
if (nodes.length > 2e3) return -1;
|
|
1043
|
+
let matched = 0;
|
|
1044
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
1045
|
+
const n = nodes[i];
|
|
1046
|
+
if (roleOf(n) !== leafRole) continue;
|
|
1047
|
+
if (leafLevel != null && levelOf(n) !== leafLevel) continue;
|
|
1048
|
+
matched++;
|
|
1049
|
+
}
|
|
1050
|
+
return matched;
|
|
1051
|
+
} catch {
|
|
1052
|
+
return -1;
|
|
106
1053
|
}
|
|
107
|
-
|
|
108
|
-
|
|
1054
|
+
};
|
|
1055
|
+
let roleNodes = [];
|
|
1056
|
+
try {
|
|
1057
|
+
const all = doc.querySelectorAll(roleSources);
|
|
1058
|
+
if (all.length <= 4e3) roleNodes = Array.from(all);
|
|
1059
|
+
} catch {
|
|
1060
|
+
roleNodes = [];
|
|
1061
|
+
}
|
|
1062
|
+
const count = (sel) => {
|
|
1063
|
+
try {
|
|
1064
|
+
return doc.querySelectorAll(sel).length;
|
|
1065
|
+
} catch {
|
|
1066
|
+
return void 0;
|
|
109
1067
|
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
1068
|
+
};
|
|
1069
|
+
const rows = [];
|
|
1070
|
+
let node = el.parentElement;
|
|
1071
|
+
let depth = 0;
|
|
1072
|
+
while (node && depth < 12) {
|
|
1073
|
+
depth++;
|
|
1074
|
+
const tag = (node.tagName || "").toLowerCase();
|
|
1075
|
+
if (tag === "body" || tag === "html") break;
|
|
1076
|
+
const testId = node.getAttribute("data-testid");
|
|
1077
|
+
const id = node.getAttribute("id");
|
|
1078
|
+
const ariaLabel = node.getAttribute("aria-label");
|
|
1079
|
+
const role = roleOf(node);
|
|
1080
|
+
const info = { tag, depth, testId: testId || null, id: id || null, ariaLabel: ariaLabel || null, role };
|
|
1081
|
+
if (testId) info.testIdCount = count(`[data-testid=${JSON.stringify(testId)}]`);
|
|
1082
|
+
if (id) {
|
|
1083
|
+
try {
|
|
1084
|
+
info.idCount = count(`#${doc.defaultView.CSS.escape(id)}`);
|
|
1085
|
+
} catch {
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (role) {
|
|
1089
|
+
let roleCount = 0;
|
|
1090
|
+
let labeledCount = 0;
|
|
1091
|
+
for (const n of roleNodes) {
|
|
1092
|
+
if (roleOf(n) !== role) continue;
|
|
1093
|
+
roleCount++;
|
|
1094
|
+
if (ariaLabel && n.getAttribute && n.getAttribute("aria-label") === ariaLabel) labeledCount++;
|
|
1095
|
+
}
|
|
1096
|
+
info.roleCount = roleCount;
|
|
1097
|
+
if (ariaLabel) info.labeledRoleCount = labeledCount;
|
|
1098
|
+
}
|
|
1099
|
+
info.scopedLeafCount = leafMatches(node);
|
|
1100
|
+
const hookLabel = testId ? `data-testid="${testId}"` : id ? `#${id}` : ariaLabel && role ? `${role} "${ariaLabel}"` : role ? `role ${role}` : "no stable hook";
|
|
1101
|
+
rows.push({
|
|
1102
|
+
node,
|
|
1103
|
+
info,
|
|
1104
|
+
hookLabel,
|
|
1105
|
+
selectable: !!(testId || id || role && (ariaLabel || info.roleCount === 1))
|
|
1106
|
+
});
|
|
1107
|
+
node = node.parentElement;
|
|
1108
|
+
}
|
|
1109
|
+
if (rows.length === 0) {
|
|
1110
|
+
g.__piwiAnchorState = "skipped";
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
const outline = doc.createElement("div");
|
|
1114
|
+
outline.style.cssText = `position:fixed;pointer-events:none;z-index:${Z};box-sizing:border-box;border:2px solid #22c55e;border-radius:3px;display:none;`;
|
|
1115
|
+
const pickedOutline = doc.createElement("div");
|
|
1116
|
+
const pr = el.getBoundingClientRect();
|
|
1117
|
+
pickedOutline.style.cssText = `position:fixed;pointer-events:none;z-index:${Z};box-sizing:border-box;border:2px solid #7c3aed;background:rgba(124,58,237,.10);border-radius:3px;left:${pr.left}px;top:${pr.top}px;width:${pr.width}px;height:${pr.height}px;`;
|
|
1118
|
+
const panel = doc.createElement("div");
|
|
1119
|
+
panel.style.cssText = `position:fixed;top:12px;right:12px;z-index:${Z + 3};width:340px;max-height:82vh;overflow:auto;background:#111827;color:#f9fafb;border-radius:10px;padding:16px;font:12px/1.5 system-ui,sans-serif;box-shadow:0 8px 40px rgba(0,0,0,.5);`;
|
|
1120
|
+
const title = doc.createElement("div");
|
|
1121
|
+
title.style.cssText = "font-weight:600;font-size:13px;margin-bottom:2px;";
|
|
1122
|
+
title.textContent = "Scope to stable parents (optional)";
|
|
1123
|
+
const sub = doc.createElement("div");
|
|
1124
|
+
sub.style.cssText = "color:#9ca3af;margin-bottom:10px;";
|
|
1125
|
+
sub.textContent = "Pick one or more parents to anchor the locator to. Hover a row to see the parent.";
|
|
1126
|
+
panel.appendChild(title);
|
|
1127
|
+
panel.appendChild(sub);
|
|
1128
|
+
const selected = /* @__PURE__ */ new Set();
|
|
1129
|
+
const footer = doc.createElement("div");
|
|
1130
|
+
footer.style.cssText = "margin:10px 0;font-weight:600;";
|
|
1131
|
+
const segMatches = (scope, info) => {
|
|
113
1132
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 };
|
|
1133
|
+
if (info.testId) return Array.from(scope.querySelectorAll(`[data-testid=${JSON.stringify(info.testId)}]`));
|
|
1134
|
+
if (info.id) return Array.from(scope.querySelectorAll(`#${doc.defaultView.CSS.escape(info.id)}`));
|
|
1135
|
+
const nodes = Array.from(scope.querySelectorAll(roleSources));
|
|
1136
|
+
if (nodes.length > 2e3) return [];
|
|
1137
|
+
return nodes.filter(
|
|
1138
|
+
(n) => roleOf(n) === info.role && (!info.ariaLabel || n.getAttribute && n.getAttribute("aria-label") === info.ariaLabel)
|
|
1139
|
+
);
|
|
1140
|
+
} catch {
|
|
1141
|
+
return [];
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
const chainCount = () => {
|
|
1145
|
+
const chosen = rows.filter((_, i) => selected.has(i)).sort((a, b) => b.info.depth - a.info.depth);
|
|
1146
|
+
if (chosen.length === 0) return -1;
|
|
1147
|
+
let scopes = [doc];
|
|
1148
|
+
for (const row of chosen) {
|
|
1149
|
+
const next = [];
|
|
1150
|
+
for (const s of scopes) next.push(...segMatches(s, row.info));
|
|
1151
|
+
scopes = next.slice(0, 200);
|
|
1152
|
+
if (scopes.length === 0) return 0;
|
|
1153
|
+
}
|
|
1154
|
+
let total = 0;
|
|
1155
|
+
for (const s of scopes) {
|
|
1156
|
+
const c = leafMatches(s);
|
|
1157
|
+
if (c > 0) total += c;
|
|
1158
|
+
if (total > 50) return total;
|
|
1159
|
+
}
|
|
1160
|
+
return total;
|
|
1161
|
+
};
|
|
1162
|
+
const refreshFooter = () => {
|
|
1163
|
+
if (selected.size === 0) {
|
|
1164
|
+
footer.textContent = "No parents selected \u2014 standard alternatives only.";
|
|
1165
|
+
footer.style.color = "#9ca3af";
|
|
1166
|
+
g.__piwiPickChainCount = void 0;
|
|
1167
|
+
return;
|
|
197
1168
|
}
|
|
198
|
-
|
|
199
|
-
|
|
1169
|
+
const c = chainCount();
|
|
1170
|
+
g.__piwiPickChainCount = c;
|
|
1171
|
+
if (c === 1) {
|
|
1172
|
+
footer.textContent = "\u2713 Selection matches exactly 1 element";
|
|
1173
|
+
footer.style.color = "#4ade80";
|
|
1174
|
+
} else {
|
|
1175
|
+
footer.textContent = c < 0 ? "Match count unavailable" : `\u2717 Selection matches ${c} elements`;
|
|
1176
|
+
footer.style.color = "#fbbf24";
|
|
200
1177
|
}
|
|
1178
|
+
};
|
|
1179
|
+
rows.forEach((row, i) => {
|
|
1180
|
+
const line = doc.createElement("label");
|
|
1181
|
+
line.style.cssText = `display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid #374151;border-radius:6px;margin-bottom:6px;cursor:${row.selectable ? "pointer" : "default"};opacity:${row.selectable ? "1" : ".45"};`;
|
|
1182
|
+
const box = doc.createElement("input");
|
|
1183
|
+
box.type = "checkbox";
|
|
1184
|
+
box.disabled = !row.selectable;
|
|
1185
|
+
const text = doc.createElement("span");
|
|
1186
|
+
text.style.cssText = "flex:1;min-width:0;";
|
|
1187
|
+
const code = doc.createElement("code");
|
|
1188
|
+
code.style.cssText = "display:block;font:11px ui-monospace,monospace;color:#e5e7eb;word-break:break-all;";
|
|
1189
|
+
code.textContent = `<${row.info.tag}> ${row.hookLabel}`;
|
|
1190
|
+
const hint = doc.createElement("span");
|
|
1191
|
+
hint.style.cssText = "color:#9ca3af;";
|
|
1192
|
+
hint.textContent = row.selectable ? row.info.scopedLeafCount === 1 ? "contains exactly 1 matching element" : `contains ${row.info.scopedLeafCount ?? "?"} matching elements` : "add a data-testid to make this usable";
|
|
1193
|
+
text.appendChild(code);
|
|
1194
|
+
text.appendChild(hint);
|
|
1195
|
+
line.appendChild(box);
|
|
1196
|
+
line.appendChild(text);
|
|
1197
|
+
line.addEventListener("mouseenter", () => {
|
|
1198
|
+
const r = row.node.getBoundingClientRect();
|
|
1199
|
+
outline.style.display = "block";
|
|
1200
|
+
outline.style.left = r.left + "px";
|
|
1201
|
+
outline.style.top = r.top + "px";
|
|
1202
|
+
outline.style.width = r.width + "px";
|
|
1203
|
+
outline.style.height = r.height + "px";
|
|
1204
|
+
});
|
|
1205
|
+
line.addEventListener("mouseleave", () => {
|
|
1206
|
+
outline.style.display = "none";
|
|
1207
|
+
});
|
|
1208
|
+
box.addEventListener("change", () => {
|
|
1209
|
+
if (box.checked) selected.add(i);
|
|
1210
|
+
else selected.delete(i);
|
|
1211
|
+
refreshFooter();
|
|
1212
|
+
});
|
|
1213
|
+
panel.appendChild(line);
|
|
1214
|
+
});
|
|
1215
|
+
panel.appendChild(footer);
|
|
1216
|
+
const cleanup = () => {
|
|
1217
|
+
doc.removeEventListener("keydown", onKey, true);
|
|
1218
|
+
panel.remove();
|
|
1219
|
+
outline.remove();
|
|
1220
|
+
pickedOutline.remove();
|
|
1221
|
+
};
|
|
1222
|
+
const done = (state) => {
|
|
1223
|
+
g.__piwiPickAnchors = state === "done" ? rows.filter((_, i) => selected.has(i)).map((r) => r.info) : [];
|
|
1224
|
+
g.__piwiAnchorState = state;
|
|
1225
|
+
cleanup();
|
|
1226
|
+
};
|
|
1227
|
+
const onKey = (e) => {
|
|
1228
|
+
if (e.key !== "Escape") return;
|
|
1229
|
+
e.preventDefault();
|
|
1230
|
+
e.stopImmediatePropagation();
|
|
1231
|
+
done("skipped");
|
|
1232
|
+
};
|
|
1233
|
+
const buttonRow = doc.createElement("div");
|
|
1234
|
+
buttonRow.style.cssText = "display:flex;gap:8px;margin-top:4px;";
|
|
1235
|
+
const useBtn = doc.createElement("button");
|
|
1236
|
+
useBtn.style.cssText = "flex:1;background:#7c3aed;color:#fff;border:none;border-radius:6px;padding:8px;cursor:pointer;font:600 12px system-ui;";
|
|
1237
|
+
useBtn.textContent = "Use selected parents";
|
|
1238
|
+
useBtn.addEventListener("click", (e) => {
|
|
1239
|
+
e.preventDefault();
|
|
1240
|
+
e.stopImmediatePropagation();
|
|
1241
|
+
done(selected.size > 0 ? "done" : "skipped");
|
|
1242
|
+
});
|
|
1243
|
+
const skipBtn = doc.createElement("button");
|
|
1244
|
+
skipBtn.style.cssText = "background:none;border:1px solid #374151;color:#9ca3af;border-radius:6px;padding:8px 10px;cursor:pointer;font:12px system-ui;";
|
|
1245
|
+
skipBtn.textContent = "Skip (Esc)";
|
|
1246
|
+
skipBtn.addEventListener("click", (e) => {
|
|
1247
|
+
e.preventDefault();
|
|
1248
|
+
e.stopImmediatePropagation();
|
|
1249
|
+
done("skipped");
|
|
1250
|
+
});
|
|
1251
|
+
buttonRow.appendChild(useBtn);
|
|
1252
|
+
buttonRow.appendChild(skipBtn);
|
|
1253
|
+
panel.appendChild(buttonRow);
|
|
1254
|
+
refreshFooter();
|
|
1255
|
+
g.__piwiAnchorCleanup = cleanup;
|
|
1256
|
+
doc.addEventListener("keydown", onKey, true);
|
|
1257
|
+
doc.body.appendChild(pickedOutline);
|
|
1258
|
+
doc.body.appendChild(outline);
|
|
1259
|
+
doc.body.appendChild(panel);
|
|
201
1260
|
}
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
1261
|
+
function showPickerChoices(arg) {
|
|
1262
|
+
const g = globalThis;
|
|
1263
|
+
const doc = g.document;
|
|
1264
|
+
if (!doc || !doc.body) {
|
|
1265
|
+
g.__piwiPickChoice = -1;
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
const Z = 2147483600;
|
|
1269
|
+
const wrap = doc.createElement("div");
|
|
1270
|
+
wrap.style.cssText = `position:fixed;inset:0;z-index:${Z + 3};background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;font:13px/1.5 system-ui,sans-serif;`;
|
|
1271
|
+
const panel = doc.createElement("div");
|
|
1272
|
+
panel.style.cssText = "background:#111827;color:#f9fafb;border-radius:10px;padding:20px;max-width:640px;width:90vw;max-height:70vh;overflow:auto;box-shadow:0 8px 40px rgba(0,0,0,.5);";
|
|
1273
|
+
const hlLocator = (expr) => {
|
|
1274
|
+
const escHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1275
|
+
const re = /('(?:\\.|[^'])*'|"(?:\\.|[^"])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
|
|
1276
|
+
let html = "";
|
|
1277
|
+
let last = 0;
|
|
1278
|
+
let m;
|
|
1279
|
+
while ((m = re.exec(expr)) !== null) {
|
|
1280
|
+
if (m.index > last) html += escHtml(expr.slice(last, m.index));
|
|
1281
|
+
const color = m[1] ? "#4ade80" : m[2] ? "#c084fc" : m[3] ? "#93c5fd" : m[4] ? "#fbbf24" : "#9ca3af";
|
|
1282
|
+
html += `<span style="color:${color}">${escHtml(m[0])}</span>`;
|
|
1283
|
+
last = re.lastIndex;
|
|
219
1284
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
1285
|
+
if (last < expr.length) html += escHtml(expr.slice(last));
|
|
1286
|
+
return html;
|
|
1287
|
+
};
|
|
1288
|
+
const title = doc.createElement("div");
|
|
1289
|
+
title.style.cssText = "font-weight:600;margin-bottom:4px;";
|
|
1290
|
+
title.textContent = arg.failing ? "Pick a replacement locator" : "Pick a locator";
|
|
1291
|
+
const sub = doc.createElement("div");
|
|
1292
|
+
sub.style.cssText = "color:#9ca3af;margin-bottom:12px;";
|
|
1293
|
+
if (arg.failing) {
|
|
1294
|
+
sub.innerHTML = `Replaces <code style="font-family:ui-monospace,Menlo,monospace">${hlLocator(arg.failing)}</code> \u2014 ranked by stability score.`;
|
|
1295
|
+
} else {
|
|
1296
|
+
sub.textContent = "For the element you picked \u2014 ranked by stability score.";
|
|
1297
|
+
}
|
|
1298
|
+
panel.appendChild(title);
|
|
1299
|
+
panel.appendChild(sub);
|
|
1300
|
+
const done = (choice) => {
|
|
1301
|
+
g.__piwiPickChoice = choice;
|
|
1302
|
+
doc.removeEventListener("keydown", onKey, true);
|
|
1303
|
+
wrap.remove();
|
|
1304
|
+
};
|
|
1305
|
+
const onKey = (e) => {
|
|
1306
|
+
if (e.key !== "Escape") return;
|
|
1307
|
+
e.preventDefault();
|
|
1308
|
+
e.stopImmediatePropagation();
|
|
1309
|
+
done(-1);
|
|
1310
|
+
};
|
|
1311
|
+
arg.choices.forEach((c, i) => {
|
|
1312
|
+
const btn = doc.createElement("button");
|
|
1313
|
+
btn.style.cssText = "display:flex;justify-content:space-between;align-items:center;gap:12px;width:100%;text-align:left;background:#1f2937;color:#e5e7eb;border:1px solid #374151;border-radius:6px;padding:8px 12px;margin:0 0 8px;cursor:pointer;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;";
|
|
1314
|
+
const code = doc.createElement("span");
|
|
1315
|
+
code.innerHTML = hlLocator(c.locator);
|
|
1316
|
+
code.style.cssText = "word-break:break-all;";
|
|
1317
|
+
const score = doc.createElement("span");
|
|
1318
|
+
score.textContent = String(c.score);
|
|
1319
|
+
score.style.cssText = "color:#a78bfa;flex-shrink:0;";
|
|
1320
|
+
btn.appendChild(code);
|
|
1321
|
+
btn.appendChild(score);
|
|
1322
|
+
btn.addEventListener("click", (e) => {
|
|
1323
|
+
e.preventDefault();
|
|
1324
|
+
e.stopImmediatePropagation();
|
|
1325
|
+
done(i);
|
|
1326
|
+
});
|
|
1327
|
+
panel.appendChild(btn);
|
|
1328
|
+
});
|
|
1329
|
+
const skip = doc.createElement("button");
|
|
1330
|
+
skip.style.cssText = "background:none;border:none;color:#9ca3af;cursor:pointer;padding:6px 0 0;font:12px system-ui,sans-serif;";
|
|
1331
|
+
skip.textContent = "Skip \u2014 keep the failure as-is (Esc)";
|
|
1332
|
+
skip.addEventListener("click", (e) => {
|
|
1333
|
+
e.preventDefault();
|
|
1334
|
+
e.stopImmediatePropagation();
|
|
1335
|
+
done(-1);
|
|
1336
|
+
});
|
|
1337
|
+
panel.appendChild(skip);
|
|
1338
|
+
doc.addEventListener("keydown", onKey, true);
|
|
1339
|
+
wrap.appendChild(panel);
|
|
1340
|
+
doc.body.appendChild(wrap);
|
|
1341
|
+
}
|
|
1342
|
+
async function cleanupPicker(page) {
|
|
1343
|
+
try {
|
|
1344
|
+
await page.evaluate(() => {
|
|
1345
|
+
const g = globalThis;
|
|
1346
|
+
if (typeof g.__piwiPickCleanup === "function") g.__piwiPickCleanup();
|
|
1347
|
+
if (typeof g.__piwiAnchorCleanup === "function") g.__piwiAnchorCleanup();
|
|
1348
|
+
delete g.__piwiPickCleanup;
|
|
1349
|
+
delete g.__piwiAnchorCleanup;
|
|
1350
|
+
delete g.__piwiPickState;
|
|
1351
|
+
delete g.__piwiPickChoice;
|
|
1352
|
+
delete g.__piwiPickedElement;
|
|
1353
|
+
delete g.__piwiAnchorState;
|
|
1354
|
+
delete g.__piwiPickAnchors;
|
|
1355
|
+
delete g.__piwiPickChainCount;
|
|
1356
|
+
});
|
|
1357
|
+
} catch {
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
async function runLocatorPicker(page, testInfo, failed, probe) {
|
|
1361
|
+
try {
|
|
1362
|
+
testInfo.setTimeout(0);
|
|
1363
|
+
const rendered = failed ? renderFailing(failed) : null;
|
|
1364
|
+
console.log(
|
|
1365
|
+
`
|
|
1366
|
+
[piwi] "${testInfo.title}" ${testInfo.status} \u2014 ${rendered ? "locator picker" : "inspector"} open in the browser: ${rendered ? `click the element that should replace ${rendered}` : "click any element to generate locators for it"} (\u2191/\u2193 to select a parent/child, Esc to skip).`
|
|
1367
|
+
);
|
|
1368
|
+
await page.evaluate(installPickerOverlay, { failing: rendered });
|
|
1369
|
+
await page.waitForFunction(() => globalThis.__piwiPickState !== void 0, void 0, {
|
|
1370
|
+
timeout: 0,
|
|
1371
|
+
polling: 250
|
|
1372
|
+
});
|
|
1373
|
+
const state = await page.evaluate(() => globalThis.__piwiPickState);
|
|
1374
|
+
if (state !== "picked") {
|
|
1375
|
+
await cleanupPicker(page);
|
|
1376
|
+
return null;
|
|
1377
|
+
}
|
|
1378
|
+
const handle = await page.evaluateHandle(() => globalThis.__piwiPickedElement);
|
|
1379
|
+
const attrs = await handle.evaluate(probe.fn, probe.arg);
|
|
1380
|
+
await handle.dispose();
|
|
1381
|
+
const accessibleName = approximateAccessibleName({ ...attrs, accessibleName: null });
|
|
1382
|
+
const role = resolveAriaRole({ ...attrs, accessibleName });
|
|
1383
|
+
const level = headingLevel({ ...attrs, accessibleName }, role);
|
|
1384
|
+
let anchors = [];
|
|
1385
|
+
let chainLeafCount;
|
|
1386
|
+
if (role) {
|
|
1387
|
+
const probeArg = probe.arg;
|
|
1388
|
+
await page.evaluate(showAnchorPicker, {
|
|
1389
|
+
tagRoles: probeArg.tagRoles,
|
|
1390
|
+
inputRoles: probeArg.inputRoles,
|
|
1391
|
+
roleSources: probeArg.roleSources,
|
|
1392
|
+
leafRole: role,
|
|
1393
|
+
leafLevel: level,
|
|
1394
|
+
leafTestId: attrs.attributes["data-testid"] ?? null
|
|
1395
|
+
});
|
|
1396
|
+
await page.waitForFunction(() => globalThis.__piwiAnchorState !== void 0, void 0, {
|
|
1397
|
+
timeout: 0,
|
|
1398
|
+
polling: 250
|
|
1399
|
+
});
|
|
1400
|
+
const anchorResult = await page.evaluate(() => {
|
|
1401
|
+
const g = globalThis;
|
|
1402
|
+
return { state: g.__piwiAnchorState, anchors: g.__piwiPickAnchors ?? [], chainCount: g.__piwiPickChainCount };
|
|
1403
|
+
});
|
|
1404
|
+
if (anchorResult.state === "done" && anchorResult.anchors.length > 0) {
|
|
1405
|
+
anchors = anchorResult.anchors;
|
|
1406
|
+
chainLeafCount = anchorResult.chainCount;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
const ranked = mergeCandidates(
|
|
1410
|
+
generateAlternatives({ ...attrs, accessibleName }),
|
|
1411
|
+
generateAnchoredAlternatives({ role, level }, anchors, chainLeafCount)
|
|
1412
|
+
);
|
|
1413
|
+
if (ranked.length === 0) {
|
|
1414
|
+
console.log("[piwi] No stable locator could be generated for the picked element \u2014 nothing recorded.");
|
|
1415
|
+
await cleanupPicker(page);
|
|
1416
|
+
return null;
|
|
1417
|
+
}
|
|
1418
|
+
await page.evaluate(showPickerChoices, {
|
|
1419
|
+
failing: rendered,
|
|
1420
|
+
choices: ranked.map((r) => ({ locator: r.locator, score: r.score }))
|
|
1421
|
+
});
|
|
1422
|
+
await page.waitForFunction(() => globalThis.__piwiPickChoice !== void 0, void 0, {
|
|
1423
|
+
timeout: 0,
|
|
1424
|
+
polling: 250
|
|
1425
|
+
});
|
|
1426
|
+
const choice = await page.evaluate(() => globalThis.__piwiPickChoice);
|
|
1427
|
+
await cleanupPicker(page);
|
|
1428
|
+
if (typeof choice !== "number" || choice < 0 || choice >= ranked.length) return null;
|
|
1429
|
+
const picked = { ...ranked[choice], pickedByUser: true };
|
|
1430
|
+
const alternatives = [picked, ...ranked.filter((_, i) => i !== choice)];
|
|
1431
|
+
const element = {
|
|
1432
|
+
tagName: attrs.tagName,
|
|
1433
|
+
attributes: attrs.attributes,
|
|
1434
|
+
textContent: attrs.textContent,
|
|
1435
|
+
accessibleName,
|
|
1436
|
+
center: attrs.center,
|
|
1437
|
+
...attrs.rolePosition ? { rolePosition: attrs.rolePosition } : {},
|
|
1438
|
+
...attrs.ancestors && attrs.ancestors.length > 0 ? { ancestors: attrs.ancestors } : {}
|
|
1439
|
+
};
|
|
1440
|
+
const location = failed?.location ?? null;
|
|
1441
|
+
console.log(
|
|
1442
|
+
failed ? `[piwi] Replacement picked for ${rendered}${location ? ` at ${location}` : ""}:
|
|
1443
|
+
[piwi] ${picked.locator}` : `[piwi] Locator picked while inspecting:
|
|
1444
|
+
[piwi] ${picked.locator}`
|
|
1445
|
+
);
|
|
224
1446
|
return {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
})),
|
|
1447
|
+
failing: failed ? { method: failed.method, args: failed.args, rendered, location } : null,
|
|
1448
|
+
picked,
|
|
1449
|
+
alternatives,
|
|
1450
|
+
element,
|
|
1451
|
+
...anchors.length > 0 ? { anchors } : {}
|
|
239
1452
|
};
|
|
1453
|
+
} catch {
|
|
1454
|
+
return null;
|
|
1455
|
+
}
|
|
240
1456
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
1457
|
+
function applyPickToSnapshots(snapshots, pick) {
|
|
1458
|
+
if (!pick.failing?.location) return false;
|
|
1459
|
+
const failing = pick.failing;
|
|
1460
|
+
for (let i = snapshots.length - 1; i >= 0; i--) {
|
|
1461
|
+
const snap = snapshots[i];
|
|
1462
|
+
if (snap.location !== failing.location || snap.element) continue;
|
|
1463
|
+
snapshots[i] = {
|
|
1464
|
+
location: snap.location,
|
|
1465
|
+
used: snap.used,
|
|
1466
|
+
element: pick.element,
|
|
1467
|
+
alternatives: pick.alternatives.slice(0, 10)
|
|
1468
|
+
};
|
|
1469
|
+
return true;
|
|
1470
|
+
}
|
|
1471
|
+
snapshots.push({
|
|
1472
|
+
location: failing.location,
|
|
1473
|
+
used: {
|
|
1474
|
+
method: failing.method,
|
|
1475
|
+
args: failing.args,
|
|
1476
|
+
raw: `${failing.method}(${JSON.stringify(failing.args)})`
|
|
1477
|
+
},
|
|
1478
|
+
element: pick.element,
|
|
1479
|
+
alternatives: pick.alternatives.slice(0, 10)
|
|
1480
|
+
});
|
|
1481
|
+
return true;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// src/internal/capture/capture-fixtures.ts
|
|
1485
|
+
function computeCoreVitals(lcpEntries, shiftEntries, eventEntries) {
|
|
1486
|
+
const lastLcp = lcpEntries && lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : null;
|
|
1487
|
+
const lcp = lastLcp && typeof lastLcp.startTime === "number" ? Math.round(lastLcp.startTime) : null;
|
|
1488
|
+
let cls = null;
|
|
1489
|
+
if (shiftEntries) {
|
|
1490
|
+
const sum = shiftEntries.reduce(
|
|
1491
|
+
(acc, e) => acc + (e.hadRecentInput ? 0 : typeof e.value === "number" ? e.value : 0),
|
|
1492
|
+
0
|
|
1493
|
+
);
|
|
1494
|
+
cls = Math.round(sum * 1e4) / 1e4;
|
|
1495
|
+
}
|
|
1496
|
+
let inp = null;
|
|
1497
|
+
if (eventEntries && eventEntries.length > 0) {
|
|
1498
|
+
const byInteraction = /* @__PURE__ */ new Map();
|
|
1499
|
+
for (const e of eventEntries) {
|
|
1500
|
+
if (typeof e.interactionId !== "number" || e.interactionId <= 0) continue;
|
|
1501
|
+
const duration = typeof e.duration === "number" ? e.duration : 0;
|
|
1502
|
+
const prev = byInteraction.get(e.interactionId) ?? 0;
|
|
1503
|
+
if (duration > prev) byInteraction.set(e.interactionId, duration);
|
|
1504
|
+
}
|
|
1505
|
+
const durations = [...byInteraction.values()].sort((a, b) => a - b);
|
|
1506
|
+
if (durations.length > 0) {
|
|
1507
|
+
const index = durations.length > 50 ? Math.floor(durations.length * 0.98) : durations.length - 1;
|
|
1508
|
+
inp = Math.round(durations[Math.min(index, durations.length - 1)]);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
if (lcp === null && cls === null && inp === null) return null;
|
|
1512
|
+
return { lcp, cls, inp };
|
|
1513
|
+
}
|
|
1514
|
+
function createSink() {
|
|
1515
|
+
return {
|
|
1516
|
+
networkRequests: [],
|
|
1517
|
+
consoleEntries: [],
|
|
1518
|
+
pendingHandlers: [],
|
|
1519
|
+
capturedLocators: [],
|
|
1520
|
+
capturePromises: [],
|
|
1521
|
+
failedLocators: [],
|
|
1522
|
+
lastActivePage: null,
|
|
1523
|
+
testInfo: null,
|
|
1524
|
+
stashedWebVitals: null,
|
|
1525
|
+
stashedPageState: null,
|
|
1526
|
+
stashedAria: null,
|
|
1527
|
+
pickOffered: false,
|
|
1528
|
+
userPick: null
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
var currentSink = null;
|
|
1532
|
+
var PENDING_PROBES = /* @__PURE__ */ new Set();
|
|
1533
|
+
async function drainPendingProbes(capMs) {
|
|
1534
|
+
if (PENDING_PROBES.size === 0) return;
|
|
1535
|
+
let cap;
|
|
1536
|
+
await Promise.race([
|
|
1537
|
+
Promise.allSettled(PENDING_PROBES),
|
|
1538
|
+
new Promise((resolve) => {
|
|
1539
|
+
cap = setTimeout(resolve, capMs);
|
|
1540
|
+
})
|
|
1541
|
+
]);
|
|
1542
|
+
clearTimeout(cap);
|
|
1543
|
+
}
|
|
1544
|
+
function isPageClosed(page) {
|
|
1545
|
+
try {
|
|
1546
|
+
return typeof page.isClosed === "function" ? page.isClosed() : false;
|
|
1547
|
+
} catch {
|
|
1548
|
+
return false;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
function pageContext(page) {
|
|
1552
|
+
try {
|
|
1553
|
+
return typeof page.context === "function" ? page.context() : null;
|
|
1554
|
+
} catch {
|
|
1555
|
+
return null;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
async function readWebVitals(page) {
|
|
1559
|
+
try {
|
|
1560
|
+
const probe = await page.evaluate(async () => {
|
|
1561
|
+
const navEntries = performance.getEntriesByType("navigation");
|
|
1562
|
+
const paintEntries = performance.getEntriesByType("paint");
|
|
1563
|
+
const nav = navEntries[0];
|
|
1564
|
+
const navigation = nav ? {
|
|
1565
|
+
url: nav.name,
|
|
1566
|
+
ttfb: Math.round(nav.responseStart - nav.fetchStart),
|
|
1567
|
+
domInteractive: Math.round(nav.domInteractive - nav.fetchStart),
|
|
1568
|
+
domContentLoaded: Math.round(nav.domContentLoadedEventEnd - nav.fetchStart),
|
|
1569
|
+
loadComplete: Math.round(nav.loadEventEnd - nav.fetchStart),
|
|
1570
|
+
transferSize: nav.transferSize || 0,
|
|
1571
|
+
encodedBodySize: nav.encodedBodySize || 0,
|
|
1572
|
+
decodedBodySize: nav.decodedBodySize || 0
|
|
1573
|
+
} : null;
|
|
1574
|
+
const paint = {};
|
|
1575
|
+
for (const entry of paintEntries) {
|
|
1576
|
+
const key = entry.name.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
|
|
1577
|
+
paint[key] = Math.round(entry.startTime);
|
|
1578
|
+
}
|
|
1579
|
+
const readBuffered = (type, extra) => new Promise((resolve) => {
|
|
1580
|
+
try {
|
|
1581
|
+
const PO = globalThis.PerformanceObserver;
|
|
1582
|
+
if (!PO || !(PO.supportedEntryTypes || []).includes(type)) return resolve(null);
|
|
1583
|
+
const out = [];
|
|
1584
|
+
const po = new PO((list) => out.push(...list.getEntries()));
|
|
1585
|
+
po.observe({ type, buffered: true, ...extra });
|
|
1586
|
+
setTimeout(() => {
|
|
262
1587
|
try {
|
|
263
|
-
|
|
1588
|
+
out.push(...po.takeRecords());
|
|
1589
|
+
po.disconnect();
|
|
1590
|
+
} catch {
|
|
264
1591
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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;
|
|
1592
|
+
resolve(out);
|
|
1593
|
+
}, 0);
|
|
1594
|
+
} catch {
|
|
1595
|
+
resolve(null);
|
|
282
1596
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
1597
|
+
});
|
|
1598
|
+
const [lcpRaw, shiftRaw, eventRaw, firstInputRaw] = await Promise.all([
|
|
1599
|
+
readBuffered("largest-contentful-paint"),
|
|
1600
|
+
readBuffered("layout-shift"),
|
|
1601
|
+
// durationThreshold 40 mirrors the web-vitals library — captures every
|
|
1602
|
+
// interaction slow enough to matter without flooding the buffer.
|
|
1603
|
+
readBuffered("event", { durationThreshold: 40 }),
|
|
1604
|
+
readBuffered("first-input")
|
|
1605
|
+
]);
|
|
1606
|
+
const project = (entries) => entries === null ? null : entries.map((e) => ({
|
|
1607
|
+
startTime: e.startTime,
|
|
1608
|
+
value: e.value,
|
|
1609
|
+
hadRecentInput: e.hadRecentInput,
|
|
1610
|
+
interactionId: e.interactionId,
|
|
1611
|
+
duration: e.duration
|
|
1612
|
+
}));
|
|
1613
|
+
const interactionEntries = eventRaw === null && firstInputRaw === null ? null : [...eventRaw ?? [], ...firstInputRaw ?? []];
|
|
1614
|
+
return {
|
|
1615
|
+
navigation,
|
|
1616
|
+
paint,
|
|
1617
|
+
lcpEntries: project(lcpRaw),
|
|
1618
|
+
shiftEntries: project(shiftRaw),
|
|
1619
|
+
eventEntries: project(interactionEntries)
|
|
1620
|
+
};
|
|
1621
|
+
});
|
|
1622
|
+
if (!probe) return null;
|
|
1623
|
+
const vitals = computeCoreVitals(probe.lcpEntries, probe.shiftEntries, probe.eventEntries);
|
|
1624
|
+
if (!probe.navigation && Object.keys(probe.paint).length === 0 && !vitals) return null;
|
|
1625
|
+
return { navigation: probe.navigation, paint: probe.paint, vitals };
|
|
1626
|
+
} catch {
|
|
1627
|
+
return null;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
var PAGE_STATE_MAX_STORAGE_KEYS = 50;
|
|
1631
|
+
var PAGE_STATE_MAX_COOKIES = 30;
|
|
1632
|
+
var PAGE_STATE_HISTORY_CAP = 2048;
|
|
1633
|
+
var TOKEN_MASK_RES = [/\beyJ[\w-]{10,}\.[\w-]{5,}\.[\w-]{5,}\b/g, /\b[0-9a-f]{32,}\b/gi];
|
|
1634
|
+
function buildPageState(raw, cookies) {
|
|
1635
|
+
let historyState = raw.historyState;
|
|
1636
|
+
if (historyState) {
|
|
1637
|
+
for (const re of TOKEN_MASK_RES) historyState = historyState.replace(re, "[masked]");
|
|
1638
|
+
if (historyState.length > PAGE_STATE_HISTORY_CAP) {
|
|
1639
|
+
historyState = historyState.slice(0, PAGE_STATE_HISTORY_CAP) + "\u2026";
|
|
287
1640
|
}
|
|
288
|
-
|
|
289
|
-
|
|
1641
|
+
}
|
|
1642
|
+
const capStorage = (entries) => (Array.isArray(entries) ? entries : []).slice(0, PAGE_STATE_MAX_STORAGE_KEYS).map((e) => ({
|
|
1643
|
+
key: String(e.key).slice(0, 200),
|
|
1644
|
+
length: typeof e.length === "number" ? e.length : 0
|
|
1645
|
+
}));
|
|
1646
|
+
return {
|
|
1647
|
+
url: raw.url,
|
|
1648
|
+
hash: raw.hash || null,
|
|
1649
|
+
historyState: historyState || null,
|
|
1650
|
+
localStorage: capStorage(raw.localStorage),
|
|
1651
|
+
sessionStorage: capStorage(raw.sessionStorage),
|
|
1652
|
+
cookies: (cookies ?? []).slice(0, PAGE_STATE_MAX_COOKIES).map((c) => ({
|
|
1653
|
+
name: String(c.name ?? ""),
|
|
1654
|
+
domain: String(c.domain ?? ""),
|
|
1655
|
+
path: String(c.path ?? ""),
|
|
1656
|
+
httpOnly: Boolean(c.httpOnly),
|
|
1657
|
+
secure: Boolean(c.secure),
|
|
1658
|
+
...c.sameSite !== void 0 ? { sameSite: String(c.sameSite) } : {},
|
|
1659
|
+
...typeof c.expires === "number" ? { expires: c.expires } : {}
|
|
1660
|
+
}))
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
async function readPageState(page) {
|
|
1664
|
+
try {
|
|
1665
|
+
const raw = await page.evaluate(() => {
|
|
1666
|
+
const listStorage = (s) => {
|
|
1667
|
+
const out = [];
|
|
1668
|
+
try {
|
|
1669
|
+
for (let i = 0; i < s.length; i++) {
|
|
1670
|
+
const key = s.key(i);
|
|
1671
|
+
if (key != null) out.push({ key, length: (s.getItem(key) ?? "").length });
|
|
1672
|
+
}
|
|
1673
|
+
} catch {
|
|
1674
|
+
}
|
|
1675
|
+
return out;
|
|
1676
|
+
};
|
|
1677
|
+
const g = globalThis;
|
|
1678
|
+
let historyState = null;
|
|
1679
|
+
try {
|
|
1680
|
+
historyState = g.history?.state == null ? null : JSON.stringify(g.history.state);
|
|
1681
|
+
} catch {
|
|
1682
|
+
}
|
|
1683
|
+
return {
|
|
1684
|
+
url: g.location.href,
|
|
1685
|
+
hash: g.location.hash || null,
|
|
1686
|
+
historyState,
|
|
1687
|
+
localStorage: listStorage(globalThis.localStorage),
|
|
1688
|
+
sessionStorage: listStorage(globalThis.sessionStorage)
|
|
1689
|
+
};
|
|
1690
|
+
});
|
|
1691
|
+
if (!raw) return null;
|
|
1692
|
+
let cookies = null;
|
|
1693
|
+
try {
|
|
1694
|
+
cookies = await pageContext(page)?.cookies() ?? null;
|
|
1695
|
+
} catch {
|
|
1696
|
+
cookies = null;
|
|
290
1697
|
}
|
|
1698
|
+
return buildPageState(raw, cookies);
|
|
1699
|
+
} catch {
|
|
1700
|
+
return null;
|
|
1701
|
+
}
|
|
291
1702
|
}
|
|
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
1703
|
async function stashPageState(sink, closing) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
1704
|
+
const page = sink.lastActivePage;
|
|
1705
|
+
if (!page || isPageClosed(page)) return;
|
|
1706
|
+
const belongsToClosing = closing.page === page || closing.context !== void 0 && pageContext(page) === closing.context;
|
|
1707
|
+
if (!belongsToClosing) return;
|
|
1708
|
+
const vitals = await readWebVitals(page);
|
|
1709
|
+
if (vitals) sink.stashedWebVitals = vitals;
|
|
1710
|
+
if (process.env.PIWI_CAPTURE_PAGE_STATE !== "false") {
|
|
1711
|
+
const pageState = await readPageState(page);
|
|
1712
|
+
if (pageState) sink.stashedPageState = pageState;
|
|
1713
|
+
}
|
|
1714
|
+
const status = sink.testInfo?.status;
|
|
1715
|
+
if (status === "failed" || status === "timedOut" || status === "interrupted") {
|
|
1716
|
+
const aria = await ariaSnapshotBestEffort(page.locator(":root"), 1e3);
|
|
1717
|
+
if (aria) sink.stashedAria = aria;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
async function maybeOpenPicker(sink, closing) {
|
|
1721
|
+
if (sink.pickOffered) return;
|
|
1722
|
+
const page = sink.lastActivePage;
|
|
1723
|
+
const testInfo = sink.testInfo;
|
|
1724
|
+
if (!page || !testInfo || isPageClosed(page)) return;
|
|
1725
|
+
if (closing) {
|
|
1726
|
+
const belongsToClosing = closing.page === page || closing.context !== void 0 && pageContext(page) === closing.context;
|
|
1727
|
+
if (!belongsToClosing) return;
|
|
1728
|
+
}
|
|
1729
|
+
const pickGate = shouldInspectOnFailure(inspectionGateFromTestInfo(testInfo, process.env.PIWI_PICK_LOCATOR_ON_FAIL));
|
|
1730
|
+
const inspectGate = shouldInspectOnFailure(inspectionGateFromTestInfo(testInfo, process.env.PIWI_INSPECT_ON_FAIL));
|
|
1731
|
+
if (!pickGate && !inspectGate) return;
|
|
1732
|
+
sink.pickOffered = true;
|
|
1733
|
+
const failed = sink.failedLocators[sink.failedLocators.length - 1] ?? deriveFailedLocator(testInfo);
|
|
1734
|
+
if (!failed && !inspectGate) {
|
|
1735
|
+
console.log("[piwi] locator picker: no failing locator could be identified in this failure \u2014 nothing to replace.");
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
const pick = await runLocatorPicker(page, testInfo, failed, { fn: probeElementAttrs, arg: CAPTURED_ATTRS_ARG });
|
|
1739
|
+
if (!pick) return;
|
|
1740
|
+
sink.userPick = pick;
|
|
1741
|
+
applyPickToSnapshots(sink.capturedLocators, pick);
|
|
319
1742
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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(','),
|
|
1743
|
+
var INSTRUMENTED_PAGES = /* @__PURE__ */ new WeakSet();
|
|
1744
|
+
var INSTRUMENTED_CONTEXTS = /* @__PURE__ */ new WeakSet();
|
|
1745
|
+
var PATCHED_BROWSERS = /* @__PURE__ */ new WeakSet();
|
|
1746
|
+
var CHAIN_METHOD_SET = new Set(CHAIN_METHODS);
|
|
1747
|
+
var ACTION_METHOD_SET = new Set(ACTION_METHODS);
|
|
1748
|
+
var FORM_FIELD_TAGS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
|
|
1749
|
+
var CAPTURED_ATTRS_ARG = {
|
|
1750
|
+
keep: [...CAPTURED_ATTRIBUTES],
|
|
1751
|
+
tagRoles: TAG_TO_ROLE,
|
|
1752
|
+
inputRoles: INPUT_TYPE_TO_ROLE,
|
|
1753
|
+
// '[role]' plus every tag the maps can resolve (input/select are handled by
|
|
1754
|
+
// special-cased logic in the probe, so add them explicitly).
|
|
1755
|
+
roleSources: [.../* @__PURE__ */ new Set(["[role]", "input", "select", ...Object.keys(TAG_TO_ROLE)])].join(",")
|
|
340
1756
|
};
|
|
341
|
-
/**
|
|
342
|
-
* ARIA snapshot that tolerates every Playwright version the reporter supports,
|
|
343
|
-
* returning null instead of throwing so a capture can never fail the test. The
|
|
344
|
-
* options validator runs client-side in the user's installed Playwright, so an
|
|
345
|
-
* unsupported key surfaces as a rejected promise (not a synchronous throw):
|
|
346
|
-
* - ≥ 1.59: `mode: 'ai'` yields the ref-annotated AI snapshot (the ideal);
|
|
347
|
-
* `ref` is unknown and silently stripped.
|
|
348
|
-
* - 1.52: `mode: 'ai'` fails validation (only 'raw'/'regex' are accepted) and
|
|
349
|
-
* rejects; the `{ ref: true }` fallback then yields a ref-annotated snapshot.
|
|
350
|
-
* - 1.53–1.58: neither `ref` nor `mode` exists — unknown keys are stripped
|
|
351
|
-
* server-side, so the first call already returns a plain flat snapshot.
|
|
352
|
-
* - < 1.49: `locator.ariaSnapshot` does not exist — returns null up front.
|
|
353
|
-
*/
|
|
354
1757
|
async function ariaSnapshotBestEffort(target, timeout) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
1758
|
+
if (typeof target.ariaSnapshot !== "function") return null;
|
|
1759
|
+
try {
|
|
1760
|
+
return await target.ariaSnapshot({
|
|
1761
|
+
...timeout != null ? { timeout } : {},
|
|
1762
|
+
mode: "ai"
|
|
1763
|
+
});
|
|
1764
|
+
} catch {
|
|
358
1765
|
try {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// 1.52 rejects `mode: 'ai'`; retry with the `ref` flag that release accepts.
|
|
366
|
-
try {
|
|
367
|
-
return await target.ariaSnapshot({
|
|
368
|
-
...(timeout != null ? { timeout } : {}),
|
|
369
|
-
ref: true,
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
catch {
|
|
373
|
-
return null;
|
|
374
|
-
}
|
|
1766
|
+
return await target.ariaSnapshot({
|
|
1767
|
+
...timeout != null ? { timeout } : {},
|
|
1768
|
+
ref: true
|
|
1769
|
+
});
|
|
1770
|
+
} catch {
|
|
1771
|
+
return null;
|
|
375
1772
|
}
|
|
1773
|
+
}
|
|
376
1774
|
}
|
|
377
|
-
/**
|
|
378
|
-
* Runs inside the browser via `evaluate()` — probes a captured element for its
|
|
379
|
-
* attributes, geometry, label association, and selector-uniqueness counts.
|
|
380
|
-
* Must stay a fully self-contained function (no references to this module's
|
|
381
|
-
* closure): Playwright serializes it and executes it in the page, browser-side.
|
|
382
|
-
* `el` is browser-context (no DOM lib in this Node package), hence `any`.
|
|
383
|
-
* Exported for unit testing; still passed directly to `evaluate()` below.
|
|
384
|
-
*/
|
|
385
1775
|
function probeElementAttrs(el, arg) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
1776
|
+
const { keep, tagRoles, inputRoles, roleSources } = arg;
|
|
1777
|
+
const attrMap = {};
|
|
1778
|
+
for (const key of keep) {
|
|
1779
|
+
const v = el.getAttribute(key) ?? el[key];
|
|
1780
|
+
attrMap[key] = typeof v === "string" ? v.slice(0, 200) : v ? String(v).slice(0, 200) : null;
|
|
1781
|
+
}
|
|
1782
|
+
const r = el.getBoundingClientRect();
|
|
1783
|
+
const selectorCounts = {};
|
|
1784
|
+
try {
|
|
1785
|
+
const doc = el.ownerDocument;
|
|
1786
|
+
const cssEsc = (s) => doc.defaultView.CSS.escape(s);
|
|
1787
|
+
const count = (sel) => {
|
|
1788
|
+
try {
|
|
1789
|
+
return doc.querySelectorAll(sel).length;
|
|
1790
|
+
} catch {
|
|
1791
|
+
return void 0;
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
if (attrMap["data-testid"]) {
|
|
1795
|
+
selectorCounts.testId = count(`[data-testid=${JSON.stringify(attrMap["data-testid"])}]`);
|
|
391
1796
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
1797
|
+
if (attrMap["id"]) selectorCounts.id = count(`#${cssEsc(attrMap["id"])}`);
|
|
1798
|
+
if (attrMap["name"]) selectorCounts.name = count(`[name=${JSON.stringify(attrMap["name"])}]`);
|
|
1799
|
+
const classList = (attrMap["class"] || "").split(/\s+/).filter((c) => c.length > 1).slice(0, 10);
|
|
1800
|
+
if (classList.length > 0) {
|
|
1801
|
+
const classCounts = {};
|
|
1802
|
+
for (const cls of classList) {
|
|
1803
|
+
const n = count(`.${cssEsc(cls)}`);
|
|
1804
|
+
if (n !== void 0) classCounts[cls] = n;
|
|
1805
|
+
}
|
|
1806
|
+
selectorCounts.classes = classCounts;
|
|
1807
|
+
}
|
|
1808
|
+
} catch {
|
|
1809
|
+
}
|
|
1810
|
+
let rolePosition = null;
|
|
1811
|
+
const ancestors = [];
|
|
1812
|
+
try {
|
|
1813
|
+
const doc = el.ownerDocument;
|
|
1814
|
+
const cssEsc = (s) => doc.defaultView.CSS.escape(s);
|
|
1815
|
+
const count = (sel) => {
|
|
1816
|
+
try {
|
|
1817
|
+
return doc.querySelectorAll(sel).length;
|
|
1818
|
+
} catch {
|
|
1819
|
+
return void 0;
|
|
1820
|
+
}
|
|
1821
|
+
};
|
|
1822
|
+
const roleOf = (n) => {
|
|
1823
|
+
const explicit = n.getAttribute("role");
|
|
1824
|
+
if (explicit) return explicit;
|
|
1825
|
+
const tag = (n.tagName || "").toLowerCase();
|
|
1826
|
+
if (tag === "input") return inputRoles[(n.getAttribute("type") || "text").toLowerCase()] ?? "textbox";
|
|
1827
|
+
if (tag === "select") return n.getAttribute("multiple") != null ? "listbox" : "combobox";
|
|
1828
|
+
if (tag === "a") return n.getAttribute("href") != null ? "link" : null;
|
|
1829
|
+
return tagRoles[tag] ?? null;
|
|
1830
|
+
};
|
|
1831
|
+
const levelOf = (n) => {
|
|
1832
|
+
const m = /^h([1-6])$/.exec((n.tagName || "").toLowerCase());
|
|
1833
|
+
if (m) return Number(m[1]);
|
|
1834
|
+
const al = n.getAttribute("aria-level");
|
|
1835
|
+
return al && /^\d+$/.test(al) ? Number(al) : null;
|
|
1836
|
+
};
|
|
1837
|
+
const targetRole = roleOf(el);
|
|
1838
|
+
const targetLevel = targetRole === "heading" ? levelOf(el) : null;
|
|
1839
|
+
if (targetRole) {
|
|
1840
|
+
const nodes = doc.querySelectorAll(roleSources);
|
|
1841
|
+
if (nodes.length <= 4e3) {
|
|
1842
|
+
let roleCountAll = 0;
|
|
1843
|
+
let index = -1;
|
|
1844
|
+
let levelCount = 0;
|
|
1845
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
1846
|
+
const n = nodes[i];
|
|
1847
|
+
if (roleOf(n) !== targetRole) continue;
|
|
1848
|
+
if (n === el) index = roleCountAll;
|
|
1849
|
+
roleCountAll++;
|
|
1850
|
+
if (targetLevel != null && levelOf(n) === targetLevel) levelCount++;
|
|
411
1851
|
}
|
|
412
|
-
if (
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
.slice(0, 10);
|
|
420
|
-
if (classList.length > 0) {
|
|
421
|
-
const classCounts = {};
|
|
422
|
-
for (const cls of classList) {
|
|
423
|
-
const n = count(`.${cssEsc(cls)}`);
|
|
424
|
-
if (n !== undefined)
|
|
425
|
-
classCounts[cls] = n;
|
|
426
|
-
}
|
|
427
|
-
selectorCounts.classes = classCounts;
|
|
1852
|
+
if (index !== -1) {
|
|
1853
|
+
rolePosition = {
|
|
1854
|
+
role: targetRole,
|
|
1855
|
+
count: roleCountAll,
|
|
1856
|
+
index,
|
|
1857
|
+
...targetLevel != null ? { levelCount } : {}
|
|
1858
|
+
};
|
|
428
1859
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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;
|
|
1860
|
+
const CONTAINER_TAGS = ["form", "nav", "main", "article", "section", "dialog", "table"];
|
|
1861
|
+
const docRoleCount = (role) => {
|
|
1862
|
+
let c = 0;
|
|
1863
|
+
for (let i = 0; i < nodes.length; i++) if (roleOf(nodes[i]) === role) c++;
|
|
1864
|
+
return c;
|
|
473
1865
|
};
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
}
|
|
1866
|
+
let node = el.parentElement;
|
|
1867
|
+
let depth = 0;
|
|
1868
|
+
while (node && depth < 12 && ancestors.length < 4) {
|
|
1869
|
+
depth++;
|
|
1870
|
+
const tag = (node.tagName || "").toLowerCase();
|
|
1871
|
+
if (tag === "body" || tag === "html") break;
|
|
1872
|
+
const testId = node.getAttribute("data-testid");
|
|
1873
|
+
const id = node.getAttribute("id");
|
|
1874
|
+
const explicitRole = node.getAttribute("role");
|
|
1875
|
+
const ariaLabel = node.getAttribute("aria-label");
|
|
1876
|
+
const anchorRole = explicitRole || (CONTAINER_TAGS.includes(tag) ? tagRoles[tag] : null) || null;
|
|
1877
|
+
if (testId || id || anchorRole || ariaLabel) {
|
|
1878
|
+
const scoped = node.querySelectorAll(roleSources);
|
|
1879
|
+
let scopedRoleCount = 0;
|
|
1880
|
+
if (scoped.length <= 2e3) {
|
|
1881
|
+
for (let i = 0; i < scoped.length; i++) {
|
|
1882
|
+
const n = scoped[i];
|
|
1883
|
+
if (roleOf(n) !== targetRole) continue;
|
|
1884
|
+
if (targetLevel != null && levelOf(n) !== targetLevel) continue;
|
|
1885
|
+
scopedRoleCount++;
|
|
1886
|
+
}
|
|
1887
|
+
} else {
|
|
1888
|
+
scopedRoleCount = -1;
|
|
557
1889
|
}
|
|
1890
|
+
ancestors.push({
|
|
1891
|
+
tag,
|
|
1892
|
+
depth,
|
|
1893
|
+
testId: testId || null,
|
|
1894
|
+
id: id || null,
|
|
1895
|
+
role: explicitRole || null,
|
|
1896
|
+
ariaLabel: ariaLabel || null,
|
|
1897
|
+
...scopedRoleCount >= 0 ? { scopedRoleCount } : {},
|
|
1898
|
+
...testId ? { testIdCount: count(`[data-testid=${JSON.stringify(testId)}]`) } : {},
|
|
1899
|
+
...id ? { idCount: count(`#${cssEsc(id)}`) } : {},
|
|
1900
|
+
...anchorRole ? { roleCount: docRoleCount(anchorRole) } : {}
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
node = node.parentElement;
|
|
558
1904
|
}
|
|
1905
|
+
}
|
|
559
1906
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
};
|
|
1907
|
+
} catch {
|
|
1908
|
+
}
|
|
1909
|
+
return {
|
|
1910
|
+
tagName: el.tagName?.toLowerCase?.() ?? "unknown",
|
|
1911
|
+
attributes: attrMap,
|
|
1912
|
+
// Collapse whitespace so multi-line text can't produce a getByText
|
|
1913
|
+
// suggestion with literal newlines in it.
|
|
1914
|
+
textContent: (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80),
|
|
1915
|
+
center: {
|
|
1916
|
+
x: Math.round(r.x + r.width / 2),
|
|
1917
|
+
y: Math.round(r.y + r.height / 2)
|
|
1918
|
+
},
|
|
1919
|
+
hasLabel: !!(el.labels && el.labels.length > 0),
|
|
1920
|
+
selectorCounts,
|
|
1921
|
+
rolePosition,
|
|
1922
|
+
ancestors
|
|
1923
|
+
};
|
|
578
1924
|
}
|
|
579
|
-
// Chain methods that take args and define a new locator scope (not just narrow).
|
|
580
|
-
// Origin method/args update to the chain call, e.g. .locator('.item') → locator('.item').
|
|
581
|
-
// Positional/filter chains that narrow but don't change locator identity.
|
|
582
|
-
// Origin stays from the page-level call, e.g. .first(), .nth(2), .filter(...).
|
|
583
1925
|
function wrapLocator(page, locator, originMethod, originArgs) {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
// snapshot when the element actually has a role or is a field.
|
|
662
|
-
const role = (0, locator_healing_js_1.resolveAriaRole)({ ...attrs, accessibleName: null });
|
|
663
|
-
const isFormField = FORM_FIELD_TAGS.has(attrs.tagName);
|
|
664
|
-
// Bound with a timeout: without it, ariaSnapshot waits up to the
|
|
665
|
-
// test timeout when the page is mid-navigation, which hangs the
|
|
666
|
-
// teardown that drains these capture promises.
|
|
667
|
-
// ariaSnapshotBestEffort adapts the options to the installed
|
|
668
|
-
// Playwright version and never throws (see its doc comment).
|
|
669
|
-
const aria = role || isFormField ? await ariaSnapshotBestEffort(target, 500) : null;
|
|
670
|
-
const accessibleName = (0, locator_healing_js_1.extractAccessibleName)(aria) || (0, locator_healing_js_1.approximateAccessibleName)({ ...attrs, accessibleName: null });
|
|
671
|
-
sink.capturedLocators[seq] = {
|
|
672
|
-
location: callerLocation,
|
|
673
|
-
used,
|
|
674
|
-
// hasLabel/selectorCounts inform alternative generation only —
|
|
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.
|
|
678
|
-
element: {
|
|
679
|
-
tagName: attrs.tagName,
|
|
680
|
-
attributes: attrs.attributes,
|
|
681
|
-
textContent: attrs.textContent,
|
|
682
|
-
accessibleName,
|
|
683
|
-
center: attrs.center,
|
|
684
|
-
...(attrs.rolePosition ? { rolePosition: attrs.rolePosition } : {}),
|
|
685
|
-
...(attrs.ancestors && attrs.ancestors.length > 0 ? { ancestors: attrs.ancestors } : {}),
|
|
686
|
-
},
|
|
687
|
-
alternatives: (0, locator_healing_js_1.generateAlternatives)({ ...attrs, accessibleName }),
|
|
688
|
-
};
|
|
689
|
-
}
|
|
690
|
-
catch {
|
|
691
|
-
// element detached or timeout — keep the placeholder
|
|
692
|
-
}
|
|
693
|
-
finally {
|
|
694
|
-
clearTimeout(deadline);
|
|
695
|
-
}
|
|
696
|
-
})();
|
|
697
|
-
sink.capturePromises.push(resolveAttrs);
|
|
698
|
-
return result;
|
|
1926
|
+
return new Proxy(locator, {
|
|
1927
|
+
get(target, prop) {
|
|
1928
|
+
const original = Reflect.get(target, prop);
|
|
1929
|
+
if (typeof original !== "function") return original;
|
|
1930
|
+
const fn = original;
|
|
1931
|
+
if (CHAIN_METHOD_SET.has(prop)) {
|
|
1932
|
+
return (...args) => {
|
|
1933
|
+
const next = fn.apply(target, args);
|
|
1934
|
+
if (LOCATOR_CREATING_CHAINS.has(prop)) {
|
|
1935
|
+
return wrapLocator(page, next, String(prop), args);
|
|
1936
|
+
}
|
|
1937
|
+
return wrapLocator(page, next, originMethod, originArgs);
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
if (!ACTION_METHOD_SET.has(prop)) return original;
|
|
1941
|
+
return async (...callArgs) => {
|
|
1942
|
+
const sink = currentSink;
|
|
1943
|
+
if (!sink) return fn.apply(target, callArgs);
|
|
1944
|
+
sink.lastActivePage = page;
|
|
1945
|
+
const seq = sink.capturedLocators.length;
|
|
1946
|
+
const callerLocation = captureCallerLocation();
|
|
1947
|
+
const used = {
|
|
1948
|
+
method: originMethod,
|
|
1949
|
+
args: originArgs,
|
|
1950
|
+
raw: `${originMethod}(${JSON.stringify(originArgs)})`
|
|
1951
|
+
};
|
|
1952
|
+
sink.capturedLocators.push({
|
|
1953
|
+
location: callerLocation,
|
|
1954
|
+
used,
|
|
1955
|
+
element: null,
|
|
1956
|
+
alternatives: []
|
|
1957
|
+
});
|
|
1958
|
+
let result;
|
|
1959
|
+
try {
|
|
1960
|
+
result = await fn.apply(target, callArgs);
|
|
1961
|
+
} catch (error) {
|
|
1962
|
+
sink.failedLocators.push({ method: originMethod, args: originArgs, location: callerLocation });
|
|
1963
|
+
throw error;
|
|
1964
|
+
}
|
|
1965
|
+
const probe = target.evaluate(probeElementAttrs, CAPTURED_ATTRS_ARG);
|
|
1966
|
+
const settledProbe = probe.then(
|
|
1967
|
+
() => void 0,
|
|
1968
|
+
() => void 0
|
|
1969
|
+
);
|
|
1970
|
+
PENDING_PROBES.add(settledProbe);
|
|
1971
|
+
settledProbe.then(() => PENDING_PROBES.delete(settledProbe));
|
|
1972
|
+
sink.capturePromises.push(settledProbe);
|
|
1973
|
+
const resolveAttrs = (async () => {
|
|
1974
|
+
let deadline;
|
|
1975
|
+
try {
|
|
1976
|
+
const attrs = await Promise.race([
|
|
1977
|
+
probe,
|
|
1978
|
+
new Promise((_, reject) => {
|
|
1979
|
+
deadline = setTimeout(() => reject(new Error("locator capture timeout")), 500);
|
|
1980
|
+
})
|
|
1981
|
+
]);
|
|
1982
|
+
const role = resolveAriaRole({ ...attrs, accessibleName: null });
|
|
1983
|
+
const isFormField = FORM_FIELD_TAGS.has(attrs.tagName);
|
|
1984
|
+
const aria = role || isFormField ? await ariaSnapshotBestEffort(target, 500) : null;
|
|
1985
|
+
const accessibleName = extractAccessibleName(aria) || approximateAccessibleName({ ...attrs, accessibleName: null });
|
|
1986
|
+
sink.capturedLocators[seq] = {
|
|
1987
|
+
location: callerLocation,
|
|
1988
|
+
used,
|
|
1989
|
+
// hasLabel/selectorCounts inform alternative generation only —
|
|
1990
|
+
// keep the stored element to the wire shape. rolePosition and
|
|
1991
|
+
// ancestors ARE wire fields: the server's renamed-element match
|
|
1992
|
+
// uses them at heal time.
|
|
1993
|
+
element: {
|
|
1994
|
+
tagName: attrs.tagName,
|
|
1995
|
+
attributes: attrs.attributes,
|
|
1996
|
+
textContent: attrs.textContent,
|
|
1997
|
+
accessibleName,
|
|
1998
|
+
center: attrs.center,
|
|
1999
|
+
...attrs.rolePosition ? { rolePosition: attrs.rolePosition } : {},
|
|
2000
|
+
...attrs.ancestors && attrs.ancestors.length > 0 ? { ancestors: attrs.ancestors } : {}
|
|
2001
|
+
},
|
|
2002
|
+
alternatives: generateAlternatives({ ...attrs, accessibleName })
|
|
699
2003
|
};
|
|
700
|
-
|
|
701
|
-
|
|
2004
|
+
} catch {
|
|
2005
|
+
} finally {
|
|
2006
|
+
clearTimeout(deadline);
|
|
2007
|
+
}
|
|
2008
|
+
})();
|
|
2009
|
+
sink.capturePromises.push(resolveAttrs);
|
|
2010
|
+
return result;
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
});
|
|
702
2014
|
}
|
|
703
|
-
/**
|
|
704
|
-
* Instrument a single page: wrap its locator-building methods for healing
|
|
705
|
-
* capture and attach console/network listeners. Idempotent — safe to call on a
|
|
706
|
-
* page already reached through another path (browser patch, `page` fixture).
|
|
707
|
-
*/
|
|
708
2015
|
function instrumentPage(page) {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
2016
|
+
if (!page || INSTRUMENTED_PAGES.has(page)) return;
|
|
2017
|
+
INSTRUMENTED_PAGES.add(page);
|
|
2018
|
+
const ctx = pageContext(page);
|
|
2019
|
+
if (ctx) instrumentContext(ctx);
|
|
2020
|
+
if (typeof page.close === "function") {
|
|
2021
|
+
const originalClose = page.close.bind(page);
|
|
2022
|
+
page.close = async (...args) => {
|
|
2023
|
+
await drainPendingProbes(1e3);
|
|
2024
|
+
const sink = currentSink;
|
|
2025
|
+
if (sink) {
|
|
2026
|
+
await stashPageState(sink, { page });
|
|
2027
|
+
await maybeOpenPicker(sink, { page });
|
|
2028
|
+
}
|
|
2029
|
+
return originalClose(...args);
|
|
2030
|
+
};
|
|
2031
|
+
}
|
|
2032
|
+
const captureLocators = process.env.PIWI_CAPTURE_LOCATORS !== "false";
|
|
2033
|
+
if (captureLocators) {
|
|
2034
|
+
const factories = page;
|
|
2035
|
+
for (const method of LOCATOR_METHODS) {
|
|
2036
|
+
const original = factories[method].bind(page);
|
|
2037
|
+
factories[method] = (...args) => {
|
|
2038
|
+
if (currentSink) currentSink.lastActivePage = page;
|
|
2039
|
+
return wrapLocator(page, original(...args), method, args);
|
|
2040
|
+
};
|
|
729
2041
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
if (currentSink)
|
|
744
|
-
currentSink.lastActivePage = page;
|
|
745
|
-
return wrapLocator(page, original(...args), method, args);
|
|
746
|
-
};
|
|
747
|
-
}
|
|
2042
|
+
}
|
|
2043
|
+
page.on("console", (msg) => {
|
|
2044
|
+
const sink = currentSink;
|
|
2045
|
+
if (!sink) return;
|
|
2046
|
+
const type = msg.type();
|
|
2047
|
+
if (["warning", "error", "assert"].includes(type)) {
|
|
2048
|
+
const location = msg.location();
|
|
2049
|
+
sink.consoleEntries.push({
|
|
2050
|
+
type,
|
|
2051
|
+
text: msg.text(),
|
|
2052
|
+
timestamp: Date.now(),
|
|
2053
|
+
location: location ? `${location.url}:${location.lineNumber}:${location.columnNumber}` : null
|
|
2054
|
+
});
|
|
748
2055
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
2056
|
+
});
|
|
2057
|
+
page.on("requestfinished", (request) => {
|
|
2058
|
+
const sink = currentSink;
|
|
2059
|
+
if (!sink) return;
|
|
2060
|
+
sink.lastActivePage = page;
|
|
2061
|
+
const p = (async () => {
|
|
2062
|
+
try {
|
|
2063
|
+
const url = request.url();
|
|
2064
|
+
if (url.startsWith("data:") || url.startsWith("blob:")) return;
|
|
2065
|
+
const timing = request.timing();
|
|
2066
|
+
const response = await request.response();
|
|
2067
|
+
const resourceType = request.resourceType();
|
|
2068
|
+
if (!["fetch", "xhr", "document", "other"].includes(resourceType)) return;
|
|
2069
|
+
const entry = {
|
|
2070
|
+
method: request.method(),
|
|
2071
|
+
url,
|
|
2072
|
+
status: response ? response.status() : 0,
|
|
2073
|
+
duration: timing.responseEnd > 0 ? Math.round(timing.responseEnd - timing.requestStart) : 0,
|
|
2074
|
+
startTime: timing.startTime,
|
|
2075
|
+
resourceType
|
|
2076
|
+
};
|
|
2077
|
+
if (response) {
|
|
2078
|
+
const headers = response.headers();
|
|
2079
|
+
const contentType = headers["content-type"];
|
|
2080
|
+
if (contentType) entry.contentType = contentType.split(";")[0].trim();
|
|
2081
|
+
const logHeader = headers["x-piwi-logs"];
|
|
2082
|
+
if (logHeader) {
|
|
770
2083
|
try {
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
return;
|
|
774
|
-
const timing = request.timing();
|
|
775
|
-
const response = await request.response();
|
|
776
|
-
const resourceType = request.resourceType();
|
|
777
|
-
// Only keep API/document requests; skip static assets (scripts, styles, fonts, images, media)
|
|
778
|
-
if (!['fetch', 'xhr', 'document', 'other'].includes(resourceType))
|
|
779
|
-
return;
|
|
780
|
-
const entry = {
|
|
781
|
-
method: request.method(),
|
|
782
|
-
url,
|
|
783
|
-
status: response ? response.status() : 0,
|
|
784
|
-
duration: timing.responseEnd > 0 ? Math.round(timing.responseEnd - timing.requestStart) : 0,
|
|
785
|
-
startTime: timing.startTime,
|
|
786
|
-
resourceType,
|
|
787
|
-
};
|
|
788
|
-
if (response) {
|
|
789
|
-
const headers = response.headers();
|
|
790
|
-
// Response content type (without charset/boundary params) — relevant
|
|
791
|
-
// per-request metadata for distinguishing API/JSON vs document/HTML calls.
|
|
792
|
-
const contentType = headers['content-type'];
|
|
793
|
-
if (contentType)
|
|
794
|
-
entry.contentType = contentType.split(';')[0].trim();
|
|
795
|
-
const logHeader = headers['x-piwi-logs'];
|
|
796
|
-
if (logHeader) {
|
|
797
|
-
try {
|
|
798
|
-
entry.serverLogs = JSON.parse((0, node_zlib_1.gunzipSync)(Buffer.from(logHeader, 'base64')).toString('utf-8'));
|
|
799
|
-
}
|
|
800
|
-
catch {
|
|
801
|
-
/* ignore malformed header */
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
sink.networkRequests.push(entry);
|
|
806
|
-
}
|
|
807
|
-
catch {
|
|
808
|
-
/* ignore */
|
|
2084
|
+
entry.serverLogs = JSON.parse((0, import_node_zlib.gunzipSync)(Buffer.from(logHeader, "base64")).toString("utf-8"));
|
|
2085
|
+
} catch {
|
|
809
2086
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
sink.networkRequests.push(entry);
|
|
2090
|
+
} catch {
|
|
2091
|
+
}
|
|
2092
|
+
})();
|
|
2093
|
+
sink.pendingHandlers.push(p);
|
|
2094
|
+
});
|
|
813
2095
|
}
|
|
814
|
-
/**
|
|
815
|
-
* Instrument a browser context so every page it opens — via `newPage()` or as a
|
|
816
|
-
* popup/`window.open` — is captured. Idempotent.
|
|
817
|
-
*/
|
|
818
2096
|
function instrumentContext(context) {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
2097
|
+
if (!context || INSTRUMENTED_CONTEXTS.has(context)) return;
|
|
2098
|
+
INSTRUMENTED_CONTEXTS.add(context);
|
|
2099
|
+
const originalNewPage = context.newPage.bind(context);
|
|
2100
|
+
context.newPage = async (...args) => {
|
|
2101
|
+
const page = await originalNewPage(...args);
|
|
2102
|
+
instrumentPage(page);
|
|
2103
|
+
return page;
|
|
2104
|
+
};
|
|
2105
|
+
if (typeof context.close === "function") {
|
|
2106
|
+
const originalClose = context.close.bind(context);
|
|
2107
|
+
context.close = async (...args) => {
|
|
2108
|
+
await drainPendingProbes(1e3);
|
|
2109
|
+
const sink = currentSink;
|
|
2110
|
+
if (sink) {
|
|
2111
|
+
await stashPageState(sink, { context });
|
|
2112
|
+
await maybeOpenPicker(sink, { context });
|
|
2113
|
+
}
|
|
2114
|
+
return originalClose(...args);
|
|
827
2115
|
};
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
}
|
|
843
|
-
// Popups and pages the context opens on its own (idempotent with the above).
|
|
844
|
-
context.on('page', (page) => instrumentPage(page));
|
|
2116
|
+
}
|
|
2117
|
+
context.on("page", (page) => instrumentPage(page));
|
|
845
2118
|
}
|
|
846
|
-
/**
|
|
847
|
-
* Patch a browser so any page or context created directly from it is
|
|
848
|
-
* instrumented. This is what makes capture work for suites that build their own
|
|
849
|
-
* pages from the worker-scoped `browser` (e.g. `browser.newPage()` /
|
|
850
|
-
* `browser.newContext().newPage()`) instead of using the `page` fixture.
|
|
851
|
-
* Idempotent.
|
|
852
|
-
*/
|
|
853
2119
|
function patchBrowser(browser) {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
2120
|
+
if (!browser || PATCHED_BROWSERS.has(browser)) return;
|
|
2121
|
+
PATCHED_BROWSERS.add(browser);
|
|
2122
|
+
const originalNewPage = browser.newPage.bind(browser);
|
|
2123
|
+
browser.newPage = async (...args) => {
|
|
2124
|
+
const page = await originalNewPage(...args);
|
|
2125
|
+
instrumentPage(page);
|
|
2126
|
+
return page;
|
|
2127
|
+
};
|
|
2128
|
+
const originalNewContext = browser.newContext.bind(browser);
|
|
2129
|
+
browser.newContext = async (...args) => {
|
|
2130
|
+
const context = await originalNewContext(...args);
|
|
2131
|
+
instrumentContext(context);
|
|
2132
|
+
return context;
|
|
2133
|
+
};
|
|
2134
|
+
if (typeof browser.close === "function") {
|
|
2135
|
+
const originalClose = browser.close.bind(browser);
|
|
2136
|
+
browser.close = async (...args) => {
|
|
2137
|
+
await drainPendingProbes(1e3);
|
|
2138
|
+
return originalClose(...args);
|
|
862
2139
|
};
|
|
863
|
-
|
|
864
|
-
browser.newContext = async (...args) => {
|
|
865
|
-
const context = await originalNewContext(...args);
|
|
866
|
-
instrumentContext(context);
|
|
867
|
-
return context;
|
|
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
|
-
}
|
|
2140
|
+
}
|
|
878
2141
|
}
|
|
879
|
-
/**
|
|
880
|
-
* Drain in-flight capture work and attach the collected `piwi-*` data
|
|
881
|
-
* to the test. Mirrors the per-test teardown the `page` fixture used to do, but
|
|
882
|
-
* sourced from the sink so it works regardless of how the test's pages were made.
|
|
883
|
-
*/
|
|
884
2142
|
async function flushSink(sink, testInfo) {
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
2143
|
+
await Promise.allSettled(sink.pendingHandlers);
|
|
2144
|
+
let drainDeadline;
|
|
2145
|
+
await Promise.race([
|
|
2146
|
+
Promise.allSettled(sink.capturePromises),
|
|
2147
|
+
new Promise((resolve) => {
|
|
2148
|
+
drainDeadline = setTimeout(resolve, 2e3);
|
|
2149
|
+
})
|
|
2150
|
+
]);
|
|
2151
|
+
clearTimeout(drainDeadline);
|
|
2152
|
+
const page = sink.lastActivePage;
|
|
2153
|
+
const pageReadable = page !== null && !isPageClosed(page);
|
|
2154
|
+
if (pageReadable) await maybeOpenPicker(sink);
|
|
2155
|
+
if (sink.capturedLocators.length > 0) {
|
|
2156
|
+
await testInfo.attach(ATTACHMENT_NAMES.locators, {
|
|
2157
|
+
contentType: "application/json",
|
|
2158
|
+
// Repeated call sites (loops) keep only their latest capture — the
|
|
2159
|
+
// server stores one row per location anyway, so shipping every
|
|
2160
|
+
// iteration is pure payload bloat.
|
|
2161
|
+
body: Buffer.from(JSON.stringify(dedupeSnapshotsByLocation(sink.capturedLocators)))
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
if (testInfo.status !== "passed" && testInfo.status !== "skipped") {
|
|
2165
|
+
try {
|
|
2166
|
+
const snapshot = (pageReadable ? await ariaSnapshotBestEffort(page.locator(":root")) : null) ?? sink.stashedAria;
|
|
2167
|
+
if (snapshot) {
|
|
2168
|
+
await testInfo.attach(ATTACHMENT_NAMES.ariaSnapshot, {
|
|
2169
|
+
contentType: "text/plain",
|
|
2170
|
+
body: snapshot
|
|
908
2171
|
});
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
contentType: 'text/plain',
|
|
921
|
-
body: snapshot,
|
|
922
|
-
});
|
|
923
|
-
// Suggest a fresh locator for the failed action from the current page.
|
|
924
|
-
// When the element was renamed/moved, the pre-captured alternatives
|
|
925
|
-
// describe the old element, so this points at where it went now — as a
|
|
926
|
-
// Playwright annotation (shown in the report + trace) and an attachment
|
|
927
|
-
// (shown in the trace viewer). It does NOT change the locator.
|
|
928
|
-
const failed = sink.failedLocators[sink.failedLocators.length - 1];
|
|
929
|
-
const suggestion = failed ? (0, locator_healing_js_1.suggestLocatorsFromAria)(failed, snapshot) : null;
|
|
930
|
-
if (suggestion) {
|
|
931
|
-
testInfo.annotations.push({
|
|
932
|
-
type: attachments_js_1.LOCATOR_SUGGESTION_ANNOTATION,
|
|
933
|
-
description: `${suggestion.failing} matched nothing on the failing page — the element may have been renamed or moved. Suggested: ${suggestion.suggestions.join(' | ')}`,
|
|
934
|
-
});
|
|
935
|
-
await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.locatorSuggestion, {
|
|
936
|
-
contentType: 'application/json',
|
|
937
|
-
body: Buffer.from(JSON.stringify(suggestion)),
|
|
938
|
-
});
|
|
939
|
-
}
|
|
940
|
-
}
|
|
2172
|
+
const failed = sink.failedLocators[sink.failedLocators.length - 1];
|
|
2173
|
+
const suggestion = failed ? suggestLocatorsFromAria(failed, snapshot) : null;
|
|
2174
|
+
if (suggestion) {
|
|
2175
|
+
testInfo.annotations.push({
|
|
2176
|
+
type: LOCATOR_SUGGESTION_ANNOTATION,
|
|
2177
|
+
description: `${suggestion.failing} matched nothing on the failing page \u2014 the element may have been renamed or moved. Suggested: ${suggestion.suggestions.join(" | ")}`
|
|
2178
|
+
});
|
|
2179
|
+
await testInfo.attach(ATTACHMENT_NAMES.locatorSuggestion, {
|
|
2180
|
+
contentType: "application/json",
|
|
2181
|
+
body: Buffer.from(JSON.stringify(suggestion))
|
|
2182
|
+
});
|
|
941
2183
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
if (sink.consoleEntries.length > 0) {
|
|
947
|
-
await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.console, {
|
|
948
|
-
contentType: 'application/json',
|
|
949
|
-
body: Buffer.from(JSON.stringify(sink.consoleEntries)),
|
|
950
|
-
});
|
|
2184
|
+
}
|
|
2185
|
+
} catch {
|
|
951
2186
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
});
|
|
2187
|
+
}
|
|
2188
|
+
if (sink.userPick) {
|
|
2189
|
+
const pick = sink.userPick;
|
|
2190
|
+
testInfo.annotations.push({
|
|
2191
|
+
type: USER_PICK_ANNOTATION,
|
|
2192
|
+
description: pick.failing ? `Replacement locator picked on the failing page for ${pick.failing.rendered}${pick.failing.location ? ` at ${pick.failing.location}` : ""}: ${pick.picked.locator}` : `Locator picked while inspecting the failing page: ${pick.picked.locator}`
|
|
2193
|
+
});
|
|
2194
|
+
try {
|
|
2195
|
+
await testInfo.attach(ATTACHMENT_NAMES.userPick, {
|
|
2196
|
+
contentType: "application/json",
|
|
2197
|
+
body: Buffer.from(JSON.stringify(pick))
|
|
2198
|
+
});
|
|
2199
|
+
} catch {
|
|
966
2200
|
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
2201
|
+
} else if (!sink.pickOffered) {
|
|
2202
|
+
const reason = environmentalSkipReason(inspectionGateFromTestInfo(testInfo, process.env.PIWI_PICK_LOCATOR_ON_FAIL)) ?? environmentalSkipReason(inspectionGateFromTestInfo(testInfo));
|
|
2203
|
+
if (reason) console.log(`[piwi] failure-time locator tools enabled but skipped: ${reason}`);
|
|
2204
|
+
}
|
|
2205
|
+
if (sink.consoleEntries.length > 0) {
|
|
2206
|
+
await testInfo.attach(ATTACHMENT_NAMES.console, {
|
|
2207
|
+
contentType: "application/json",
|
|
2208
|
+
body: Buffer.from(JSON.stringify(sink.consoleEntries))
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
if (sink.networkRequests.length > 0) {
|
|
2212
|
+
await testInfo.attach(ATTACHMENT_NAMES.network, {
|
|
2213
|
+
contentType: "application/json",
|
|
2214
|
+
body: Buffer.from(JSON.stringify(sink.networkRequests))
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
const webVitals = (pageReadable ? await readWebVitals(page) : null) ?? sink.stashedWebVitals;
|
|
2218
|
+
if (webVitals) {
|
|
2219
|
+
await testInfo.attach(ATTACHMENT_NAMES.webVitals, {
|
|
2220
|
+
contentType: "application/json",
|
|
2221
|
+
body: Buffer.from(JSON.stringify(webVitals))
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
if (process.env.PIWI_CAPTURE_PAGE_STATE !== "false") {
|
|
2225
|
+
const pageState = (pageReadable ? await readPageState(page) : null) ?? sink.stashedPageState;
|
|
2226
|
+
if (pageState) {
|
|
2227
|
+
await testInfo.attach(ATTACHMENT_NAMES.pageState, {
|
|
2228
|
+
contentType: "application/json",
|
|
2229
|
+
body: Buffer.from(JSON.stringify(pageState))
|
|
2230
|
+
});
|
|
976
2231
|
}
|
|
2232
|
+
}
|
|
977
2233
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
* test-info attachments which the Piwi Dashboard reporter parses on `onTestEnd`.
|
|
986
|
-
*/
|
|
987
|
-
exports.piwiFixtures = {
|
|
988
|
-
// Worker-scoped: patch the shared browser so every page/context created from
|
|
989
|
-
// it — including by user fixtures that take `browser` directly — is captured.
|
|
990
|
-
browser: [
|
|
991
|
-
async ({ browser }, use) => {
|
|
992
|
-
patchBrowser(browser);
|
|
993
|
-
await use(browser);
|
|
994
|
-
},
|
|
995
|
-
{ scope: 'worker' },
|
|
996
|
-
],
|
|
997
|
-
// Safety net for the standard `page` fixture, in case its page is created
|
|
998
|
-
// through an internal path the browser patch doesn't see. Idempotent, so it
|
|
999
|
-
// never double-wraps a page the browser patch already instrumented.
|
|
1000
|
-
page: async ({ page }, use) => {
|
|
1001
|
-
instrumentPage(page);
|
|
1002
|
-
await use(page);
|
|
2234
|
+
var piwiFixtures = {
|
|
2235
|
+
// Worker-scoped: patch the shared browser so every page/context created from
|
|
2236
|
+
// it — including by user fixtures that take `browser` directly — is captured.
|
|
2237
|
+
browser: [
|
|
2238
|
+
async ({ browser }, use) => {
|
|
2239
|
+
patchBrowser(browser);
|
|
2240
|
+
await use(browser);
|
|
1003
2241
|
},
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
2242
|
+
{ scope: "worker" }
|
|
2243
|
+
],
|
|
2244
|
+
// Safety net for the standard `page` fixture, in case its page is created
|
|
2245
|
+
// through an internal path the browser patch doesn't see. Idempotent, so it
|
|
2246
|
+
// never double-wraps a page the browser patch already instrumented.
|
|
2247
|
+
page: async ({ page }, use) => {
|
|
2248
|
+
instrumentPage(page);
|
|
2249
|
+
await use(page);
|
|
2250
|
+
},
|
|
2251
|
+
// Auto, test-scoped: open a capture sink for the running test and flush it
|
|
2252
|
+
// (attach the collected data) at teardown. Runs for every test without being
|
|
2253
|
+
// requested, so suites that never destructure `page` are still captured.
|
|
2254
|
+
piwiCapture: [
|
|
2255
|
+
async ({}, use, testInfo) => {
|
|
2256
|
+
const sink = createSink();
|
|
2257
|
+
sink.testInfo = testInfo;
|
|
2258
|
+
currentSink = sink;
|
|
2259
|
+
try {
|
|
2260
|
+
await use();
|
|
2261
|
+
} finally {
|
|
2262
|
+
currentSink = null;
|
|
2263
|
+
await flushSink(sink, testInfo);
|
|
2264
|
+
}
|
|
2265
|
+
},
|
|
2266
|
+
{ auto: true }
|
|
2267
|
+
]
|
|
1022
2268
|
};
|
|
1023
|
-
/**
|
|
1024
|
-
* Extend a Playwright `test` object with the Piwi capture fixtures. The
|
|
1025
|
-
* returned `test` carries the existing fixtures plus {@link PiwiFixtures}.
|
|
1026
|
-
*
|
|
1027
|
-
* Use this instead of importing `@playwright/test` directly from this package
|
|
1028
|
-
* to avoid the "Requiring @playwright/test second time" error caused by
|
|
1029
|
-
* duplicate module resolution.
|
|
1030
|
-
*
|
|
1031
|
-
* @example
|
|
1032
|
-
* ```ts
|
|
1033
|
-
* import { test as base } from '@playwright/test';
|
|
1034
|
-
* import { extendPiwiFixtures } from '@piwitests/reporter';
|
|
1035
|
-
*
|
|
1036
|
-
* export const test = extendPiwiFixtures(base);
|
|
1037
|
-
* ```
|
|
1038
|
-
*/
|
|
1039
2269
|
function extendPiwiFixtures(test) {
|
|
1040
|
-
|
|
2270
|
+
return test.extend(piwiFixtures);
|
|
1041
2271
|
}
|
|
2272
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
2273
|
+
0 && (module.exports = {
|
|
2274
|
+
CAPTURED_ATTRS_ARG,
|
|
2275
|
+
ariaSnapshotBestEffort,
|
|
2276
|
+
buildPageState,
|
|
2277
|
+
computeCoreVitals,
|
|
2278
|
+
extendPiwiFixtures,
|
|
2279
|
+
piwiFixtures,
|
|
2280
|
+
probeElementAttrs
|
|
2281
|
+
});
|