@piwitests/reporter 0.11.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 +9 -3
- package/dist/internal/capture/attachments.js +46 -20
- package/dist/internal/capture/capture-fixtures.d.ts +110 -7
- package/dist/internal/capture/capture-fixtures.js +2227 -639
- 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 -131
- package/dist/internal/capture/locator-healing.js +602 -711
- 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 -45
- package/dist/internal/config/env.js +0 -153
- 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 -198
- 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 -107
- 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 -78
- 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 -96
- package/dist/types/collected.js +0 -10
- package/dist/types/wire.d.ts +0 -174
- package/dist/types/wire.js +0 -14
- package/dist/types.d.ts +0 -11
- package/dist/types.js +0 -27
|
@@ -1,748 +1,639 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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/locator-healing.ts
|
|
31
|
+
var locator_healing_exports = {};
|
|
32
|
+
__export(locator_healing_exports, {
|
|
33
|
+
ACTION_METHODS: () => ACTION_METHODS,
|
|
34
|
+
CAPTURED_ATTRIBUTES: () => CAPTURED_ATTRIBUTES,
|
|
35
|
+
CHAIN_METHODS: () => CHAIN_METHODS,
|
|
36
|
+
INPUT_TYPE_TO_ROLE: () => INPUT_TYPE_TO_ROLE,
|
|
37
|
+
LOCATOR_CREATING_CHAINS: () => LOCATOR_CREATING_CHAINS,
|
|
38
|
+
LOCATOR_METHODS: () => LOCATOR_METHODS,
|
|
39
|
+
TAG_TO_ROLE: () => TAG_TO_ROLE,
|
|
40
|
+
approximateAccessibleName: () => approximateAccessibleName,
|
|
41
|
+
captureCallerLocation: () => captureCallerLocation,
|
|
42
|
+
classifyCssStability: () => classifyCssStability,
|
|
43
|
+
dedupeSnapshotsByLocation: () => dedupeSnapshotsByLocation,
|
|
44
|
+
extractAccessibleName: () => extractAccessibleName,
|
|
45
|
+
generateAlternatives: () => generateAlternatives,
|
|
46
|
+
headingLevel: () => headingLevel,
|
|
47
|
+
isAutoGenerated: () => isAutoGenerated,
|
|
48
|
+
renderFailing: () => renderFailing,
|
|
49
|
+
resolveAriaRole: () => resolveAriaRole,
|
|
50
|
+
suggestLocatorsFromAria: () => suggestLocatorsFromAria
|
|
17
51
|
});
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
exports.resolveAriaRole = resolveAriaRole;
|
|
39
|
-
exports.generateAlternatives = generateAlternatives;
|
|
40
|
-
exports.classifyCssStability = classifyCssStability;
|
|
41
|
-
exports.isAutoGenerated = isAutoGenerated;
|
|
42
|
-
exports.extractAccessibleName = extractAccessibleName;
|
|
43
|
-
exports.approximateAccessibleName = approximateAccessibleName;
|
|
44
|
-
exports.parseAriaRoleName = parseAriaRoleName;
|
|
45
|
-
exports.nameSimilarity = nameSimilarity;
|
|
46
|
-
exports.suggestLocatorsFromAria = suggestLocatorsFromAria;
|
|
47
|
-
exports.captureCallerLocation = captureCallerLocation;
|
|
48
|
-
const path = __importStar(require("node:path"));
|
|
49
|
-
/**
|
|
50
|
-
* Drop repeated captures of the same call site before attaching: a loop (or a
|
|
51
|
-
* page-object method called repeatedly) produces one snapshot per action, but
|
|
52
|
-
* the server keeps only the latest per location anyway. Keeps the last
|
|
53
|
-
* element-bearing snapshot per location — falling back to the last placeholder
|
|
54
|
-
* when no capture resolved, so the location still counts as "seen this run"
|
|
55
|
-
* for the server's stale-location purge. Entries with no location pass through.
|
|
56
|
-
*/
|
|
57
|
-
function dedupeSnapshotsByLocation(snaps) {
|
|
58
|
-
const lastWithElement = new Map();
|
|
59
|
-
const lastAny = new Map();
|
|
60
|
-
snaps.forEach((s, i) => {
|
|
61
|
-
if (!s.location)
|
|
62
|
-
return;
|
|
63
|
-
lastAny.set(s.location, i);
|
|
64
|
-
if (s.element)
|
|
65
|
-
lastWithElement.set(s.location, i);
|
|
66
|
-
});
|
|
67
|
-
return snaps.filter((s, i) => {
|
|
68
|
-
if (!s.location)
|
|
69
|
-
return true;
|
|
70
|
-
return (lastWithElement.get(s.location) ?? lastAny.get(s.location)) === i;
|
|
71
|
-
});
|
|
52
|
+
module.exports = __toCommonJS(locator_healing_exports);
|
|
53
|
+
var path = __toESM(require("path"));
|
|
54
|
+
|
|
55
|
+
// ../packages/core/src/locator-fingerprint.ts
|
|
56
|
+
var PRESENT_SIMILARITY = 0.8;
|
|
57
|
+
var MATCH_SIMILARITY = 0.2;
|
|
58
|
+
function parseAriaCandidates(ariaSnapshot) {
|
|
59
|
+
if (!ariaSnapshot) return [];
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const line of ariaSnapshot.split("\n")) {
|
|
62
|
+
const m = line.match(/^\s*-\s+([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?/i);
|
|
63
|
+
if (!m) continue;
|
|
64
|
+
const role = m[1];
|
|
65
|
+
const name = m[2] != null ? m[2].replace(/\\(.)/g, "$1") : null;
|
|
66
|
+
if (!name && (role === "generic" || role === "group" || role === "list" || role === "paragraph")) continue;
|
|
67
|
+
const levelMatch = line.slice(m[0].length).match(/\[level=(\d+)\]/);
|
|
68
|
+
const level = levelMatch ? Number(levelMatch[1]) : null;
|
|
69
|
+
out.push({ role, name, level });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
72
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
'selectText',
|
|
132
|
-
// Not an action, but a successful waitFor proves the element resolved — the
|
|
133
|
-
// closest capture hook available for assertion-style usage of a locator.
|
|
134
|
-
'waitFor',
|
|
135
|
-
];
|
|
136
|
-
/** Chain methods that create a new locator scope (origin tracks the chain call). */
|
|
137
|
-
exports.LOCATOR_CREATING_CHAINS = new Set(exports.LOCATOR_METHODS);
|
|
138
|
-
/**
|
|
139
|
-
* Element attributes to capture after a successful action, passed into the
|
|
140
|
-
* in-page `evaluate`. Shared so the reporter and dogfooding fixtures capture
|
|
141
|
-
* the same attribute set.
|
|
142
|
-
*/
|
|
143
|
-
exports.CAPTURED_ATTRIBUTES = [
|
|
144
|
-
'id',
|
|
145
|
-
'class',
|
|
146
|
-
'name',
|
|
147
|
-
'data-testid',
|
|
148
|
-
'placeholder',
|
|
149
|
-
'alt',
|
|
150
|
-
'title',
|
|
151
|
-
'aria-label',
|
|
152
|
-
'role',
|
|
153
|
-
'type',
|
|
154
|
-
'href',
|
|
155
|
-
// `multiple` distinguishes listbox vs combobox for <select>. `value` is
|
|
156
|
-
// deliberately NOT captured: no alternative generator uses it, and reading
|
|
157
|
-
// the live value after fill() would leak user-typed secrets (passwords,
|
|
158
|
-
// tokens) into the snapshot payload and server storage.
|
|
159
|
-
'multiple',
|
|
73
|
+
function textSimilarity(a, b) {
|
|
74
|
+
const tok = (s) => new Set(
|
|
75
|
+
(s ?? "").toLowerCase().split(/[^a-z0-9]+/i).filter(Boolean)
|
|
76
|
+
);
|
|
77
|
+
const sa = tok(a);
|
|
78
|
+
const sb = tok(b);
|
|
79
|
+
if (sa.size === 0 && sb.size === 0) return 1;
|
|
80
|
+
if (sa.size === 0 || sb.size === 0) return 0;
|
|
81
|
+
let common = 0;
|
|
82
|
+
for (const t of sa) if (sb.has(t)) common++;
|
|
83
|
+
return 2 * common / (sa.size + sb.size);
|
|
84
|
+
}
|
|
85
|
+
function fingerprintPresent(fp, candidates) {
|
|
86
|
+
if (!fp.name) return false;
|
|
87
|
+
return candidates.some(
|
|
88
|
+
(c) => (!fp.role || c.role === fp.role) && textSimilarity(c.name, fp.name) >= PRESENT_SIMILARITY
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
function matchRenamedElement(fp, candidates) {
|
|
92
|
+
if (candidates.length === 0) return null;
|
|
93
|
+
const sameRole = fp.role ? candidates.filter((c) => c.role === fp.role) : candidates;
|
|
94
|
+
if (sameRole.length === 0) return null;
|
|
95
|
+
let pool = sameRole;
|
|
96
|
+
if (fp.level != null) {
|
|
97
|
+
const sameLevel = sameRole.filter((c) => c.level === fp.level);
|
|
98
|
+
if (sameLevel.length > 0) pool = sameLevel;
|
|
99
|
+
}
|
|
100
|
+
if (pool.length === 1) {
|
|
101
|
+
return { candidate: pool[0], confidence: 0.7 };
|
|
102
|
+
}
|
|
103
|
+
let best = null;
|
|
104
|
+
let bestScore = -1;
|
|
105
|
+
for (const c of pool) {
|
|
106
|
+
const s = textSimilarity(c.name, fp.name);
|
|
107
|
+
if (s > bestScore) {
|
|
108
|
+
bestScore = s;
|
|
109
|
+
best = c;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (best && bestScore >= MATCH_SIMILARITY) return { candidate: best, confidence: bestScore };
|
|
113
|
+
const pos = fp.rolePosition;
|
|
114
|
+
if (pos && fp.role && pos.role === fp.role && sameRole.length >= 2 && sameRole.length === pos.count) {
|
|
115
|
+
const byIndex = sameRole[pos.index];
|
|
116
|
+
if (byIndex) return { candidate: byIndex, confidence: 0.5 };
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ../packages/core/src/locator-methods.ts
|
|
122
|
+
var LOCATOR_BUILDER_METHODS = [
|
|
123
|
+
"getByRole",
|
|
124
|
+
"getByTestId",
|
|
125
|
+
"getByText",
|
|
126
|
+
"getByLabel",
|
|
127
|
+
"getByPlaceholder",
|
|
128
|
+
"getByAltText",
|
|
129
|
+
"getByTitle",
|
|
130
|
+
"locator"
|
|
160
131
|
];
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
132
|
+
|
|
133
|
+
// ../packages/core/src/locator-generation.ts
|
|
134
|
+
var TAG_TO_ROLE = {
|
|
135
|
+
a: "link",
|
|
136
|
+
button: "button",
|
|
137
|
+
nav: "navigation",
|
|
138
|
+
main: "main",
|
|
139
|
+
article: "article",
|
|
140
|
+
section: "region",
|
|
141
|
+
form: "form",
|
|
142
|
+
img: "img",
|
|
143
|
+
figure: "figure",
|
|
144
|
+
figcaption: "caption",
|
|
145
|
+
blockquote: "blockquote",
|
|
146
|
+
table: "table",
|
|
147
|
+
ul: "list",
|
|
148
|
+
ol: "list",
|
|
149
|
+
li: "listitem",
|
|
150
|
+
dialog: "dialog",
|
|
151
|
+
output: "status",
|
|
152
|
+
progress: "progressbar",
|
|
153
|
+
meter: "meter",
|
|
154
|
+
textarea: "textbox",
|
|
155
|
+
h1: "heading",
|
|
156
|
+
h2: "heading",
|
|
157
|
+
h3: "heading",
|
|
158
|
+
h4: "heading",
|
|
159
|
+
h5: "heading",
|
|
160
|
+
h6: "heading",
|
|
161
|
+
details: "group",
|
|
162
|
+
summary: "button",
|
|
163
|
+
search: "search"
|
|
193
164
|
};
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
password: 'textbox',
|
|
165
|
+
var INPUT_TYPE_TO_ROLE = {
|
|
166
|
+
button: "button",
|
|
167
|
+
submit: "button",
|
|
168
|
+
reset: "button",
|
|
169
|
+
image: "button",
|
|
170
|
+
checkbox: "checkbox",
|
|
171
|
+
radio: "radio",
|
|
172
|
+
range: "slider",
|
|
173
|
+
search: "searchbox",
|
|
174
|
+
number: "spinbutton",
|
|
175
|
+
text: "textbox",
|
|
176
|
+
email: "textbox",
|
|
177
|
+
tel: "textbox",
|
|
178
|
+
url: "textbox",
|
|
179
|
+
password: "textbox"
|
|
210
180
|
};
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
181
|
+
var CAPTURED_ATTRIBUTES = [
|
|
182
|
+
"id",
|
|
183
|
+
"class",
|
|
184
|
+
"name",
|
|
185
|
+
"data-testid",
|
|
186
|
+
"placeholder",
|
|
187
|
+
"alt",
|
|
188
|
+
"title",
|
|
189
|
+
"aria-label",
|
|
190
|
+
"aria-level",
|
|
191
|
+
"role",
|
|
192
|
+
"type",
|
|
193
|
+
"href",
|
|
194
|
+
"multiple"
|
|
195
|
+
];
|
|
218
196
|
function resolveAriaRole(attrs) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
197
|
+
const explicit = attrs.attributes["role"];
|
|
198
|
+
if (explicit) return explicit;
|
|
199
|
+
const tag = attrs.tagName;
|
|
200
|
+
if (!tag) return null;
|
|
201
|
+
if (tag === "input") {
|
|
202
|
+
const type = (attrs.attributes["type"] ?? "text").toLowerCase();
|
|
203
|
+
return INPUT_TYPE_TO_ROLE[type] ?? "textbox";
|
|
204
|
+
}
|
|
205
|
+
if (tag === "select") {
|
|
206
|
+
return attrs.attributes["multiple"] != null ? "listbox" : "combobox";
|
|
207
|
+
}
|
|
208
|
+
if (tag === "a") {
|
|
209
|
+
return attrs.attributes["href"] != null ? "link" : null;
|
|
210
|
+
}
|
|
211
|
+
return TAG_TO_ROLE[tag] ?? null;
|
|
212
|
+
}
|
|
213
|
+
function headingLevel(attrs, role) {
|
|
214
|
+
if (role !== "heading") return null;
|
|
215
|
+
const tagMatch = attrs.tagName.match(/^h([1-6])$/);
|
|
216
|
+
if (tagMatch) return Number(tagMatch[1]);
|
|
217
|
+
const ariaLevel = attrs.attributes["aria-level"];
|
|
218
|
+
if (ariaLevel && /^\d+$/.test(ariaLevel)) return Number(ariaLevel);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
var attr = (a, key) => a.attributes[key] || null;
|
|
222
|
+
var esc = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
223
|
+
var escCssAttrValue = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
224
|
+
var isCssSafeId = (id) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(id);
|
|
225
|
+
function classifyCssStability(className) {
|
|
226
|
+
if (/[a-f0-9]{8,}/i.test(className)) return 10;
|
|
227
|
+
if (/^(?:css|sc|emotion|styled)-/i.test(className)) return 15;
|
|
228
|
+
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(
|
|
229
|
+
className
|
|
230
|
+
))
|
|
231
|
+
return 25;
|
|
232
|
+
if (/__/.test(className) || /--/.test(className)) return 35;
|
|
233
|
+
if (/^[a-z]+(-[a-z]+)+$/.test(className)) return 40;
|
|
234
|
+
if (/^[a-z]+[A-Z][a-zA-Z]+$/.test(className)) return 40;
|
|
235
|
+
if (className.includes("_")) return 15;
|
|
236
|
+
if (/^[a-z]+-[a-z0-9]{5,}$/.test(className) && /[0-9]/.test(className)) return 15;
|
|
237
|
+
return 15;
|
|
238
|
+
}
|
|
239
|
+
function isAutoGenerated(value) {
|
|
240
|
+
if (/^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/i.test(value)) return true;
|
|
241
|
+
if (/^[a-f0-9]{8,}$/i.test(value)) return true;
|
|
242
|
+
if (/^[a-z]+-\d+$/.test(value)) return true;
|
|
243
|
+
if (/^(emotion-|styled-|css-|sc-)/.test(value)) return true;
|
|
244
|
+
if (value.startsWith("ng-")) return true;
|
|
245
|
+
if (/^(radix-|headlessui-|mui-|mantine-|chakra-)/i.test(value)) return true;
|
|
246
|
+
if (/^:r[0-9a-z]+:$/i.test(value) || /^«r[0-9a-z]+»$/i.test(value)) return true;
|
|
247
|
+
return false;
|
|
238
248
|
}
|
|
239
|
-
// ── Alternative generation ───────────────────────────────────────────────────
|
|
240
|
-
const attr = (a, key) => a.attributes[key] || null;
|
|
241
|
-
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
242
|
-
/** Escape a value for use inside a double-quoted CSS attribute selector. */
|
|
243
|
-
const escCssAttrValue = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
244
|
-
/** An id usable as a bare `#id` CSS selector without escaping (framework ids like `:r3:` are not). */
|
|
245
|
-
const isCssSafeId = (id) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(id);
|
|
246
|
-
/**
|
|
247
|
-
* Build a ranked list of alternative locators from the captured element
|
|
248
|
-
* attributes. The list is sorted descending by stability score.
|
|
249
|
-
*
|
|
250
|
-
* Only generates alternatives that differ from each other — no duplicates
|
|
251
|
-
* of the same locator expression.
|
|
252
|
-
*/
|
|
253
249
|
function generateAlternatives(attrs) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
};
|
|
262
|
-
const { accessibleName } = attrs;
|
|
263
|
-
const tag = attrs.tagName;
|
|
264
|
-
const role = resolveAriaRole(attrs);
|
|
265
|
-
// A probed count > 1 means the selector matches several elements on the
|
|
266
|
-
// captured page — a strict-mode violation, so the alternative is suppressed.
|
|
267
|
-
// Unknown counts (probe absent/failed) keep the alternative.
|
|
268
|
-
const counts = attrs.selectorCounts;
|
|
269
|
-
const isUnique = (n) => n == null || n <= 1;
|
|
270
|
-
// Collapse whitespace: multi-line/indented textContent would otherwise
|
|
271
|
-
// produce a getByText with literal newlines — invalid when pasted as code.
|
|
272
|
-
const text = attrs.textContent ? attrs.textContent.replace(/\s+/g, ' ').trim() : null;
|
|
273
|
-
// 1. data-testid — highest stability (100)
|
|
274
|
-
const testId = attr(attrs, 'data-testid');
|
|
275
|
-
if (testId && isUnique(counts?.testId)) {
|
|
276
|
-
add({
|
|
277
|
-
locator: `getByTestId('${esc(testId)}')`,
|
|
278
|
-
method: 'getByTestId',
|
|
279
|
-
args: { testId },
|
|
280
|
-
score: 100,
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
// 2. role + accessible name from browser ARIA tree (85-95)
|
|
284
|
-
if (role && accessibleName) {
|
|
285
|
-
add({
|
|
286
|
-
locator: `getByRole('${role}', { name: '${esc(accessibleName)}' })`,
|
|
287
|
-
method: 'getByRole',
|
|
288
|
-
args: { role, name: accessibleName },
|
|
289
|
-
score: 90,
|
|
290
|
-
});
|
|
250
|
+
const alts = [];
|
|
251
|
+
const seen = /* @__PURE__ */ new Set();
|
|
252
|
+
const add = (loc) => {
|
|
253
|
+
if (!seen.has(loc.locator)) {
|
|
254
|
+
seen.add(loc.locator);
|
|
255
|
+
alts.push(loc);
|
|
291
256
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
257
|
+
};
|
|
258
|
+
const { accessibleName } = attrs;
|
|
259
|
+
const tag = attrs.tagName;
|
|
260
|
+
const role = resolveAriaRole(attrs);
|
|
261
|
+
const counts = attrs.selectorCounts;
|
|
262
|
+
const isUnique = (n) => n == null || n <= 1;
|
|
263
|
+
const text = attrs.textContent ? attrs.textContent.replace(/\s+/g, " ").trim() : null;
|
|
264
|
+
const testId = attr(attrs, "data-testid");
|
|
265
|
+
if (testId && isUnique(counts?.testId)) {
|
|
266
|
+
add({
|
|
267
|
+
locator: `getByTestId('${esc(testId)}')`,
|
|
268
|
+
method: "getByTestId",
|
|
269
|
+
args: { testId },
|
|
270
|
+
score: 100
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const level = headingLevel(attrs, role);
|
|
274
|
+
const levelPart = level != null ? `, level: ${level}` : "";
|
|
275
|
+
const withLevel = (base) => level != null ? { ...base, level } : base;
|
|
276
|
+
if (role && accessibleName) {
|
|
277
|
+
add({
|
|
278
|
+
locator: `getByRole('${role}', { name: '${esc(accessibleName)}'${levelPart} })`,
|
|
279
|
+
method: "getByRole",
|
|
280
|
+
args: withLevel({ role, name: accessibleName }),
|
|
281
|
+
score: 90
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const ariaLabel = attr(attrs, "aria-label");
|
|
285
|
+
if (role && ariaLabel && ariaLabel !== accessibleName) {
|
|
286
|
+
add({
|
|
287
|
+
locator: `getByRole('${role}', { name: '${esc(ariaLabel)}'${levelPart} })`,
|
|
288
|
+
method: "getByRole",
|
|
289
|
+
args: withLevel({ role, name: ariaLabel }),
|
|
290
|
+
score: 85
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (accessibleName && ["input", "select", "textarea"].includes(tag)) {
|
|
294
|
+
const label = attr(attrs, "aria-label");
|
|
295
|
+
const labelBacked = attrs.hasLabel === void 0 ? true : attrs.hasLabel === true || label === accessibleName;
|
|
296
|
+
if (labelBacked) {
|
|
297
|
+
add({
|
|
298
|
+
locator: `getByLabel('${esc(accessibleName)}')`,
|
|
299
|
+
method: "getByLabel",
|
|
300
|
+
args: { label: accessibleName },
|
|
301
|
+
score: 85
|
|
302
|
+
});
|
|
337
303
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
304
|
+
}
|
|
305
|
+
const placeholder = attr(attrs, "placeholder");
|
|
306
|
+
if (placeholder) {
|
|
307
|
+
add({
|
|
308
|
+
locator: `getByPlaceholder('${esc(placeholder)}')`,
|
|
309
|
+
method: "getByPlaceholder",
|
|
310
|
+
args: { placeholder },
|
|
311
|
+
score: 80
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (text && text.length < 80) {
|
|
315
|
+
add({
|
|
316
|
+
locator: `getByText('${esc(text)}')`,
|
|
317
|
+
method: "getByText",
|
|
318
|
+
args: { text },
|
|
319
|
+
score: 75
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
const id = attr(attrs, "id");
|
|
323
|
+
if (id && !isAutoGenerated(id) && isUnique(counts?.id)) {
|
|
324
|
+
const selector = isCssSafeId(id) ? `#${id}` : `[id="${escCssAttrValue(id)}"]`;
|
|
325
|
+
add({
|
|
326
|
+
locator: `locator('${esc(selector)}')`,
|
|
327
|
+
method: "locator",
|
|
328
|
+
args: { selector },
|
|
329
|
+
score: 65
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
const name = attr(attrs, "name");
|
|
333
|
+
if (name && isUnique(counts?.name)) {
|
|
334
|
+
const selector = `[name="${escCssAttrValue(name)}"]`;
|
|
335
|
+
add({
|
|
336
|
+
locator: `locator('${esc(selector)}')`,
|
|
337
|
+
method: "locator",
|
|
338
|
+
args: { selector },
|
|
339
|
+
score: 60
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
const alt = attr(attrs, "alt");
|
|
343
|
+
if (alt) {
|
|
344
|
+
add({
|
|
345
|
+
locator: `getByAltText('${esc(alt)}')`,
|
|
346
|
+
method: "getByAltText",
|
|
347
|
+
args: { text: alt },
|
|
348
|
+
score: 60
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
const title = attr(attrs, "title");
|
|
352
|
+
if (title) {
|
|
353
|
+
add({
|
|
354
|
+
locator: `getByTitle('${esc(title)}')`,
|
|
355
|
+
method: "getByTitle",
|
|
356
|
+
args: { title },
|
|
357
|
+
score: 50
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const hasOwnTestId = !!(testId && isUnique(counts?.testId));
|
|
361
|
+
if (role && !hasOwnTestId) {
|
|
362
|
+
const rolePart = level != null ? `'${role}', { level: ${level} }` : `'${role}'`;
|
|
363
|
+
const leafArgs = withLevel({ role });
|
|
364
|
+
let testIdAnchorDone = false;
|
|
365
|
+
let idAnchorDone = false;
|
|
366
|
+
let roleAnchorDone = false;
|
|
367
|
+
for (const anc of attrs.ancestors ?? []) {
|
|
368
|
+
if (anc.scopedRoleCount !== 1) continue;
|
|
369
|
+
if (!testIdAnchorDone && anc.testId && anc.testIdCount === 1) {
|
|
370
|
+
testIdAnchorDone = true;
|
|
344
371
|
add({
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
372
|
+
locator: `getByTestId('${esc(anc.testId)}').getByRole(${rolePart})`,
|
|
373
|
+
method: "getByRole",
|
|
374
|
+
args: { ...leafArgs, anchorTestId: anc.testId },
|
|
375
|
+
score: 72
|
|
349
376
|
});
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const selector = `[name="${escCssAttrValue(name)}"]`;
|
|
377
|
+
}
|
|
378
|
+
if (!idAnchorDone && anc.id && !isAutoGenerated(anc.id) && anc.idCount === 1) {
|
|
379
|
+
idAnchorDone = true;
|
|
380
|
+
const anchorSelector = isCssSafeId(anc.id) ? `#${anc.id}` : `[id="${escCssAttrValue(anc.id)}"]`;
|
|
355
381
|
add({
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
382
|
+
locator: `locator('${esc(anchorSelector)}').getByRole(${rolePart})`,
|
|
383
|
+
method: "getByRole",
|
|
384
|
+
args: { ...leafArgs, anchorSelector },
|
|
385
|
+
score: 64
|
|
360
386
|
});
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
387
|
+
}
|
|
388
|
+
const ancestorRole = anc.role || TAG_TO_ROLE[anc.tag] || null;
|
|
389
|
+
if (!roleAnchorDone && ancestorRole && ancestorRole !== role && anc.roleCount === 1) {
|
|
390
|
+
roleAnchorDone = true;
|
|
365
391
|
add({
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
392
|
+
locator: `getByRole('${esc(ancestorRole)}').getByRole(${rolePart})`,
|
|
393
|
+
method: "getByRole",
|
|
394
|
+
args: { ...leafArgs, anchorRole: ancestorRole },
|
|
395
|
+
score: 55
|
|
370
396
|
});
|
|
397
|
+
}
|
|
371
398
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
});
|
|
399
|
+
const pos = attrs.rolePosition;
|
|
400
|
+
if (pos && pos.role === role && (pos.count === 1 || level != null && pos.levelCount === 1)) {
|
|
401
|
+
add({
|
|
402
|
+
locator: `getByRole(${rolePart})`,
|
|
403
|
+
method: "getByRole",
|
|
404
|
+
args: leafArgs,
|
|
405
|
+
score: 58
|
|
406
|
+
});
|
|
381
407
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}))
|
|
395
|
-
.sort((a, b) => b.score - a.score)
|
|
396
|
-
.slice(0, 3);
|
|
397
|
-
for (const { cls, score } of classes) {
|
|
398
|
-
add({
|
|
399
|
-
locator: `locator('.${esc(cls)}')`,
|
|
400
|
-
method: 'locator',
|
|
401
|
-
args: { selector: `.${cls}` },
|
|
402
|
-
score,
|
|
403
|
-
});
|
|
404
|
-
}
|
|
408
|
+
}
|
|
409
|
+
const clsStr = attr(attrs, "class");
|
|
410
|
+
if (clsStr) {
|
|
411
|
+
const countsClasses = counts?.classes;
|
|
412
|
+
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);
|
|
413
|
+
for (const { cls, score } of classes) {
|
|
414
|
+
add({
|
|
415
|
+
locator: `locator('.${esc(cls)}')`,
|
|
416
|
+
method: "locator",
|
|
417
|
+
args: { selector: `.${cls}` },
|
|
418
|
+
score
|
|
419
|
+
});
|
|
405
420
|
}
|
|
406
|
-
|
|
421
|
+
}
|
|
422
|
+
return alts.sort((a, b) => b.score - a.score);
|
|
407
423
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
*/
|
|
419
|
-
function classifyCssStability(className) {
|
|
420
|
-
// Hex hash patterns: contains 8+ consecutive hex digits
|
|
421
|
-
if (/[a-f0-9]{8,}/i.test(className))
|
|
422
|
-
return 10;
|
|
423
|
-
// CSS-in-JS patterns — check before utility/semantic since prefixes like
|
|
424
|
-
// "css-", "emotion-" are generated and fragile
|
|
425
|
-
if (/^(?:css|sc|emotion|styled)-/i.test(className))
|
|
426
|
-
return 15;
|
|
427
|
-
// Tailwind/utility classes
|
|
428
|
-
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(className))
|
|
429
|
-
return 25;
|
|
430
|
-
// BEM-style: block__element--modifier
|
|
431
|
-
if (/__/.test(className) || /--/.test(className))
|
|
432
|
-
return 35;
|
|
433
|
-
// Plain semantic class: hyphenated word pairs or camelCase
|
|
434
|
-
if (/^[a-z]+(-[a-z]+)+$/.test(className))
|
|
435
|
-
return 40;
|
|
436
|
-
if (/^[a-z]+[A-Z][a-zA-Z]+$/.test(className))
|
|
437
|
-
return 40;
|
|
438
|
-
// Hyphenated strings with digit-containing suffixes that look hashed
|
|
439
|
-
if (className.includes('_'))
|
|
440
|
-
return 15;
|
|
441
|
-
if (/^[a-z]+-[a-z0-9]{5,}$/.test(className) && /[0-9]/.test(className))
|
|
442
|
-
return 15;
|
|
443
|
-
// Unknown — score conservatively
|
|
444
|
-
return 15;
|
|
424
|
+
function approximateAccessibleName(attrs) {
|
|
425
|
+
const a = attrs.attributes;
|
|
426
|
+
const ariaLabel = a["aria-label"];
|
|
427
|
+
if (ariaLabel) return ariaLabel;
|
|
428
|
+
if (attrs.textContent) return attrs.textContent;
|
|
429
|
+
const title = a["title"];
|
|
430
|
+
if (title) return title;
|
|
431
|
+
const placeholder = a["placeholder"];
|
|
432
|
+
if (placeholder) return placeholder;
|
|
433
|
+
return null;
|
|
445
434
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
*/
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
return true;
|
|
461
|
-
// Random-looking CSS-in-JS IDs
|
|
462
|
-
if (/^(emotion-|styled-|css-|sc-)/.test(value))
|
|
463
|
-
return true;
|
|
464
|
-
// Angular-style generated IDs (ng-xxx-N)
|
|
465
|
-
if (value.startsWith('ng-'))
|
|
466
|
-
return true;
|
|
467
|
-
// Component-library generated ids (Radix, Headless UI, MUI, Mantine, Chakra)
|
|
468
|
-
if (/^(radix-|headlessui-|mui-|mantine-|chakra-)/i.test(value))
|
|
469
|
-
return true;
|
|
470
|
-
// React useId format — `:r1:` (18) / `«r1»` (19)
|
|
471
|
-
if (/^:r[0-9a-z]+:$/i.test(value) || /^«r[0-9a-z]+»$/i.test(value))
|
|
472
|
-
return true;
|
|
473
|
-
return false;
|
|
435
|
+
|
|
436
|
+
// src/internal/capture/locator-healing.ts
|
|
437
|
+
function dedupeSnapshotsByLocation(snaps) {
|
|
438
|
+
const lastWithElement = /* @__PURE__ */ new Map();
|
|
439
|
+
const lastAny = /* @__PURE__ */ new Map();
|
|
440
|
+
snaps.forEach((s, i) => {
|
|
441
|
+
if (!s.location) return;
|
|
442
|
+
lastAny.set(s.location, i);
|
|
443
|
+
if (s.element) lastWithElement.set(s.location, i);
|
|
444
|
+
});
|
|
445
|
+
return snaps.filter((s, i) => {
|
|
446
|
+
if (!s.location) return true;
|
|
447
|
+
return (lastWithElement.get(s.location) ?? lastAny.get(s.location)) === i;
|
|
448
|
+
});
|
|
474
449
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
450
|
+
var LOCATOR_METHODS = [...LOCATOR_BUILDER_METHODS];
|
|
451
|
+
var CHAIN_METHODS = [
|
|
452
|
+
"first",
|
|
453
|
+
"nth",
|
|
454
|
+
"last",
|
|
455
|
+
"filter",
|
|
456
|
+
"and",
|
|
457
|
+
"or",
|
|
458
|
+
"locator",
|
|
459
|
+
"getByRole",
|
|
460
|
+
"getByTestId",
|
|
461
|
+
"getByText",
|
|
462
|
+
"getByLabel",
|
|
463
|
+
"getByPlaceholder",
|
|
464
|
+
"getByAltText",
|
|
465
|
+
"getByTitle"
|
|
466
|
+
];
|
|
467
|
+
var ACTION_METHODS = [
|
|
468
|
+
"click",
|
|
469
|
+
"fill",
|
|
470
|
+
"check",
|
|
471
|
+
"uncheck",
|
|
472
|
+
"selectOption",
|
|
473
|
+
"dblclick",
|
|
474
|
+
"tap",
|
|
475
|
+
"hover",
|
|
476
|
+
"press",
|
|
477
|
+
"type",
|
|
478
|
+
"pressSequentially",
|
|
479
|
+
"clear",
|
|
480
|
+
"setInputFiles",
|
|
481
|
+
"dragTo",
|
|
482
|
+
"focus",
|
|
483
|
+
"blur",
|
|
484
|
+
"scrollIntoViewIfNeeded",
|
|
485
|
+
"dispatchEvent",
|
|
486
|
+
"selectText",
|
|
487
|
+
// Not an action, but a successful waitFor proves the element resolved — the
|
|
488
|
+
// closest capture hook available for assertion-style usage of a locator.
|
|
489
|
+
"waitFor"
|
|
490
|
+
];
|
|
491
|
+
var LOCATOR_CREATING_CHAINS = new Set(LOCATOR_METHODS);
|
|
487
492
|
function extractAccessibleName(ariaSnapshot) {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const match = ariaSnapshot.match(/- \w+ "([^"]+)"/);
|
|
493
|
-
if (match)
|
|
494
|
-
return match[1];
|
|
495
|
-
return null;
|
|
493
|
+
if (!ariaSnapshot) return null;
|
|
494
|
+
const match = ariaSnapshot.match(/- \w+ "([^"]+)"/);
|
|
495
|
+
if (match) return match[1];
|
|
496
|
+
return null;
|
|
496
497
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
if (ariaLabel)
|
|
505
|
-
return ariaLabel;
|
|
506
|
-
if (attrs.textContent)
|
|
507
|
-
return attrs.textContent;
|
|
508
|
-
const title = a['title'];
|
|
509
|
-
if (title)
|
|
510
|
-
return title;
|
|
511
|
-
const placeholder = a['placeholder'];
|
|
512
|
-
if (placeholder)
|
|
513
|
-
return placeholder;
|
|
514
|
-
return null;
|
|
515
|
-
}
|
|
516
|
-
/** Name-based locator methods — the only ones whose target can be re-found by accessible name. */
|
|
517
|
-
const NAME_BASED_METHODS = new Set([
|
|
518
|
-
'getByText',
|
|
519
|
-
'getByRole',
|
|
520
|
-
'getByLabel',
|
|
521
|
-
'getByPlaceholder',
|
|
522
|
-
'getByTitle',
|
|
523
|
-
'getByAltText',
|
|
498
|
+
var NAME_BASED_METHODS = /* @__PURE__ */ new Set([
|
|
499
|
+
"getByText",
|
|
500
|
+
"getByRole",
|
|
501
|
+
"getByLabel",
|
|
502
|
+
"getByPlaceholder",
|
|
503
|
+
"getByTitle",
|
|
504
|
+
"getByAltText"
|
|
524
505
|
]);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
}
|
|
543
|
-
return out;
|
|
544
|
-
}
|
|
545
|
-
/**
|
|
546
|
-
* Token-set (Dice) similarity, 0-1, case- and punctuation-insensitive.
|
|
547
|
-
* Duplicated from `textSimilarity` in application/shared/locator-fingerprint.ts —
|
|
548
|
-
* this package publishes standalone to npm and can't import monorepo-relative
|
|
549
|
-
* shared/ code, so keep the two implementations in sync by hand. Exported so
|
|
550
|
-
* the dashboard's drift-guard unit test can compare the two.
|
|
551
|
-
*/
|
|
552
|
-
function nameSimilarity(a, b) {
|
|
553
|
-
const tok = (s) => new Set((s ?? '')
|
|
554
|
-
.toLowerCase()
|
|
555
|
-
.split(/[^a-z0-9]+/i)
|
|
556
|
-
.filter(Boolean));
|
|
557
|
-
const sa = tok(a);
|
|
558
|
-
const sb = tok(b);
|
|
559
|
-
if (sa.size === 0 && sb.size === 0)
|
|
560
|
-
return 1;
|
|
561
|
-
if (sa.size === 0 || sb.size === 0)
|
|
562
|
-
return 0;
|
|
563
|
-
let common = 0;
|
|
564
|
-
for (const t of sa)
|
|
565
|
-
if (sb.has(t))
|
|
566
|
-
common++;
|
|
567
|
-
return (2 * common) / (sa.size + sb.size);
|
|
568
|
-
}
|
|
569
|
-
const SUGG_TEXT_ROLES = new Set([
|
|
570
|
-
'button',
|
|
571
|
-
'link',
|
|
572
|
-
'heading',
|
|
573
|
-
'menuitem',
|
|
574
|
-
'tab',
|
|
575
|
-
'option',
|
|
576
|
-
'cell',
|
|
577
|
-
'columnheader',
|
|
578
|
-
'rowheader',
|
|
579
|
-
'gridcell',
|
|
580
|
-
'treeitem',
|
|
581
|
-
'listitem',
|
|
582
|
-
'checkbox',
|
|
583
|
-
'radio',
|
|
584
|
-
'switch',
|
|
506
|
+
var escAttr = (s) => s.replaceAll("\\", "\\\\").replaceAll("'", "\\'");
|
|
507
|
+
var SUGG_TEXT_ROLES = /* @__PURE__ */ new Set([
|
|
508
|
+
"button",
|
|
509
|
+
"link",
|
|
510
|
+
"heading",
|
|
511
|
+
"menuitem",
|
|
512
|
+
"tab",
|
|
513
|
+
"option",
|
|
514
|
+
"cell",
|
|
515
|
+
"columnheader",
|
|
516
|
+
"rowheader",
|
|
517
|
+
"gridcell",
|
|
518
|
+
"treeitem",
|
|
519
|
+
"listitem",
|
|
520
|
+
"checkbox",
|
|
521
|
+
"radio",
|
|
522
|
+
"switch"
|
|
585
523
|
]);
|
|
586
|
-
|
|
587
|
-
/** Extract the role (for getByRole) and the targeted accessible name from a failed locator's args. */
|
|
524
|
+
var SUGG_FIELD_ROLES = /* @__PURE__ */ new Set(["textbox", "combobox", "searchbox", "spinbutton", "slider"]);
|
|
588
525
|
function failedNameAndRole(failed) {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
|
|
526
|
+
if (failed.method === "getByRole") {
|
|
527
|
+
const role = typeof failed.args[0] === "string" ? failed.args[0] : null;
|
|
528
|
+
const opts = failed.args[1];
|
|
529
|
+
const name = opts && typeof opts.name === "string" ? opts.name : null;
|
|
530
|
+
const level = opts && typeof opts.level === "number" ? opts.level : null;
|
|
531
|
+
return { role, name, level };
|
|
532
|
+
}
|
|
533
|
+
const first = failed.args.find((a) => typeof a === "string");
|
|
534
|
+
return { role: null, name: typeof first === "string" ? first : null, level: null };
|
|
597
535
|
}
|
|
598
|
-
/** Render the failed locator back to source for the annotation message. */
|
|
599
536
|
function renderFailing(failed) {
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
}
|
|
606
|
-
return `${failed.method}('${escAttr(name ?? '')}')`;
|
|
537
|
+
const { role, name } = failedNameAndRole(failed);
|
|
538
|
+
if (failed.method === "getByRole") {
|
|
539
|
+
return name ? `getByRole('${escAttr(role ?? "")}', { name: '${escAttr(name)}' })` : `getByRole('${escAttr(role ?? "")}')`;
|
|
540
|
+
}
|
|
541
|
+
return `${failed.method}('${escAttr(name ?? "")}')`;
|
|
607
542
|
}
|
|
608
|
-
/** Build fresh locator suggestions for a matched candidate, with the failed method's style first. */
|
|
609
543
|
function freshSuggestions(candidate, failedMethod) {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
if (SUGG_TEXT_ROLES.has(role))
|
|
627
|
-
push(textLoc);
|
|
628
|
-
else if (SUGG_FIELD_ROLES.has(role))
|
|
629
|
-
push(labelLoc);
|
|
630
|
-
return out;
|
|
544
|
+
const out = [];
|
|
545
|
+
const role = candidate.role;
|
|
546
|
+
const name = candidate.name;
|
|
547
|
+
const push = (s) => {
|
|
548
|
+
if (!out.includes(s)) out.push(s);
|
|
549
|
+
};
|
|
550
|
+
const levelPart = candidate.level != null ? `, level: ${candidate.level}` : "";
|
|
551
|
+
const roleLoc = `getByRole('${escAttr(role)}', { name: '${escAttr(name)}'${levelPart} })`;
|
|
552
|
+
const textLoc = `getByText('${escAttr(name)}')`;
|
|
553
|
+
const labelLoc = `getByLabel('${escAttr(name)}')`;
|
|
554
|
+
if (failedMethod === "getByText" && SUGG_TEXT_ROLES.has(role)) push(textLoc);
|
|
555
|
+
if (failedMethod === "getByLabel" && SUGG_FIELD_ROLES.has(role)) push(labelLoc);
|
|
556
|
+
push(roleLoc);
|
|
557
|
+
if (SUGG_TEXT_ROLES.has(role)) push(textLoc);
|
|
558
|
+
else if (SUGG_FIELD_ROLES.has(role)) push(labelLoc);
|
|
559
|
+
return out;
|
|
631
560
|
}
|
|
632
|
-
/**
|
|
633
|
-
* Best-effort runtime suggestion for a locator that matched nothing: find the
|
|
634
|
-
* element on the *current* page that the failed locator most likely targeted
|
|
635
|
-
* (by accessible name similarity, restricted to the failed role when given) and
|
|
636
|
-
* return fresh locators for it.
|
|
637
|
-
*
|
|
638
|
-
* Unlike the server lookup this has no pre-captured fingerprint — only the
|
|
639
|
-
* failed locator + the live page — so it's a hint, not a guarantee. Returns null
|
|
640
|
-
* for non-name-based locators (testid/CSS), when the targeted name is still
|
|
641
|
-
* present (so the failure wasn't a rename), or when no candidate is confident.
|
|
642
|
-
*/
|
|
643
561
|
function suggestLocatorsFromAria(failed, ariaSnapshot) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
return null;
|
|
657
|
-
let best = null;
|
|
658
|
-
let bestScore = -1;
|
|
659
|
-
for (const c of pool) {
|
|
660
|
-
const s = nameSimilarity(c.name, name);
|
|
661
|
-
if (s > bestScore) {
|
|
662
|
-
bestScore = s;
|
|
663
|
-
best = c;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
if (!best || !best.name)
|
|
667
|
-
return null;
|
|
668
|
-
if (bestScore < 0.2 && pool.length !== 1)
|
|
669
|
-
return null;
|
|
670
|
-
const suggestions = freshSuggestions({ role: best.role, name: best.name }, failed.method);
|
|
671
|
-
if (suggestions.length === 0)
|
|
672
|
-
return null;
|
|
673
|
-
return { failing: renderFailing(failed), suggestions };
|
|
562
|
+
if (!ariaSnapshot || !NAME_BASED_METHODS.has(failed.method)) return null;
|
|
563
|
+
const { role, name, level } = failedNameAndRole(failed);
|
|
564
|
+
if (!name) return null;
|
|
565
|
+
const candidates = parseAriaCandidates(ariaSnapshot);
|
|
566
|
+
if (candidates.length === 0) return null;
|
|
567
|
+
const fingerprint = { role, name, level };
|
|
568
|
+
if (fingerprintPresent(fingerprint, candidates)) return null;
|
|
569
|
+
const best = matchRenamedElement(fingerprint, candidates)?.candidate;
|
|
570
|
+
if (!best || !best.name) return null;
|
|
571
|
+
const suggestions = freshSuggestions({ role: best.role, name: best.name, level: best.level }, failed.method);
|
|
572
|
+
if (suggestions.length === 0) return null;
|
|
573
|
+
return { failing: renderFailing(failed), suggestions };
|
|
674
574
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
for (let i = 1; i < lines.length; i++) {
|
|
700
|
-
const line = lines[i].trim();
|
|
701
|
-
if (!line.startsWith('at'))
|
|
702
|
-
continue;
|
|
703
|
-
const m = line.match(/^at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/);
|
|
704
|
-
if (!m) {
|
|
705
|
-
prevWasCaptureModule = false;
|
|
706
|
-
continue;
|
|
707
|
-
}
|
|
708
|
-
let file = m[2];
|
|
709
|
-
if (!file || file.startsWith('node:')) {
|
|
710
|
-
prevWasCaptureModule = false;
|
|
711
|
-
continue;
|
|
712
|
-
}
|
|
713
|
-
file = file.replace(/^file:\/\/\/?/, '');
|
|
714
|
-
// Must look like a source file (has an extension).
|
|
715
|
-
if (!/\.[a-z]+$/i.test(file)) {
|
|
716
|
-
prevWasCaptureModule = false;
|
|
717
|
-
continue;
|
|
718
|
-
}
|
|
719
|
-
// This module — always an internal frame; remember it so the immediately
|
|
720
|
-
// following fixtures-proxy frame can be skipped too.
|
|
721
|
-
if (/[\\/]locator-healing\.[a-z]+$/i.test(file)) {
|
|
722
|
-
prevWasCaptureModule = true;
|
|
723
|
-
continue;
|
|
724
|
-
}
|
|
725
|
-
// The fixtures proxy that called us — skip only when it directly follows
|
|
726
|
-
// this module, so a user's own `fixtures.*` deeper down is not dropped.
|
|
727
|
-
if (prevWasCaptureModule && /[\\/]fixtures\.[a-z]+$/i.test(file)) {
|
|
728
|
-
prevWasCaptureModule = false;
|
|
729
|
-
continue;
|
|
730
|
-
}
|
|
731
|
-
if (/[\\/]node_modules[\\/]/.test(file)) {
|
|
732
|
-
prevWasCaptureModule = false;
|
|
733
|
-
continue;
|
|
734
|
-
}
|
|
735
|
-
let rel = file;
|
|
736
|
-
try {
|
|
737
|
-
rel = path.relative(process.cwd(), file);
|
|
738
|
-
}
|
|
739
|
-
catch {
|
|
740
|
-
/* keep absolute if relative fails */
|
|
741
|
-
}
|
|
742
|
-
rel = rel.split(path.sep).join('/');
|
|
743
|
-
if (rel.startsWith('./'))
|
|
744
|
-
rel = rel.slice(2);
|
|
745
|
-
return `${rel}:${m[3]}:${m[4]}`;
|
|
575
|
+
function captureCallerLocation(stack = new Error().stack ?? "") {
|
|
576
|
+
const lines = stack.split("\n");
|
|
577
|
+
let prevWasCaptureModule = false;
|
|
578
|
+
for (let i = 1; i < lines.length; i++) {
|
|
579
|
+
const line = lines[i].trim();
|
|
580
|
+
if (!line.startsWith("at")) continue;
|
|
581
|
+
const m = line.match(/^at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/);
|
|
582
|
+
if (!m) {
|
|
583
|
+
prevWasCaptureModule = false;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
let file = m[2];
|
|
587
|
+
if (!file || file.startsWith("node:")) {
|
|
588
|
+
prevWasCaptureModule = false;
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
file = file.replace(/^file:\/\/\/?/, "");
|
|
592
|
+
if (!/\.[a-z]+$/i.test(file)) {
|
|
593
|
+
prevWasCaptureModule = false;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (/[\\/]locator-healing\.[a-z]+$/i.test(file)) {
|
|
597
|
+
prevWasCaptureModule = true;
|
|
598
|
+
continue;
|
|
746
599
|
}
|
|
747
|
-
|
|
600
|
+
if (prevWasCaptureModule && /[\\/](?:capture-)?fixtures\.[a-z]+$/i.test(file)) {
|
|
601
|
+
prevWasCaptureModule = false;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (/[\\/]node_modules[\\/]/.test(file)) {
|
|
605
|
+
prevWasCaptureModule = false;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
let rel = file;
|
|
609
|
+
try {
|
|
610
|
+
rel = path.relative(process.cwd(), file);
|
|
611
|
+
} catch {
|
|
612
|
+
}
|
|
613
|
+
rel = rel.split(path.sep).join("/");
|
|
614
|
+
if (rel.startsWith("./")) rel = rel.slice(2);
|
|
615
|
+
return `${rel}:${m[3]}:${m[4]}`;
|
|
616
|
+
}
|
|
617
|
+
return null;
|
|
748
618
|
}
|
|
619
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
620
|
+
0 && (module.exports = {
|
|
621
|
+
ACTION_METHODS,
|
|
622
|
+
CAPTURED_ATTRIBUTES,
|
|
623
|
+
CHAIN_METHODS,
|
|
624
|
+
INPUT_TYPE_TO_ROLE,
|
|
625
|
+
LOCATOR_CREATING_CHAINS,
|
|
626
|
+
LOCATOR_METHODS,
|
|
627
|
+
TAG_TO_ROLE,
|
|
628
|
+
approximateAccessibleName,
|
|
629
|
+
captureCallerLocation,
|
|
630
|
+
classifyCssStability,
|
|
631
|
+
dedupeSnapshotsByLocation,
|
|
632
|
+
extractAccessibleName,
|
|
633
|
+
generateAlternatives,
|
|
634
|
+
headingLevel,
|
|
635
|
+
isAutoGenerated,
|
|
636
|
+
renderFailing,
|
|
637
|
+
resolveAriaRole,
|
|
638
|
+
suggestLocatorsFromAria
|
|
639
|
+
});
|