@piwitests/reporter 0.12.0 → 0.14.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.
Files changed (77) hide show
  1. package/dist/global-setup-module.d.ts +2 -1
  2. package/dist/global-setup-module.js +432 -3
  3. package/dist/index.d.ts +204 -8
  4. package/dist/index.js +4724 -23
  5. package/dist/internal/capture/attachments.d.ts +8 -3
  6. package/dist/internal/capture/attachments.js +46 -21
  7. package/dist/internal/capture/capture-fixtures.d.ts +16 -14
  8. package/dist/internal/capture/capture-fixtures.js +2211 -971
  9. package/dist/internal/capture/inspect-on-failure.d.ts +50 -0
  10. package/dist/internal/capture/inspect-on-failure.js +66 -0
  11. package/dist/internal/capture/locator-healing.d.ts +33 -190
  12. package/dist/internal/capture/locator-healing.js +599 -815
  13. package/dist/internal/capture/pick-on-failure.d.ts +198 -0
  14. package/dist/internal/capture/pick-on-failure.js +1203 -0
  15. package/package.json +9 -4
  16. package/dist/internal/collect/error-text.d.ts +0 -18
  17. package/dist/internal/collect/error-text.js +0 -79
  18. package/dist/internal/collect/metadata-collector.d.ts +0 -32
  19. package/dist/internal/collect/metadata-collector.js +0 -246
  20. package/dist/internal/collect/skip-classify.d.ts +0 -27
  21. package/dist/internal/collect/skip-classify.js +0 -40
  22. package/dist/internal/collect/step-analyzer.d.ts +0 -103
  23. package/dist/internal/collect/step-analyzer.js +0 -221
  24. package/dist/internal/config/env.d.ts +0 -46
  25. package/dist/internal/config/env.js +0 -162
  26. package/dist/internal/files/compression.d.ts +0 -5
  27. package/dist/internal/files/compression.js +0 -69
  28. package/dist/internal/files/file-handler.d.ts +0 -38
  29. package/dist/internal/files/file-handler.js +0 -207
  30. package/dist/internal/streaming/crash-recovery.d.ts +0 -23
  31. package/dist/internal/streaming/crash-recovery.js +0 -106
  32. package/dist/internal/streaming/stream-buffer.d.ts +0 -17
  33. package/dist/internal/streaming/stream-buffer.js +0 -102
  34. package/dist/internal/streaming/stream-manager.d.ts +0 -88
  35. package/dist/internal/streaming/stream-manager.js +0 -395
  36. package/dist/internal/submit/run-submitter.d.ts +0 -67
  37. package/dist/internal/submit/run-submitter.js +0 -190
  38. package/dist/internal/submit/serializer.d.ts +0 -45
  39. package/dist/internal/submit/serializer.js +0 -108
  40. package/dist/internal/submit/uploader.d.ts +0 -89
  41. package/dist/internal/submit/uploader.js +0 -226
  42. package/dist/internal/support/ci.d.ts +0 -2
  43. package/dist/internal/support/ci.js +0 -32
  44. package/dist/internal/support/cli-filters.d.ts +0 -1
  45. package/dist/internal/support/cli-filters.js +0 -51
  46. package/dist/internal/support/errors.d.ts +0 -8
  47. package/dist/internal/support/errors.js +0 -15
  48. package/dist/internal/support/instance-id.d.ts +0 -4
  49. package/dist/internal/support/instance-id.js +0 -48
  50. package/dist/internal/support/limiter.d.ts +0 -2
  51. package/dist/internal/support/limiter.js +0 -27
  52. package/dist/internal/support/logger.d.ts +0 -26
  53. package/dist/internal/support/logger.js +0 -43
  54. package/dist/internal/support/reporter-version.d.ts +0 -2
  55. package/dist/internal/support/reporter-version.js +0 -54
  56. package/dist/internal/support/setup-file.d.ts +0 -13
  57. package/dist/internal/support/setup-file.js +0 -61
  58. package/dist/internal/support/source-snippet.d.ts +0 -12
  59. package/dist/internal/support/source-snippet.js +0 -97
  60. package/dist/internal/support/worker-index.d.ts +0 -7
  61. package/dist/internal/support/worker-index.js +0 -14
  62. package/dist/internal/transport/http-client.d.ts +0 -52
  63. package/dist/internal/transport/http-client.js +0 -201
  64. package/dist/public/config-wrapper.d.ts +0 -21
  65. package/dist/public/config-wrapper.js +0 -64
  66. package/dist/public/global-setup.d.ts +0 -13
  67. package/dist/public/global-setup.js +0 -146
  68. package/dist/public/options.d.ts +0 -86
  69. package/dist/public/options.js +0 -2
  70. package/dist/public/reporter.d.ts +0 -68
  71. package/dist/public/reporter.js +0 -376
  72. package/dist/types/collected.d.ts +0 -97
  73. package/dist/types/collected.js +0 -10
  74. package/dist/types/wire.d.ts +0 -176
  75. package/dist/types/wire.js +0 -14
  76. package/dist/types.d.ts +0 -11
  77. package/dist/types.js +0 -27
@@ -1,855 +1,639 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
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
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
- Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.INPUT_TYPE_TO_ROLE = exports.TAG_TO_ROLE = exports.CAPTURED_ATTRIBUTES = exports.LOCATOR_CREATING_CHAINS = exports.ACTION_METHODS = exports.CHAIN_METHODS = exports.LOCATOR_METHODS = void 0;
37
- exports.dedupeSnapshotsByLocation = dedupeSnapshotsByLocation;
38
- exports.resolveAriaRole = resolveAriaRole;
39
- exports.headingLevel = headingLevel;
40
- exports.generateAlternatives = generateAlternatives;
41
- exports.classifyCssStability = classifyCssStability;
42
- exports.isAutoGenerated = isAutoGenerated;
43
- exports.extractAccessibleName = extractAccessibleName;
44
- exports.approximateAccessibleName = approximateAccessibleName;
45
- exports.parseAriaRoleName = parseAriaRoleName;
46
- exports.nameSimilarity = nameSimilarity;
47
- exports.suggestLocatorsFromAria = suggestLocatorsFromAria;
48
- exports.captureCallerLocation = captureCallerLocation;
49
- const path = __importStar(require("node:path"));
50
- /**
51
- * Drop repeated captures of the same call site before attaching: a loop (or a
52
- * page-object method called repeatedly) produces one snapshot per action, but
53
- * the server keeps only the latest per location anyway. Keeps the last
54
- * element-bearing snapshot per location — falling back to the last placeholder
55
- * when no capture resolved, so the location still counts as "seen this run"
56
- * for the server's stale-location purge. Entries with no location pass through.
57
- */
58
- function dedupeSnapshotsByLocation(snaps) {
59
- const lastWithElement = new Map();
60
- const lastAny = new Map();
61
- snaps.forEach((s, i) => {
62
- if (!s.location)
63
- return;
64
- lastAny.set(s.location, i);
65
- if (s.element)
66
- lastWithElement.set(s.location, i);
67
- });
68
- return snaps.filter((s, i) => {
69
- if (!s.location)
70
- return true;
71
- return (lastWithElement.get(s.location) ?? lastAny.get(s.location)) === i;
72
- });
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;
73
72
  }
74
- // ── Playwright method surface (shared with the fixture proxy) ────────────────
75
- /**
76
- * Page-level locator-building methods wrapped by the capture proxy. Imported by
77
- * both the capture fixtures (`capture-fixtures.ts`) and the dogfooding
78
- * `application/tests/fixtures.ts` so the two stay in sync (a prior drift missed
79
- * `scrollIntoViewIfNeeded`).
80
- */
81
- exports.LOCATOR_METHODS = [
82
- 'getByRole',
83
- 'getByTestId',
84
- 'getByText',
85
- 'getByLabel',
86
- 'getByPlaceholder',
87
- 'getByAltText',
88
- 'getByTitle',
89
- 'locator',
90
- ];
91
- /**
92
- * Methods that can be chained onto a wrapped locator. Locator-creating chains
93
- * (those also in `LOCATOR_METHODS`) update the origin; positional/filter chains
94
- * (`first`, `nth`, `filter`, …) narrow without changing locator identity.
95
- */
96
- exports.CHAIN_METHODS = [
97
- 'first',
98
- 'nth',
99
- 'last',
100
- 'filter',
101
- 'and',
102
- 'or',
103
- 'locator',
104
- 'getByRole',
105
- 'getByTestId',
106
- 'getByText',
107
- 'getByLabel',
108
- 'getByPlaceholder',
109
- 'getByAltText',
110
- 'getByTitle',
111
- ];
112
- /** Locator action methods that trigger element capture. */
113
- exports.ACTION_METHODS = [
114
- 'click',
115
- 'fill',
116
- 'check',
117
- 'uncheck',
118
- 'selectOption',
119
- 'dblclick',
120
- 'tap',
121
- 'hover',
122
- 'press',
123
- 'type',
124
- 'pressSequentially',
125
- 'clear',
126
- 'setInputFiles',
127
- 'dragTo',
128
- 'focus',
129
- 'blur',
130
- 'scrollIntoViewIfNeeded',
131
- 'dispatchEvent',
132
- 'selectText',
133
- // Not an action, but a successful waitFor proves the element resolved — the
134
- // closest capture hook available for assertion-style usage of a locator.
135
- 'waitFor',
136
- ];
137
- /** Chain methods that create a new locator scope (origin tracks the chain call). */
138
- exports.LOCATOR_CREATING_CHAINS = new Set(exports.LOCATOR_METHODS);
139
- /**
140
- * Element attributes to capture after a successful action, passed into the
141
- * in-page `evaluate`. Shared so the reporter and dogfooding fixtures capture
142
- * the same attribute set.
143
- */
144
- exports.CAPTURED_ATTRIBUTES = [
145
- 'id',
146
- 'class',
147
- 'name',
148
- 'data-testid',
149
- 'placeholder',
150
- 'alt',
151
- 'title',
152
- 'aria-label',
153
- // `aria-level` carries the heading level for `role="heading"` elements
154
- // (h1-h6 levels come from the tag itself).
155
- 'aria-level',
156
- 'role',
157
- 'type',
158
- 'href',
159
- // `multiple` distinguishes listbox vs combobox for <select>. `value` is
160
- // deliberately NOT captured: no alternative generator uses it, and reading
161
- // the live value after fill() would leak user-typed secrets (passwords,
162
- // tokens) into the snapshot payload and server storage.
163
- '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"
164
131
  ];
165
- // ── ARIA role resolution ─────────────────────────────────────────────────────
166
- /**
167
- * Implicit ARIA role for an HTML tag (when no explicit `role` is set). Exported
168
- * so the in-page probe (`capture-fixtures.ts#probeElementAttrs`) can receive
169
- * this same map as an `evaluate` argument instead of re-declaring it — the
170
- * probe is serialized into the browser and can't reference this module's
171
- * closure, but the map is pure data and rides in as an argument.
172
- */
173
- exports.TAG_TO_ROLE = {
174
- a: 'link',
175
- button: 'button',
176
- nav: 'navigation',
177
- main: 'main',
178
- article: 'article',
179
- section: 'region',
180
- form: 'form',
181
- img: 'img',
182
- figure: 'figure',
183
- figcaption: 'caption',
184
- blockquote: 'blockquote',
185
- table: 'table',
186
- ul: 'list',
187
- ol: 'list',
188
- li: 'listitem',
189
- dialog: 'dialog',
190
- output: 'status',
191
- progress: 'progressbar',
192
- meter: 'meter',
193
- textarea: 'textbox',
194
- h1: 'heading',
195
- h2: 'heading',
196
- h3: 'heading',
197
- h4: 'heading',
198
- h5: 'heading',
199
- h6: 'heading',
200
- details: 'group',
201
- summary: 'button',
202
- search: 'search',
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"
203
164
  };
204
- /** Implicit ARIA role for an `<input>` keyed by its `type` attribute. Exported for the probe (see {@link TAG_TO_ROLE}). */
205
- exports.INPUT_TYPE_TO_ROLE = {
206
- button: 'button',
207
- submit: 'button',
208
- reset: 'button',
209
- image: 'button',
210
- checkbox: 'checkbox',
211
- radio: 'radio',
212
- range: 'slider',
213
- search: 'searchbox',
214
- number: 'spinbutton',
215
- text: 'textbox',
216
- email: 'textbox',
217
- tel: 'textbox',
218
- url: 'textbox',
219
- 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"
220
180
  };
221
- /**
222
- * Resolve the ARIA role for an element. An explicit `role` attribute wins;
223
- * otherwise the implicit role is derived from the tag name (and `type` for
224
- * `<input>`). Returns null when the element has no ARIA role (e.g. `<div>`,
225
- * `<span>`, `<a>` without `href`) — `getByRole` is not a valid locator for
226
- * such elements and other alternatives take over.
227
- */
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
+ ];
228
196
  function resolveAriaRole(attrs) {
229
- const explicit = attrs.attributes['role'];
230
- if (explicit)
231
- return explicit;
232
- const tag = attrs.tagName;
233
- if (!tag)
234
- return null;
235
- if (tag === 'input') {
236
- const type = (attrs.attributes['type'] ?? 'text').toLowerCase();
237
- return exports.INPUT_TYPE_TO_ROLE[type] ?? 'textbox';
238
- }
239
- // A plain <select> is a combobox; only with `multiple` (or size > 1, not
240
- // captured) does it become a listbox. Matches the server's implicitRoleForTag.
241
- if (tag === 'select') {
242
- return attrs.attributes['multiple'] != null ? 'listbox' : 'combobox';
243
- }
244
- if (tag === 'a') {
245
- return attrs.attributes['href'] != null ? 'link' : null;
246
- }
247
- return exports.TAG_TO_ROLE[tag] ?? null;
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;
248
212
  }
249
- /**
250
- * Heading level for a `heading`-role element: h1-h6 from the tag, else an
251
- * explicit `aria-level` attribute. Null when unknown (don't guess the ARIA
252
- * default) or when the element isn't a heading.
253
- */
254
213
  function headingLevel(attrs, role) {
255
- if (role !== 'heading')
256
- return null;
257
- const tagMatch = attrs.tagName.match(/^h([1-6])$/);
258
- if (tagMatch)
259
- return Number(tagMatch[1]);
260
- const ariaLevel = attrs.attributes['aria-level'];
261
- if (ariaLevel && /^\d+$/.test(ariaLevel))
262
- return Number(ariaLevel);
263
- return null;
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;
264
248
  }
265
- // ── Alternative generation ───────────────────────────────────────────────────
266
- const attr = (a, key) => a.attributes[key] || null;
267
- const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
268
- /** Escape a value for use inside a double-quoted CSS attribute selector. */
269
- const escCssAttrValue = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
270
- /** An id usable as a bare `#id` CSS selector without escaping (framework ids like `:r3:` are not). */
271
- const isCssSafeId = (id) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(id);
272
- /**
273
- * Build a ranked list of alternative locators from the captured element
274
- * attributes. The list is sorted descending by stability score.
275
- *
276
- * Only generates alternatives that differ from each other — no duplicates
277
- * of the same locator expression.
278
- */
279
249
  function generateAlternatives(attrs) {
280
- const alts = [];
281
- const seen = new Set();
282
- const add = (loc) => {
283
- if (!seen.has(loc.locator)) {
284
- seen.add(loc.locator);
285
- alts.push(loc);
286
- }
287
- };
288
- const { accessibleName } = attrs;
289
- const tag = attrs.tagName;
290
- const role = resolveAriaRole(attrs);
291
- // A probed count > 1 means the selector matches several elements on the
292
- // captured page — a strict-mode violation, so the alternative is suppressed.
293
- // Unknown counts (probe absent/failed) keep the alternative.
294
- const counts = attrs.selectorCounts;
295
- const isUnique = (n) => n == null || n <= 1;
296
- // Collapse whitespace: multi-line/indented textContent would otherwise
297
- // produce a getByText with literal newlines — invalid when pasted as code.
298
- const text = attrs.textContent ? attrs.textContent.replace(/\s+/g, ' ').trim() : null;
299
- // 1. data-testid — highest stability (100)
300
- const testId = attr(attrs, 'data-testid');
301
- if (testId && isUnique(counts?.testId)) {
302
- add({
303
- locator: `getByTestId('${esc(testId)}')`,
304
- method: 'getByTestId',
305
- args: { testId },
306
- score: 100,
307
- });
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);
308
256
  }
309
- // Heading level rides along in every heading getByRole — it survives renames
310
- // and disambiguates same-named headings at different levels.
311
- const level = headingLevel(attrs, role);
312
- const levelPart = level != null ? `, level: ${level}` : '';
313
- const withLevel = (base) => level != null ? { ...base, level } : base;
314
- // 2. role + accessible name from browser ARIA tree (85-95)
315
- if (role && accessibleName) {
316
- add({
317
- locator: `getByRole('${role}', { name: '${esc(accessibleName)}'${levelPart} })`,
318
- method: 'getByRole',
319
- args: withLevel({ role, name: accessibleName }),
320
- score: 90,
321
- });
322
- }
323
- // 3. role + explicit aria-label (85, fallback when no browser-computed name)
324
- const ariaLabel = attr(attrs, 'aria-label');
325
- if (role && ariaLabel && ariaLabel !== accessibleName) {
326
- add({
327
- locator: `getByRole('${role}', { name: '${esc(ariaLabel)}'${levelPart} })`,
328
- method: 'getByRole',
329
- args: withLevel({ role, name: ariaLabel }),
330
- score: 85,
331
- });
332
- }
333
- // 4. getByLabel — for form fields (85). getByLabel only matches a real
334
- // <label> association or aria-label — but the accessible name may have been
335
- // computed (or approximated) from placeholder/title, where getByLabel would
336
- // match nothing. Only emit when a label/aria-label actually backs the name;
337
- // legacy payloads without the hasLabel probe keep the old permissive behavior.
338
- if (accessibleName && ['input', 'select', 'textarea'].includes(tag)) {
339
- const label = attr(attrs, 'aria-label');
340
- const labelBacked = attrs.hasLabel === undefined ? true : attrs.hasLabel === true || label === accessibleName;
341
- if (labelBacked) {
342
- add({
343
- locator: `getByLabel('${esc(accessibleName)}')`,
344
- method: 'getByLabel',
345
- args: { label: accessibleName },
346
- score: 85,
347
- });
348
- }
349
- }
350
- // 5. getByPlaceholder — for inputs (80)
351
- const placeholder = attr(attrs, 'placeholder');
352
- if (placeholder) {
353
- add({
354
- locator: `getByPlaceholder('${esc(placeholder)}')`,
355
- method: 'getByPlaceholder',
356
- args: { placeholder },
357
- score: 80,
358
- });
359
- }
360
- // 6. getByText — from visible text content (70-80)
361
- if (text && text.length < 80) {
362
- add({
363
- locator: `getByText('${esc(text)}')`,
364
- method: 'getByText',
365
- args: { text },
366
- score: 75,
367
- });
368
- }
369
- // 7. locator('#id') — if id exists and doesn't look auto-generated (50-70).
370
- // Framework ids that aren't valid bare CSS identifiers (React useId's
371
- // `:r3:`, ids with dots) fall back to an attribute selector.
372
- const id = attr(attrs, 'id');
373
- if (id && !isAutoGenerated(id) && isUnique(counts?.id)) {
374
- const selector = isCssSafeId(id) ? `#${id}` : `[id="${escCssAttrValue(id)}"]`;
375
- add({
376
- locator: `locator('${esc(selector)}')`,
377
- method: 'locator',
378
- args: { selector },
379
- score: 65,
380
- });
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
+ });
381
303
  }
382
- // 8. locator('[name="..."]') — for form elements (60)
383
- const name = attr(attrs, 'name');
384
- if (name && isUnique(counts?.name)) {
385
- const selector = `[name="${escCssAttrValue(name)}"]`;
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;
386
371
  add({
387
- locator: `locator('${esc(selector)}')`,
388
- method: 'locator',
389
- args: { selector },
390
- score: 60,
372
+ locator: `getByTestId('${esc(anc.testId)}').getByRole(${rolePart})`,
373
+ method: "getByRole",
374
+ args: { ...leafArgs, anchorTestId: anc.testId },
375
+ score: 72
391
376
  });
392
- }
393
- // 9. getByAltText for images (60)
394
- const alt = attr(attrs, 'alt');
395
- if (alt) {
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)}"]`;
396
381
  add({
397
- locator: `getByAltText('${esc(alt)}')`,
398
- method: 'getByAltText',
399
- args: { text: alt },
400
- score: 60,
382
+ locator: `locator('${esc(anchorSelector)}').getByRole(${rolePart})`,
383
+ method: "getByRole",
384
+ args: { ...leafArgs, anchorSelector },
385
+ score: 64
401
386
  });
402
- }
403
- // 10. getByTitle (50)
404
- const title = attr(attrs, 'title');
405
- if (title) {
387
+ }
388
+ const ancestorRole = anc.role || TAG_TO_ROLE[anc.tag] || null;
389
+ if (!roleAnchorDone && ancestorRole && ancestorRole !== role && anc.roleCount === 1) {
390
+ roleAnchorDone = true;
406
391
  add({
407
- locator: `getByTitle('${esc(title)}')`,
408
- method: 'getByTitle',
409
- args: { title },
410
- score: 50,
392
+ locator: `getByRole('${esc(ancestorRole)}').getByRole(${rolePart})`,
393
+ method: "getByRole",
394
+ args: { ...leafArgs, anchorRole: ancestorRole },
395
+ score: 55
411
396
  });
397
+ }
412
398
  }
413
- // Structural alternatives (55-72) — name-free, so they survive label/text
414
- // renames that break every name-derived locator above. Skipped when the
415
- // element has its own unique data-testid (already the top alternative).
416
- // Chained alternatives carry the LEAF method with flat args (never a `name`
417
- // key) so the recommendation's method-family logic and the server's
418
- // fingerprint reader treat them correctly.
419
- const hasOwnTestId = !!(testId && isUnique(counts?.testId));
420
- if (role && !hasOwnTestId) {
421
- const rolePart = level != null ? `'${role}', { level: ${level} }` : `'${role}'`;
422
- const leafArgs = withLevel({ role });
423
- // Ancestor-anchored role locators: nearest anchor of each kind whose own
424
- // hook is document-unique and that contains exactly one leaf-role match
425
- // (level-scoped for headings — the probe counts accordingly).
426
- let testIdAnchorDone = false;
427
- let idAnchorDone = false;
428
- let roleAnchorDone = false;
429
- for (const anc of attrs.ancestors ?? []) {
430
- if (anc.scopedRoleCount !== 1)
431
- continue;
432
- if (!testIdAnchorDone && anc.testId && anc.testIdCount === 1) {
433
- testIdAnchorDone = true;
434
- add({
435
- locator: `getByTestId('${esc(anc.testId)}').getByRole(${rolePart})`,
436
- method: 'getByRole',
437
- args: { ...leafArgs, anchorTestId: anc.testId },
438
- score: 72,
439
- });
440
- }
441
- if (!idAnchorDone && anc.id && !isAutoGenerated(anc.id) && anc.idCount === 1) {
442
- idAnchorDone = true;
443
- const anchorSelector = isCssSafeId(anc.id) ? `#${anc.id}` : `[id="${escCssAttrValue(anc.id)}"]`;
444
- add({
445
- locator: `locator('${esc(anchorSelector)}').getByRole(${rolePart})`,
446
- method: 'getByRole',
447
- args: { ...leafArgs, anchorSelector },
448
- score: 64,
449
- });
450
- }
451
- const ancestorRole = anc.role || exports.TAG_TO_ROLE[anc.tag] || null;
452
- if (!roleAnchorDone && ancestorRole && ancestorRole !== role && anc.roleCount === 1) {
453
- roleAnchorDone = true;
454
- add({
455
- locator: `getByRole('${esc(ancestorRole)}').getByRole(${rolePart})`,
456
- method: 'getByRole',
457
- args: { ...leafArgs, anchorRole: ancestorRole },
458
- score: 55,
459
- });
460
- }
461
- }
462
- // Name-free bare role — the sole element of its role on the page, or the
463
- // sole heading at its level (a lone h1 among many h2s).
464
- const pos = attrs.rolePosition;
465
- if (pos && pos.role === role && (pos.count === 1 || (level != null && pos.levelCount === 1))) {
466
- add({
467
- locator: `getByRole(${rolePart})`,
468
- method: 'getByRole',
469
- args: leafArgs,
470
- score: 58,
471
- });
472
- }
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
+ });
473
407
  }
474
- // 11. CSS class-based locators — capped at 3 most stable classes; classes
475
- // the uniqueness probe saw on more than one element are dropped outright.
476
- const clsStr = attr(attrs, 'class');
477
- if (clsStr) {
478
- const classes = clsStr
479
- .split(/\s+/)
480
- // Only classes valid as a bare `.cls` selector — Tailwind variants
481
- // (`hover:bg-red-500`) and arbitrary values (`w-[10px]`) are not.
482
- .filter((c) => c.length > 1 && /^[A-Za-z_-][A-Za-z0-9_-]*$/.test(c) && isUnique(counts?.classes?.[c]))
483
- .map((cls) => ({
484
- cls,
485
- score: classifyCssStability(cls),
486
- }))
487
- .sort((a, b) => b.score - a.score)
488
- .slice(0, 3);
489
- for (const { cls, score } of classes) {
490
- add({
491
- locator: `locator('.${esc(cls)}')`,
492
- method: 'locator',
493
- args: { selector: `.${cls}` },
494
- score,
495
- });
496
- }
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
+ });
497
420
  }
498
- return alts.sort((a, b) => b.score - a.score);
421
+ }
422
+ return alts.sort((a, b) => b.score - a.score);
499
423
  }
500
- // ── CSS class stability ──────────────────────────────────────────────────────
501
- /**
502
- * Score a CSS class name on a 0-40 stability scale.
503
- *
504
- * Heuristics (inherited from common CSS-naming conventions):
505
- * - Hash-like suffixes (≥4 hex chars) → 10 — auto-generated, fragile
506
- * - CSS-in-JS patterns (css-, sc-, emotion-, styled-, _) 15
507
- * - Tailwind/utility classes → 25
508
- * - BEM-style semantic → 35
509
- * - Plain semantic → 40
510
- */
511
- function classifyCssStability(className) {
512
- // Hex hash patterns: contains 8+ consecutive hex digits
513
- if (/[a-f0-9]{8,}/i.test(className))
514
- return 10;
515
- // CSS-in-JS patterns — check before utility/semantic since prefixes like
516
- // "css-", "emotion-" are generated and fragile
517
- if (/^(?:css|sc|emotion|styled)-/i.test(className))
518
- return 15;
519
- // Tailwind/utility classes
520
- 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))
521
- return 25;
522
- // BEM-style: block__element--modifier
523
- if (/__/.test(className) || /--/.test(className))
524
- return 35;
525
- // Plain semantic class: hyphenated word pairs or camelCase
526
- if (/^[a-z]+(-[a-z]+)+$/.test(className))
527
- return 40;
528
- if (/^[a-z]+[A-Z][a-zA-Z]+$/.test(className))
529
- return 40;
530
- // Hyphenated strings with digit-containing suffixes that look hashed
531
- if (className.includes('_'))
532
- return 15;
533
- if (/^[a-z]+-[a-z0-9]{5,}$/.test(className) && /[0-9]/.test(className))
534
- return 15;
535
- // Unknown — score conservatively
536
- 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;
537
434
  }
538
- // ── Auto-generation detection ────────────────────────────────────────────────
539
- /**
540
- * Detects GUID-like, auto-incremented, or hash-suffixed IDs that are
541
- * likely regenerated on each render and unstable for testing.
542
- */
543
- function isAutoGenerated(value) {
544
- // GUID / UUID patterns
545
- if (/^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/i.test(value))
546
- return true;
547
- // Hash-like (8+ hex chars)
548
- if (/^[a-f0-9]{8,}$/i.test(value))
549
- return true;
550
- // Numeric auto-increment with hash suffix
551
- if (/^[a-z]+-\d+$/.test(value))
552
- return true;
553
- // Random-looking CSS-in-JS IDs
554
- if (/^(emotion-|styled-|css-|sc-)/.test(value))
555
- return true;
556
- // Angular-style generated IDs (ng-xxx-N)
557
- if (value.startsWith('ng-'))
558
- return true;
559
- // Component-library generated ids (Radix, Headless UI, MUI, Mantine, Chakra)
560
- if (/^(radix-|headlessui-|mui-|mantine-|chakra-)/i.test(value))
561
- return true;
562
- // React useId format — `:r1:` (18) / `«r1»` (19)
563
- if (/^:r[0-9a-z]+:$/i.test(value) || /^«r[0-9a-z]+»$/i.test(value))
564
- return true;
565
- 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
+ });
566
449
  }
567
- // ── Accessible name extraction ───────────────────────────────────────────────
568
- /**
569
- * Extract the accessible name from a YAML-like ariaSnapshot() output.
570
- *
571
- * Format example:
572
- * - button "Submit order"
573
- * - heading "Welcome, Alice"
574
- * - textbox "Email" [ref=e12]
575
- * - generic
576
- *
577
- * Returns the first quoted string after the role, or null if none found.
578
- */
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);
579
492
  function extractAccessibleName(ariaSnapshot) {
580
- if (!ariaSnapshot)
581
- return null;
582
- // Match the role line: - role "name" [...]
583
- // The name is the first double-quoted string after the role keyword.
584
- const match = ariaSnapshot.match(/- \w+ "([^"]+)"/);
585
- if (match)
586
- return match[1];
587
- return null;
588
- }
589
- /**
590
- * Approximate the accessible name from HTML attributes when ariaSnapshot()
591
- * is unavailable. Priority: aria-label > text content > title > placeholder.
592
- */
593
- function approximateAccessibleName(attrs) {
594
- const a = attrs.attributes;
595
- const ariaLabel = a['aria-label'];
596
- if (ariaLabel)
597
- return ariaLabel;
598
- if (attrs.textContent)
599
- return attrs.textContent;
600
- const title = a['title'];
601
- if (title)
602
- return title;
603
- const placeholder = a['placeholder'];
604
- if (placeholder)
605
- return placeholder;
606
- return null;
493
+ if (!ariaSnapshot) return null;
494
+ const match = ariaSnapshot.match(/- \w+ "([^"]+)"/);
495
+ if (match) return match[1];
496
+ return null;
607
497
  }
608
- /** Name-based locator methods the only ones whose target can be re-found by accessible name. */
609
- const NAME_BASED_METHODS = new Set([
610
- 'getByText',
611
- 'getByRole',
612
- 'getByLabel',
613
- 'getByPlaceholder',
614
- 'getByTitle',
615
- 'getByAltText',
498
+ var NAME_BASED_METHODS = /* @__PURE__ */ new Set([
499
+ "getByText",
500
+ "getByRole",
501
+ "getByLabel",
502
+ "getByPlaceholder",
503
+ "getByTitle",
504
+ "getByAltText"
616
505
  ]);
617
- const escAttr = (s) => s.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
618
- /**
619
- * Parse `ariaSnapshot()` lines into role/name pairs (mirrors the server-side
620
- * `parseAriaCandidates` in application/shared/locator-fingerprint.ts).
621
- * Exported so the dashboard's drift-guard unit test can compare the two.
622
- */
623
- function parseAriaRoleName(ariaSnapshot) {
624
- const out = [];
625
- for (const line of ariaSnapshot.split('\n')) {
626
- const m = /^\s*-\s+([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?/i.exec(line);
627
- if (!m)
628
- continue;
629
- const role = m[1];
630
- const name = m[2] == null ? null : m[2].replace(/\\(.)/g, '$1');
631
- if (!name && (role === 'generic' || role === 'group' || role === 'list' || role === 'paragraph'))
632
- continue;
633
- // Heading level rides after the name as `[level=N]` (other bracketed
634
- // markers like `[ref=eN]` are ignored). Scan only past the matched part so
635
- // brackets inside the quoted name can't fake a level.
636
- const levelMatch = /\[level=(\d+)\]/.exec(line.slice(m[0].length));
637
- const level = levelMatch ? Number(levelMatch[1]) : null;
638
- out.push({ role, name, level });
639
- }
640
- return out;
641
- }
642
- /**
643
- * Token-set (Dice) similarity, 0-1, case- and punctuation-insensitive.
644
- * Duplicated from `textSimilarity` in application/shared/locator-fingerprint.ts —
645
- * this package publishes standalone to npm and can't import monorepo-relative
646
- * shared/ code, so keep the two implementations in sync by hand. Exported so
647
- * the dashboard's drift-guard unit test can compare the two.
648
- */
649
- function nameSimilarity(a, b) {
650
- const tok = (s) => new Set((s ?? '')
651
- .toLowerCase()
652
- .split(/[^a-z0-9]+/i)
653
- .filter(Boolean));
654
- const sa = tok(a);
655
- const sb = tok(b);
656
- if (sa.size === 0 && sb.size === 0)
657
- return 1;
658
- if (sa.size === 0 || sb.size === 0)
659
- return 0;
660
- let common = 0;
661
- for (const t of sa)
662
- if (sb.has(t))
663
- common++;
664
- return (2 * common) / (sa.size + sb.size);
665
- }
666
- const SUGG_TEXT_ROLES = new Set([
667
- 'button',
668
- 'link',
669
- 'heading',
670
- 'menuitem',
671
- 'tab',
672
- 'option',
673
- 'cell',
674
- 'columnheader',
675
- 'rowheader',
676
- 'gridcell',
677
- 'treeitem',
678
- 'listitem',
679
- 'checkbox',
680
- 'radio',
681
- '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"
682
523
  ]);
683
- const SUGG_FIELD_ROLES = new Set(['textbox', 'combobox', 'searchbox', 'spinbutton', 'slider']);
684
- /** Extract the role (for getByRole), targeted accessible name, and heading level from a failed locator's args. */
524
+ var SUGG_FIELD_ROLES = /* @__PURE__ */ new Set(["textbox", "combobox", "searchbox", "spinbutton", "slider"]);
685
525
  function failedNameAndRole(failed) {
686
- if (failed.method === 'getByRole') {
687
- const role = typeof failed.args[0] === 'string' ? failed.args[0] : null;
688
- const opts = failed.args[1];
689
- const name = opts && typeof opts.name === 'string' ? opts.name : null;
690
- const level = opts && typeof opts.level === 'number' ? opts.level : null;
691
- return { role, name, level };
692
- }
693
- const first = failed.args.find((a) => typeof a === 'string');
694
- return { role: null, name: typeof first === 'string' ? first : null, level: null };
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 };
695
535
  }
696
- /** Render the failed locator back to source for the annotation message. */
697
536
  function renderFailing(failed) {
698
- const { role, name } = failedNameAndRole(failed);
699
- if (failed.method === 'getByRole') {
700
- return name
701
- ? `getByRole('${escAttr(role ?? '')}', { name: '${escAttr(name)}' })`
702
- : `getByRole('${escAttr(role ?? '')}')`;
703
- }
704
- 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 ?? "")}')`;
705
542
  }
706
- /** Build fresh locator suggestions for a matched candidate, with the failed method's style first. */
707
543
  function freshSuggestions(candidate, failedMethod) {
708
- const out = [];
709
- const role = candidate.role;
710
- const name = candidate.name;
711
- const push = (s) => {
712
- if (!out.includes(s))
713
- out.push(s);
714
- };
715
- const levelPart = candidate.level != null ? `, level: ${candidate.level}` : '';
716
- const roleLoc = `getByRole('${escAttr(role)}', { name: '${escAttr(name)}'${levelPart} })`;
717
- const textLoc = `getByText('${escAttr(name)}')`;
718
- const labelLoc = `getByLabel('${escAttr(name)}')`;
719
- // Same-style first: a broken getByText is re-suggested as getByText where viable.
720
- if (failedMethod === 'getByText' && SUGG_TEXT_ROLES.has(role))
721
- push(textLoc);
722
- if (failedMethod === 'getByLabel' && SUGG_FIELD_ROLES.has(role))
723
- push(labelLoc);
724
- push(roleLoc);
725
- if (SUGG_TEXT_ROLES.has(role))
726
- push(textLoc);
727
- else if (SUGG_FIELD_ROLES.has(role))
728
- push(labelLoc);
729
- 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;
730
560
  }
731
- /**
732
- * Best-effort runtime suggestion for a locator that matched nothing: find the
733
- * element on the *current* page that the failed locator most likely targeted
734
- * (by accessible name similarity, restricted to the failed role when given) and
735
- * return fresh locators for it.
736
- *
737
- * Unlike the server lookup this has no pre-captured fingerprint — only the
738
- * failed locator + the live page — so it's a hint, not a guarantee. Returns null
739
- * for non-name-based locators (testid/CSS), when the targeted name is still
740
- * present (so the failure wasn't a rename), or when no candidate is confident.
741
- */
742
561
  function suggestLocatorsFromAria(failed, ariaSnapshot) {
743
- if (!ariaSnapshot || !NAME_BASED_METHODS.has(failed.method))
744
- return null;
745
- const { role, name, level } = failedNameAndRole(failed);
746
- if (!name)
747
- return null;
748
- const candidates = parseAriaRoleName(ariaSnapshot);
749
- if (candidates.length === 0)
750
- return null;
751
- const sameRole = role ? candidates.filter((c) => c.role === role) : [];
752
- let pool = sameRole.length > 0 ? sameRole : candidates;
753
- // A targeted heading level narrows the pool further — a lone renamed h1
754
- // among many h2s becomes the single confident candidate. Fall back to all
755
- // same-role candidates when none share the level (it may have changed too).
756
- if (level != null && sameRole.length > 0) {
757
- const sameLevel = sameRole.filter((c) => c.level === level);
758
- if (sameLevel.length > 0)
759
- pool = sameLevel;
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 };
574
+ }
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;
760
585
  }
761
- // The targeted name is still on the page → not a rename, nothing to suggest.
762
- if (pool.some((c) => nameSimilarity(c.name, name) >= 0.8))
763
- return null;
764
- let best = null;
765
- let bestScore = -1;
766
- for (const c of pool) {
767
- const s = nameSimilarity(c.name, name);
768
- if (s > bestScore) {
769
- bestScore = s;
770
- best = c;
771
- }
586
+ let file = m[2];
587
+ if (!file || file.startsWith("node:")) {
588
+ prevWasCaptureModule = false;
589
+ continue;
772
590
  }
773
- if (!best || !best.name)
774
- return null;
775
- if (bestScore < 0.2 && pool.length !== 1)
776
- return null;
777
- const suggestions = freshSuggestions({ role: best.role, name: best.name, level: best.level }, failed.method);
778
- if (suggestions.length === 0)
779
- return null;
780
- return { failing: renderFailing(failed), suggestions };
781
- }
782
- // ── Call-site capture ────────────────────────────────────────────────────────
783
- /**
784
- * Capture the calling test's source location (`file:line:col`) from the current
785
- * stack, for stamping onto a locator snapshot. The path is made cwd-relative so
786
- * it matches the location format Playwright embeds in error messages — which is
787
- * what the server's exact-match healing lookup (`extractErrorLocation`) parses.
788
- *
789
- * Stamping at action *call* time (rather than correlating with `pw:api` step
790
- * indices at step *end* time) avoids three classes of misalignment: `pw:api`
791
- * steps with no wrapped locator (e.g. `page.keyboard.press`), cross-worker step
792
- * interleaving, and concurrent actions reordering by end time.
793
- *
794
- * Returns null when no user frame can be identified — the snapshot keeps
795
- * `location: null` and the server falls back to fingerprint / ARIA lookup.
796
- */
797
- function captureCallerLocation(stack = new Error().stack ?? '') {
798
- const lines = stack.split('\n');
799
- // The capture machinery's own frames sit at the top of the stack: this module
800
- // (locator-healing) then the single fixtures-proxy frame that called it. Skip
801
- // exactly those, so the first remaining frame is the real test call site. A
802
- // USER file named `fixtures.*` further down the stack must be kept, so the
803
- // fixtures-proxy frame is only skipped when it directly follows this module.
804
- let prevWasCaptureModule = false;
805
- for (let i = 1; i < lines.length; i++) {
806
- const line = lines[i].trim();
807
- if (!line.startsWith('at'))
808
- continue;
809
- const m = line.match(/^at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/);
810
- if (!m) {
811
- prevWasCaptureModule = false;
812
- continue;
813
- }
814
- let file = m[2];
815
- if (!file || file.startsWith('node:')) {
816
- prevWasCaptureModule = false;
817
- continue;
818
- }
819
- file = file.replace(/^file:\/\/\/?/, '');
820
- // Must look like a source file (has an extension).
821
- if (!/\.[a-z]+$/i.test(file)) {
822
- prevWasCaptureModule = false;
823
- continue;
824
- }
825
- // This module — always an internal frame; remember it so the immediately
826
- // following fixtures-proxy frame can be skipped too.
827
- if (/[\\/]locator-healing\.[a-z]+$/i.test(file)) {
828
- prevWasCaptureModule = true;
829
- continue;
830
- }
831
- // The fixtures proxy that called us — `capture-fixtures.*` in this
832
- // package, `fixtures.*` in the dogfood mirror. Skip only when it directly
833
- // follows this module, so a user's own `fixtures.*` deeper down is kept.
834
- if (prevWasCaptureModule && /[\\/](?:capture-)?fixtures\.[a-z]+$/i.test(file)) {
835
- prevWasCaptureModule = false;
836
- continue;
837
- }
838
- if (/[\\/]node_modules[\\/]/.test(file)) {
839
- prevWasCaptureModule = false;
840
- continue;
841
- }
842
- let rel = file;
843
- try {
844
- rel = path.relative(process.cwd(), file);
845
- }
846
- catch {
847
- /* keep absolute if relative fails */
848
- }
849
- rel = rel.split(path.sep).join('/');
850
- if (rel.startsWith('./'))
851
- rel = rel.slice(2);
852
- return `${rel}:${m[3]}:${m[4]}`;
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;
853
599
  }
854
- return null;
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;
855
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
+ });