@emesoft_company/playwright-core 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -0
- package/dist/api/index.cjs +248 -0
- package/dist/api/index.cjs.map +1 -0
- package/dist/api/index.d.cts +12 -0
- package/dist/api/index.d.ts +12 -0
- package/dist/api/index.js +245 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api-client-C_StjpPX.d.ts +58 -0
- package/dist/api-client-CpXo_NOU.d.cts +58 -0
- package/dist/auth/index.cjs +310 -0
- package/dist/auth/index.cjs.map +1 -0
- package/dist/auth/index.d.cts +98 -0
- package/dist/auth/index.d.ts +98 -0
- package/dist/auth/index.js +304 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/config/index.cjs +268 -0
- package/dist/config/index.cjs.map +1 -0
- package/dist/config/index.d.cts +105 -0
- package/dist/config/index.d.ts +105 -0
- package/dist/config/index.js +254 -0
- package/dist/config/index.js.map +1 -0
- package/dist/data/index.cjs +59 -0
- package/dist/data/index.cjs.map +1 -0
- package/dist/data/index.d.cts +25 -0
- package/dist/data/index.d.ts +25 -0
- package/dist/data/index.js +50 -0
- package/dist/data/index.js.map +1 -0
- package/dist/di/index.cjs +47 -0
- package/dist/di/index.cjs.map +1 -0
- package/dist/di/index.d.cts +19 -0
- package/dist/di/index.d.ts +19 -0
- package/dist/di/index.js +45 -0
- package/dist/di/index.js.map +1 -0
- package/dist/errors/index.cjs +90 -0
- package/dist/errors/index.cjs.map +1 -0
- package/dist/errors/index.d.cts +38 -0
- package/dist/errors/index.d.ts +38 -0
- package/dist/errors/index.js +83 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/expect/index.cjs +36 -0
- package/dist/expect/index.cjs.map +1 -0
- package/dist/expect/index.d.cts +30 -0
- package/dist/expect/index.d.ts +30 -0
- package/dist/expect/index.js +34 -0
- package/dist/expect/index.js.map +1 -0
- package/dist/fixtures/index.cjs +660 -0
- package/dist/fixtures/index.cjs.map +1 -0
- package/dist/fixtures/index.d.cts +68 -0
- package/dist/fixtures/index.d.ts +68 -0
- package/dist/fixtures/index.js +651 -0
- package/dist/fixtures/index.js.map +1 -0
- package/dist/index.cjs +1322 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1248 -0
- package/dist/index.js.map +1 -0
- package/dist/logging/index.cjs +32 -0
- package/dist/logging/index.cjs.map +1 -0
- package/dist/logging/index.d.cts +18 -0
- package/dist/logging/index.d.ts +18 -0
- package/dist/logging/index.js +25 -0
- package/dist/logging/index.js.map +1 -0
- package/dist/reporting/index.cjs +59 -0
- package/dist/reporting/index.cjs.map +1 -0
- package/dist/reporting/index.d.cts +63 -0
- package/dist/reporting/index.d.ts +63 -0
- package/dist/reporting/index.js +52 -0
- package/dist/reporting/index.js.map +1 -0
- package/dist/retry-XqnSC8YL.d.cts +27 -0
- package/dist/retry-XqnSC8YL.d.ts +27 -0
- package/dist/schema-y5w2hbT-.d.cts +292 -0
- package/dist/schema-y5w2hbT-.d.ts +292 -0
- package/dist/types-CbWnSYEK.d.cts +5 -0
- package/dist/types-CbWnSYEK.d.ts +5 -0
- package/dist/types-CopwwpSW.d.cts +8 -0
- package/dist/types-CopwwpSW.d.ts +8 -0
- package/dist/types-hInbkPXf.d.cts +59 -0
- package/dist/types-hInbkPXf.d.ts +59 -0
- package/dist/ui/index.cjs +366 -0
- package/dist/ui/index.cjs.map +1 -0
- package/dist/ui/index.d.cts +190 -0
- package/dist/ui/index.d.ts +190 -0
- package/dist/ui/index.js +348 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/utils/index.cjs +72 -0
- package/dist/utils/index.cjs.map +1 -0
- package/dist/utils/index.d.cts +27 -0
- package/dist/utils/index.d.ts +27 -0
- package/dist/utils/index.js +66 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +133 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
import { expect as expect$1, test as test$1 } from '@playwright/test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import pino from 'pino';
|
|
4
|
+
|
|
5
|
+
// src/fixtures/base-test.ts
|
|
6
|
+
|
|
7
|
+
// src/errors/app-error.ts
|
|
8
|
+
var AppError = class extends Error {
|
|
9
|
+
code;
|
|
10
|
+
context;
|
|
11
|
+
constructor(message, options) {
|
|
12
|
+
super(message, { cause: options.cause });
|
|
13
|
+
this.name = new.target.name;
|
|
14
|
+
this.code = options.code;
|
|
15
|
+
this.context = options.context;
|
|
16
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var ConfigValidationError = class extends AppError {
|
|
20
|
+
constructor(message, issues, context) {
|
|
21
|
+
super(message, { code: "CONFIG_VALIDATION_ERROR", context: { issues, ...context } });
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var ApiError = class extends AppError {
|
|
25
|
+
status;
|
|
26
|
+
method;
|
|
27
|
+
url;
|
|
28
|
+
constructor(message, options) {
|
|
29
|
+
super(message, options);
|
|
30
|
+
this.status = options.status;
|
|
31
|
+
this.method = options.method;
|
|
32
|
+
this.url = options.url;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var AuthError = class extends AppError {
|
|
36
|
+
constructor(message, options = {}) {
|
|
37
|
+
super(message, { code: "AUTH_ERROR", ...options });
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// src/auth/token-provider.ts
|
|
42
|
+
var NullTokenProvider = class {
|
|
43
|
+
async getToken() {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
invalidate() {
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
var JwtTokenProvider = class {
|
|
50
|
+
constructor(options) {
|
|
51
|
+
this.options = options;
|
|
52
|
+
}
|
|
53
|
+
options;
|
|
54
|
+
cached;
|
|
55
|
+
inFlight;
|
|
56
|
+
async getToken() {
|
|
57
|
+
if (this.cached && this.cached.expiresAt > Date.now()) {
|
|
58
|
+
return this.cached.token;
|
|
59
|
+
}
|
|
60
|
+
if (!this.inFlight) {
|
|
61
|
+
this.inFlight = this.fetchToken().finally(() => {
|
|
62
|
+
this.inFlight = void 0;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return this.inFlight;
|
|
66
|
+
}
|
|
67
|
+
invalidate() {
|
|
68
|
+
this.cached = void 0;
|
|
69
|
+
}
|
|
70
|
+
async fetchToken() {
|
|
71
|
+
const { tokenUrl, clientId, clientSecret, username, password, refreshSkewSeconds = 30, logger: logger2, fetchImpl = fetch } = this.options;
|
|
72
|
+
const body = new URLSearchParams();
|
|
73
|
+
if (username && password) {
|
|
74
|
+
body.set("grant_type", "password");
|
|
75
|
+
body.set("username", username);
|
|
76
|
+
body.set("password", password);
|
|
77
|
+
} else {
|
|
78
|
+
body.set("grant_type", "client_credentials");
|
|
79
|
+
}
|
|
80
|
+
if (clientId) body.set("client_id", clientId);
|
|
81
|
+
if (clientSecret) body.set("client_secret", clientSecret);
|
|
82
|
+
logger2?.debug({ tokenUrl }, "fetching auth token");
|
|
83
|
+
let response;
|
|
84
|
+
try {
|
|
85
|
+
response = await fetchImpl(tokenUrl, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
88
|
+
body
|
|
89
|
+
});
|
|
90
|
+
} catch (cause) {
|
|
91
|
+
throw new AuthError(`Failed to reach token endpoint ${tokenUrl}`, { cause });
|
|
92
|
+
}
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
const text = await response.text().catch(() => "");
|
|
95
|
+
throw new AuthError(`Token endpoint returned ${response.status}: ${text}`, {
|
|
96
|
+
context: { status: response.status, tokenUrl }
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const data = await response.json();
|
|
100
|
+
if (!data.access_token) {
|
|
101
|
+
throw new AuthError("Token endpoint response missing access_token", { context: { tokenUrl } });
|
|
102
|
+
}
|
|
103
|
+
const expiresInSeconds = data.expires_in ?? 300;
|
|
104
|
+
this.cached = {
|
|
105
|
+
token: data.access_token,
|
|
106
|
+
expiresAt: Date.now() + Math.max(expiresInSeconds - refreshSkewSeconds, 0) * 1e3
|
|
107
|
+
};
|
|
108
|
+
return this.cached.token;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// src/utils/sleep.ts
|
|
113
|
+
function sleep(ms) {
|
|
114
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/errors/retry.ts
|
|
118
|
+
function backoffDelay(attempt, minDelayMs, maxDelayMs, factor) {
|
|
119
|
+
const exponential = minDelayMs * factor ** (attempt - 1);
|
|
120
|
+
const jitter = Math.random() * minDelayMs;
|
|
121
|
+
return Math.min(exponential + jitter, maxDelayMs);
|
|
122
|
+
}
|
|
123
|
+
async function withRetry(fn, options = {}) {
|
|
124
|
+
const {
|
|
125
|
+
retries = 2,
|
|
126
|
+
minDelayMs = 200,
|
|
127
|
+
maxDelayMs = 5e3,
|
|
128
|
+
factor = 2,
|
|
129
|
+
retryOn = () => true,
|
|
130
|
+
onRetry,
|
|
131
|
+
onGiveUp
|
|
132
|
+
} = options;
|
|
133
|
+
const maxAttempts = retries + 1;
|
|
134
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
135
|
+
try {
|
|
136
|
+
return await fn();
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const isLastAttempt = attempt >= maxAttempts;
|
|
139
|
+
if (isLastAttempt || !retryOn(error)) {
|
|
140
|
+
onGiveUp?.(error, attempt);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
onRetry?.(error, attempt);
|
|
144
|
+
await sleep(backoffDelay(attempt, minDelayMs, maxDelayMs, factor));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw new Error("withRetry: unreachable state");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/api/interceptors.ts
|
|
151
|
+
function isRetryableStatus(status) {
|
|
152
|
+
return status === 429 || status >= 500 && status <= 599;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/api/api-client.ts
|
|
156
|
+
var TransientHttpError = class extends Error {
|
|
157
|
+
constructor(status) {
|
|
158
|
+
super(`Transient HTTP status ${status}`);
|
|
159
|
+
this.status = status;
|
|
160
|
+
}
|
|
161
|
+
status;
|
|
162
|
+
};
|
|
163
|
+
var ApiClient = class {
|
|
164
|
+
request;
|
|
165
|
+
baseUrl;
|
|
166
|
+
tokenProvider;
|
|
167
|
+
logger;
|
|
168
|
+
retryOptions;
|
|
169
|
+
defaultHeaders;
|
|
170
|
+
interceptors;
|
|
171
|
+
constructor(options) {
|
|
172
|
+
this.request = options.request;
|
|
173
|
+
this.baseUrl = options.baseUrl;
|
|
174
|
+
this.tokenProvider = options.tokenProvider ?? new NullTokenProvider();
|
|
175
|
+
this.logger = options.logger;
|
|
176
|
+
this.defaultHeaders = options.defaultHeaders ?? {};
|
|
177
|
+
this.interceptors = options.interceptors ?? {};
|
|
178
|
+
this.retryOptions = { ...options.retry };
|
|
179
|
+
}
|
|
180
|
+
get(path, options) {
|
|
181
|
+
return this.requestBody("GET", path, options);
|
|
182
|
+
}
|
|
183
|
+
post(path, options) {
|
|
184
|
+
return this.requestBody("POST", path, options);
|
|
185
|
+
}
|
|
186
|
+
put(path, options) {
|
|
187
|
+
return this.requestBody("PUT", path, options);
|
|
188
|
+
}
|
|
189
|
+
patch(path, options) {
|
|
190
|
+
return this.requestBody("PATCH", path, options);
|
|
191
|
+
}
|
|
192
|
+
delete(path, options) {
|
|
193
|
+
return this.requestBody("DELETE", path, options);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Full response including status and headers. Retries transient statuses, then returns
|
|
197
|
+
* whatever came back — a 404 or 500 is a normal return value here, not an exception.
|
|
198
|
+
* Network/timeout failures still throw.
|
|
199
|
+
*/
|
|
200
|
+
async send(method, path, options = {}) {
|
|
201
|
+
const url = this.resolveUrl(path, options.params);
|
|
202
|
+
let lastResponse;
|
|
203
|
+
try {
|
|
204
|
+
await withRetry(
|
|
205
|
+
async () => {
|
|
206
|
+
lastResponse = await this.fetchOnce(method, url, options);
|
|
207
|
+
if (isRetryableStatus(lastResponse.status) && !this.isExpectedStatus(lastResponse.status, options)) {
|
|
208
|
+
throw new TransientHttpError(lastResponse.status);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
...this.retryOptions,
|
|
213
|
+
retryOn: (error) => error instanceof TransientHttpError,
|
|
214
|
+
onRetry: (error, attempt) => this.logger?.warn({ method, url, attempt, err: error }, "retrying API request")
|
|
215
|
+
}
|
|
216
|
+
);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (!(error instanceof TransientHttpError)) throw error;
|
|
219
|
+
}
|
|
220
|
+
if (!lastResponse) {
|
|
221
|
+
throw new AppError(`API request produced no response: ${method} ${url}`, {
|
|
222
|
+
code: "API_NO_RESPONSE",
|
|
223
|
+
context: { method, url }
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return lastResponse;
|
|
227
|
+
}
|
|
228
|
+
async requestBody(method, path, options = {}) {
|
|
229
|
+
const response = await this.send(method, path, options);
|
|
230
|
+
if (!this.isExpectedStatus(response.status, options)) {
|
|
231
|
+
if (response.status === 401) this.tokenProvider.invalidate();
|
|
232
|
+
this.logger?.error(
|
|
233
|
+
{ status: response.status, method, url: response.url, body: response.data },
|
|
234
|
+
"API request failed"
|
|
235
|
+
);
|
|
236
|
+
throw new ApiError(
|
|
237
|
+
`API request failed: ${method} ${response.url} -> ${response.status}` + (options.expectStatus === void 0 ? "" : ` (expected ${String(options.expectStatus)})`),
|
|
238
|
+
{
|
|
239
|
+
code: "API_REQUEST_FAILED",
|
|
240
|
+
status: response.status,
|
|
241
|
+
method,
|
|
242
|
+
url: response.url,
|
|
243
|
+
context: { body: response.data }
|
|
244
|
+
}
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return response.data;
|
|
248
|
+
}
|
|
249
|
+
isExpectedStatus(status, options) {
|
|
250
|
+
if (options.expectStatus === void 0) return status >= 200 && status < 300;
|
|
251
|
+
const expected = Array.isArray(options.expectStatus) ? options.expectStatus : [options.expectStatus];
|
|
252
|
+
return expected.includes(status);
|
|
253
|
+
}
|
|
254
|
+
async fetchOnce(method, url, options) {
|
|
255
|
+
const headers = await this.buildHeaders(method, url, options);
|
|
256
|
+
const response = await this.request.fetch(url, {
|
|
257
|
+
method,
|
|
258
|
+
headers,
|
|
259
|
+
data: options.data,
|
|
260
|
+
form: options.form,
|
|
261
|
+
multipart: options.multipart,
|
|
262
|
+
timeout: options.timeoutMs
|
|
263
|
+
});
|
|
264
|
+
await this.interceptors.onResponse?.({ method, url, status: response.status() });
|
|
265
|
+
return {
|
|
266
|
+
status: response.status(),
|
|
267
|
+
ok: response.ok(),
|
|
268
|
+
headers: response.headers(),
|
|
269
|
+
data: await this.parseBody(response, options),
|
|
270
|
+
url,
|
|
271
|
+
method
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
resolveUrl(path, params) {
|
|
275
|
+
const base2 = this.baseUrl ? new URL(path, this.baseUrl).toString() : path;
|
|
276
|
+
if (!params) return base2;
|
|
277
|
+
const url = new URL(base2);
|
|
278
|
+
for (const [key, value] of Object.entries(params)) {
|
|
279
|
+
if (value !== void 0) url.searchParams.set(key, String(value));
|
|
280
|
+
}
|
|
281
|
+
return url.toString();
|
|
282
|
+
}
|
|
283
|
+
async buildHeaders(method, url, options) {
|
|
284
|
+
let headers = { ...this.defaultHeaders, ...options.headers };
|
|
285
|
+
if (!options.skipAuth) {
|
|
286
|
+
const token = await this.tokenProvider.getToken();
|
|
287
|
+
if (token) headers = { ...headers, Authorization: `Bearer ${token}` };
|
|
288
|
+
}
|
|
289
|
+
const overridden = await this.interceptors.onRequest?.({ method, url, headers });
|
|
290
|
+
return overridden ?? headers;
|
|
291
|
+
}
|
|
292
|
+
async parseBody(response, options) {
|
|
293
|
+
const raw = await this.readBody(response);
|
|
294
|
+
if (!options.schema) return raw;
|
|
295
|
+
const result = options.schema.safeParse(raw);
|
|
296
|
+
if (!result.success) {
|
|
297
|
+
throw new ApiError(`API response did not match the expected schema: ${response.url()}`, {
|
|
298
|
+
code: "API_SCHEMA_MISMATCH",
|
|
299
|
+
status: response.status(),
|
|
300
|
+
url: response.url(),
|
|
301
|
+
context: { issues: result.error.issues, body: raw }
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return result.data;
|
|
305
|
+
}
|
|
306
|
+
async readBody(response) {
|
|
307
|
+
const contentType = response.headers()["content-type"] ?? "";
|
|
308
|
+
if (contentType.includes("application/json")) {
|
|
309
|
+
const text = await response.text();
|
|
310
|
+
if (text.trim() === "") return void 0;
|
|
311
|
+
try {
|
|
312
|
+
return JSON.parse(text);
|
|
313
|
+
} catch {
|
|
314
|
+
return text;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return response.text();
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// src/utils/deep-merge.ts
|
|
322
|
+
function isPlainObject(value) {
|
|
323
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
324
|
+
}
|
|
325
|
+
function deepMerge(base2, override) {
|
|
326
|
+
if (override === void 0) return base2;
|
|
327
|
+
if (!isPlainObject(base2) || !isPlainObject(override)) {
|
|
328
|
+
return override;
|
|
329
|
+
}
|
|
330
|
+
const result = { ...base2 };
|
|
331
|
+
for (const [key, value] of Object.entries(override)) {
|
|
332
|
+
if (value === void 0) continue;
|
|
333
|
+
const baseValue = base2[key];
|
|
334
|
+
result[key] = isPlainObject(baseValue) && isPlainObject(value) ? deepMerge(baseValue, value) : value;
|
|
335
|
+
}
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
338
|
+
function pruneUndefined(value) {
|
|
339
|
+
if (Array.isArray(value)) return value.map(pruneUndefined);
|
|
340
|
+
if (isPlainObject(value)) {
|
|
341
|
+
const result = {};
|
|
342
|
+
for (const [key, val] of Object.entries(value)) {
|
|
343
|
+
if (val === void 0) continue;
|
|
344
|
+
const pruned = pruneUndefined(val);
|
|
345
|
+
if (isPlainObject(pruned) && Object.keys(pruned).length === 0) continue;
|
|
346
|
+
result[key] = pruned;
|
|
347
|
+
}
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
return value;
|
|
351
|
+
}
|
|
352
|
+
var EnvironmentSchema = z.string().min(1);
|
|
353
|
+
var TimeoutsSchema = z.object({
|
|
354
|
+
actionMs: z.number().int().positive().default(15e3),
|
|
355
|
+
navigationMs: z.number().int().positive().default(3e4),
|
|
356
|
+
testMs: z.number().int().positive().default(6e4),
|
|
357
|
+
/** Default budget for `waitForCondition` and other non-locator polling. */
|
|
358
|
+
waitMs: z.number().int().positive().default(3e4)
|
|
359
|
+
});
|
|
360
|
+
var AuthConfigSchema = z.object({
|
|
361
|
+
tokenUrl: z.string().url().optional(),
|
|
362
|
+
clientId: z.string().optional(),
|
|
363
|
+
clientSecret: z.string().optional(),
|
|
364
|
+
username: z.string().optional(),
|
|
365
|
+
password: z.string().optional(),
|
|
366
|
+
/** Single-session storage state file (see `buildGlobalSetup`). */
|
|
367
|
+
storageStatePath: z.string().optional(),
|
|
368
|
+
/** Directory used for per-role storage state files in multi-role setups. */
|
|
369
|
+
storageStateDir: z.string().default(".auth")
|
|
370
|
+
}).default({});
|
|
371
|
+
var ReportingConfigSchema = z.object({
|
|
372
|
+
allureEnabled: z.boolean().default(false),
|
|
373
|
+
resultsDir: z.string().default("allure-results"),
|
|
374
|
+
/** `{id}` is replaced with a test-case id — see `meta()` in `@emesoft_company/playwright-core/reporting`. */
|
|
375
|
+
testCaseUrlTemplate: z.string().optional()
|
|
376
|
+
}).default({});
|
|
377
|
+
var RetryConfigSchema = z.object({
|
|
378
|
+
retries: z.number().int().min(0).default(2),
|
|
379
|
+
minDelayMs: z.number().int().positive().default(200),
|
|
380
|
+
maxDelayMs: z.number().int().positive().default(5e3)
|
|
381
|
+
}).default({});
|
|
382
|
+
var DiagnosticsConfigSchema = z.object({
|
|
383
|
+
/** Collect browser console errors + failed requests and attach them to the report. */
|
|
384
|
+
enabled: z.boolean().default(true),
|
|
385
|
+
/** Fail the test if the page logged a console error. Off by default — many apps are noisy. */
|
|
386
|
+
failOnConsoleError: z.boolean().default(false),
|
|
387
|
+
/** Fail the test on an uncaught page exception. */
|
|
388
|
+
failOnPageError: z.boolean().default(false),
|
|
389
|
+
/** Console messages matching any of these substrings are ignored entirely. */
|
|
390
|
+
ignoreConsolePatterns: z.array(z.string()).default([])
|
|
391
|
+
}).default({});
|
|
392
|
+
var AppConfigSchema = z.object({
|
|
393
|
+
environment: EnvironmentSchema,
|
|
394
|
+
baseUrl: z.string().url(),
|
|
395
|
+
apiBaseUrl: z.string().url().optional(),
|
|
396
|
+
tenant: z.string().optional(),
|
|
397
|
+
timeouts: TimeoutsSchema.default({}),
|
|
398
|
+
auth: AuthConfigSchema,
|
|
399
|
+
reporting: ReportingConfigSchema,
|
|
400
|
+
retry: RetryConfigSchema,
|
|
401
|
+
diagnostics: DiagnosticsConfigSchema,
|
|
402
|
+
logLevel: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"),
|
|
403
|
+
custom: z.record(z.string(), z.unknown()).default({})
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// src/config/loader.ts
|
|
407
|
+
function readEnvConfig(env) {
|
|
408
|
+
return {
|
|
409
|
+
environment: env.TEST_ENV ?? "dev",
|
|
410
|
+
baseUrl: env.BASE_URL,
|
|
411
|
+
apiBaseUrl: env.API_BASE_URL,
|
|
412
|
+
tenant: env.TENANT,
|
|
413
|
+
logLevel: env.LOG_LEVEL,
|
|
414
|
+
timeouts: {
|
|
415
|
+
actionMs: env.ACTION_TIMEOUT_MS ? Number(env.ACTION_TIMEOUT_MS) : void 0,
|
|
416
|
+
navigationMs: env.NAVIGATION_TIMEOUT_MS ? Number(env.NAVIGATION_TIMEOUT_MS) : void 0,
|
|
417
|
+
testMs: env.TEST_TIMEOUT_MS ? Number(env.TEST_TIMEOUT_MS) : void 0,
|
|
418
|
+
waitMs: env.WAIT_TIMEOUT_MS ? Number(env.WAIT_TIMEOUT_MS) : void 0
|
|
419
|
+
},
|
|
420
|
+
auth: {
|
|
421
|
+
tokenUrl: env.AUTH_TOKEN_URL,
|
|
422
|
+
clientId: env.AUTH_CLIENT_ID,
|
|
423
|
+
clientSecret: env.AUTH_CLIENT_SECRET,
|
|
424
|
+
username: env.AUTH_USERNAME,
|
|
425
|
+
password: env.AUTH_PASSWORD,
|
|
426
|
+
storageStatePath: env.AUTH_STORAGE_STATE_PATH,
|
|
427
|
+
storageStateDir: env.AUTH_STORAGE_STATE_DIR
|
|
428
|
+
},
|
|
429
|
+
reporting: {
|
|
430
|
+
allureEnabled: env.REPORT_ALLURE === "true",
|
|
431
|
+
resultsDir: env.ALLURE_RESULTS_DIR,
|
|
432
|
+
testCaseUrlTemplate: env.TEST_CASE_URL_TEMPLATE
|
|
433
|
+
},
|
|
434
|
+
retry: {
|
|
435
|
+
retries: env.RETRY_COUNT ? Number(env.RETRY_COUNT) : void 0,
|
|
436
|
+
minDelayMs: env.RETRY_MIN_DELAY_MS ? Number(env.RETRY_MIN_DELAY_MS) : void 0,
|
|
437
|
+
maxDelayMs: env.RETRY_MAX_DELAY_MS ? Number(env.RETRY_MAX_DELAY_MS) : void 0
|
|
438
|
+
},
|
|
439
|
+
diagnostics: {
|
|
440
|
+
enabled: env.DIAGNOSTICS === void 0 ? void 0 : env.DIAGNOSTICS !== "false",
|
|
441
|
+
failOnConsoleError: env.FAIL_ON_CONSOLE_ERROR === void 0 ? void 0 : env.FAIL_ON_CONSOLE_ERROR === "true",
|
|
442
|
+
failOnPageError: env.FAIL_ON_PAGE_ERROR === void 0 ? void 0 : env.FAIL_ON_PAGE_ERROR === "true"
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
function loadConfig(options = {}) {
|
|
447
|
+
const { env = process.env, overrides } = options;
|
|
448
|
+
const fromEnv = pruneUndefined(readEnvConfig(env));
|
|
449
|
+
const merged = deepMerge(fromEnv, overrides);
|
|
450
|
+
const result = AppConfigSchema.safeParse(merged);
|
|
451
|
+
if (!result.success) {
|
|
452
|
+
throw new ConfigValidationError(
|
|
453
|
+
`Invalid application config: ${result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ")}`,
|
|
454
|
+
result.error.issues
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
return result.data;
|
|
458
|
+
}
|
|
459
|
+
function createLogger(options = {}) {
|
|
460
|
+
const {
|
|
461
|
+
level = process.env.LOG_LEVEL ?? "info",
|
|
462
|
+
name = "playwright-core",
|
|
463
|
+
pretty = process.env.LOG_PRETTY === "true"
|
|
464
|
+
} = options;
|
|
465
|
+
return pino({
|
|
466
|
+
name,
|
|
467
|
+
level,
|
|
468
|
+
...pretty ? {
|
|
469
|
+
transport: {
|
|
470
|
+
target: "pino-pretty",
|
|
471
|
+
options: { colorize: true, translateTime: "SYS:standard" }
|
|
472
|
+
}
|
|
473
|
+
} : {}
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
createLogger();
|
|
477
|
+
|
|
478
|
+
// src/reporting/meta.ts
|
|
479
|
+
function linkTestCases(testInfo, urlTemplate) {
|
|
480
|
+
if (!urlTemplate) return;
|
|
481
|
+
for (const annotation of testInfo.annotations) {
|
|
482
|
+
if (annotation.type === "testCase" && annotation.description) {
|
|
483
|
+
annotation.description = urlTemplate.replace("{id}", annotation.description);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// src/fixtures/auth.fixture.ts
|
|
489
|
+
function resolveTokenProvider(config, logger2) {
|
|
490
|
+
const { auth } = config;
|
|
491
|
+
if (auth.tokenUrl) {
|
|
492
|
+
return new JwtTokenProvider({
|
|
493
|
+
tokenUrl: auth.tokenUrl,
|
|
494
|
+
clientId: auth.clientId,
|
|
495
|
+
clientSecret: auth.clientSecret,
|
|
496
|
+
username: auth.username,
|
|
497
|
+
password: auth.password,
|
|
498
|
+
logger: logger2
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return new NullTokenProvider();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/reporting/attachments.ts
|
|
505
|
+
async function attachText(testInfo, name, body) {
|
|
506
|
+
await testInfo.attach(name, { body, contentType: "text/plain" });
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/fixtures/diagnostics.fixture.ts
|
|
510
|
+
function collectDiagnostics(page, config) {
|
|
511
|
+
const collected = { consoleErrors: [], pageErrors: [], failedRequests: [] };
|
|
512
|
+
const isIgnored = (text) => config.ignoreConsolePatterns.some((pattern) => text.includes(pattern));
|
|
513
|
+
page.on("console", (message) => {
|
|
514
|
+
if (message.type() !== "error") return;
|
|
515
|
+
const text = message.text();
|
|
516
|
+
if (!isIgnored(text)) collected.consoleErrors.push(text);
|
|
517
|
+
});
|
|
518
|
+
page.on("pageerror", (error) => {
|
|
519
|
+
collected.pageErrors.push(error.message);
|
|
520
|
+
});
|
|
521
|
+
page.on("requestfailed", (request) => {
|
|
522
|
+
collected.failedRequests.push(
|
|
523
|
+
`${request.method()} ${request.url()} \u2014 ${request.failure()?.errorText ?? "failed"}`
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
const report = async (testInfo) => {
|
|
527
|
+
if (collected.consoleErrors.length > 0) {
|
|
528
|
+
await attachText(testInfo, "console-errors", collected.consoleErrors.join("\n"));
|
|
529
|
+
}
|
|
530
|
+
if (collected.pageErrors.length > 0) {
|
|
531
|
+
await attachText(testInfo, "page-errors", collected.pageErrors.join("\n"));
|
|
532
|
+
}
|
|
533
|
+
if (collected.failedRequests.length > 0) {
|
|
534
|
+
await attachText(testInfo, "failed-requests", collected.failedRequests.join("\n"));
|
|
535
|
+
}
|
|
536
|
+
if (testInfo.status !== "passed") return;
|
|
537
|
+
if (config.failOnPageError && collected.pageErrors.length > 0) {
|
|
538
|
+
throw new Error(`Uncaught page error(s):
|
|
539
|
+
${collected.pageErrors.join("\n")}`);
|
|
540
|
+
}
|
|
541
|
+
if (config.failOnConsoleError && collected.consoleErrors.length > 0) {
|
|
542
|
+
throw new Error(`Console error(s):
|
|
543
|
+
${collected.consoleErrors.join("\n")}`);
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
return { collected, report };
|
|
547
|
+
}
|
|
548
|
+
var expect = expect$1.extend({
|
|
549
|
+
/**
|
|
550
|
+
* Asserts the status of an `ApiResponse` from `apiClient.send()`, printing the body on
|
|
551
|
+
* failure — otherwise a wrong status tells you nothing about why.
|
|
552
|
+
*/
|
|
553
|
+
toHaveStatus(received, expected) {
|
|
554
|
+
const pass = received.status === expected;
|
|
555
|
+
return {
|
|
556
|
+
pass,
|
|
557
|
+
name: "toHaveStatus",
|
|
558
|
+
expected,
|
|
559
|
+
actual: received.status,
|
|
560
|
+
message: () => pass ? `Expected status not to be ${expected}` : `Expected ${received.method} ${received.url} to return ${expected}, got ${received.status}
|
|
561
|
+
Body: ${JSON.stringify(received.data, null, 2)}`
|
|
562
|
+
};
|
|
563
|
+
},
|
|
564
|
+
/** Asserts a value matches a zod schema, listing the exact field-level issues on failure. */
|
|
565
|
+
toMatchSchema(received, schema) {
|
|
566
|
+
const result = schema.safeParse(received);
|
|
567
|
+
return {
|
|
568
|
+
pass: result.success,
|
|
569
|
+
name: "toMatchSchema",
|
|
570
|
+
message: () => result.success ? "Expected value not to match the schema" : `Value did not match the schema:
|
|
571
|
+
${result.error.issues.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n")}`
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// src/fixtures/base-test.ts
|
|
577
|
+
var test = test$1.extend({
|
|
578
|
+
workerConfig: [
|
|
579
|
+
async ({}, use) => {
|
|
580
|
+
await use(loadConfig());
|
|
581
|
+
},
|
|
582
|
+
{ scope: "worker" }
|
|
583
|
+
],
|
|
584
|
+
appConfig: async ({ workerConfig }, use) => {
|
|
585
|
+
await use(workerConfig);
|
|
586
|
+
},
|
|
587
|
+
logger: async ({ appConfig }, use, testInfo) => {
|
|
588
|
+
await use(createLogger({ level: appConfig.logLevel, name: testInfo.title }));
|
|
589
|
+
},
|
|
590
|
+
tokenProvider: async ({ appConfig, logger: logger2 }, use) => {
|
|
591
|
+
await use(resolveTokenProvider(appConfig, logger2));
|
|
592
|
+
},
|
|
593
|
+
apiClient: async ({ appConfig, tokenProvider, logger: logger2, request }, use) => {
|
|
594
|
+
await use(
|
|
595
|
+
new ApiClient({
|
|
596
|
+
request,
|
|
597
|
+
baseUrl: appConfig.apiBaseUrl ?? appConfig.baseUrl,
|
|
598
|
+
tokenProvider,
|
|
599
|
+
logger: logger2,
|
|
600
|
+
retry: {
|
|
601
|
+
retries: appConfig.retry.retries,
|
|
602
|
+
minDelayMs: appConfig.retry.minDelayMs,
|
|
603
|
+
maxDelayMs: appConfig.retry.maxDelayMs
|
|
604
|
+
}
|
|
605
|
+
})
|
|
606
|
+
);
|
|
607
|
+
},
|
|
608
|
+
retry: async ({ logger: logger2 }, use) => {
|
|
609
|
+
const boundRetry = (fn, options) => withRetry(fn, {
|
|
610
|
+
onRetry: (error, attempt) => logger2.warn({ err: error, attempt }, "retrying operation"),
|
|
611
|
+
...options
|
|
612
|
+
});
|
|
613
|
+
await use(boundRetry);
|
|
614
|
+
},
|
|
615
|
+
cleanup: async ({ logger: logger2 }, use) => {
|
|
616
|
+
const tasks = [];
|
|
617
|
+
await use((task) => {
|
|
618
|
+
tasks.push(task);
|
|
619
|
+
});
|
|
620
|
+
for (const task of tasks.reverse()) {
|
|
621
|
+
try {
|
|
622
|
+
await task();
|
|
623
|
+
} catch (error) {
|
|
624
|
+
logger2.error({ err: error }, "cleanup task failed");
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
},
|
|
628
|
+
// Overriding `page` (instead of an auto fixture) keeps API-only tests from launching a
|
|
629
|
+
// browser: the override only runs for tests that actually request a page.
|
|
630
|
+
page: async ({ page, appConfig }, use, testInfo) => {
|
|
631
|
+
if (!appConfig.diagnostics.enabled) {
|
|
632
|
+
await use(page);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const diagnostics = collectDiagnostics(page, appConfig.diagnostics);
|
|
636
|
+
await use(page);
|
|
637
|
+
await diagnostics.report(testInfo);
|
|
638
|
+
},
|
|
639
|
+
// testInfo-only, so this is safe to run automatically for every test.
|
|
640
|
+
_testCaseLinks: [
|
|
641
|
+
async ({ appConfig }, use, testInfo) => {
|
|
642
|
+
linkTestCases(testInfo, appConfig.reporting.testCaseUrlTemplate);
|
|
643
|
+
await use();
|
|
644
|
+
},
|
|
645
|
+
{ auto: true }
|
|
646
|
+
]
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
export { collectDiagnostics, expect, resolveTokenProvider, test };
|
|
650
|
+
//# sourceMappingURL=index.js.map
|
|
651
|
+
//# sourceMappingURL=index.js.map
|