@sdods/core 0.3.1 → 0.5.1
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/.tsbuildinfo +1 -1
- package/dist/api/client.d.ts +62 -1
- package/dist/api/client.js +189 -27
- package/dist/api/template.d.ts +15 -2
- package/dist/api/template.js +31 -13
- package/dist/auth/capture.js +4 -3
- package/dist/auth/index.js +3 -18
- package/dist/config/defaults.js +2 -0
- package/dist/config/resolve.d.ts +5 -1
- package/dist/config/resolve.js +16 -3
- package/dist/config/runner.d.ts +26 -1
- package/dist/config/runner.js +155 -24
- package/dist/config/tags.d.ts +14 -2
- package/dist/config/tags.js +27 -7
- package/dist/fixtures/api-context.d.ts +7 -0
- package/dist/fixtures/api-context.js +7 -0
- package/dist/fixtures/auth.js +72 -5
- package/dist/fixtures/test.js +23 -6
- package/dist/heal/healer.d.ts +12 -1
- package/dist/heal/healer.js +80 -34
- package/dist/heal/strategies.d.ts +12 -2
- package/dist/heal/strategies.js +42 -0
- package/dist/lint/index.d.ts +1 -0
- package/dist/lint/index.js +44 -3
- package/dist/lint/steps.d.ts +13 -0
- package/dist/lint/steps.js +333 -0
- package/dist/load-module.d.ts +7 -0
- package/dist/load-module.js +70 -0
- package/dist/logger.d.ts +1 -0
- package/dist/logger.js +9 -3
- package/dist/steps/a11y.steps.js +10 -10
- package/dist/steps/api.steps.js +64 -27
- package/dist/steps/browser.steps.js +34 -33
- package/dist/steps/clock.steps.js +5 -5
- package/dist/steps/data.steps.js +61 -8
- package/dist/steps/db.steps.js +8 -8
- package/dist/steps/dom.steps.js +49 -49
- package/dist/steps/hybrid.steps.js +9 -5
- package/dist/steps/iframe.steps.js +9 -9
- package/dist/steps/net.steps.d.ts +12 -7
- package/dist/steps/net.steps.js +78 -83
- package/dist/steps/perf.steps.js +6 -6
- package/dist/steps/tabs.steps.js +6 -6
- package/dist/steps/ui.steps.js +94 -45
- package/dist/steps/webhook.steps.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +15 -1
- package/package.json +6 -4
package/dist/api/client.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { APIRequestContext, TestInfo } from '@playwright/test';
|
|
1
|
+
import type { APIRequest, APIRequestContext, TestInfo } from '@playwright/test';
|
|
2
2
|
import { type ApiSnapshot } from '@sdods/contracts';
|
|
3
|
+
import type { ApiAuthConfig } from '@sdods/contracts';
|
|
3
4
|
import type { ResolvedConfig } from '../config/resolve.js';
|
|
4
5
|
import type { ApiContext } from '../fixtures/api-context.js';
|
|
5
6
|
import type { ScenarioMeta } from '../fixtures/scenario.js';
|
|
@@ -18,6 +19,11 @@ export interface ApiRequestOptions {
|
|
|
18
19
|
}
|
|
19
20
|
export interface ApiClientDeps {
|
|
20
21
|
request: APIRequestContext;
|
|
22
|
+
/**
|
|
23
|
+
* A request context with no cookie jar, used while `ctx.isolated` is set. The `api` fixture
|
|
24
|
+
* creates it lazily (see {@link isolatedRequestFactory}) and disposes it at teardown.
|
|
25
|
+
*/
|
|
26
|
+
isolatedRequest?: () => Promise<APIRequestContext>;
|
|
21
27
|
config: ResolvedConfig;
|
|
22
28
|
ctx: ApiContext;
|
|
23
29
|
testInfo?: TestInfo;
|
|
@@ -46,8 +52,63 @@ export declare class ApiClient {
|
|
|
46
52
|
patch(path: string, opts?: ApiRequestOptions): Promise<ApiSnapshot>;
|
|
47
53
|
delete(path: string, opts?: ApiRequestOptions): Promise<ApiSnapshot>;
|
|
48
54
|
resolveUrl(path: string, query?: Record<string, string | number | boolean>): string;
|
|
55
|
+
/**
|
|
56
|
+
* The request context this scenario's calls go through.
|
|
57
|
+
*
|
|
58
|
+
* The shared `request` fixture is seeded from the test's context options, which on @ui/@hybrid
|
|
59
|
+
* include the leased role's storageState — so every call also carries that session cookie, and
|
|
60
|
+
* whatever cookies earlier responses set. `ctx.isolated` swaps in a context with an empty jar.
|
|
61
|
+
*/
|
|
62
|
+
requestContext(): Promise<APIRequestContext>;
|
|
49
63
|
private buildHeaders;
|
|
50
64
|
send(method: HttpMethod, path: string, opts?: ApiRequestOptions): Promise<ApiSnapshot>;
|
|
51
65
|
private recordAndAttach;
|
|
52
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The bytes to send.
|
|
69
|
+
*
|
|
70
|
+
* A string goes out as a Buffer, never as Playwright's `data: string`: when the content-type is
|
|
71
|
+
* exactly `application/json`, Playwright runs `JSON.stringify` over any string that does not parse,
|
|
72
|
+
* so `{ this is not json` arrived as the valid JSON string `"{ this is not json"` and a server's
|
|
73
|
+
* malformed-body branch could never be reached. Objects are serialised here, once.
|
|
74
|
+
*/
|
|
75
|
+
export declare function encodeBody(body: unknown): Buffer | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Which credential a request carries. `null` at either level means "send this unauthenticated";
|
|
78
|
+
* only `undefined` falls through to the next layer, so a scenario can opt out of the environment
|
|
79
|
+
* credential without the environment having to know.
|
|
80
|
+
*/
|
|
81
|
+
export declare function resolveAuth(optsAuth: ApiContext['auth'] | undefined, ctxAuth: ApiContext['auth'], env: ApiAuthConfig): Promise<ApiContext['auth']>;
|
|
82
|
+
export declare function applyAuth(headers: Record<string, string>, auth: ApiContext['auth']): void;
|
|
83
|
+
/**
|
|
84
|
+
* The environment credential. Every type the schema accepts is either implemented or refused:
|
|
85
|
+
* returning `undefined` for an unhandled type sent every request anonymous with no warning, and
|
|
86
|
+
* an authorization scenario against an endpoint that answers anonymous reads passed while
|
|
87
|
+
* proving nothing.
|
|
88
|
+
*/
|
|
89
|
+
export declare function envAuth(auth: ApiAuthConfig): Promise<ApiContext['auth']>;
|
|
90
|
+
type ClientCredentialsAuth = Extract<ApiAuthConfig, {
|
|
91
|
+
type: 'oauth-client-credentials';
|
|
92
|
+
}>;
|
|
93
|
+
/**
|
|
94
|
+
* OAuth 2.0 client-credentials grant (RFC 6749 §4.4), cached per worker process until shortly
|
|
95
|
+
* before `expires_in`. A response without `expires_in` is cached for the life of the worker.
|
|
96
|
+
* Concurrent callers share one in-flight request.
|
|
97
|
+
*/
|
|
98
|
+
export declare function clientCredentialsToken(auth: ClientCredentialsAuth): Promise<string>;
|
|
99
|
+
/** Test/teardown hook: forget cached client-credentials tokens. */
|
|
100
|
+
export declare function clearClientCredentialsTokens(): void;
|
|
101
|
+
/**
|
|
102
|
+
* A lazily created request context with an EMPTY cookie jar, for `I use an isolated API client`.
|
|
103
|
+
*
|
|
104
|
+
* The options are explicit rather than omitted: inside the test runner Playwright fills in any
|
|
105
|
+
* context option whose key is absent from the test's `use` (storageState — the role's session —
|
|
106
|
+
* httpCredentials, extraHTTPHeaders), so an omitted storageState would quietly bring the jar back.
|
|
107
|
+
* Transport settings (baseURL, proxy, ignoreHTTPSErrors, client certificates) still apply.
|
|
108
|
+
*/
|
|
109
|
+
export declare function isolatedRequestFactory(playwrightRequest: Pick<APIRequest, 'newContext'>): {
|
|
110
|
+
get: () => Promise<APIRequestContext>;
|
|
111
|
+
dispose: () => Promise<void>;
|
|
112
|
+
};
|
|
113
|
+
export {};
|
|
53
114
|
//# sourceMappingURL=client.d.ts.map
|
package/dist/api/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
2
|
import { attachmentNames, scenarioFiles } from '@sdods/contracts';
|
|
3
|
+
import { SdodsError } from '../errors.js';
|
|
3
4
|
import { Logger, redact } from '../logger.js';
|
|
4
5
|
const REDACT_HEADERS = /^(authorization|cookie|set-cookie|x-api-key|proxy-authorization)$/i;
|
|
5
6
|
/**
|
|
@@ -43,6 +44,23 @@ export class ApiClient {
|
|
|
43
44
|
url.searchParams.set(k, String(v));
|
|
44
45
|
return url.toString();
|
|
45
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The request context this scenario's calls go through.
|
|
49
|
+
*
|
|
50
|
+
* The shared `request` fixture is seeded from the test's context options, which on @ui/@hybrid
|
|
51
|
+
* include the leased role's storageState — so every call also carries that session cookie, and
|
|
52
|
+
* whatever cookies earlier responses set. `ctx.isolated` swaps in a context with an empty jar.
|
|
53
|
+
*/
|
|
54
|
+
async requestContext() {
|
|
55
|
+
if (!this.deps.ctx.isolated)
|
|
56
|
+
return this.deps.request;
|
|
57
|
+
if (!this.deps.isolatedRequest) {
|
|
58
|
+
throw new SdodsError('NOT_SUPPORTED', 'This API client was built without an isolated request context, so it cannot honour `I use an isolated API client`.', {
|
|
59
|
+
hint: 'Use the `api` fixture from @sdods/core, or pass `isolatedRequest` when constructing ApiClient yourself.',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return this.deps.isolatedRequest();
|
|
63
|
+
}
|
|
46
64
|
buildHeaders(opts) {
|
|
47
65
|
const env = this.deps.config.env.api;
|
|
48
66
|
const headers = {
|
|
@@ -53,21 +71,6 @@ export class ApiClient {
|
|
|
53
71
|
};
|
|
54
72
|
if (opts.body !== undefined && !headers['content-type'] && !opts.form)
|
|
55
73
|
headers['content-type'] = 'application/json';
|
|
56
|
-
// `null` at either level means "send this unauthenticated". Only `undefined`
|
|
57
|
-
// falls through to the next layer, so a scenario can opt out of the
|
|
58
|
-
// environment credential without the environment having to know.
|
|
59
|
-
const ctxAuth = this.deps.ctx.auth;
|
|
60
|
-
const auth = opts.auth === null
|
|
61
|
-
? undefined
|
|
62
|
-
: (opts.auth ?? (ctxAuth === null ? undefined : (ctxAuth ?? envAuth(env.auth))));
|
|
63
|
-
if (auth) {
|
|
64
|
-
if (auth.type === 'bearer')
|
|
65
|
-
headers.authorization = `Bearer ${auth.token}`;
|
|
66
|
-
else if (auth.type === 'basic')
|
|
67
|
-
headers.authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`;
|
|
68
|
-
else if (auth.type === 'header')
|
|
69
|
-
headers[auth.name.toLowerCase()] = auth.value;
|
|
70
|
-
}
|
|
71
74
|
return headers;
|
|
72
75
|
}
|
|
73
76
|
async send(method, path, opts = {}) {
|
|
@@ -81,10 +84,11 @@ export class ApiClient {
|
|
|
81
84
|
method,
|
|
82
85
|
url,
|
|
83
86
|
headers: redactHeaders(headers),
|
|
84
|
-
body: opts.form ?? opts.body,
|
|
87
|
+
body: opts.form ?? (Buffer.isBuffer(opts.body) ? opts.body.toString('utf8') : opts.body),
|
|
85
88
|
query: opts.query
|
|
86
89
|
? Object.fromEntries(Object.entries(opts.query).map(([k, v]) => [k, String(v)]))
|
|
87
90
|
: undefined,
|
|
91
|
+
...(this.deps.ctx.isolated ? { isolated: true } : {}),
|
|
88
92
|
};
|
|
89
93
|
// HAR replay (API layer playback)
|
|
90
94
|
if (this.deps.har?.replay) {
|
|
@@ -103,21 +107,26 @@ export class ApiClient {
|
|
|
103
107
|
if (this.deps.har.strict)
|
|
104
108
|
throw new Error(`HAR strict mode: no recorded entry for ${method} ${url}`);
|
|
105
109
|
}
|
|
110
|
+
// Resolved after HAR replay (a replayed call sends nothing, so an offline replay must not need
|
|
111
|
+
// to mint a token) and before the attempt loop: a credential that cannot be obtained is a
|
|
112
|
+
// configuration failure, not a transient one to retry, and the request must not go out
|
|
113
|
+
// without it.
|
|
114
|
+
const auth = await resolveAuth(opts.auth, this.deps.ctx.auth, this.deps.config.env.api.auth);
|
|
115
|
+
applyAuth(headers, auth);
|
|
116
|
+
requestSnap.headers = redactHeaders(headers);
|
|
117
|
+
// Which credential class went out, never its value — so a report shows at a glance whether a
|
|
118
|
+
// scenario sent a bearer, a key header, or nothing.
|
|
119
|
+
requestSnap.auth = describeAuth(auth);
|
|
120
|
+
const request = await this.requestContext();
|
|
106
121
|
let lastError;
|
|
107
122
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
108
123
|
const t0 = Date.now();
|
|
109
124
|
try {
|
|
110
|
-
this.log.step(`→ ${method} ${url}`, opts.body !== undefined ? { body:
|
|
111
|
-
const res = await
|
|
125
|
+
this.log.step(`→ ${method} ${url}`, opts.body !== undefined ? { body: requestSnap.body } : undefined);
|
|
126
|
+
const res = await request.fetch(url, {
|
|
112
127
|
method,
|
|
113
128
|
headers,
|
|
114
|
-
data: opts.form
|
|
115
|
-
? undefined
|
|
116
|
-
: opts.body === undefined
|
|
117
|
-
? undefined
|
|
118
|
-
: typeof opts.body === 'string'
|
|
119
|
-
? opts.body
|
|
120
|
-
: JSON.stringify(opts.body),
|
|
129
|
+
data: opts.form ? undefined : encodeBody(opts.body),
|
|
121
130
|
form: opts.form,
|
|
122
131
|
timeout,
|
|
123
132
|
failOnStatusCode: false,
|
|
@@ -191,16 +200,169 @@ function lower(h) {
|
|
|
191
200
|
function redactHeaders(h) {
|
|
192
201
|
return Object.fromEntries(Object.entries(h).map(([k, v]) => [k, REDACT_HEADERS.test(k) ? '***' : v]));
|
|
193
202
|
}
|
|
194
|
-
|
|
203
|
+
/**
|
|
204
|
+
* The bytes to send.
|
|
205
|
+
*
|
|
206
|
+
* A string goes out as a Buffer, never as Playwright's `data: string`: when the content-type is
|
|
207
|
+
* exactly `application/json`, Playwright runs `JSON.stringify` over any string that does not parse,
|
|
208
|
+
* so `{ this is not json` arrived as the valid JSON string `"{ this is not json"` and a server's
|
|
209
|
+
* malformed-body branch could never be reached. Objects are serialised here, once.
|
|
210
|
+
*/
|
|
211
|
+
export function encodeBody(body) {
|
|
212
|
+
if (body === undefined)
|
|
213
|
+
return undefined;
|
|
214
|
+
if (Buffer.isBuffer(body))
|
|
215
|
+
return body;
|
|
216
|
+
return Buffer.from(typeof body === 'string' ? body : JSON.stringify(body), 'utf8');
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Which credential a request carries. `null` at either level means "send this unauthenticated";
|
|
220
|
+
* only `undefined` falls through to the next layer, so a scenario can opt out of the environment
|
|
221
|
+
* credential without the environment having to know.
|
|
222
|
+
*/
|
|
223
|
+
export async function resolveAuth(optsAuth, ctxAuth, env) {
|
|
224
|
+
if (optsAuth === null)
|
|
225
|
+
return undefined;
|
|
226
|
+
if (optsAuth)
|
|
227
|
+
return optsAuth;
|
|
228
|
+
if (ctxAuth === null)
|
|
229
|
+
return undefined;
|
|
230
|
+
return ctxAuth ?? envAuth(env);
|
|
231
|
+
}
|
|
232
|
+
export function applyAuth(headers, auth) {
|
|
233
|
+
if (!auth)
|
|
234
|
+
return;
|
|
235
|
+
if (auth.type === 'bearer')
|
|
236
|
+
headers.authorization = `Bearer ${auth.token}`;
|
|
237
|
+
else if (auth.type === 'basic')
|
|
238
|
+
headers.authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`;
|
|
239
|
+
else if (auth.type === 'header')
|
|
240
|
+
headers[auth.name.toLowerCase()] = auth.value;
|
|
241
|
+
}
|
|
242
|
+
function describeAuth(auth) {
|
|
243
|
+
if (!auth)
|
|
244
|
+
return 'none';
|
|
245
|
+
return auth.type === 'header' ? `header:${auth.name.toLowerCase()}` : auth.type;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* The environment credential. Every type the schema accepts is either implemented or refused:
|
|
249
|
+
* returning `undefined` for an unhandled type sent every request anonymous with no warning, and
|
|
250
|
+
* an authorization scenario against an endpoint that answers anonymous reads passed while
|
|
251
|
+
* proving nothing.
|
|
252
|
+
*/
|
|
253
|
+
export async function envAuth(auth) {
|
|
195
254
|
switch (auth.type) {
|
|
255
|
+
case 'none':
|
|
256
|
+
return undefined;
|
|
196
257
|
case 'bearer':
|
|
197
258
|
return { type: 'bearer', token: auth.token };
|
|
198
259
|
case 'basic':
|
|
199
260
|
return { type: 'basic', username: auth.username, password: auth.password };
|
|
200
261
|
case 'header':
|
|
201
262
|
return { type: 'header', name: auth.name, value: auth.value };
|
|
263
|
+
case 'oauth-client-credentials':
|
|
264
|
+
return { type: 'bearer', token: await clientCredentialsToken(auth) };
|
|
202
265
|
default:
|
|
203
|
-
|
|
266
|
+
throw new SdodsError('NOT_SUPPORTED', `env.api.auth type "${auth.type}" is not supported by the API client.`, {
|
|
267
|
+
hint: 'Use one of none, bearer, basic, header or oauth-client-credentials, or set the credential per scenario with `I authenticate with bearer token from "<VAR>"`.',
|
|
268
|
+
exitCode: 2,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** Tokens are refreshed this long before they expire, so a request never races the expiry. */
|
|
273
|
+
const TOKEN_EXPIRY_SKEW_MS = 30_000;
|
|
274
|
+
const tokenCache = new Map();
|
|
275
|
+
/**
|
|
276
|
+
* OAuth 2.0 client-credentials grant (RFC 6749 §4.4), cached per worker process until shortly
|
|
277
|
+
* before `expires_in`. A response without `expires_in` is cached for the life of the worker.
|
|
278
|
+
* Concurrent callers share one in-flight request.
|
|
279
|
+
*/
|
|
280
|
+
export async function clientCredentialsToken(auth) {
|
|
281
|
+
const key = [auth.tokenUrl, auth.clientId, auth.scope ?? '', auth.audience ?? ''].join('\n');
|
|
282
|
+
const cached = tokenCache.get(key);
|
|
283
|
+
if (cached) {
|
|
284
|
+
const hit = await cached.catch(() => undefined);
|
|
285
|
+
if (hit && hit.expiresAt > Date.now())
|
|
286
|
+
return hit.token;
|
|
287
|
+
if (tokenCache.get(key) === cached)
|
|
288
|
+
tokenCache.delete(key);
|
|
289
|
+
}
|
|
290
|
+
const pending = mintClientCredentialsToken(auth);
|
|
291
|
+
tokenCache.set(key, pending);
|
|
292
|
+
try {
|
|
293
|
+
return (await pending).token;
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
if (tokenCache.get(key) === pending)
|
|
297
|
+
tokenCache.delete(key);
|
|
298
|
+
throw e;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function mintClientCredentialsToken(auth) {
|
|
302
|
+
const body = new URLSearchParams({
|
|
303
|
+
grant_type: 'client_credentials',
|
|
304
|
+
client_id: auth.clientId,
|
|
305
|
+
client_secret: auth.clientSecret,
|
|
306
|
+
});
|
|
307
|
+
if (auth.scope)
|
|
308
|
+
body.set('scope', auth.scope);
|
|
309
|
+
if (auth.audience)
|
|
310
|
+
body.set('audience', auth.audience);
|
|
311
|
+
const hint = `Check tokenUrl, clientId and clientSecret in env.api.auth (secrets as \${VAR}).`;
|
|
312
|
+
let res;
|
|
313
|
+
try {
|
|
314
|
+
res = await fetch(auth.tokenUrl, {
|
|
315
|
+
method: 'POST',
|
|
316
|
+
headers: {
|
|
317
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
318
|
+
accept: 'application/json',
|
|
319
|
+
},
|
|
320
|
+
body,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
catch (cause) {
|
|
324
|
+
throw new SdodsError('AUTH_FAILED', `OAuth token request to ${auth.tokenUrl} failed: ${cause.message}`, { hint, cause });
|
|
325
|
+
}
|
|
326
|
+
if (!res.ok) {
|
|
327
|
+
throw new SdodsError('AUTH_FAILED', `OAuth token request to ${auth.tokenUrl} was refused: ${res.status} ${res.statusText}`, { hint });
|
|
328
|
+
}
|
|
329
|
+
const json = (await res.json().catch(() => ({})));
|
|
330
|
+
if (typeof json.access_token !== 'string' || !json.access_token) {
|
|
331
|
+
throw new SdodsError('AUTH_FAILED', `OAuth token response from ${auth.tokenUrl} has no access_token.`, { hint });
|
|
204
332
|
}
|
|
333
|
+
const expiresIn = Number(json.expires_in);
|
|
334
|
+
const expiresAt = Number.isFinite(expiresIn)
|
|
335
|
+
? Date.now() + expiresIn * 1000 - TOKEN_EXPIRY_SKEW_MS
|
|
336
|
+
: Number.POSITIVE_INFINITY;
|
|
337
|
+
return { token: json.access_token, expiresAt };
|
|
338
|
+
}
|
|
339
|
+
/** Test/teardown hook: forget cached client-credentials tokens. */
|
|
340
|
+
export function clearClientCredentialsTokens() {
|
|
341
|
+
tokenCache.clear();
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* A lazily created request context with an EMPTY cookie jar, for `I use an isolated API client`.
|
|
345
|
+
*
|
|
346
|
+
* The options are explicit rather than omitted: inside the test runner Playwright fills in any
|
|
347
|
+
* context option whose key is absent from the test's `use` (storageState — the role's session —
|
|
348
|
+
* httpCredentials, extraHTTPHeaders), so an omitted storageState would quietly bring the jar back.
|
|
349
|
+
* Transport settings (baseURL, proxy, ignoreHTTPSErrors, client certificates) still apply.
|
|
350
|
+
*/
|
|
351
|
+
export function isolatedRequestFactory(playwrightRequest) {
|
|
352
|
+
let pending;
|
|
353
|
+
return {
|
|
354
|
+
get: () => (pending ??= playwrightRequest.newContext({
|
|
355
|
+
storageState: { cookies: [], origins: [] },
|
|
356
|
+
httpCredentials: undefined,
|
|
357
|
+
extraHTTPHeaders: undefined,
|
|
358
|
+
})),
|
|
359
|
+
dispose: async () => {
|
|
360
|
+
if (!pending)
|
|
361
|
+
return;
|
|
362
|
+
const ctx = await pending.catch(() => undefined);
|
|
363
|
+
pending = undefined;
|
|
364
|
+
await ctx?.dispose();
|
|
365
|
+
},
|
|
366
|
+
};
|
|
205
367
|
}
|
|
206
368
|
//# sourceMappingURL=client.js.map
|
package/dist/api/template.d.ts
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
|
+
type Scopes = Array<Record<string, unknown> | undefined>;
|
|
1
2
|
/**
|
|
2
3
|
* Render `{{name}}` / `{{obj.path}}` placeholders. Values are looked up in order over the given
|
|
3
4
|
* scopes (first wins). `${VAR}` env references are left untouched (config already resolved them).
|
|
5
|
+
* A placeholder with no value is left as the literal `{{name}}`; use `renderStrict` wherever that
|
|
6
|
+
* literal could be matched instead of failing.
|
|
4
7
|
*/
|
|
5
|
-
export declare function render(template: string, ...scopes:
|
|
8
|
+
export declare function render(template: string, ...scopes: Scopes): string;
|
|
9
|
+
/**
|
|
10
|
+
* `render`, but a placeholder that resolves to nothing throws `no such variable: <name>` instead
|
|
11
|
+
* of surviving as the literal `{{name}}`. Every built-in step renders its string arguments through
|
|
12
|
+
* this: a literal placeholder can never appear in a URL, a title or a response, so a positive
|
|
13
|
+
* assertion on it fails for the wrong reason and a negative one ("should not contain") passes
|
|
14
|
+
* without testing anything. Only the template's own placeholders are checked, so a variable whose
|
|
15
|
+
* value happens to contain `{{…}}` is not mistaken for a miss.
|
|
16
|
+
*/
|
|
17
|
+
export declare function renderStrict(template: string, ...scopes: Scopes): string;
|
|
6
18
|
export declare function hasTemplate(s: string): boolean;
|
|
7
19
|
/** Render a JSON doc string, keeping types for whole-value placeholders (`"id": {{postId}}`). */
|
|
8
|
-
export declare function renderJson(template: string, ...scopes:
|
|
20
|
+
export declare function renderJson(template: string, ...scopes: Scopes): unknown;
|
|
21
|
+
export {};
|
|
9
22
|
//# sourceMappingURL=template.d.ts.map
|
package/dist/api/template.js
CHANGED
|
@@ -1,9 +1,38 @@
|
|
|
1
|
+
import { SdodsError } from '../errors.js';
|
|
1
2
|
const TPL_RE = /\{\{\s*([A-Za-z_][\w.]*)\s*\}\}/g;
|
|
2
3
|
/**
|
|
3
4
|
* Render `{{name}}` / `{{obj.path}}` placeholders. Values are looked up in order over the given
|
|
4
5
|
* scopes (first wins). `${VAR}` env references are left untouched (config already resolved them).
|
|
6
|
+
* A placeholder with no value is left as the literal `{{name}}`; use `renderStrict` wherever that
|
|
7
|
+
* literal could be matched instead of failing.
|
|
5
8
|
*/
|
|
6
9
|
export function render(template, ...scopes) {
|
|
10
|
+
return substitute(template, scopes, []);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* `render`, but a placeholder that resolves to nothing throws `no such variable: <name>` instead
|
|
14
|
+
* of surviving as the literal `{{name}}`. Every built-in step renders its string arguments through
|
|
15
|
+
* this: a literal placeholder can never appear in a URL, a title or a response, so a positive
|
|
16
|
+
* assertion on it fails for the wrong reason and a negative one ("should not contain") passes
|
|
17
|
+
* without testing anything. Only the template's own placeholders are checked, so a variable whose
|
|
18
|
+
* value happens to contain `{{…}}` is not mistaken for a miss.
|
|
19
|
+
*/
|
|
20
|
+
export function renderStrict(template, ...scopes) {
|
|
21
|
+
const missing = [];
|
|
22
|
+
const out = substitute(template, scopes, missing);
|
|
23
|
+
if (missing.length > 0) {
|
|
24
|
+
const names = [...new Set(missing)];
|
|
25
|
+
throw new SdodsError('CONFIG_UNRESOLVED_VAR', `no such variable: ${names.join(', ')} (in "${template}").`, {
|
|
26
|
+
hint: 'No scenario variable or env var of that name exists yet. Save it first (for example with "I save the response JSON path ... as ..."), or fix the spelling. The step refuses to match the literal placeholder, which would fail for the wrong reason or, in a negative assertion, pass without testing anything.',
|
|
27
|
+
details: { missing: names, template },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
export function hasTemplate(s) {
|
|
33
|
+
return TPL_RE.test(s) && ((TPL_RE.lastIndex = 0), true);
|
|
34
|
+
}
|
|
35
|
+
function substitute(template, scopes, missing) {
|
|
7
36
|
return template.replace(TPL_RE, (_m, name) => {
|
|
8
37
|
for (const scope of scopes) {
|
|
9
38
|
if (!scope)
|
|
@@ -12,12 +41,10 @@ export function render(template, ...scopes) {
|
|
|
12
41
|
if (v !== undefined)
|
|
13
42
|
return typeof v === 'string' ? v : JSON.stringify(v);
|
|
14
43
|
}
|
|
44
|
+
missing.push(name);
|
|
15
45
|
return `{{${name}}}`;
|
|
16
46
|
});
|
|
17
47
|
}
|
|
18
|
-
export function hasTemplate(s) {
|
|
19
|
-
return TPL_RE.test(s) && ((TPL_RE.lastIndex = 0), true);
|
|
20
|
-
}
|
|
21
48
|
function lookup(scope, name) {
|
|
22
49
|
if (name in scope)
|
|
23
50
|
return scope[name];
|
|
@@ -27,16 +54,7 @@ function lookup(scope, name) {
|
|
|
27
54
|
}
|
|
28
55
|
/** Render a JSON doc string, keeping types for whole-value placeholders (`"id": {{postId}}`). */
|
|
29
56
|
export function renderJson(template, ...scopes) {
|
|
30
|
-
const text = template
|
|
31
|
-
for (const scope of scopes) {
|
|
32
|
-
if (!scope)
|
|
33
|
-
continue;
|
|
34
|
-
const v = lookup(scope, name);
|
|
35
|
-
if (v !== undefined)
|
|
36
|
-
return typeof v === 'string' ? v : JSON.stringify(v);
|
|
37
|
-
}
|
|
38
|
-
return `{{${name}}}`;
|
|
39
|
-
});
|
|
57
|
+
const text = substitute(template, scopes, []);
|
|
40
58
|
try {
|
|
41
59
|
return JSON.parse(text);
|
|
42
60
|
}
|
package/dist/auth/capture.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { pathToFileURL } from 'node:url';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import { execa } from 'execa';
|
|
5
4
|
import { SdodsError } from '../errors.js';
|
|
5
|
+
import { importProjectModule, namedExport } from '../load-module.js';
|
|
6
6
|
import { Logger } from '../logger.js';
|
|
7
7
|
import { CompositeDataProvider } from '../data/provider.js';
|
|
8
8
|
import { AuthStateCache } from '../fixtures/auth.js';
|
|
@@ -52,8 +52,9 @@ export async function loadProjectAuth(config) {
|
|
|
52
52
|
for (const file of candidates) {
|
|
53
53
|
if (!existsSync(file))
|
|
54
54
|
continue;
|
|
55
|
-
|
|
56
|
-
const
|
|
55
|
+
// Same module resolution as `sdods run` (#86): `import './helper.js'` must find helper.ts.
|
|
56
|
+
const mod = await importProjectModule(file);
|
|
57
|
+
const strategy = namedExport(mod, 'auth') ?? mod.default;
|
|
57
58
|
if (strategy && typeof strategy.login === 'function')
|
|
58
59
|
return strategy;
|
|
59
60
|
}
|
package/dist/auth/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { clientCredentialsToken } from '../api/client.js';
|
|
3
4
|
import { SdodsError } from '../errors.js';
|
|
4
5
|
/**
|
|
5
6
|
* Define the project's auth strategy. `form` uses `auth.form` selectors from the project yaml
|
|
@@ -48,28 +49,12 @@ export function defineAuth(def) {
|
|
|
48
49
|
return {
|
|
49
50
|
strategy: 'oauth-client-credentials',
|
|
50
51
|
login: async () => undefined,
|
|
52
|
+
// One implementation (and one cache) shared with ApiClient's `env.api.auth`.
|
|
51
53
|
token: async ({ config }) => {
|
|
52
54
|
const auth = config.env.api.auth;
|
|
53
55
|
if (auth.type !== 'oauth-client-credentials')
|
|
54
56
|
return undefined;
|
|
55
|
-
|
|
56
|
-
grant_type: 'client_credentials',
|
|
57
|
-
client_id: auth.clientId,
|
|
58
|
-
client_secret: auth.clientSecret,
|
|
59
|
-
});
|
|
60
|
-
if (auth.scope)
|
|
61
|
-
body.set('scope', auth.scope);
|
|
62
|
-
if (auth.audience)
|
|
63
|
-
body.set('audience', auth.audience);
|
|
64
|
-
const res = await fetch(auth.tokenUrl, {
|
|
65
|
-
method: 'POST',
|
|
66
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
67
|
-
body,
|
|
68
|
-
});
|
|
69
|
-
if (!res.ok)
|
|
70
|
-
throw new Error(`OAuth token request failed: ${res.status} ${res.statusText}`);
|
|
71
|
-
const json = (await res.json());
|
|
72
|
-
return json.access_token;
|
|
57
|
+
return clientCredentialsToken(auth);
|
|
73
58
|
},
|
|
74
59
|
};
|
|
75
60
|
case 'sso':
|
package/dist/config/defaults.js
CHANGED
|
@@ -31,6 +31,8 @@ export const ENV_TO_CONFIG_PATH = {
|
|
|
31
31
|
SDODS_SHOT_POLICY: 'project.screenshots.policy.default',
|
|
32
32
|
SDODS_SHOTS_ONLY_ON_FAILURE: 'project.screenshots.onlyOnFailure',
|
|
33
33
|
SDODS_HEAL: 'project.heal.enabled',
|
|
34
|
+
SDODS_TRACE: 'project.evidence.trace',
|
|
35
|
+
SDODS_VIDEO: 'project.evidence.video',
|
|
34
36
|
SDODS_HAR_MODE: 'runtime.harMode',
|
|
35
37
|
SDODS_OFFLINE: 'runtime.offline',
|
|
36
38
|
SDODS_UPDATE_SNAPSHOTS: 'runtime.updateSnapshots',
|
package/dist/config/resolve.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ZodError } from 'zod';
|
|
2
|
-
import { type EnvConfig, type ProjectConfig } from '@sdods/contracts';
|
|
2
|
+
import { type EnvConfig, type EvidenceConfig, type ProjectConfig } from '@sdods/contracts';
|
|
3
3
|
import { SdodsConfigError } from '../errors.js';
|
|
4
4
|
import { type LayerName } from './merge.js';
|
|
5
5
|
export type HarMode = 'off' | 'update' | 'replay';
|
|
@@ -37,6 +37,10 @@ export interface CliOverrides {
|
|
|
37
37
|
shotPolicy?: string;
|
|
38
38
|
shotsOnlyOnFailure?: boolean;
|
|
39
39
|
heal?: boolean;
|
|
40
|
+
/** Playwright trace mode (`evidence.trace`), e.g. `on` to get a trace without a retry. */
|
|
41
|
+
trace?: EvidenceConfig['trace'];
|
|
42
|
+
/** Playwright video mode (`evidence.video`). */
|
|
43
|
+
video?: EvidenceConfig['video'];
|
|
40
44
|
}
|
|
41
45
|
export interface ResolvedConfig {
|
|
42
46
|
project: ProjectConfig & {
|
package/dist/config/resolve.js
CHANGED
|
@@ -93,7 +93,7 @@ export function resolveConfig(opts) {
|
|
|
93
93
|
markAll(envYaml, prov, 'envYaml', 'env');
|
|
94
94
|
// env-level overrides of project sections
|
|
95
95
|
let projectMerged = project;
|
|
96
|
-
for (const section of ['screenshots', 'heal', 'timeouts', 'perf']) {
|
|
96
|
+
for (const section of ['screenshots', 'heal', 'timeouts', 'perf', 'evidence']) {
|
|
97
97
|
const patch = envYaml[section];
|
|
98
98
|
if (patch) {
|
|
99
99
|
projectMerged = {
|
|
@@ -154,8 +154,19 @@ export function resolveConfig(opts) {
|
|
|
154
154
|
rt.offline = Boolean(rt.offline);
|
|
155
155
|
rt.updateSnapshots = Boolean(rt.updateSnapshots);
|
|
156
156
|
// final validation (env may have been overridden by process/cli)
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
let envFinal;
|
|
158
|
+
let projectFinal;
|
|
159
|
+
try {
|
|
160
|
+
envFinal = EnvConfigSchema.parse(interpolated.env);
|
|
161
|
+
projectFinal = ProjectConfigSchema.parse(interpolated.project);
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
// ${VAR} values, SDODS_* variables and CLI flags land after the file validation
|
|
165
|
+
// (SDODS_TRACE=sometimes); name the path instead of throwing a raw ZodError.
|
|
166
|
+
if (e instanceof ZodError)
|
|
167
|
+
throw zodToConfigError(e, `${opts.projectRoot} after \${VAR}, SDODS_* and CLI overrides`);
|
|
168
|
+
throw e;
|
|
169
|
+
}
|
|
159
170
|
return {
|
|
160
171
|
project: { ...projectFinal, root: resolvePath(opts.projectRoot) },
|
|
161
172
|
env: envFinal,
|
|
@@ -197,6 +208,8 @@ function applyCli(tree, cli, prov) {
|
|
|
197
208
|
set('project.screenshots.policy.default', cli.shotPolicy);
|
|
198
209
|
set('project.screenshots.onlyOnFailure', cli.shotsOnlyOnFailure);
|
|
199
210
|
set('project.heal.enabled', cli.heal);
|
|
211
|
+
set('project.evidence.trace', cli.trace);
|
|
212
|
+
set('project.evidence.video', cli.video);
|
|
200
213
|
}
|
|
201
214
|
/** Config paths that are booleans: `SDODS_X=1|true|yes|on` → true, `0|false|no|off` → false. */
|
|
202
215
|
const BOOLEAN_PATHS = new Set([
|
package/dist/config/runner.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type PlaywrightTestConfig } from '@playwright/test';
|
|
2
|
-
import { type BrowserName, type Layer } from '@sdods/contracts';
|
|
2
|
+
import { type BrowserName, type Layer, type ProcessConfig, type ProjectConfig, type SetupConfig } from '@sdods/contracts';
|
|
3
3
|
import type { ProjectRegistry } from './registry.js';
|
|
4
4
|
export interface RunnerSelection {
|
|
5
5
|
project?: string;
|
|
@@ -12,7 +12,10 @@ export interface RunnerSelection {
|
|
|
12
12
|
allure?: boolean;
|
|
13
13
|
reporters?: string[];
|
|
14
14
|
reporterMode?: 'default' | 'server' | 'quiet';
|
|
15
|
+
/** Named process (`--process`): its `fullyParallel` and `setup` override the project's. */
|
|
16
|
+
process?: string;
|
|
15
17
|
}
|
|
18
|
+
type RunnerProject = NonNullable<PlaywrightTestConfig['projects']>[number];
|
|
16
19
|
export interface SdodsUseOption {
|
|
17
20
|
project: string;
|
|
18
21
|
layer: Layer;
|
|
@@ -34,8 +37,30 @@ export declare function listGeneratedProjects(registry: ProjectRegistry, sel: Ru
|
|
|
34
37
|
* One `defineBddConfig` per project × layer; browsers reuse the generated testDir.
|
|
35
38
|
*/
|
|
36
39
|
export declare function buildRunnerConfig(registry: ProjectRegistry, sel?: RunnerSelection): PlaywrightTestConfig;
|
|
40
|
+
/**
|
|
41
|
+
* `retries.byTag` as runner projects.
|
|
42
|
+
*
|
|
43
|
+
* Retries are fixed when a test is collected: Playwright takes them from the enclosing
|
|
44
|
+
* `test.describe.configure` or the project, and offers no runtime setter; bddgen has no hook to
|
|
45
|
+
* add `@retries:N` to a scenario it did not read from the feature file. What Playwright does
|
|
46
|
+
* offer is project-level `grep`, matched against the title AND the tags. So each byTag entry
|
|
47
|
+
* becomes a sibling of the project — same name, so `--project <name>`, reports and dashboards are
|
|
48
|
+
* unchanged — that greps for the tag and carries its retries, and the base project inverts every
|
|
49
|
+
* byTag grep. Entries are ordered by retries, highest first, and each sibling also inverts the
|
|
50
|
+
* ones before it, so a scenario with two such tags runs exactly once, with the larger count.
|
|
51
|
+
*
|
|
52
|
+
* Explicit `--retries` on the CLI still overrides every project, these included. A scenario's own
|
|
53
|
+
* `@retries:N` tag (playwright-bdd) wins over all of it.
|
|
54
|
+
*/
|
|
55
|
+
export declare function withRetriesByTag(project: RunnerProject, byTag: Record<string, number>): RunnerProject[];
|
|
56
|
+
/**
|
|
57
|
+
* The setup tier a run uses: the process's `setup` (`false` turns it off), else the project's.
|
|
58
|
+
* Tags are normalised like `--tags`, so `setup: { tags: setup }` means `@setup`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function setupTierOf(project: Pick<ProjectConfig, 'setup'>, proc?: Pick<ProcessConfig, 'setup'>): SetupConfig | undefined;
|
|
37
61
|
/** @deprecated Use {@link buildRunnerConfig}. Removed in the next minor. */
|
|
38
62
|
export declare const buildPlaywrightConfig: typeof buildRunnerConfig;
|
|
39
63
|
/** @deprecated Use {@link RunnerSelection}. Removed in the next minor. */
|
|
40
64
|
export type PlaywrightSelection = RunnerSelection;
|
|
65
|
+
export {};
|
|
41
66
|
//# sourceMappingURL=runner.d.ts.map
|