@piwitests/reporter 0.5.0 → 0.7.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 (76) hide show
  1. package/README.md +37 -26
  2. package/dist/global-setup-module.js +2 -2
  3. package/dist/index.d.ts +13 -4
  4. package/dist/index.js +19 -5
  5. package/dist/internal/capture/attachments.d.ts +19 -0
  6. package/dist/internal/capture/attachments.js +22 -0
  7. package/dist/internal/capture/capture-fixtures.d.ts +41 -0
  8. package/dist/internal/capture/capture-fixtures.js +562 -0
  9. package/dist/{locator-healing.d.ts → internal/capture/locator-healing.d.ts} +47 -1
  10. package/dist/{locator-healing.js → internal/capture/locator-healing.js} +113 -29
  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} +50 -35
  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} +8 -8
  28. package/dist/{stream-manager.js → internal/streaming/stream-manager.js} +16 -13
  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} +11 -9
  31. package/dist/{serializer.d.ts → internal/submit/serializer.d.ts} +1 -1
  32. package/dist/{uploader.d.ts → internal/submit/uploader.d.ts} +8 -8
  33. package/dist/{uploader.js → internal/submit/uploader.js} +45 -10
  34. package/dist/internal/support/ci.d.ts +2 -0
  35. package/dist/internal/support/ci.js +32 -0
  36. package/dist/internal/support/cli-filters.d.ts +1 -0
  37. package/dist/internal/support/cli-filters.js +51 -0
  38. package/dist/internal/support/errors.d.ts +8 -0
  39. package/dist/internal/support/errors.js +15 -0
  40. package/dist/internal/support/instance-id.d.ts +4 -0
  41. package/dist/internal/support/instance-id.js +48 -0
  42. package/dist/internal/support/limiter.d.ts +2 -0
  43. package/dist/internal/support/limiter.js +27 -0
  44. package/dist/internal/support/setup-file.d.ts +13 -0
  45. package/dist/internal/support/setup-file.js +61 -0
  46. package/dist/internal/support/source-snippet.d.ts +12 -0
  47. package/dist/internal/support/source-snippet.js +97 -0
  48. package/dist/internal/support/worker-index.d.ts +7 -0
  49. package/dist/internal/support/worker-index.js +14 -0
  50. package/dist/{http-client.d.ts → internal/transport/http-client.d.ts} +11 -1
  51. package/dist/{http-client.js → internal/transport/http-client.js} +57 -10
  52. package/dist/{config-wrapper.d.ts → public/config-wrapper.d.ts} +1 -1
  53. package/dist/{config-wrapper.js → public/config-wrapper.js} +4 -4
  54. package/dist/public/global-setup.d.ts +13 -0
  55. package/dist/public/global-setup.js +146 -0
  56. package/dist/{config.d.ts → public/options.d.ts} +7 -45
  57. package/dist/public/options.js +2 -0
  58. package/dist/{reporter.d.ts → public/reporter.d.ts} +1 -1
  59. package/dist/{reporter.js → public/reporter.js} +44 -54
  60. package/dist/types/collected.d.ts +96 -0
  61. package/dist/types/collected.js +10 -0
  62. package/dist/types/wire.d.ts +174 -0
  63. package/dist/types/wire.js +14 -0
  64. package/dist/types.d.ts +8 -253
  65. package/dist/types.js +23 -10
  66. package/package.json +1 -6
  67. package/dist/compression.js +0 -39
  68. package/dist/fixtures.d.ts +0 -25
  69. package/dist/fixtures.js +0 -336
  70. package/dist/helpers.d.ts +0 -44
  71. package/dist/helpers.js +0 -312
  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/{serializer.js → internal/submit/serializer.js} +0 -0
  75. /package/dist/{logger.d.ts → internal/support/logger.d.ts} +0 -0
  76. /package/dist/{logger.js → internal/support/logger.js} +0 -0
@@ -34,15 +34,42 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CAPTURED_ATTRIBUTES = exports.LOCATOR_CREATING_CHAINS = exports.ACTION_METHODS = exports.CHAIN_METHODS = exports.LOCATOR_METHODS = void 0;
37
+ exports.dedupeSnapshotsByLocation = dedupeSnapshotsByLocation;
37
38
  exports.resolveAriaRole = resolveAriaRole;
38
39
  exports.generateAlternatives = generateAlternatives;
39
40
  exports.classifyCssStability = classifyCssStability;
40
41
  exports.isAutoGenerated = isAutoGenerated;
41
42
  exports.extractAccessibleName = extractAccessibleName;
42
43
  exports.approximateAccessibleName = approximateAccessibleName;
44
+ exports.parseAriaRoleName = parseAriaRoleName;
45
+ exports.nameSimilarity = nameSimilarity;
43
46
  exports.suggestLocatorsFromAria = suggestLocatorsFromAria;
44
47
  exports.captureCallerLocation = captureCallerLocation;
45
- const path = __importStar(require("path"));
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
+ });
72
+ }
46
73
  // ── Playwright method surface (shared with the fixture proxy) ────────────────
47
74
  /**
48
75
  * Page-level locator-building methods wrapped by the capture proxy. Imported by
@@ -92,12 +119,18 @@ exports.ACTION_METHODS = [
92
119
  'hover',
93
120
  'press',
94
121
  'type',
122
+ 'pressSequentially',
95
123
  'clear',
96
124
  'setInputFiles',
97
125
  'dragTo',
98
126
  'focus',
99
127
  'blur',
100
128
  'scrollIntoViewIfNeeded',
129
+ 'dispatchEvent',
130
+ 'selectText',
131
+ // Not an action, but a successful waitFor proves the element resolved — the
132
+ // closest capture hook available for assertion-style usage of a locator.
133
+ 'waitFor',
101
134
  ];
102
135
  /** Chain methods that create a new locator scope (origin tracks the chain call). */
103
136
  exports.LOCATOR_CREATING_CHAINS = new Set(exports.LOCATOR_METHODS);
@@ -118,7 +151,11 @@ exports.CAPTURED_ATTRIBUTES = [
118
151
  'role',
119
152
  'type',
120
153
  'href',
121
- 'value',
154
+ // `multiple` distinguishes listbox vs combobox for <select>. `value` is
155
+ // deliberately NOT captured: no alternative generator uses it, and reading
156
+ // the live value after fill() would leak user-typed secrets (passwords,
157
+ // tokens) into the snapshot payload and server storage.
158
+ 'multiple',
122
159
  ];
123
160
  // ── ARIA role resolution ─────────────────────────────────────────────────────
124
161
  /** Implicit ARIA role for an HTML tag (when no explicit `role` is set). */
@@ -142,7 +179,6 @@ const TAG_TO_ROLE = {
142
179
  output: 'status',
143
180
  progress: 'progressbar',
144
181
  meter: 'meter',
145
- select: 'listbox',
146
182
  textarea: 'textbox',
147
183
  h1: 'heading',
148
184
  h2: 'heading',
@@ -189,6 +225,11 @@ function resolveAriaRole(attrs) {
189
225
  const type = (attrs.attributes['type'] ?? 'text').toLowerCase();
190
226
  return INPUT_TYPE_TO_ROLE[type] ?? 'textbox';
191
227
  }
228
+ // A plain <select> is a combobox; only with `multiple` (or size > 1, not
229
+ // captured) does it become a listbox. Matches the server's implicitRoleForTag.
230
+ if (tag === 'select') {
231
+ return attrs.attributes['multiple'] != null ? 'listbox' : 'combobox';
232
+ }
192
233
  if (tag === 'a') {
193
234
  return attrs.attributes['href'] != null ? 'link' : null;
194
235
  }
@@ -197,6 +238,10 @@ function resolveAriaRole(attrs) {
197
238
  // ── Alternative generation ───────────────────────────────────────────────────
198
239
  const attr = (a, key) => a.attributes[key] || null;
199
240
  const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
241
+ /** Escape a value for use inside a double-quoted CSS attribute selector. */
242
+ const escCssAttrValue = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
243
+ /** An id usable as a bare `#id` CSS selector without escaping (framework ids like `:r3:` are not). */
244
+ const isCssSafeId = (id) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(id);
200
245
  /**
201
246
  * Build a ranked list of alternative locators from the captured element
202
247
  * attributes. The list is sorted descending by stability score.
@@ -216,9 +261,17 @@ function generateAlternatives(attrs) {
216
261
  const { accessibleName } = attrs;
217
262
  const tag = attrs.tagName;
218
263
  const role = resolveAriaRole(attrs);
264
+ // A probed count > 1 means the selector matches several elements on the
265
+ // captured page — a strict-mode violation, so the alternative is suppressed.
266
+ // Unknown counts (probe absent/failed) keep the alternative.
267
+ const counts = attrs.selectorCounts;
268
+ const isUnique = (n) => n == null || n <= 1;
269
+ // Collapse whitespace: multi-line/indented textContent would otherwise
270
+ // produce a getByText with literal newlines — invalid when pasted as code.
271
+ const text = attrs.textContent ? attrs.textContent.replace(/\s+/g, ' ').trim() : null;
219
272
  // 1. data-testid — highest stability (100)
220
273
  const testId = attr(attrs, 'data-testid');
221
- if (testId) {
274
+ if (testId && isUnique(counts?.testId)) {
222
275
  add({
223
276
  locator: `getByTestId('${esc(testId)}')`,
224
277
  method: 'getByTestId',
@@ -245,14 +298,22 @@ function generateAlternatives(attrs) {
245
298
  score: 85,
246
299
  });
247
300
  }
248
- // 4. getByLabel — for form fields with associated <label> (85)
301
+ // 4. getByLabel — for form fields (85). getByLabel only matches a real
302
+ // <label> association or aria-label — but the accessible name may have been
303
+ // computed (or approximated) from placeholder/title, where getByLabel would
304
+ // match nothing. Only emit when a label/aria-label actually backs the name;
305
+ // legacy payloads without the hasLabel probe keep the old permissive behavior.
249
306
  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
- });
307
+ const label = attr(attrs, 'aria-label');
308
+ const labelBacked = attrs.hasLabel === undefined ? true : attrs.hasLabel === true || label === accessibleName;
309
+ if (labelBacked) {
310
+ add({
311
+ locator: `getByLabel('${esc(accessibleName)}')`,
312
+ method: 'getByLabel',
313
+ args: { label: accessibleName },
314
+ score: 85,
315
+ });
316
+ }
256
317
  }
257
318
  // 5. getByPlaceholder — for inputs (80)
258
319
  const placeholder = attr(attrs, 'placeholder');
@@ -265,31 +326,35 @@ function generateAlternatives(attrs) {
265
326
  });
266
327
  }
267
328
  // 6. getByText — from visible text content (70-80)
268
- if (attrs.textContent && attrs.textContent.length < 80) {
329
+ if (text && text.length < 80) {
269
330
  add({
270
- locator: `getByText('${esc(attrs.textContent)}')`,
331
+ locator: `getByText('${esc(text)}')`,
271
332
  method: 'getByText',
272
- args: { text: attrs.textContent },
333
+ args: { text },
273
334
  score: 75,
274
335
  });
275
336
  }
276
- // 7. locator('#id') — if id exists and doesn't look auto-generated (50-70)
337
+ // 7. locator('#id') — if id exists and doesn't look auto-generated (50-70).
338
+ // Framework ids that aren't valid bare CSS identifiers (React useId's
339
+ // `:r3:`, ids with dots) fall back to an attribute selector.
277
340
  const id = attr(attrs, 'id');
278
- if (id && !isAutoGenerated(id)) {
341
+ if (id && !isAutoGenerated(id) && isUnique(counts?.id)) {
342
+ const selector = isCssSafeId(id) ? `#${id}` : `[id="${escCssAttrValue(id)}"]`;
279
343
  add({
280
- locator: `locator('#${esc(id)}')`,
344
+ locator: `locator('${esc(selector)}')`,
281
345
  method: 'locator',
282
- args: { selector: `#${id}` },
346
+ args: { selector },
283
347
  score: 65,
284
348
  });
285
349
  }
286
350
  // 8. locator('[name="..."]') — for form elements (60)
287
351
  const name = attr(attrs, 'name');
288
- if (name) {
352
+ if (name && isUnique(counts?.name)) {
353
+ const selector = `[name="${escCssAttrValue(name)}"]`;
289
354
  add({
290
- locator: `locator('[name="${esc(name)}"]')`,
355
+ locator: `locator('${esc(selector)}')`,
291
356
  method: 'locator',
292
- args: { selector: `[name="${name}"]` },
357
+ args: { selector },
293
358
  score: 60,
294
359
  });
295
360
  }
@@ -313,12 +378,15 @@ function generateAlternatives(attrs) {
313
378
  score: 50,
314
379
  });
315
380
  }
316
- // 11. CSS class-based locators — capped at 3 most stable classes
381
+ // 11. CSS class-based locators — capped at 3 most stable classes; classes
382
+ // the uniqueness probe saw on more than one element are dropped outright.
317
383
  const clsStr = attr(attrs, 'class');
318
384
  if (clsStr) {
319
385
  const classes = clsStr
320
386
  .split(/\s+/)
321
- .filter((c) => c.length > 1)
387
+ // Only classes valid as a bare `.cls` selector — Tailwind variants
388
+ // (`hover:bg-red-500`) and arbitrary values (`w-[10px]`) are not.
389
+ .filter((c) => c.length > 1 && /^[A-Za-z_-][A-Za-z0-9_-]*$/.test(c) && isUnique(counts?.classes?.[c]))
322
390
  .map((cls) => ({
323
391
  cls,
324
392
  score: classifyCssStability(cls),
@@ -393,7 +461,13 @@ function isAutoGenerated(value) {
393
461
  if (/^(emotion-|styled-|css-|sc-)/.test(value))
394
462
  return true;
395
463
  // Angular-style generated IDs (ng-xxx-N)
396
- if (/^ng-/.test(value))
464
+ if (value.startsWith('ng-'))
465
+ return true;
466
+ // Component-library generated ids (Radix, Headless UI, MUI, Mantine, Chakra)
467
+ if (/^(radix-|headlessui-|mui-|mantine-|chakra-)/i.test(value))
468
+ return true;
469
+ // React useId format — `:r1:` (18) / `«r1»` (19)
470
+ if (/^:r[0-9a-z]+:$/i.test(value) || /^«r[0-9a-z]+»$/i.test(value))
397
471
  return true;
398
472
  return false;
399
473
  }
@@ -447,23 +521,33 @@ const NAME_BASED_METHODS = new Set([
447
521
  'getByTitle',
448
522
  'getByAltText',
449
523
  ]);
450
- const escAttr = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
451
- /** Parse `ariaSnapshot()` lines into role/name pairs (mirrors the server-side matcher). */
524
+ const escAttr = (s) => s.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
525
+ /**
526
+ * Parse `ariaSnapshot()` lines into role/name pairs (mirrors the server-side
527
+ * `parseAriaCandidates` in application/shared/locator-fingerprint.ts).
528
+ * Exported so the dashboard's drift-guard unit test can compare the two.
529
+ */
452
530
  function parseAriaRoleName(ariaSnapshot) {
453
531
  const out = [];
454
532
  for (const line of ariaSnapshot.split('\n')) {
455
- const m = line.match(/^\s*-\s+([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?/i);
533
+ const m = /^\s*-\s+([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?/i.exec(line);
456
534
  if (!m)
457
535
  continue;
458
536
  const role = m[1];
459
- const name = m[2] != null ? m[2].replace(/\\(.)/g, '$1') : null;
537
+ const name = m[2] == null ? null : m[2].replace(/\\(.)/g, '$1');
460
538
  if (!name && (role === 'generic' || role === 'group' || role === 'list' || role === 'paragraph'))
461
539
  continue;
462
540
  out.push({ role, name });
463
541
  }
464
542
  return out;
465
543
  }
466
- /** Token-set (Dice) similarity, 0-1, case- and punctuation-insensitive. */
544
+ /**
545
+ * Token-set (Dice) similarity, 0-1, case- and punctuation-insensitive.
546
+ * Duplicated from `textSimilarity` in application/shared/locator-fingerprint.ts —
547
+ * this package publishes standalone to npm and can't import monorepo-relative
548
+ * shared/ code, so keep the two implementations in sync by hand. Exported so
549
+ * the dashboard's drift-guard unit test can compare the two.
550
+ */
467
551
  function nameSimilarity(a, b) {
468
552
  const tok = (s) => new Set((s ?? '')
469
553
  .toLowerCase()
@@ -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;
@@ -0,0 +1,79 @@
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.buildErrorText = buildErrorText;
37
+ const path = __importStar(require("node:path"));
38
+ /**
39
+ * Build the stored error text for a test result.
40
+ *
41
+ * Playwright's `result.error` carries the primary failure but for a **timeout**
42
+ * it is only the bare `Test timeout of 30000ms exceeded.` — the interrupted
43
+ * action's actual error (with its call log — the single most diagnostic fact)
44
+ * lives in `result.errors[1..]`. Concatenating all errors (deduped by message)
45
+ * ensures the server sees the call log so it can extract the locator, fingerprint
46
+ * correctly, and feed the model the right facts.
47
+ *
48
+ * A synthetic `at file:line:col` frame from `error.location` is appended when
49
+ * no stack frame is present, enabling locator-healing to find the pre-captured
50
+ * snapshot for that call site. The frame sits after the message, where
51
+ * `extractMessageHead` trims it before fingerprinting — so clustering is
52
+ * unaffected.
53
+ */
54
+ function buildErrorText(result) {
55
+ if (!result.errors || result.errors.length === 0)
56
+ return null;
57
+ const seen = new Set();
58
+ const parts = [];
59
+ for (const err of result.errors) {
60
+ const msg = err.message ?? '';
61
+ if (!msg)
62
+ continue;
63
+ const dedupeKey = msg.trim().slice(0, 200);
64
+ if (seen.has(dedupeKey))
65
+ continue;
66
+ seen.add(dedupeKey);
67
+ parts.push(msg);
68
+ }
69
+ let text = parts.join('\n---\n');
70
+ if (!text)
71
+ return null;
72
+ const primary = result.error;
73
+ const loc = primary?.location;
74
+ if (loc?.file && !/\n\s+at\s/.test(text)) {
75
+ const rel = path.relative(process.cwd(), loc.file).split(path.sep).join('/');
76
+ text += `\n at ${rel}:${loc.line}:${loc.column}`;
77
+ }
78
+ return text;
79
+ }
@@ -1,6 +1,6 @@
1
1
  import type { FullConfig, Suite, TestCase } from '@playwright/test/reporter';
2
- import { Logger } from './logger.js';
3
- import type { SuiteConfigEntry } from './types.js';
2
+ import { Logger } from '../support/logger.js';
3
+ import type { SuiteConfigEntry } from '../../types.js';
4
4
  /**
5
5
  * Collects CI/CD environment metadata, SCM (git) information, and Playwright
6
6
  * config metadata to attach to each test run submission.
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MetadataCollector = void 0;
4
- const child_process_1 = require("child_process");
5
- const logger_js_1 = require("./logger.js");
4
+ const node_child_process_1 = require("node:child_process");
5
+ const errors_js_1 = require("../support/errors.js");
6
+ const logger_js_1 = require("../support/logger.js");
6
7
  /**
7
8
  * Collects CI/CD environment metadata, SCM (git) information, and Playwright
8
9
  * config metadata to attach to each test run submission.
@@ -47,7 +48,7 @@ class MetadataCollector {
47
48
  const all = suite.allTests();
48
49
  if (all.length > 0) {
49
50
  const first = all[0];
50
- if (first?.parent?.project) {
51
+ if (first?.parent) {
51
52
  const proj = first.parent.project();
52
53
  if (proj?.metadata)
53
54
  metadata.playwrightProject = proj.metadata;
@@ -146,23 +147,25 @@ class MetadataCollector {
146
147
  const scm = {};
147
148
  try {
148
149
  const execOpts = { encoding: 'utf8', timeout: 5000, maxBuffer: 1024 * 1024 };
149
- scm.commit = (0, child_process_1.execSync)('git rev-parse HEAD', execOpts).trim();
150
- scm.branch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', execOpts).trim();
151
- scm.author = (0, child_process_1.execSync)('git log -1 --pretty=format:"%an"', execOpts).trim();
152
- scm.commitMessage = (0, child_process_1.execSync)('git log -1 --pretty=format:"%s"', execOpts).trim();
150
+ scm.commit = (0, node_child_process_1.execSync)('git rev-parse HEAD', execOpts).trim();
151
+ scm.branch = (0, node_child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', execOpts).trim();
152
+ scm.author = (0, node_child_process_1.execSync)('git log -1 --pretty=format:"%an"', execOpts).trim();
153
+ scm.commitMessage = (0, node_child_process_1.execSync)('git log -1 --pretty=format:"%s"', execOpts).trim();
153
154
  try {
154
- scm.remoteUrl = (0, child_process_1.execSync)('git config --get remote.origin.url', execOpts).trim();
155
+ scm.remoteUrl = (0, node_child_process_1.execSync)('git config --get remote.origin.url', execOpts).trim();
155
156
  }
156
157
  catch {
157
158
  /* optional */
158
159
  }
159
160
  }
160
161
  catch (error) {
161
- this.logger.debug(`Git info not available: ${error.message}`);
162
+ this.logger.debug(`Git info not available: ${(0, errors_js_1.errorMessage)(error)}`);
162
163
  }
163
164
  return Object.keys(scm).length > 0 ? scm : undefined;
164
165
  }
165
166
  collectCiInfo() {
167
+ // Env vars are `string | undefined`; undefined values are dropped on JSON
168
+ // serialization, so collecting them directly preserves the prior behavior.
166
169
  const ci = {};
167
170
  const env = process.env;
168
171
  if (env.JENKINS_URL) {
@@ -1,4 +1,4 @@
1
- import type { TestAnnotation } from './types.js';
1
+ import type { TestAnnotation } from '../../types.js';
2
2
  /** Minimal structural shape for the annotation carriers (avoids importing Playwright types). */
3
3
  interface AnnotationCarrier {
4
4
  annotations?: ReadonlyArray<{
@@ -14,6 +14,12 @@ export interface FlatStep {
14
14
  title: string;
15
15
  duration: number;
16
16
  category: string;
17
+ /** Error message when the step failed (undefined when the step passed). */
18
+ error?: {
19
+ message: string;
20
+ };
21
+ /** True when the step carried an error — the signal the server needs for inline failure markers. */
22
+ failed?: boolean;
17
23
  }
18
24
  /** Step-event category restricted to the values `extractTestStepEvents` emits. */
19
25
  export type StepEventCategory = 'hook' | 'fixture' | 'test.step' | 'expect' | 'wait';
@@ -84,11 +84,16 @@ function categorizeStep(title, pwCategory) {
84
84
  function flattenSteps(steps) {
85
85
  const result = [];
86
86
  for (const step of steps) {
87
- result.push({
87
+ const flat = {
88
88
  title: step.title,
89
89
  duration: step.duration,
90
90
  category: categorizeStep(step.title, step.category),
91
- });
91
+ };
92
+ if (step.error?.message) {
93
+ flat.error = { message: step.error.message };
94
+ flat.failed = true;
95
+ }
96
+ result.push(flat);
92
97
  if (step.steps?.length > 0)
93
98
  result.push(...flattenSteps(step.steps));
94
99
  }
@@ -0,0 +1,45 @@
1
+ import type { PiwiDashboardOptions } from '../../public/options.js';
2
+ /**
3
+ * Single source of truth for the `PIWI_*` env-var → option mapping. Both
4
+ * `resolveOptions` (env → option) and `applyOptionsToEnv` (option → env, used
5
+ * by `wrapConfig` to bridge into the global-setup process) read these names so
6
+ * the mapping lives in exactly one place.
7
+ */
8
+ export declare const PIWI_ENV_KEYS: {
9
+ readonly serverUrl: "PIWI_DASHBOARD_URL";
10
+ readonly projectName: "PIWI_PROJECT_NAME";
11
+ readonly verbose: "PIWI_VERBOSE";
12
+ readonly apiKey: "PIWI_API_KEY";
13
+ readonly username: "PIWI_USERNAME";
14
+ readonly password: "PIWI_PASSWORD";
15
+ readonly environment: "PIWI_ENVIRONMENT";
16
+ readonly label: "PIWI_LABEL";
17
+ readonly runLabel: "PIWI_RUN_LABEL";
18
+ readonly streaming: "PIWI_STREAMING";
19
+ readonly streamingBatchSize: "PIWI_STREAMING_BATCH_SIZE";
20
+ readonly streamingBatchDelay: "PIWI_STREAMING_BATCH_DELAY";
21
+ readonly liveFileUploads: "PIWI_LIVE_FILE_UPLOADS";
22
+ readonly uploadTraces: "PIWI_UPLOAD_TRACES";
23
+ readonly uploadReport: "PIWI_UPLOAD_REPORT";
24
+ readonly captureLocators: "PIWI_CAPTURE_LOCATORS";
25
+ };
26
+ /**
27
+ * Merge raw user options with defaults, reading from `PIWI_*` env vars when
28
+ * options are not provided.
29
+ *
30
+ * Env semantics: env vars fill in values the caller didn't provide (fallback).
31
+ * They're applied to `raw` *before* the `DEFAULTS` merge so a built-in default
32
+ * never masks an env var (`PIWI_PROJECT_NAME` would otherwise be masked by the
33
+ * `default-project` default).
34
+ *
35
+ * One preserved quirk: `PIWI_VERBOSE` wins over both the default *and* an
36
+ * explicit user option.
37
+ */
38
+ export declare function resolveOptions(raw: Record<string, any>): PiwiDashboardOptions;
39
+ /**
40
+ * Write the options that the isolated `global-setup-module` process needs into
41
+ * `PIWI_*` env vars. `wrapConfig` calls this so the global setup (which runs
42
+ * `resolveOptions({})` in a separate module) picks up the same server/auth
43
+ * config the reporter instance uses. Only writes values that are actually set.
44
+ */
45
+ export declare function applyOptionsToEnv(options: PiwiDashboardOptions): void;
@@ -3,6 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PIWI_ENV_KEYS = void 0;
4
4
  exports.resolveOptions = resolveOptions;
5
5
  exports.applyOptionsToEnv = applyOptionsToEnv;
6
+ /**
7
+ * Built-in option defaults, merged *under* any user-provided or env-derived
8
+ * value (see `resolveOptions`).
9
+ */
6
10
  const DEFAULTS = {
7
11
  projectName: 'default-project',
8
12
  uploadTraces: true,
@@ -49,53 +53,64 @@ function readBool(val) {
49
53
  return undefined;
50
54
  return val === 'true';
51
55
  }
56
+ /**
57
+ * Declarative env → option fallback table. `resolveOptions` walks this instead
58
+ * of repeating ~15 near-identical merge lines, so adding an env-backed option
59
+ * means adding one row.
60
+ *
61
+ * Guard semantics (preserved from the original hand-written merges):
62
+ * - `string` / `number`: a *truthy* env value fills the option, so an empty
63
+ * string is ignored.
64
+ * - `bool`: any *defined* env value fills it (via `readBool`), so
65
+ * `PIWI_STREAMING=false` actually disables streaming.
66
+ *
67
+ * `verbose` is intentionally absent — it *overrides* rather than falls back, and
68
+ * is applied after the `DEFAULTS` merge in `resolveOptions`.
69
+ */
70
+ const ENV_FALLBACK_SPECS = [
71
+ { option: 'serverUrl', env: exports.PIWI_ENV_KEYS.serverUrl, kind: 'string' },
72
+ { option: 'projectName', env: exports.PIWI_ENV_KEYS.projectName, kind: 'string' },
73
+ { option: 'apiKey', env: exports.PIWI_ENV_KEYS.apiKey, kind: 'string' },
74
+ { option: 'username', env: exports.PIWI_ENV_KEYS.username, kind: 'string' },
75
+ { option: 'password', env: exports.PIWI_ENV_KEYS.password, kind: 'string' },
76
+ { option: 'environment', env: exports.PIWI_ENV_KEYS.environment, kind: 'string' },
77
+ { option: 'label', env: exports.PIWI_ENV_KEYS.label, kind: 'string' },
78
+ { option: 'runLabel', env: exports.PIWI_ENV_KEYS.runLabel, kind: 'string' },
79
+ { option: 'streaming', env: exports.PIWI_ENV_KEYS.streaming, kind: 'bool' },
80
+ { option: 'streamingBatchSize', env: exports.PIWI_ENV_KEYS.streamingBatchSize, kind: 'number' },
81
+ { option: 'streamingBatchDelay', env: exports.PIWI_ENV_KEYS.streamingBatchDelay, kind: 'number' },
82
+ { option: 'liveFileUploads', env: exports.PIWI_ENV_KEYS.liveFileUploads, kind: 'bool' },
83
+ { option: 'uploadTraces', env: exports.PIWI_ENV_KEYS.uploadTraces, kind: 'bool' },
84
+ { option: 'uploadReport', env: exports.PIWI_ENV_KEYS.uploadReport, kind: 'bool' },
85
+ { option: 'captureLocators', env: exports.PIWI_ENV_KEYS.captureLocators, kind: 'bool' },
86
+ ];
52
87
  /**
53
88
  * Merge raw user options with defaults, reading from `PIWI_*` env vars when
54
89
  * options are not provided.
55
90
  *
56
91
  * Env semantics: env vars fill in values the caller didn't provide (fallback).
57
92
  * They're applied to `raw` *before* the `DEFAULTS` merge so a built-in default
58
- * never masks an env var (the pre-Phase-4 `PIWI_PROJECT_NAME` was masked by the
59
- * `default-project` default — that quirk is now fixed).
93
+ * never masks an env var (`PIWI_PROJECT_NAME` would otherwise be masked by the
94
+ * `default-project` default).
60
95
  *
61
96
  * One preserved quirk: `PIWI_VERBOSE` wins over both the default *and* an
62
- * explicit user option, matching the pre-Phase-4 behavior.
97
+ * explicit user option.
63
98
  */
64
99
  function resolveOptions(raw) {
65
100
  const env = process.env;
66
101
  const mergedRaw = { ...raw };
67
- // String options: env fills in when the caller didn't provide one.
68
- if (mergedRaw.serverUrl === undefined && env[exports.PIWI_ENV_KEYS.serverUrl])
69
- mergedRaw.serverUrl = env[exports.PIWI_ENV_KEYS.serverUrl];
70
- if (mergedRaw.projectName === undefined && env[exports.PIWI_ENV_KEYS.projectName])
71
- mergedRaw.projectName = env[exports.PIWI_ENV_KEYS.projectName];
72
- if (mergedRaw.apiKey === undefined && env[exports.PIWI_ENV_KEYS.apiKey])
73
- mergedRaw.apiKey = env[exports.PIWI_ENV_KEYS.apiKey];
74
- if (mergedRaw.username === undefined && env[exports.PIWI_ENV_KEYS.username])
75
- mergedRaw.username = env[exports.PIWI_ENV_KEYS.username];
76
- if (mergedRaw.password === undefined && env[exports.PIWI_ENV_KEYS.password])
77
- mergedRaw.password = env[exports.PIWI_ENV_KEYS.password];
78
- if (mergedRaw.environment === undefined && env[exports.PIWI_ENV_KEYS.environment])
79
- mergedRaw.environment = env[exports.PIWI_ENV_KEYS.environment];
80
- if (mergedRaw.label === undefined && env[exports.PIWI_ENV_KEYS.label])
81
- mergedRaw.label = env[exports.PIWI_ENV_KEYS.label];
82
- if (mergedRaw.runLabel === undefined && env[exports.PIWI_ENV_KEYS.runLabel])
83
- mergedRaw.runLabel = env[exports.PIWI_ENV_KEYS.runLabel];
84
- // Boolean / numeric options: env fills in when the caller didn't provide one.
85
- if (mergedRaw.streaming === undefined && env[exports.PIWI_ENV_KEYS.streaming] !== undefined)
86
- mergedRaw.streaming = readBool(env[exports.PIWI_ENV_KEYS.streaming]);
87
- if (mergedRaw.streamingBatchSize === undefined && env[exports.PIWI_ENV_KEYS.streamingBatchSize])
88
- mergedRaw.streamingBatchSize = Number(env[exports.PIWI_ENV_KEYS.streamingBatchSize]);
89
- if (mergedRaw.streamingBatchDelay === undefined && env[exports.PIWI_ENV_KEYS.streamingBatchDelay])
90
- mergedRaw.streamingBatchDelay = Number(env[exports.PIWI_ENV_KEYS.streamingBatchDelay]);
91
- if (mergedRaw.liveFileUploads === undefined && env[exports.PIWI_ENV_KEYS.liveFileUploads] !== undefined)
92
- mergedRaw.liveFileUploads = readBool(env[exports.PIWI_ENV_KEYS.liveFileUploads]);
93
- if (mergedRaw.uploadTraces === undefined && env[exports.PIWI_ENV_KEYS.uploadTraces] !== undefined)
94
- mergedRaw.uploadTraces = readBool(env[exports.PIWI_ENV_KEYS.uploadTraces]);
95
- if (mergedRaw.uploadReport === undefined && env[exports.PIWI_ENV_KEYS.uploadReport] !== undefined)
96
- mergedRaw.uploadReport = readBool(env[exports.PIWI_ENV_KEYS.uploadReport]);
97
- if (mergedRaw.captureLocators === undefined && env[exports.PIWI_ENV_KEYS.captureLocators] !== undefined)
98
- mergedRaw.captureLocators = readBool(env[exports.PIWI_ENV_KEYS.captureLocators]);
102
+ for (const spec of ENV_FALLBACK_SPECS) {
103
+ if (mergedRaw[spec.option] !== undefined)
104
+ continue;
105
+ const value = env[spec.env];
106
+ if (spec.kind === 'bool') {
107
+ if (value !== undefined)
108
+ mergedRaw[spec.option] = readBool(value);
109
+ }
110
+ else if (value) {
111
+ mergedRaw[spec.option] = spec.kind === 'number' ? Number(value) : value;
112
+ }
113
+ }
99
114
  const opts = { ...DEFAULTS, ...mergedRaw };
100
115
  // Preserved quirk: PIWI_VERBOSE wins over both default and user option.
101
116
  if (env[exports.PIWI_ENV_KEYS.verbose] !== undefined)