@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.
- package/README.md +37 -26
- package/dist/global-setup-module.js +2 -2
- package/dist/index.d.ts +13 -4
- package/dist/index.js +19 -5
- package/dist/internal/capture/attachments.d.ts +19 -0
- package/dist/internal/capture/attachments.js +22 -0
- package/dist/internal/capture/capture-fixtures.d.ts +41 -0
- package/dist/internal/capture/capture-fixtures.js +562 -0
- package/dist/{locator-healing.d.ts → internal/capture/locator-healing.d.ts} +47 -1
- package/dist/{locator-healing.js → internal/capture/locator-healing.js} +113 -29
- package/dist/internal/collect/error-text.d.ts +18 -0
- package/dist/internal/collect/error-text.js +79 -0
- package/dist/{metadata-collector.d.ts → internal/collect/metadata-collector.d.ts} +2 -2
- package/dist/{metadata-collector.js → internal/collect/metadata-collector.js} +12 -9
- package/dist/{skip-classify.d.ts → internal/collect/skip-classify.d.ts} +1 -1
- package/dist/{step-analyzer.d.ts → internal/collect/step-analyzer.d.ts} +6 -0
- package/dist/{step-analyzer.js → internal/collect/step-analyzer.js} +7 -2
- package/dist/internal/config/env.d.ts +45 -0
- package/dist/{config.js → internal/config/env.js} +50 -35
- package/dist/internal/files/compression.js +69 -0
- package/dist/{file-handler.d.ts → internal/files/file-handler.d.ts} +4 -4
- package/dist/{file-handler.js → internal/files/file-handler.js} +61 -29
- package/dist/{crash-recovery.d.ts → internal/streaming/crash-recovery.d.ts} +5 -5
- package/dist/{crash-recovery.js → internal/streaming/crash-recovery.js} +9 -8
- package/dist/{stream-buffer.d.ts → internal/streaming/stream-buffer.d.ts} +2 -2
- package/dist/{stream-buffer.js → internal/streaming/stream-buffer.js} +5 -5
- package/dist/{stream-manager.d.ts → internal/streaming/stream-manager.d.ts} +8 -8
- package/dist/{stream-manager.js → internal/streaming/stream-manager.js} +16 -13
- package/dist/{run-submitter.d.ts → internal/submit/run-submitter.d.ts} +7 -7
- package/dist/{run-submitter.js → internal/submit/run-submitter.js} +11 -9
- package/dist/{serializer.d.ts → internal/submit/serializer.d.ts} +1 -1
- package/dist/{uploader.d.ts → internal/submit/uploader.d.ts} +8 -8
- package/dist/{uploader.js → internal/submit/uploader.js} +45 -10
- package/dist/internal/support/ci.d.ts +2 -0
- package/dist/internal/support/ci.js +32 -0
- package/dist/internal/support/cli-filters.d.ts +1 -0
- package/dist/internal/support/cli-filters.js +51 -0
- package/dist/internal/support/errors.d.ts +8 -0
- package/dist/internal/support/errors.js +15 -0
- package/dist/internal/support/instance-id.d.ts +4 -0
- package/dist/internal/support/instance-id.js +48 -0
- package/dist/internal/support/limiter.d.ts +2 -0
- package/dist/internal/support/limiter.js +27 -0
- package/dist/internal/support/setup-file.d.ts +13 -0
- package/dist/internal/support/setup-file.js +61 -0
- package/dist/internal/support/source-snippet.d.ts +12 -0
- package/dist/internal/support/source-snippet.js +97 -0
- package/dist/internal/support/worker-index.d.ts +7 -0
- package/dist/internal/support/worker-index.js +14 -0
- package/dist/{http-client.d.ts → internal/transport/http-client.d.ts} +11 -1
- package/dist/{http-client.js → internal/transport/http-client.js} +57 -10
- package/dist/{config-wrapper.d.ts → public/config-wrapper.d.ts} +1 -1
- package/dist/{config-wrapper.js → public/config-wrapper.js} +4 -4
- package/dist/public/global-setup.d.ts +13 -0
- package/dist/public/global-setup.js +146 -0
- package/dist/{config.d.ts → public/options.d.ts} +7 -45
- package/dist/public/options.js +2 -0
- package/dist/{reporter.d.ts → public/reporter.d.ts} +1 -1
- package/dist/{reporter.js → public/reporter.js} +44 -54
- package/dist/types/collected.d.ts +96 -0
- package/dist/types/collected.js +10 -0
- package/dist/types/wire.d.ts +174 -0
- package/dist/types/wire.js +14 -0
- package/dist/types.d.ts +8 -253
- package/dist/types.js +23 -10
- package/package.json +1 -6
- package/dist/compression.js +0 -39
- package/dist/fixtures.d.ts +0 -25
- package/dist/fixtures.js +0 -336
- package/dist/helpers.d.ts +0 -44
- package/dist/helpers.js +0 -312
- /package/dist/{skip-classify.js → internal/collect/skip-classify.js} +0 -0
- /package/dist/{compression.d.ts → internal/files/compression.d.ts} +0 -0
- /package/dist/{serializer.js → internal/submit/serializer.js} +0 -0
- /package/dist/{logger.d.ts → internal/support/logger.d.ts} +0 -0
- /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
|
-
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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 (
|
|
329
|
+
if (text && text.length < 80) {
|
|
269
330
|
add({
|
|
270
|
-
locator: `getByText('${esc(
|
|
331
|
+
locator: `getByText('${esc(text)}')`,
|
|
271
332
|
method: 'getByText',
|
|
272
|
-
args: { text
|
|
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('
|
|
344
|
+
locator: `locator('${esc(selector)}')`,
|
|
281
345
|
method: 'locator',
|
|
282
|
-
args: { selector
|
|
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('
|
|
355
|
+
locator: `locator('${esc(selector)}')`,
|
|
291
356
|
method: 'locator',
|
|
292
|
-
args: { selector
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
451
|
-
/**
|
|
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 =
|
|
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]
|
|
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
|
-
/**
|
|
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 '
|
|
3
|
-
import type { SuiteConfigEntry } from '
|
|
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
|
|
5
|
-
const
|
|
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
|
|
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,
|
|
150
|
-
scm.branch = (0,
|
|
151
|
-
scm.author = (0,
|
|
152
|
-
scm.commitMessage = (0,
|
|
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,
|
|
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
|
|
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) {
|
|
@@ -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
|
-
|
|
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 (
|
|
59
|
-
* `default-project` default
|
|
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
|
|
97
|
+
* explicit user option.
|
|
63
98
|
*/
|
|
64
99
|
function resolveOptions(raw) {
|
|
65
100
|
const env = process.env;
|
|
66
101
|
const mergedRaw = { ...raw };
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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)
|