@piwitests/reporter 0.6.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/internal/capture/capture-fixtures.d.ts +44 -1
- package/dist/internal/capture/capture-fixtures.js +100 -25
- package/dist/internal/capture/locator-healing.d.ts +47 -1
- package/dist/internal/capture/locator-healing.js +103 -24
- package/dist/internal/streaming/stream-manager.d.ts +1 -1
- package/dist/internal/streaming/stream-manager.js +6 -3
- package/dist/internal/submit/run-submitter.d.ts +1 -0
- package/dist/internal/submit/run-submitter.js +2 -0
- package/dist/internal/submit/serializer.js +1 -0
- package/dist/internal/submit/uploader.d.ts +2 -0
- package/dist/internal/support/reporter-version.d.ts +2 -0
- package/dist/internal/support/reporter-version.js +54 -0
- package/dist/public/reporter.d.ts +1 -0
- package/dist/public/reporter.js +4 -1
- package/package.json +3 -1
|
@@ -1,4 +1,46 @@
|
|
|
1
|
-
import type { Fixtures } from '@playwright/test';
|
|
1
|
+
import type { Fixtures, Locator } from '@playwright/test';
|
|
2
|
+
/** Shape returned by the in-page element probe (see `wrapLocator`). */
|
|
3
|
+
interface CapturedAttrs {
|
|
4
|
+
tagName: string;
|
|
5
|
+
attributes: Record<string, string | null>;
|
|
6
|
+
textContent: string;
|
|
7
|
+
center: {
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
};
|
|
11
|
+
/** True when the element has an associated <label> — gates getByLabel. */
|
|
12
|
+
hasLabel: boolean;
|
|
13
|
+
/** querySelectorAll match counts for candidate selectors (uniqueness probe). */
|
|
14
|
+
selectorCounts: {
|
|
15
|
+
testId?: number;
|
|
16
|
+
id?: number;
|
|
17
|
+
name?: number;
|
|
18
|
+
classes?: Record<string, number>;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* ARIA snapshot that tolerates every Playwright version the reporter supports,
|
|
23
|
+
* returning null instead of throwing so a capture can never fail the test. The
|
|
24
|
+
* options validator runs client-side in the user's installed Playwright, so an
|
|
25
|
+
* unsupported key surfaces as a rejected promise (not a synchronous throw):
|
|
26
|
+
* - ≥ 1.59: `mode: 'ai'` yields the ref-annotated AI snapshot (the ideal);
|
|
27
|
+
* `ref` is unknown and silently stripped.
|
|
28
|
+
* - 1.52: `mode: 'ai'` fails validation (only 'raw'/'regex' are accepted) and
|
|
29
|
+
* rejects; the `{ ref: true }` fallback then yields a ref-annotated snapshot.
|
|
30
|
+
* - 1.53–1.58: neither `ref` nor `mode` exists — unknown keys are stripped
|
|
31
|
+
* server-side, so the first call already returns a plain flat snapshot.
|
|
32
|
+
* - < 1.49: `locator.ariaSnapshot` does not exist — returns null up front.
|
|
33
|
+
*/
|
|
34
|
+
export declare function ariaSnapshotBestEffort(target: Locator, timeout?: number): Promise<string | null>;
|
|
35
|
+
/**
|
|
36
|
+
* Runs inside the browser via `evaluate()` — probes a captured element for its
|
|
37
|
+
* attributes, geometry, label association, and selector-uniqueness counts.
|
|
38
|
+
* Must stay a fully self-contained function (no references to this module's
|
|
39
|
+
* closure): Playwright serializes it and executes it in the page, browser-side.
|
|
40
|
+
* `el` is browser-context (no DOM lib in this Node package), hence `any`.
|
|
41
|
+
* Exported for unit testing; still passed directly to `evaluate()` below.
|
|
42
|
+
*/
|
|
43
|
+
export declare function probeElementAttrs(el: any, keep: string[]): CapturedAttrs;
|
|
2
44
|
/**
|
|
3
45
|
* Playwright fixtures that collect network requests, console entries,
|
|
4
46
|
* web vitals, ARIA snapshots, and locator interaction data during a test.
|
|
@@ -25,3 +67,4 @@ export declare const dashboardFixtures: Fixtures;
|
|
|
25
67
|
* ```
|
|
26
68
|
*/
|
|
27
69
|
export declare function extendDashboardFixtures<T>(test: T): T;
|
|
70
|
+
export {};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.dashboardFixtures = void 0;
|
|
4
|
+
exports.ariaSnapshotBestEffort = ariaSnapshotBestEffort;
|
|
5
|
+
exports.probeElementAttrs = probeElementAttrs;
|
|
4
6
|
exports.extendDashboardFixtures = extendDashboardFixtures;
|
|
5
7
|
const node_zlib_1 = require("node:zlib");
|
|
6
8
|
const locator_healing_js_1 = require("./locator-healing.js");
|
|
@@ -74,6 +76,75 @@ async function ariaSnapshotBestEffort(target, timeout) {
|
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Runs inside the browser via `evaluate()` — probes a captured element for its
|
|
81
|
+
* attributes, geometry, label association, and selector-uniqueness counts.
|
|
82
|
+
* Must stay a fully self-contained function (no references to this module's
|
|
83
|
+
* closure): Playwright serializes it and executes it in the page, browser-side.
|
|
84
|
+
* `el` is browser-context (no DOM lib in this Node package), hence `any`.
|
|
85
|
+
* Exported for unit testing; still passed directly to `evaluate()` below.
|
|
86
|
+
*/
|
|
87
|
+
function probeElementAttrs(el, keep) {
|
|
88
|
+
const attrMap = {};
|
|
89
|
+
for (const key of keep) {
|
|
90
|
+
const v = el.getAttribute(key) ?? el[key];
|
|
91
|
+
attrMap[key] = typeof v === 'string' ? v.slice(0, 200) : v ? String(v).slice(0, 200) : null;
|
|
92
|
+
}
|
|
93
|
+
const r = el.getBoundingClientRect();
|
|
94
|
+
// Uniqueness probe: how many elements each candidate selector matches. A
|
|
95
|
+
// count > 1 marks the alternative as ambiguous (strict-mode violation) so
|
|
96
|
+
// generateAlternatives drops it. All DOM/CSS access goes through `el` (no
|
|
97
|
+
// DOM lib here).
|
|
98
|
+
const selectorCounts = {};
|
|
99
|
+
try {
|
|
100
|
+
const doc = el.ownerDocument;
|
|
101
|
+
const cssEsc = (s) => doc.defaultView.CSS.escape(s);
|
|
102
|
+
const count = (sel) => {
|
|
103
|
+
try {
|
|
104
|
+
return doc.querySelectorAll(sel).length;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
if (attrMap['data-testid']) {
|
|
111
|
+
selectorCounts.testId = count(`[data-testid=${JSON.stringify(attrMap['data-testid'])}]`);
|
|
112
|
+
}
|
|
113
|
+
if (attrMap['id'])
|
|
114
|
+
selectorCounts.id = count(`#${cssEsc(attrMap['id'])}`);
|
|
115
|
+
if (attrMap['name'])
|
|
116
|
+
selectorCounts.name = count(`[name=${JSON.stringify(attrMap['name'])}]`);
|
|
117
|
+
const classList = (attrMap['class'] || '')
|
|
118
|
+
.split(/\s+/)
|
|
119
|
+
.filter((c) => c.length > 1)
|
|
120
|
+
.slice(0, 10);
|
|
121
|
+
if (classList.length > 0) {
|
|
122
|
+
const classCounts = {};
|
|
123
|
+
for (const cls of classList) {
|
|
124
|
+
const n = count(`.${cssEsc(cls)}`);
|
|
125
|
+
if (n !== undefined)
|
|
126
|
+
classCounts[cls] = n;
|
|
127
|
+
}
|
|
128
|
+
selectorCounts.classes = classCounts;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Uniqueness probing is best-effort — never fail the capture.
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
tagName: el.tagName?.toLowerCase?.() ?? 'unknown',
|
|
136
|
+
attributes: attrMap,
|
|
137
|
+
// Collapse whitespace so multi-line text can't produce a getByText
|
|
138
|
+
// suggestion with literal newlines in it.
|
|
139
|
+
textContent: (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
|
|
140
|
+
center: {
|
|
141
|
+
x: Math.round(r.x + r.width / 2),
|
|
142
|
+
y: Math.round(r.y + r.height / 2),
|
|
143
|
+
},
|
|
144
|
+
hasLabel: !!(el.labels && el.labels.length > 0),
|
|
145
|
+
selectorCounts,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
77
148
|
// Chain methods that take args and define a new locator scope (not just narrow).
|
|
78
149
|
// Origin method/args update to the chain call, e.g. .locator('.item') → locator('.item').
|
|
79
150
|
// Positional/filter chains that narrow but don't change locator identity.
|
|
@@ -118,7 +189,6 @@ function wrapLocator(page, locator, originMethod, originArgs) {
|
|
|
118
189
|
// Push a placeholder immediately — DOM capture runs async below
|
|
119
190
|
sink.capturedLocators.push({
|
|
120
191
|
location: callerLocation,
|
|
121
|
-
stepIndex: seq,
|
|
122
192
|
used,
|
|
123
193
|
element: null,
|
|
124
194
|
alternatives: [],
|
|
@@ -138,28 +208,13 @@ function wrapLocator(page, locator, originMethod, originArgs) {
|
|
|
138
208
|
// evaluate() can hang when page navigates (element detaches), so
|
|
139
209
|
// race it against a 500ms deadline and never throw.
|
|
140
210
|
const resolveAttrs = (async () => {
|
|
211
|
+
let deadline;
|
|
141
212
|
try {
|
|
142
|
-
// `el` is browser-context (no DOM lib in this Node package), so it
|
|
143
|
-
// stays `any`; the callback's return type pins `attrs` to CapturedAttrs.
|
|
144
213
|
const attrs = await Promise.race([
|
|
145
|
-
target.evaluate(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
attrMap[key] = typeof v === 'string' ? v.slice(0, 200) : v ? String(v).slice(0, 200) : null;
|
|
150
|
-
}
|
|
151
|
-
const r = el.getBoundingClientRect();
|
|
152
|
-
return {
|
|
153
|
-
tagName: el.tagName?.toLowerCase?.() ?? 'unknown',
|
|
154
|
-
attributes: attrMap,
|
|
155
|
-
textContent: (el.textContent || '').trim().slice(0, 80),
|
|
156
|
-
center: {
|
|
157
|
-
x: Math.round(r.x + r.width / 2),
|
|
158
|
-
y: Math.round(r.y + r.height / 2),
|
|
159
|
-
},
|
|
160
|
-
};
|
|
161
|
-
}, CAPTURED_ATTRS_ARG),
|
|
162
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error('locator capture timeout')), 500)),
|
|
214
|
+
target.evaluate(probeElementAttrs, CAPTURED_ATTRS_ARG),
|
|
215
|
+
new Promise((_, reject) => {
|
|
216
|
+
deadline = setTimeout(() => reject(new Error('locator capture timeout')), 500);
|
|
217
|
+
}),
|
|
163
218
|
]);
|
|
164
219
|
// The browser-computed accessible name only feeds role-based and
|
|
165
220
|
// form-field alternatives, so only pay for the extra ARIA
|
|
@@ -175,15 +230,25 @@ function wrapLocator(page, locator, originMethod, originArgs) {
|
|
|
175
230
|
const accessibleName = (0, locator_healing_js_1.extractAccessibleName)(aria) || (0, locator_healing_js_1.approximateAccessibleName)({ ...attrs, accessibleName: null });
|
|
176
231
|
sink.capturedLocators[seq] = {
|
|
177
232
|
location: callerLocation,
|
|
178
|
-
stepIndex: seq,
|
|
179
233
|
used,
|
|
180
|
-
|
|
234
|
+
// hasLabel/selectorCounts inform alternative generation only —
|
|
235
|
+
// keep the stored element to the wire shape.
|
|
236
|
+
element: {
|
|
237
|
+
tagName: attrs.tagName,
|
|
238
|
+
attributes: attrs.attributes,
|
|
239
|
+
textContent: attrs.textContent,
|
|
240
|
+
accessibleName,
|
|
241
|
+
center: attrs.center,
|
|
242
|
+
},
|
|
181
243
|
alternatives: (0, locator_healing_js_1.generateAlternatives)({ ...attrs, accessibleName }),
|
|
182
244
|
};
|
|
183
245
|
}
|
|
184
246
|
catch {
|
|
185
247
|
// element detached or timeout — keep the placeholder
|
|
186
248
|
}
|
|
249
|
+
finally {
|
|
250
|
+
clearTimeout(deadline);
|
|
251
|
+
}
|
|
187
252
|
})();
|
|
188
253
|
sink.capturePromises.push(resolveAttrs);
|
|
189
254
|
return result;
|
|
@@ -339,11 +404,21 @@ async function flushSink(sink, testInfo) {
|
|
|
339
404
|
// Cap the drain so a stuck capture (e.g. a navigation in flight) can never
|
|
340
405
|
// hang teardown past the test timeout; per-action evaluate/ariaSnapshot are
|
|
341
406
|
// already bounded, this is a backstop.
|
|
342
|
-
|
|
407
|
+
let drainDeadline;
|
|
408
|
+
await Promise.race([
|
|
409
|
+
Promise.allSettled(sink.capturePromises),
|
|
410
|
+
new Promise((resolve) => {
|
|
411
|
+
drainDeadline = setTimeout(resolve, 2000);
|
|
412
|
+
}),
|
|
413
|
+
]);
|
|
414
|
+
clearTimeout(drainDeadline);
|
|
343
415
|
if (sink.capturedLocators.length > 0) {
|
|
344
416
|
await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.locators, {
|
|
345
417
|
contentType: 'application/json',
|
|
346
|
-
|
|
418
|
+
// Repeated call sites (loops) keep only their latest capture — the
|
|
419
|
+
// server stores one row per location anyway, so shipping every
|
|
420
|
+
// iteration is pure payload bloat.
|
|
421
|
+
body: Buffer.from(JSON.stringify((0, locator_healing_js_1.dedupeSnapshotsByLocation)(sink.capturedLocators))),
|
|
347
422
|
});
|
|
348
423
|
}
|
|
349
424
|
const page = sink.lastActivePage;
|
|
@@ -11,6 +11,18 @@ export interface RankedLocator {
|
|
|
11
11
|
/** 0-100 stability score. data-testid=100, semantic CSS=35-40, hash-suffixed=10. */
|
|
12
12
|
score: number;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Match counts for candidate selectors, probed with `querySelectorAll` against
|
|
16
|
+
* the live page at capture time. A count > 1 means the selector is ambiguous
|
|
17
|
+
* (would be a strict-mode violation) and the alternative is suppressed; a
|
|
18
|
+
* missing count means the probe didn't run and the alternative is kept.
|
|
19
|
+
*/
|
|
20
|
+
export interface SelectorCounts {
|
|
21
|
+
testId?: number;
|
|
22
|
+
id?: number;
|
|
23
|
+
name?: number;
|
|
24
|
+
classes?: Record<string, number>;
|
|
25
|
+
}
|
|
14
26
|
export interface ElementAttributes {
|
|
15
27
|
tagName: string;
|
|
16
28
|
attributes: Record<string, string | null>;
|
|
@@ -20,10 +32,18 @@ export interface ElementAttributes {
|
|
|
20
32
|
x: number;
|
|
21
33
|
y: number;
|
|
22
34
|
} | null;
|
|
35
|
+
/**
|
|
36
|
+
* True when the element has an associated `<label>` (`el.labels`). Gates the
|
|
37
|
+
* `getByLabel` alternative — an accessible name approximated from
|
|
38
|
+
* placeholder/title would produce a `getByLabel` that matches nothing.
|
|
39
|
+
* Undefined on payloads from older capture probes (legacy behavior applies).
|
|
40
|
+
*/
|
|
41
|
+
hasLabel?: boolean;
|
|
42
|
+
/** Live-page uniqueness probe results for candidate selectors. */
|
|
43
|
+
selectorCounts?: SelectorCounts;
|
|
23
44
|
}
|
|
24
45
|
export interface LocatorSnapshot {
|
|
25
46
|
location: string | null;
|
|
26
|
-
stepIndex: number;
|
|
27
47
|
used: {
|
|
28
48
|
method: string;
|
|
29
49
|
args: unknown[];
|
|
@@ -41,6 +61,15 @@ export interface LocatorSnapshot {
|
|
|
41
61
|
} | null;
|
|
42
62
|
alternatives: RankedLocator[];
|
|
43
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Drop repeated captures of the same call site before attaching: a loop (or a
|
|
66
|
+
* page-object method called repeatedly) produces one snapshot per action, but
|
|
67
|
+
* the server keeps only the latest per location anyway. Keeps the last
|
|
68
|
+
* element-bearing snapshot per location — falling back to the last placeholder
|
|
69
|
+
* when no capture resolved, so the location still counts as "seen this run"
|
|
70
|
+
* for the server's stale-location purge. Entries with no location pass through.
|
|
71
|
+
*/
|
|
72
|
+
export declare function dedupeSnapshotsByLocation(snaps: LocatorSnapshot[]): LocatorSnapshot[];
|
|
44
73
|
/**
|
|
45
74
|
* Page-level locator-building methods wrapped by the capture proxy. Imported by
|
|
46
75
|
* both `reporter/src/fixtures.ts` and the dogfooding `application/tests/fixtures.ts`
|
|
@@ -123,6 +152,23 @@ export interface LocatorSuggestion {
|
|
|
123
152
|
/** Fresh locator suggestions for the element's current identity, best first. */
|
|
124
153
|
suggestions: string[];
|
|
125
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Parse `ariaSnapshot()` lines into role/name pairs (mirrors the server-side
|
|
157
|
+
* `parseAriaCandidates` in application/shared/locator-fingerprint.ts).
|
|
158
|
+
* Exported so the dashboard's drift-guard unit test can compare the two.
|
|
159
|
+
*/
|
|
160
|
+
export declare function parseAriaRoleName(ariaSnapshot: string): Array<{
|
|
161
|
+
role: string;
|
|
162
|
+
name: string | null;
|
|
163
|
+
}>;
|
|
164
|
+
/**
|
|
165
|
+
* Token-set (Dice) similarity, 0-1, case- and punctuation-insensitive.
|
|
166
|
+
* Duplicated from `textSimilarity` in application/shared/locator-fingerprint.ts —
|
|
167
|
+
* this package publishes standalone to npm and can't import monorepo-relative
|
|
168
|
+
* shared/ code, so keep the two implementations in sync by hand. Exported so
|
|
169
|
+
* the dashboard's drift-guard unit test can compare the two.
|
|
170
|
+
*/
|
|
171
|
+
export declare function nameSimilarity(a: string | null, b: string | null): number;
|
|
126
172
|
/**
|
|
127
173
|
* Best-effort runtime suggestion for a locator that matched nothing: find the
|
|
128
174
|
* element on the *current* page that the failed locator most likely targeted
|
|
@@ -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
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),
|
|
@@ -395,6 +463,12 @@ function isAutoGenerated(value) {
|
|
|
395
463
|
// Angular-style generated IDs (ng-xxx-N)
|
|
396
464
|
if (value.startsWith('ng-'))
|
|
397
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))
|
|
471
|
+
return true;
|
|
398
472
|
return false;
|
|
399
473
|
}
|
|
400
474
|
// ── Accessible name extraction ───────────────────────────────────────────────
|
|
@@ -448,7 +522,11 @@ const NAME_BASED_METHODS = new Set([
|
|
|
448
522
|
'getByAltText',
|
|
449
523
|
]);
|
|
450
524
|
const escAttr = (s) => s.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
|
|
451
|
-
/**
|
|
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')) {
|
|
@@ -467,7 +545,8 @@ function parseAriaRoleName(ariaSnapshot) {
|
|
|
467
545
|
* Token-set (Dice) similarity, 0-1, case- and punctuation-insensitive.
|
|
468
546
|
* Duplicated from `textSimilarity` in application/shared/locator-fingerprint.ts —
|
|
469
547
|
* this package publishes standalone to npm and can't import monorepo-relative
|
|
470
|
-
* shared/ code, so keep the two implementations in sync by hand.
|
|
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.
|
|
471
550
|
*/
|
|
472
551
|
function nameSimilarity(a, b) {
|
|
473
552
|
const tok = (s) => new Set((s ?? '')
|
|
@@ -66,7 +66,7 @@ export declare class StreamManager {
|
|
|
66
66
|
*/
|
|
67
67
|
constructor(httpClient: HttpClient, streamBuffer: StreamBuffer, recovery: CrashRecovery, uploader: Uploader, fileHandler: FileHandler, options: PiwiDashboardOptions, logger?: Logger);
|
|
68
68
|
/** Begin the streaming session after `onBegin` fires. Non-blocking — the actual handshake runs asynchronously. */
|
|
69
|
-
start(startTime: string, metadata: Record<string, any>, instanceId: string, playwrightVersion?: string | null, shardInfo?: ShardInfo | null, isFullRun?: boolean, filterDetails?: FilterDetails | null): void;
|
|
69
|
+
start(startTime: string, metadata: Record<string, any>, instanceId: string, playwrightVersion?: string | null, reporterVersion?: string | null, shardInfo?: ShardInfo | null, isFullRun?: boolean, filterDetails?: FilterDetails | null): void;
|
|
70
70
|
private _doStart;
|
|
71
71
|
/** Queue a test-case `begin` event. Held in a pre-start buffer if the stream is not yet open, then prepended so it arrives before the matching `complete` event. */
|
|
72
72
|
queueBeginEvent(event: StreamEvent): void;
|
|
@@ -111,10 +111,10 @@ class StreamManager {
|
|
|
111
111
|
this._startPromise = null;
|
|
112
112
|
}
|
|
113
113
|
/** Begin the streaming session after `onBegin` fires. Non-blocking — the actual handshake runs asynchronously. */
|
|
114
|
-
start(startTime, metadata, instanceId, playwrightVersion, shardInfo, isFullRun, filterDetails) {
|
|
115
|
-
this._startPromise = this._doStart(startTime, metadata, instanceId, playwrightVersion, shardInfo, isFullRun, filterDetails);
|
|
114
|
+
start(startTime, metadata, instanceId, playwrightVersion, reporterVersion, shardInfo, isFullRun, filterDetails) {
|
|
115
|
+
this._startPromise = this._doStart(startTime, metadata, instanceId, playwrightVersion, reporterVersion, shardInfo, isFullRun, filterDetails);
|
|
116
116
|
}
|
|
117
|
-
async _doStart(startTime, metadata, instanceId, playwrightVersion, shardInfo, isFullRun, filterDetails) {
|
|
117
|
+
async _doStart(startTime, metadata, instanceId, playwrightVersion, reporterVersion, shardInfo, isFullRun, filterDetails) {
|
|
118
118
|
const setupInfo = (0, setup_file_js_1.readSetupInfo)(this.options.projectName);
|
|
119
119
|
try {
|
|
120
120
|
this._auth = await this.httpClient.resolveAuth(this.options);
|
|
@@ -129,6 +129,7 @@ class StreamManager {
|
|
|
129
129
|
totalTests: 0,
|
|
130
130
|
metadata,
|
|
131
131
|
playwrightVersion,
|
|
132
|
+
reporterVersion,
|
|
132
133
|
shardIndex,
|
|
133
134
|
shardTotal,
|
|
134
135
|
isFullRun,
|
|
@@ -150,6 +151,7 @@ class StreamManager {
|
|
|
150
151
|
metadata,
|
|
151
152
|
instanceId,
|
|
152
153
|
playwrightVersion,
|
|
154
|
+
reporterVersion,
|
|
153
155
|
shardIndex,
|
|
154
156
|
shardTotal,
|
|
155
157
|
isFullRun,
|
|
@@ -167,6 +169,7 @@ class StreamManager {
|
|
|
167
169
|
metadata,
|
|
168
170
|
instanceId,
|
|
169
171
|
playwrightVersion,
|
|
172
|
+
reporterVersion,
|
|
170
173
|
shardIndex,
|
|
171
174
|
shardTotal,
|
|
172
175
|
isFullRun,
|
|
@@ -97,6 +97,7 @@ class RunSubmitter {
|
|
|
97
97
|
metadata: run.metadata,
|
|
98
98
|
instanceId: run.instanceId,
|
|
99
99
|
playwrightVersion: run.playwrightVersion ?? undefined,
|
|
100
|
+
reporterVersion: run.reporterVersion ?? undefined,
|
|
100
101
|
testCases: run.testCases,
|
|
101
102
|
shardIndex: run.shardInfo?.current,
|
|
102
103
|
shardTotal: run.shardInfo?.total,
|
|
@@ -126,6 +127,7 @@ class RunSubmitter {
|
|
|
126
127
|
metadata: run.metadata,
|
|
127
128
|
hasPendingUploads: this.hasReports(run),
|
|
128
129
|
playwrightVersion: run.playwrightVersion ?? undefined,
|
|
130
|
+
reporterVersion: run.reporterVersion ?? undefined,
|
|
129
131
|
setupSteps: run.setupSteps.length > 0 ? run.setupSteps : undefined,
|
|
130
132
|
isFullRun: run.isFullRun,
|
|
131
133
|
filterDetails: run.filterDetails ?? null,
|
|
@@ -94,6 +94,7 @@ function serializeRun(payload, opts) {
|
|
|
94
94
|
metadata: payload.metadata,
|
|
95
95
|
instanceId: payload.instanceId,
|
|
96
96
|
playwrightVersion: payload.playwrightVersion,
|
|
97
|
+
reporterVersion: payload.reporterVersion,
|
|
97
98
|
shardIndex: payload.shardIndex,
|
|
98
99
|
shardTotal: payload.shardTotal,
|
|
99
100
|
isFullRun: payload.isFullRun ?? true,
|
|
@@ -33,6 +33,8 @@ export interface RunPayload {
|
|
|
33
33
|
testCases: CollectedTestCase[];
|
|
34
34
|
/** Playwright framework version used for this run */
|
|
35
35
|
playwrightVersion?: string;
|
|
36
|
+
/** Piwi reporter package version that produced this run */
|
|
37
|
+
reporterVersion?: string;
|
|
36
38
|
/** 1-based shard index (e.g. 1, 2, 3) */
|
|
37
39
|
shardIndex?: number;
|
|
38
40
|
/** Total number of shards (e.g. 3) */
|
|
@@ -0,0 +1,54 @@
|
|
|
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.getReporterVersion = getReporterVersion;
|
|
37
|
+
const fs = __importStar(require("node:fs"));
|
|
38
|
+
const path = __importStar(require("node:path"));
|
|
39
|
+
let cachedVersion = null;
|
|
40
|
+
/** Read the reporter package's own version from `package.json`, memoized. Falls back to `'unknown'` if it can't be read (e.g. an unusual install layout). */
|
|
41
|
+
function getReporterVersion() {
|
|
42
|
+
if (cachedVersion)
|
|
43
|
+
return cachedVersion;
|
|
44
|
+
try {
|
|
45
|
+
const pkgPath = path.resolve(__dirname, '../../../package.json');
|
|
46
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
47
|
+
const version = pkg.version;
|
|
48
|
+
cachedVersion = typeof version === 'string' ? version : 'unknown';
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
cachedVersion = 'unknown';
|
|
52
|
+
}
|
|
53
|
+
return cachedVersion;
|
|
54
|
+
}
|
package/dist/public/reporter.js
CHANGED
|
@@ -46,6 +46,7 @@ const metadata_collector_js_1 = require("../internal/collect/metadata-collector.
|
|
|
46
46
|
const stream_manager_js_1 = require("../internal/streaming/stream-manager.js");
|
|
47
47
|
const step_analyzer_js_1 = require("../internal/collect/step-analyzer.js");
|
|
48
48
|
const instance_id_js_1 = require("../internal/support/instance-id.js");
|
|
49
|
+
const reporter_version_js_1 = require("../internal/support/reporter-version.js");
|
|
49
50
|
const source_snippet_js_1 = require("../internal/support/source-snippet.js");
|
|
50
51
|
const ci_js_1 = require("../internal/support/ci.js");
|
|
51
52
|
const worker_index_js_1 = require("../internal/support/worker-index.js");
|
|
@@ -79,6 +80,7 @@ class PiwiDashboardReporter {
|
|
|
79
80
|
this.testCases = [];
|
|
80
81
|
this.startTime = null;
|
|
81
82
|
this.playwrightVersion = null;
|
|
83
|
+
this.reporterVersion = (0, reporter_version_js_1.getReporterVersion)();
|
|
82
84
|
this.totalTests = 0;
|
|
83
85
|
this.passedTests = 0;
|
|
84
86
|
this.failedTests = 0;
|
|
@@ -154,7 +156,7 @@ class PiwiDashboardReporter {
|
|
|
154
156
|
this.shardInfo = { current: pwShard.current, total: pwShard.total };
|
|
155
157
|
this.logger.info(`Shard ${this.shardInfo.current}/${this.shardInfo.total} detected`);
|
|
156
158
|
}
|
|
157
|
-
this.streamManager?.start(this.startTime, this.metadata, this.instanceId, this.playwrightVersion, this.shardInfo, this.isFullRun, this.filterDetails);
|
|
159
|
+
this.streamManager?.start(this.startTime, this.metadata, this.instanceId, this.playwrightVersion, this.reporterVersion, this.shardInfo, this.isFullRun, this.filterDetails);
|
|
158
160
|
}
|
|
159
161
|
/** Playwright reporter hook: called when an individual test begins */
|
|
160
162
|
onTestBegin(test, result) {
|
|
@@ -353,6 +355,7 @@ class PiwiDashboardReporter {
|
|
|
353
355
|
testCases: this.testCases,
|
|
354
356
|
startTime: this.startTime,
|
|
355
357
|
playwrightVersion: this.playwrightVersion,
|
|
358
|
+
reporterVersion: this.reporterVersion,
|
|
356
359
|
totalTests: this.totalTests,
|
|
357
360
|
passedTests: this.passedTests,
|
|
358
361
|
failedTests: this.failedTests,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@piwitests/reporter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Playwright reporter for sending test results to Piwi Dashboard",
|
|
5
5
|
"url": "https://github.com/PiwiTests/platform",
|
|
6
6
|
"homepage": "https://piwitests.github.io",
|
|
@@ -41,7 +41,9 @@
|
|
|
41
41
|
"reporter:lint": "oxlint --config oxlint.config.mts .",
|
|
42
42
|
"reporter:lint:fix": "oxlint --config oxlint.config.mts . --fix",
|
|
43
43
|
"reporter:test": "vitest run",
|
|
44
|
+
"reporter:test:coverage": "vitest run --coverage",
|
|
44
45
|
"reporter:test:watch": "vitest",
|
|
46
|
+
"reporter:test:integration": "npm run reporter:build && playwright test --config=tests/integration/playwright.config.ts",
|
|
45
47
|
"test": "npm run reporter:test",
|
|
46
48
|
"prepublishOnly": "npm run reporter:build"
|
|
47
49
|
},
|