@piwitests/reporter 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/{fixtures.d.ts → internal/capture/capture-fixtures.d.ts} +5 -3
- package/dist/internal/capture/capture-fixtures.js +495 -0
- package/dist/{locator-healing.js → internal/capture/locator-healing.js} +11 -6
- 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.js +0 -336
- package/dist/helpers.d.ts +0 -44
- package/dist/helpers.js +0 -312
- /package/dist/{locator-healing.d.ts → internal/capture/locator-healing.d.ts} +0 -0
- /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
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.dashboardFixtures = void 0;
|
|
4
|
+
exports.extendDashboardFixtures = extendDashboardFixtures;
|
|
5
|
+
const node_zlib_1 = require("node:zlib");
|
|
6
|
+
const locator_healing_js_1 = require("./locator-healing.js");
|
|
7
|
+
const attachments_js_1 = require("./attachments.js");
|
|
8
|
+
function createSink() {
|
|
9
|
+
return {
|
|
10
|
+
networkRequests: [],
|
|
11
|
+
consoleEntries: [],
|
|
12
|
+
pendingHandlers: [],
|
|
13
|
+
capturedLocators: [],
|
|
14
|
+
capturePromises: [],
|
|
15
|
+
failedLocators: [],
|
|
16
|
+
lastActivePage: null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The capture sink for the test currently running in this worker. Playwright
|
|
21
|
+
* runs a worker's tests sequentially, so a single "current" sink is unambiguous.
|
|
22
|
+
* It is null between tests and during `beforeAll`/`afterAll` — activity outside a
|
|
23
|
+
* test (auth setup, teardown) is intentionally not captured.
|
|
24
|
+
*/
|
|
25
|
+
let currentSink = null;
|
|
26
|
+
// Idempotency guards: a page/context/browser can be reached through several
|
|
27
|
+
// paths (browser patch, context patch, the `page` fixture, popup events), and
|
|
28
|
+
// must be wrapped exactly once.
|
|
29
|
+
const INSTRUMENTED_PAGES = new WeakSet();
|
|
30
|
+
const INSTRUMENTED_CONTEXTS = new WeakSet();
|
|
31
|
+
const PATCHED_BROWSERS = new WeakSet();
|
|
32
|
+
// O(1) membership for the proxy `get` trap below, which runs on every property
|
|
33
|
+
// access of every wrapped locator. The source arrays from locator-healing are
|
|
34
|
+
// turned into Sets once here.
|
|
35
|
+
const CHAIN_METHOD_SET = new Set(locator_healing_js_1.CHAIN_METHODS);
|
|
36
|
+
const ACTION_METHOD_SET = new Set(locator_healing_js_1.ACTION_METHODS);
|
|
37
|
+
const FORM_FIELD_TAGS = new Set(['input', 'select', 'textarea']);
|
|
38
|
+
// CAPTURED_ATTRIBUTES is passed verbatim into evaluate() on every action — copy
|
|
39
|
+
// it once instead of spreading a fresh array per call.
|
|
40
|
+
const CAPTURED_ATTRS_ARG = [...locator_healing_js_1.CAPTURED_ATTRIBUTES];
|
|
41
|
+
/**
|
|
42
|
+
* ARIA snapshot that tolerates every Playwright version the reporter supports,
|
|
43
|
+
* returning null instead of throwing so a capture can never fail the test. The
|
|
44
|
+
* options validator runs client-side in the user's installed Playwright, so an
|
|
45
|
+
* unsupported key surfaces as a rejected promise (not a synchronous throw):
|
|
46
|
+
* - ≥ 1.59: `mode: 'ai'` yields the ref-annotated AI snapshot (the ideal);
|
|
47
|
+
* `ref` is unknown and silently stripped.
|
|
48
|
+
* - 1.52: `mode: 'ai'` fails validation (only 'raw'/'regex' are accepted) and
|
|
49
|
+
* rejects; the `{ ref: true }` fallback then yields a ref-annotated snapshot.
|
|
50
|
+
* - 1.53–1.58: neither `ref` nor `mode` exists — unknown keys are stripped
|
|
51
|
+
* server-side, so the first call already returns a plain flat snapshot.
|
|
52
|
+
* - < 1.49: `locator.ariaSnapshot` does not exist — returns null up front.
|
|
53
|
+
*/
|
|
54
|
+
async function ariaSnapshotBestEffort(target, timeout) {
|
|
55
|
+
// Playwright < 1.49 predates locator.ariaSnapshot entirely.
|
|
56
|
+
if (typeof target.ariaSnapshot !== 'function')
|
|
57
|
+
return null;
|
|
58
|
+
try {
|
|
59
|
+
return await target.ariaSnapshot({
|
|
60
|
+
...(timeout != null ? { timeout } : {}),
|
|
61
|
+
mode: 'ai',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// 1.52 rejects `mode: 'ai'`; retry with the `ref` flag that release accepts.
|
|
66
|
+
try {
|
|
67
|
+
return await target.ariaSnapshot({
|
|
68
|
+
...(timeout != null ? { timeout } : {}),
|
|
69
|
+
ref: true,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Chain methods that take args and define a new locator scope (not just narrow).
|
|
78
|
+
// Origin method/args update to the chain call, e.g. .locator('.item') → locator('.item').
|
|
79
|
+
// Positional/filter chains that narrow but don't change locator identity.
|
|
80
|
+
// Origin stays from the page-level call, e.g. .first(), .nth(2), .filter(...).
|
|
81
|
+
function wrapLocator(page, locator, originMethod, originArgs) {
|
|
82
|
+
return new Proxy(locator, {
|
|
83
|
+
get(target, prop) {
|
|
84
|
+
const original = Reflect.get(target, prop);
|
|
85
|
+
if (typeof original !== 'function')
|
|
86
|
+
return original;
|
|
87
|
+
const fn = original;
|
|
88
|
+
if (CHAIN_METHOD_SET.has(prop)) {
|
|
89
|
+
return (...args) => {
|
|
90
|
+
const next = fn.apply(target, args);
|
|
91
|
+
if (locator_healing_js_1.LOCATOR_CREATING_CHAINS.has(prop)) {
|
|
92
|
+
return wrapLocator(page, next, String(prop), args);
|
|
93
|
+
}
|
|
94
|
+
return wrapLocator(page, next, originMethod, originArgs);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (!ACTION_METHOD_SET.has(prop))
|
|
98
|
+
return original;
|
|
99
|
+
return async (...callArgs) => {
|
|
100
|
+
// Bind to the sink active when the action starts so the async element
|
|
101
|
+
// capture below writes back to the right test even across a boundary.
|
|
102
|
+
const sink = currentSink;
|
|
103
|
+
// Action outside a tracked test (e.g. during beforeAll) — run untouched.
|
|
104
|
+
if (!sink)
|
|
105
|
+
return fn.apply(target, callArgs);
|
|
106
|
+
sink.lastActivePage = page;
|
|
107
|
+
const seq = sink.capturedLocators.length;
|
|
108
|
+
// Capture the test call-site now (sync) so the snapshot's location
|
|
109
|
+
// matches the error stack's first user frame — independent of
|
|
110
|
+
// pw:api step ordering, worker interleaving, or concurrent actions.
|
|
111
|
+
const callerLocation = (0, locator_healing_js_1.captureCallerLocation)();
|
|
112
|
+
// Built once, shared by the placeholder and the resolved snapshot below.
|
|
113
|
+
const used = {
|
|
114
|
+
method: originMethod,
|
|
115
|
+
args: originArgs,
|
|
116
|
+
raw: `${originMethod}(${JSON.stringify(originArgs)})`,
|
|
117
|
+
};
|
|
118
|
+
// Push a placeholder immediately — DOM capture runs async below
|
|
119
|
+
sink.capturedLocators.push({
|
|
120
|
+
location: callerLocation,
|
|
121
|
+
stepIndex: seq,
|
|
122
|
+
used,
|
|
123
|
+
element: null,
|
|
124
|
+
alternatives: [],
|
|
125
|
+
});
|
|
126
|
+
// The placeholder is already pushed. If the action throws, record the
|
|
127
|
+
// failed locator (so teardown can suggest a fresh one for the element's
|
|
128
|
+
// current identity) and re-throw so the test still fails.
|
|
129
|
+
let result;
|
|
130
|
+
try {
|
|
131
|
+
result = await fn.apply(target, callArgs);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
sink.failedLocators.push({ method: originMethod, args: originArgs });
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
// Fire-and-forget: capture element data without blocking the test.
|
|
138
|
+
// evaluate() can hang when page navigates (element detaches), so
|
|
139
|
+
// race it against a 500ms deadline and never throw.
|
|
140
|
+
const resolveAttrs = (async () => {
|
|
141
|
+
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
|
+
const attrs = await Promise.race([
|
|
145
|
+
target.evaluate((el, keep) => {
|
|
146
|
+
const attrMap = {};
|
|
147
|
+
for (const key of keep) {
|
|
148
|
+
const v = el.getAttribute(key) ?? el[key];
|
|
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)),
|
|
163
|
+
]);
|
|
164
|
+
// The browser-computed accessible name only feeds role-based and
|
|
165
|
+
// form-field alternatives, so only pay for the extra ARIA
|
|
166
|
+
// snapshot when the element actually has a role or is a field.
|
|
167
|
+
const role = (0, locator_healing_js_1.resolveAriaRole)({ ...attrs, accessibleName: null });
|
|
168
|
+
const isFormField = FORM_FIELD_TAGS.has(attrs.tagName);
|
|
169
|
+
// Bound with a timeout: without it, ariaSnapshot waits up to the
|
|
170
|
+
// test timeout when the page is mid-navigation, which hangs the
|
|
171
|
+
// teardown that drains these capture promises.
|
|
172
|
+
// ariaSnapshotBestEffort adapts the options to the installed
|
|
173
|
+
// Playwright version and never throws (see its doc comment).
|
|
174
|
+
const aria = role || isFormField ? await ariaSnapshotBestEffort(target, 500) : null;
|
|
175
|
+
const accessibleName = (0, locator_healing_js_1.extractAccessibleName)(aria) || (0, locator_healing_js_1.approximateAccessibleName)({ ...attrs, accessibleName: null });
|
|
176
|
+
sink.capturedLocators[seq] = {
|
|
177
|
+
location: callerLocation,
|
|
178
|
+
stepIndex: seq,
|
|
179
|
+
used,
|
|
180
|
+
element: { ...attrs, accessibleName },
|
|
181
|
+
alternatives: (0, locator_healing_js_1.generateAlternatives)({ ...attrs, accessibleName }),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// element detached or timeout — keep the placeholder
|
|
186
|
+
}
|
|
187
|
+
})();
|
|
188
|
+
sink.capturePromises.push(resolveAttrs);
|
|
189
|
+
return result;
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Instrument a single page: wrap its locator-building methods for healing
|
|
196
|
+
* capture and attach console/network listeners. Idempotent — safe to call on a
|
|
197
|
+
* page already reached through another path (browser patch, `page` fixture).
|
|
198
|
+
*/
|
|
199
|
+
function instrumentPage(page) {
|
|
200
|
+
if (!page || INSTRUMENTED_PAGES.has(page))
|
|
201
|
+
return;
|
|
202
|
+
INSTRUMENTED_PAGES.add(page);
|
|
203
|
+
// Opt-out: skipped when PIWI_CAPTURE_LOCATORS=false (set automatically when
|
|
204
|
+
// the reporter's collectPerformanceMetrics / captureLocators is disabled),
|
|
205
|
+
// so the per-action DOM read + ARIA snapshot cost is never paid when unused.
|
|
206
|
+
const captureLocators = process.env.PIWI_CAPTURE_LOCATORS !== 'false';
|
|
207
|
+
if (captureLocators) {
|
|
208
|
+
// Locator factories are accessed by dynamic name, so a string-indexed view
|
|
209
|
+
// of the page is the cleanest local escape hatch from the static surface.
|
|
210
|
+
const factories = page;
|
|
211
|
+
for (const method of locator_healing_js_1.LOCATOR_METHODS) {
|
|
212
|
+
const original = factories[method].bind(page);
|
|
213
|
+
factories[method] = (...args) => {
|
|
214
|
+
// Touching a page's locator factory marks it the active page even for
|
|
215
|
+
// assertion-only tests that never call an action method.
|
|
216
|
+
if (currentSink)
|
|
217
|
+
currentSink.lastActivePage = page;
|
|
218
|
+
return wrapLocator(page, original(...args), method, args);
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
page.on('console', (msg) => {
|
|
223
|
+
const sink = currentSink;
|
|
224
|
+
if (!sink)
|
|
225
|
+
return;
|
|
226
|
+
const type = msg.type();
|
|
227
|
+
if (['warning', 'error', 'assert'].includes(type)) {
|
|
228
|
+
const location = msg.location();
|
|
229
|
+
sink.consoleEntries.push({
|
|
230
|
+
type,
|
|
231
|
+
text: msg.text(),
|
|
232
|
+
timestamp: Date.now(),
|
|
233
|
+
location: location ? `${location.url}:${location.lineNumber}:${location.columnNumber}` : null,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
page.on('requestfinished', (request) => {
|
|
238
|
+
const sink = currentSink;
|
|
239
|
+
if (!sink)
|
|
240
|
+
return;
|
|
241
|
+
sink.lastActivePage = page;
|
|
242
|
+
const p = (async () => {
|
|
243
|
+
try {
|
|
244
|
+
const url = request.url();
|
|
245
|
+
if (url.startsWith('data:') || url.startsWith('blob:'))
|
|
246
|
+
return;
|
|
247
|
+
const timing = request.timing();
|
|
248
|
+
const response = await request.response();
|
|
249
|
+
const resourceType = request.resourceType();
|
|
250
|
+
// Only keep API/document requests; skip static assets (scripts, styles, fonts, images, media)
|
|
251
|
+
if (!['fetch', 'xhr', 'document', 'other'].includes(resourceType))
|
|
252
|
+
return;
|
|
253
|
+
const entry = {
|
|
254
|
+
method: request.method(),
|
|
255
|
+
url,
|
|
256
|
+
status: response ? response.status() : 0,
|
|
257
|
+
duration: timing.responseEnd > 0 ? Math.round(timing.responseEnd - timing.requestStart) : 0,
|
|
258
|
+
startTime: timing.startTime,
|
|
259
|
+
resourceType,
|
|
260
|
+
};
|
|
261
|
+
if (response) {
|
|
262
|
+
const headers = response.headers();
|
|
263
|
+
// Response content type (without charset/boundary params) — relevant
|
|
264
|
+
// per-request metadata for distinguishing API/JSON vs document/HTML calls.
|
|
265
|
+
const contentType = headers['content-type'];
|
|
266
|
+
if (contentType)
|
|
267
|
+
entry.contentType = contentType.split(';')[0].trim();
|
|
268
|
+
const logHeader = headers['x-piwi-logs'];
|
|
269
|
+
if (logHeader) {
|
|
270
|
+
try {
|
|
271
|
+
entry.serverLogs = JSON.parse((0, node_zlib_1.gunzipSync)(Buffer.from(logHeader, 'base64')).toString('utf-8'));
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
/* ignore malformed header */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
sink.networkRequests.push(entry);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
/* ignore */
|
|
282
|
+
}
|
|
283
|
+
})();
|
|
284
|
+
sink.pendingHandlers.push(p);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Instrument a browser context so every page it opens — via `newPage()` or as a
|
|
289
|
+
* popup/`window.open` — is captured. Idempotent.
|
|
290
|
+
*/
|
|
291
|
+
function instrumentContext(context) {
|
|
292
|
+
if (!context || INSTRUMENTED_CONTEXTS.has(context))
|
|
293
|
+
return;
|
|
294
|
+
INSTRUMENTED_CONTEXTS.add(context);
|
|
295
|
+
const originalNewPage = context.newPage.bind(context);
|
|
296
|
+
context.newPage = async (...args) => {
|
|
297
|
+
const page = await originalNewPage(...args);
|
|
298
|
+
instrumentPage(page);
|
|
299
|
+
return page;
|
|
300
|
+
};
|
|
301
|
+
// Popups and pages the context opens on its own (idempotent with the above).
|
|
302
|
+
context.on('page', (page) => instrumentPage(page));
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Patch a browser so any page or context created directly from it is
|
|
306
|
+
* instrumented. This is what makes capture work for suites that build their own
|
|
307
|
+
* pages from the worker-scoped `browser` (e.g. `browser.newPage()` /
|
|
308
|
+
* `browser.newContext().newPage()`) instead of using the `page` fixture.
|
|
309
|
+
* Idempotent.
|
|
310
|
+
*/
|
|
311
|
+
function patchBrowser(browser) {
|
|
312
|
+
if (!browser || PATCHED_BROWSERS.has(browser))
|
|
313
|
+
return;
|
|
314
|
+
PATCHED_BROWSERS.add(browser);
|
|
315
|
+
const originalNewPage = browser.newPage.bind(browser);
|
|
316
|
+
browser.newPage = async (...args) => {
|
|
317
|
+
const page = await originalNewPage(...args);
|
|
318
|
+
instrumentPage(page);
|
|
319
|
+
return page;
|
|
320
|
+
};
|
|
321
|
+
const originalNewContext = browser.newContext.bind(browser);
|
|
322
|
+
browser.newContext = async (...args) => {
|
|
323
|
+
const context = await originalNewContext(...args);
|
|
324
|
+
instrumentContext(context);
|
|
325
|
+
return context;
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Drain in-flight capture work and attach the collected `piwi-*` data
|
|
330
|
+
* to the test. Mirrors the per-test teardown the `page` fixture used to do, but
|
|
331
|
+
* sourced from the sink so it works regardless of how the test's pages were made.
|
|
332
|
+
*/
|
|
333
|
+
async function flushSink(sink, testInfo) {
|
|
334
|
+
// Wait for all in-flight requestfinished handlers before snapshotting
|
|
335
|
+
// networkRequests — the last request (often the one that failed the test)
|
|
336
|
+
// races with teardown and its serverLogs would otherwise be lost.
|
|
337
|
+
await Promise.allSettled(sink.pendingHandlers);
|
|
338
|
+
// ── Attach locator snapshots ──────────────────────────────────────────
|
|
339
|
+
// Cap the drain so a stuck capture (e.g. a navigation in flight) can never
|
|
340
|
+
// hang teardown past the test timeout; per-action evaluate/ariaSnapshot are
|
|
341
|
+
// already bounded, this is a backstop.
|
|
342
|
+
await Promise.race([Promise.allSettled(sink.capturePromises), new Promise((resolve) => setTimeout(resolve, 2000))]);
|
|
343
|
+
if (sink.capturedLocators.length > 0) {
|
|
344
|
+
await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.locators, {
|
|
345
|
+
contentType: 'application/json',
|
|
346
|
+
body: Buffer.from(JSON.stringify(sink.capturedLocators)),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
const page = sink.lastActivePage;
|
|
350
|
+
if (page && testInfo.status !== 'passed' && testInfo.status !== 'skipped') {
|
|
351
|
+
try {
|
|
352
|
+
const snapshot = await ariaSnapshotBestEffort(page.locator(':root'));
|
|
353
|
+
if (snapshot) {
|
|
354
|
+
await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.ariaSnapshot, {
|
|
355
|
+
contentType: 'text/plain',
|
|
356
|
+
body: snapshot,
|
|
357
|
+
});
|
|
358
|
+
// Suggest a fresh locator for the failed action from the current page.
|
|
359
|
+
// When the element was renamed/moved, the pre-captured alternatives
|
|
360
|
+
// describe the old element, so this points at where it went now — as a
|
|
361
|
+
// Playwright annotation (shown in the report + trace) and an attachment
|
|
362
|
+
// (shown in the trace viewer). It does NOT change the locator.
|
|
363
|
+
const failed = sink.failedLocators[sink.failedLocators.length - 1];
|
|
364
|
+
const suggestion = failed ? (0, locator_healing_js_1.suggestLocatorsFromAria)(failed, snapshot) : null;
|
|
365
|
+
if (suggestion) {
|
|
366
|
+
testInfo.annotations.push({
|
|
367
|
+
type: attachments_js_1.LOCATOR_SUGGESTION_ANNOTATION,
|
|
368
|
+
description: `${suggestion.failing} matched nothing on the failing page — the element may have been renamed or moved. Suggested: ${suggestion.suggestions.join(' | ')}`,
|
|
369
|
+
});
|
|
370
|
+
await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.locatorSuggestion, {
|
|
371
|
+
contentType: 'application/json',
|
|
372
|
+
body: Buffer.from(JSON.stringify(suggestion)),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
/* ignore */
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (sink.consoleEntries.length > 0) {
|
|
382
|
+
await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.console, {
|
|
383
|
+
contentType: 'application/json',
|
|
384
|
+
body: Buffer.from(JSON.stringify(sink.consoleEntries)),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (sink.networkRequests.length > 0) {
|
|
388
|
+
await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.network, {
|
|
389
|
+
contentType: 'application/json',
|
|
390
|
+
body: Buffer.from(JSON.stringify(sink.networkRequests)),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
if (page) {
|
|
394
|
+
try {
|
|
395
|
+
// Runs in the browser, so the perf-entry reads stay `any` (no DOM lib);
|
|
396
|
+
// the callback return type pins `webVitals` to WebVitals.
|
|
397
|
+
const webVitals = await page.evaluate(() => {
|
|
398
|
+
const navEntries = performance.getEntriesByType('navigation');
|
|
399
|
+
const paintEntries = performance.getEntriesByType('paint');
|
|
400
|
+
const nav = navEntries[0];
|
|
401
|
+
const navigation = nav
|
|
402
|
+
? {
|
|
403
|
+
url: nav.name,
|
|
404
|
+
ttfb: Math.round(nav.responseStart - nav.fetchStart),
|
|
405
|
+
domInteractive: Math.round(nav.domInteractive - nav.fetchStart),
|
|
406
|
+
domContentLoaded: Math.round(nav.domContentLoadedEventEnd - nav.fetchStart),
|
|
407
|
+
loadComplete: Math.round(nav.loadEventEnd - nav.fetchStart),
|
|
408
|
+
transferSize: nav.transferSize || 0,
|
|
409
|
+
encodedBodySize: nav.encodedBodySize || 0,
|
|
410
|
+
decodedBodySize: nav.decodedBodySize || 0,
|
|
411
|
+
}
|
|
412
|
+
: null;
|
|
413
|
+
const paint = {};
|
|
414
|
+
for (const entry of paintEntries) {
|
|
415
|
+
const key = entry.name.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
|
|
416
|
+
paint[key] = Math.round(entry.startTime);
|
|
417
|
+
}
|
|
418
|
+
if (!navigation && Object.keys(paint).length === 0)
|
|
419
|
+
return null;
|
|
420
|
+
return { navigation, paint };
|
|
421
|
+
});
|
|
422
|
+
if (webVitals) {
|
|
423
|
+
await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.webVitals, {
|
|
424
|
+
contentType: 'application/json',
|
|
425
|
+
body: Buffer.from(JSON.stringify(webVitals)),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
/* ignore */
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Playwright fixtures that collect network requests, console entries,
|
|
436
|
+
* web vitals, ARIA snapshots, and locator interaction data during a test.
|
|
437
|
+
*
|
|
438
|
+
* Capture is wired at the `browser` level, so it works whether a test uses the
|
|
439
|
+
* standard `page` fixture or builds its own pages from `browser` /
|
|
440
|
+
* `browser.newContext()`. Collected data is attached as `piwi-*`
|
|
441
|
+
* test-info attachments which the Piwi Dashboard reporter parses on `onTestEnd`.
|
|
442
|
+
*/
|
|
443
|
+
exports.dashboardFixtures = {
|
|
444
|
+
// Worker-scoped: patch the shared browser so every page/context created from
|
|
445
|
+
// it — including by user fixtures that take `browser` directly — is captured.
|
|
446
|
+
browser: [
|
|
447
|
+
async ({ browser }, use) => {
|
|
448
|
+
patchBrowser(browser);
|
|
449
|
+
await use(browser);
|
|
450
|
+
},
|
|
451
|
+
{ scope: 'worker' },
|
|
452
|
+
],
|
|
453
|
+
// Safety net for the standard `page` fixture, in case its page is created
|
|
454
|
+
// through an internal path the browser patch doesn't see. Idempotent, so it
|
|
455
|
+
// never double-wraps a page the browser patch already instrumented.
|
|
456
|
+
page: async ({ page }, use) => {
|
|
457
|
+
instrumentPage(page);
|
|
458
|
+
await use(page);
|
|
459
|
+
},
|
|
460
|
+
// Auto, test-scoped: open a capture sink for the running test and flush it
|
|
461
|
+
// (attach the collected data) at teardown. Runs for every test without being
|
|
462
|
+
// requested, so suites that never destructure `page` are still captured.
|
|
463
|
+
piwiDashboardCapture: [
|
|
464
|
+
async ({}, use, testInfo) => {
|
|
465
|
+
const sink = createSink();
|
|
466
|
+
currentSink = sink;
|
|
467
|
+
try {
|
|
468
|
+
await use();
|
|
469
|
+
}
|
|
470
|
+
finally {
|
|
471
|
+
currentSink = null;
|
|
472
|
+
await flushSink(sink, testInfo);
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
{ auto: true },
|
|
476
|
+
],
|
|
477
|
+
};
|
|
478
|
+
/**
|
|
479
|
+
* Extend a Playwright `test` object with Piwi Dashboard fixtures.
|
|
480
|
+
*
|
|
481
|
+
* Use this instead of importing `@playwright/test` directly from this package
|
|
482
|
+
* to avoid the "Requiring @playwright/test second time" error caused by
|
|
483
|
+
* duplicate module resolution.
|
|
484
|
+
*
|
|
485
|
+
* @example
|
|
486
|
+
* ```ts
|
|
487
|
+
* import { test as base } from '@playwright/test';
|
|
488
|
+
* import { extendDashboardFixtures } from '@piwitests/reporter';
|
|
489
|
+
*
|
|
490
|
+
* export const test = extendDashboardFixtures(base);
|
|
491
|
+
* ```
|
|
492
|
+
*/
|
|
493
|
+
function extendDashboardFixtures(test) {
|
|
494
|
+
return test.extend(exports.dashboardFixtures);
|
|
495
|
+
}
|
|
@@ -42,7 +42,7 @@ exports.extractAccessibleName = extractAccessibleName;
|
|
|
42
42
|
exports.approximateAccessibleName = approximateAccessibleName;
|
|
43
43
|
exports.suggestLocatorsFromAria = suggestLocatorsFromAria;
|
|
44
44
|
exports.captureCallerLocation = captureCallerLocation;
|
|
45
|
-
const path = __importStar(require("path"));
|
|
45
|
+
const path = __importStar(require("node:path"));
|
|
46
46
|
// ── Playwright method surface (shared with the fixture proxy) ────────────────
|
|
47
47
|
/**
|
|
48
48
|
* Page-level locator-building methods wrapped by the capture proxy. Imported by
|
|
@@ -393,7 +393,7 @@ function isAutoGenerated(value) {
|
|
|
393
393
|
if (/^(emotion-|styled-|css-|sc-)/.test(value))
|
|
394
394
|
return true;
|
|
395
395
|
// Angular-style generated IDs (ng-xxx-N)
|
|
396
|
-
if (
|
|
396
|
+
if (value.startsWith('ng-'))
|
|
397
397
|
return true;
|
|
398
398
|
return false;
|
|
399
399
|
}
|
|
@@ -447,23 +447,28 @@ const NAME_BASED_METHODS = new Set([
|
|
|
447
447
|
'getByTitle',
|
|
448
448
|
'getByAltText',
|
|
449
449
|
]);
|
|
450
|
-
const escAttr = (s) => s.
|
|
450
|
+
const escAttr = (s) => s.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
|
|
451
451
|
/** Parse `ariaSnapshot()` lines into role/name pairs (mirrors the server-side matcher). */
|
|
452
452
|
function parseAriaRoleName(ariaSnapshot) {
|
|
453
453
|
const out = [];
|
|
454
454
|
for (const line of ariaSnapshot.split('\n')) {
|
|
455
|
-
const m =
|
|
455
|
+
const m = /^\s*-\s+([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?/i.exec(line);
|
|
456
456
|
if (!m)
|
|
457
457
|
continue;
|
|
458
458
|
const role = m[1];
|
|
459
|
-
const name = m[2]
|
|
459
|
+
const name = m[2] == null ? null : m[2].replace(/\\(.)/g, '$1');
|
|
460
460
|
if (!name && (role === 'generic' || role === 'group' || role === 'list' || role === 'paragraph'))
|
|
461
461
|
continue;
|
|
462
462
|
out.push({ role, name });
|
|
463
463
|
}
|
|
464
464
|
return out;
|
|
465
465
|
}
|
|
466
|
-
/**
|
|
466
|
+
/**
|
|
467
|
+
* Token-set (Dice) similarity, 0-1, case- and punctuation-insensitive.
|
|
468
|
+
* Duplicated from `textSimilarity` in application/shared/locator-fingerprint.ts —
|
|
469
|
+
* this package publishes standalone to npm and can't import monorepo-relative
|
|
470
|
+
* shared/ code, so keep the two implementations in sync by hand.
|
|
471
|
+
*/
|
|
467
472
|
function nameSimilarity(a, b) {
|
|
468
473
|
const tok = (s) => new Set((s ?? '')
|
|
469
474
|
.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.
|