@piwitests/reporter 0.12.0 → 0.13.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/global-setup-module.d.ts +2 -1
- package/dist/global-setup-module.js +432 -3
- package/dist/index.d.ts +204 -8
- package/dist/index.js +4724 -23
- package/dist/internal/capture/attachments.d.ts +8 -3
- package/dist/internal/capture/attachments.js +46 -21
- package/dist/internal/capture/capture-fixtures.d.ts +16 -14
- package/dist/internal/capture/capture-fixtures.js +2211 -971
- package/dist/internal/capture/inspect-on-failure.d.ts +50 -0
- package/dist/internal/capture/inspect-on-failure.js +66 -0
- package/dist/internal/capture/locator-healing.d.ts +33 -190
- package/dist/internal/capture/locator-healing.js +599 -815
- package/dist/internal/capture/pick-on-failure.d.ts +198 -0
- package/dist/internal/capture/pick-on-failure.js +1203 -0
- package/package.json +9 -4
- package/dist/internal/collect/error-text.d.ts +0 -18
- package/dist/internal/collect/error-text.js +0 -79
- package/dist/internal/collect/metadata-collector.d.ts +0 -32
- package/dist/internal/collect/metadata-collector.js +0 -246
- package/dist/internal/collect/skip-classify.d.ts +0 -27
- package/dist/internal/collect/skip-classify.js +0 -40
- package/dist/internal/collect/step-analyzer.d.ts +0 -103
- package/dist/internal/collect/step-analyzer.js +0 -221
- package/dist/internal/config/env.d.ts +0 -46
- package/dist/internal/config/env.js +0 -162
- package/dist/internal/files/compression.d.ts +0 -5
- package/dist/internal/files/compression.js +0 -69
- package/dist/internal/files/file-handler.d.ts +0 -38
- package/dist/internal/files/file-handler.js +0 -207
- package/dist/internal/streaming/crash-recovery.d.ts +0 -23
- package/dist/internal/streaming/crash-recovery.js +0 -106
- package/dist/internal/streaming/stream-buffer.d.ts +0 -17
- package/dist/internal/streaming/stream-buffer.js +0 -102
- package/dist/internal/streaming/stream-manager.d.ts +0 -88
- package/dist/internal/streaming/stream-manager.js +0 -395
- package/dist/internal/submit/run-submitter.d.ts +0 -67
- package/dist/internal/submit/run-submitter.js +0 -190
- package/dist/internal/submit/serializer.d.ts +0 -45
- package/dist/internal/submit/serializer.js +0 -108
- package/dist/internal/submit/uploader.d.ts +0 -89
- package/dist/internal/submit/uploader.js +0 -226
- package/dist/internal/support/ci.d.ts +0 -2
- package/dist/internal/support/ci.js +0 -32
- package/dist/internal/support/cli-filters.d.ts +0 -1
- package/dist/internal/support/cli-filters.js +0 -51
- package/dist/internal/support/errors.d.ts +0 -8
- package/dist/internal/support/errors.js +0 -15
- package/dist/internal/support/instance-id.d.ts +0 -4
- package/dist/internal/support/instance-id.js +0 -48
- package/dist/internal/support/limiter.d.ts +0 -2
- package/dist/internal/support/limiter.js +0 -27
- package/dist/internal/support/logger.d.ts +0 -26
- package/dist/internal/support/logger.js +0 -43
- package/dist/internal/support/reporter-version.d.ts +0 -2
- package/dist/internal/support/reporter-version.js +0 -54
- package/dist/internal/support/setup-file.d.ts +0 -13
- package/dist/internal/support/setup-file.js +0 -61
- package/dist/internal/support/source-snippet.d.ts +0 -12
- package/dist/internal/support/source-snippet.js +0 -97
- package/dist/internal/support/worker-index.d.ts +0 -7
- package/dist/internal/support/worker-index.js +0 -14
- package/dist/internal/transport/http-client.d.ts +0 -52
- package/dist/internal/transport/http-client.js +0 -201
- package/dist/public/config-wrapper.d.ts +0 -21
- package/dist/public/config-wrapper.js +0 -64
- package/dist/public/global-setup.d.ts +0 -13
- package/dist/public/global-setup.js +0 -146
- package/dist/public/options.d.ts +0 -86
- package/dist/public/options.js +0 -2
- package/dist/public/reporter.d.ts +0 -68
- package/dist/public/reporter.js +0 -376
- package/dist/types/collected.d.ts +0 -97
- package/dist/types/collected.js +0 -10
- package/dist/types/wire.d.ts +0 -176
- package/dist/types/wire.js +0 -14
- package/dist/types.d.ts +0 -11
- package/dist/types.js +0 -27
package/dist/index.js
CHANGED
|
@@ -1,24 +1,4725 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
PiwiDashboardReporter: () => PiwiDashboardReporter,
|
|
34
|
+
createGlobalSetup: () => createGlobalSetup,
|
|
35
|
+
default: () => index_default,
|
|
36
|
+
extendPiwiFixtures: () => extendPiwiFixtures,
|
|
37
|
+
piwiFixtures: () => piwiFixtures,
|
|
38
|
+
wrapConfig: () => wrapConfig
|
|
39
|
+
});
|
|
40
|
+
module.exports = __toCommonJS(index_exports);
|
|
41
|
+
|
|
42
|
+
// src/public/reporter.ts
|
|
43
|
+
var path12 = __toESM(require("path"));
|
|
44
|
+
|
|
45
|
+
// src/internal/config/env.ts
|
|
46
|
+
var DEFAULTS = {
|
|
47
|
+
projectName: "default-project",
|
|
48
|
+
uploadTraces: true,
|
|
49
|
+
uploadReport: true,
|
|
50
|
+
liveFileUploads: true,
|
|
51
|
+
collectScmInfo: true,
|
|
52
|
+
collectCiInfo: true,
|
|
53
|
+
collectPerformanceMetrics: true,
|
|
54
|
+
captureLocators: true,
|
|
55
|
+
capturePageState: true,
|
|
56
|
+
streaming: true,
|
|
57
|
+
streamingBatchSize: 5,
|
|
58
|
+
streamingBatchDelay: 2e3,
|
|
59
|
+
username: null,
|
|
60
|
+
password: null,
|
|
61
|
+
apiKey: null,
|
|
62
|
+
verbose: false
|
|
63
|
+
};
|
|
64
|
+
var PIWI_ENV_KEYS = {
|
|
65
|
+
serverUrl: "PIWI_DASHBOARD_URL",
|
|
66
|
+
projectName: "PIWI_PROJECT_NAME",
|
|
67
|
+
verbose: "PIWI_VERBOSE",
|
|
68
|
+
apiKey: "PIWI_API_KEY",
|
|
69
|
+
username: "PIWI_USERNAME",
|
|
70
|
+
password: "PIWI_PASSWORD",
|
|
71
|
+
environment: "PIWI_ENVIRONMENT",
|
|
72
|
+
label: "PIWI_LABEL",
|
|
73
|
+
runLabel: "PIWI_RUN_LABEL",
|
|
74
|
+
streaming: "PIWI_STREAMING",
|
|
75
|
+
streamingBatchSize: "PIWI_STREAMING_BATCH_SIZE",
|
|
76
|
+
streamingBatchDelay: "PIWI_STREAMING_BATCH_DELAY",
|
|
77
|
+
liveFileUploads: "PIWI_LIVE_FILE_UPLOADS",
|
|
78
|
+
uploadTraces: "PIWI_UPLOAD_TRACES",
|
|
79
|
+
uploadReport: "PIWI_UPLOAD_REPORT",
|
|
80
|
+
captureLocators: "PIWI_CAPTURE_LOCATORS",
|
|
81
|
+
capturePageState: "PIWI_CAPTURE_PAGE_STATE",
|
|
82
|
+
inspectOnFailure: "PIWI_INSPECT_ON_FAIL",
|
|
83
|
+
pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL"
|
|
84
|
+
};
|
|
85
|
+
function readBool(val) {
|
|
86
|
+
if (val === void 0) return void 0;
|
|
87
|
+
return val === "true";
|
|
88
|
+
}
|
|
89
|
+
var ENV_FALLBACK_SPECS = [
|
|
90
|
+
{ option: "serverUrl", env: PIWI_ENV_KEYS.serverUrl, kind: "string" },
|
|
91
|
+
{ option: "projectName", env: PIWI_ENV_KEYS.projectName, kind: "string" },
|
|
92
|
+
{ option: "apiKey", env: PIWI_ENV_KEYS.apiKey, kind: "string" },
|
|
93
|
+
{ option: "username", env: PIWI_ENV_KEYS.username, kind: "string" },
|
|
94
|
+
{ option: "password", env: PIWI_ENV_KEYS.password, kind: "string" },
|
|
95
|
+
{ option: "environment", env: PIWI_ENV_KEYS.environment, kind: "string" },
|
|
96
|
+
{ option: "label", env: PIWI_ENV_KEYS.label, kind: "string" },
|
|
97
|
+
{ option: "runLabel", env: PIWI_ENV_KEYS.runLabel, kind: "string" },
|
|
98
|
+
{ option: "streaming", env: PIWI_ENV_KEYS.streaming, kind: "bool" },
|
|
99
|
+
{ option: "streamingBatchSize", env: PIWI_ENV_KEYS.streamingBatchSize, kind: "number" },
|
|
100
|
+
{ option: "streamingBatchDelay", env: PIWI_ENV_KEYS.streamingBatchDelay, kind: "number" },
|
|
101
|
+
{ option: "liveFileUploads", env: PIWI_ENV_KEYS.liveFileUploads, kind: "bool" },
|
|
102
|
+
{ option: "uploadTraces", env: PIWI_ENV_KEYS.uploadTraces, kind: "bool" },
|
|
103
|
+
{ option: "uploadReport", env: PIWI_ENV_KEYS.uploadReport, kind: "bool" },
|
|
104
|
+
{ option: "captureLocators", env: PIWI_ENV_KEYS.captureLocators, kind: "bool" },
|
|
105
|
+
{ option: "capturePageState", env: PIWI_ENV_KEYS.capturePageState, kind: "bool" },
|
|
106
|
+
{ option: "inspectOnFailure", env: PIWI_ENV_KEYS.inspectOnFailure, kind: "bool" },
|
|
107
|
+
{ option: "pickLocatorOnFailure", env: PIWI_ENV_KEYS.pickLocatorOnFailure, kind: "bool" }
|
|
108
|
+
];
|
|
109
|
+
function resolveOptions(raw) {
|
|
110
|
+
const env = process.env;
|
|
111
|
+
const mergedRaw = { ...raw };
|
|
112
|
+
for (const spec of ENV_FALLBACK_SPECS) {
|
|
113
|
+
if (mergedRaw[spec.option] !== void 0) continue;
|
|
114
|
+
const value = env[spec.env];
|
|
115
|
+
if (spec.kind === "bool") {
|
|
116
|
+
if (value !== void 0) mergedRaw[spec.option] = readBool(value);
|
|
117
|
+
} else if (value) {
|
|
118
|
+
mergedRaw[spec.option] = spec.kind === "number" ? Number(value) : value;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const opts = { ...DEFAULTS, ...mergedRaw };
|
|
122
|
+
if (env[PIWI_ENV_KEYS.verbose] !== void 0) opts.verbose = env[PIWI_ENV_KEYS.verbose] === "true";
|
|
123
|
+
return opts;
|
|
124
|
+
}
|
|
125
|
+
function applyOptionsToEnv(options) {
|
|
126
|
+
const env = process.env;
|
|
127
|
+
if (options.serverUrl !== void 0) env[PIWI_ENV_KEYS.serverUrl] = options.serverUrl;
|
|
128
|
+
if (options.projectName !== void 0) env[PIWI_ENV_KEYS.projectName] = options.projectName;
|
|
129
|
+
if (options.verbose !== void 0) env[PIWI_ENV_KEYS.verbose] = String(options.verbose);
|
|
130
|
+
if (options.apiKey) env[PIWI_ENV_KEYS.apiKey] = options.apiKey;
|
|
131
|
+
if (options.username) env[PIWI_ENV_KEYS.username] = options.username;
|
|
132
|
+
if (options.password) env[PIWI_ENV_KEYS.password] = options.password;
|
|
133
|
+
if (options.environment) env[PIWI_ENV_KEYS.environment] = options.environment;
|
|
134
|
+
if (options.label) env[PIWI_ENV_KEYS.label] = options.label;
|
|
135
|
+
if (options.runLabel) env[PIWI_ENV_KEYS.runLabel] = options.runLabel;
|
|
136
|
+
if (options.captureLocators === false || options.collectPerformanceMetrics === false)
|
|
137
|
+
env[PIWI_ENV_KEYS.captureLocators] = "false";
|
|
138
|
+
else if (options.captureLocators === true) env[PIWI_ENV_KEYS.captureLocators] = "true";
|
|
139
|
+
if (options.capturePageState === false || options.collectPerformanceMetrics === false)
|
|
140
|
+
env[PIWI_ENV_KEYS.capturePageState] = "false";
|
|
141
|
+
else if (options.capturePageState === true) env[PIWI_ENV_KEYS.capturePageState] = "true";
|
|
142
|
+
if (options.inspectOnFailure !== void 0) env[PIWI_ENV_KEYS.inspectOnFailure] = String(options.inspectOnFailure);
|
|
143
|
+
if (options.pickLocatorOnFailure !== void 0)
|
|
144
|
+
env[PIWI_ENV_KEYS.pickLocatorOnFailure] = String(options.pickLocatorOnFailure);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/internal/transport/http-client.ts
|
|
148
|
+
var https = __toESM(require("https"));
|
|
149
|
+
var http = __toESM(require("http"));
|
|
150
|
+
var import_node_url = require("url");
|
|
151
|
+
var import_form_data = __toESM(require("form-data"));
|
|
152
|
+
|
|
153
|
+
// src/internal/support/logger.ts
|
|
154
|
+
var Logger = class {
|
|
155
|
+
constructor(verbose = false) {
|
|
156
|
+
this.verbose = verbose;
|
|
157
|
+
this.prefix = "[Piwi Dashboard] ";
|
|
158
|
+
}
|
|
159
|
+
info(msg) {
|
|
160
|
+
console.log(this.prefix + msg);
|
|
161
|
+
}
|
|
162
|
+
warn(msg) {
|
|
163
|
+
console.warn(this.prefix + msg);
|
|
164
|
+
}
|
|
165
|
+
error(msg) {
|
|
166
|
+
console.error(this.prefix + msg);
|
|
167
|
+
}
|
|
168
|
+
debug(msg) {
|
|
169
|
+
if (this.verbose) console.log(this.prefix + msg);
|
|
170
|
+
}
|
|
171
|
+
debugError(msg) {
|
|
172
|
+
if (this.verbose) console.error(this.prefix + msg);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// src/internal/transport/http-client.ts
|
|
177
|
+
function parseErrorMessage(text) {
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(text);
|
|
180
|
+
const message = parsed?.message;
|
|
181
|
+
return typeof message === "string" ? message : void 0;
|
|
182
|
+
} catch {
|
|
183
|
+
return void 0;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
var HttpError = class extends Error {
|
|
187
|
+
constructor(status, message = `Request failed with status ${status}`) {
|
|
188
|
+
super(message);
|
|
189
|
+
this.status = status;
|
|
190
|
+
this.name = "HttpError";
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
var HttpClient = class {
|
|
194
|
+
/**
|
|
195
|
+
* @param serverUrl Base URL of the Piwi Dashboard server (e.g. `http://localhost:3000`).
|
|
196
|
+
* @param logger Prefixed logger for verbose diagnostics.
|
|
197
|
+
* @param timeout Socket inactivity timeout in ms (default 30s). A hung
|
|
198
|
+
* server now fails fast instead of stalling the reporter.
|
|
199
|
+
*/
|
|
200
|
+
constructor(serverUrl, logger = new Logger(), timeout = 3e4) {
|
|
201
|
+
this.serverUrl = serverUrl;
|
|
202
|
+
this.logger = logger;
|
|
203
|
+
this.timeout = timeout;
|
|
204
|
+
}
|
|
205
|
+
/** Base URL of the Piwi Dashboard server this client talks to (used to print run links). */
|
|
206
|
+
get baseUrl() {
|
|
207
|
+
return this.serverUrl;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Resolve an auth credential: prefer `apiKey`, fall back to `username`/`password` login,
|
|
211
|
+
* or return `null` when neither is configured.
|
|
212
|
+
*/
|
|
213
|
+
async resolveAuth(options) {
|
|
214
|
+
if (options.apiKey) return options.apiKey;
|
|
215
|
+
if (options.username && options.password) {
|
|
216
|
+
this.logger.info(`Authenticating as ${options.username}...`);
|
|
217
|
+
return this.login(options.username, options.password);
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
/** Authenticate with username/password and return the session cookie string */
|
|
222
|
+
async login(username, password) {
|
|
223
|
+
const body = JSON.stringify({ username, password });
|
|
224
|
+
const res = await this.request("POST", "/api/auth/login", {
|
|
225
|
+
headers: {
|
|
226
|
+
"Content-Type": "application/json",
|
|
227
|
+
"Content-Length": Buffer.byteLength(body)
|
|
228
|
+
},
|
|
229
|
+
body
|
|
230
|
+
});
|
|
231
|
+
if (res.status < 200 || res.status >= 300) {
|
|
232
|
+
this.logger.debugError(`Login response: ${res.text}`);
|
|
233
|
+
throw new HttpError(res.status, `Login failed with status ${res.status}`);
|
|
234
|
+
}
|
|
235
|
+
const setCookie = res.headers["set-cookie"];
|
|
236
|
+
if (!setCookie || setCookie.length === 0) {
|
|
237
|
+
throw new Error("Login succeeded but no session cookie was returned");
|
|
238
|
+
}
|
|
239
|
+
const cookie = setCookie.map((c) => c.split(";")[0]).join("; ");
|
|
240
|
+
this.logger.debug("Logged in successfully");
|
|
241
|
+
return cookie;
|
|
242
|
+
}
|
|
243
|
+
/** Send a JSON POST request. `auth` can be an API key (prefix `pd_`) or a session cookie string. */
|
|
244
|
+
async postJSON(pathname, payload, auth) {
|
|
245
|
+
const body = JSON.stringify(payload);
|
|
246
|
+
const res = await this.request("POST", pathname, {
|
|
247
|
+
headers: {
|
|
248
|
+
"Content-Type": "application/json",
|
|
249
|
+
"Content-Length": Buffer.byteLength(body)
|
|
250
|
+
},
|
|
251
|
+
body,
|
|
252
|
+
auth
|
|
253
|
+
});
|
|
254
|
+
if (res.status < 200 || res.status >= 300) {
|
|
255
|
+
this.logger.debugError(`Response: ${res.text}`);
|
|
256
|
+
throw new HttpError(res.status);
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
return JSON.parse(res.text);
|
|
260
|
+
} catch {
|
|
261
|
+
return {};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Send a multipart form-data POST request. Used for report and trace uploads. */
|
|
265
|
+
async postFormData(pathname, form, auth) {
|
|
266
|
+
const headers = form.getHeaders();
|
|
267
|
+
const res = await this.request("POST", pathname, { headers, form, auth });
|
|
268
|
+
if (res.status < 200 || res.status >= 300) {
|
|
269
|
+
this.logger.debugError(`Response: ${res.text}`);
|
|
270
|
+
const detail = parseErrorMessage(res.text);
|
|
271
|
+
throw new HttpError(
|
|
272
|
+
res.status,
|
|
273
|
+
detail ? `Request failed with status ${res.status}: ${detail}` : `Request failed with status ${res.status}`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
return JSON.parse(res.text);
|
|
278
|
+
} catch {
|
|
279
|
+
return {};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** Unified request core: transport, headers, auth, response accumulation, timeout. */
|
|
283
|
+
request(method, pathname, opts) {
|
|
284
|
+
return new Promise((resolve5, reject) => {
|
|
285
|
+
const url = new import_node_url.URL(pathname, this.serverUrl);
|
|
286
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
287
|
+
const headers = { ...opts.headers };
|
|
288
|
+
this.applyAuth(headers, opts.auth);
|
|
289
|
+
const req = transport.request(
|
|
290
|
+
{
|
|
291
|
+
hostname: url.hostname,
|
|
292
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
293
|
+
path: url.pathname,
|
|
294
|
+
method,
|
|
295
|
+
headers
|
|
296
|
+
},
|
|
297
|
+
(res) => {
|
|
298
|
+
let data = "";
|
|
299
|
+
res.on("data", (chunk) => {
|
|
300
|
+
data += chunk;
|
|
301
|
+
});
|
|
302
|
+
res.on("end", () => {
|
|
303
|
+
resolve5({ status: res.statusCode ?? 0, text: data, headers: res.headers });
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
);
|
|
307
|
+
req.on("error", reject);
|
|
308
|
+
req.setTimeout(this.timeout, () => {
|
|
309
|
+
req.destroy(new Error(`Request to ${pathname} timed out after ${this.timeout}ms`));
|
|
310
|
+
});
|
|
311
|
+
if (opts.form) {
|
|
312
|
+
opts.form.pipe(req);
|
|
313
|
+
} else if (opts.body !== void 0) {
|
|
314
|
+
req.write(opts.body);
|
|
315
|
+
req.end();
|
|
316
|
+
} else {
|
|
317
|
+
req.end();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
applyAuth(headers, auth) {
|
|
322
|
+
if (!auth) return;
|
|
323
|
+
if (auth.startsWith("pd_")) {
|
|
324
|
+
headers["Authorization"] = `Bearer ${auth}`;
|
|
325
|
+
} else {
|
|
326
|
+
headers["Cookie"] = auth;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// src/internal/submit/uploader.ts
|
|
332
|
+
var fs = __toESM(require("fs"));
|
|
333
|
+
var path = __toESM(require("path"));
|
|
334
|
+
var import_form_data2 = __toESM(require("form-data"));
|
|
335
|
+
|
|
336
|
+
// src/internal/submit/serializer.ts
|
|
337
|
+
function resolveOverallStatus(result, counters) {
|
|
338
|
+
const STATUS_MAP = {
|
|
339
|
+
passed: "passed",
|
|
340
|
+
failed: "failed",
|
|
341
|
+
timedout: "failed",
|
|
342
|
+
interrupted: "failed"
|
|
343
|
+
};
|
|
344
|
+
if (result?.status) return STATUS_MAP[result.status] ?? "failed";
|
|
345
|
+
if (counters.failedTests === 0 && counters.timedOutTests === 0 && counters.totalTests > 0) return "passed";
|
|
346
|
+
return "failed";
|
|
347
|
+
}
|
|
348
|
+
function toWireTestCase(tc) {
|
|
349
|
+
const { type, ...rest } = tc;
|
|
350
|
+
return {
|
|
351
|
+
type,
|
|
352
|
+
title: rest.title,
|
|
353
|
+
location: rest.location,
|
|
354
|
+
status: rest.status,
|
|
355
|
+
duration: rest.duration,
|
|
356
|
+
error: rest.error,
|
|
357
|
+
retries: rest.retries,
|
|
358
|
+
workerIndex: rest.workerIndex ?? null,
|
|
359
|
+
shardIndex: rest.shardIndex ?? null,
|
|
360
|
+
startedAt: rest.startedAt ?? null,
|
|
361
|
+
steps: rest.performanceMetrics?.steps || null,
|
|
362
|
+
stepEvents: rest.stepEvents || null,
|
|
363
|
+
slowestStep: rest.performanceMetrics?.slowestStep?.title || null,
|
|
364
|
+
slowestStepDuration: rest.performanceMetrics?.slowestStep?.duration || null,
|
|
365
|
+
wastedTimeMs: rest.performanceMetrics?.waitTotalDuration ?? null,
|
|
366
|
+
networkRequests: rest.networkRequests || null,
|
|
367
|
+
webVitals: rest.webVitals || null,
|
|
368
|
+
pageState: rest.pageState || null,
|
|
369
|
+
consoleLogs: rest.consoleLogs || null,
|
|
370
|
+
ariaSnapshot: rest.ariaSnapshot || null,
|
|
371
|
+
testSource: rest.testSource || null,
|
|
372
|
+
testSourceFrames: rest.testSourceFrames || null,
|
|
373
|
+
browser: rest.browser || null,
|
|
374
|
+
suitePath: rest.suitePath ?? null,
|
|
375
|
+
suiteConfig: rest.suiteConfig ?? null,
|
|
376
|
+
testAnnotations: rest.testAnnotations ?? null,
|
|
377
|
+
locatorSnapshots: rest.locatorSnapshots || null
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function serializeRun(payload, opts) {
|
|
381
|
+
const body = {
|
|
382
|
+
projectName: payload.projectName,
|
|
383
|
+
projectDescription: payload.projectDescription,
|
|
384
|
+
status: payload.status,
|
|
385
|
+
startTime: payload.startTime,
|
|
386
|
+
duration: payload.duration,
|
|
387
|
+
totalTests: payload.totalTests,
|
|
388
|
+
passedTests: payload.passedTests,
|
|
389
|
+
failedTests: payload.failedTests,
|
|
390
|
+
timedOutTests: payload.timedOutTests ?? 0,
|
|
391
|
+
skippedTests: payload.skippedTests,
|
|
392
|
+
didNotRunTests: payload.didNotRunTests ?? 0,
|
|
393
|
+
environment: payload.environment ?? null,
|
|
394
|
+
label: payload.label ?? null,
|
|
395
|
+
metadata: payload.metadata,
|
|
396
|
+
instanceId: payload.instanceId,
|
|
397
|
+
playwrightVersion: payload.playwrightVersion,
|
|
398
|
+
reporterVersion: payload.reporterVersion,
|
|
399
|
+
shardIndex: payload.shardIndex,
|
|
400
|
+
shardTotal: payload.shardTotal,
|
|
401
|
+
isFullRun: payload.isFullRun ?? true,
|
|
402
|
+
filterDetails: payload.filterDetails ?? null
|
|
403
|
+
};
|
|
404
|
+
if (opts.includeTestCases) {
|
|
405
|
+
body.testCases = payload.testCases.map((tc) => toWireTestCase(tc));
|
|
406
|
+
}
|
|
407
|
+
return body;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/internal/support/run-url.ts
|
|
411
|
+
function runUrl(serverUrl, runId) {
|
|
412
|
+
return `${serverUrl.replace(/\/+$/, "")}/test-runs/${runId}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/internal/submit/uploader.ts
|
|
416
|
+
var Uploader = class {
|
|
417
|
+
/**
|
|
418
|
+
* @param httpClient HTTP client for server communication.
|
|
419
|
+
* @param fileHandler File discovery and compression helper.
|
|
420
|
+
* @param logger Prefixed logger.
|
|
421
|
+
*/
|
|
422
|
+
constructor(httpClient, fileHandler, logger = new Logger()) {
|
|
423
|
+
this.httpClient = httpClient;
|
|
424
|
+
this.fileHandler = fileHandler;
|
|
425
|
+
this.logger = logger;
|
|
426
|
+
}
|
|
427
|
+
/** Submit test results as a plain JSON payload (no file attachments) */
|
|
428
|
+
async uploadJSON(payload, auth) {
|
|
429
|
+
const response = await this.httpClient.postJSON(
|
|
430
|
+
"/api/test-runs/submit",
|
|
431
|
+
serializeRun(payload, { includeTestCases: true }),
|
|
432
|
+
auth
|
|
433
|
+
);
|
|
434
|
+
this.logger.info(`Successfully uploaded test results`);
|
|
435
|
+
if (response.testRunId) {
|
|
436
|
+
this.logger.info(`Test Run ID: ${response.testRunId}, Project ID: ${response.projectId}`);
|
|
437
|
+
this.logger.info(`View run: ${runUrl(this.httpClient.baseUrl, response.testRunId)}`);
|
|
438
|
+
}
|
|
439
|
+
return response;
|
|
440
|
+
}
|
|
441
|
+
/** Submit test results as a multipart form with trace files and compressed report directories */
|
|
442
|
+
async uploadWithFiles(payload, reportOptions, auth) {
|
|
443
|
+
const form = new import_form_data2.default();
|
|
444
|
+
form.append("projectName", payload.projectName);
|
|
445
|
+
form.append("testRun", JSON.stringify(serializeRun(payload, { includeTestCases: false })));
|
|
446
|
+
form.append("testCases", JSON.stringify(payload.testCases.map((tc) => toWireTestCase(tc))));
|
|
447
|
+
await this.appendReportsToForm(form, reportOptions.reports, reportOptions.uploadReport);
|
|
448
|
+
await this.appendFilesToForm(form, payload.testCases, reportOptions.uploadTraces);
|
|
449
|
+
const response = await this.httpClient.postFormData("/api/test-runs/upload", form, auth);
|
|
450
|
+
this.logger.info(`Successfully uploaded test results with files`);
|
|
451
|
+
if (response.testRunId) {
|
|
452
|
+
this.logger.info(`Test Run ID: ${response.testRunId}, Project ID: ${response.projectId}`);
|
|
453
|
+
this.logger.info(`View run: ${runUrl(this.httpClient.baseUrl, response.testRunId)}`);
|
|
454
|
+
}
|
|
455
|
+
if (response.reports) {
|
|
456
|
+
for (const r of response.reports) this.logger.info(`${r.label}: ${r.path}`);
|
|
457
|
+
}
|
|
458
|
+
return response;
|
|
459
|
+
}
|
|
460
|
+
/** Upload report files for an already-submitted streaming run */
|
|
461
|
+
async uploadReportsForStreamingRun(projectName, runId, reportOptions, startTime, auth) {
|
|
462
|
+
const form = new import_form_data2.default();
|
|
463
|
+
form.append("testRunId", String(runId));
|
|
464
|
+
form.append("projectName", projectName);
|
|
465
|
+
form.append(
|
|
466
|
+
"testRun",
|
|
467
|
+
JSON.stringify({
|
|
468
|
+
status: "already-submitted",
|
|
469
|
+
startTime,
|
|
470
|
+
duration: 0,
|
|
471
|
+
totalTests: 0,
|
|
472
|
+
passedTests: 0,
|
|
473
|
+
failedTests: 0,
|
|
474
|
+
skippedTests: 0,
|
|
475
|
+
metadata: {}
|
|
476
|
+
})
|
|
477
|
+
);
|
|
478
|
+
form.append("testCases", JSON.stringify([]));
|
|
479
|
+
await this.appendReportsToForm(form, reportOptions.reports, reportOptions.uploadReport);
|
|
480
|
+
const response = await this.httpClient.postFormData("/api/test-runs/upload", form, auth);
|
|
481
|
+
this.logger.info(`Successfully uploaded reports for streaming run #${runId}`);
|
|
482
|
+
if (response.reports) {
|
|
483
|
+
for (const r of response.reports) this.logger.info(`${r.label}: ${r.path}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Upload one test case's trace and attachments for a streaming run.
|
|
488
|
+
* The matching `complete` event must have been flushed to the server first.
|
|
489
|
+
* Returns `false` when the case has no files to upload.
|
|
490
|
+
*/
|
|
491
|
+
async uploadCaseFiles(projectName, runId, streamToken, testCase, uploadTraces, auth) {
|
|
492
|
+
const attachments = this.fileHandler.findAllAttachments(testCase);
|
|
493
|
+
let traceInfo = null;
|
|
494
|
+
if (uploadTraces) {
|
|
495
|
+
traceInfo = await this.fileHandler.computeSingleTraceHash(testCase);
|
|
496
|
+
}
|
|
497
|
+
if (!traceInfo && attachments.length === 0) return false;
|
|
498
|
+
let missingHashes = null;
|
|
499
|
+
if (traceInfo) {
|
|
500
|
+
missingHashes = await this.fileHandler.checkMissingTraces(
|
|
501
|
+
this.httpClient,
|
|
502
|
+
projectName,
|
|
503
|
+
/* @__PURE__ */ new Map([[0, traceInfo]]),
|
|
504
|
+
auth
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
const buildForm = (includeTraceFile2) => {
|
|
508
|
+
const form = new import_form_data2.default();
|
|
509
|
+
form.append("streamToken", streamToken);
|
|
510
|
+
form.append(
|
|
511
|
+
"testCase",
|
|
512
|
+
JSON.stringify({
|
|
513
|
+
title: testCase.title,
|
|
514
|
+
location: testCase.location,
|
|
515
|
+
retries: testCase.retries ?? 0,
|
|
516
|
+
suitePath: testCase.suitePath ?? null
|
|
517
|
+
})
|
|
518
|
+
);
|
|
519
|
+
if (traceInfo) {
|
|
520
|
+
form.append("trace_hash", traceInfo.hash);
|
|
521
|
+
if (includeTraceFile2) {
|
|
522
|
+
form.append("trace", fs.createReadStream(traceInfo.tracePath), {
|
|
523
|
+
filename: path.basename(traceInfo.tracePath)
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (attachments.length > 0) {
|
|
528
|
+
form.append(
|
|
529
|
+
"attach_meta",
|
|
530
|
+
JSON.stringify(
|
|
531
|
+
attachments.map((a) => ({ name: a.name, contentType: a.contentType, originalName: a.originalName }))
|
|
532
|
+
)
|
|
533
|
+
);
|
|
534
|
+
for (const a of attachments) {
|
|
535
|
+
form.append("attach_file", fs.createReadStream(a.path), { filename: a.originalName });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return form;
|
|
539
|
+
};
|
|
540
|
+
const includeTraceFile = !traceInfo || !missingHashes || missingHashes.has(traceInfo.hash);
|
|
541
|
+
try {
|
|
542
|
+
await this.httpClient.postFormData(`/api/test-runs/${runId}/case-files`, buildForm(includeTraceFile), auth);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (traceInfo && !includeTraceFile && error instanceof HttpError && error.status === 422) {
|
|
545
|
+
await this.httpClient.postFormData(`/api/test-runs/${runId}/case-files`, buildForm(true), auth);
|
|
546
|
+
} else {
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
this.logger.debug(
|
|
551
|
+
`Uploaded files for "${testCase.title}" (trace: ${traceInfo ? "yes" : "no"}, attachments: ${attachments.length})`
|
|
552
|
+
);
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
async appendReportsToForm(form, reports, uploadReport) {
|
|
556
|
+
const list = reports ? [...reports] : [];
|
|
557
|
+
if (uploadReport && !list.some((r) => r.type === "html")) list.push({ type: "html" });
|
|
558
|
+
for (const cfg of list) {
|
|
559
|
+
const defaultDir = this.fileHandler.getDefaultReportDirs()[cfg.type] || cfg.type + "-report";
|
|
560
|
+
const reportDir = cfg.dir ? this.fileHandler.findReportDirectory(cfg.dir) : cfg.type === "html" ? this.fileHandler.findHTMLReportDirectory() : this.fileHandler.findReportDirectory(defaultDir);
|
|
561
|
+
if (!reportDir) {
|
|
562
|
+
this.logger.debug(`No report directory found for type '${cfg.type}'`);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const compressed = await this.fileHandler.compressReportDirectory(reportDir);
|
|
566
|
+
if (compressed) {
|
|
567
|
+
form.append(`report_${cfg.type}`, compressed, { filename: `${cfg.type}-report.gz` });
|
|
568
|
+
if (cfg.label) form.append(`report_label_${cfg.type}`, cfg.label);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async appendFilesToForm(form, testCases, uploadTraces) {
|
|
573
|
+
let attachmentCount = 0;
|
|
574
|
+
for (const [i, tc] of testCases.entries()) {
|
|
575
|
+
const attachments = this.fileHandler.findAllAttachments(tc);
|
|
576
|
+
if (attachments.length === 0) continue;
|
|
577
|
+
form.append(
|
|
578
|
+
`attach_meta_${i}`,
|
|
579
|
+
JSON.stringify(
|
|
580
|
+
attachments.map((a) => ({
|
|
581
|
+
name: a.name,
|
|
582
|
+
contentType: a.contentType,
|
|
583
|
+
originalName: a.originalName
|
|
584
|
+
}))
|
|
585
|
+
)
|
|
586
|
+
);
|
|
587
|
+
for (const a of attachments) {
|
|
588
|
+
form.append(`attach_file_${i}`, fs.createReadStream(a.path), { filename: a.originalName });
|
|
589
|
+
attachmentCount++;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (attachmentCount > 0) this.logger.info(`Uploading ${attachmentCount} non-trace attachments`);
|
|
593
|
+
if (!uploadTraces) return;
|
|
594
|
+
let traceCount = 0;
|
|
595
|
+
for (const [i, tc] of testCases.entries()) {
|
|
596
|
+
for (const tp of this.fileHandler.findTraceFiles(tc)) {
|
|
597
|
+
if (fs.existsSync(tp)) {
|
|
598
|
+
form.append(`trace_${i}`, fs.createReadStream(tp), { filename: path.basename(tp) });
|
|
599
|
+
traceCount++;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
this.logger.info(`Found ${traceCount} trace files`);
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// src/internal/streaming/stream-buffer.ts
|
|
608
|
+
var path2 = __toESM(require("path"));
|
|
609
|
+
var os2 = __toESM(require("os"));
|
|
610
|
+
var fs2 = __toESM(require("fs"));
|
|
611
|
+
|
|
612
|
+
// src/internal/support/instance-id.ts
|
|
613
|
+
var crypto = __toESM(require("crypto"));
|
|
614
|
+
var os = __toESM(require("os"));
|
|
615
|
+
function hashForProject(projectName) {
|
|
616
|
+
return crypto.createHash("sha1").update(projectName).digest("hex").slice(0, 16);
|
|
617
|
+
}
|
|
618
|
+
function computeInstanceId(projectName, runLabel) {
|
|
619
|
+
const seed = runLabel ? `${projectName}|${runLabel}` : `${os.hostname()}|${projectName}`;
|
|
620
|
+
return crypto.createHash("sha256").update(seed).digest("hex").slice(0, 16);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// src/internal/streaming/stream-buffer.ts
|
|
624
|
+
var StreamBuffer = class {
|
|
625
|
+
constructor(projectName) {
|
|
626
|
+
this.filePath = path2.join(os2.tmpdir(), `piwi-dashboard-stream-${hashForProject(projectName)}.jsonl`);
|
|
627
|
+
}
|
|
628
|
+
/** Append one or more events to the on-disk buffer */
|
|
629
|
+
append(events) {
|
|
630
|
+
if (events.length === 0) return;
|
|
631
|
+
try {
|
|
632
|
+
const lines = events.map((e) => JSON.stringify(e) + "\n").join("");
|
|
633
|
+
fs2.appendFileSync(this.filePath, lines, "utf8");
|
|
634
|
+
} catch {
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/** Load all buffered events from disk, clearing the file */
|
|
638
|
+
load() {
|
|
639
|
+
try {
|
|
640
|
+
if (fs2.existsSync(this.filePath)) {
|
|
641
|
+
const content = fs2.readFileSync(this.filePath, "utf8");
|
|
642
|
+
return content.split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
643
|
+
}
|
|
644
|
+
} catch {
|
|
645
|
+
}
|
|
646
|
+
return [];
|
|
647
|
+
}
|
|
648
|
+
/** Delete the buffer file from disk */
|
|
649
|
+
clear() {
|
|
650
|
+
try {
|
|
651
|
+
if (fs2.existsSync(this.filePath)) fs2.unlinkSync(this.filePath);
|
|
652
|
+
} catch {
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/** Remove the buffer file if it is older than `maxAgeMs` (default 2 hours). Used on startup to discard orphaned data. */
|
|
656
|
+
clearStale(maxAgeMs = 72e5) {
|
|
657
|
+
try {
|
|
658
|
+
if (fs2.existsSync(this.filePath)) {
|
|
659
|
+
const stats = fs2.statSync(this.filePath);
|
|
660
|
+
if (Date.now() - stats.mtimeMs > maxAgeMs) {
|
|
661
|
+
fs2.unlinkSync(this.filePath);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
} catch {
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
// src/internal/streaming/crash-recovery.ts
|
|
670
|
+
var path3 = __toESM(require("path"));
|
|
671
|
+
|
|
672
|
+
// src/internal/support/errors.ts
|
|
673
|
+
function errorMessage(error) {
|
|
674
|
+
if (error instanceof Error) return error.message;
|
|
675
|
+
return String(error);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// src/internal/streaming/crash-recovery.ts
|
|
679
|
+
var os3 = __toESM(require("os"));
|
|
680
|
+
var fs3 = __toESM(require("fs"));
|
|
681
|
+
var CrashRecovery = class {
|
|
682
|
+
/**
|
|
683
|
+
* @param projectName Used to derive the temp-file name so recovery data is project-scoped.
|
|
684
|
+
* @param logger Prefixed logger.
|
|
685
|
+
*/
|
|
686
|
+
constructor(projectName, logger = new Logger()) {
|
|
687
|
+
this.logger = logger;
|
|
688
|
+
this.filePath = path3.join(os3.tmpdir(), `piwi-dashboard-recovery-${hashForProject(projectName)}.json`);
|
|
689
|
+
}
|
|
690
|
+
/** Serialise the payload to a temp file for later retry */
|
|
691
|
+
save(data) {
|
|
692
|
+
try {
|
|
693
|
+
fs3.writeFileSync(this.filePath, JSON.stringify(data), "utf8");
|
|
694
|
+
this.logger.info("Saved recovery data for later upload");
|
|
695
|
+
} catch (error) {
|
|
696
|
+
this.logger.error(`Failed to save recovery data: ${errorMessage(error)}`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/** Read the saved payload from disk, or return `null` if none exists */
|
|
700
|
+
load() {
|
|
701
|
+
try {
|
|
702
|
+
if (fs3.existsSync(this.filePath)) return JSON.parse(fs3.readFileSync(this.filePath, "utf8"));
|
|
703
|
+
} catch {
|
|
704
|
+
}
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
/** Delete the recovery file from disk */
|
|
708
|
+
clear() {
|
|
709
|
+
try {
|
|
710
|
+
if (fs3.existsSync(this.filePath)) fs3.unlinkSync(this.filePath);
|
|
711
|
+
} catch {
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
/** Attempt to submit a previously saved payload. Clears the recovery file on success. */
|
|
715
|
+
async tryUpload(httpClient, auth) {
|
|
716
|
+
const data = this.load();
|
|
717
|
+
if (!data) return;
|
|
718
|
+
this.logger.info("Found saved test data from a previous run, uploading...");
|
|
719
|
+
delete data.instanceId;
|
|
720
|
+
try {
|
|
721
|
+
await httpClient.postJSON("/api/test-runs/submit", data, auth);
|
|
722
|
+
this.logger.info("Successfully uploaded saved test data");
|
|
723
|
+
this.clear();
|
|
724
|
+
} catch (error) {
|
|
725
|
+
this.logger.warn(`Could not upload saved test data: ${errorMessage(error)}`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// src/internal/files/file-handler.ts
|
|
731
|
+
var fs5 = __toESM(require("fs"));
|
|
732
|
+
var path5 = __toESM(require("path"));
|
|
733
|
+
var crypto2 = __toESM(require("crypto"));
|
|
734
|
+
|
|
735
|
+
// src/internal/files/compression.ts
|
|
736
|
+
var fs4 = __toESM(require("fs"));
|
|
737
|
+
var path4 = __toESM(require("path"));
|
|
738
|
+
var zlib = __toESM(require("zlib"));
|
|
739
|
+
var import_node_util = require("util");
|
|
740
|
+
var gzipAsync = (0, import_node_util.promisify)(zlib.gzip);
|
|
741
|
+
async function compressDirectory(sourceDir) {
|
|
742
|
+
const files = [];
|
|
743
|
+
function collect(dir, baseDir = "") {
|
|
744
|
+
for (const entry of fs4.readdirSync(dir, { withFileTypes: true })) {
|
|
745
|
+
const full = path4.join(dir, entry.name);
|
|
746
|
+
const rel = path4.join(baseDir, entry.name);
|
|
747
|
+
if (entry.isDirectory()) collect(full, rel);
|
|
748
|
+
else if (entry.isFile()) files.push({ path: rel, content: fs4.readFileSync(full) });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
collect(sourceDir);
|
|
752
|
+
const parts = [];
|
|
753
|
+
for (const f of files) {
|
|
754
|
+
const pb = Buffer.from(f.path, "utf8");
|
|
755
|
+
const plb = Buffer.allocUnsafe(4);
|
|
756
|
+
plb.writeUInt32LE(pb.length, 0);
|
|
757
|
+
const clb = Buffer.allocUnsafe(4);
|
|
758
|
+
clb.writeUInt32LE(f.content.length, 0);
|
|
759
|
+
parts.push(plb, pb, clb, f.content);
|
|
760
|
+
}
|
|
761
|
+
return await gzipAsync(Buffer.concat(parts), { level: 5 });
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// src/internal/capture/attachments.ts
|
|
765
|
+
var ATTACHMENT_NAMES = {
|
|
766
|
+
locators: "piwi-locators",
|
|
767
|
+
ariaSnapshot: "piwi-aria-snapshot",
|
|
768
|
+
console: "piwi-console",
|
|
769
|
+
network: "piwi-network",
|
|
770
|
+
webVitals: "piwi-web-vitals",
|
|
771
|
+
locatorSuggestion: "piwi-locator-suggestion",
|
|
772
|
+
pageState: "piwi-page-state",
|
|
773
|
+
userPick: "piwi-user-pick"
|
|
774
|
+
};
|
|
775
|
+
var INTERNAL_ATTACHMENT_NAMES = new Set(Object.values(ATTACHMENT_NAMES));
|
|
776
|
+
var LOCATOR_SUGGESTION_ANNOTATION = ATTACHMENT_NAMES.locatorSuggestion;
|
|
777
|
+
var USER_PICK_ANNOTATION = ATTACHMENT_NAMES.userPick;
|
|
778
|
+
|
|
779
|
+
// src/internal/files/file-handler.ts
|
|
780
|
+
var FileHandler = class {
|
|
781
|
+
constructor(logger = new Logger()) {
|
|
782
|
+
this.logger = logger;
|
|
783
|
+
}
|
|
784
|
+
/** Locate a Playwright HTML report directory containing `index.html`. Optionally override the search path. */
|
|
785
|
+
findHTMLReportDirectory(customDir) {
|
|
786
|
+
const possibleDirs = customDir ? [customDir, path5.join(process.cwd(), customDir)] : ["playwright-report", "./playwright-report", path5.join(process.cwd(), "playwright-report")];
|
|
787
|
+
for (const reportDir of possibleDirs) {
|
|
788
|
+
if (fs5.existsSync(reportDir) && fs5.statSync(reportDir).isDirectory()) {
|
|
789
|
+
if (fs5.existsSync(path5.join(reportDir, "index.html"))) return reportDir;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
/** Check if `dir` exists and is a directory; also checks `path.join(process.cwd(), dir)` */
|
|
795
|
+
findReportDirectory(dir) {
|
|
796
|
+
const candidates = [dir, path5.join(process.cwd(), dir)];
|
|
797
|
+
for (const candidate of candidates) {
|
|
798
|
+
if (fs5.existsSync(candidate) && fs5.statSync(candidate).isDirectory()) return candidate;
|
|
799
|
+
}
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
/** Gzip-compress an entire report directory into a single buffer. Returns `null` on failure. */
|
|
803
|
+
async compressReportDirectory(reportDir) {
|
|
804
|
+
try {
|
|
805
|
+
return await compressDirectory(reportDir) || null;
|
|
806
|
+
} catch (error) {
|
|
807
|
+
this.logger.warn(`Failed to compress report directory ${reportDir}: ${errorMessage(error)}`);
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/** Return resolved paths for all trace attachments on a test case */
|
|
812
|
+
findTraceFiles(testCase) {
|
|
813
|
+
const set = /* @__PURE__ */ new Set();
|
|
814
|
+
if (testCase.attachments) {
|
|
815
|
+
for (const a of testCase.attachments) {
|
|
816
|
+
if (a.name === "trace" && a.path) set.add(path5.resolve(a.path));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return Array.from(set);
|
|
820
|
+
}
|
|
821
|
+
/** Return all non-trace, non-internal attachments from a test case. Skips `trace` and `piwi-*` attachments. */
|
|
822
|
+
findAllAttachments(testCase) {
|
|
823
|
+
const result = [];
|
|
824
|
+
if (testCase.attachments) {
|
|
825
|
+
for (const a of testCase.attachments) {
|
|
826
|
+
if (a.name === "trace") continue;
|
|
827
|
+
if (a.name && INTERNAL_ATTACHMENT_NAMES.has(a.name)) continue;
|
|
828
|
+
if (a.path && fs5.existsSync(a.path)) {
|
|
829
|
+
result.push({
|
|
830
|
+
name: a.name || "attachment",
|
|
831
|
+
path: path5.resolve(a.path),
|
|
832
|
+
contentType: a.contentType || "application/octet-stream",
|
|
833
|
+
originalName: path5.basename(a.path)
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return result;
|
|
839
|
+
}
|
|
840
|
+
/** Mapping of well-known report type names to their default output directories */
|
|
841
|
+
getDefaultReportDirs() {
|
|
842
|
+
return {
|
|
843
|
+
html: "playwright-report",
|
|
844
|
+
monocart: "monocart-report",
|
|
845
|
+
allure: "allure-report",
|
|
846
|
+
blob: "blob-report"
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
/** Parse Piwi-internal attachment bodies (`piwi-network`, `piwi-web-vitals`, `piwi-console`, `piwi-aria-snapshot`) into structured fields on the test case */
|
|
850
|
+
parsePerformanceAttachments(testCase, attachments) {
|
|
851
|
+
const find = (name) => attachments.find((a) => a.name === name);
|
|
852
|
+
const net = find(ATTACHMENT_NAMES.network);
|
|
853
|
+
if (net?.body) {
|
|
854
|
+
try {
|
|
855
|
+
testCase.networkRequests = JSON.parse(net.body.toString());
|
|
856
|
+
} catch {
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const vitals = find(ATTACHMENT_NAMES.webVitals);
|
|
860
|
+
if (vitals?.body) {
|
|
861
|
+
try {
|
|
862
|
+
testCase.webVitals = JSON.parse(vitals.body.toString());
|
|
863
|
+
} catch {
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
const consoleLog = find(ATTACHMENT_NAMES.console);
|
|
867
|
+
if (consoleLog?.body) {
|
|
868
|
+
try {
|
|
869
|
+
testCase.consoleLogs = JSON.parse(consoleLog.body.toString());
|
|
870
|
+
} catch {
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
const aria = find(ATTACHMENT_NAMES.ariaSnapshot);
|
|
874
|
+
if (aria?.body) testCase.ariaSnapshot = aria.body.toString();
|
|
875
|
+
const pageState = find(ATTACHMENT_NAMES.pageState);
|
|
876
|
+
if (pageState?.body) {
|
|
877
|
+
try {
|
|
878
|
+
testCase.pageState = JSON.parse(pageState.body.toString());
|
|
879
|
+
} catch {
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/** Compute SHA-256 hash and size for a single test case's trace file. Returns `null` when the case has no trace on disk. */
|
|
884
|
+
async computeSingleTraceHash(testCase) {
|
|
885
|
+
const tracePaths = this.findTraceFiles(testCase);
|
|
886
|
+
let lastPath = null;
|
|
887
|
+
for (const tp of tracePaths) {
|
|
888
|
+
if (fs5.existsSync(tp)) lastPath = tp;
|
|
889
|
+
}
|
|
890
|
+
if (!lastPath) return null;
|
|
891
|
+
const hash = crypto2.createHash("sha256");
|
|
892
|
+
await new Promise((resolve5, reject) => {
|
|
893
|
+
fs5.createReadStream(lastPath).on("data", (chunk) => hash.update(chunk)).on("end", resolve5).on("error", reject);
|
|
894
|
+
});
|
|
895
|
+
return {
|
|
896
|
+
tracePath: lastPath,
|
|
897
|
+
hash: hash.digest("hex"),
|
|
898
|
+
size: fs5.statSync(lastPath).size
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
/** Ask the server which trace hashes it already has, returning the set of hashes that are missing */
|
|
902
|
+
async checkMissingTraces(httpClient, projectName, traceHashMap, auth) {
|
|
903
|
+
if (traceHashMap.size === 0) return /* @__PURE__ */ new Set();
|
|
904
|
+
const hashes = [...traceHashMap.values()].map((h) => h.hash);
|
|
905
|
+
try {
|
|
906
|
+
const response = await httpClient.postJSON("/api/traces/check", { projectName, hashes }, auth);
|
|
907
|
+
const missing = Array.isArray(response.missing) ? response.missing : hashes;
|
|
908
|
+
return new Set(missing);
|
|
909
|
+
} catch {
|
|
910
|
+
return new Set(hashes);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
// src/internal/collect/metadata-collector.ts
|
|
916
|
+
var import_node_child_process = require("child_process");
|
|
917
|
+
var MetadataCollector = class {
|
|
918
|
+
constructor(logger = new Logger()) {
|
|
919
|
+
this.logger = logger;
|
|
920
|
+
}
|
|
921
|
+
/** Collect all available metadata from the environment, config, and suite */
|
|
922
|
+
collect(config, suite, options) {
|
|
923
|
+
const metadata = {};
|
|
924
|
+
if (options.projectDescription) metadata.projectDescription = options.projectDescription;
|
|
925
|
+
if (options.relatedIssue) metadata.relatedIssue = options.relatedIssue;
|
|
926
|
+
if (options.ciInfo) metadata.ciInfo = options.ciInfo;
|
|
927
|
+
if (options.tags && Array.isArray(options.tags)) metadata.tags = options.tags;
|
|
928
|
+
if (options.customData) metadata.customData = options.customData;
|
|
929
|
+
if (options.collectScmInfo) {
|
|
930
|
+
const scm = this.collectScmInfo(options);
|
|
931
|
+
if (scm) metadata.scm = scm;
|
|
932
|
+
}
|
|
933
|
+
if (options.collectCiInfo) {
|
|
934
|
+
const ci = this.collectCiInfo();
|
|
935
|
+
if (ci) metadata.ci = ci;
|
|
936
|
+
}
|
|
937
|
+
const htmlMeta = this.extractPlaywrightConfigMetadata(config);
|
|
938
|
+
if (htmlMeta && Object.keys(htmlMeta).length > 0) metadata.htmlReport = htmlMeta;
|
|
939
|
+
if (config.metadata) metadata.playwrightConfig = config.metadata;
|
|
940
|
+
if (suite?.allTests) {
|
|
941
|
+
const all = suite.allTests();
|
|
942
|
+
if (all.length > 0) {
|
|
943
|
+
const first = all[0];
|
|
944
|
+
if (first?.parent) {
|
|
945
|
+
const proj = first.parent.project();
|
|
946
|
+
if (proj?.metadata) metadata.playwrightProject = proj.metadata;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
return metadata;
|
|
951
|
+
}
|
|
952
|
+
/** Walk the test's parent hierarchy to extract the resolved browser/project configuration */
|
|
953
|
+
getBrowserConfig(test) {
|
|
954
|
+
try {
|
|
955
|
+
let suite = test.parent;
|
|
956
|
+
let depth = 0;
|
|
957
|
+
while (suite && depth < 20) {
|
|
958
|
+
depth++;
|
|
959
|
+
const project = suite.project?.();
|
|
960
|
+
if (project) {
|
|
961
|
+
const use = project.use ?? {};
|
|
962
|
+
const config = { projectName: project.name };
|
|
963
|
+
if (use.browserName) config.browserName = use.browserName;
|
|
964
|
+
if (use.channel) config.channel = use.channel;
|
|
965
|
+
if (use.viewport) config.viewport = { width: use.viewport.width, height: use.viewport.height };
|
|
966
|
+
if (use.deviceScaleFactor != null) config.deviceScaleFactor = use.deviceScaleFactor;
|
|
967
|
+
if (use.isMobile != null) config.isMobile = use.isMobile;
|
|
968
|
+
if (use.hasTouch != null) config.hasTouch = use.hasTouch;
|
|
969
|
+
if (use.locale) config.locale = use.locale;
|
|
970
|
+
if (use.timezoneId) config.timezoneId = use.timezoneId;
|
|
971
|
+
if (use.geolocation) {
|
|
972
|
+
config.geolocation = {
|
|
973
|
+
longitude: use.geolocation.longitude,
|
|
974
|
+
latitude: use.geolocation.latitude,
|
|
975
|
+
...use.geolocation.accuracy != null && { accuracy: use.geolocation.accuracy }
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
if (use.colorScheme) config.colorScheme = use.colorScheme;
|
|
979
|
+
if (use.reducedMotion) config.reducedMotion = use.reducedMotion;
|
|
980
|
+
if (use.forcedColors) config.forcedColors = use.forcedColors;
|
|
981
|
+
if (use.offline) config.offline = use.offline;
|
|
982
|
+
if (use.bypassCSP) config.bypassCSP = use.bypassCSP;
|
|
983
|
+
if (use.javaScriptEnabled === false) config.javaScriptEnabled = false;
|
|
984
|
+
if (use.serviceWorkers) config.serviceWorkers = use.serviceWorkers;
|
|
985
|
+
if (use.userAgent) config.userAgent = use.userAgent;
|
|
986
|
+
return config;
|
|
987
|
+
}
|
|
988
|
+
suite = suite.parent;
|
|
989
|
+
}
|
|
990
|
+
return null;
|
|
991
|
+
} catch {
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Walk the test's parent `describe` suites to extract the suite path (titles)
|
|
997
|
+
* and per-level config (parallel mode + annotations). Reaches into Playwright
|
|
998
|
+
* suite internals (`_parallelMode`, `_annotations`) — kept here next to
|
|
999
|
+
* `getBrowserConfig` so all such access is guarded behind one class.
|
|
1000
|
+
*/
|
|
1001
|
+
getSuiteInfo(test) {
|
|
1002
|
+
const suitePath = [];
|
|
1003
|
+
const suiteConfig = [];
|
|
1004
|
+
const suites = [];
|
|
1005
|
+
let suite = test.parent;
|
|
1006
|
+
while (suite && suite.type === "describe") {
|
|
1007
|
+
suites.unshift(suite);
|
|
1008
|
+
suite = suite.parent;
|
|
1009
|
+
}
|
|
1010
|
+
for (const s of suites) {
|
|
1011
|
+
if (!s.title) continue;
|
|
1012
|
+
suitePath.push(s.title);
|
|
1013
|
+
const rawMode = s._parallelMode;
|
|
1014
|
+
const mode = rawMode === "parallel" ? "parallel" : rawMode === "serial" ? "serial" : "default";
|
|
1015
|
+
const annotations = s._annotations ?? [];
|
|
1016
|
+
suiteConfig.push({ mode, annotations });
|
|
1017
|
+
}
|
|
1018
|
+
return { suitePath, suiteConfig };
|
|
1019
|
+
}
|
|
1020
|
+
collectScmInfo(_options) {
|
|
1021
|
+
const scm = {};
|
|
1022
|
+
try {
|
|
1023
|
+
const execOpts = {
|
|
1024
|
+
encoding: "utf8",
|
|
1025
|
+
timeout: 5e3,
|
|
1026
|
+
maxBuffer: 1024 * 1024,
|
|
1027
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
1028
|
+
};
|
|
1029
|
+
scm.commit = (0, import_node_child_process.execSync)("git rev-parse HEAD", execOpts).trim();
|
|
1030
|
+
scm.branch = (0, import_node_child_process.execSync)("git rev-parse --abbrev-ref HEAD", execOpts).trim();
|
|
1031
|
+
scm.author = (0, import_node_child_process.execSync)('git log -1 --pretty=format:"%an"', execOpts).trim();
|
|
1032
|
+
scm.commitMessage = (0, import_node_child_process.execSync)('git log -1 --pretty=format:"%s"', execOpts).trim();
|
|
1033
|
+
try {
|
|
1034
|
+
scm.remoteUrl = (0, import_node_child_process.execSync)("git config --get remote.origin.url", execOpts).trim();
|
|
1035
|
+
} catch {
|
|
1036
|
+
}
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
this.logger.debug(`Git info not available: ${errorMessage(error)}`);
|
|
1039
|
+
}
|
|
1040
|
+
return Object.keys(scm).length > 0 ? scm : void 0;
|
|
1041
|
+
}
|
|
1042
|
+
collectCiInfo() {
|
|
1043
|
+
const ci = {};
|
|
1044
|
+
const env = process.env;
|
|
1045
|
+
if (env.JENKINS_URL) {
|
|
1046
|
+
ci.provider = "Jenkins";
|
|
1047
|
+
ci.buildNumber = env.BUILD_NUMBER;
|
|
1048
|
+
ci.buildUrl = env.BUILD_URL;
|
|
1049
|
+
ci.jobName = env.JOB_NAME;
|
|
1050
|
+
} else if (env.GITHUB_ACTIONS) {
|
|
1051
|
+
ci.provider = "GitHub Actions";
|
|
1052
|
+
ci.runId = env.GITHUB_RUN_ID;
|
|
1053
|
+
ci.runNumber = env.GITHUB_RUN_NUMBER;
|
|
1054
|
+
ci.workflow = env.GITHUB_WORKFLOW;
|
|
1055
|
+
ci.actor = env.GITHUB_ACTOR;
|
|
1056
|
+
ci.repository = env.GITHUB_REPOSITORY;
|
|
1057
|
+
ci.ref = env.GITHUB_REF;
|
|
1058
|
+
ci.sha = env.GITHUB_SHA;
|
|
1059
|
+
ci.serverUrl = env.GITHUB_SERVER_URL;
|
|
1060
|
+
if (ci.serverUrl && ci.repository && ci.runId) {
|
|
1061
|
+
ci.buildUrl = `${ci.serverUrl}/${ci.repository}/actions/runs/${ci.runId}`;
|
|
1062
|
+
}
|
|
1063
|
+
} else if (env.GITLAB_CI) {
|
|
1064
|
+
ci.provider = "GitLab CI";
|
|
1065
|
+
ci.pipelineId = env.CI_PIPELINE_ID;
|
|
1066
|
+
ci.pipelineUrl = env.CI_PIPELINE_URL;
|
|
1067
|
+
ci.jobId = env.CI_JOB_ID;
|
|
1068
|
+
ci.jobUrl = env.CI_JOB_URL;
|
|
1069
|
+
ci.jobName = env.CI_JOB_NAME;
|
|
1070
|
+
} else if (env.CIRCLECI) {
|
|
1071
|
+
ci.provider = "CircleCI";
|
|
1072
|
+
ci.buildNumber = env.CIRCLE_BUILD_NUM;
|
|
1073
|
+
ci.buildUrl = env.CIRCLE_BUILD_URL;
|
|
1074
|
+
ci.jobName = env.CIRCLE_JOB;
|
|
1075
|
+
ci.workflow = env.CIRCLE_WORKFLOW_ID;
|
|
1076
|
+
} else if (env.TRAVIS) {
|
|
1077
|
+
ci.provider = "Travis CI";
|
|
1078
|
+
ci.buildNumber = env.TRAVIS_BUILD_NUMBER;
|
|
1079
|
+
ci.buildUrl = env.TRAVIS_BUILD_WEB_URL;
|
|
1080
|
+
ci.jobNumber = env.TRAVIS_JOB_NUMBER;
|
|
1081
|
+
} else if (env.TF_BUILD) {
|
|
1082
|
+
ci.provider = "Azure Pipelines";
|
|
1083
|
+
ci.buildNumber = env.BUILD_BUILDNUMBER;
|
|
1084
|
+
ci.buildId = env.BUILD_BUILDID;
|
|
1085
|
+
if (env.SYSTEM_TEAMFOUNDATIONSERVERURI && env.SYSTEM_TEAMPROJECT && env.BUILD_BUILDID) {
|
|
1086
|
+
ci.buildUrl = `${env.SYSTEM_TEAMFOUNDATIONSERVERURI}${env.SYSTEM_TEAMPROJECT}/_build/results?buildId=${env.BUILD_BUILDID}`;
|
|
1087
|
+
}
|
|
1088
|
+
ci.jobName = env.AGENT_JOBNAME;
|
|
1089
|
+
} else if (env.CI) {
|
|
1090
|
+
ci.provider = "Unknown CI";
|
|
1091
|
+
ci.detected = true;
|
|
1092
|
+
}
|
|
1093
|
+
return Object.keys(ci).length > 0 ? ci : void 0;
|
|
1094
|
+
}
|
|
1095
|
+
extractPlaywrightConfigMetadata(config) {
|
|
1096
|
+
const meta = {};
|
|
1097
|
+
if (config.projects?.length > 0) {
|
|
1098
|
+
meta.projects = config.projects.map((p) => ({
|
|
1099
|
+
name: p.name,
|
|
1100
|
+
testDir: p.testDir,
|
|
1101
|
+
use: {
|
|
1102
|
+
browserName: p.use?.browserName || p.name,
|
|
1103
|
+
viewport: p.use?.viewport,
|
|
1104
|
+
deviceScaleFactor: p.use?.deviceScaleFactor
|
|
1105
|
+
}
|
|
1106
|
+
}));
|
|
1107
|
+
}
|
|
1108
|
+
meta.workers = config.workers;
|
|
1109
|
+
meta.timeout = config.globalTimeout;
|
|
1110
|
+
meta.fullyParallel = config.fullyParallel;
|
|
1111
|
+
return meta;
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
// src/internal/streaming/stream-manager.ts
|
|
1116
|
+
var fs7 = __toESM(require("fs"));
|
|
1117
|
+
|
|
1118
|
+
// src/internal/support/limiter.ts
|
|
1119
|
+
function createLimiter(maxConcurrent) {
|
|
1120
|
+
const limitValue = Math.max(1, Math.floor(maxConcurrent));
|
|
1121
|
+
let active = 0;
|
|
1122
|
+
const queue = [];
|
|
1123
|
+
const next = () => {
|
|
1124
|
+
active--;
|
|
1125
|
+
const run = queue.shift();
|
|
1126
|
+
if (run) run();
|
|
1127
|
+
};
|
|
1128
|
+
return function limit(fn) {
|
|
1129
|
+
return new Promise((resolve5, reject) => {
|
|
1130
|
+
const run = () => {
|
|
1131
|
+
active++;
|
|
1132
|
+
Promise.resolve().then(fn).then(resolve5, reject).finally(next);
|
|
1133
|
+
};
|
|
1134
|
+
if (active < limitValue) run();
|
|
1135
|
+
else queue.push(run);
|
|
1136
|
+
});
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// src/internal/support/setup-file.ts
|
|
1141
|
+
var path6 = __toESM(require("path"));
|
|
1142
|
+
var os4 = __toESM(require("os"));
|
|
1143
|
+
var fs6 = __toESM(require("fs"));
|
|
1144
|
+
function getSetupFilePath(projectName) {
|
|
1145
|
+
return path6.join(os4.tmpdir(), `piwi-dashboard-setup-${hashForProject(projectName)}.json`);
|
|
1146
|
+
}
|
|
1147
|
+
function readSetupInfo(projectName) {
|
|
1148
|
+
const setupFile = getSetupFilePath(projectName);
|
|
1149
|
+
try {
|
|
1150
|
+
if (fs6.existsSync(setupFile)) {
|
|
1151
|
+
const info = JSON.parse(fs6.readFileSync(setupFile, "utf8"));
|
|
1152
|
+
fs6.unlinkSync(setupFile);
|
|
1153
|
+
if (info.projectName === projectName) return info;
|
|
1154
|
+
}
|
|
1155
|
+
} catch {
|
|
1156
|
+
}
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// src/internal/streaming/stream-manager.ts
|
|
1161
|
+
var StreamManager = class {
|
|
1162
|
+
/**
|
|
1163
|
+
* @param httpClient HTTP client for server communication.
|
|
1164
|
+
* @param streamBuffer On-disk buffer for crash-safe event persistence.
|
|
1165
|
+
* @param recovery Crash-recovery handler for uploading stale payloads on startup.
|
|
1166
|
+
* @param uploader Uploader for per-test-case file uploads.
|
|
1167
|
+
* @param fileHandler File-discovery helper for finding traces and attachments.
|
|
1168
|
+
* @param options Piwi Dashboard reporter options.
|
|
1169
|
+
* @param logger Prefixed logger.
|
|
1170
|
+
*/
|
|
1171
|
+
constructor(httpClient, streamBuffer, recovery, uploader, fileHandler, options, logger = new Logger()) {
|
|
1172
|
+
this.httpClient = httpClient;
|
|
1173
|
+
this.streamBuffer = streamBuffer;
|
|
1174
|
+
this.recovery = recovery;
|
|
1175
|
+
this.uploader = uploader;
|
|
1176
|
+
this.fileHandler = fileHandler;
|
|
1177
|
+
this.options = options;
|
|
1178
|
+
this.logger = logger;
|
|
1179
|
+
this.pendingEvents = [];
|
|
1180
|
+
this.pendingBeginEvents = [];
|
|
1181
|
+
this.flushTimer = null;
|
|
1182
|
+
this.flushPromises = [];
|
|
1183
|
+
this.liveUploadPromises = [];
|
|
1184
|
+
this.limitLiveUpload = createLimiter(2);
|
|
1185
|
+
this.retryCount = 0;
|
|
1186
|
+
this.retryTimer = null;
|
|
1187
|
+
this.maxRetryDelay = 3e4;
|
|
1188
|
+
/**
|
|
1189
|
+
* Idle heartbeat: while the run is open but no events are flowing (e.g. a single
|
|
1190
|
+
* long test, or `beforeAll` setup), a periodic ping keeps the server's activity
|
|
1191
|
+
* timestamp fresh so the stale-run reaper can tell a live run from a crashed one.
|
|
1192
|
+
* The interval must stay well below the server's stale timeout.
|
|
1193
|
+
*/
|
|
1194
|
+
this.heartbeatTimer = null;
|
|
1195
|
+
this.heartbeatStopped = false;
|
|
1196
|
+
this.lastActivityAt = 0;
|
|
1197
|
+
this.heartbeatInterval = 15e3;
|
|
1198
|
+
/** Tracks cases whose files have already been uploaded live, so `uploadRemaining` can skip them. */
|
|
1199
|
+
this.uploadedCaseFiles = /* @__PURE__ */ new WeakSet();
|
|
1200
|
+
this._enabled = false;
|
|
1201
|
+
this._runId = null;
|
|
1202
|
+
this._token = null;
|
|
1203
|
+
this._auth = null;
|
|
1204
|
+
this._startPromise = null;
|
|
1205
|
+
}
|
|
1206
|
+
/** Whether the streaming session is active */
|
|
1207
|
+
get enabled() {
|
|
1208
|
+
return this._enabled;
|
|
1209
|
+
}
|
|
1210
|
+
/** Server-assigned run ID (`null` until the stream opens) */
|
|
1211
|
+
get runId() {
|
|
1212
|
+
return this._runId;
|
|
1213
|
+
}
|
|
1214
|
+
/** Stream authentication token (`null` until the stream opens) */
|
|
1215
|
+
get token() {
|
|
1216
|
+
return this._token;
|
|
1217
|
+
}
|
|
1218
|
+
/** Resolved auth string (API key or session cookie) used in stream requests */
|
|
1219
|
+
get auth() {
|
|
1220
|
+
return this._auth;
|
|
1221
|
+
}
|
|
1222
|
+
/** Promise that resolves when the stream has been fully initialised */
|
|
1223
|
+
get startPromise() {
|
|
1224
|
+
return this._startPromise;
|
|
1225
|
+
}
|
|
1226
|
+
/** Begin the streaming session after `onBegin` fires. Non-blocking — the actual handshake runs asynchronously. */
|
|
1227
|
+
start(startTime, metadata, instanceId, playwrightVersion, reporterVersion, shardInfo, isFullRun, filterDetails) {
|
|
1228
|
+
this._startPromise = this._doStart(
|
|
1229
|
+
startTime,
|
|
1230
|
+
metadata,
|
|
1231
|
+
instanceId,
|
|
1232
|
+
playwrightVersion,
|
|
1233
|
+
reporterVersion,
|
|
1234
|
+
shardInfo,
|
|
1235
|
+
isFullRun,
|
|
1236
|
+
filterDetails
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
async _doStart(startTime, metadata, instanceId, playwrightVersion, reporterVersion, shardInfo, isFullRun, filterDetails) {
|
|
1240
|
+
const setupInfo = readSetupInfo(this.options.projectName);
|
|
1241
|
+
try {
|
|
1242
|
+
this._auth = await this.httpClient.resolveAuth(this.options);
|
|
1243
|
+
await this.recovery.tryUpload(this.httpClient, this._auth);
|
|
1244
|
+
let response;
|
|
1245
|
+
const shardIndex = shardInfo?.current;
|
|
1246
|
+
const shardTotal = shardInfo?.total;
|
|
1247
|
+
if (setupInfo) {
|
|
1248
|
+
try {
|
|
1249
|
+
response = await this.httpClient.postJSON(
|
|
1250
|
+
`/api/test-runs/${setupInfo.runId}/begin`,
|
|
1251
|
+
{
|
|
1252
|
+
setupToken: setupInfo.setupToken,
|
|
1253
|
+
totalTests: 0,
|
|
1254
|
+
metadata,
|
|
1255
|
+
playwrightVersion,
|
|
1256
|
+
reporterVersion,
|
|
1257
|
+
shardIndex,
|
|
1258
|
+
shardTotal,
|
|
1259
|
+
isFullRun,
|
|
1260
|
+
filterDetails
|
|
1261
|
+
},
|
|
1262
|
+
this._auth
|
|
1263
|
+
);
|
|
1264
|
+
} catch (error) {
|
|
1265
|
+
if (!(error instanceof HttpError && error.status === 409)) throw error;
|
|
1266
|
+
this.logger.debug(`Setup info expired, creating fresh run...`);
|
|
1267
|
+
response = await this.httpClient.postJSON(
|
|
1268
|
+
"/api/test-runs/start",
|
|
1269
|
+
{
|
|
1270
|
+
projectName: this.options.projectName,
|
|
1271
|
+
projectDescription: this.options.projectDescription,
|
|
1272
|
+
startTime,
|
|
1273
|
+
environment: this.options.environment || null,
|
|
1274
|
+
label: this.options.label || null,
|
|
1275
|
+
metadata,
|
|
1276
|
+
instanceId,
|
|
1277
|
+
playwrightVersion,
|
|
1278
|
+
reporterVersion,
|
|
1279
|
+
shardIndex,
|
|
1280
|
+
shardTotal,
|
|
1281
|
+
isFullRun,
|
|
1282
|
+
filterDetails
|
|
1283
|
+
},
|
|
1284
|
+
this._auth
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
} else {
|
|
1288
|
+
response = await this.httpClient.postJSON(
|
|
1289
|
+
"/api/test-runs/start",
|
|
1290
|
+
{
|
|
1291
|
+
projectName: this.options.projectName,
|
|
1292
|
+
projectDescription: this.options.projectDescription,
|
|
1293
|
+
startTime,
|
|
1294
|
+
environment: this.options.environment || null,
|
|
1295
|
+
label: this.options.label || null,
|
|
1296
|
+
metadata,
|
|
1297
|
+
instanceId,
|
|
1298
|
+
playwrightVersion,
|
|
1299
|
+
reporterVersion,
|
|
1300
|
+
shardIndex,
|
|
1301
|
+
shardTotal,
|
|
1302
|
+
isFullRun,
|
|
1303
|
+
filterDetails
|
|
1304
|
+
},
|
|
1305
|
+
this._auth
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
if (response?.runId && response?.streamToken) {
|
|
1309
|
+
this._runId = response.runId;
|
|
1310
|
+
this._token = response.streamToken;
|
|
1311
|
+
this._enabled = true;
|
|
1312
|
+
this.logger.info(`Streaming enabled. Run ID: ${response.runId}`);
|
|
1313
|
+
if (this.options.serverUrl) {
|
|
1314
|
+
this.logger.info(`Watch live: ${runUrl(this.options.serverUrl, response.runId)}`);
|
|
1315
|
+
}
|
|
1316
|
+
this.lastActivityAt = Date.now();
|
|
1317
|
+
this.scheduleHeartbeat();
|
|
1318
|
+
if (this.pendingBeginEvents.length > 0) {
|
|
1319
|
+
this.pendingEvents = [...this.pendingBeginEvents, ...this.pendingEvents];
|
|
1320
|
+
this.pendingBeginEvents = [];
|
|
1321
|
+
this.flush();
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
} catch (error) {
|
|
1325
|
+
if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
|
|
1326
|
+
this.logger.warn(
|
|
1327
|
+
`Live streaming could not start: the dashboard at ${this.options.serverUrl} requires authentication. Set the reporter's \`apiKey\` option (or PIWI_API_KEY) \u2014 create a key under Settings \u2192 Users on the dashboard. Falling back to batch upload at the end of the run.`
|
|
1328
|
+
);
|
|
1329
|
+
} else {
|
|
1330
|
+
this.logger.debug(`Streaming not available: ${errorMessage(error)}. Will use batch mode.`);
|
|
1331
|
+
}
|
|
1332
|
+
this._enabled = false;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
// Queues a begin event; held in a pre-start buffer until the stream is open,
|
|
1336
|
+
// then prepended to the main queue so it arrives before the matching complete event.
|
|
1337
|
+
/** 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. */
|
|
1338
|
+
queueBeginEvent(event) {
|
|
1339
|
+
if (this._enabled && this._runId) {
|
|
1340
|
+
this.queueEvent(event);
|
|
1341
|
+
} else {
|
|
1342
|
+
this.pendingBeginEvents.push(event);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
/** Queue a test-case event. Triggers an immediate flush when the batch size is reached, otherwise schedules a timer-based flush. */
|
|
1346
|
+
queueEvent(event) {
|
|
1347
|
+
this.pendingEvents.push(event);
|
|
1348
|
+
if (this.pendingEvents.length >= this.options.streamingBatchSize) {
|
|
1349
|
+
this.flush();
|
|
1350
|
+
} else if (!this.flushTimer) {
|
|
1351
|
+
this.flushTimer = setTimeout(() => this.flush(), this.options.streamingBatchDelay);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
/** Flush all pending events to the server. Returns a promise that resolves to `true` on success or `false` on failure (events are re-queued for retry). */
|
|
1355
|
+
flush() {
|
|
1356
|
+
if (this.flushTimer) {
|
|
1357
|
+
clearTimeout(this.flushTimer);
|
|
1358
|
+
this.flushTimer = null;
|
|
1359
|
+
}
|
|
1360
|
+
if (this.pendingEvents.length === 0 || !this._enabled || !this._runId) return null;
|
|
1361
|
+
const events = this.pendingEvents.splice(0);
|
|
1362
|
+
const promise = this.httpClient.postJSON(`/api/test-runs/${this._runId}/events`, { streamToken: this._token, testCases: events }, this._auth).then(
|
|
1363
|
+
() => {
|
|
1364
|
+
this.retryCount = 0;
|
|
1365
|
+
this.lastActivityAt = Date.now();
|
|
1366
|
+
return true;
|
|
1367
|
+
},
|
|
1368
|
+
() => {
|
|
1369
|
+
this.pendingEvents = events.concat(this.pendingEvents);
|
|
1370
|
+
this.scheduleRetry();
|
|
1371
|
+
return false;
|
|
1372
|
+
}
|
|
1373
|
+
);
|
|
1374
|
+
this.flushPromises.push(promise);
|
|
1375
|
+
return promise;
|
|
1376
|
+
}
|
|
1377
|
+
scheduleRetry() {
|
|
1378
|
+
if (this.retryTimer) return;
|
|
1379
|
+
this.retryCount++;
|
|
1380
|
+
const delay = Math.min(1e3 * Math.pow(2, this.retryCount - 1), this.maxRetryDelay);
|
|
1381
|
+
this.logger.debug(`Will retry streaming flush in ${delay}ms (attempt ${this.retryCount})`);
|
|
1382
|
+
this.retryTimer = setTimeout(() => {
|
|
1383
|
+
this.retryTimer = null;
|
|
1384
|
+
const buffered = this.streamBuffer.load();
|
|
1385
|
+
if (buffered.length > 0) {
|
|
1386
|
+
this.streamBuffer.clear();
|
|
1387
|
+
this.pendingEvents = buffered.concat(this.pendingEvents);
|
|
1388
|
+
}
|
|
1389
|
+
if (this.pendingEvents.length > 0) this.flush();
|
|
1390
|
+
}, delay);
|
|
1391
|
+
}
|
|
1392
|
+
// Schedule the next idle heartbeat. Self-rescheduling; cleared by stopHeartbeat.
|
|
1393
|
+
scheduleHeartbeat() {
|
|
1394
|
+
if (this.heartbeatStopped || this.heartbeatTimer) return;
|
|
1395
|
+
this.heartbeatTimer = setTimeout(() => {
|
|
1396
|
+
this.heartbeatTimer = null;
|
|
1397
|
+
void this.sendHeartbeat();
|
|
1398
|
+
}, this.heartbeatInterval);
|
|
1399
|
+
}
|
|
1400
|
+
// Ping the server only when the run has actually been idle. Real event traffic
|
|
1401
|
+
// already bumps the server's activity timestamp, so a recent flush (or pending
|
|
1402
|
+
// events about to flush) makes the ping redundant — skip it and reschedule.
|
|
1403
|
+
async sendHeartbeat() {
|
|
1404
|
+
if (this.heartbeatStopped || !this._enabled || !this._runId || !this._token) return;
|
|
1405
|
+
const idleFor = Date.now() - this.lastActivityAt;
|
|
1406
|
+
if (idleFor < this.heartbeatInterval || this.pendingEvents.length > 0) {
|
|
1407
|
+
this.scheduleHeartbeat();
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
try {
|
|
1411
|
+
await this.httpClient.postJSON(
|
|
1412
|
+
`/api/test-runs/${this._runId}/heartbeat`,
|
|
1413
|
+
{ streamToken: this._token },
|
|
1414
|
+
this._auth
|
|
1415
|
+
);
|
|
1416
|
+
this.lastActivityAt = Date.now();
|
|
1417
|
+
} catch (error) {
|
|
1418
|
+
this.logger.debug(`Heartbeat failed: ${errorMessage(error)}`);
|
|
1419
|
+
}
|
|
1420
|
+
this.scheduleHeartbeat();
|
|
1421
|
+
}
|
|
1422
|
+
/** Stop the idle heartbeat permanently (called when the run is wrapping up). */
|
|
1423
|
+
stopHeartbeat() {
|
|
1424
|
+
this.heartbeatStopped = true;
|
|
1425
|
+
if (this.heartbeatTimer) {
|
|
1426
|
+
clearTimeout(this.heartbeatTimer);
|
|
1427
|
+
this.heartbeatTimer = null;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
/** Drain all pending and buffered events before the run finishes. Retries up to 10 times with exponential back-off. */
|
|
1431
|
+
async drain() {
|
|
1432
|
+
this.stopHeartbeat();
|
|
1433
|
+
if (!this._enabled) {
|
|
1434
|
+
this.pendingEvents = [];
|
|
1435
|
+
this.flushPromises = [];
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
const MAX_ATTEMPTS = 10;
|
|
1439
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
1440
|
+
if (this._enabled && this.pendingEvents.length > 0) this.flush();
|
|
1441
|
+
if (this.flushPromises.length > 0) {
|
|
1442
|
+
await Promise.allSettled(this.flushPromises);
|
|
1443
|
+
this.flushPromises = [];
|
|
1444
|
+
}
|
|
1445
|
+
if (this.pendingEvents.length === 0) {
|
|
1446
|
+
const buffered = this.streamBuffer.load();
|
|
1447
|
+
if (buffered.length > 0) {
|
|
1448
|
+
this.pendingEvents = buffered;
|
|
1449
|
+
this.streamBuffer.clear();
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
this.logger.debugError(
|
|
1455
|
+
`${this.pendingEvents.length} events pending, retrying (attempt ${attempt + 1}/${MAX_ATTEMPTS})...`
|
|
1456
|
+
);
|
|
1457
|
+
await new Promise((resolve5) => setTimeout(resolve5, Math.min(1e3 * Math.pow(2, attempt), 1e4)));
|
|
1458
|
+
}
|
|
1459
|
+
if (this.pendingEvents.length > 0) {
|
|
1460
|
+
this.streamBuffer.append(this.pendingEvents);
|
|
1461
|
+
this.pendingEvents = [];
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
/** Schedule a live upload of trace and attachment files for a test case. Skips cases with no files. Concurrency is limited to 2 simultaneous uploads. */
|
|
1465
|
+
scheduleLiveUpload(tc) {
|
|
1466
|
+
const hasTrace = !!this.options.uploadTraces && this.fileHandler.findTraceFiles(tc).some((p) => fs7.existsSync(p));
|
|
1467
|
+
const hasAttachments = this.fileHandler.findAllAttachments(tc).length > 0;
|
|
1468
|
+
if (!hasTrace && !hasAttachments) return;
|
|
1469
|
+
const promise = (async () => {
|
|
1470
|
+
if (this._startPromise) await this._startPromise;
|
|
1471
|
+
if (!this._enabled || !this._runId || !this._token) return;
|
|
1472
|
+
if (this.flushPromises.length > 0) {
|
|
1473
|
+
await Promise.allSettled(this.flushPromises);
|
|
1474
|
+
}
|
|
1475
|
+
const flush = this.flush();
|
|
1476
|
+
if (flush) await flush;
|
|
1477
|
+
const delays = [0, 1e3, 3e3];
|
|
1478
|
+
for (let attempt = 0; attempt < delays.length; attempt++) {
|
|
1479
|
+
if (delays[attempt]) await new Promise((resolve5) => setTimeout(resolve5, delays[attempt]));
|
|
1480
|
+
try {
|
|
1481
|
+
await this.limitLiveUpload(
|
|
1482
|
+
() => this.uploader.uploadCaseFiles(
|
|
1483
|
+
this.options.projectName,
|
|
1484
|
+
this._runId,
|
|
1485
|
+
this._token,
|
|
1486
|
+
tc,
|
|
1487
|
+
this.options.uploadTraces,
|
|
1488
|
+
this._auth
|
|
1489
|
+
)
|
|
1490
|
+
);
|
|
1491
|
+
this.uploadedCaseFiles.add(tc);
|
|
1492
|
+
return;
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
const retryable = error instanceof HttpError && error.status === 404;
|
|
1495
|
+
if (!retryable || attempt === delays.length - 1) {
|
|
1496
|
+
this.logger.debug(`Live file upload failed for "${tc.title}": ${errorMessage(error)}`);
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
})();
|
|
1502
|
+
this.liveUploadPromises.push(promise);
|
|
1503
|
+
}
|
|
1504
|
+
/** Wait for all live uploads to settle, then upload files for any test cases that weren't uploaded live */
|
|
1505
|
+
async uploadRemaining(testCases) {
|
|
1506
|
+
if (this.liveUploadPromises.length > 0) {
|
|
1507
|
+
await Promise.allSettled(this.liveUploadPromises);
|
|
1508
|
+
this.liveUploadPromises = [];
|
|
1509
|
+
}
|
|
1510
|
+
if (!this._enabled || !this._runId || !this._token) return;
|
|
1511
|
+
for (const tc of testCases) {
|
|
1512
|
+
if (this.uploadedCaseFiles.has(tc)) continue;
|
|
1513
|
+
try {
|
|
1514
|
+
const uploaded = await this.uploader.uploadCaseFiles(
|
|
1515
|
+
this.options.projectName,
|
|
1516
|
+
this._runId,
|
|
1517
|
+
this._token,
|
|
1518
|
+
tc,
|
|
1519
|
+
this.options.uploadTraces,
|
|
1520
|
+
this._auth
|
|
1521
|
+
);
|
|
1522
|
+
if (uploaded) this.uploadedCaseFiles.add(tc);
|
|
1523
|
+
} catch (error) {
|
|
1524
|
+
this.logger.warn(`Failed to upload files for "${tc.title}": ${errorMessage(error)}`);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
|
|
1530
|
+
// src/internal/collect/step-analyzer.ts
|
|
1531
|
+
function categorizeStep(title, pwCategory) {
|
|
1532
|
+
if (!title) return "other";
|
|
1533
|
+
if (pwCategory === "hook" || pwCategory === "fixture") return pwCategory;
|
|
1534
|
+
if (pwCategory === "expect") return "assertion";
|
|
1535
|
+
const lower = title.toLowerCase();
|
|
1536
|
+
if (lower.startsWith("wait for") || lower.startsWith("locator.waitfor") || lower.startsWith("page.waitfor") || lower.startsWith("frame.waitfor"))
|
|
1537
|
+
return "wait";
|
|
1538
|
+
if (lower.startsWith("navigate to") || lower.startsWith("go back") || lower.startsWith("go forward") || lower.startsWith("reload") || lower.startsWith("page.goto") || lower.startsWith("page.reload") || lower.startsWith("page.goback") || lower.startsWith("page.goforward"))
|
|
1539
|
+
return "navigation";
|
|
1540
|
+
if (lower.startsWith("click") || lower.startsWith("double click") || lower.startsWith("check") || lower.startsWith("uncheck") || lower.startsWith("tap") || lower.startsWith("hover") || lower.startsWith("select option") || lower.startsWith("drag") || lower.startsWith("locator.click") || lower.startsWith("locator.dblclick") || lower.startsWith("locator.check") || lower.startsWith("locator.uncheck") || lower.startsWith("locator.selectoption") || lower.startsWith("locator.tap"))
|
|
1541
|
+
return "action";
|
|
1542
|
+
if (lower.startsWith("fill ") || lower === "fill" || lower.startsWith("type") || lower.startsWith("press") || lower.startsWith("insert ") || lower.startsWith("set input files") || lower.startsWith("locator.fill") || lower.startsWith("locator.type") || lower.startsWith("locator.press") || lower.startsWith("locator.clear") || lower.startsWith("locator.setinputfiles"))
|
|
1543
|
+
return "input";
|
|
1544
|
+
if (lower.startsWith("expect") || lower.startsWith("locator.expect") || lower.startsWith("page.expect"))
|
|
1545
|
+
return "assertion";
|
|
1546
|
+
if (lower.startsWith("apirequestcontext") || lower.startsWith("apiresponse")) return "api";
|
|
1547
|
+
if (lower === "before hooks" || lower === "after hooks" || lower.startsWith("fixture:")) return "hook";
|
|
1548
|
+
return "other";
|
|
1549
|
+
}
|
|
1550
|
+
function flattenSteps(steps) {
|
|
1551
|
+
const result = [];
|
|
1552
|
+
for (const step of steps) {
|
|
1553
|
+
const flat = {
|
|
1554
|
+
title: step.title,
|
|
1555
|
+
duration: step.duration,
|
|
1556
|
+
category: categorizeStep(step.title, step.category)
|
|
1557
|
+
};
|
|
1558
|
+
if (step.error?.message) {
|
|
1559
|
+
flat.error = { message: step.error.message };
|
|
1560
|
+
flat.failed = true;
|
|
1561
|
+
}
|
|
1562
|
+
if (step.location) flat.location = `${step.location.file}:${step.location.line}:${step.location.column}`;
|
|
1563
|
+
if (step.startTime) flat.startTime = step.startTime instanceof Date ? step.startTime.getTime() : step.startTime;
|
|
1564
|
+
result.push(flat);
|
|
1565
|
+
if (step.steps?.length > 0) result.push(...flattenSteps(step.steps));
|
|
1566
|
+
}
|
|
1567
|
+
return result;
|
|
1568
|
+
}
|
|
1569
|
+
function collectStepMetrics(steps) {
|
|
1570
|
+
const flatSteps = flattenSteps(steps);
|
|
1571
|
+
const totalStepDuration = steps.reduce((sum, s) => sum + (s.duration || 0), 0);
|
|
1572
|
+
let slowestStep = null;
|
|
1573
|
+
for (const s of flatSteps) {
|
|
1574
|
+
if (!slowestStep || s.duration > slowestStep.duration) slowestStep = { title: s.title, duration: s.duration };
|
|
1575
|
+
}
|
|
1576
|
+
const navSteps = flatSteps.filter((s) => s.category === "navigation");
|
|
1577
|
+
const waitSteps = flatSteps.filter((s) => s.category === "wait");
|
|
1578
|
+
const waitTotalDuration = waitSteps.reduce((sum, s) => sum + (s.duration || 0), 0);
|
|
1579
|
+
return {
|
|
1580
|
+
steps: flatSteps,
|
|
1581
|
+
totalStepDuration,
|
|
1582
|
+
slowestStep,
|
|
1583
|
+
navigationCount: navSteps.length,
|
|
1584
|
+
navigationTotalDuration: navSteps.reduce((sum, s) => sum + (s.duration || 0), 0),
|
|
1585
|
+
waitTotalDuration,
|
|
1586
|
+
waitCount: waitSteps.length
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
function percentile(sortedArr, p) {
|
|
1590
|
+
if (sortedArr.length === 0) return 0;
|
|
1591
|
+
const index = Math.ceil(p / 100 * sortedArr.length) - 1;
|
|
1592
|
+
return sortedArr[Math.max(0, index)];
|
|
1593
|
+
}
|
|
1594
|
+
function computePerformanceSummary(testCases) {
|
|
1595
|
+
const durations = testCases.filter((tc) => tc.duration != null).map((tc) => tc.duration);
|
|
1596
|
+
if (durations.length === 0) return {};
|
|
1597
|
+
const sorted = [...durations].sort((a, b) => a - b);
|
|
1598
|
+
const sum = durations.reduce((a, b) => a + b, 0);
|
|
1599
|
+
const result = {
|
|
1600
|
+
avgTestDuration: Math.round(sum / durations.length),
|
|
1601
|
+
p50TestDuration: percentile(sorted, 50),
|
|
1602
|
+
p90TestDuration: percentile(sorted, 90),
|
|
1603
|
+
p95TestDuration: percentile(sorted, 95),
|
|
1604
|
+
slowestTests: [...testCases].filter((tc) => tc.duration != null).sort((a, b) => b.duration - a.duration).slice(0, 5).map((tc) => ({ title: tc.title, duration: tc.duration }))
|
|
1605
|
+
};
|
|
1606
|
+
let totalNavDur = 0;
|
|
1607
|
+
let totalNavCount = 0;
|
|
1608
|
+
for (const tc of testCases) {
|
|
1609
|
+
if (tc.performanceMetrics) {
|
|
1610
|
+
totalNavDur += tc.performanceMetrics.navigationTotalDuration || 0;
|
|
1611
|
+
totalNavCount += tc.performanceMetrics.navigationCount || 0;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
result.totalNavigationDuration = totalNavDur;
|
|
1615
|
+
result.avgNavigationDuration = totalNavCount > 0 ? Math.round(totalNavDur / totalNavCount) : 0;
|
|
1616
|
+
let totalWasted = 0;
|
|
1617
|
+
for (const tc of testCases) {
|
|
1618
|
+
if (tc.performanceMetrics) {
|
|
1619
|
+
totalWasted += tc.performanceMetrics.waitTotalDuration || 0;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
result.totalWastedTimeMs = totalWasted;
|
|
1623
|
+
return result;
|
|
1624
|
+
}
|
|
1625
|
+
function extractTestStepEvents(steps, _testStartTime) {
|
|
1626
|
+
const events = [];
|
|
1627
|
+
for (const step of steps) {
|
|
1628
|
+
const cat = categorizeStep(step.title, step.category);
|
|
1629
|
+
if (cat !== "hook" && cat !== "fixture") continue;
|
|
1630
|
+
if (!step.startTime) continue;
|
|
1631
|
+
const startedAt = step.startTime instanceof Date ? step.startTime.getTime() : step.startTime;
|
|
1632
|
+
events.push({
|
|
1633
|
+
title: step.title,
|
|
1634
|
+
category: cat,
|
|
1635
|
+
startedAt,
|
|
1636
|
+
duration: step.duration || 0,
|
|
1637
|
+
status: step.error ? "failed" : "passed",
|
|
1638
|
+
location: step.location ? `${step.location.file}:${step.location.line}:${step.location.column}` : null
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
return events;
|
|
1642
|
+
}
|
|
1643
|
+
function extractWaitEvents(steps, insideWait = false) {
|
|
1644
|
+
const events = [];
|
|
1645
|
+
for (const step of steps) {
|
|
1646
|
+
const cat = categorizeStep(step.title, step.category);
|
|
1647
|
+
const isWait = cat === "wait";
|
|
1648
|
+
if (isWait && !insideWait && step.startTime) {
|
|
1649
|
+
const startedAt = step.startTime instanceof Date ? step.startTime.getTime() : step.startTime;
|
|
1650
|
+
events.push({
|
|
1651
|
+
title: step.title,
|
|
1652
|
+
category: "wait",
|
|
1653
|
+
startedAt,
|
|
1654
|
+
duration: step.duration || 0,
|
|
1655
|
+
status: "wasted",
|
|
1656
|
+
location: step.location ? `${step.location.file}:${step.location.line}:${step.location.column}` : null
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
if (step.steps?.length > 0) {
|
|
1660
|
+
events.push(...extractWaitEvents(step.steps, insideWait || isWait));
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
return events;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// src/internal/support/reporter-version.ts
|
|
1667
|
+
var fs8 = __toESM(require("fs"));
|
|
1668
|
+
var path7 = __toESM(require("path"));
|
|
1669
|
+
var cachedVersion = null;
|
|
1670
|
+
var CANDIDATE_OFFSETS = ["..", "../../.."];
|
|
1671
|
+
function findOwnPackageJson(fromDir) {
|
|
1672
|
+
for (const offset of CANDIDATE_OFFSETS) {
|
|
1673
|
+
try {
|
|
1674
|
+
const pkgPath = path7.resolve(fromDir, offset, "package.json");
|
|
1675
|
+
const pkg = JSON.parse(fs8.readFileSync(pkgPath, "utf-8"));
|
|
1676
|
+
const { name, version } = pkg;
|
|
1677
|
+
if (name === "@piwitests/reporter" && typeof version === "string") return { version };
|
|
1678
|
+
} catch {
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
return void 0;
|
|
1682
|
+
}
|
|
1683
|
+
function getReporterVersion() {
|
|
1684
|
+
if (cachedVersion) return cachedVersion;
|
|
1685
|
+
cachedVersion = findOwnPackageJson(__dirname)?.version ?? "unknown";
|
|
1686
|
+
return cachedVersion;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// src/internal/support/source-snippet.ts
|
|
1690
|
+
var fs9 = __toESM(require("fs"));
|
|
1691
|
+
var path8 = __toESM(require("path"));
|
|
1692
|
+
function readSourceSnippet(file, declLine, context, failingLine) {
|
|
1693
|
+
try {
|
|
1694
|
+
const content = fs9.readFileSync(file, "utf-8");
|
|
1695
|
+
const lines = content.split("\n");
|
|
1696
|
+
const anchor = failingLine ?? declLine;
|
|
1697
|
+
const start = Math.max(0, anchor - context - 1);
|
|
1698
|
+
const end = Math.min(lines.length, anchor + context);
|
|
1699
|
+
return lines.slice(start, end).map((l, i) => {
|
|
1700
|
+
const lineNum = start + i + 1;
|
|
1701
|
+
const hasFailingLine = failingLine != null;
|
|
1702
|
+
const isFailing = hasFailingLine && lineNum === failingLine;
|
|
1703
|
+
const isDecl = lineNum === declLine && !isFailing;
|
|
1704
|
+
let marker = " ";
|
|
1705
|
+
if (isFailing) marker = "> ";
|
|
1706
|
+
else if (isDecl) marker = hasFailingLine ? "* " : "> ";
|
|
1707
|
+
return `${marker}${String(lineNum).padStart(4)} | ${l}`;
|
|
1708
|
+
}).join("\n");
|
|
1709
|
+
} catch {
|
|
1710
|
+
return null;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
function collectSourceFrames(errorText, testFile, declLine, opts = {}) {
|
|
1714
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
1715
|
+
const context = opts.context ?? 8;
|
|
1716
|
+
const maxFrames = opts.maxFrames ?? 4;
|
|
1717
|
+
const picked = [];
|
|
1718
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1719
|
+
const add = (absFile, line) => {
|
|
1720
|
+
const key = `${absFile}:${line}`;
|
|
1721
|
+
if (seen.has(key)) return;
|
|
1722
|
+
seen.add(key);
|
|
1723
|
+
picked.push({ absFile, line });
|
|
1724
|
+
};
|
|
1725
|
+
if (errorText) {
|
|
1726
|
+
const stackRe = /^\s+at (?:[^(]*\()?(.+?):(\d+):\d+\)?\s*$/gm;
|
|
1727
|
+
let m;
|
|
1728
|
+
while ((m = stackRe.exec(errorText)) !== null && picked.length < maxFrames) {
|
|
1729
|
+
let abs;
|
|
1730
|
+
try {
|
|
1731
|
+
abs = path8.resolve(m[1]);
|
|
1732
|
+
} catch {
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
if (abs.includes(`${path8.sep}node_modules${path8.sep}`)) continue;
|
|
1736
|
+
const rel = path8.relative(projectRoot, abs);
|
|
1737
|
+
if (rel.startsWith("..") || path8.isAbsolute(rel)) continue;
|
|
1738
|
+
const line = parseInt(m[2], 10);
|
|
1739
|
+
if (!isNaN(line)) add(abs, line);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
const absTest = path8.resolve(testFile);
|
|
1743
|
+
if (!picked.some((p) => p.absFile === absTest)) {
|
|
1744
|
+
add(absTest, extractFailingLine(errorText, testFile, declLine));
|
|
1745
|
+
}
|
|
1746
|
+
const frames = [];
|
|
1747
|
+
for (const p of picked.slice(0, maxFrames)) {
|
|
1748
|
+
const snippet = readSourceSnippet(p.absFile, p.line, context, p.line);
|
|
1749
|
+
if (snippet) {
|
|
1750
|
+
frames.push({ file: path8.relative(projectRoot, p.absFile) || path8.basename(p.absFile), line: p.line, snippet });
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
return frames;
|
|
1754
|
+
}
|
|
1755
|
+
function extractFailingLine(errorText, testFile, declarationLine) {
|
|
1756
|
+
if (!errorText) return declarationLine;
|
|
1757
|
+
const expectedFile = path8.resolve(testFile);
|
|
1758
|
+
const stackRe = /^\s+at (?:[^(]*\()?(.+?):(\d+):\d+\)?\s*$/gm;
|
|
1759
|
+
let m;
|
|
1760
|
+
while ((m = stackRe.exec(errorText)) !== null) {
|
|
1761
|
+
try {
|
|
1762
|
+
const frameFile = path8.resolve(m[1]);
|
|
1763
|
+
if (frameFile === expectedFile) {
|
|
1764
|
+
const line = parseInt(m[2], 10);
|
|
1765
|
+
if (!isNaN(line)) return line;
|
|
1766
|
+
}
|
|
1767
|
+
} catch {
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
return declarationLine;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// src/internal/support/ci.ts
|
|
1774
|
+
function detectCiRunLabel() {
|
|
1775
|
+
const env = process.env;
|
|
1776
|
+
if (env.GITHUB_ACTIONS && env.GITHUB_RUN_ID) return env.GITHUB_RUN_ID;
|
|
1777
|
+
if (env.GITLAB_CI && env.CI_PIPELINE_ID) return env.CI_PIPELINE_ID;
|
|
1778
|
+
if (env.CIRCLECI && env.CIRCLE_WORKFLOW_ID) return env.CIRCLE_WORKFLOW_ID;
|
|
1779
|
+
if (env.TRAVIS && env.TRAVIS_BUILD_ID) return env.TRAVIS_BUILD_ID;
|
|
1780
|
+
if (env.TF_BUILD && env.BUILD_BUILDID) return env.BUILD_BUILDID;
|
|
1781
|
+
if (env.JENKINS_URL && env.BUILD_ID) return env.BUILD_ID;
|
|
1782
|
+
if (env.BUILDKITE_BUILD_ID) return env.BUILDKITE_BUILD_ID;
|
|
1783
|
+
if (env.TEAMCITY_BUILD_ID) return env.TEAMCITY_BUILD_ID;
|
|
1784
|
+
if (env.BITBUCKET_BUILD_NUMBER) return env.BITBUCKET_BUILD_NUMBER;
|
|
1785
|
+
if (env.SEMAPHORE_WORKFLOW_ID) return env.SEMAPHORE_WORKFLOW_ID;
|
|
1786
|
+
if (env.APPVEYOR_BUILD_ID) return env.APPVEYOR_BUILD_ID;
|
|
1787
|
+
if (env.DRONE_BUILD_NUMBER) return env.DRONE_BUILD_NUMBER;
|
|
1788
|
+
return null;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// src/internal/support/worker-index.ts
|
|
1792
|
+
function workerIndexOf(result) {
|
|
1793
|
+
if (!result) return null;
|
|
1794
|
+
return result.workerIndex ?? result.parallelIndex ?? null;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// src/internal/support/cli-filters.ts
|
|
1798
|
+
var PW_VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
1799
|
+
"-c",
|
|
1800
|
+
"--config",
|
|
1801
|
+
"-j",
|
|
1802
|
+
"--workers",
|
|
1803
|
+
"-g",
|
|
1804
|
+
"--grep",
|
|
1805
|
+
"--grep-invert",
|
|
1806
|
+
"--global-timeout",
|
|
1807
|
+
"--max-failures",
|
|
1808
|
+
"--output",
|
|
1809
|
+
"-p",
|
|
1810
|
+
"--project",
|
|
1811
|
+
"--repeat-each",
|
|
1812
|
+
"--reporter",
|
|
1813
|
+
"--retries",
|
|
1814
|
+
"--shard",
|
|
1815
|
+
"--timeout",
|
|
1816
|
+
"--trace"
|
|
1817
|
+
]);
|
|
1818
|
+
function detectCliFileFilters(argv = process.argv) {
|
|
1819
|
+
const args = argv.slice(2);
|
|
1820
|
+
const testIdx = args.indexOf("test");
|
|
1821
|
+
const rest = testIdx >= 0 ? args.slice(testIdx + 1) : args;
|
|
1822
|
+
const files = [];
|
|
1823
|
+
for (let i = 0; i < rest.length; i++) {
|
|
1824
|
+
const tok = rest[i];
|
|
1825
|
+
if (tok.startsWith("-")) {
|
|
1826
|
+
if (!tok.includes("=") && PW_VALUE_FLAGS.has(tok)) i++;
|
|
1827
|
+
continue;
|
|
1828
|
+
}
|
|
1829
|
+
files.push(tok);
|
|
1830
|
+
}
|
|
1831
|
+
return files;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// src/public/global-setup.ts
|
|
1835
|
+
var path9 = __toESM(require("path"));
|
|
1836
|
+
var fs10 = __toESM(require("fs"));
|
|
1837
|
+
function createGlobalSetup(options, userSetup) {
|
|
1838
|
+
return async function globalSetupFn(config) {
|
|
1839
|
+
const piwiReporterPath = path9.resolve(__dirname, "./index.js");
|
|
1840
|
+
let inlineReporterOptions = {};
|
|
1841
|
+
if (Array.isArray(config?.reporter)) {
|
|
1842
|
+
for (const r of config.reporter) {
|
|
1843
|
+
if (!Array.isArray(r) || typeof r[0] !== "string") continue;
|
|
1844
|
+
const isPiwi = r[0].toLowerCase().includes("piwi") || (() => {
|
|
1845
|
+
try {
|
|
1846
|
+
return path9.resolve(require.resolve(r[0])) === piwiReporterPath;
|
|
1847
|
+
} catch {
|
|
1848
|
+
return false;
|
|
1849
|
+
}
|
|
1850
|
+
})();
|
|
1851
|
+
if (isPiwi && r[1] && typeof r[1] === "object") {
|
|
1852
|
+
inlineReporterOptions = r[1];
|
|
1853
|
+
break;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
const opts = resolveOptions({ ...inlineReporterOptions, ...options });
|
|
1858
|
+
const logger = new Logger(opts.verbose ?? false);
|
|
1859
|
+
if (opts.enabled === false || !opts.serverUrl) {
|
|
1860
|
+
logger.info("Not enabled \u2014 set PIWI_DASHBOARD_URL or serverUrl to enable.");
|
|
1861
|
+
if (userSetup) return userSetup(config);
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
const hasPiwi = Object.keys(inlineReporterOptions).length > 0 || Array.isArray(config?.reporter) && config.reporter.some((r) => {
|
|
1865
|
+
if (!Array.isArray(r) || typeof r[0] !== "string") return false;
|
|
1866
|
+
if (r[0].toLowerCase().includes("piwi")) return true;
|
|
1867
|
+
try {
|
|
1868
|
+
return path9.resolve(require.resolve(r[0])) === piwiReporterPath;
|
|
1869
|
+
} catch {
|
|
1870
|
+
return false;
|
|
1871
|
+
}
|
|
1872
|
+
});
|
|
1873
|
+
if (!hasPiwi) {
|
|
1874
|
+
logger.debug("Not reporting \u2014 Piwi is not in the Playwright reporters list.");
|
|
1875
|
+
if (userSetup) return userSetup(config);
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
const httpClient = new HttpClient(opts.serverUrl, logger);
|
|
1879
|
+
try {
|
|
1880
|
+
const auth = await httpClient.resolveAuth(opts);
|
|
1881
|
+
const runLabel = opts.runLabel || detectCiRunLabel();
|
|
1882
|
+
const pwShard = config.shard;
|
|
1883
|
+
const shardIndex = pwShard?.current;
|
|
1884
|
+
const shardTotal = pwShard?.total;
|
|
1885
|
+
const response = await httpClient.postJSON(
|
|
1886
|
+
"/api/test-runs/setup",
|
|
1887
|
+
{
|
|
1888
|
+
projectName: opts.projectName,
|
|
1889
|
+
projectDescription: opts.projectDescription,
|
|
1890
|
+
environment: opts.environment || null,
|
|
1891
|
+
label: opts.label || null,
|
|
1892
|
+
startTime: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1893
|
+
instanceId: computeInstanceId(opts.projectName, runLabel),
|
|
1894
|
+
shardIndex,
|
|
1895
|
+
shardTotal
|
|
1896
|
+
},
|
|
1897
|
+
auth
|
|
1898
|
+
);
|
|
1899
|
+
if (response?.runId && response?.setupToken) {
|
|
1900
|
+
fs10.writeFileSync(
|
|
1901
|
+
getSetupFilePath(opts.projectName),
|
|
1902
|
+
JSON.stringify({
|
|
1903
|
+
runId: response.runId,
|
|
1904
|
+
setupToken: response.setupToken,
|
|
1905
|
+
projectName: opts.projectName
|
|
1906
|
+
})
|
|
1907
|
+
);
|
|
1908
|
+
logger.debug(`Global setup: initialising run #${response.runId}`);
|
|
1909
|
+
}
|
|
1910
|
+
} catch (error) {
|
|
1911
|
+
logger.warn(`Could not register global setup: ${errorMessage(error)}`);
|
|
1912
|
+
}
|
|
1913
|
+
if (userSetup) return userSetup(config);
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// src/public/config-wrapper.ts
|
|
1918
|
+
var path10 = __toESM(require("path"));
|
|
1919
|
+
var fs11 = __toESM(require("fs"));
|
|
1920
|
+
var PIWI_MODULE = "@piwitests/reporter";
|
|
1921
|
+
function isPiwiReporterEntry(entry) {
|
|
1922
|
+
if (typeof entry === "string") return entry.toLowerCase().includes("piwi");
|
|
1923
|
+
if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0].toLowerCase().includes("piwi");
|
|
1924
|
+
return false;
|
|
1925
|
+
}
|
|
1926
|
+
function injectReporter(reporter, piwiOptions) {
|
|
1927
|
+
const piwiEntry = piwiOptions ? [PIWI_MODULE, piwiOptions] : [PIWI_MODULE];
|
|
1928
|
+
if (!reporter) return [piwiEntry];
|
|
1929
|
+
if (Array.isArray(reporter)) {
|
|
1930
|
+
if (reporter.some(isPiwiReporterEntry)) return reporter;
|
|
1931
|
+
return [...reporter, piwiEntry];
|
|
1932
|
+
}
|
|
1933
|
+
return [[reporter], piwiEntry];
|
|
1934
|
+
}
|
|
1935
|
+
function resolveSetupModule() {
|
|
1936
|
+
const candidates = [
|
|
1937
|
+
path10.join(__dirname, "global-setup-module.js"),
|
|
1938
|
+
path10.join(__dirname, "..", "global-setup-module.js"),
|
|
1939
|
+
path10.join(__dirname, "..", "global-setup-module.ts")
|
|
1940
|
+
];
|
|
1941
|
+
return candidates.find((candidate) => fs11.existsSync(candidate)) ?? candidates[0];
|
|
1942
|
+
}
|
|
1943
|
+
function wrapConfig(config, piwiOptions) {
|
|
1944
|
+
if (piwiOptions) applyOptionsToEnv(piwiOptions);
|
|
1945
|
+
const globalSetupModules = [];
|
|
1946
|
+
if (config.globalSetup) {
|
|
1947
|
+
const orig = Array.isArray(config.globalSetup) ? config.globalSetup : [config.globalSetup];
|
|
1948
|
+
globalSetupModules.push(...orig);
|
|
1949
|
+
}
|
|
1950
|
+
globalSetupModules.push(resolveSetupModule());
|
|
1951
|
+
return {
|
|
1952
|
+
...config,
|
|
1953
|
+
reporter: injectReporter(config.reporter, piwiOptions),
|
|
1954
|
+
globalSetup: globalSetupModules.length === 1 ? globalSetupModules[0] : globalSetupModules
|
|
1955
|
+
};
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// src/internal/collect/skip-classify.ts
|
|
1959
|
+
function mergeAnnotations(test, result) {
|
|
1960
|
+
const out = [];
|
|
1961
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1962
|
+
for (const list of [test.annotations, result.annotations]) {
|
|
1963
|
+
for (const a of list ?? []) {
|
|
1964
|
+
const key = `${a.type}\0${a.description ?? ""}`;
|
|
1965
|
+
if (seen.has(key)) continue;
|
|
1966
|
+
seen.add(key);
|
|
1967
|
+
out.push(a.description === void 0 ? { type: a.type } : { type: a.type, description: a.description });
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
return out;
|
|
1971
|
+
}
|
|
1972
|
+
function classifyStatus(rawStatus, annotations) {
|
|
1973
|
+
if (rawStatus !== "skipped") return rawStatus;
|
|
1974
|
+
const intentional = annotations.some((a) => a.type === "skip" || a.type === "fixme");
|
|
1975
|
+
return intentional ? "skipped" : "didnotrun";
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// src/internal/collect/error-text.ts
|
|
1979
|
+
var path11 = __toESM(require("path"));
|
|
1980
|
+
function buildErrorText(result) {
|
|
1981
|
+
if (!result.errors || result.errors.length === 0) return null;
|
|
1982
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1983
|
+
const parts = [];
|
|
1984
|
+
for (const err of result.errors) {
|
|
1985
|
+
const msg = err.message ?? "";
|
|
1986
|
+
if (!msg) continue;
|
|
1987
|
+
const dedupeKey = msg.trim().slice(0, 200);
|
|
1988
|
+
if (seen.has(dedupeKey)) continue;
|
|
1989
|
+
seen.add(dedupeKey);
|
|
1990
|
+
parts.push(msg);
|
|
1991
|
+
}
|
|
1992
|
+
let text = parts.join("\n---\n");
|
|
1993
|
+
if (!text) return null;
|
|
1994
|
+
const primary = result.error;
|
|
1995
|
+
const loc = primary?.location;
|
|
1996
|
+
if (loc?.file && !/\n\s+at\s/.test(text)) {
|
|
1997
|
+
const rel = path11.relative(process.cwd(), loc.file).split(path11.sep).join("/");
|
|
1998
|
+
text += `
|
|
1999
|
+
at ${rel}:${loc.line}:${loc.column}`;
|
|
2000
|
+
}
|
|
2001
|
+
return text;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// src/internal/submit/run-submitter.ts
|
|
2005
|
+
var RunSubmitter = class {
|
|
2006
|
+
/**
|
|
2007
|
+
* @param httpClient HTTP client for auth resolution and the finish call.
|
|
2008
|
+
* @param uploader Upload strategies (multipart + JSON).
|
|
2009
|
+
* @param recovery Crash-recovery persistence.
|
|
2010
|
+
* @param streamManager Streaming session (may be `null` when streaming is disabled).
|
|
2011
|
+
* @param logger Prefixed logger.
|
|
2012
|
+
*/
|
|
2013
|
+
constructor(httpClient, uploader, recovery, streamManager, logger = new Logger()) {
|
|
2014
|
+
this.httpClient = httpClient;
|
|
2015
|
+
this.uploader = uploader;
|
|
2016
|
+
this.recovery = recovery;
|
|
2017
|
+
this.streamManager = streamManager;
|
|
2018
|
+
this.logger = logger;
|
|
2019
|
+
}
|
|
2020
|
+
/** Run the fallback ladder for a completed test run. */
|
|
2021
|
+
async submit(run, result) {
|
|
2022
|
+
const endTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
2023
|
+
const duration = new Date(endTime).getTime() - new Date(run.startTime).getTime();
|
|
2024
|
+
const overallStatus = resolveOverallStatus(result, {
|
|
2025
|
+
failedTests: run.failedTests,
|
|
2026
|
+
timedOutTests: run.timedOutTests,
|
|
2027
|
+
totalTests: run.totalTests
|
|
2028
|
+
});
|
|
2029
|
+
this.logger.info(
|
|
2030
|
+
`Test run completed. Status: ${overallStatus} (Playwright result.status: ${result?.status || "undefined"})`
|
|
2031
|
+
);
|
|
2032
|
+
this.logger.info(
|
|
2033
|
+
`Total: ${run.totalTests}, Passed: ${run.passedTests}, Failed: ${run.failedTests}, Skipped: ${run.skippedTests}, TimedOut: ${run.timedOutTests}, DidNotRun: ${run.didNotRunTests}`
|
|
2034
|
+
);
|
|
2035
|
+
if (run.options.collectPerformanceMetrics) {
|
|
2036
|
+
run.metadata.performance = computePerformanceSummary(run.testCases);
|
|
2037
|
+
}
|
|
2038
|
+
const sm = this.streamManager;
|
|
2039
|
+
if (sm?.startPromise) await sm.startPromise;
|
|
2040
|
+
await sm?.drain();
|
|
2041
|
+
let auth;
|
|
2042
|
+
try {
|
|
2043
|
+
auth = sm?.auth ?? await this.httpClient.resolveAuth(run.options);
|
|
2044
|
+
} catch (error) {
|
|
2045
|
+
this.logger.error(`Authentication failed: ${errorMessage(error)}`);
|
|
2046
|
+
throw error;
|
|
2047
|
+
}
|
|
2048
|
+
if (sm?.enabled && sm?.runId != null) {
|
|
2049
|
+
if (await this.tryFinishStreaming(run, overallStatus, duration, auth)) return;
|
|
2050
|
+
}
|
|
2051
|
+
if (this.hasReports(run) || run.options.uploadTraces) {
|
|
2052
|
+
if (await this.tryUploadWithFiles(run, overallStatus, duration, auth)) return;
|
|
2053
|
+
}
|
|
2054
|
+
await this.tryUploadJSON(run, overallStatus, duration, auth);
|
|
2055
|
+
}
|
|
2056
|
+
hasReports(run) {
|
|
2057
|
+
return !!run.options.uploadReport || (run.options.reports?.length ?? 0) > 0;
|
|
2058
|
+
}
|
|
2059
|
+
reportOptions(run) {
|
|
2060
|
+
return {
|
|
2061
|
+
uploadTraces: run.options.uploadTraces,
|
|
2062
|
+
uploadReport: run.options.uploadReport,
|
|
2063
|
+
reports: run.options.reports
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
buildRunPayload(run, status, duration) {
|
|
2067
|
+
return {
|
|
2068
|
+
projectName: run.options.projectName,
|
|
2069
|
+
projectDescription: run.options.projectDescription,
|
|
2070
|
+
status,
|
|
2071
|
+
startTime: run.startTime,
|
|
2072
|
+
duration,
|
|
2073
|
+
totalTests: run.totalTests,
|
|
2074
|
+
passedTests: run.passedTests,
|
|
2075
|
+
failedTests: run.failedTests,
|
|
2076
|
+
timedOutTests: run.timedOutTests,
|
|
2077
|
+
skippedTests: run.skippedTests,
|
|
2078
|
+
didNotRunTests: run.didNotRunTests,
|
|
2079
|
+
environment: run.options.environment,
|
|
2080
|
+
label: run.options.label || null,
|
|
2081
|
+
metadata: run.metadata,
|
|
2082
|
+
instanceId: run.instanceId,
|
|
2083
|
+
playwrightVersion: run.playwrightVersion ?? void 0,
|
|
2084
|
+
reporterVersion: run.reporterVersion ?? void 0,
|
|
2085
|
+
testCases: run.testCases,
|
|
2086
|
+
shardIndex: run.shardInfo?.current,
|
|
2087
|
+
shardTotal: run.shardInfo?.total,
|
|
2088
|
+
isFullRun: run.isFullRun,
|
|
2089
|
+
filterDetails: run.filterDetails
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
async tryFinishStreaming(run, overallStatus, duration, auth) {
|
|
2093
|
+
const sm = this.streamManager;
|
|
2094
|
+
try {
|
|
2095
|
+
const flakyTests = run.testCases.filter((tc) => tc.status === "passed" && (tc.retries || 0) > 0).length;
|
|
2096
|
+
const durations = run.testCases.filter((tc) => tc.duration != null).map((tc) => tc.duration);
|
|
2097
|
+
await sm.uploadRemaining(run.testCases);
|
|
2098
|
+
const finishBody = {
|
|
2099
|
+
streamToken: sm.token,
|
|
2100
|
+
status: overallStatus,
|
|
2101
|
+
duration,
|
|
2102
|
+
totalTests: run.totalTests,
|
|
2103
|
+
passedTests: run.passedTests,
|
|
2104
|
+
failedTests: run.failedTests,
|
|
2105
|
+
timedOutTests: run.timedOutTests,
|
|
2106
|
+
skippedTests: run.skippedTests,
|
|
2107
|
+
didNotRunTests: run.didNotRunTests,
|
|
2108
|
+
flakyTests,
|
|
2109
|
+
durations,
|
|
2110
|
+
label: run.options.label || null,
|
|
2111
|
+
metadata: run.metadata,
|
|
2112
|
+
hasPendingUploads: this.hasReports(run),
|
|
2113
|
+
playwrightVersion: run.playwrightVersion ?? void 0,
|
|
2114
|
+
reporterVersion: run.reporterVersion ?? void 0,
|
|
2115
|
+
setupSteps: run.setupSteps.length > 0 ? run.setupSteps : void 0,
|
|
2116
|
+
isFullRun: run.isFullRun,
|
|
2117
|
+
filterDetails: run.filterDetails ?? null
|
|
2118
|
+
};
|
|
2119
|
+
if (run.shardInfo) {
|
|
2120
|
+
finishBody.shardIndex = run.shardInfo.current;
|
|
2121
|
+
finishBody.shardTotal = run.shardInfo.total;
|
|
2122
|
+
}
|
|
2123
|
+
await this.httpClient.postJSON(`/api/test-runs/${sm.runId}/finish`, finishBody, auth);
|
|
2124
|
+
this.logger.info(`Successfully finalized streaming run #${sm.runId}`);
|
|
2125
|
+
if (run.options.serverUrl) {
|
|
2126
|
+
this.logger.info(`View run: ${runUrl(run.options.serverUrl, sm.runId)}`);
|
|
2127
|
+
}
|
|
2128
|
+
this.recovery.clear();
|
|
2129
|
+
if (this.hasReports(run)) {
|
|
2130
|
+
try {
|
|
2131
|
+
await this.uploader.uploadReportsForStreamingRun(
|
|
2132
|
+
run.options.projectName,
|
|
2133
|
+
sm.runId,
|
|
2134
|
+
this.reportOptions(run),
|
|
2135
|
+
run.startTime,
|
|
2136
|
+
auth
|
|
2137
|
+
);
|
|
2138
|
+
} catch (error) {
|
|
2139
|
+
this.logger.warn(`Failed to upload reports for streaming run: ${errorMessage(error)}`);
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
return true;
|
|
2143
|
+
} catch (error) {
|
|
2144
|
+
this.logger.warn(`Failed to finalize streaming run: ${errorMessage(error)}`);
|
|
2145
|
+
this.logger.info("Falling back to batch upload...");
|
|
2146
|
+
return false;
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
async tryUploadWithFiles(run, overallStatus, duration, auth) {
|
|
2150
|
+
try {
|
|
2151
|
+
await this.uploader.uploadWithFiles(
|
|
2152
|
+
this.buildRunPayload(run, overallStatus, duration),
|
|
2153
|
+
this.reportOptions(run),
|
|
2154
|
+
auth
|
|
2155
|
+
);
|
|
2156
|
+
this.recovery.clear();
|
|
2157
|
+
return true;
|
|
2158
|
+
} catch (error) {
|
|
2159
|
+
if (error instanceof HttpError && error.status === 401 && !auth) {
|
|
2160
|
+
this.logAuthRequired(run.options.serverUrl);
|
|
2161
|
+
throw error;
|
|
2162
|
+
}
|
|
2163
|
+
this.logger.warn(`Failed to upload with files: ${errorMessage(error)}`);
|
|
2164
|
+
this.logger.info("Falling back to JSON upload...");
|
|
2165
|
+
return false;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
async tryUploadJSON(run, overallStatus, duration, auth) {
|
|
2169
|
+
const payload = this.buildRunPayload(run, overallStatus, duration);
|
|
2170
|
+
try {
|
|
2171
|
+
await this.uploader.uploadJSON(payload, auth);
|
|
2172
|
+
this.recovery.clear();
|
|
2173
|
+
} catch (error) {
|
|
2174
|
+
if (error instanceof HttpError && error.status === 401 && !auth) {
|
|
2175
|
+
this.logAuthRequired(run.options.serverUrl);
|
|
2176
|
+
throw error;
|
|
2177
|
+
}
|
|
2178
|
+
this.logger.error(`All upload methods failed: ${errorMessage(error)}`);
|
|
2179
|
+
this.logger.info(
|
|
2180
|
+
`Saved a local recovery copy \u2014 it will be uploaded automatically on your next test run. If this keeps happening, check that serverUrl (${run.options.serverUrl ?? "not set"}) is correct and reachable.`
|
|
2181
|
+
);
|
|
2182
|
+
this.recovery.save(serializeRun(payload, { includeTestCases: true }));
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
/** Log one actionable line explaining how to fix a 401 caused by a missing credential. */
|
|
2186
|
+
logAuthRequired(serverUrl) {
|
|
2187
|
+
this.logger.error(
|
|
2188
|
+
`Authentication is required by ${serverUrl ?? "the dashboard"} but no credentials were configured. Create an API key (Settings \u2192 Users on the dashboard) and set the reporter's \`apiKey\` option or the PIWI_API_KEY environment variable.`
|
|
2189
|
+
);
|
|
2190
|
+
}
|
|
2191
|
+
};
|
|
2192
|
+
|
|
2193
|
+
// src/public/reporter.ts
|
|
2194
|
+
function testLocation(test) {
|
|
2195
|
+
const relativeFilePath = path12.relative(process.cwd(), test.location.file).split(path12.sep).join("/");
|
|
2196
|
+
return `${relativeFilePath}:${test.location.line}:${test.location.column}`;
|
|
2197
|
+
}
|
|
2198
|
+
var PiwiDashboardReporter = class {
|
|
2199
|
+
constructor(rawOptions = {}) {
|
|
2200
|
+
this.testCases = [];
|
|
2201
|
+
this.startTime = null;
|
|
2202
|
+
this.playwrightVersion = null;
|
|
2203
|
+
this.reporterVersion = getReporterVersion();
|
|
2204
|
+
this.totalTests = 0;
|
|
2205
|
+
this.passedTests = 0;
|
|
2206
|
+
this.failedTests = 0;
|
|
2207
|
+
this.skippedTests = 0;
|
|
2208
|
+
this.timedOutTests = 0;
|
|
2209
|
+
this.didNotRunTests = 0;
|
|
2210
|
+
/** Full set of tests Playwright planned to run this shard (captured in `onBegin`). */
|
|
2211
|
+
this.plannedTests = [];
|
|
2212
|
+
/** Ids of tests that actually reported via `onTestEnd`, to find the ones that never ran. */
|
|
2213
|
+
this.reportedTestIds = /* @__PURE__ */ new Set();
|
|
2214
|
+
this.runLabel = null;
|
|
2215
|
+
this.shardInfo = null;
|
|
2216
|
+
this.metadata = {};
|
|
2217
|
+
this.isFullRun = true;
|
|
2218
|
+
this.filterDetails = null;
|
|
2219
|
+
this.streamManager = null;
|
|
2220
|
+
/** Track suite-level setup steps (beforeAll/afterAll) not tied to any test */
|
|
2221
|
+
this.setupSteps = [];
|
|
2222
|
+
this.options = resolveOptions(rawOptions);
|
|
2223
|
+
this.enabled = this.options.enabled !== false && !!this.options.serverUrl;
|
|
2224
|
+
this.runLabel = this.options.runLabel || detectCiRunLabel();
|
|
2225
|
+
this.instanceId = computeInstanceId(this.options.projectName, this.runLabel);
|
|
2226
|
+
const logger = new Logger(this.options.verbose ?? false);
|
|
2227
|
+
this.logger = logger;
|
|
2228
|
+
this.httpClient = new HttpClient(this.options.serverUrl ?? "http://localhost:3000", logger);
|
|
2229
|
+
this.fileHandler = new FileHandler(logger);
|
|
2230
|
+
this.uploader = new Uploader(this.httpClient, this.fileHandler, logger);
|
|
2231
|
+
this.recovery = new CrashRecovery(this.options.projectName, logger);
|
|
2232
|
+
this.metadataCollector = new MetadataCollector(logger);
|
|
2233
|
+
const streamBuffer = new StreamBuffer(this.options.projectName);
|
|
2234
|
+
streamBuffer.clearStale();
|
|
2235
|
+
if (this.options.streaming) {
|
|
2236
|
+
this.streamManager = new StreamManager(
|
|
2237
|
+
this.httpClient,
|
|
2238
|
+
streamBuffer,
|
|
2239
|
+
this.recovery,
|
|
2240
|
+
this.uploader,
|
|
2241
|
+
this.fileHandler,
|
|
2242
|
+
this.options,
|
|
2243
|
+
logger
|
|
2244
|
+
);
|
|
2245
|
+
}
|
|
2246
|
+
this.submitter = new RunSubmitter(this.httpClient, this.uploader, this.recovery, this.streamManager, logger);
|
|
2247
|
+
}
|
|
2248
|
+
static {
|
|
2249
|
+
this.wrapConfig = wrapConfig;
|
|
2250
|
+
}
|
|
2251
|
+
static {
|
|
2252
|
+
this.createGlobalSetup = createGlobalSetup;
|
|
2253
|
+
}
|
|
2254
|
+
/** Playwright reporter hook: called once at the start of the test run */
|
|
2255
|
+
onBegin(config, suite) {
|
|
2256
|
+
if (!this.enabled) {
|
|
2257
|
+
this.logger.info("Not enabled \u2014 set PIWI_DASHBOARD_URL or serverUrl to enable.");
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
this.startTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
2261
|
+
this.playwrightVersion = config.version;
|
|
2262
|
+
this.logger.info(
|
|
2263
|
+
`Starting test run for project: ${this.options.projectName} (Playwright v${this.playwrightVersion})`
|
|
2264
|
+
);
|
|
2265
|
+
const rawConfig = config;
|
|
2266
|
+
const grepRe = rawConfig.grep instanceof RegExp ? rawConfig.grep : void 0;
|
|
2267
|
+
const grepInvertRe = rawConfig.grepInvert instanceof RegExp ? rawConfig.grepInvert : void 0;
|
|
2268
|
+
const grep = grepRe && grepRe.source !== ".*" ? grepRe.source : void 0;
|
|
2269
|
+
const grepInvert = grepInvertRe?.source;
|
|
2270
|
+
const fileFilters = detectCliFileFilters();
|
|
2271
|
+
if (grep || grepInvert || fileFilters.length > 0) {
|
|
2272
|
+
this.isFullRun = false;
|
|
2273
|
+
this.filterDetails = {
|
|
2274
|
+
...grep ? { grep } : {},
|
|
2275
|
+
...grepInvert ? { grepInvert } : {},
|
|
2276
|
+
...fileFilters.length > 0 ? { files: fileFilters } : {}
|
|
2277
|
+
};
|
|
2278
|
+
this.logger.info("Partial run detected (filter active)");
|
|
2279
|
+
}
|
|
2280
|
+
this.metadata = this.metadataCollector.collect(config, suite, this.options);
|
|
2281
|
+
this.plannedTests = suite.allTests();
|
|
2282
|
+
const pwShard = config.shard;
|
|
2283
|
+
if (pwShard?.total && pwShard.total > 1) {
|
|
2284
|
+
this.shardInfo = { current: pwShard.current, total: pwShard.total };
|
|
2285
|
+
this.logger.info(`Shard ${this.shardInfo.current}/${this.shardInfo.total} detected`);
|
|
2286
|
+
}
|
|
2287
|
+
this.streamManager?.start(
|
|
2288
|
+
this.startTime,
|
|
2289
|
+
this.metadata,
|
|
2290
|
+
this.instanceId,
|
|
2291
|
+
this.playwrightVersion,
|
|
2292
|
+
this.reporterVersion,
|
|
2293
|
+
this.shardInfo,
|
|
2294
|
+
this.isFullRun,
|
|
2295
|
+
this.filterDetails
|
|
2296
|
+
);
|
|
2297
|
+
}
|
|
2298
|
+
/** Playwright reporter hook: called when an individual test begins */
|
|
2299
|
+
onTestBegin(test, result) {
|
|
2300
|
+
const { suitePath, suiteConfig } = this.metadataCollector.getSuiteInfo(test);
|
|
2301
|
+
const beginEvent = {
|
|
2302
|
+
type: "begin",
|
|
2303
|
+
title: test.title,
|
|
2304
|
+
location: testLocation(test),
|
|
2305
|
+
workerIndex: workerIndexOf(result),
|
|
2306
|
+
shardIndex: this.shardInfo?.current ?? null,
|
|
2307
|
+
browser: this.metadataCollector.getBrowserConfig(test) || void 0,
|
|
2308
|
+
suitePath,
|
|
2309
|
+
suiteConfig
|
|
2310
|
+
};
|
|
2311
|
+
if (this.streamManager) {
|
|
2312
|
+
this.streamManager.queueBeginEvent(toWireTestCase(beginEvent));
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
/** Playwright reporter hook: called when a step (including hook/fixture) begins */
|
|
2316
|
+
onStepBegin(test, _result, step) {
|
|
2317
|
+
if (!this.enabled || !this.streamManager?.enabled) return;
|
|
2318
|
+
const cat = step.category;
|
|
2319
|
+
if (cat !== "hook" && cat !== "fixture") return;
|
|
2320
|
+
const event = {
|
|
2321
|
+
type: "step-begin",
|
|
2322
|
+
title: step.title,
|
|
2323
|
+
location: step.location ? `${step.location.file}:${step.location.line}:${step.location.column}` : "unknown",
|
|
2324
|
+
stepCategory: cat,
|
|
2325
|
+
parentTitle: test?.title || null,
|
|
2326
|
+
workerIndex: workerIndexOf(_result),
|
|
2327
|
+
startedAt: step.startTime instanceof Date ? step.startTime.getTime() : null
|
|
2328
|
+
};
|
|
2329
|
+
this.streamManager?.queueBeginEvent(event);
|
|
2330
|
+
}
|
|
2331
|
+
/** Playwright reporter hook: called when a step (including hook/fixture) ends */
|
|
2332
|
+
onStepEnd(test, _result, step) {
|
|
2333
|
+
const cat = step.category;
|
|
2334
|
+
if (cat === "pw:api") return;
|
|
2335
|
+
if (!this.enabled || !this.streamManager?.enabled) return;
|
|
2336
|
+
if (cat !== "hook" && cat !== "fixture") return;
|
|
2337
|
+
const workerIndex = workerIndexOf(_result);
|
|
2338
|
+
const startedAt = step.startTime instanceof Date ? step.startTime.getTime() : null;
|
|
2339
|
+
const event = {
|
|
2340
|
+
type: "step-end",
|
|
2341
|
+
title: step.title,
|
|
2342
|
+
location: step.location ? `${step.location.file}:${step.location.line}:${step.location.column}` : "unknown",
|
|
2343
|
+
status: step.error ? "failed" : "passed",
|
|
2344
|
+
duration: step.duration || 0,
|
|
2345
|
+
stepCategory: cat,
|
|
2346
|
+
parentTitle: test?.title || null,
|
|
2347
|
+
workerIndex,
|
|
2348
|
+
startedAt
|
|
2349
|
+
};
|
|
2350
|
+
this.streamManager?.queueEvent(event);
|
|
2351
|
+
if (!test && startedAt) {
|
|
2352
|
+
this.setupSteps.push({
|
|
2353
|
+
title: step.title,
|
|
2354
|
+
category: cat,
|
|
2355
|
+
startedAt,
|
|
2356
|
+
duration: step.duration || 0,
|
|
2357
|
+
status: step.error ? "failed" : "passed",
|
|
2358
|
+
location: step.location ? `${step.location.file}:${step.location.line}:${step.location.column}` : null,
|
|
2359
|
+
workerIndex
|
|
2360
|
+
});
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
/** Playwright reporter hook: called when an individual test finishes */
|
|
2364
|
+
onTestEnd(test, result) {
|
|
2365
|
+
this.totalTests++;
|
|
2366
|
+
this.reportedTestIds.add(test.id);
|
|
2367
|
+
const { suitePath, suiteConfig } = this.metadataCollector.getSuiteInfo(test);
|
|
2368
|
+
const annotations = mergeAnnotations(test, result);
|
|
2369
|
+
const status = classifyStatus(result.status, annotations);
|
|
2370
|
+
const testCase = {
|
|
2371
|
+
type: "complete",
|
|
2372
|
+
title: test.title,
|
|
2373
|
+
location: testLocation(test),
|
|
2374
|
+
status,
|
|
2375
|
+
duration: result.duration,
|
|
2376
|
+
error: buildErrorText(result),
|
|
2377
|
+
retries: result.retry,
|
|
2378
|
+
workerIndex: workerIndexOf(result),
|
|
2379
|
+
shardIndex: this.shardInfo?.current ?? null,
|
|
2380
|
+
startedAt: result.startTime ? result.startTime.getTime() : null,
|
|
2381
|
+
attachments: result.attachments || [],
|
|
2382
|
+
browser: this.metadataCollector.getBrowserConfig(test) || void 0,
|
|
2383
|
+
suitePath,
|
|
2384
|
+
suiteConfig,
|
|
2385
|
+
testAnnotations: annotations.length ? annotations : null
|
|
2386
|
+
};
|
|
2387
|
+
if (result.status === "failed" || result.status === "timedOut") {
|
|
2388
|
+
const failingLine = extractFailingLine(testCase.error, test.location.file, test.location.line);
|
|
2389
|
+
const snippet = readSourceSnippet(test.location.file, test.location.line, 30, failingLine);
|
|
2390
|
+
if (snippet) testCase.testSource = snippet;
|
|
2391
|
+
const frames = collectSourceFrames(testCase.error, test.location.file, test.location.line);
|
|
2392
|
+
if (frames.length) testCase.testSourceFrames = frames;
|
|
2393
|
+
}
|
|
2394
|
+
if (this.options.collectPerformanceMetrics && result.steps?.length > 0) {
|
|
2395
|
+
testCase.performanceMetrics = collectStepMetrics(result.steps);
|
|
2396
|
+
const stepEvents = extractTestStepEvents(result.steps, result.startTime);
|
|
2397
|
+
const waitEvents = extractWaitEvents(result.steps);
|
|
2398
|
+
const allEvents = [...stepEvents, ...waitEvents];
|
|
2399
|
+
if (allEvents.length > 0) testCase.stepEvents = allEvents;
|
|
2400
|
+
}
|
|
2401
|
+
if (this.options.collectPerformanceMetrics && result.attachments) {
|
|
2402
|
+
this.fileHandler.parsePerformanceAttachments(testCase, result.attachments);
|
|
2403
|
+
const locatorAttachment = this.options.captureLocators !== false ? result.attachments.find((a) => a.name === ATTACHMENT_NAMES.locators) : void 0;
|
|
2404
|
+
if (locatorAttachment?.body) {
|
|
2405
|
+
try {
|
|
2406
|
+
testCase.locatorSnapshots = JSON.parse(locatorAttachment.body.toString());
|
|
2407
|
+
} catch {
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
switch (status) {
|
|
2412
|
+
case "passed":
|
|
2413
|
+
this.passedTests++;
|
|
2414
|
+
break;
|
|
2415
|
+
case "failed":
|
|
2416
|
+
this.failedTests++;
|
|
2417
|
+
break;
|
|
2418
|
+
case "skipped":
|
|
2419
|
+
this.skippedTests++;
|
|
2420
|
+
break;
|
|
2421
|
+
case "didnotrun":
|
|
2422
|
+
this.didNotRunTests++;
|
|
2423
|
+
break;
|
|
2424
|
+
case "timedOut":
|
|
2425
|
+
this.timedOutTests++;
|
|
2426
|
+
break;
|
|
2427
|
+
}
|
|
2428
|
+
this.testCases.push(testCase);
|
|
2429
|
+
if (this.streamManager) {
|
|
2430
|
+
this.streamManager.queueEvent(toWireTestCase(testCase));
|
|
2431
|
+
if (this.options.liveFileUploads) this.streamManager.scheduleLiveUpload(testCase);
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
/**
|
|
2435
|
+
* Synthesize `didnotrun` cases for tests Playwright planned but never reported
|
|
2436
|
+
* (no `onTestEnd`) — typically because `maxFailures` cut the run short. These
|
|
2437
|
+
* carry no result, so they're emitted with zero duration and no error. In
|
|
2438
|
+
* streaming mode they're queued as complete events so the pre-finish drain
|
|
2439
|
+
* sends them alongside the rest.
|
|
2440
|
+
*/
|
|
2441
|
+
materializeUnrunTests() {
|
|
2442
|
+
for (const test of this.plannedTests) {
|
|
2443
|
+
if (this.reportedTestIds.has(test.id)) continue;
|
|
2444
|
+
const { suitePath, suiteConfig } = this.metadataCollector.getSuiteInfo(test);
|
|
2445
|
+
const testCase = {
|
|
2446
|
+
type: "complete",
|
|
2447
|
+
title: test.title,
|
|
2448
|
+
location: testLocation(test),
|
|
2449
|
+
status: "didnotrun",
|
|
2450
|
+
duration: 0,
|
|
2451
|
+
error: null,
|
|
2452
|
+
retries: 0,
|
|
2453
|
+
workerIndex: null,
|
|
2454
|
+
shardIndex: this.shardInfo?.current ?? null,
|
|
2455
|
+
startedAt: null,
|
|
2456
|
+
attachments: [],
|
|
2457
|
+
browser: this.metadataCollector.getBrowserConfig(test) || void 0,
|
|
2458
|
+
suitePath,
|
|
2459
|
+
suiteConfig,
|
|
2460
|
+
testAnnotations: test.annotations?.length ? test.annotations : null
|
|
2461
|
+
};
|
|
2462
|
+
this.testCases.push(testCase);
|
|
2463
|
+
this.totalTests++;
|
|
2464
|
+
this.didNotRunTests++;
|
|
2465
|
+
if (this.streamManager) {
|
|
2466
|
+
this.streamManager.queueEvent(toWireTestCase(testCase));
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
/** Playwright reporter hook: called when the full test run finishes */
|
|
2471
|
+
async onEnd(result) {
|
|
2472
|
+
if (!this.enabled) return;
|
|
2473
|
+
this.materializeUnrunTests();
|
|
2474
|
+
await this.submitter.submit(
|
|
2475
|
+
{
|
|
2476
|
+
options: this.options,
|
|
2477
|
+
testCases: this.testCases,
|
|
2478
|
+
startTime: this.startTime,
|
|
2479
|
+
playwrightVersion: this.playwrightVersion,
|
|
2480
|
+
reporterVersion: this.reporterVersion,
|
|
2481
|
+
totalTests: this.totalTests,
|
|
2482
|
+
passedTests: this.passedTests,
|
|
2483
|
+
failedTests: this.failedTests,
|
|
2484
|
+
skippedTests: this.skippedTests,
|
|
2485
|
+
timedOutTests: this.timedOutTests,
|
|
2486
|
+
didNotRunTests: this.didNotRunTests,
|
|
2487
|
+
metadata: this.metadata,
|
|
2488
|
+
instanceId: this.instanceId,
|
|
2489
|
+
shardInfo: this.shardInfo,
|
|
2490
|
+
setupSteps: this.setupSteps,
|
|
2491
|
+
isFullRun: this.isFullRun,
|
|
2492
|
+
filterDetails: this.filterDetails
|
|
2493
|
+
},
|
|
2494
|
+
result
|
|
2495
|
+
);
|
|
2496
|
+
}
|
|
2497
|
+
};
|
|
2498
|
+
|
|
2499
|
+
// src/internal/capture/capture-fixtures.ts
|
|
2500
|
+
var import_node_zlib = require("zlib");
|
|
2501
|
+
|
|
2502
|
+
// src/internal/capture/locator-healing.ts
|
|
2503
|
+
var path13 = __toESM(require("path"));
|
|
2504
|
+
|
|
2505
|
+
// ../packages/core/src/locator-fingerprint.ts
|
|
2506
|
+
var PRESENT_SIMILARITY = 0.8;
|
|
2507
|
+
var MATCH_SIMILARITY = 0.2;
|
|
2508
|
+
function parseAriaCandidates(ariaSnapshot) {
|
|
2509
|
+
if (!ariaSnapshot) return [];
|
|
2510
|
+
const out = [];
|
|
2511
|
+
for (const line of ariaSnapshot.split("\n")) {
|
|
2512
|
+
const m = line.match(/^\s*-\s+([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?/i);
|
|
2513
|
+
if (!m) continue;
|
|
2514
|
+
const role = m[1];
|
|
2515
|
+
const name = m[2] != null ? m[2].replace(/\\(.)/g, "$1") : null;
|
|
2516
|
+
if (!name && (role === "generic" || role === "group" || role === "list" || role === "paragraph")) continue;
|
|
2517
|
+
const levelMatch = line.slice(m[0].length).match(/\[level=(\d+)\]/);
|
|
2518
|
+
const level = levelMatch ? Number(levelMatch[1]) : null;
|
|
2519
|
+
out.push({ role, name, level });
|
|
2520
|
+
}
|
|
2521
|
+
return out;
|
|
2522
|
+
}
|
|
2523
|
+
function textSimilarity(a, b) {
|
|
2524
|
+
const tok = (s) => new Set(
|
|
2525
|
+
(s ?? "").toLowerCase().split(/[^a-z0-9]+/i).filter(Boolean)
|
|
2526
|
+
);
|
|
2527
|
+
const sa = tok(a);
|
|
2528
|
+
const sb = tok(b);
|
|
2529
|
+
if (sa.size === 0 && sb.size === 0) return 1;
|
|
2530
|
+
if (sa.size === 0 || sb.size === 0) return 0;
|
|
2531
|
+
let common = 0;
|
|
2532
|
+
for (const t of sa) if (sb.has(t)) common++;
|
|
2533
|
+
return 2 * common / (sa.size + sb.size);
|
|
2534
|
+
}
|
|
2535
|
+
function fingerprintPresent(fp, candidates) {
|
|
2536
|
+
if (!fp.name) return false;
|
|
2537
|
+
return candidates.some(
|
|
2538
|
+
(c) => (!fp.role || c.role === fp.role) && textSimilarity(c.name, fp.name) >= PRESENT_SIMILARITY
|
|
2539
|
+
);
|
|
2540
|
+
}
|
|
2541
|
+
function matchRenamedElement(fp, candidates) {
|
|
2542
|
+
if (candidates.length === 0) return null;
|
|
2543
|
+
const sameRole = fp.role ? candidates.filter((c) => c.role === fp.role) : candidates;
|
|
2544
|
+
if (sameRole.length === 0) return null;
|
|
2545
|
+
let pool = sameRole;
|
|
2546
|
+
if (fp.level != null) {
|
|
2547
|
+
const sameLevel = sameRole.filter((c) => c.level === fp.level);
|
|
2548
|
+
if (sameLevel.length > 0) pool = sameLevel;
|
|
2549
|
+
}
|
|
2550
|
+
if (pool.length === 1) {
|
|
2551
|
+
return { candidate: pool[0], confidence: 0.7 };
|
|
2552
|
+
}
|
|
2553
|
+
let best = null;
|
|
2554
|
+
let bestScore = -1;
|
|
2555
|
+
for (const c of pool) {
|
|
2556
|
+
const s = textSimilarity(c.name, fp.name);
|
|
2557
|
+
if (s > bestScore) {
|
|
2558
|
+
bestScore = s;
|
|
2559
|
+
best = c;
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
if (best && bestScore >= MATCH_SIMILARITY) return { candidate: best, confidence: bestScore };
|
|
2563
|
+
const pos = fp.rolePosition;
|
|
2564
|
+
if (pos && fp.role && pos.role === fp.role && sameRole.length >= 2 && sameRole.length === pos.count) {
|
|
2565
|
+
const byIndex = sameRole[pos.index];
|
|
2566
|
+
if (byIndex) return { candidate: byIndex, confidence: 0.5 };
|
|
2567
|
+
}
|
|
2568
|
+
return null;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// ../packages/core/src/locator-methods.ts
|
|
2572
|
+
var LOCATOR_BUILDER_METHODS = [
|
|
2573
|
+
"getByRole",
|
|
2574
|
+
"getByTestId",
|
|
2575
|
+
"getByText",
|
|
2576
|
+
"getByLabel",
|
|
2577
|
+
"getByPlaceholder",
|
|
2578
|
+
"getByAltText",
|
|
2579
|
+
"getByTitle",
|
|
2580
|
+
"locator"
|
|
2581
|
+
];
|
|
2582
|
+
|
|
2583
|
+
// ../packages/core/src/locator-generation.ts
|
|
2584
|
+
var TAG_TO_ROLE = {
|
|
2585
|
+
a: "link",
|
|
2586
|
+
button: "button",
|
|
2587
|
+
nav: "navigation",
|
|
2588
|
+
main: "main",
|
|
2589
|
+
article: "article",
|
|
2590
|
+
section: "region",
|
|
2591
|
+
form: "form",
|
|
2592
|
+
img: "img",
|
|
2593
|
+
figure: "figure",
|
|
2594
|
+
figcaption: "caption",
|
|
2595
|
+
blockquote: "blockquote",
|
|
2596
|
+
table: "table",
|
|
2597
|
+
ul: "list",
|
|
2598
|
+
ol: "list",
|
|
2599
|
+
li: "listitem",
|
|
2600
|
+
dialog: "dialog",
|
|
2601
|
+
output: "status",
|
|
2602
|
+
progress: "progressbar",
|
|
2603
|
+
meter: "meter",
|
|
2604
|
+
textarea: "textbox",
|
|
2605
|
+
h1: "heading",
|
|
2606
|
+
h2: "heading",
|
|
2607
|
+
h3: "heading",
|
|
2608
|
+
h4: "heading",
|
|
2609
|
+
h5: "heading",
|
|
2610
|
+
h6: "heading",
|
|
2611
|
+
details: "group",
|
|
2612
|
+
summary: "button",
|
|
2613
|
+
search: "search"
|
|
2614
|
+
};
|
|
2615
|
+
var INPUT_TYPE_TO_ROLE = {
|
|
2616
|
+
button: "button",
|
|
2617
|
+
submit: "button",
|
|
2618
|
+
reset: "button",
|
|
2619
|
+
image: "button",
|
|
2620
|
+
checkbox: "checkbox",
|
|
2621
|
+
radio: "radio",
|
|
2622
|
+
range: "slider",
|
|
2623
|
+
search: "searchbox",
|
|
2624
|
+
number: "spinbutton",
|
|
2625
|
+
text: "textbox",
|
|
2626
|
+
email: "textbox",
|
|
2627
|
+
tel: "textbox",
|
|
2628
|
+
url: "textbox",
|
|
2629
|
+
password: "textbox"
|
|
2630
|
+
};
|
|
2631
|
+
var CAPTURED_ATTRIBUTES = [
|
|
2632
|
+
"id",
|
|
2633
|
+
"class",
|
|
2634
|
+
"name",
|
|
2635
|
+
"data-testid",
|
|
2636
|
+
"placeholder",
|
|
2637
|
+
"alt",
|
|
2638
|
+
"title",
|
|
2639
|
+
"aria-label",
|
|
2640
|
+
"aria-level",
|
|
2641
|
+
"role",
|
|
2642
|
+
"type",
|
|
2643
|
+
"href",
|
|
2644
|
+
"multiple"
|
|
2645
|
+
];
|
|
2646
|
+
function resolveAriaRole(attrs) {
|
|
2647
|
+
const explicit = attrs.attributes["role"];
|
|
2648
|
+
if (explicit) return explicit;
|
|
2649
|
+
const tag = attrs.tagName;
|
|
2650
|
+
if (!tag) return null;
|
|
2651
|
+
if (tag === "input") {
|
|
2652
|
+
const type = (attrs.attributes["type"] ?? "text").toLowerCase();
|
|
2653
|
+
return INPUT_TYPE_TO_ROLE[type] ?? "textbox";
|
|
2654
|
+
}
|
|
2655
|
+
if (tag === "select") {
|
|
2656
|
+
return attrs.attributes["multiple"] != null ? "listbox" : "combobox";
|
|
2657
|
+
}
|
|
2658
|
+
if (tag === "a") {
|
|
2659
|
+
return attrs.attributes["href"] != null ? "link" : null;
|
|
2660
|
+
}
|
|
2661
|
+
return TAG_TO_ROLE[tag] ?? null;
|
|
2662
|
+
}
|
|
2663
|
+
function headingLevel(attrs, role) {
|
|
2664
|
+
if (role !== "heading") return null;
|
|
2665
|
+
const tagMatch = attrs.tagName.match(/^h([1-6])$/);
|
|
2666
|
+
if (tagMatch) return Number(tagMatch[1]);
|
|
2667
|
+
const ariaLevel = attrs.attributes["aria-level"];
|
|
2668
|
+
if (ariaLevel && /^\d+$/.test(ariaLevel)) return Number(ariaLevel);
|
|
2669
|
+
return null;
|
|
2670
|
+
}
|
|
2671
|
+
var attr = (a, key) => a.attributes[key] || null;
|
|
2672
|
+
var esc = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
2673
|
+
var escCssAttrValue = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
2674
|
+
var isCssSafeId = (id) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(id);
|
|
2675
|
+
function classifyCssStability(className) {
|
|
2676
|
+
if (/[a-f0-9]{8,}/i.test(className)) return 10;
|
|
2677
|
+
if (/^(?:css|sc|emotion|styled)-/i.test(className)) return 15;
|
|
2678
|
+
if (/^(bg|text|border|shadow|opacity|font|w-|h-|m[tblrxy]?-|p[tblrxy]?-|flex|grid|gap|rounded|absolute|relative|fixed|sticky|block|inline|hidden|overflow|z-|top-|right-|bottom-|left-|inset-|justify-|items-|self-|content-|order-|col-|row-)/.test(
|
|
2679
|
+
className
|
|
2680
|
+
))
|
|
2681
|
+
return 25;
|
|
2682
|
+
if (/__/.test(className) || /--/.test(className)) return 35;
|
|
2683
|
+
if (/^[a-z]+(-[a-z]+)+$/.test(className)) return 40;
|
|
2684
|
+
if (/^[a-z]+[A-Z][a-zA-Z]+$/.test(className)) return 40;
|
|
2685
|
+
if (className.includes("_")) return 15;
|
|
2686
|
+
if (/^[a-z]+-[a-z0-9]{5,}$/.test(className) && /[0-9]/.test(className)) return 15;
|
|
2687
|
+
return 15;
|
|
2688
|
+
}
|
|
2689
|
+
function isAutoGenerated(value) {
|
|
2690
|
+
if (/^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/i.test(value)) return true;
|
|
2691
|
+
if (/^[a-f0-9]{8,}$/i.test(value)) return true;
|
|
2692
|
+
if (/^[a-z]+-\d+$/.test(value)) return true;
|
|
2693
|
+
if (/^(emotion-|styled-|css-|sc-)/.test(value)) return true;
|
|
2694
|
+
if (value.startsWith("ng-")) return true;
|
|
2695
|
+
if (/^(radix-|headlessui-|mui-|mantine-|chakra-)/i.test(value)) return true;
|
|
2696
|
+
if (/^:r[0-9a-z]+:$/i.test(value) || /^«r[0-9a-z]+»$/i.test(value)) return true;
|
|
2697
|
+
return false;
|
|
2698
|
+
}
|
|
2699
|
+
function generateAlternatives(attrs) {
|
|
2700
|
+
const alts = [];
|
|
2701
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2702
|
+
const add = (loc) => {
|
|
2703
|
+
if (!seen.has(loc.locator)) {
|
|
2704
|
+
seen.add(loc.locator);
|
|
2705
|
+
alts.push(loc);
|
|
2706
|
+
}
|
|
2707
|
+
};
|
|
2708
|
+
const { accessibleName } = attrs;
|
|
2709
|
+
const tag = attrs.tagName;
|
|
2710
|
+
const role = resolveAriaRole(attrs);
|
|
2711
|
+
const counts = attrs.selectorCounts;
|
|
2712
|
+
const isUnique = (n) => n == null || n <= 1;
|
|
2713
|
+
const text = attrs.textContent ? attrs.textContent.replace(/\s+/g, " ").trim() : null;
|
|
2714
|
+
const testId = attr(attrs, "data-testid");
|
|
2715
|
+
if (testId && isUnique(counts?.testId)) {
|
|
2716
|
+
add({
|
|
2717
|
+
locator: `getByTestId('${esc(testId)}')`,
|
|
2718
|
+
method: "getByTestId",
|
|
2719
|
+
args: { testId },
|
|
2720
|
+
score: 100
|
|
2721
|
+
});
|
|
2722
|
+
}
|
|
2723
|
+
const level = headingLevel(attrs, role);
|
|
2724
|
+
const levelPart = level != null ? `, level: ${level}` : "";
|
|
2725
|
+
const withLevel = (base) => level != null ? { ...base, level } : base;
|
|
2726
|
+
if (role && accessibleName) {
|
|
2727
|
+
add({
|
|
2728
|
+
locator: `getByRole('${role}', { name: '${esc(accessibleName)}'${levelPart} })`,
|
|
2729
|
+
method: "getByRole",
|
|
2730
|
+
args: withLevel({ role, name: accessibleName }),
|
|
2731
|
+
score: 90
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
const ariaLabel = attr(attrs, "aria-label");
|
|
2735
|
+
if (role && ariaLabel && ariaLabel !== accessibleName) {
|
|
2736
|
+
add({
|
|
2737
|
+
locator: `getByRole('${role}', { name: '${esc(ariaLabel)}'${levelPart} })`,
|
|
2738
|
+
method: "getByRole",
|
|
2739
|
+
args: withLevel({ role, name: ariaLabel }),
|
|
2740
|
+
score: 85
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
if (accessibleName && ["input", "select", "textarea"].includes(tag)) {
|
|
2744
|
+
const label = attr(attrs, "aria-label");
|
|
2745
|
+
const labelBacked = attrs.hasLabel === void 0 ? true : attrs.hasLabel === true || label === accessibleName;
|
|
2746
|
+
if (labelBacked) {
|
|
2747
|
+
add({
|
|
2748
|
+
locator: `getByLabel('${esc(accessibleName)}')`,
|
|
2749
|
+
method: "getByLabel",
|
|
2750
|
+
args: { label: accessibleName },
|
|
2751
|
+
score: 85
|
|
2752
|
+
});
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
const placeholder = attr(attrs, "placeholder");
|
|
2756
|
+
if (placeholder) {
|
|
2757
|
+
add({
|
|
2758
|
+
locator: `getByPlaceholder('${esc(placeholder)}')`,
|
|
2759
|
+
method: "getByPlaceholder",
|
|
2760
|
+
args: { placeholder },
|
|
2761
|
+
score: 80
|
|
2762
|
+
});
|
|
2763
|
+
}
|
|
2764
|
+
if (text && text.length < 80) {
|
|
2765
|
+
add({
|
|
2766
|
+
locator: `getByText('${esc(text)}')`,
|
|
2767
|
+
method: "getByText",
|
|
2768
|
+
args: { text },
|
|
2769
|
+
score: 75
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
2772
|
+
const id = attr(attrs, "id");
|
|
2773
|
+
if (id && !isAutoGenerated(id) && isUnique(counts?.id)) {
|
|
2774
|
+
const selector = isCssSafeId(id) ? `#${id}` : `[id="${escCssAttrValue(id)}"]`;
|
|
2775
|
+
add({
|
|
2776
|
+
locator: `locator('${esc(selector)}')`,
|
|
2777
|
+
method: "locator",
|
|
2778
|
+
args: { selector },
|
|
2779
|
+
score: 65
|
|
2780
|
+
});
|
|
2781
|
+
}
|
|
2782
|
+
const name = attr(attrs, "name");
|
|
2783
|
+
if (name && isUnique(counts?.name)) {
|
|
2784
|
+
const selector = `[name="${escCssAttrValue(name)}"]`;
|
|
2785
|
+
add({
|
|
2786
|
+
locator: `locator('${esc(selector)}')`,
|
|
2787
|
+
method: "locator",
|
|
2788
|
+
args: { selector },
|
|
2789
|
+
score: 60
|
|
2790
|
+
});
|
|
2791
|
+
}
|
|
2792
|
+
const alt = attr(attrs, "alt");
|
|
2793
|
+
if (alt) {
|
|
2794
|
+
add({
|
|
2795
|
+
locator: `getByAltText('${esc(alt)}')`,
|
|
2796
|
+
method: "getByAltText",
|
|
2797
|
+
args: { text: alt },
|
|
2798
|
+
score: 60
|
|
2799
|
+
});
|
|
2800
|
+
}
|
|
2801
|
+
const title = attr(attrs, "title");
|
|
2802
|
+
if (title) {
|
|
2803
|
+
add({
|
|
2804
|
+
locator: `getByTitle('${esc(title)}')`,
|
|
2805
|
+
method: "getByTitle",
|
|
2806
|
+
args: { title },
|
|
2807
|
+
score: 50
|
|
2808
|
+
});
|
|
2809
|
+
}
|
|
2810
|
+
const hasOwnTestId = !!(testId && isUnique(counts?.testId));
|
|
2811
|
+
if (role && !hasOwnTestId) {
|
|
2812
|
+
const rolePart = level != null ? `'${role}', { level: ${level} }` : `'${role}'`;
|
|
2813
|
+
const leafArgs = withLevel({ role });
|
|
2814
|
+
let testIdAnchorDone = false;
|
|
2815
|
+
let idAnchorDone = false;
|
|
2816
|
+
let roleAnchorDone = false;
|
|
2817
|
+
for (const anc of attrs.ancestors ?? []) {
|
|
2818
|
+
if (anc.scopedRoleCount !== 1) continue;
|
|
2819
|
+
if (!testIdAnchorDone && anc.testId && anc.testIdCount === 1) {
|
|
2820
|
+
testIdAnchorDone = true;
|
|
2821
|
+
add({
|
|
2822
|
+
locator: `getByTestId('${esc(anc.testId)}').getByRole(${rolePart})`,
|
|
2823
|
+
method: "getByRole",
|
|
2824
|
+
args: { ...leafArgs, anchorTestId: anc.testId },
|
|
2825
|
+
score: 72
|
|
2826
|
+
});
|
|
2827
|
+
}
|
|
2828
|
+
if (!idAnchorDone && anc.id && !isAutoGenerated(anc.id) && anc.idCount === 1) {
|
|
2829
|
+
idAnchorDone = true;
|
|
2830
|
+
const anchorSelector = isCssSafeId(anc.id) ? `#${anc.id}` : `[id="${escCssAttrValue(anc.id)}"]`;
|
|
2831
|
+
add({
|
|
2832
|
+
locator: `locator('${esc(anchorSelector)}').getByRole(${rolePart})`,
|
|
2833
|
+
method: "getByRole",
|
|
2834
|
+
args: { ...leafArgs, anchorSelector },
|
|
2835
|
+
score: 64
|
|
2836
|
+
});
|
|
2837
|
+
}
|
|
2838
|
+
const ancestorRole = anc.role || TAG_TO_ROLE[anc.tag] || null;
|
|
2839
|
+
if (!roleAnchorDone && ancestorRole && ancestorRole !== role && anc.roleCount === 1) {
|
|
2840
|
+
roleAnchorDone = true;
|
|
2841
|
+
add({
|
|
2842
|
+
locator: `getByRole('${esc(ancestorRole)}').getByRole(${rolePart})`,
|
|
2843
|
+
method: "getByRole",
|
|
2844
|
+
args: { ...leafArgs, anchorRole: ancestorRole },
|
|
2845
|
+
score: 55
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
const pos = attrs.rolePosition;
|
|
2850
|
+
if (pos && pos.role === role && (pos.count === 1 || level != null && pos.levelCount === 1)) {
|
|
2851
|
+
add({
|
|
2852
|
+
locator: `getByRole(${rolePart})`,
|
|
2853
|
+
method: "getByRole",
|
|
2854
|
+
args: leafArgs,
|
|
2855
|
+
score: 58
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
const clsStr = attr(attrs, "class");
|
|
2860
|
+
if (clsStr) {
|
|
2861
|
+
const countsClasses = counts?.classes;
|
|
2862
|
+
const classes = clsStr.split(/\s+/).filter((c) => c.length > 1 && /^[A-Za-z_-][A-Za-z0-9_-]*$/.test(c) && isUnique(countsClasses?.[c])).map((cls) => ({ cls, score: classifyCssStability(cls) })).sort((a, b) => b.score - a.score).slice(0, 3);
|
|
2863
|
+
for (const { cls, score } of classes) {
|
|
2864
|
+
add({
|
|
2865
|
+
locator: `locator('.${esc(cls)}')`,
|
|
2866
|
+
method: "locator",
|
|
2867
|
+
args: { selector: `.${cls}` },
|
|
2868
|
+
score
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
return alts.sort((a, b) => b.score - a.score);
|
|
2873
|
+
}
|
|
2874
|
+
function approximateAccessibleName(attrs) {
|
|
2875
|
+
const a = attrs.attributes;
|
|
2876
|
+
const ariaLabel = a["aria-label"];
|
|
2877
|
+
if (ariaLabel) return ariaLabel;
|
|
2878
|
+
if (attrs.textContent) return attrs.textContent;
|
|
2879
|
+
const title = a["title"];
|
|
2880
|
+
if (title) return title;
|
|
2881
|
+
const placeholder = a["placeholder"];
|
|
2882
|
+
if (placeholder) return placeholder;
|
|
2883
|
+
return null;
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
// src/internal/capture/locator-healing.ts
|
|
2887
|
+
function dedupeSnapshotsByLocation(snaps) {
|
|
2888
|
+
const lastWithElement = /* @__PURE__ */ new Map();
|
|
2889
|
+
const lastAny = /* @__PURE__ */ new Map();
|
|
2890
|
+
snaps.forEach((s, i) => {
|
|
2891
|
+
if (!s.location) return;
|
|
2892
|
+
lastAny.set(s.location, i);
|
|
2893
|
+
if (s.element) lastWithElement.set(s.location, i);
|
|
2894
|
+
});
|
|
2895
|
+
return snaps.filter((s, i) => {
|
|
2896
|
+
if (!s.location) return true;
|
|
2897
|
+
return (lastWithElement.get(s.location) ?? lastAny.get(s.location)) === i;
|
|
2898
|
+
});
|
|
2899
|
+
}
|
|
2900
|
+
var LOCATOR_METHODS = [...LOCATOR_BUILDER_METHODS];
|
|
2901
|
+
var CHAIN_METHODS = [
|
|
2902
|
+
"first",
|
|
2903
|
+
"nth",
|
|
2904
|
+
"last",
|
|
2905
|
+
"filter",
|
|
2906
|
+
"and",
|
|
2907
|
+
"or",
|
|
2908
|
+
"locator",
|
|
2909
|
+
"getByRole",
|
|
2910
|
+
"getByTestId",
|
|
2911
|
+
"getByText",
|
|
2912
|
+
"getByLabel",
|
|
2913
|
+
"getByPlaceholder",
|
|
2914
|
+
"getByAltText",
|
|
2915
|
+
"getByTitle"
|
|
2916
|
+
];
|
|
2917
|
+
var ACTION_METHODS = [
|
|
2918
|
+
"click",
|
|
2919
|
+
"fill",
|
|
2920
|
+
"check",
|
|
2921
|
+
"uncheck",
|
|
2922
|
+
"selectOption",
|
|
2923
|
+
"dblclick",
|
|
2924
|
+
"tap",
|
|
2925
|
+
"hover",
|
|
2926
|
+
"press",
|
|
2927
|
+
"type",
|
|
2928
|
+
"pressSequentially",
|
|
2929
|
+
"clear",
|
|
2930
|
+
"setInputFiles",
|
|
2931
|
+
"dragTo",
|
|
2932
|
+
"focus",
|
|
2933
|
+
"blur",
|
|
2934
|
+
"scrollIntoViewIfNeeded",
|
|
2935
|
+
"dispatchEvent",
|
|
2936
|
+
"selectText",
|
|
2937
|
+
// Not an action, but a successful waitFor proves the element resolved — the
|
|
2938
|
+
// closest capture hook available for assertion-style usage of a locator.
|
|
2939
|
+
"waitFor"
|
|
2940
|
+
];
|
|
2941
|
+
var LOCATOR_CREATING_CHAINS = new Set(LOCATOR_METHODS);
|
|
2942
|
+
function extractAccessibleName(ariaSnapshot) {
|
|
2943
|
+
if (!ariaSnapshot) return null;
|
|
2944
|
+
const match = ariaSnapshot.match(/- \w+ "([^"]+)"/);
|
|
2945
|
+
if (match) return match[1];
|
|
2946
|
+
return null;
|
|
2947
|
+
}
|
|
2948
|
+
var NAME_BASED_METHODS = /* @__PURE__ */ new Set([
|
|
2949
|
+
"getByText",
|
|
2950
|
+
"getByRole",
|
|
2951
|
+
"getByLabel",
|
|
2952
|
+
"getByPlaceholder",
|
|
2953
|
+
"getByTitle",
|
|
2954
|
+
"getByAltText"
|
|
2955
|
+
]);
|
|
2956
|
+
var escAttr = (s) => s.replaceAll("\\", "\\\\").replaceAll("'", "\\'");
|
|
2957
|
+
var SUGG_TEXT_ROLES = /* @__PURE__ */ new Set([
|
|
2958
|
+
"button",
|
|
2959
|
+
"link",
|
|
2960
|
+
"heading",
|
|
2961
|
+
"menuitem",
|
|
2962
|
+
"tab",
|
|
2963
|
+
"option",
|
|
2964
|
+
"cell",
|
|
2965
|
+
"columnheader",
|
|
2966
|
+
"rowheader",
|
|
2967
|
+
"gridcell",
|
|
2968
|
+
"treeitem",
|
|
2969
|
+
"listitem",
|
|
2970
|
+
"checkbox",
|
|
2971
|
+
"radio",
|
|
2972
|
+
"switch"
|
|
2973
|
+
]);
|
|
2974
|
+
var SUGG_FIELD_ROLES = /* @__PURE__ */ new Set(["textbox", "combobox", "searchbox", "spinbutton", "slider"]);
|
|
2975
|
+
function failedNameAndRole(failed) {
|
|
2976
|
+
if (failed.method === "getByRole") {
|
|
2977
|
+
const role = typeof failed.args[0] === "string" ? failed.args[0] : null;
|
|
2978
|
+
const opts = failed.args[1];
|
|
2979
|
+
const name = opts && typeof opts.name === "string" ? opts.name : null;
|
|
2980
|
+
const level = opts && typeof opts.level === "number" ? opts.level : null;
|
|
2981
|
+
return { role, name, level };
|
|
2982
|
+
}
|
|
2983
|
+
const first = failed.args.find((a) => typeof a === "string");
|
|
2984
|
+
return { role: null, name: typeof first === "string" ? first : null, level: null };
|
|
2985
|
+
}
|
|
2986
|
+
function renderFailing(failed) {
|
|
2987
|
+
const { role, name } = failedNameAndRole(failed);
|
|
2988
|
+
if (failed.method === "getByRole") {
|
|
2989
|
+
return name ? `getByRole('${escAttr(role ?? "")}', { name: '${escAttr(name)}' })` : `getByRole('${escAttr(role ?? "")}')`;
|
|
2990
|
+
}
|
|
2991
|
+
return `${failed.method}('${escAttr(name ?? "")}')`;
|
|
2992
|
+
}
|
|
2993
|
+
function freshSuggestions(candidate, failedMethod) {
|
|
2994
|
+
const out = [];
|
|
2995
|
+
const role = candidate.role;
|
|
2996
|
+
const name = candidate.name;
|
|
2997
|
+
const push = (s) => {
|
|
2998
|
+
if (!out.includes(s)) out.push(s);
|
|
2999
|
+
};
|
|
3000
|
+
const levelPart = candidate.level != null ? `, level: ${candidate.level}` : "";
|
|
3001
|
+
const roleLoc = `getByRole('${escAttr(role)}', { name: '${escAttr(name)}'${levelPart} })`;
|
|
3002
|
+
const textLoc = `getByText('${escAttr(name)}')`;
|
|
3003
|
+
const labelLoc = `getByLabel('${escAttr(name)}')`;
|
|
3004
|
+
if (failedMethod === "getByText" && SUGG_TEXT_ROLES.has(role)) push(textLoc);
|
|
3005
|
+
if (failedMethod === "getByLabel" && SUGG_FIELD_ROLES.has(role)) push(labelLoc);
|
|
3006
|
+
push(roleLoc);
|
|
3007
|
+
if (SUGG_TEXT_ROLES.has(role)) push(textLoc);
|
|
3008
|
+
else if (SUGG_FIELD_ROLES.has(role)) push(labelLoc);
|
|
3009
|
+
return out;
|
|
3010
|
+
}
|
|
3011
|
+
function suggestLocatorsFromAria(failed, ariaSnapshot) {
|
|
3012
|
+
if (!ariaSnapshot || !NAME_BASED_METHODS.has(failed.method)) return null;
|
|
3013
|
+
const { role, name, level } = failedNameAndRole(failed);
|
|
3014
|
+
if (!name) return null;
|
|
3015
|
+
const candidates = parseAriaCandidates(ariaSnapshot);
|
|
3016
|
+
if (candidates.length === 0) return null;
|
|
3017
|
+
const fingerprint = { role, name, level };
|
|
3018
|
+
if (fingerprintPresent(fingerprint, candidates)) return null;
|
|
3019
|
+
const best = matchRenamedElement(fingerprint, candidates)?.candidate;
|
|
3020
|
+
if (!best || !best.name) return null;
|
|
3021
|
+
const suggestions = freshSuggestions({ role: best.role, name: best.name, level: best.level }, failed.method);
|
|
3022
|
+
if (suggestions.length === 0) return null;
|
|
3023
|
+
return { failing: renderFailing(failed), suggestions };
|
|
3024
|
+
}
|
|
3025
|
+
function captureCallerLocation(stack = new Error().stack ?? "") {
|
|
3026
|
+
const lines = stack.split("\n");
|
|
3027
|
+
let prevWasCaptureModule = false;
|
|
3028
|
+
for (let i = 1; i < lines.length; i++) {
|
|
3029
|
+
const line = lines[i].trim();
|
|
3030
|
+
if (!line.startsWith("at")) continue;
|
|
3031
|
+
const m = line.match(/^at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/);
|
|
3032
|
+
if (!m) {
|
|
3033
|
+
prevWasCaptureModule = false;
|
|
3034
|
+
continue;
|
|
3035
|
+
}
|
|
3036
|
+
let file = m[2];
|
|
3037
|
+
if (!file || file.startsWith("node:")) {
|
|
3038
|
+
prevWasCaptureModule = false;
|
|
3039
|
+
continue;
|
|
3040
|
+
}
|
|
3041
|
+
file = file.replace(/^file:\/\/\/?/, "");
|
|
3042
|
+
if (!/\.[a-z]+$/i.test(file)) {
|
|
3043
|
+
prevWasCaptureModule = false;
|
|
3044
|
+
continue;
|
|
3045
|
+
}
|
|
3046
|
+
if (/[\\/]locator-healing\.[a-z]+$/i.test(file)) {
|
|
3047
|
+
prevWasCaptureModule = true;
|
|
3048
|
+
continue;
|
|
3049
|
+
}
|
|
3050
|
+
if (prevWasCaptureModule && /[\\/](?:capture-)?fixtures\.[a-z]+$/i.test(file)) {
|
|
3051
|
+
prevWasCaptureModule = false;
|
|
3052
|
+
continue;
|
|
3053
|
+
}
|
|
3054
|
+
if (/[\\/]node_modules[\\/]/.test(file)) {
|
|
3055
|
+
prevWasCaptureModule = false;
|
|
3056
|
+
continue;
|
|
3057
|
+
}
|
|
3058
|
+
let rel = file;
|
|
3059
|
+
try {
|
|
3060
|
+
rel = path13.relative(process.cwd(), file);
|
|
3061
|
+
} catch {
|
|
3062
|
+
}
|
|
3063
|
+
rel = rel.split(path13.sep).join("/");
|
|
3064
|
+
if (rel.startsWith("./")) rel = rel.slice(2);
|
|
3065
|
+
return `${rel}:${m[3]}:${m[4]}`;
|
|
3066
|
+
}
|
|
3067
|
+
return null;
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
// src/internal/capture/inspect-on-failure.ts
|
|
3071
|
+
function isCi(ci) {
|
|
3072
|
+
return ci !== void 0 && ci !== "" && ci !== "false";
|
|
3073
|
+
}
|
|
3074
|
+
function shouldInspectOnFailure(gate) {
|
|
3075
|
+
if (gate.enabled !== "true") return false;
|
|
3076
|
+
if (isCi(gate.ci)) return false;
|
|
3077
|
+
if (gate.headless !== false) return false;
|
|
3078
|
+
if (gate.status !== "failed" && gate.status !== "timedOut") return false;
|
|
3079
|
+
if (gate.status === gate.expectedStatus) return false;
|
|
3080
|
+
return gate.retry >= gate.retries;
|
|
3081
|
+
}
|
|
3082
|
+
function environmentalSkipReason(gate) {
|
|
3083
|
+
if (gate.enabled !== "true") return null;
|
|
3084
|
+
if (gate.status !== "failed" && gate.status !== "timedOut") return null;
|
|
3085
|
+
if (gate.status === gate.expectedStatus) return null;
|
|
3086
|
+
if (isCi(gate.ci)) return "running under CI \u2014 this is a headed, local-only feature";
|
|
3087
|
+
if (gate.headless !== false) {
|
|
3088
|
+
return "the browser is headless \u2014 re-run with --headed (or set use: { headless: false })";
|
|
3089
|
+
}
|
|
3090
|
+
return null;
|
|
3091
|
+
}
|
|
3092
|
+
function inspectionGateFromTestInfo(testInfo, enabled = process.env.PIWI_INSPECT_ON_FAIL) {
|
|
3093
|
+
const use = testInfo.project?.use ?? {};
|
|
3094
|
+
return {
|
|
3095
|
+
enabled,
|
|
3096
|
+
ci: process.env.CI,
|
|
3097
|
+
status: testInfo.status,
|
|
3098
|
+
expectedStatus: testInfo.expectedStatus,
|
|
3099
|
+
headless: use.headless,
|
|
3100
|
+
retry: testInfo.retry,
|
|
3101
|
+
retries: testInfo.project?.retries ?? 0
|
|
3102
|
+
};
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
// src/internal/capture/pick-on-failure.ts
|
|
3106
|
+
var path14 = __toESM(require("path"));
|
|
3107
|
+
var ANSI_RE = /\[[0-9;]*m/g;
|
|
3108
|
+
function endOfString(s, start) {
|
|
3109
|
+
const q = s[start];
|
|
3110
|
+
for (let i = start + 1; i < s.length; i++) {
|
|
3111
|
+
if (s[i] === "\\") {
|
|
3112
|
+
i++;
|
|
3113
|
+
continue;
|
|
3114
|
+
}
|
|
3115
|
+
if (s[i] === q) return i;
|
|
3116
|
+
}
|
|
3117
|
+
return s.length - 1;
|
|
3118
|
+
}
|
|
3119
|
+
function matchBrace(s, start) {
|
|
3120
|
+
let depth = 0;
|
|
3121
|
+
for (let i = start; i < s.length; i++) {
|
|
3122
|
+
if (s[i] === "{") depth++;
|
|
3123
|
+
else if (s[i] === "}" && --depth === 0) return i;
|
|
3124
|
+
}
|
|
3125
|
+
return s.length - 1;
|
|
3126
|
+
}
|
|
3127
|
+
function parseOptions(src) {
|
|
3128
|
+
const obj = {};
|
|
3129
|
+
const re = /(\w+)\s*:\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*"|true|false|-?\d+)/g;
|
|
3130
|
+
let m;
|
|
3131
|
+
while ((m = re.exec(src)) !== null) {
|
|
3132
|
+
const key = m[1];
|
|
3133
|
+
const raw = m[2];
|
|
3134
|
+
if (raw === "true") obj[key] = true;
|
|
3135
|
+
else if (raw === "false") obj[key] = false;
|
|
3136
|
+
else if (/^-?\d+$/.test(raw)) obj[key] = Number(raw);
|
|
3137
|
+
else obj[key] = raw.slice(1, -1).replace(/\\(.)/g, "$1");
|
|
3138
|
+
}
|
|
3139
|
+
return obj;
|
|
3140
|
+
}
|
|
3141
|
+
function parseArgs(inner) {
|
|
3142
|
+
const args = [];
|
|
3143
|
+
let i = 0;
|
|
3144
|
+
while (i < inner.length) {
|
|
3145
|
+
const c = inner[i];
|
|
3146
|
+
if (c === " " || c === ",") {
|
|
3147
|
+
i++;
|
|
3148
|
+
continue;
|
|
3149
|
+
}
|
|
3150
|
+
if (c === "'" || c === '"') {
|
|
3151
|
+
const end = endOfString(inner, i);
|
|
3152
|
+
args.push(inner.slice(i + 1, end).replace(/\\(.)/g, "$1"));
|
|
3153
|
+
i = end + 1;
|
|
3154
|
+
continue;
|
|
3155
|
+
}
|
|
3156
|
+
if (c === "{") {
|
|
3157
|
+
const end = matchBrace(inner, i);
|
|
3158
|
+
args.push(parseOptions(inner.slice(i, end + 1)));
|
|
3159
|
+
i = end + 1;
|
|
3160
|
+
continue;
|
|
3161
|
+
}
|
|
3162
|
+
i++;
|
|
3163
|
+
}
|
|
3164
|
+
return args;
|
|
3165
|
+
}
|
|
3166
|
+
function leafExpression(expr) {
|
|
3167
|
+
let depth = 0;
|
|
3168
|
+
let leafStart = 0;
|
|
3169
|
+
for (let i = 0; i < expr.length - 1; i++) {
|
|
3170
|
+
const c = expr[i];
|
|
3171
|
+
if (c === "'" || c === '"') {
|
|
3172
|
+
i = endOfString(expr, i);
|
|
3173
|
+
continue;
|
|
3174
|
+
}
|
|
3175
|
+
if (c === "(") depth++;
|
|
3176
|
+
else if (c === ")") {
|
|
3177
|
+
depth--;
|
|
3178
|
+
if (depth === 0 && expr[i + 1] === ".") leafStart = i + 2;
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
return expr.slice(leafStart);
|
|
3182
|
+
}
|
|
3183
|
+
function parseLeafLocatorExpression(rawExpr) {
|
|
3184
|
+
const expr = leafExpression(rawExpr.trim());
|
|
3185
|
+
const m = /^([A-Za-z]+)\((.*)\)$/s.exec(expr);
|
|
3186
|
+
if (!m) return null;
|
|
3187
|
+
return { method: m[1], args: parseArgs(m[2].trim()) };
|
|
3188
|
+
}
|
|
3189
|
+
function deriveFailedLocator(testInfo) {
|
|
3190
|
+
const info = testInfo;
|
|
3191
|
+
const errors = info.errors && info.errors.length > 0 ? info.errors : info.error ? [info.error] : [];
|
|
3192
|
+
for (const err of errors) {
|
|
3193
|
+
const text = `${err.message ?? ""}
|
|
3194
|
+
${err.stack ?? ""}`.replace(ANSI_RE, "");
|
|
3195
|
+
const line = /^\s*Locator:\s*(.+)$/m.exec(text);
|
|
3196
|
+
if (!line) continue;
|
|
3197
|
+
const parsed = parseLeafLocatorExpression(line[1].trim());
|
|
3198
|
+
if (!parsed) continue;
|
|
3199
|
+
const loc = err.location;
|
|
3200
|
+
const location = loc ? `${path14.relative(process.cwd(), loc.file).split(path14.sep).join("/")}:${loc.line}:${loc.column}` : null;
|
|
3201
|
+
return { method: parsed.method, args: parsed.args, location };
|
|
3202
|
+
}
|
|
3203
|
+
return null;
|
|
3204
|
+
}
|
|
3205
|
+
var escStr = (s) => s.replaceAll("\\", "\\\\").replaceAll("'", "\\'");
|
|
3206
|
+
var cssIdSelector = (id) => /^[A-Za-z_][A-Za-z0-9_-]*$/.test(id) ? `#${id}` : `[id="${id.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"]`;
|
|
3207
|
+
var ANCHOR_KIND_SCORES = { testid: 80, id: 74, labeledRole: 68, role: 62 };
|
|
3208
|
+
function anchorSegment(a) {
|
|
3209
|
+
if (a.testId && a.testIdCount === 1) {
|
|
3210
|
+
return { code: `getByTestId('${escStr(a.testId)}')`, kind: "testid", flatArgs: { anchorTestId: a.testId } };
|
|
3211
|
+
}
|
|
3212
|
+
if (a.id && !isAutoGenerated(a.id) && a.idCount === 1) {
|
|
3213
|
+
const anchorSelector = cssIdSelector(a.id);
|
|
3214
|
+
return { code: `locator('${escStr(anchorSelector)}')`, kind: "id", flatArgs: { anchorSelector } };
|
|
3215
|
+
}
|
|
3216
|
+
if (a.role && a.ariaLabel && a.labeledRoleCount === 1) {
|
|
3217
|
+
return {
|
|
3218
|
+
code: `getByRole('${escStr(a.role)}', { name: '${escStr(a.ariaLabel)}' })`,
|
|
3219
|
+
kind: "labeledRole",
|
|
3220
|
+
flatArgs: { anchorRole: a.role, anchorName: a.ariaLabel }
|
|
3221
|
+
};
|
|
3222
|
+
}
|
|
3223
|
+
if (a.role && a.roleCount === 1) {
|
|
3224
|
+
return { code: `getByRole('${escStr(a.role)}')`, kind: "role", flatArgs: { anchorRole: a.role } };
|
|
3225
|
+
}
|
|
3226
|
+
return null;
|
|
3227
|
+
}
|
|
3228
|
+
function generateAnchoredAlternatives(leaf, anchors, chainLeafCount) {
|
|
3229
|
+
if (!leaf.role || anchors.length === 0) return [];
|
|
3230
|
+
const out = [];
|
|
3231
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3232
|
+
const rolePart = leaf.level != null ? `'${leaf.role}', { level: ${leaf.level} }` : `'${leaf.role}'`;
|
|
3233
|
+
const leafArgs = leaf.level != null ? { role: leaf.role, level: leaf.level } : { role: leaf.role };
|
|
3234
|
+
const add = (l) => {
|
|
3235
|
+
if (!seen.has(l.locator)) {
|
|
3236
|
+
seen.add(l.locator);
|
|
3237
|
+
out.push(l);
|
|
3238
|
+
}
|
|
3239
|
+
};
|
|
3240
|
+
for (const anchor of anchors) {
|
|
3241
|
+
if (anchor.scopedLeafCount !== 1) continue;
|
|
3242
|
+
const seg = anchorSegment(anchor);
|
|
3243
|
+
if (!seg) continue;
|
|
3244
|
+
add({
|
|
3245
|
+
locator: `${seg.code}.getByRole(${rolePart})`,
|
|
3246
|
+
method: "getByRole",
|
|
3247
|
+
args: { ...leafArgs, ...seg.flatArgs },
|
|
3248
|
+
score: ANCHOR_KIND_SCORES[seg.kind]
|
|
3249
|
+
});
|
|
3250
|
+
}
|
|
3251
|
+
if (anchors.length >= 2 && chainLeafCount === 1) {
|
|
3252
|
+
const ordered = [...anchors].sort((a, b) => b.depth - a.depth);
|
|
3253
|
+
const segments = ordered.map(anchorSegment);
|
|
3254
|
+
if (segments.every((s) => s !== null)) {
|
|
3255
|
+
const innermost = segments[segments.length - 1];
|
|
3256
|
+
const score = Math.min(...segments.map((s) => ANCHOR_KIND_SCORES[s.kind]));
|
|
3257
|
+
add({
|
|
3258
|
+
locator: `${segments.map((s) => s.code).join(".")}.getByRole(${rolePart})`,
|
|
3259
|
+
method: "getByRole",
|
|
3260
|
+
// Flat args describe the innermost anchor (what the healing fingerprint
|
|
3261
|
+
// logic understands); the full chain rides along for transparency.
|
|
3262
|
+
args: { ...leafArgs, ...innermost.flatArgs, anchorChain: segments.map((s) => s.code) },
|
|
3263
|
+
score
|
|
3264
|
+
});
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
return out.sort((a, b) => b.score - a.score);
|
|
3268
|
+
}
|
|
3269
|
+
function mergeCandidates(base, extra) {
|
|
3270
|
+
const seen = new Set(base.map((b) => b.locator));
|
|
3271
|
+
const merged = [...base];
|
|
3272
|
+
for (const e of extra) {
|
|
3273
|
+
if (seen.has(e.locator)) continue;
|
|
3274
|
+
seen.add(e.locator);
|
|
3275
|
+
merged.push(e);
|
|
3276
|
+
}
|
|
3277
|
+
return merged.sort((a, b) => b.score - a.score);
|
|
3278
|
+
}
|
|
3279
|
+
function installPickerOverlay(arg) {
|
|
3280
|
+
const g = globalThis;
|
|
3281
|
+
const doc = g.document;
|
|
3282
|
+
if (!doc || !doc.body) {
|
|
3283
|
+
g.__piwiPickState = "skipped";
|
|
3284
|
+
return;
|
|
3285
|
+
}
|
|
3286
|
+
const Z = 2147483600;
|
|
3287
|
+
const highlight = doc.createElement("div");
|
|
3288
|
+
highlight.style.cssText = `position:fixed;pointer-events:none;z-index:${Z};display:none;box-sizing:border-box;border:2px solid #7c3aed;background:rgba(124,58,237,.12);border-radius:3px;`;
|
|
3289
|
+
const banner = doc.createElement("div");
|
|
3290
|
+
banner.style.cssText = `position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:${Z + 2};background:#111827;color:#f9fafb;font:13px/1.5 system-ui,sans-serif;padding:10px 16px;border-radius:8px;box-shadow:0 4px 24px rgba(0,0,0,.4);max-width:80vw;`;
|
|
3291
|
+
const hlLocator = (expr) => {
|
|
3292
|
+
const escHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
3293
|
+
const re = /('(?:\\.|[^'])*'|"(?:\\.|[^"])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
|
|
3294
|
+
let html = "";
|
|
3295
|
+
let last = 0;
|
|
3296
|
+
let m;
|
|
3297
|
+
while ((m = re.exec(expr)) !== null) {
|
|
3298
|
+
if (m.index > last) html += escHtml(expr.slice(last, m.index));
|
|
3299
|
+
const color = m[1] ? "#4ade80" : m[2] ? "#c084fc" : m[3] ? "#93c5fd" : m[4] ? "#fbbf24" : "#9ca3af";
|
|
3300
|
+
html += `<span style="color:${color}">${escHtml(m[0])}</span>`;
|
|
3301
|
+
last = re.lastIndex;
|
|
3302
|
+
}
|
|
3303
|
+
if (last < expr.length) html += escHtml(expr.slice(last));
|
|
3304
|
+
return `<code style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace">${html}</code>`;
|
|
3305
|
+
};
|
|
3306
|
+
const head = doc.createElement("div");
|
|
3307
|
+
head.innerHTML = arg.failing ? `Piwi locator picker \u2014 click the element that should replace ${hlLocator(arg.failing)}` : "Piwi inspector \u2014 click any element to generate locators for it";
|
|
3308
|
+
const foot = doc.createElement("div");
|
|
3309
|
+
foot.style.cssText = "color:#9ca3af;margin-top:3px;";
|
|
3310
|
+
foot.textContent = "\u2191 parent \xB7 \u2193 child \xB7 Esc skip";
|
|
3311
|
+
banner.appendChild(head);
|
|
3312
|
+
banner.appendChild(foot);
|
|
3313
|
+
doc.body.appendChild(highlight);
|
|
3314
|
+
doc.body.appendChild(banner);
|
|
3315
|
+
const escJs = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
3316
|
+
const describe = (el) => {
|
|
3317
|
+
const tag = (el.tagName || "?").toLowerCase();
|
|
3318
|
+
const testId = el.getAttribute && el.getAttribute("data-testid");
|
|
3319
|
+
if (testId) return `getByTestId('${escJs(testId)}')`;
|
|
3320
|
+
if (el.labels && el.labels.length > 0) {
|
|
3321
|
+
const labelText = (el.labels[0].textContent || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
3322
|
+
if (labelText) return `getByLabel('${escJs(labelText)}')`;
|
|
3323
|
+
}
|
|
3324
|
+
const ariaLabel = el.getAttribute && el.getAttribute("aria-label");
|
|
3325
|
+
if (ariaLabel) return `getByLabel('${escJs(ariaLabel)}')`;
|
|
3326
|
+
const placeholder = el.getAttribute && el.getAttribute("placeholder");
|
|
3327
|
+
if (placeholder) return `getByPlaceholder('${escJs(placeholder)}')`;
|
|
3328
|
+
const alt = el.getAttribute && el.getAttribute("alt");
|
|
3329
|
+
if (alt) return `getByAltText('${escJs(alt)}')`;
|
|
3330
|
+
const titleAttr = el.getAttribute && el.getAttribute("title");
|
|
3331
|
+
if (titleAttr) return `getByTitle('${escJs(titleAttr)}')`;
|
|
3332
|
+
if (el.id) return `locator('#${escJs(el.id)}')`;
|
|
3333
|
+
const cls = (el.getAttribute && el.getAttribute("class") || "").split(/\s+/).find((c) => c.length > 1);
|
|
3334
|
+
return cls ? `locator('.${escJs(cls)}')` : tag;
|
|
3335
|
+
};
|
|
3336
|
+
const buildChain = (raw) => {
|
|
3337
|
+
const chain2 = [];
|
|
3338
|
+
let node = raw;
|
|
3339
|
+
while (node && chain2.length < 15) {
|
|
3340
|
+
const tag = (node.tagName || "").toLowerCase();
|
|
3341
|
+
if (tag === "body" || tag === "html") break;
|
|
3342
|
+
chain2.push(node);
|
|
3343
|
+
node = node.parentElement;
|
|
3344
|
+
}
|
|
3345
|
+
return chain2.length ? chain2 : [raw];
|
|
3346
|
+
};
|
|
3347
|
+
const ACTIONABLE_TAGS = ["button", "a", "input", "select", "textarea", "summary", "option"];
|
|
3348
|
+
const snapIndex = (chain2) => {
|
|
3349
|
+
for (let i = 0; i < Math.min(chain2.length, 4); i++) {
|
|
3350
|
+
const el = chain2[i];
|
|
3351
|
+
const tag = (el.tagName || "").toLowerCase();
|
|
3352
|
+
if (ACTIONABLE_TAGS.includes(tag)) return i;
|
|
3353
|
+
if (el.getAttribute && (el.getAttribute("role") || el.getAttribute("data-testid"))) return i;
|
|
3354
|
+
}
|
|
3355
|
+
return 0;
|
|
3356
|
+
};
|
|
3357
|
+
let chain = [];
|
|
3358
|
+
let idx = 0;
|
|
3359
|
+
let lastRaw = null;
|
|
3360
|
+
const current = () => chain[idx] ?? null;
|
|
3361
|
+
const refresh = () => {
|
|
3362
|
+
const el = current();
|
|
3363
|
+
if (!el) {
|
|
3364
|
+
highlight.style.display = "none";
|
|
3365
|
+
return;
|
|
3366
|
+
}
|
|
3367
|
+
const r = el.getBoundingClientRect();
|
|
3368
|
+
highlight.style.display = "block";
|
|
3369
|
+
highlight.style.left = r.left + "px";
|
|
3370
|
+
highlight.style.top = r.top + "px";
|
|
3371
|
+
highlight.style.width = r.width + "px";
|
|
3372
|
+
highlight.style.height = r.height + "px";
|
|
3373
|
+
foot.textContent = `${describe(el)} \u2014 click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip`;
|
|
3374
|
+
};
|
|
3375
|
+
const stop = (e) => {
|
|
3376
|
+
e.preventDefault();
|
|
3377
|
+
e.stopImmediatePropagation();
|
|
3378
|
+
};
|
|
3379
|
+
const isOwn = (el) => el === banner || el === highlight || banner.contains && banner.contains(el);
|
|
3380
|
+
let bannerDocked = "top";
|
|
3381
|
+
const dockBanner = (side) => {
|
|
3382
|
+
if (bannerDocked === side) return;
|
|
3383
|
+
bannerDocked = side;
|
|
3384
|
+
if (side === "bottom") {
|
|
3385
|
+
banner.style.top = "auto";
|
|
3386
|
+
banner.style.bottom = "12px";
|
|
3387
|
+
} else {
|
|
3388
|
+
banner.style.top = "12px";
|
|
3389
|
+
banner.style.bottom = "auto";
|
|
3390
|
+
}
|
|
3391
|
+
};
|
|
3392
|
+
const onMove = (e) => {
|
|
3393
|
+
const raw = e.target;
|
|
3394
|
+
if (!raw || isOwn(raw)) {
|
|
3395
|
+
highlight.style.display = "none";
|
|
3396
|
+
return;
|
|
3397
|
+
}
|
|
3398
|
+
if (raw !== lastRaw) {
|
|
3399
|
+
lastRaw = raw;
|
|
3400
|
+
chain = buildChain(raw);
|
|
3401
|
+
idx = snapIndex(chain);
|
|
3402
|
+
}
|
|
3403
|
+
refresh();
|
|
3404
|
+
const el = current();
|
|
3405
|
+
if (el) {
|
|
3406
|
+
const r = el.getBoundingClientRect();
|
|
3407
|
+
const br = banner.getBoundingClientRect();
|
|
3408
|
+
const margin = 8;
|
|
3409
|
+
if (r.left < br.right + margin && r.right > br.left - margin && r.top < br.bottom + margin && r.bottom > br.top - margin) {
|
|
3410
|
+
dockBanner(bannerDocked === "top" ? "bottom" : "top");
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
};
|
|
3414
|
+
const onClick = (e) => {
|
|
3415
|
+
stop(e);
|
|
3416
|
+
const el = current();
|
|
3417
|
+
if (!el || isOwn(e.target)) return;
|
|
3418
|
+
g.__piwiPickedElement = el;
|
|
3419
|
+
g.__piwiPickState = "picked";
|
|
3420
|
+
removeListeners();
|
|
3421
|
+
highlight.style.display = "none";
|
|
3422
|
+
foot.textContent = "Analyzing element\u2026";
|
|
3423
|
+
};
|
|
3424
|
+
const onKey = (e) => {
|
|
3425
|
+
if (e.key === "Escape") {
|
|
3426
|
+
stop(e);
|
|
3427
|
+
g.__piwiPickState = "skipped";
|
|
3428
|
+
cleanup();
|
|
3429
|
+
return;
|
|
3430
|
+
}
|
|
3431
|
+
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
|
3432
|
+
stop(e);
|
|
3433
|
+
if (e.key === "ArrowUp") idx = Math.min(idx + 1, chain.length - 1);
|
|
3434
|
+
else idx = Math.max(idx - 1, 0);
|
|
3435
|
+
refresh();
|
|
3436
|
+
}
|
|
3437
|
+
};
|
|
3438
|
+
const suppressed = ["mousedown", "mouseup", "pointerdown", "pointerup", "auxclick", "dblclick"];
|
|
3439
|
+
const removeListeners = () => {
|
|
3440
|
+
doc.removeEventListener("mousemove", onMove, true);
|
|
3441
|
+
doc.removeEventListener("click", onClick, true);
|
|
3442
|
+
doc.removeEventListener("keydown", onKey, true);
|
|
3443
|
+
for (const t of suppressed) doc.removeEventListener(t, stop, true);
|
|
3444
|
+
};
|
|
3445
|
+
const cleanup = () => {
|
|
3446
|
+
removeListeners();
|
|
3447
|
+
highlight.remove();
|
|
3448
|
+
banner.remove();
|
|
3449
|
+
};
|
|
3450
|
+
g.__piwiPickCleanup = cleanup;
|
|
3451
|
+
doc.addEventListener("mousemove", onMove, true);
|
|
3452
|
+
doc.addEventListener("click", onClick, true);
|
|
3453
|
+
doc.addEventListener("keydown", onKey, true);
|
|
3454
|
+
for (const t of suppressed) doc.addEventListener(t, stop, true);
|
|
3455
|
+
}
|
|
3456
|
+
function showAnchorPicker(arg) {
|
|
3457
|
+
const g = globalThis;
|
|
3458
|
+
const doc = g.document;
|
|
3459
|
+
const el = g.__piwiPickedElement;
|
|
3460
|
+
if (!doc || !doc.body || !el) {
|
|
3461
|
+
g.__piwiAnchorState = "skipped";
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
3464
|
+
const Z = 2147483600;
|
|
3465
|
+
const { tagRoles, inputRoles, roleSources, leafRole, leafLevel, leafTestId } = arg;
|
|
3466
|
+
const roleOf = (n) => {
|
|
3467
|
+
const explicit = n.getAttribute && n.getAttribute("role");
|
|
3468
|
+
if (explicit) return explicit;
|
|
3469
|
+
const tag = (n.tagName || "").toLowerCase();
|
|
3470
|
+
if (tag === "input") return inputRoles[(n.getAttribute("type") || "text").toLowerCase()] ?? "textbox";
|
|
3471
|
+
if (tag === "select") return n.getAttribute("multiple") != null ? "listbox" : "combobox";
|
|
3472
|
+
if (tag === "a") return n.getAttribute("href") != null ? "link" : null;
|
|
3473
|
+
return tagRoles[tag] ?? null;
|
|
3474
|
+
};
|
|
3475
|
+
const levelOf = (n) => {
|
|
3476
|
+
const m = /^h([1-6])$/.exec((n.tagName || "").toLowerCase());
|
|
3477
|
+
if (m) return Number(m[1]);
|
|
3478
|
+
const al = n.getAttribute && n.getAttribute("aria-level");
|
|
3479
|
+
return al && /^\d+$/.test(al) ? Number(al) : null;
|
|
3480
|
+
};
|
|
3481
|
+
const leafMatches = (scope) => {
|
|
3482
|
+
try {
|
|
3483
|
+
if (leafTestId) return scope.querySelectorAll(`[data-testid=${JSON.stringify(leafTestId)}]`).length;
|
|
3484
|
+
const nodes = scope.querySelectorAll(roleSources);
|
|
3485
|
+
if (nodes.length > 2e3) return -1;
|
|
3486
|
+
let matched = 0;
|
|
3487
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
3488
|
+
const n = nodes[i];
|
|
3489
|
+
if (roleOf(n) !== leafRole) continue;
|
|
3490
|
+
if (leafLevel != null && levelOf(n) !== leafLevel) continue;
|
|
3491
|
+
matched++;
|
|
3492
|
+
}
|
|
3493
|
+
return matched;
|
|
3494
|
+
} catch {
|
|
3495
|
+
return -1;
|
|
3496
|
+
}
|
|
3497
|
+
};
|
|
3498
|
+
let roleNodes = [];
|
|
3499
|
+
try {
|
|
3500
|
+
const all = doc.querySelectorAll(roleSources);
|
|
3501
|
+
if (all.length <= 4e3) roleNodes = Array.from(all);
|
|
3502
|
+
} catch {
|
|
3503
|
+
roleNodes = [];
|
|
3504
|
+
}
|
|
3505
|
+
const count = (sel) => {
|
|
3506
|
+
try {
|
|
3507
|
+
return doc.querySelectorAll(sel).length;
|
|
3508
|
+
} catch {
|
|
3509
|
+
return void 0;
|
|
3510
|
+
}
|
|
3511
|
+
};
|
|
3512
|
+
const rows = [];
|
|
3513
|
+
let node = el.parentElement;
|
|
3514
|
+
let depth = 0;
|
|
3515
|
+
while (node && depth < 12) {
|
|
3516
|
+
depth++;
|
|
3517
|
+
const tag = (node.tagName || "").toLowerCase();
|
|
3518
|
+
if (tag === "body" || tag === "html") break;
|
|
3519
|
+
const testId = node.getAttribute("data-testid");
|
|
3520
|
+
const id = node.getAttribute("id");
|
|
3521
|
+
const ariaLabel = node.getAttribute("aria-label");
|
|
3522
|
+
const role = roleOf(node);
|
|
3523
|
+
const info = { tag, depth, testId: testId || null, id: id || null, ariaLabel: ariaLabel || null, role };
|
|
3524
|
+
if (testId) info.testIdCount = count(`[data-testid=${JSON.stringify(testId)}]`);
|
|
3525
|
+
if (id) {
|
|
3526
|
+
try {
|
|
3527
|
+
info.idCount = count(`#${doc.defaultView.CSS.escape(id)}`);
|
|
3528
|
+
} catch {
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
if (role) {
|
|
3532
|
+
let roleCount = 0;
|
|
3533
|
+
let labeledCount = 0;
|
|
3534
|
+
for (const n of roleNodes) {
|
|
3535
|
+
if (roleOf(n) !== role) continue;
|
|
3536
|
+
roleCount++;
|
|
3537
|
+
if (ariaLabel && n.getAttribute && n.getAttribute("aria-label") === ariaLabel) labeledCount++;
|
|
3538
|
+
}
|
|
3539
|
+
info.roleCount = roleCount;
|
|
3540
|
+
if (ariaLabel) info.labeledRoleCount = labeledCount;
|
|
3541
|
+
}
|
|
3542
|
+
info.scopedLeafCount = leafMatches(node);
|
|
3543
|
+
const hookLabel = testId ? `data-testid="${testId}"` : id ? `#${id}` : ariaLabel && role ? `${role} "${ariaLabel}"` : role ? `role ${role}` : "no stable hook";
|
|
3544
|
+
rows.push({
|
|
3545
|
+
node,
|
|
3546
|
+
info,
|
|
3547
|
+
hookLabel,
|
|
3548
|
+
selectable: !!(testId || id || role && (ariaLabel || info.roleCount === 1))
|
|
3549
|
+
});
|
|
3550
|
+
node = node.parentElement;
|
|
3551
|
+
}
|
|
3552
|
+
if (rows.length === 0) {
|
|
3553
|
+
g.__piwiAnchorState = "skipped";
|
|
3554
|
+
return;
|
|
3555
|
+
}
|
|
3556
|
+
const outline = doc.createElement("div");
|
|
3557
|
+
outline.style.cssText = `position:fixed;pointer-events:none;z-index:${Z};box-sizing:border-box;border:2px solid #22c55e;border-radius:3px;display:none;`;
|
|
3558
|
+
const pickedOutline = doc.createElement("div");
|
|
3559
|
+
const pr = el.getBoundingClientRect();
|
|
3560
|
+
pickedOutline.style.cssText = `position:fixed;pointer-events:none;z-index:${Z};box-sizing:border-box;border:2px solid #7c3aed;background:rgba(124,58,237,.10);border-radius:3px;left:${pr.left}px;top:${pr.top}px;width:${pr.width}px;height:${pr.height}px;`;
|
|
3561
|
+
const panel = doc.createElement("div");
|
|
3562
|
+
panel.style.cssText = `position:fixed;top:12px;right:12px;z-index:${Z + 3};width:340px;max-height:82vh;overflow:auto;background:#111827;color:#f9fafb;border-radius:10px;padding:16px;font:12px/1.5 system-ui,sans-serif;box-shadow:0 8px 40px rgba(0,0,0,.5);`;
|
|
3563
|
+
const title = doc.createElement("div");
|
|
3564
|
+
title.style.cssText = "font-weight:600;font-size:13px;margin-bottom:2px;";
|
|
3565
|
+
title.textContent = "Scope to stable parents (optional)";
|
|
3566
|
+
const sub = doc.createElement("div");
|
|
3567
|
+
sub.style.cssText = "color:#9ca3af;margin-bottom:10px;";
|
|
3568
|
+
sub.textContent = "Pick one or more parents to anchor the locator to. Hover a row to see the parent.";
|
|
3569
|
+
panel.appendChild(title);
|
|
3570
|
+
panel.appendChild(sub);
|
|
3571
|
+
const selected = /* @__PURE__ */ new Set();
|
|
3572
|
+
const footer = doc.createElement("div");
|
|
3573
|
+
footer.style.cssText = "margin:10px 0;font-weight:600;";
|
|
3574
|
+
const segMatches = (scope, info) => {
|
|
3575
|
+
try {
|
|
3576
|
+
if (info.testId) return Array.from(scope.querySelectorAll(`[data-testid=${JSON.stringify(info.testId)}]`));
|
|
3577
|
+
if (info.id) return Array.from(scope.querySelectorAll(`#${doc.defaultView.CSS.escape(info.id)}`));
|
|
3578
|
+
const nodes = Array.from(scope.querySelectorAll(roleSources));
|
|
3579
|
+
if (nodes.length > 2e3) return [];
|
|
3580
|
+
return nodes.filter(
|
|
3581
|
+
(n) => roleOf(n) === info.role && (!info.ariaLabel || n.getAttribute && n.getAttribute("aria-label") === info.ariaLabel)
|
|
3582
|
+
);
|
|
3583
|
+
} catch {
|
|
3584
|
+
return [];
|
|
3585
|
+
}
|
|
3586
|
+
};
|
|
3587
|
+
const chainCount = () => {
|
|
3588
|
+
const chosen = rows.filter((_, i) => selected.has(i)).sort((a, b) => b.info.depth - a.info.depth);
|
|
3589
|
+
if (chosen.length === 0) return -1;
|
|
3590
|
+
let scopes = [doc];
|
|
3591
|
+
for (const row of chosen) {
|
|
3592
|
+
const next = [];
|
|
3593
|
+
for (const s of scopes) next.push(...segMatches(s, row.info));
|
|
3594
|
+
scopes = next.slice(0, 200);
|
|
3595
|
+
if (scopes.length === 0) return 0;
|
|
3596
|
+
}
|
|
3597
|
+
let total = 0;
|
|
3598
|
+
for (const s of scopes) {
|
|
3599
|
+
const c = leafMatches(s);
|
|
3600
|
+
if (c > 0) total += c;
|
|
3601
|
+
if (total > 50) return total;
|
|
3602
|
+
}
|
|
3603
|
+
return total;
|
|
3604
|
+
};
|
|
3605
|
+
const refreshFooter = () => {
|
|
3606
|
+
if (selected.size === 0) {
|
|
3607
|
+
footer.textContent = "No parents selected \u2014 standard alternatives only.";
|
|
3608
|
+
footer.style.color = "#9ca3af";
|
|
3609
|
+
g.__piwiPickChainCount = void 0;
|
|
3610
|
+
return;
|
|
3611
|
+
}
|
|
3612
|
+
const c = chainCount();
|
|
3613
|
+
g.__piwiPickChainCount = c;
|
|
3614
|
+
if (c === 1) {
|
|
3615
|
+
footer.textContent = "\u2713 Selection matches exactly 1 element";
|
|
3616
|
+
footer.style.color = "#4ade80";
|
|
3617
|
+
} else {
|
|
3618
|
+
footer.textContent = c < 0 ? "Match count unavailable" : `\u2717 Selection matches ${c} elements`;
|
|
3619
|
+
footer.style.color = "#fbbf24";
|
|
3620
|
+
}
|
|
3621
|
+
};
|
|
3622
|
+
rows.forEach((row, i) => {
|
|
3623
|
+
const line = doc.createElement("label");
|
|
3624
|
+
line.style.cssText = `display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid #374151;border-radius:6px;margin-bottom:6px;cursor:${row.selectable ? "pointer" : "default"};opacity:${row.selectable ? "1" : ".45"};`;
|
|
3625
|
+
const box = doc.createElement("input");
|
|
3626
|
+
box.type = "checkbox";
|
|
3627
|
+
box.disabled = !row.selectable;
|
|
3628
|
+
const text = doc.createElement("span");
|
|
3629
|
+
text.style.cssText = "flex:1;min-width:0;";
|
|
3630
|
+
const code = doc.createElement("code");
|
|
3631
|
+
code.style.cssText = "display:block;font:11px ui-monospace,monospace;color:#e5e7eb;word-break:break-all;";
|
|
3632
|
+
code.textContent = `<${row.info.tag}> ${row.hookLabel}`;
|
|
3633
|
+
const hint = doc.createElement("span");
|
|
3634
|
+
hint.style.cssText = "color:#9ca3af;";
|
|
3635
|
+
hint.textContent = row.selectable ? row.info.scopedLeafCount === 1 ? "contains exactly 1 matching element" : `contains ${row.info.scopedLeafCount ?? "?"} matching elements` : "add a data-testid to make this usable";
|
|
3636
|
+
text.appendChild(code);
|
|
3637
|
+
text.appendChild(hint);
|
|
3638
|
+
line.appendChild(box);
|
|
3639
|
+
line.appendChild(text);
|
|
3640
|
+
line.addEventListener("mouseenter", () => {
|
|
3641
|
+
const r = row.node.getBoundingClientRect();
|
|
3642
|
+
outline.style.display = "block";
|
|
3643
|
+
outline.style.left = r.left + "px";
|
|
3644
|
+
outline.style.top = r.top + "px";
|
|
3645
|
+
outline.style.width = r.width + "px";
|
|
3646
|
+
outline.style.height = r.height + "px";
|
|
3647
|
+
});
|
|
3648
|
+
line.addEventListener("mouseleave", () => {
|
|
3649
|
+
outline.style.display = "none";
|
|
3650
|
+
});
|
|
3651
|
+
box.addEventListener("change", () => {
|
|
3652
|
+
if (box.checked) selected.add(i);
|
|
3653
|
+
else selected.delete(i);
|
|
3654
|
+
refreshFooter();
|
|
3655
|
+
});
|
|
3656
|
+
panel.appendChild(line);
|
|
3657
|
+
});
|
|
3658
|
+
panel.appendChild(footer);
|
|
3659
|
+
const cleanup = () => {
|
|
3660
|
+
doc.removeEventListener("keydown", onKey, true);
|
|
3661
|
+
panel.remove();
|
|
3662
|
+
outline.remove();
|
|
3663
|
+
pickedOutline.remove();
|
|
3664
|
+
};
|
|
3665
|
+
const done = (state) => {
|
|
3666
|
+
g.__piwiPickAnchors = state === "done" ? rows.filter((_, i) => selected.has(i)).map((r) => r.info) : [];
|
|
3667
|
+
g.__piwiAnchorState = state;
|
|
3668
|
+
cleanup();
|
|
3669
|
+
};
|
|
3670
|
+
const onKey = (e) => {
|
|
3671
|
+
if (e.key !== "Escape") return;
|
|
3672
|
+
e.preventDefault();
|
|
3673
|
+
e.stopImmediatePropagation();
|
|
3674
|
+
done("skipped");
|
|
3675
|
+
};
|
|
3676
|
+
const buttonRow = doc.createElement("div");
|
|
3677
|
+
buttonRow.style.cssText = "display:flex;gap:8px;margin-top:4px;";
|
|
3678
|
+
const useBtn = doc.createElement("button");
|
|
3679
|
+
useBtn.style.cssText = "flex:1;background:#7c3aed;color:#fff;border:none;border-radius:6px;padding:8px;cursor:pointer;font:600 12px system-ui;";
|
|
3680
|
+
useBtn.textContent = "Use selected parents";
|
|
3681
|
+
useBtn.addEventListener("click", (e) => {
|
|
3682
|
+
e.preventDefault();
|
|
3683
|
+
e.stopImmediatePropagation();
|
|
3684
|
+
done(selected.size > 0 ? "done" : "skipped");
|
|
3685
|
+
});
|
|
3686
|
+
const skipBtn = doc.createElement("button");
|
|
3687
|
+
skipBtn.style.cssText = "background:none;border:1px solid #374151;color:#9ca3af;border-radius:6px;padding:8px 10px;cursor:pointer;font:12px system-ui;";
|
|
3688
|
+
skipBtn.textContent = "Skip (Esc)";
|
|
3689
|
+
skipBtn.addEventListener("click", (e) => {
|
|
3690
|
+
e.preventDefault();
|
|
3691
|
+
e.stopImmediatePropagation();
|
|
3692
|
+
done("skipped");
|
|
3693
|
+
});
|
|
3694
|
+
buttonRow.appendChild(useBtn);
|
|
3695
|
+
buttonRow.appendChild(skipBtn);
|
|
3696
|
+
panel.appendChild(buttonRow);
|
|
3697
|
+
refreshFooter();
|
|
3698
|
+
g.__piwiAnchorCleanup = cleanup;
|
|
3699
|
+
doc.addEventListener("keydown", onKey, true);
|
|
3700
|
+
doc.body.appendChild(pickedOutline);
|
|
3701
|
+
doc.body.appendChild(outline);
|
|
3702
|
+
doc.body.appendChild(panel);
|
|
3703
|
+
}
|
|
3704
|
+
function showPickerChoices(arg) {
|
|
3705
|
+
const g = globalThis;
|
|
3706
|
+
const doc = g.document;
|
|
3707
|
+
if (!doc || !doc.body) {
|
|
3708
|
+
g.__piwiPickChoice = -1;
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
const Z = 2147483600;
|
|
3712
|
+
const wrap = doc.createElement("div");
|
|
3713
|
+
wrap.style.cssText = `position:fixed;inset:0;z-index:${Z + 3};background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;font:13px/1.5 system-ui,sans-serif;`;
|
|
3714
|
+
const panel = doc.createElement("div");
|
|
3715
|
+
panel.style.cssText = "background:#111827;color:#f9fafb;border-radius:10px;padding:20px;max-width:640px;width:90vw;max-height:70vh;overflow:auto;box-shadow:0 8px 40px rgba(0,0,0,.5);";
|
|
3716
|
+
const hlLocator = (expr) => {
|
|
3717
|
+
const escHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
3718
|
+
const re = /('(?:\\.|[^'])*'|"(?:\\.|[^"])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
|
|
3719
|
+
let html = "";
|
|
3720
|
+
let last = 0;
|
|
3721
|
+
let m;
|
|
3722
|
+
while ((m = re.exec(expr)) !== null) {
|
|
3723
|
+
if (m.index > last) html += escHtml(expr.slice(last, m.index));
|
|
3724
|
+
const color = m[1] ? "#4ade80" : m[2] ? "#c084fc" : m[3] ? "#93c5fd" : m[4] ? "#fbbf24" : "#9ca3af";
|
|
3725
|
+
html += `<span style="color:${color}">${escHtml(m[0])}</span>`;
|
|
3726
|
+
last = re.lastIndex;
|
|
3727
|
+
}
|
|
3728
|
+
if (last < expr.length) html += escHtml(expr.slice(last));
|
|
3729
|
+
return html;
|
|
3730
|
+
};
|
|
3731
|
+
const title = doc.createElement("div");
|
|
3732
|
+
title.style.cssText = "font-weight:600;margin-bottom:4px;";
|
|
3733
|
+
title.textContent = arg.failing ? "Pick a replacement locator" : "Pick a locator";
|
|
3734
|
+
const sub = doc.createElement("div");
|
|
3735
|
+
sub.style.cssText = "color:#9ca3af;margin-bottom:12px;";
|
|
3736
|
+
if (arg.failing) {
|
|
3737
|
+
sub.innerHTML = `Replaces <code style="font-family:ui-monospace,Menlo,monospace">${hlLocator(arg.failing)}</code> \u2014 ranked by stability score.`;
|
|
3738
|
+
} else {
|
|
3739
|
+
sub.textContent = "For the element you picked \u2014 ranked by stability score.";
|
|
3740
|
+
}
|
|
3741
|
+
panel.appendChild(title);
|
|
3742
|
+
panel.appendChild(sub);
|
|
3743
|
+
const done = (choice) => {
|
|
3744
|
+
g.__piwiPickChoice = choice;
|
|
3745
|
+
doc.removeEventListener("keydown", onKey, true);
|
|
3746
|
+
wrap.remove();
|
|
3747
|
+
};
|
|
3748
|
+
const onKey = (e) => {
|
|
3749
|
+
if (e.key !== "Escape") return;
|
|
3750
|
+
e.preventDefault();
|
|
3751
|
+
e.stopImmediatePropagation();
|
|
3752
|
+
done(-1);
|
|
3753
|
+
};
|
|
3754
|
+
arg.choices.forEach((c, i) => {
|
|
3755
|
+
const btn = doc.createElement("button");
|
|
3756
|
+
btn.style.cssText = "display:flex;justify-content:space-between;align-items:center;gap:12px;width:100%;text-align:left;background:#1f2937;color:#e5e7eb;border:1px solid #374151;border-radius:6px;padding:8px 12px;margin:0 0 8px;cursor:pointer;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;";
|
|
3757
|
+
const code = doc.createElement("span");
|
|
3758
|
+
code.innerHTML = hlLocator(c.locator);
|
|
3759
|
+
code.style.cssText = "word-break:break-all;";
|
|
3760
|
+
const score = doc.createElement("span");
|
|
3761
|
+
score.textContent = String(c.score);
|
|
3762
|
+
score.style.cssText = "color:#a78bfa;flex-shrink:0;";
|
|
3763
|
+
btn.appendChild(code);
|
|
3764
|
+
btn.appendChild(score);
|
|
3765
|
+
btn.addEventListener("click", (e) => {
|
|
3766
|
+
e.preventDefault();
|
|
3767
|
+
e.stopImmediatePropagation();
|
|
3768
|
+
done(i);
|
|
3769
|
+
});
|
|
3770
|
+
panel.appendChild(btn);
|
|
3771
|
+
});
|
|
3772
|
+
const skip = doc.createElement("button");
|
|
3773
|
+
skip.style.cssText = "background:none;border:none;color:#9ca3af;cursor:pointer;padding:6px 0 0;font:12px system-ui,sans-serif;";
|
|
3774
|
+
skip.textContent = "Skip \u2014 keep the failure as-is (Esc)";
|
|
3775
|
+
skip.addEventListener("click", (e) => {
|
|
3776
|
+
e.preventDefault();
|
|
3777
|
+
e.stopImmediatePropagation();
|
|
3778
|
+
done(-1);
|
|
3779
|
+
});
|
|
3780
|
+
panel.appendChild(skip);
|
|
3781
|
+
doc.addEventListener("keydown", onKey, true);
|
|
3782
|
+
wrap.appendChild(panel);
|
|
3783
|
+
doc.body.appendChild(wrap);
|
|
3784
|
+
}
|
|
3785
|
+
async function cleanupPicker(page) {
|
|
3786
|
+
try {
|
|
3787
|
+
await page.evaluate(() => {
|
|
3788
|
+
const g = globalThis;
|
|
3789
|
+
if (typeof g.__piwiPickCleanup === "function") g.__piwiPickCleanup();
|
|
3790
|
+
if (typeof g.__piwiAnchorCleanup === "function") g.__piwiAnchorCleanup();
|
|
3791
|
+
delete g.__piwiPickCleanup;
|
|
3792
|
+
delete g.__piwiAnchorCleanup;
|
|
3793
|
+
delete g.__piwiPickState;
|
|
3794
|
+
delete g.__piwiPickChoice;
|
|
3795
|
+
delete g.__piwiPickedElement;
|
|
3796
|
+
delete g.__piwiAnchorState;
|
|
3797
|
+
delete g.__piwiPickAnchors;
|
|
3798
|
+
delete g.__piwiPickChainCount;
|
|
3799
|
+
});
|
|
3800
|
+
} catch {
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
async function runLocatorPicker(page, testInfo, failed, probe) {
|
|
3804
|
+
try {
|
|
3805
|
+
testInfo.setTimeout(0);
|
|
3806
|
+
const rendered = failed ? renderFailing(failed) : null;
|
|
3807
|
+
console.log(
|
|
3808
|
+
`
|
|
3809
|
+
[piwi] "${testInfo.title}" ${testInfo.status} \u2014 ${rendered ? "locator picker" : "inspector"} open in the browser: ${rendered ? `click the element that should replace ${rendered}` : "click any element to generate locators for it"} (\u2191/\u2193 to select a parent/child, Esc to skip).`
|
|
3810
|
+
);
|
|
3811
|
+
await page.evaluate(installPickerOverlay, { failing: rendered });
|
|
3812
|
+
await page.waitForFunction(() => globalThis.__piwiPickState !== void 0, void 0, {
|
|
3813
|
+
timeout: 0,
|
|
3814
|
+
polling: 250
|
|
3815
|
+
});
|
|
3816
|
+
const state = await page.evaluate(() => globalThis.__piwiPickState);
|
|
3817
|
+
if (state !== "picked") {
|
|
3818
|
+
await cleanupPicker(page);
|
|
3819
|
+
return null;
|
|
3820
|
+
}
|
|
3821
|
+
const handle = await page.evaluateHandle(() => globalThis.__piwiPickedElement);
|
|
3822
|
+
const attrs = await handle.evaluate(probe.fn, probe.arg);
|
|
3823
|
+
await handle.dispose();
|
|
3824
|
+
const accessibleName = approximateAccessibleName({ ...attrs, accessibleName: null });
|
|
3825
|
+
const role = resolveAriaRole({ ...attrs, accessibleName });
|
|
3826
|
+
const level = headingLevel({ ...attrs, accessibleName }, role);
|
|
3827
|
+
let anchors = [];
|
|
3828
|
+
let chainLeafCount;
|
|
3829
|
+
if (role) {
|
|
3830
|
+
const probeArg = probe.arg;
|
|
3831
|
+
await page.evaluate(showAnchorPicker, {
|
|
3832
|
+
tagRoles: probeArg.tagRoles,
|
|
3833
|
+
inputRoles: probeArg.inputRoles,
|
|
3834
|
+
roleSources: probeArg.roleSources,
|
|
3835
|
+
leafRole: role,
|
|
3836
|
+
leafLevel: level,
|
|
3837
|
+
leafTestId: attrs.attributes["data-testid"] ?? null
|
|
3838
|
+
});
|
|
3839
|
+
await page.waitForFunction(() => globalThis.__piwiAnchorState !== void 0, void 0, {
|
|
3840
|
+
timeout: 0,
|
|
3841
|
+
polling: 250
|
|
3842
|
+
});
|
|
3843
|
+
const anchorResult = await page.evaluate(() => {
|
|
3844
|
+
const g = globalThis;
|
|
3845
|
+
return { state: g.__piwiAnchorState, anchors: g.__piwiPickAnchors ?? [], chainCount: g.__piwiPickChainCount };
|
|
3846
|
+
});
|
|
3847
|
+
if (anchorResult.state === "done" && anchorResult.anchors.length > 0) {
|
|
3848
|
+
anchors = anchorResult.anchors;
|
|
3849
|
+
chainLeafCount = anchorResult.chainCount;
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
const ranked = mergeCandidates(
|
|
3853
|
+
generateAlternatives({ ...attrs, accessibleName }),
|
|
3854
|
+
generateAnchoredAlternatives({ role, level }, anchors, chainLeafCount)
|
|
3855
|
+
);
|
|
3856
|
+
if (ranked.length === 0) {
|
|
3857
|
+
console.log("[piwi] No stable locator could be generated for the picked element \u2014 nothing recorded.");
|
|
3858
|
+
await cleanupPicker(page);
|
|
3859
|
+
return null;
|
|
3860
|
+
}
|
|
3861
|
+
await page.evaluate(showPickerChoices, {
|
|
3862
|
+
failing: rendered,
|
|
3863
|
+
choices: ranked.map((r) => ({ locator: r.locator, score: r.score }))
|
|
3864
|
+
});
|
|
3865
|
+
await page.waitForFunction(() => globalThis.__piwiPickChoice !== void 0, void 0, {
|
|
3866
|
+
timeout: 0,
|
|
3867
|
+
polling: 250
|
|
3868
|
+
});
|
|
3869
|
+
const choice = await page.evaluate(() => globalThis.__piwiPickChoice);
|
|
3870
|
+
await cleanupPicker(page);
|
|
3871
|
+
if (typeof choice !== "number" || choice < 0 || choice >= ranked.length) return null;
|
|
3872
|
+
const picked = { ...ranked[choice], pickedByUser: true };
|
|
3873
|
+
const alternatives = [picked, ...ranked.filter((_, i) => i !== choice)];
|
|
3874
|
+
const element = {
|
|
3875
|
+
tagName: attrs.tagName,
|
|
3876
|
+
attributes: attrs.attributes,
|
|
3877
|
+
textContent: attrs.textContent,
|
|
3878
|
+
accessibleName,
|
|
3879
|
+
center: attrs.center,
|
|
3880
|
+
...attrs.rolePosition ? { rolePosition: attrs.rolePosition } : {},
|
|
3881
|
+
...attrs.ancestors && attrs.ancestors.length > 0 ? { ancestors: attrs.ancestors } : {}
|
|
3882
|
+
};
|
|
3883
|
+
const location = failed?.location ?? null;
|
|
3884
|
+
console.log(
|
|
3885
|
+
failed ? `[piwi] Replacement picked for ${rendered}${location ? ` at ${location}` : ""}:
|
|
3886
|
+
[piwi] ${picked.locator}` : `[piwi] Locator picked while inspecting:
|
|
3887
|
+
[piwi] ${picked.locator}`
|
|
3888
|
+
);
|
|
3889
|
+
return {
|
|
3890
|
+
failing: failed ? { method: failed.method, args: failed.args, rendered, location } : null,
|
|
3891
|
+
picked,
|
|
3892
|
+
alternatives,
|
|
3893
|
+
element,
|
|
3894
|
+
...anchors.length > 0 ? { anchors } : {}
|
|
3895
|
+
};
|
|
3896
|
+
} catch {
|
|
3897
|
+
return null;
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
function applyPickToSnapshots(snapshots, pick) {
|
|
3901
|
+
if (!pick.failing?.location) return false;
|
|
3902
|
+
const failing = pick.failing;
|
|
3903
|
+
for (let i = snapshots.length - 1; i >= 0; i--) {
|
|
3904
|
+
const snap = snapshots[i];
|
|
3905
|
+
if (snap.location !== failing.location || snap.element) continue;
|
|
3906
|
+
snapshots[i] = {
|
|
3907
|
+
location: snap.location,
|
|
3908
|
+
used: snap.used,
|
|
3909
|
+
element: pick.element,
|
|
3910
|
+
alternatives: pick.alternatives.slice(0, 10)
|
|
3911
|
+
};
|
|
3912
|
+
return true;
|
|
3913
|
+
}
|
|
3914
|
+
snapshots.push({
|
|
3915
|
+
location: failing.location,
|
|
3916
|
+
used: {
|
|
3917
|
+
method: failing.method,
|
|
3918
|
+
args: failing.args,
|
|
3919
|
+
raw: `${failing.method}(${JSON.stringify(failing.args)})`
|
|
3920
|
+
},
|
|
3921
|
+
element: pick.element,
|
|
3922
|
+
alternatives: pick.alternatives.slice(0, 10)
|
|
3923
|
+
});
|
|
3924
|
+
return true;
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
// src/internal/capture/capture-fixtures.ts
|
|
3928
|
+
function computeCoreVitals(lcpEntries, shiftEntries, eventEntries) {
|
|
3929
|
+
const lastLcp = lcpEntries && lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : null;
|
|
3930
|
+
const lcp = lastLcp && typeof lastLcp.startTime === "number" ? Math.round(lastLcp.startTime) : null;
|
|
3931
|
+
let cls = null;
|
|
3932
|
+
if (shiftEntries) {
|
|
3933
|
+
const sum = shiftEntries.reduce(
|
|
3934
|
+
(acc, e) => acc + (e.hadRecentInput ? 0 : typeof e.value === "number" ? e.value : 0),
|
|
3935
|
+
0
|
|
3936
|
+
);
|
|
3937
|
+
cls = Math.round(sum * 1e4) / 1e4;
|
|
3938
|
+
}
|
|
3939
|
+
let inp = null;
|
|
3940
|
+
if (eventEntries && eventEntries.length > 0) {
|
|
3941
|
+
const byInteraction = /* @__PURE__ */ new Map();
|
|
3942
|
+
for (const e of eventEntries) {
|
|
3943
|
+
if (typeof e.interactionId !== "number" || e.interactionId <= 0) continue;
|
|
3944
|
+
const duration = typeof e.duration === "number" ? e.duration : 0;
|
|
3945
|
+
const prev = byInteraction.get(e.interactionId) ?? 0;
|
|
3946
|
+
if (duration > prev) byInteraction.set(e.interactionId, duration);
|
|
3947
|
+
}
|
|
3948
|
+
const durations = [...byInteraction.values()].sort((a, b) => a - b);
|
|
3949
|
+
if (durations.length > 0) {
|
|
3950
|
+
const index = durations.length > 50 ? Math.floor(durations.length * 0.98) : durations.length - 1;
|
|
3951
|
+
inp = Math.round(durations[Math.min(index, durations.length - 1)]);
|
|
3952
|
+
}
|
|
3953
|
+
}
|
|
3954
|
+
if (lcp === null && cls === null && inp === null) return null;
|
|
3955
|
+
return { lcp, cls, inp };
|
|
3956
|
+
}
|
|
3957
|
+
function createSink() {
|
|
3958
|
+
return {
|
|
3959
|
+
networkRequests: [],
|
|
3960
|
+
consoleEntries: [],
|
|
3961
|
+
pendingHandlers: [],
|
|
3962
|
+
capturedLocators: [],
|
|
3963
|
+
capturePromises: [],
|
|
3964
|
+
failedLocators: [],
|
|
3965
|
+
lastActivePage: null,
|
|
3966
|
+
testInfo: null,
|
|
3967
|
+
stashedWebVitals: null,
|
|
3968
|
+
stashedPageState: null,
|
|
3969
|
+
stashedAria: null,
|
|
3970
|
+
pickOffered: false,
|
|
3971
|
+
userPick: null
|
|
3972
|
+
};
|
|
3973
|
+
}
|
|
3974
|
+
var currentSink = null;
|
|
3975
|
+
var PENDING_PROBES = /* @__PURE__ */ new Set();
|
|
3976
|
+
async function drainPendingProbes(capMs) {
|
|
3977
|
+
if (PENDING_PROBES.size === 0) return;
|
|
3978
|
+
let cap;
|
|
3979
|
+
await Promise.race([
|
|
3980
|
+
Promise.allSettled(PENDING_PROBES),
|
|
3981
|
+
new Promise((resolve5) => {
|
|
3982
|
+
cap = setTimeout(resolve5, capMs);
|
|
3983
|
+
})
|
|
3984
|
+
]);
|
|
3985
|
+
clearTimeout(cap);
|
|
3986
|
+
}
|
|
3987
|
+
function isPageClosed(page) {
|
|
3988
|
+
try {
|
|
3989
|
+
return typeof page.isClosed === "function" ? page.isClosed() : false;
|
|
3990
|
+
} catch {
|
|
3991
|
+
return false;
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
function pageContext(page) {
|
|
3995
|
+
try {
|
|
3996
|
+
return typeof page.context === "function" ? page.context() : null;
|
|
3997
|
+
} catch {
|
|
3998
|
+
return null;
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
async function readWebVitals(page) {
|
|
4002
|
+
try {
|
|
4003
|
+
const probe = await page.evaluate(async () => {
|
|
4004
|
+
const navEntries = performance.getEntriesByType("navigation");
|
|
4005
|
+
const paintEntries = performance.getEntriesByType("paint");
|
|
4006
|
+
const nav = navEntries[0];
|
|
4007
|
+
const navigation = nav ? {
|
|
4008
|
+
url: nav.name,
|
|
4009
|
+
ttfb: Math.round(nav.responseStart - nav.fetchStart),
|
|
4010
|
+
domInteractive: Math.round(nav.domInteractive - nav.fetchStart),
|
|
4011
|
+
domContentLoaded: Math.round(nav.domContentLoadedEventEnd - nav.fetchStart),
|
|
4012
|
+
loadComplete: Math.round(nav.loadEventEnd - nav.fetchStart),
|
|
4013
|
+
transferSize: nav.transferSize || 0,
|
|
4014
|
+
encodedBodySize: nav.encodedBodySize || 0,
|
|
4015
|
+
decodedBodySize: nav.decodedBodySize || 0
|
|
4016
|
+
} : null;
|
|
4017
|
+
const paint = {};
|
|
4018
|
+
for (const entry of paintEntries) {
|
|
4019
|
+
const key = entry.name.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
|
|
4020
|
+
paint[key] = Math.round(entry.startTime);
|
|
4021
|
+
}
|
|
4022
|
+
const readBuffered = (type, extra) => new Promise((resolve5) => {
|
|
4023
|
+
try {
|
|
4024
|
+
const PO = globalThis.PerformanceObserver;
|
|
4025
|
+
if (!PO || !(PO.supportedEntryTypes || []).includes(type)) return resolve5(null);
|
|
4026
|
+
const out = [];
|
|
4027
|
+
const po = new PO((list) => out.push(...list.getEntries()));
|
|
4028
|
+
po.observe({ type, buffered: true, ...extra });
|
|
4029
|
+
setTimeout(() => {
|
|
4030
|
+
try {
|
|
4031
|
+
out.push(...po.takeRecords());
|
|
4032
|
+
po.disconnect();
|
|
4033
|
+
} catch {
|
|
4034
|
+
}
|
|
4035
|
+
resolve5(out);
|
|
4036
|
+
}, 0);
|
|
4037
|
+
} catch {
|
|
4038
|
+
resolve5(null);
|
|
4039
|
+
}
|
|
4040
|
+
});
|
|
4041
|
+
const [lcpRaw, shiftRaw, eventRaw, firstInputRaw] = await Promise.all([
|
|
4042
|
+
readBuffered("largest-contentful-paint"),
|
|
4043
|
+
readBuffered("layout-shift"),
|
|
4044
|
+
// durationThreshold 40 mirrors the web-vitals library — captures every
|
|
4045
|
+
// interaction slow enough to matter without flooding the buffer.
|
|
4046
|
+
readBuffered("event", { durationThreshold: 40 }),
|
|
4047
|
+
readBuffered("first-input")
|
|
4048
|
+
]);
|
|
4049
|
+
const project = (entries) => entries === null ? null : entries.map((e) => ({
|
|
4050
|
+
startTime: e.startTime,
|
|
4051
|
+
value: e.value,
|
|
4052
|
+
hadRecentInput: e.hadRecentInput,
|
|
4053
|
+
interactionId: e.interactionId,
|
|
4054
|
+
duration: e.duration
|
|
4055
|
+
}));
|
|
4056
|
+
const interactionEntries = eventRaw === null && firstInputRaw === null ? null : [...eventRaw ?? [], ...firstInputRaw ?? []];
|
|
4057
|
+
return {
|
|
4058
|
+
navigation,
|
|
4059
|
+
paint,
|
|
4060
|
+
lcpEntries: project(lcpRaw),
|
|
4061
|
+
shiftEntries: project(shiftRaw),
|
|
4062
|
+
eventEntries: project(interactionEntries)
|
|
4063
|
+
};
|
|
4064
|
+
});
|
|
4065
|
+
if (!probe) return null;
|
|
4066
|
+
const vitals = computeCoreVitals(probe.lcpEntries, probe.shiftEntries, probe.eventEntries);
|
|
4067
|
+
if (!probe.navigation && Object.keys(probe.paint).length === 0 && !vitals) return null;
|
|
4068
|
+
return { navigation: probe.navigation, paint: probe.paint, vitals };
|
|
4069
|
+
} catch {
|
|
4070
|
+
return null;
|
|
4071
|
+
}
|
|
4072
|
+
}
|
|
4073
|
+
var PAGE_STATE_MAX_STORAGE_KEYS = 50;
|
|
4074
|
+
var PAGE_STATE_MAX_COOKIES = 30;
|
|
4075
|
+
var PAGE_STATE_HISTORY_CAP = 2048;
|
|
4076
|
+
var TOKEN_MASK_RES = [/\beyJ[\w-]{10,}\.[\w-]{5,}\.[\w-]{5,}\b/g, /\b[0-9a-f]{32,}\b/gi];
|
|
4077
|
+
function buildPageState(raw, cookies) {
|
|
4078
|
+
let historyState = raw.historyState;
|
|
4079
|
+
if (historyState) {
|
|
4080
|
+
for (const re of TOKEN_MASK_RES) historyState = historyState.replace(re, "[masked]");
|
|
4081
|
+
if (historyState.length > PAGE_STATE_HISTORY_CAP) {
|
|
4082
|
+
historyState = historyState.slice(0, PAGE_STATE_HISTORY_CAP) + "\u2026";
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
const capStorage = (entries) => (Array.isArray(entries) ? entries : []).slice(0, PAGE_STATE_MAX_STORAGE_KEYS).map((e) => ({
|
|
4086
|
+
key: String(e.key).slice(0, 200),
|
|
4087
|
+
length: typeof e.length === "number" ? e.length : 0
|
|
4088
|
+
}));
|
|
4089
|
+
return {
|
|
4090
|
+
url: raw.url,
|
|
4091
|
+
hash: raw.hash || null,
|
|
4092
|
+
historyState: historyState || null,
|
|
4093
|
+
localStorage: capStorage(raw.localStorage),
|
|
4094
|
+
sessionStorage: capStorage(raw.sessionStorage),
|
|
4095
|
+
cookies: (cookies ?? []).slice(0, PAGE_STATE_MAX_COOKIES).map((c) => ({
|
|
4096
|
+
name: String(c.name ?? ""),
|
|
4097
|
+
domain: String(c.domain ?? ""),
|
|
4098
|
+
path: String(c.path ?? ""),
|
|
4099
|
+
httpOnly: Boolean(c.httpOnly),
|
|
4100
|
+
secure: Boolean(c.secure),
|
|
4101
|
+
...c.sameSite !== void 0 ? { sameSite: String(c.sameSite) } : {},
|
|
4102
|
+
...typeof c.expires === "number" ? { expires: c.expires } : {}
|
|
4103
|
+
}))
|
|
4104
|
+
};
|
|
4105
|
+
}
|
|
4106
|
+
async function readPageState(page) {
|
|
4107
|
+
try {
|
|
4108
|
+
const raw = await page.evaluate(() => {
|
|
4109
|
+
const listStorage = (s) => {
|
|
4110
|
+
const out = [];
|
|
4111
|
+
try {
|
|
4112
|
+
for (let i = 0; i < s.length; i++) {
|
|
4113
|
+
const key = s.key(i);
|
|
4114
|
+
if (key != null) out.push({ key, length: (s.getItem(key) ?? "").length });
|
|
4115
|
+
}
|
|
4116
|
+
} catch {
|
|
4117
|
+
}
|
|
4118
|
+
return out;
|
|
4119
|
+
};
|
|
4120
|
+
const g = globalThis;
|
|
4121
|
+
let historyState = null;
|
|
4122
|
+
try {
|
|
4123
|
+
historyState = g.history?.state == null ? null : JSON.stringify(g.history.state);
|
|
4124
|
+
} catch {
|
|
4125
|
+
}
|
|
4126
|
+
return {
|
|
4127
|
+
url: g.location.href,
|
|
4128
|
+
hash: g.location.hash || null,
|
|
4129
|
+
historyState,
|
|
4130
|
+
localStorage: listStorage(globalThis.localStorage),
|
|
4131
|
+
sessionStorage: listStorage(globalThis.sessionStorage)
|
|
4132
|
+
};
|
|
4133
|
+
});
|
|
4134
|
+
if (!raw) return null;
|
|
4135
|
+
let cookies = null;
|
|
4136
|
+
try {
|
|
4137
|
+
cookies = await pageContext(page)?.cookies() ?? null;
|
|
4138
|
+
} catch {
|
|
4139
|
+
cookies = null;
|
|
4140
|
+
}
|
|
4141
|
+
return buildPageState(raw, cookies);
|
|
4142
|
+
} catch {
|
|
4143
|
+
return null;
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4146
|
+
async function stashPageState(sink, closing) {
|
|
4147
|
+
const page = sink.lastActivePage;
|
|
4148
|
+
if (!page || isPageClosed(page)) return;
|
|
4149
|
+
const belongsToClosing = closing.page === page || closing.context !== void 0 && pageContext(page) === closing.context;
|
|
4150
|
+
if (!belongsToClosing) return;
|
|
4151
|
+
const vitals = await readWebVitals(page);
|
|
4152
|
+
if (vitals) sink.stashedWebVitals = vitals;
|
|
4153
|
+
if (process.env.PIWI_CAPTURE_PAGE_STATE !== "false") {
|
|
4154
|
+
const pageState = await readPageState(page);
|
|
4155
|
+
if (pageState) sink.stashedPageState = pageState;
|
|
4156
|
+
}
|
|
4157
|
+
const status = sink.testInfo?.status;
|
|
4158
|
+
if (status === "failed" || status === "timedOut" || status === "interrupted") {
|
|
4159
|
+
const aria = await ariaSnapshotBestEffort(page.locator(":root"), 1e3);
|
|
4160
|
+
if (aria) sink.stashedAria = aria;
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
4163
|
+
async function maybeOpenPicker(sink, closing) {
|
|
4164
|
+
if (sink.pickOffered) return;
|
|
4165
|
+
const page = sink.lastActivePage;
|
|
4166
|
+
const testInfo = sink.testInfo;
|
|
4167
|
+
if (!page || !testInfo || isPageClosed(page)) return;
|
|
4168
|
+
if (closing) {
|
|
4169
|
+
const belongsToClosing = closing.page === page || closing.context !== void 0 && pageContext(page) === closing.context;
|
|
4170
|
+
if (!belongsToClosing) return;
|
|
4171
|
+
}
|
|
4172
|
+
const pickGate = shouldInspectOnFailure(inspectionGateFromTestInfo(testInfo, process.env.PIWI_PICK_LOCATOR_ON_FAIL));
|
|
4173
|
+
const inspectGate = shouldInspectOnFailure(inspectionGateFromTestInfo(testInfo, process.env.PIWI_INSPECT_ON_FAIL));
|
|
4174
|
+
if (!pickGate && !inspectGate) return;
|
|
4175
|
+
sink.pickOffered = true;
|
|
4176
|
+
const failed = sink.failedLocators[sink.failedLocators.length - 1] ?? deriveFailedLocator(testInfo);
|
|
4177
|
+
if (!failed && !inspectGate) {
|
|
4178
|
+
console.log("[piwi] locator picker: no failing locator could be identified in this failure \u2014 nothing to replace.");
|
|
4179
|
+
return;
|
|
4180
|
+
}
|
|
4181
|
+
const pick = await runLocatorPicker(page, testInfo, failed, { fn: probeElementAttrs, arg: CAPTURED_ATTRS_ARG });
|
|
4182
|
+
if (!pick) return;
|
|
4183
|
+
sink.userPick = pick;
|
|
4184
|
+
applyPickToSnapshots(sink.capturedLocators, pick);
|
|
4185
|
+
}
|
|
4186
|
+
var INSTRUMENTED_PAGES = /* @__PURE__ */ new WeakSet();
|
|
4187
|
+
var INSTRUMENTED_CONTEXTS = /* @__PURE__ */ new WeakSet();
|
|
4188
|
+
var PATCHED_BROWSERS = /* @__PURE__ */ new WeakSet();
|
|
4189
|
+
var CHAIN_METHOD_SET = new Set(CHAIN_METHODS);
|
|
4190
|
+
var ACTION_METHOD_SET = new Set(ACTION_METHODS);
|
|
4191
|
+
var FORM_FIELD_TAGS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
|
|
4192
|
+
var CAPTURED_ATTRS_ARG = {
|
|
4193
|
+
keep: [...CAPTURED_ATTRIBUTES],
|
|
4194
|
+
tagRoles: TAG_TO_ROLE,
|
|
4195
|
+
inputRoles: INPUT_TYPE_TO_ROLE,
|
|
4196
|
+
// '[role]' plus every tag the maps can resolve (input/select are handled by
|
|
4197
|
+
// special-cased logic in the probe, so add them explicitly).
|
|
4198
|
+
roleSources: [.../* @__PURE__ */ new Set(["[role]", "input", "select", ...Object.keys(TAG_TO_ROLE)])].join(",")
|
|
4199
|
+
};
|
|
4200
|
+
async function ariaSnapshotBestEffort(target, timeout) {
|
|
4201
|
+
if (typeof target.ariaSnapshot !== "function") return null;
|
|
4202
|
+
try {
|
|
4203
|
+
return await target.ariaSnapshot({
|
|
4204
|
+
...timeout != null ? { timeout } : {},
|
|
4205
|
+
mode: "ai"
|
|
4206
|
+
});
|
|
4207
|
+
} catch {
|
|
4208
|
+
try {
|
|
4209
|
+
return await target.ariaSnapshot({
|
|
4210
|
+
...timeout != null ? { timeout } : {},
|
|
4211
|
+
ref: true
|
|
4212
|
+
});
|
|
4213
|
+
} catch {
|
|
4214
|
+
return null;
|
|
4215
|
+
}
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4218
|
+
function probeElementAttrs(el, arg) {
|
|
4219
|
+
const { keep, tagRoles, inputRoles, roleSources } = arg;
|
|
4220
|
+
const attrMap = {};
|
|
4221
|
+
for (const key of keep) {
|
|
4222
|
+
const v = el.getAttribute(key) ?? el[key];
|
|
4223
|
+
attrMap[key] = typeof v === "string" ? v.slice(0, 200) : v ? String(v).slice(0, 200) : null;
|
|
4224
|
+
}
|
|
4225
|
+
const r = el.getBoundingClientRect();
|
|
4226
|
+
const selectorCounts = {};
|
|
4227
|
+
try {
|
|
4228
|
+
const doc = el.ownerDocument;
|
|
4229
|
+
const cssEsc = (s) => doc.defaultView.CSS.escape(s);
|
|
4230
|
+
const count = (sel) => {
|
|
4231
|
+
try {
|
|
4232
|
+
return doc.querySelectorAll(sel).length;
|
|
4233
|
+
} catch {
|
|
4234
|
+
return void 0;
|
|
4235
|
+
}
|
|
4236
|
+
};
|
|
4237
|
+
if (attrMap["data-testid"]) {
|
|
4238
|
+
selectorCounts.testId = count(`[data-testid=${JSON.stringify(attrMap["data-testid"])}]`);
|
|
4239
|
+
}
|
|
4240
|
+
if (attrMap["id"]) selectorCounts.id = count(`#${cssEsc(attrMap["id"])}`);
|
|
4241
|
+
if (attrMap["name"]) selectorCounts.name = count(`[name=${JSON.stringify(attrMap["name"])}]`);
|
|
4242
|
+
const classList = (attrMap["class"] || "").split(/\s+/).filter((c) => c.length > 1).slice(0, 10);
|
|
4243
|
+
if (classList.length > 0) {
|
|
4244
|
+
const classCounts = {};
|
|
4245
|
+
for (const cls of classList) {
|
|
4246
|
+
const n = count(`.${cssEsc(cls)}`);
|
|
4247
|
+
if (n !== void 0) classCounts[cls] = n;
|
|
4248
|
+
}
|
|
4249
|
+
selectorCounts.classes = classCounts;
|
|
4250
|
+
}
|
|
4251
|
+
} catch {
|
|
4252
|
+
}
|
|
4253
|
+
let rolePosition = null;
|
|
4254
|
+
const ancestors = [];
|
|
4255
|
+
try {
|
|
4256
|
+
const doc = el.ownerDocument;
|
|
4257
|
+
const cssEsc = (s) => doc.defaultView.CSS.escape(s);
|
|
4258
|
+
const count = (sel) => {
|
|
4259
|
+
try {
|
|
4260
|
+
return doc.querySelectorAll(sel).length;
|
|
4261
|
+
} catch {
|
|
4262
|
+
return void 0;
|
|
4263
|
+
}
|
|
4264
|
+
};
|
|
4265
|
+
const roleOf = (n) => {
|
|
4266
|
+
const explicit = n.getAttribute("role");
|
|
4267
|
+
if (explicit) return explicit;
|
|
4268
|
+
const tag = (n.tagName || "").toLowerCase();
|
|
4269
|
+
if (tag === "input") return inputRoles[(n.getAttribute("type") || "text").toLowerCase()] ?? "textbox";
|
|
4270
|
+
if (tag === "select") return n.getAttribute("multiple") != null ? "listbox" : "combobox";
|
|
4271
|
+
if (tag === "a") return n.getAttribute("href") != null ? "link" : null;
|
|
4272
|
+
return tagRoles[tag] ?? null;
|
|
4273
|
+
};
|
|
4274
|
+
const levelOf = (n) => {
|
|
4275
|
+
const m = /^h([1-6])$/.exec((n.tagName || "").toLowerCase());
|
|
4276
|
+
if (m) return Number(m[1]);
|
|
4277
|
+
const al = n.getAttribute("aria-level");
|
|
4278
|
+
return al && /^\d+$/.test(al) ? Number(al) : null;
|
|
4279
|
+
};
|
|
4280
|
+
const targetRole = roleOf(el);
|
|
4281
|
+
const targetLevel = targetRole === "heading" ? levelOf(el) : null;
|
|
4282
|
+
if (targetRole) {
|
|
4283
|
+
const nodes = doc.querySelectorAll(roleSources);
|
|
4284
|
+
if (nodes.length <= 4e3) {
|
|
4285
|
+
let roleCountAll = 0;
|
|
4286
|
+
let index = -1;
|
|
4287
|
+
let levelCount = 0;
|
|
4288
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
4289
|
+
const n = nodes[i];
|
|
4290
|
+
if (roleOf(n) !== targetRole) continue;
|
|
4291
|
+
if (n === el) index = roleCountAll;
|
|
4292
|
+
roleCountAll++;
|
|
4293
|
+
if (targetLevel != null && levelOf(n) === targetLevel) levelCount++;
|
|
4294
|
+
}
|
|
4295
|
+
if (index !== -1) {
|
|
4296
|
+
rolePosition = {
|
|
4297
|
+
role: targetRole,
|
|
4298
|
+
count: roleCountAll,
|
|
4299
|
+
index,
|
|
4300
|
+
...targetLevel != null ? { levelCount } : {}
|
|
4301
|
+
};
|
|
4302
|
+
}
|
|
4303
|
+
const CONTAINER_TAGS = ["form", "nav", "main", "article", "section", "dialog", "table"];
|
|
4304
|
+
const docRoleCount = (role) => {
|
|
4305
|
+
let c = 0;
|
|
4306
|
+
for (let i = 0; i < nodes.length; i++) if (roleOf(nodes[i]) === role) c++;
|
|
4307
|
+
return c;
|
|
4308
|
+
};
|
|
4309
|
+
let node = el.parentElement;
|
|
4310
|
+
let depth = 0;
|
|
4311
|
+
while (node && depth < 12 && ancestors.length < 4) {
|
|
4312
|
+
depth++;
|
|
4313
|
+
const tag = (node.tagName || "").toLowerCase();
|
|
4314
|
+
if (tag === "body" || tag === "html") break;
|
|
4315
|
+
const testId = node.getAttribute("data-testid");
|
|
4316
|
+
const id = node.getAttribute("id");
|
|
4317
|
+
const explicitRole = node.getAttribute("role");
|
|
4318
|
+
const ariaLabel = node.getAttribute("aria-label");
|
|
4319
|
+
const anchorRole = explicitRole || (CONTAINER_TAGS.includes(tag) ? tagRoles[tag] : null) || null;
|
|
4320
|
+
if (testId || id || anchorRole || ariaLabel) {
|
|
4321
|
+
const scoped = node.querySelectorAll(roleSources);
|
|
4322
|
+
let scopedRoleCount = 0;
|
|
4323
|
+
if (scoped.length <= 2e3) {
|
|
4324
|
+
for (let i = 0; i < scoped.length; i++) {
|
|
4325
|
+
const n = scoped[i];
|
|
4326
|
+
if (roleOf(n) !== targetRole) continue;
|
|
4327
|
+
if (targetLevel != null && levelOf(n) !== targetLevel) continue;
|
|
4328
|
+
scopedRoleCount++;
|
|
4329
|
+
}
|
|
4330
|
+
} else {
|
|
4331
|
+
scopedRoleCount = -1;
|
|
4332
|
+
}
|
|
4333
|
+
ancestors.push({
|
|
4334
|
+
tag,
|
|
4335
|
+
depth,
|
|
4336
|
+
testId: testId || null,
|
|
4337
|
+
id: id || null,
|
|
4338
|
+
role: explicitRole || null,
|
|
4339
|
+
ariaLabel: ariaLabel || null,
|
|
4340
|
+
...scopedRoleCount >= 0 ? { scopedRoleCount } : {},
|
|
4341
|
+
...testId ? { testIdCount: count(`[data-testid=${JSON.stringify(testId)}]`) } : {},
|
|
4342
|
+
...id ? { idCount: count(`#${cssEsc(id)}`) } : {},
|
|
4343
|
+
...anchorRole ? { roleCount: docRoleCount(anchorRole) } : {}
|
|
4344
|
+
});
|
|
4345
|
+
}
|
|
4346
|
+
node = node.parentElement;
|
|
4347
|
+
}
|
|
4348
|
+
}
|
|
4349
|
+
}
|
|
4350
|
+
} catch {
|
|
4351
|
+
}
|
|
4352
|
+
return {
|
|
4353
|
+
tagName: el.tagName?.toLowerCase?.() ?? "unknown",
|
|
4354
|
+
attributes: attrMap,
|
|
4355
|
+
// Collapse whitespace so multi-line text can't produce a getByText
|
|
4356
|
+
// suggestion with literal newlines in it.
|
|
4357
|
+
textContent: (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80),
|
|
4358
|
+
center: {
|
|
4359
|
+
x: Math.round(r.x + r.width / 2),
|
|
4360
|
+
y: Math.round(r.y + r.height / 2)
|
|
4361
|
+
},
|
|
4362
|
+
hasLabel: !!(el.labels && el.labels.length > 0),
|
|
4363
|
+
selectorCounts,
|
|
4364
|
+
rolePosition,
|
|
4365
|
+
ancestors
|
|
4366
|
+
};
|
|
4367
|
+
}
|
|
4368
|
+
function wrapLocator(page, locator, originMethod, originArgs) {
|
|
4369
|
+
return new Proxy(locator, {
|
|
4370
|
+
get(target, prop) {
|
|
4371
|
+
const original = Reflect.get(target, prop);
|
|
4372
|
+
if (typeof original !== "function") return original;
|
|
4373
|
+
const fn = original;
|
|
4374
|
+
if (CHAIN_METHOD_SET.has(prop)) {
|
|
4375
|
+
return (...args) => {
|
|
4376
|
+
const next = fn.apply(target, args);
|
|
4377
|
+
if (LOCATOR_CREATING_CHAINS.has(prop)) {
|
|
4378
|
+
return wrapLocator(page, next, String(prop), args);
|
|
4379
|
+
}
|
|
4380
|
+
return wrapLocator(page, next, originMethod, originArgs);
|
|
4381
|
+
};
|
|
4382
|
+
}
|
|
4383
|
+
if (!ACTION_METHOD_SET.has(prop)) return original;
|
|
4384
|
+
return async (...callArgs) => {
|
|
4385
|
+
const sink = currentSink;
|
|
4386
|
+
if (!sink) return fn.apply(target, callArgs);
|
|
4387
|
+
sink.lastActivePage = page;
|
|
4388
|
+
const seq = sink.capturedLocators.length;
|
|
4389
|
+
const callerLocation = captureCallerLocation();
|
|
4390
|
+
const used = {
|
|
4391
|
+
method: originMethod,
|
|
4392
|
+
args: originArgs,
|
|
4393
|
+
raw: `${originMethod}(${JSON.stringify(originArgs)})`
|
|
4394
|
+
};
|
|
4395
|
+
sink.capturedLocators.push({
|
|
4396
|
+
location: callerLocation,
|
|
4397
|
+
used,
|
|
4398
|
+
element: null,
|
|
4399
|
+
alternatives: []
|
|
4400
|
+
});
|
|
4401
|
+
let result;
|
|
4402
|
+
try {
|
|
4403
|
+
result = await fn.apply(target, callArgs);
|
|
4404
|
+
} catch (error) {
|
|
4405
|
+
sink.failedLocators.push({ method: originMethod, args: originArgs, location: callerLocation });
|
|
4406
|
+
throw error;
|
|
4407
|
+
}
|
|
4408
|
+
const probe = target.evaluate(probeElementAttrs, CAPTURED_ATTRS_ARG);
|
|
4409
|
+
const settledProbe = probe.then(
|
|
4410
|
+
() => void 0,
|
|
4411
|
+
() => void 0
|
|
4412
|
+
);
|
|
4413
|
+
PENDING_PROBES.add(settledProbe);
|
|
4414
|
+
settledProbe.then(() => PENDING_PROBES.delete(settledProbe));
|
|
4415
|
+
sink.capturePromises.push(settledProbe);
|
|
4416
|
+
const resolveAttrs = (async () => {
|
|
4417
|
+
let deadline;
|
|
4418
|
+
try {
|
|
4419
|
+
const attrs = await Promise.race([
|
|
4420
|
+
probe,
|
|
4421
|
+
new Promise((_, reject) => {
|
|
4422
|
+
deadline = setTimeout(() => reject(new Error("locator capture timeout")), 500);
|
|
4423
|
+
})
|
|
4424
|
+
]);
|
|
4425
|
+
const role = resolveAriaRole({ ...attrs, accessibleName: null });
|
|
4426
|
+
const isFormField = FORM_FIELD_TAGS.has(attrs.tagName);
|
|
4427
|
+
const aria = role || isFormField ? await ariaSnapshotBestEffort(target, 500) : null;
|
|
4428
|
+
const accessibleName = extractAccessibleName(aria) || approximateAccessibleName({ ...attrs, accessibleName: null });
|
|
4429
|
+
sink.capturedLocators[seq] = {
|
|
4430
|
+
location: callerLocation,
|
|
4431
|
+
used,
|
|
4432
|
+
// hasLabel/selectorCounts inform alternative generation only —
|
|
4433
|
+
// keep the stored element to the wire shape. rolePosition and
|
|
4434
|
+
// ancestors ARE wire fields: the server's renamed-element match
|
|
4435
|
+
// uses them at heal time.
|
|
4436
|
+
element: {
|
|
4437
|
+
tagName: attrs.tagName,
|
|
4438
|
+
attributes: attrs.attributes,
|
|
4439
|
+
textContent: attrs.textContent,
|
|
4440
|
+
accessibleName,
|
|
4441
|
+
center: attrs.center,
|
|
4442
|
+
...attrs.rolePosition ? { rolePosition: attrs.rolePosition } : {},
|
|
4443
|
+
...attrs.ancestors && attrs.ancestors.length > 0 ? { ancestors: attrs.ancestors } : {}
|
|
4444
|
+
},
|
|
4445
|
+
alternatives: generateAlternatives({ ...attrs, accessibleName })
|
|
4446
|
+
};
|
|
4447
|
+
} catch {
|
|
4448
|
+
} finally {
|
|
4449
|
+
clearTimeout(deadline);
|
|
4450
|
+
}
|
|
4451
|
+
})();
|
|
4452
|
+
sink.capturePromises.push(resolveAttrs);
|
|
4453
|
+
return result;
|
|
4454
|
+
};
|
|
4455
|
+
}
|
|
4456
|
+
});
|
|
4457
|
+
}
|
|
4458
|
+
function instrumentPage(page) {
|
|
4459
|
+
if (!page || INSTRUMENTED_PAGES.has(page)) return;
|
|
4460
|
+
INSTRUMENTED_PAGES.add(page);
|
|
4461
|
+
const ctx = pageContext(page);
|
|
4462
|
+
if (ctx) instrumentContext(ctx);
|
|
4463
|
+
if (typeof page.close === "function") {
|
|
4464
|
+
const originalClose = page.close.bind(page);
|
|
4465
|
+
page.close = async (...args) => {
|
|
4466
|
+
await drainPendingProbes(1e3);
|
|
4467
|
+
const sink = currentSink;
|
|
4468
|
+
if (sink) {
|
|
4469
|
+
await stashPageState(sink, { page });
|
|
4470
|
+
await maybeOpenPicker(sink, { page });
|
|
4471
|
+
}
|
|
4472
|
+
return originalClose(...args);
|
|
4473
|
+
};
|
|
4474
|
+
}
|
|
4475
|
+
const captureLocators = process.env.PIWI_CAPTURE_LOCATORS !== "false";
|
|
4476
|
+
if (captureLocators) {
|
|
4477
|
+
const factories = page;
|
|
4478
|
+
for (const method of LOCATOR_METHODS) {
|
|
4479
|
+
const original = factories[method].bind(page);
|
|
4480
|
+
factories[method] = (...args) => {
|
|
4481
|
+
if (currentSink) currentSink.lastActivePage = page;
|
|
4482
|
+
return wrapLocator(page, original(...args), method, args);
|
|
4483
|
+
};
|
|
4484
|
+
}
|
|
4485
|
+
}
|
|
4486
|
+
page.on("console", (msg) => {
|
|
4487
|
+
const sink = currentSink;
|
|
4488
|
+
if (!sink) return;
|
|
4489
|
+
const type = msg.type();
|
|
4490
|
+
if (["warning", "error", "assert"].includes(type)) {
|
|
4491
|
+
const location = msg.location();
|
|
4492
|
+
sink.consoleEntries.push({
|
|
4493
|
+
type,
|
|
4494
|
+
text: msg.text(),
|
|
4495
|
+
timestamp: Date.now(),
|
|
4496
|
+
location: location ? `${location.url}:${location.lineNumber}:${location.columnNumber}` : null
|
|
4497
|
+
});
|
|
4498
|
+
}
|
|
4499
|
+
});
|
|
4500
|
+
page.on("requestfinished", (request) => {
|
|
4501
|
+
const sink = currentSink;
|
|
4502
|
+
if (!sink) return;
|
|
4503
|
+
sink.lastActivePage = page;
|
|
4504
|
+
const p = (async () => {
|
|
4505
|
+
try {
|
|
4506
|
+
const url = request.url();
|
|
4507
|
+
if (url.startsWith("data:") || url.startsWith("blob:")) return;
|
|
4508
|
+
const timing = request.timing();
|
|
4509
|
+
const response = await request.response();
|
|
4510
|
+
const resourceType = request.resourceType();
|
|
4511
|
+
if (!["fetch", "xhr", "document", "other"].includes(resourceType)) return;
|
|
4512
|
+
const entry = {
|
|
4513
|
+
method: request.method(),
|
|
4514
|
+
url,
|
|
4515
|
+
status: response ? response.status() : 0,
|
|
4516
|
+
duration: timing.responseEnd > 0 ? Math.round(timing.responseEnd - timing.requestStart) : 0,
|
|
4517
|
+
startTime: timing.startTime,
|
|
4518
|
+
resourceType
|
|
4519
|
+
};
|
|
4520
|
+
if (response) {
|
|
4521
|
+
const headers = response.headers();
|
|
4522
|
+
const contentType = headers["content-type"];
|
|
4523
|
+
if (contentType) entry.contentType = contentType.split(";")[0].trim();
|
|
4524
|
+
const logHeader = headers["x-piwi-logs"];
|
|
4525
|
+
if (logHeader) {
|
|
4526
|
+
try {
|
|
4527
|
+
entry.serverLogs = JSON.parse((0, import_node_zlib.gunzipSync)(Buffer.from(logHeader, "base64")).toString("utf-8"));
|
|
4528
|
+
} catch {
|
|
4529
|
+
}
|
|
4530
|
+
}
|
|
4531
|
+
}
|
|
4532
|
+
sink.networkRequests.push(entry);
|
|
4533
|
+
} catch {
|
|
4534
|
+
}
|
|
4535
|
+
})();
|
|
4536
|
+
sink.pendingHandlers.push(p);
|
|
4537
|
+
});
|
|
4538
|
+
}
|
|
4539
|
+
function instrumentContext(context) {
|
|
4540
|
+
if (!context || INSTRUMENTED_CONTEXTS.has(context)) return;
|
|
4541
|
+
INSTRUMENTED_CONTEXTS.add(context);
|
|
4542
|
+
const originalNewPage = context.newPage.bind(context);
|
|
4543
|
+
context.newPage = async (...args) => {
|
|
4544
|
+
const page = await originalNewPage(...args);
|
|
4545
|
+
instrumentPage(page);
|
|
4546
|
+
return page;
|
|
4547
|
+
};
|
|
4548
|
+
if (typeof context.close === "function") {
|
|
4549
|
+
const originalClose = context.close.bind(context);
|
|
4550
|
+
context.close = async (...args) => {
|
|
4551
|
+
await drainPendingProbes(1e3);
|
|
4552
|
+
const sink = currentSink;
|
|
4553
|
+
if (sink) {
|
|
4554
|
+
await stashPageState(sink, { context });
|
|
4555
|
+
await maybeOpenPicker(sink, { context });
|
|
4556
|
+
}
|
|
4557
|
+
return originalClose(...args);
|
|
4558
|
+
};
|
|
4559
|
+
}
|
|
4560
|
+
context.on("page", (page) => instrumentPage(page));
|
|
4561
|
+
}
|
|
4562
|
+
function patchBrowser(browser) {
|
|
4563
|
+
if (!browser || PATCHED_BROWSERS.has(browser)) return;
|
|
4564
|
+
PATCHED_BROWSERS.add(browser);
|
|
4565
|
+
const originalNewPage = browser.newPage.bind(browser);
|
|
4566
|
+
browser.newPage = async (...args) => {
|
|
4567
|
+
const page = await originalNewPage(...args);
|
|
4568
|
+
instrumentPage(page);
|
|
4569
|
+
return page;
|
|
4570
|
+
};
|
|
4571
|
+
const originalNewContext = browser.newContext.bind(browser);
|
|
4572
|
+
browser.newContext = async (...args) => {
|
|
4573
|
+
const context = await originalNewContext(...args);
|
|
4574
|
+
instrumentContext(context);
|
|
4575
|
+
return context;
|
|
4576
|
+
};
|
|
4577
|
+
if (typeof browser.close === "function") {
|
|
4578
|
+
const originalClose = browser.close.bind(browser);
|
|
4579
|
+
browser.close = async (...args) => {
|
|
4580
|
+
await drainPendingProbes(1e3);
|
|
4581
|
+
return originalClose(...args);
|
|
4582
|
+
};
|
|
4583
|
+
}
|
|
4584
|
+
}
|
|
4585
|
+
async function flushSink(sink, testInfo) {
|
|
4586
|
+
await Promise.allSettled(sink.pendingHandlers);
|
|
4587
|
+
let drainDeadline;
|
|
4588
|
+
await Promise.race([
|
|
4589
|
+
Promise.allSettled(sink.capturePromises),
|
|
4590
|
+
new Promise((resolve5) => {
|
|
4591
|
+
drainDeadline = setTimeout(resolve5, 2e3);
|
|
4592
|
+
})
|
|
4593
|
+
]);
|
|
4594
|
+
clearTimeout(drainDeadline);
|
|
4595
|
+
const page = sink.lastActivePage;
|
|
4596
|
+
const pageReadable = page !== null && !isPageClosed(page);
|
|
4597
|
+
if (pageReadable) await maybeOpenPicker(sink);
|
|
4598
|
+
if (sink.capturedLocators.length > 0) {
|
|
4599
|
+
await testInfo.attach(ATTACHMENT_NAMES.locators, {
|
|
4600
|
+
contentType: "application/json",
|
|
4601
|
+
// Repeated call sites (loops) keep only their latest capture — the
|
|
4602
|
+
// server stores one row per location anyway, so shipping every
|
|
4603
|
+
// iteration is pure payload bloat.
|
|
4604
|
+
body: Buffer.from(JSON.stringify(dedupeSnapshotsByLocation(sink.capturedLocators)))
|
|
4605
|
+
});
|
|
4606
|
+
}
|
|
4607
|
+
if (testInfo.status !== "passed" && testInfo.status !== "skipped") {
|
|
4608
|
+
try {
|
|
4609
|
+
const snapshot = (pageReadable ? await ariaSnapshotBestEffort(page.locator(":root")) : null) ?? sink.stashedAria;
|
|
4610
|
+
if (snapshot) {
|
|
4611
|
+
await testInfo.attach(ATTACHMENT_NAMES.ariaSnapshot, {
|
|
4612
|
+
contentType: "text/plain",
|
|
4613
|
+
body: snapshot
|
|
4614
|
+
});
|
|
4615
|
+
const failed = sink.failedLocators[sink.failedLocators.length - 1];
|
|
4616
|
+
const suggestion = failed ? suggestLocatorsFromAria(failed, snapshot) : null;
|
|
4617
|
+
if (suggestion) {
|
|
4618
|
+
testInfo.annotations.push({
|
|
4619
|
+
type: LOCATOR_SUGGESTION_ANNOTATION,
|
|
4620
|
+
description: `${suggestion.failing} matched nothing on the failing page \u2014 the element may have been renamed or moved. Suggested: ${suggestion.suggestions.join(" | ")}`
|
|
4621
|
+
});
|
|
4622
|
+
await testInfo.attach(ATTACHMENT_NAMES.locatorSuggestion, {
|
|
4623
|
+
contentType: "application/json",
|
|
4624
|
+
body: Buffer.from(JSON.stringify(suggestion))
|
|
4625
|
+
});
|
|
4626
|
+
}
|
|
4627
|
+
}
|
|
4628
|
+
} catch {
|
|
4629
|
+
}
|
|
4630
|
+
}
|
|
4631
|
+
if (sink.userPick) {
|
|
4632
|
+
const pick = sink.userPick;
|
|
4633
|
+
testInfo.annotations.push({
|
|
4634
|
+
type: USER_PICK_ANNOTATION,
|
|
4635
|
+
description: pick.failing ? `Replacement locator picked on the failing page for ${pick.failing.rendered}${pick.failing.location ? ` at ${pick.failing.location}` : ""}: ${pick.picked.locator}` : `Locator picked while inspecting the failing page: ${pick.picked.locator}`
|
|
4636
|
+
});
|
|
4637
|
+
try {
|
|
4638
|
+
await testInfo.attach(ATTACHMENT_NAMES.userPick, {
|
|
4639
|
+
contentType: "application/json",
|
|
4640
|
+
body: Buffer.from(JSON.stringify(pick))
|
|
4641
|
+
});
|
|
4642
|
+
} catch {
|
|
4643
|
+
}
|
|
4644
|
+
} else if (!sink.pickOffered) {
|
|
4645
|
+
const reason = environmentalSkipReason(inspectionGateFromTestInfo(testInfo, process.env.PIWI_PICK_LOCATOR_ON_FAIL)) ?? environmentalSkipReason(inspectionGateFromTestInfo(testInfo));
|
|
4646
|
+
if (reason) console.log(`[piwi] failure-time locator tools enabled but skipped: ${reason}`);
|
|
4647
|
+
}
|
|
4648
|
+
if (sink.consoleEntries.length > 0) {
|
|
4649
|
+
await testInfo.attach(ATTACHMENT_NAMES.console, {
|
|
4650
|
+
contentType: "application/json",
|
|
4651
|
+
body: Buffer.from(JSON.stringify(sink.consoleEntries))
|
|
4652
|
+
});
|
|
4653
|
+
}
|
|
4654
|
+
if (sink.networkRequests.length > 0) {
|
|
4655
|
+
await testInfo.attach(ATTACHMENT_NAMES.network, {
|
|
4656
|
+
contentType: "application/json",
|
|
4657
|
+
body: Buffer.from(JSON.stringify(sink.networkRequests))
|
|
4658
|
+
});
|
|
4659
|
+
}
|
|
4660
|
+
const webVitals = (pageReadable ? await readWebVitals(page) : null) ?? sink.stashedWebVitals;
|
|
4661
|
+
if (webVitals) {
|
|
4662
|
+
await testInfo.attach(ATTACHMENT_NAMES.webVitals, {
|
|
4663
|
+
contentType: "application/json",
|
|
4664
|
+
body: Buffer.from(JSON.stringify(webVitals))
|
|
4665
|
+
});
|
|
4666
|
+
}
|
|
4667
|
+
if (process.env.PIWI_CAPTURE_PAGE_STATE !== "false") {
|
|
4668
|
+
const pageState = (pageReadable ? await readPageState(page) : null) ?? sink.stashedPageState;
|
|
4669
|
+
if (pageState) {
|
|
4670
|
+
await testInfo.attach(ATTACHMENT_NAMES.pageState, {
|
|
4671
|
+
contentType: "application/json",
|
|
4672
|
+
body: Buffer.from(JSON.stringify(pageState))
|
|
4673
|
+
});
|
|
4674
|
+
}
|
|
4675
|
+
}
|
|
4676
|
+
}
|
|
4677
|
+
var piwiFixtures = {
|
|
4678
|
+
// Worker-scoped: patch the shared browser so every page/context created from
|
|
4679
|
+
// it — including by user fixtures that take `browser` directly — is captured.
|
|
4680
|
+
browser: [
|
|
4681
|
+
async ({ browser }, use) => {
|
|
4682
|
+
patchBrowser(browser);
|
|
4683
|
+
await use(browser);
|
|
4684
|
+
},
|
|
4685
|
+
{ scope: "worker" }
|
|
4686
|
+
],
|
|
4687
|
+
// Safety net for the standard `page` fixture, in case its page is created
|
|
4688
|
+
// through an internal path the browser patch doesn't see. Idempotent, so it
|
|
4689
|
+
// never double-wraps a page the browser patch already instrumented.
|
|
4690
|
+
page: async ({ page }, use) => {
|
|
4691
|
+
instrumentPage(page);
|
|
4692
|
+
await use(page);
|
|
4693
|
+
},
|
|
4694
|
+
// Auto, test-scoped: open a capture sink for the running test and flush it
|
|
4695
|
+
// (attach the collected data) at teardown. Runs for every test without being
|
|
4696
|
+
// requested, so suites that never destructure `page` are still captured.
|
|
4697
|
+
piwiCapture: [
|
|
4698
|
+
async ({}, use, testInfo) => {
|
|
4699
|
+
const sink = createSink();
|
|
4700
|
+
sink.testInfo = testInfo;
|
|
4701
|
+
currentSink = sink;
|
|
4702
|
+
try {
|
|
4703
|
+
await use();
|
|
4704
|
+
} finally {
|
|
4705
|
+
currentSink = null;
|
|
4706
|
+
await flushSink(sink, testInfo);
|
|
4707
|
+
}
|
|
4708
|
+
},
|
|
4709
|
+
{ auto: true }
|
|
4710
|
+
]
|
|
4711
|
+
};
|
|
4712
|
+
function extendPiwiFixtures(test) {
|
|
4713
|
+
return test.extend(piwiFixtures);
|
|
4714
|
+
}
|
|
4715
|
+
|
|
4716
|
+
// src/index.ts
|
|
4717
|
+
var index_default = PiwiDashboardReporter;
|
|
4718
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
4719
|
+
0 && (module.exports = {
|
|
4720
|
+
PiwiDashboardReporter,
|
|
4721
|
+
createGlobalSetup,
|
|
4722
|
+
extendPiwiFixtures,
|
|
4723
|
+
piwiFixtures,
|
|
4724
|
+
wrapConfig
|
|
4725
|
+
});
|