@piwitests/reporter 0.4.4 → 0.6.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 (75) hide show
  1. package/README.md +37 -26
  2. package/dist/global-setup-module.js +2 -2
  3. package/dist/index.d.ts +15 -8
  4. package/dist/index.js +23 -5
  5. package/dist/internal/capture/attachments.d.ts +19 -0
  6. package/dist/internal/capture/attachments.js +22 -0
  7. package/dist/{fixtures.d.ts → internal/capture/capture-fixtures.d.ts} +6 -4
  8. package/dist/internal/capture/capture-fixtures.js +495 -0
  9. package/dist/internal/capture/locator-healing.d.ts +152 -0
  10. package/dist/internal/capture/locator-healing.js +668 -0
  11. package/dist/internal/collect/error-text.d.ts +18 -0
  12. package/dist/internal/collect/error-text.js +79 -0
  13. package/dist/{metadata-collector.d.ts → internal/collect/metadata-collector.d.ts} +2 -2
  14. package/dist/{metadata-collector.js → internal/collect/metadata-collector.js} +12 -9
  15. package/dist/{skip-classify.d.ts → internal/collect/skip-classify.d.ts} +1 -1
  16. package/dist/{step-analyzer.d.ts → internal/collect/step-analyzer.d.ts} +6 -0
  17. package/dist/{step-analyzer.js → internal/collect/step-analyzer.js} +7 -2
  18. package/dist/internal/config/env.d.ts +45 -0
  19. package/dist/{config.js → internal/config/env.js} +59 -33
  20. package/dist/internal/files/compression.js +69 -0
  21. package/dist/{file-handler.d.ts → internal/files/file-handler.d.ts} +4 -4
  22. package/dist/{file-handler.js → internal/files/file-handler.js} +61 -29
  23. package/dist/{crash-recovery.d.ts → internal/streaming/crash-recovery.d.ts} +5 -5
  24. package/dist/{crash-recovery.js → internal/streaming/crash-recovery.js} +9 -8
  25. package/dist/{stream-buffer.d.ts → internal/streaming/stream-buffer.d.ts} +2 -2
  26. package/dist/{stream-buffer.js → internal/streaming/stream-buffer.js} +5 -5
  27. package/dist/{stream-manager.d.ts → internal/streaming/stream-manager.d.ts} +22 -8
  28. package/dist/{stream-manager.js → internal/streaming/stream-manager.js} +66 -12
  29. package/dist/{run-submitter.d.ts → internal/submit/run-submitter.d.ts} +7 -7
  30. package/dist/{run-submitter.js → internal/submit/run-submitter.js} +13 -9
  31. package/dist/{serializer.d.ts → internal/submit/serializer.d.ts} +1 -1
  32. package/dist/{serializer.js → internal/submit/serializer.js} +2 -0
  33. package/dist/{uploader.d.ts → internal/submit/uploader.d.ts} +9 -8
  34. package/dist/{uploader.js → internal/submit/uploader.js} +45 -10
  35. package/dist/internal/support/ci.d.ts +2 -0
  36. package/dist/internal/support/ci.js +32 -0
  37. package/dist/internal/support/cli-filters.d.ts +1 -0
  38. package/dist/internal/support/cli-filters.js +51 -0
  39. package/dist/internal/support/errors.d.ts +8 -0
  40. package/dist/internal/support/errors.js +15 -0
  41. package/dist/internal/support/instance-id.d.ts +4 -0
  42. package/dist/internal/support/instance-id.js +48 -0
  43. package/dist/internal/support/limiter.d.ts +2 -0
  44. package/dist/internal/support/limiter.js +27 -0
  45. package/dist/internal/support/setup-file.d.ts +13 -0
  46. package/dist/internal/support/setup-file.js +61 -0
  47. package/dist/internal/support/source-snippet.d.ts +12 -0
  48. package/dist/internal/support/source-snippet.js +97 -0
  49. package/dist/internal/support/worker-index.d.ts +7 -0
  50. package/dist/internal/support/worker-index.js +14 -0
  51. package/dist/{http-client.d.ts → internal/transport/http-client.d.ts} +11 -1
  52. package/dist/{http-client.js → internal/transport/http-client.js} +57 -10
  53. package/dist/{config-wrapper.d.ts → public/config-wrapper.d.ts} +1 -1
  54. package/dist/{config-wrapper.js → public/config-wrapper.js} +4 -4
  55. package/dist/public/global-setup.d.ts +13 -0
  56. package/dist/public/global-setup.js +146 -0
  57. package/dist/{config.d.ts → public/options.d.ts} +18 -44
  58. package/dist/public/options.js +2 -0
  59. package/dist/{reporter.d.ts → public/reporter.d.ts} +3 -1
  60. package/dist/{reporter.js → public/reporter.js} +66 -34
  61. package/dist/types/collected.d.ts +96 -0
  62. package/dist/types/collected.js +10 -0
  63. package/dist/types/wire.d.ts +174 -0
  64. package/dist/types/wire.js +14 -0
  65. package/dist/types.d.ts +8 -248
  66. package/dist/types.js +23 -10
  67. package/package.json +1 -6
  68. package/dist/compression.js +0 -39
  69. package/dist/fixtures.js +0 -156
  70. package/dist/helpers.d.ts +0 -44
  71. package/dist/helpers.js +0 -288
  72. /package/dist/{skip-classify.js → internal/collect/skip-classify.js} +0 -0
  73. /package/dist/{compression.d.ts → internal/files/compression.d.ts} +0 -0
  74. /package/dist/{logger.d.ts → internal/support/logger.d.ts} +0 -0
  75. /package/dist/{logger.js → internal/support/logger.js} +0 -0
@@ -0,0 +1,668 @@
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;
17
+ });
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.CAPTURED_ATTRIBUTES = exports.LOCATOR_CREATING_CHAINS = exports.ACTION_METHODS = exports.CHAIN_METHODS = exports.LOCATOR_METHODS = void 0;
37
+ exports.resolveAriaRole = resolveAriaRole;
38
+ exports.generateAlternatives = generateAlternatives;
39
+ exports.classifyCssStability = classifyCssStability;
40
+ exports.isAutoGenerated = isAutoGenerated;
41
+ exports.extractAccessibleName = extractAccessibleName;
42
+ exports.approximateAccessibleName = approximateAccessibleName;
43
+ exports.suggestLocatorsFromAria = suggestLocatorsFromAria;
44
+ exports.captureCallerLocation = captureCallerLocation;
45
+ const path = __importStar(require("node:path"));
46
+ // ── Playwright method surface (shared with the fixture proxy) ────────────────
47
+ /**
48
+ * Page-level locator-building methods wrapped by the capture proxy. Imported by
49
+ * both `reporter/src/fixtures.ts` and the dogfooding `application/tests/fixtures.ts`
50
+ * so the two stay in sync (a prior drift missed `scrollIntoViewIfNeeded`).
51
+ */
52
+ exports.LOCATOR_METHODS = [
53
+ 'getByRole',
54
+ 'getByTestId',
55
+ 'getByText',
56
+ 'getByLabel',
57
+ 'getByPlaceholder',
58
+ 'getByAltText',
59
+ 'getByTitle',
60
+ 'locator',
61
+ ];
62
+ /**
63
+ * Methods that can be chained onto a wrapped locator. Locator-creating chains
64
+ * (those also in `LOCATOR_METHODS`) update the origin; positional/filter chains
65
+ * (`first`, `nth`, `filter`, …) narrow without changing locator identity.
66
+ */
67
+ exports.CHAIN_METHODS = [
68
+ 'first',
69
+ 'nth',
70
+ 'last',
71
+ 'filter',
72
+ 'and',
73
+ 'or',
74
+ 'locator',
75
+ 'getByRole',
76
+ 'getByTestId',
77
+ 'getByText',
78
+ 'getByLabel',
79
+ 'getByPlaceholder',
80
+ 'getByAltText',
81
+ 'getByTitle',
82
+ ];
83
+ /** Locator action methods that trigger element capture. */
84
+ exports.ACTION_METHODS = [
85
+ 'click',
86
+ 'fill',
87
+ 'check',
88
+ 'uncheck',
89
+ 'selectOption',
90
+ 'dblclick',
91
+ 'tap',
92
+ 'hover',
93
+ 'press',
94
+ 'type',
95
+ 'clear',
96
+ 'setInputFiles',
97
+ 'dragTo',
98
+ 'focus',
99
+ 'blur',
100
+ 'scrollIntoViewIfNeeded',
101
+ ];
102
+ /** Chain methods that create a new locator scope (origin tracks the chain call). */
103
+ exports.LOCATOR_CREATING_CHAINS = new Set(exports.LOCATOR_METHODS);
104
+ /**
105
+ * Element attributes to capture after a successful action, passed into the
106
+ * in-page `evaluate`. Shared so the reporter and dogfooding fixtures capture
107
+ * the same attribute set.
108
+ */
109
+ exports.CAPTURED_ATTRIBUTES = [
110
+ 'id',
111
+ 'class',
112
+ 'name',
113
+ 'data-testid',
114
+ 'placeholder',
115
+ 'alt',
116
+ 'title',
117
+ 'aria-label',
118
+ 'role',
119
+ 'type',
120
+ 'href',
121
+ 'value',
122
+ ];
123
+ // ── ARIA role resolution ─────────────────────────────────────────────────────
124
+ /** Implicit ARIA role for an HTML tag (when no explicit `role` is set). */
125
+ const TAG_TO_ROLE = {
126
+ a: 'link',
127
+ button: 'button',
128
+ nav: 'navigation',
129
+ main: 'main',
130
+ article: 'article',
131
+ section: 'region',
132
+ form: 'form',
133
+ img: 'img',
134
+ figure: 'figure',
135
+ figcaption: 'caption',
136
+ blockquote: 'blockquote',
137
+ table: 'table',
138
+ ul: 'list',
139
+ ol: 'list',
140
+ li: 'listitem',
141
+ dialog: 'dialog',
142
+ output: 'status',
143
+ progress: 'progressbar',
144
+ meter: 'meter',
145
+ select: 'listbox',
146
+ textarea: 'textbox',
147
+ h1: 'heading',
148
+ h2: 'heading',
149
+ h3: 'heading',
150
+ h4: 'heading',
151
+ h5: 'heading',
152
+ h6: 'heading',
153
+ details: 'group',
154
+ summary: 'button',
155
+ search: 'search',
156
+ };
157
+ /** Implicit ARIA role for an `<input>` keyed by its `type` attribute. */
158
+ const INPUT_TYPE_TO_ROLE = {
159
+ button: 'button',
160
+ submit: 'button',
161
+ reset: 'button',
162
+ image: 'button',
163
+ checkbox: 'checkbox',
164
+ radio: 'radio',
165
+ range: 'slider',
166
+ search: 'searchbox',
167
+ number: 'spinbutton',
168
+ text: 'textbox',
169
+ email: 'textbox',
170
+ tel: 'textbox',
171
+ url: 'textbox',
172
+ password: 'textbox',
173
+ };
174
+ /**
175
+ * Resolve the ARIA role for an element. An explicit `role` attribute wins;
176
+ * otherwise the implicit role is derived from the tag name (and `type` for
177
+ * `<input>`). Returns null when the element has no ARIA role (e.g. `<div>`,
178
+ * `<span>`, `<a>` without `href`) — `getByRole` is not a valid locator for
179
+ * such elements and other alternatives take over.
180
+ */
181
+ function resolveAriaRole(attrs) {
182
+ const explicit = attrs.attributes['role'];
183
+ if (explicit)
184
+ return explicit;
185
+ const tag = attrs.tagName;
186
+ if (!tag)
187
+ return null;
188
+ if (tag === 'input') {
189
+ const type = (attrs.attributes['type'] ?? 'text').toLowerCase();
190
+ return INPUT_TYPE_TO_ROLE[type] ?? 'textbox';
191
+ }
192
+ if (tag === 'a') {
193
+ return attrs.attributes['href'] != null ? 'link' : null;
194
+ }
195
+ return TAG_TO_ROLE[tag] ?? null;
196
+ }
197
+ // ── Alternative generation ───────────────────────────────────────────────────
198
+ const attr = (a, key) => a.attributes[key] || null;
199
+ const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
200
+ /**
201
+ * Build a ranked list of alternative locators from the captured element
202
+ * attributes. The list is sorted descending by stability score.
203
+ *
204
+ * Only generates alternatives that differ from each other — no duplicates
205
+ * of the same locator expression.
206
+ */
207
+ function generateAlternatives(attrs) {
208
+ const alts = [];
209
+ const seen = new Set();
210
+ const add = (loc) => {
211
+ if (!seen.has(loc.locator)) {
212
+ seen.add(loc.locator);
213
+ alts.push(loc);
214
+ }
215
+ };
216
+ const { accessibleName } = attrs;
217
+ const tag = attrs.tagName;
218
+ const role = resolveAriaRole(attrs);
219
+ // 1. data-testid — highest stability (100)
220
+ const testId = attr(attrs, 'data-testid');
221
+ if (testId) {
222
+ add({
223
+ locator: `getByTestId('${esc(testId)}')`,
224
+ method: 'getByTestId',
225
+ args: { testId },
226
+ score: 100,
227
+ });
228
+ }
229
+ // 2. role + accessible name from browser ARIA tree (85-95)
230
+ if (role && accessibleName) {
231
+ add({
232
+ locator: `getByRole('${role}', { name: '${esc(accessibleName)}' })`,
233
+ method: 'getByRole',
234
+ args: { role, name: accessibleName },
235
+ score: 90,
236
+ });
237
+ }
238
+ // 3. role + explicit aria-label (85, fallback when no browser-computed name)
239
+ const ariaLabel = attr(attrs, 'aria-label');
240
+ if (role && ariaLabel && ariaLabel !== accessibleName) {
241
+ add({
242
+ locator: `getByRole('${role}', { name: '${esc(ariaLabel)}' })`,
243
+ method: 'getByRole',
244
+ args: { role, name: ariaLabel },
245
+ score: 85,
246
+ });
247
+ }
248
+ // 4. getByLabel — for form fields with associated <label> (85)
249
+ if (accessibleName && ['input', 'select', 'textarea'].includes(tag)) {
250
+ add({
251
+ locator: `getByLabel('${esc(accessibleName)}')`,
252
+ method: 'getByLabel',
253
+ args: { label: accessibleName },
254
+ score: 85,
255
+ });
256
+ }
257
+ // 5. getByPlaceholder — for inputs (80)
258
+ const placeholder = attr(attrs, 'placeholder');
259
+ if (placeholder) {
260
+ add({
261
+ locator: `getByPlaceholder('${esc(placeholder)}')`,
262
+ method: 'getByPlaceholder',
263
+ args: { placeholder },
264
+ score: 80,
265
+ });
266
+ }
267
+ // 6. getByText — from visible text content (70-80)
268
+ if (attrs.textContent && attrs.textContent.length < 80) {
269
+ add({
270
+ locator: `getByText('${esc(attrs.textContent)}')`,
271
+ method: 'getByText',
272
+ args: { text: attrs.textContent },
273
+ score: 75,
274
+ });
275
+ }
276
+ // 7. locator('#id') — if id exists and doesn't look auto-generated (50-70)
277
+ const id = attr(attrs, 'id');
278
+ if (id && !isAutoGenerated(id)) {
279
+ add({
280
+ locator: `locator('#${esc(id)}')`,
281
+ method: 'locator',
282
+ args: { selector: `#${id}` },
283
+ score: 65,
284
+ });
285
+ }
286
+ // 8. locator('[name="..."]') — for form elements (60)
287
+ const name = attr(attrs, 'name');
288
+ if (name) {
289
+ add({
290
+ locator: `locator('[name="${esc(name)}"]')`,
291
+ method: 'locator',
292
+ args: { selector: `[name="${name}"]` },
293
+ score: 60,
294
+ });
295
+ }
296
+ // 9. getByAltText — for images (60)
297
+ const alt = attr(attrs, 'alt');
298
+ if (alt) {
299
+ add({
300
+ locator: `getByAltText('${esc(alt)}')`,
301
+ method: 'getByAltText',
302
+ args: { text: alt },
303
+ score: 60,
304
+ });
305
+ }
306
+ // 10. getByTitle (50)
307
+ const title = attr(attrs, 'title');
308
+ if (title) {
309
+ add({
310
+ locator: `getByTitle('${esc(title)}')`,
311
+ method: 'getByTitle',
312
+ args: { title },
313
+ score: 50,
314
+ });
315
+ }
316
+ // 11. CSS class-based locators — capped at 3 most stable classes
317
+ const clsStr = attr(attrs, 'class');
318
+ if (clsStr) {
319
+ const classes = clsStr
320
+ .split(/\s+/)
321
+ .filter((c) => c.length > 1)
322
+ .map((cls) => ({
323
+ cls,
324
+ score: classifyCssStability(cls),
325
+ }))
326
+ .sort((a, b) => b.score - a.score)
327
+ .slice(0, 3);
328
+ for (const { cls, score } of classes) {
329
+ add({
330
+ locator: `locator('.${esc(cls)}')`,
331
+ method: 'locator',
332
+ args: { selector: `.${cls}` },
333
+ score,
334
+ });
335
+ }
336
+ }
337
+ return alts.sort((a, b) => b.score - a.score);
338
+ }
339
+ // ── CSS class stability ──────────────────────────────────────────────────────
340
+ /**
341
+ * Score a CSS class name on a 0-40 stability scale.
342
+ *
343
+ * Heuristics (inherited from common CSS-naming conventions):
344
+ * - Hash-like suffixes (≥4 hex chars) → 10 — auto-generated, fragile
345
+ * - CSS-in-JS patterns (css-, sc-, emotion-, styled-, _) → 15
346
+ * - Tailwind/utility classes → 25
347
+ * - BEM-style semantic → 35
348
+ * - Plain semantic → 40
349
+ */
350
+ function classifyCssStability(className) {
351
+ // Hex hash patterns: contains 8+ consecutive hex digits
352
+ if (/[a-f0-9]{8,}/i.test(className))
353
+ return 10;
354
+ // CSS-in-JS patterns — check before utility/semantic since prefixes like
355
+ // "css-", "emotion-" are generated and fragile
356
+ if (/^(?:css|sc|emotion|styled)-/i.test(className))
357
+ return 15;
358
+ // Tailwind/utility classes
359
+ 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))
360
+ return 25;
361
+ // BEM-style: block__element--modifier
362
+ if (/__/.test(className) || /--/.test(className))
363
+ return 35;
364
+ // Plain semantic class: hyphenated word pairs or camelCase
365
+ if (/^[a-z]+(-[a-z]+)+$/.test(className))
366
+ return 40;
367
+ if (/^[a-z]+[A-Z][a-zA-Z]+$/.test(className))
368
+ return 40;
369
+ // Hyphenated strings with digit-containing suffixes that look hashed
370
+ if (className.includes('_'))
371
+ return 15;
372
+ if (/^[a-z]+-[a-z0-9]{5,}$/.test(className) && /[0-9]/.test(className))
373
+ return 15;
374
+ // Unknown — score conservatively
375
+ return 15;
376
+ }
377
+ // ── Auto-generation detection ────────────────────────────────────────────────
378
+ /**
379
+ * Detects GUID-like, auto-incremented, or hash-suffixed IDs that are
380
+ * likely regenerated on each render and unstable for testing.
381
+ */
382
+ function isAutoGenerated(value) {
383
+ // GUID / UUID patterns
384
+ if (/^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/i.test(value))
385
+ return true;
386
+ // Hash-like (8+ hex chars)
387
+ if (/^[a-f0-9]{8,}$/i.test(value))
388
+ return true;
389
+ // Numeric auto-increment with hash suffix
390
+ if (/^[a-z]+-\d+$/.test(value))
391
+ return true;
392
+ // Random-looking CSS-in-JS IDs
393
+ if (/^(emotion-|styled-|css-|sc-)/.test(value))
394
+ return true;
395
+ // Angular-style generated IDs (ng-xxx-N)
396
+ if (value.startsWith('ng-'))
397
+ return true;
398
+ return false;
399
+ }
400
+ // ── Accessible name extraction ───────────────────────────────────────────────
401
+ /**
402
+ * Extract the accessible name from a YAML-like ariaSnapshot() output.
403
+ *
404
+ * Format example:
405
+ * - button "Submit order"
406
+ * - heading "Welcome, Alice"
407
+ * - textbox "Email" [ref=e12]
408
+ * - generic
409
+ *
410
+ * Returns the first quoted string after the role, or null if none found.
411
+ */
412
+ function extractAccessibleName(ariaSnapshot) {
413
+ if (!ariaSnapshot)
414
+ return null;
415
+ // Match the role line: - role "name" [...]
416
+ // The name is the first double-quoted string after the role keyword.
417
+ const match = ariaSnapshot.match(/- \w+ "([^"]+)"/);
418
+ if (match)
419
+ return match[1];
420
+ return null;
421
+ }
422
+ /**
423
+ * Approximate the accessible name from HTML attributes when ariaSnapshot()
424
+ * is unavailable. Priority: aria-label > text content > title > placeholder.
425
+ */
426
+ function approximateAccessibleName(attrs) {
427
+ const a = attrs.attributes;
428
+ const ariaLabel = a['aria-label'];
429
+ if (ariaLabel)
430
+ return ariaLabel;
431
+ if (attrs.textContent)
432
+ return attrs.textContent;
433
+ const title = a['title'];
434
+ if (title)
435
+ return title;
436
+ const placeholder = a['placeholder'];
437
+ if (placeholder)
438
+ return placeholder;
439
+ return null;
440
+ }
441
+ /** Name-based locator methods — the only ones whose target can be re-found by accessible name. */
442
+ const NAME_BASED_METHODS = new Set([
443
+ 'getByText',
444
+ 'getByRole',
445
+ 'getByLabel',
446
+ 'getByPlaceholder',
447
+ 'getByTitle',
448
+ 'getByAltText',
449
+ ]);
450
+ const escAttr = (s) => s.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
451
+ /** Parse `ariaSnapshot()` lines into role/name pairs (mirrors the server-side matcher). */
452
+ function parseAriaRoleName(ariaSnapshot) {
453
+ const out = [];
454
+ for (const line of ariaSnapshot.split('\n')) {
455
+ const m = /^\s*-\s+([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?/i.exec(line);
456
+ if (!m)
457
+ continue;
458
+ const role = m[1];
459
+ const name = m[2] == null ? null : m[2].replace(/\\(.)/g, '$1');
460
+ if (!name && (role === 'generic' || role === 'group' || role === 'list' || role === 'paragraph'))
461
+ continue;
462
+ out.push({ role, name });
463
+ }
464
+ return out;
465
+ }
466
+ /**
467
+ * Token-set (Dice) similarity, 0-1, case- and punctuation-insensitive.
468
+ * Duplicated from `textSimilarity` in application/shared/locator-fingerprint.ts —
469
+ * this package publishes standalone to npm and can't import monorepo-relative
470
+ * shared/ code, so keep the two implementations in sync by hand.
471
+ */
472
+ function nameSimilarity(a, b) {
473
+ const tok = (s) => new Set((s ?? '')
474
+ .toLowerCase()
475
+ .split(/[^a-z0-9]+/i)
476
+ .filter(Boolean));
477
+ const sa = tok(a);
478
+ const sb = tok(b);
479
+ if (sa.size === 0 && sb.size === 0)
480
+ return 1;
481
+ if (sa.size === 0 || sb.size === 0)
482
+ return 0;
483
+ let common = 0;
484
+ for (const t of sa)
485
+ if (sb.has(t))
486
+ common++;
487
+ return (2 * common) / (sa.size + sb.size);
488
+ }
489
+ const SUGG_TEXT_ROLES = new Set([
490
+ 'button',
491
+ 'link',
492
+ 'heading',
493
+ 'menuitem',
494
+ 'tab',
495
+ 'option',
496
+ 'cell',
497
+ 'columnheader',
498
+ 'rowheader',
499
+ 'gridcell',
500
+ 'treeitem',
501
+ 'listitem',
502
+ 'checkbox',
503
+ 'radio',
504
+ 'switch',
505
+ ]);
506
+ const SUGG_FIELD_ROLES = new Set(['textbox', 'combobox', 'searchbox', 'spinbutton', 'slider']);
507
+ /** Extract the role (for getByRole) and the targeted accessible name from a failed locator's args. */
508
+ function failedNameAndRole(failed) {
509
+ if (failed.method === 'getByRole') {
510
+ const role = typeof failed.args[0] === 'string' ? failed.args[0] : null;
511
+ const opts = failed.args[1];
512
+ const name = opts && typeof opts.name === 'string' ? opts.name : null;
513
+ return { role, name };
514
+ }
515
+ const first = failed.args.find((a) => typeof a === 'string');
516
+ return { role: null, name: typeof first === 'string' ? first : null };
517
+ }
518
+ /** Render the failed locator back to source for the annotation message. */
519
+ function renderFailing(failed) {
520
+ const { role, name } = failedNameAndRole(failed);
521
+ if (failed.method === 'getByRole') {
522
+ return name
523
+ ? `getByRole('${escAttr(role ?? '')}', { name: '${escAttr(name)}' })`
524
+ : `getByRole('${escAttr(role ?? '')}')`;
525
+ }
526
+ return `${failed.method}('${escAttr(name ?? '')}')`;
527
+ }
528
+ /** Build fresh locator suggestions for a matched candidate, with the failed method's style first. */
529
+ function freshSuggestions(candidate, failedMethod) {
530
+ const out = [];
531
+ const role = candidate.role;
532
+ const name = candidate.name;
533
+ const push = (s) => {
534
+ if (!out.includes(s))
535
+ out.push(s);
536
+ };
537
+ const roleLoc = `getByRole('${escAttr(role)}', { name: '${escAttr(name)}' })`;
538
+ const textLoc = `getByText('${escAttr(name)}')`;
539
+ const labelLoc = `getByLabel('${escAttr(name)}')`;
540
+ // Same-style first: a broken getByText is re-suggested as getByText where viable.
541
+ if (failedMethod === 'getByText' && SUGG_TEXT_ROLES.has(role))
542
+ push(textLoc);
543
+ if (failedMethod === 'getByLabel' && SUGG_FIELD_ROLES.has(role))
544
+ push(labelLoc);
545
+ push(roleLoc);
546
+ if (SUGG_TEXT_ROLES.has(role))
547
+ push(textLoc);
548
+ else if (SUGG_FIELD_ROLES.has(role))
549
+ push(labelLoc);
550
+ return out;
551
+ }
552
+ /**
553
+ * Best-effort runtime suggestion for a locator that matched nothing: find the
554
+ * element on the *current* page that the failed locator most likely targeted
555
+ * (by accessible name similarity, restricted to the failed role when given) and
556
+ * return fresh locators for it.
557
+ *
558
+ * Unlike the server lookup this has no pre-captured fingerprint — only the
559
+ * failed locator + the live page — so it's a hint, not a guarantee. Returns null
560
+ * for non-name-based locators (testid/CSS), when the targeted name is still
561
+ * present (so the failure wasn't a rename), or when no candidate is confident.
562
+ */
563
+ function suggestLocatorsFromAria(failed, ariaSnapshot) {
564
+ if (!ariaSnapshot || !NAME_BASED_METHODS.has(failed.method))
565
+ return null;
566
+ const { role, name } = failedNameAndRole(failed);
567
+ if (!name)
568
+ return null;
569
+ const candidates = parseAriaRoleName(ariaSnapshot);
570
+ if (candidates.length === 0)
571
+ return null;
572
+ const sameRole = role ? candidates.filter((c) => c.role === role) : [];
573
+ const pool = sameRole.length > 0 ? sameRole : candidates;
574
+ // The targeted name is still on the page → not a rename, nothing to suggest.
575
+ if (pool.some((c) => nameSimilarity(c.name, name) >= 0.8))
576
+ return null;
577
+ let best = null;
578
+ let bestScore = -1;
579
+ for (const c of pool) {
580
+ const s = nameSimilarity(c.name, name);
581
+ if (s > bestScore) {
582
+ bestScore = s;
583
+ best = c;
584
+ }
585
+ }
586
+ if (!best || !best.name)
587
+ return null;
588
+ if (bestScore < 0.2 && pool.length !== 1)
589
+ return null;
590
+ const suggestions = freshSuggestions({ role: best.role, name: best.name }, failed.method);
591
+ if (suggestions.length === 0)
592
+ return null;
593
+ return { failing: renderFailing(failed), suggestions };
594
+ }
595
+ // ── Call-site capture ────────────────────────────────────────────────────────
596
+ /**
597
+ * Capture the calling test's source location (`file:line:col`) from the current
598
+ * stack, for stamping onto a locator snapshot. The path is made cwd-relative so
599
+ * it matches the location format Playwright embeds in error messages — which is
600
+ * what the server's exact-match healing lookup (`extractErrorLocation`) parses.
601
+ *
602
+ * Stamping at action *call* time (rather than correlating with `pw:api` step
603
+ * indices at step *end* time) avoids three classes of misalignment: `pw:api`
604
+ * steps with no wrapped locator (e.g. `page.keyboard.press`), cross-worker step
605
+ * interleaving, and concurrent actions reordering by end time.
606
+ *
607
+ * Returns null when no user frame can be identified — the snapshot keeps
608
+ * `location: null` and the server falls back to fingerprint / ARIA lookup.
609
+ */
610
+ function captureCallerLocation() {
611
+ const stack = new Error().stack ?? '';
612
+ const lines = stack.split('\n');
613
+ // The capture machinery's own frames sit at the top of the stack: this module
614
+ // (locator-healing) then the single fixtures-proxy frame that called it. Skip
615
+ // exactly those, so the first remaining frame is the real test call site. A
616
+ // USER file named `fixtures.*` further down the stack must be kept, so the
617
+ // fixtures-proxy frame is only skipped when it directly follows this module.
618
+ let prevWasCaptureModule = false;
619
+ for (let i = 1; i < lines.length; i++) {
620
+ const line = lines[i].trim();
621
+ if (!line.startsWith('at'))
622
+ continue;
623
+ const m = line.match(/^at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/);
624
+ if (!m) {
625
+ prevWasCaptureModule = false;
626
+ continue;
627
+ }
628
+ let file = m[2];
629
+ if (!file || file.startsWith('node:')) {
630
+ prevWasCaptureModule = false;
631
+ continue;
632
+ }
633
+ file = file.replace(/^file:\/\/\/?/, '');
634
+ // Must look like a source file (has an extension).
635
+ if (!/\.[a-z]+$/i.test(file)) {
636
+ prevWasCaptureModule = false;
637
+ continue;
638
+ }
639
+ // This module — always an internal frame; remember it so the immediately
640
+ // following fixtures-proxy frame can be skipped too.
641
+ if (/[\\/]locator-healing\.[a-z]+$/i.test(file)) {
642
+ prevWasCaptureModule = true;
643
+ continue;
644
+ }
645
+ // The fixtures proxy that called us — skip only when it directly follows
646
+ // this module, so a user's own `fixtures.*` deeper down is not dropped.
647
+ if (prevWasCaptureModule && /[\\/]fixtures\.[a-z]+$/i.test(file)) {
648
+ prevWasCaptureModule = false;
649
+ continue;
650
+ }
651
+ if (/[\\/]node_modules[\\/]/.test(file)) {
652
+ prevWasCaptureModule = false;
653
+ continue;
654
+ }
655
+ let rel = file;
656
+ try {
657
+ rel = path.relative(process.cwd(), file);
658
+ }
659
+ catch {
660
+ /* keep absolute if relative fails */
661
+ }
662
+ rel = rel.split(path.sep).join('/');
663
+ if (rel.startsWith('./'))
664
+ rel = rel.slice(2);
665
+ return `${rel}:${m[3]}:${m[4]}`;
666
+ }
667
+ return null;
668
+ }
@@ -0,0 +1,18 @@
1
+ import type { TestResult } from '@playwright/test/reporter';
2
+ /**
3
+ * Build the stored error text for a test result.
4
+ *
5
+ * Playwright's `result.error` carries the primary failure but for a **timeout**
6
+ * it is only the bare `Test timeout of 30000ms exceeded.` — the interrupted
7
+ * action's actual error (with its call log — the single most diagnostic fact)
8
+ * lives in `result.errors[1..]`. Concatenating all errors (deduped by message)
9
+ * ensures the server sees the call log so it can extract the locator, fingerprint
10
+ * correctly, and feed the model the right facts.
11
+ *
12
+ * A synthetic `at file:line:col` frame from `error.location` is appended when
13
+ * no stack frame is present, enabling locator-healing to find the pre-captured
14
+ * snapshot for that call site. The frame sits after the message, where
15
+ * `extractMessageHead` trims it before fingerprinting — so clustering is
16
+ * unaffected.
17
+ */
18
+ export declare function buildErrorText(result: TestResult): string | null;