@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.
Files changed (48) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/api/client.d.ts +62 -1
  3. package/dist/api/client.js +189 -27
  4. package/dist/api/template.d.ts +15 -2
  5. package/dist/api/template.js +31 -13
  6. package/dist/auth/capture.js +4 -3
  7. package/dist/auth/index.js +3 -18
  8. package/dist/config/defaults.js +2 -0
  9. package/dist/config/resolve.d.ts +5 -1
  10. package/dist/config/resolve.js +16 -3
  11. package/dist/config/runner.d.ts +26 -1
  12. package/dist/config/runner.js +155 -24
  13. package/dist/config/tags.d.ts +14 -2
  14. package/dist/config/tags.js +27 -7
  15. package/dist/fixtures/api-context.d.ts +7 -0
  16. package/dist/fixtures/api-context.js +7 -0
  17. package/dist/fixtures/auth.js +72 -5
  18. package/dist/fixtures/test.js +23 -6
  19. package/dist/heal/healer.d.ts +12 -1
  20. package/dist/heal/healer.js +80 -34
  21. package/dist/heal/strategies.d.ts +12 -2
  22. package/dist/heal/strategies.js +42 -0
  23. package/dist/lint/index.d.ts +1 -0
  24. package/dist/lint/index.js +44 -3
  25. package/dist/lint/steps.d.ts +13 -0
  26. package/dist/lint/steps.js +333 -0
  27. package/dist/load-module.d.ts +7 -0
  28. package/dist/load-module.js +70 -0
  29. package/dist/logger.d.ts +1 -0
  30. package/dist/logger.js +9 -3
  31. package/dist/steps/a11y.steps.js +10 -10
  32. package/dist/steps/api.steps.js +64 -27
  33. package/dist/steps/browser.steps.js +34 -33
  34. package/dist/steps/clock.steps.js +5 -5
  35. package/dist/steps/data.steps.js +61 -8
  36. package/dist/steps/db.steps.js +8 -8
  37. package/dist/steps/dom.steps.js +49 -49
  38. package/dist/steps/hybrid.steps.js +9 -5
  39. package/dist/steps/iframe.steps.js +9 -9
  40. package/dist/steps/net.steps.d.ts +12 -7
  41. package/dist/steps/net.steps.js +78 -83
  42. package/dist/steps/perf.steps.js +6 -6
  43. package/dist/steps/tabs.steps.js +6 -6
  44. package/dist/steps/ui.steps.js +94 -45
  45. package/dist/steps/webhook.steps.js +3 -3
  46. package/dist/version.d.ts +1 -1
  47. package/dist/version.js +15 -1
  48. package/package.json +6 -4
@@ -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
@@ -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: opts.body } : undefined);
111
- const res = await this.deps.request.fetch(url, {
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
- function envAuth(auth) {
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
- return undefined;
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
@@ -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: Array<Record<string, unknown> | undefined>): string;
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: Array<Record<string, unknown> | undefined>): unknown;
20
+ export declare function renderJson(template: string, ...scopes: Scopes): unknown;
21
+ export {};
9
22
  //# sourceMappingURL=template.d.ts.map
@@ -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.replace(TPL_RE, (_m, name) => {
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
  }
@@ -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
- const mod = (await import(pathToFileURL(file).href));
56
- const strategy = mod.auth ?? mod.default;
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
  }
@@ -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
- const body = new URLSearchParams({
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':
@@ -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',
@@ -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 & {
@@ -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
- const envFinal = EnvConfigSchema.parse(interpolated.env);
158
- const projectFinal = ProjectConfigSchema.parse(interpolated.project);
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([
@@ -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