@sdods/core 0.3.2 → 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 +9 -2
  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
@@ -0,0 +1,70 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ /**
3
+ * Import a TypeScript module that belongs to a project (`steps/auth.ts` and friends) from outside
4
+ * the Playwright runner.
5
+ *
6
+ * `sdods run` loads project files through Playwright's TS loader, which transpiles full
7
+ * TypeScript, remaps `import './helper.js'` to `helper.ts`, and treats a file as ESM or CommonJS
8
+ * by its package type. A bare `import()` from a CLI command hands the file to Node's native type
9
+ * stripping instead, which does none of the remapping — so a helper imported the conventional way
10
+ * worked under `sdods run` and crashed `sdods auth capture` with "Cannot find module .../helper.js"
11
+ * (#86). In the workspace the gap was invisible, because bin/sdods.js registers tsx globally.
12
+ *
13
+ * tsx gives the same three behaviours as Playwright's loader. Both its ESM and CommonJS hooks are
14
+ * registered — a project without `"type": "module"` loads `.ts` as CommonJS, and a CommonJS
15
+ * `require('./helper.js')` only goes through tsx's CJS hook — and removed again once the import
16
+ * settles, so nothing else in the process is affected. Registrations are reference-counted so
17
+ * overlapping imports do not unregister each other's hooks mid-load.
18
+ */
19
+ let active = 0;
20
+ let hooks;
21
+ async function acquire() {
22
+ active++;
23
+ hooks ??= (async () => {
24
+ const [esm, cjs] = await Promise.all([import('tsx/esm/api'), import('tsx/cjs/api')]);
25
+ const offEsm = esm.register();
26
+ const offCjs = cjs.register();
27
+ return async () => {
28
+ offCjs();
29
+ await offEsm();
30
+ };
31
+ })();
32
+ try {
33
+ await hooks;
34
+ }
35
+ catch (e) {
36
+ // Do not leave a rejected registration cached for every later import.
37
+ if (--active === 0)
38
+ hooks = undefined;
39
+ throw e;
40
+ }
41
+ }
42
+ async function releaseOne() {
43
+ if (--active > 0 || !hooks)
44
+ return;
45
+ const off = hooks;
46
+ hooks = undefined;
47
+ await (await off)();
48
+ }
49
+ export async function importProjectModule(file) {
50
+ await acquire();
51
+ try {
52
+ return (await import(pathToFileURL(file).href));
53
+ }
54
+ finally {
55
+ await releaseOne();
56
+ }
57
+ }
58
+ /**
59
+ * A named export from a module loaded by {@link importProjectModule}. A CommonJS-format file (no
60
+ * `"type": "module"` in the project) arrives with its exports under `default`.
61
+ */
62
+ export function namedExport(mod, name) {
63
+ if (mod[name] !== undefined)
64
+ return mod[name];
65
+ const def = mod.default;
66
+ if (def && typeof def === 'object' && def[name] !== undefined)
67
+ return def[name];
68
+ return undefined;
69
+ }
70
+ //# sourceMappingURL=load-module.js.map
package/dist/logger.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'step' | 'debug';
2
+ export declare function isSecretKey(name: string): boolean;
2
3
  export declare function setLogLevel(level: LogLevel): void;
3
4
  export declare function setLogJson(enabled: boolean): void;
4
5
  export declare function getLogLevel(): LogLevel;
package/dist/logger.js CHANGED
@@ -9,7 +9,11 @@ const ORDER = {
9
9
  };
10
10
  let globalLevel = process.env.SDODS_LOG_LEVEL || 'info';
11
11
  let jsonMode = process.env.SDODS_LOG_JSON === '1';
12
- const SECRET_KEY = /(password|secret|token|apikey|api_key|authorization|cookie)/i;
12
+ /** Key names whose string values are credentials: config fields, header names, log fields. */
13
+ const SECRET_KEY = /(passw(or)?d|secret|token|api[-_]?key|[-_]key$|authorization|cookie|credential)/i;
14
+ export function isSecretKey(name) {
15
+ return SECRET_KEY.test(name);
16
+ }
13
17
  export function setLogLevel(level) {
14
18
  globalLevel = level;
15
19
  }
@@ -26,9 +30,11 @@ export function redact(value, depth = 0) {
26
30
  return value.map((v) => redact(v, depth + 1));
27
31
  if (value && typeof value === 'object') {
28
32
  const out = {};
33
+ // Header auth (`{ type: header, name, value }`) keeps its secret under a neutral key.
34
+ const headerAuth = value.type === 'header';
29
35
  for (const [k, v] of Object.entries(value)) {
30
- out[k] =
31
- SECRET_KEY.test(k) && typeof v === 'string' && v.length > 0 ? '***' : redact(v, depth + 1);
36
+ const secret = SECRET_KEY.test(k) || (headerAuth && k === 'value');
37
+ out[k] = secret && typeof v === 'string' && v.length > 0 ? '***' : redact(v, depth + 1);
32
38
  }
33
39
  return out;
34
40
  }
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test';
3
3
  import { attachmentNames, scenarioFiles } from '@sdods/contracts';
4
4
  import './params.js';
5
5
  import { Then } from '../fixtures/test.js';
6
- import { render } from '../api/template.js';
6
+ import { renderStrict } from '../api/template.js';
7
7
  import { SdodsError } from '../errors.js';
8
8
  import { Logger } from '../logger.js';
9
9
  /**
@@ -449,7 +449,7 @@ Then('the page should have no accessibility violations', async ({ page, scenario
449
449
  * an empty context happily and reports zero violations, which is indistinguishable from a pass.
450
450
  */
451
451
  Then('the page should have no accessibility violations within {string}', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, selector) => {
452
- const sel = render(selector, ...scopesOf(apiContext, env));
452
+ const sel = renderStrict(selector, ...scopesOf(apiContext, env));
453
453
  await requireRegion(page, sel);
454
454
  const results = await runAxe(page, { include: sel });
455
455
  attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
@@ -462,7 +462,7 @@ Then('the page should have no accessibility violations within {string}', async (
462
462
  * nothing — "or worse than banana" would otherwise be the greenest step in the suite.
463
463
  */
464
464
  Then('the page should have no accessibility violations of impact {string} or worse', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, level) => {
465
- const floor = parseImpactFloor(render(level, ...scopesOf(apiContext, env)));
465
+ const floor = parseImpactFloor(renderStrict(level, ...scopesOf(apiContext, env)));
466
466
  const results = await runAxe(page, {});
467
467
  attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
468
468
  assertAxeChecked(results, `on ${page.url()}`);
@@ -472,8 +472,8 @@ Then('the page should have no accessibility violations of impact {string} or wor
472
472
  /** PROVES a region carries no violation at or above a severity — the scoped form of the above. */
473
473
  Then('the page should have no accessibility violations of impact {string} or worse within {string}', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, level, selector) => {
474
474
  const scopes = scopesOf(apiContext, env);
475
- const floor = parseImpactFloor(render(level, ...scopes));
476
- const sel = render(selector, ...scopes);
475
+ const floor = parseImpactFloor(renderStrict(level, ...scopes));
476
+ const sel = renderStrict(selector, ...scopes);
477
477
  await requireRegion(page, sel);
478
478
  const results = await runAxe(page, { include: sel });
479
479
  attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
@@ -489,7 +489,7 @@ Then('the page should have no accessibility violations of impact {string} or wor
489
489
  * step fails on either, because a rule that never ran cannot have passed.
490
490
  */
491
491
  Then('the page should have no accessibility violations of rule {string}', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, ruleId) => {
492
- const rule = render(ruleId, ...scopesOf(apiContext, env));
492
+ const rule = renderStrict(ruleId, ...scopesOf(apiContext, env));
493
493
  const results = await runAxe(page, { rules: [rule] });
494
494
  attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
495
495
  const bucket = assertRuleRan(results, rule, `on ${page.url()}`);
@@ -510,7 +510,7 @@ Then('the page should have no colour-contrast violations', async ({ page, scenar
510
510
  });
511
511
  /** PROVES one region's text meets AA contrast — for auditing a themed component in isolation. */
512
512
  Then('the page should have no colour-contrast violations within {string}', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, selector) => {
513
- const sel = render(selector, ...scopesOf(apiContext, env));
513
+ const sel = renderStrict(selector, ...scopesOf(apiContext, env));
514
514
  await requireRegion(page, sel);
515
515
  const results = await runAxe(page, { include: sel, rules: ['color-contrast'] });
516
516
  attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
@@ -556,7 +556,7 @@ Then('every image on the page should carry an alt attribute', async ({ page }) =
556
556
  });
557
557
  /** PROVES every image inside one region declares its alt intent — the scoped form of the above. */
558
558
  Then('every image within {string} should carry an alt attribute', async ({ page, apiContext, env }, selector) => {
559
- const sel = render(selector, ...scopesOf(apiContext, env));
559
+ const sel = renderStrict(selector, ...scopesOf(apiContext, env));
560
560
  const gathered = await page.evaluate(gatherImages, sel);
561
561
  const images = assertGathered(assertRegion(gathered, sel), 'img elements', `within "${sel}"`);
562
562
  const missing = imagesWithoutAlt(images);
@@ -571,7 +571,7 @@ Then('every image within {string} should carry an alt attribute', async ({ page,
571
571
  * itself — axe sees a non-empty name and passes it.
572
572
  */
573
573
  Then('every icon-only control within {string} should expose an accessible name', async ({ page, apiContext, env }, selector) => {
574
- const sel = render(selector, ...scopesOf(apiContext, env));
574
+ const sel = renderStrict(selector, ...scopesOf(apiContext, env));
575
575
  const gathered = await page.evaluate(gatherControls, sel);
576
576
  const controls = assertRegion(gathered, sel);
577
577
  const iconOnly = assertGathered(iconOnlyControls(controls), 'icon-only controls', `within "${sel}" (${controls.length} control(s) there render text)`);
@@ -588,7 +588,7 @@ Then('every icon-only control within {string} should expose an accessible name',
588
588
  * enter keyboard modality and never blurs between elements.
589
589
  */
590
590
  Then('every focusable element within {string} should show a visible focus indicator', async ({ page, apiContext, env }, selector) => {
591
- const sel = render(selector, ...scopesOf(apiContext, env));
591
+ const sel = renderStrict(selector, ...scopesOf(apiContext, env));
592
592
  await page.keyboard.press('Tab');
593
593
  const gathered = await page.evaluate(gatherFocusIndicators, sel);
594
594
  const focusables = assertGathered(assertRegion(gathered, sel), 'focusable elements', `within "${sel}"`);
@@ -2,30 +2,41 @@ import { expect } from '@playwright/test';
2
2
  import './params.js';
3
3
  import { Given, Then, When } from '../fixtures/test.js';
4
4
  import { coerce, getPath } from '../api/json-path.js';
5
- import { render, renderJson } from '../api/template.js';
5
+ import { renderJson, renderStrict } from '../api/template.js';
6
6
  import { pollUntil } from '../api/poll.js';
7
7
  import { loadJsonSchema, loadZodSchema, validateAgainstOpenApi, validateJsonSchema, validateZod, } from '../api/validate.js';
8
8
  import { SdodsError } from '../errors.js';
9
9
  /* ── requests ─────────────────────────────────────────────────────────── */
10
10
  When('I send a {method} request to {string}', async ({ api, apiContext, env }, method, path) => {
11
- await api.send(method, render(path, apiContext.vars.toObject(), env.vars));
11
+ await api.send(method, renderStrict(path, apiContext.vars.toObject(), env.vars));
12
12
  });
13
13
  When('I send a {method} request to {string} with body:', async ({ api, apiContext, env }, method, path, body) => {
14
14
  const scopes = [apiContext.vars.toObject(), env.vars];
15
- await api.send(method, render(path, ...scopes), { body: renderJson(body, ...scopes) });
15
+ await api.send(method, renderStrict(path, ...scopes), { body: renderJson(body, ...scopes) });
16
+ });
17
+ /**
18
+ * The doc string goes out byte-for-byte: not parsed, not re-serialised, and not template-rendered
19
+ * (only the path is). This is how a malformed-input scenario sends `{ this is not json`; the
20
+ * `with body:` step parses a `json` doc string and would throw before sending.
21
+ * The content-type defaults to `application/json`; set another with `I set the request header`.
22
+ */
23
+ When('I send a {method} request to {string} with the raw body:', async ({ api, apiContext, env }, method, path, body) => {
24
+ await api.send(method, renderStrict(path, apiContext.vars.toObject(), env.vars), {
25
+ body: Buffer.from(body, 'utf8'),
26
+ });
16
27
  });
17
28
  When('I send a {method} request to {string} with form:', async ({ api, apiContext, env }, method, path, table) => {
18
29
  const scopes = [apiContext.vars.toObject(), env.vars];
19
30
  const form = {};
20
31
  for (const [k, v] of table.raw())
21
- form[k] = render(String(v ?? ''), ...scopes);
22
- await api.send(method, render(path, ...scopes), { form });
32
+ form[k] = renderStrict(String(v ?? ''), ...scopes);
33
+ await api.send(method, renderStrict(path, ...scopes), { form });
23
34
  });
24
35
  Given('I set the request header {string} to {string}', async ({ apiContext, env }, name, value) => {
25
- apiContext.headers.set(name, render(value, apiContext.vars.toObject(), env.vars));
36
+ apiContext.headers.set(name, renderStrict(value, apiContext.vars.toObject(), env.vars));
26
37
  });
27
38
  Given('I set the query parameter {string} to {string}', async ({ apiContext, env }, name, value) => {
28
- apiContext.query.set(name, render(value, apiContext.vars.toObject(), env.vars));
39
+ apiContext.query.set(name, renderStrict(value, apiContext.vars.toObject(), env.vars));
29
40
  });
30
41
  Given('I clear the request headers and query parameters', async ({ apiContext }) => {
31
42
  apiContext.resetRequestState();
@@ -40,8 +51,8 @@ Given('I authenticate with basic credentials {string} and {string}', async ({ ap
40
51
  const scopes = [apiContext.vars.toObject(), env.vars];
41
52
  apiContext.auth = {
42
53
  type: 'basic',
43
- username: render(username, ...scopes),
44
- password: render(password, ...scopes),
54
+ username: renderStrict(username, ...scopes),
55
+ password: renderStrict(password, ...scopes),
45
56
  };
46
57
  });
47
58
  Given('I use no authentication', async ({ apiContext }) => {
@@ -51,6 +62,14 @@ Given('I use no authentication', async ({ apiContext }) => {
51
62
  // refusal matters.
52
63
  apiContext.auth = null;
53
64
  });
65
+ /**
66
+ * Send the rest of the scenario's API calls through a request context with an empty cookie jar,
67
+ * so they carry only the credentials the scenario set. Without it, @ui/@hybrid calls also carry the
68
+ * leased role's session cookie, and "credential X alone is refused" can pass on the session.
69
+ */
70
+ Given('I use an isolated API client', async ({ apiContext }) => {
71
+ apiContext.isolated = true;
72
+ });
54
73
  /* ── assertions ───────────────────────────────────────────────────────── */
55
74
  Then('the response status should be {int}', async ({ apiContext }, status) => {
56
75
  expect(apiContext.last().response.status, `expected HTTP ${status}, got ${apiContext.last().response.status} ${apiContext.last().response.statusText}`).toBe(status);
@@ -65,70 +84,88 @@ Then('the response status should be one of {string}', async ({ apiContext }, lis
65
84
  Then('the response should contain {string}', async ({ apiContext, env }, text) => {
66
85
  const body = apiContext.last().response.body;
67
86
  const haystack = typeof body === 'string' ? body : JSON.stringify(body);
68
- expect(haystack).toContain(render(text, apiContext.vars.toObject(), env.vars));
87
+ expect(haystack).toContain(renderStrict(text, apiContext.vars.toObject(), env.vars));
69
88
  });
70
- Then('the response JSON path {string} should equal {string}', async ({ apiContext, env }, path, expected) => {
89
+ Then('the response JSON path {string} should equal {string}', async ({ apiContext, env }, jsonPath, expected) => {
90
+ const scopes = [apiContext.vars.toObject(), env.vars];
91
+ const path = renderStrict(jsonPath, ...scopes);
71
92
  const actual = getPath(apiContext.last().response.body, path);
72
- expect(actual, `JSON path ${path}`).toEqual(coerce(render(expected, apiContext.vars.toObject(), env.vars)));
93
+ expect(actual, `JSON path ${path}`).toEqual(coerce(renderStrict(expected, ...scopes)));
73
94
  });
74
- Then('the response JSON path {string} should exist', async ({ apiContext }, path) => {
95
+ Then('the response JSON path {string} should exist', async ({ apiContext, env }, jsonPath) => {
96
+ const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
75
97
  expect(getPath(apiContext.last().response.body, path), `JSON path ${path}`).toBeDefined();
76
98
  });
77
- Then('the response JSON path {string} should match {string}', async ({ apiContext }, path, pattern) => {
99
+ Then('the response JSON path {string} should match {string}', async ({ apiContext, env }, jsonPath, pattern) => {
100
+ const scopes = [apiContext.vars.toObject(), env.vars];
101
+ const path = renderStrict(jsonPath, ...scopes);
78
102
  const actual = getPath(apiContext.last().response.body, path);
79
- expect(String(actual), `JSON path ${path}`).toMatch(new RegExp(pattern));
103
+ expect(String(actual), `JSON path ${path}`).toMatch(new RegExp(renderStrict(pattern, ...scopes)));
80
104
  });
81
- Then('the response JSON path {string} should have {int} items', async ({ apiContext }, path, count) => {
105
+ Then('the response JSON path {string} should have {int} items', async ({ apiContext, env }, jsonPath, count) => {
106
+ const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
82
107
  const actual = getPath(apiContext.last().response.body, path);
83
108
  expect(Array.isArray(actual) ? actual.length : -1, `JSON path ${path} length`).toBe(count);
84
109
  });
85
- Then('the response JSON path {string} should have at least {int} items', async ({ apiContext }, path, count) => {
110
+ Then('the response JSON path {string} should have at least {int} items', async ({ apiContext, env }, jsonPath, count) => {
111
+ const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
86
112
  const actual = getPath(apiContext.last().response.body, path);
87
113
  expect(Array.isArray(actual) ? actual.length : -1, `JSON path ${path} length`).toBeGreaterThanOrEqual(count);
88
114
  });
89
115
  Then('the response body should be an array', async ({ apiContext }) => {
90
116
  expect(Array.isArray(apiContext.last().response.body)).toBe(true);
91
117
  });
92
- Then('the response header {string} should contain {string}', async ({ apiContext }, name, value) => {
93
- expect(apiContext.last().response.headers[name.toLowerCase()] ?? '').toContain(value);
118
+ Then('the response header {string} should contain {string}', async ({ apiContext, env }, name, value) => {
119
+ const scopes = [apiContext.vars.toObject(), env.vars];
120
+ const header = renderStrict(name, ...scopes).toLowerCase();
121
+ expect(apiContext.last().response.headers[header] ?? '').toContain(renderStrict(value, ...scopes));
94
122
  });
95
123
  Then('the response time should be under {int} ms', async ({ apiContext }, maxMs) => {
96
124
  expect(apiContext.last().response.responseTime).toBeLessThan(maxMs);
97
125
  });
98
- Then('the response should match the JSON schema {string}', async ({ apiContext, config }, file) => {
126
+ Then('the response should match the JSON schema {string}', async ({ apiContext, config, env }, schemaFile) => {
127
+ const file = renderStrict(schemaFile, apiContext.vars.toObject(), env.vars);
99
128
  const result = validateJsonSchema(apiContext.last().response.body, loadJsonSchema(config, file));
100
129
  expect(result.ok, `schema ${file} violations:\n${result.errors.join('\n')}`).toBe(true);
101
130
  });
102
- Then('the response should match the zod schema {string}', async ({ apiContext, config }, spec) => {
131
+ Then('the response should match the zod schema {string}', async ({ apiContext, config, env }, zodSpec) => {
132
+ const spec = renderStrict(zodSpec, apiContext.vars.toObject(), env.vars);
103
133
  const result = validateZod(apiContext.last().response.body, await loadZodSchema(config, spec));
104
134
  expect(result.ok, `zod schema ${spec} violations:\n${result.errors.join('\n')}`).toBe(true);
105
135
  });
106
- Then('the response should match the OpenAPI schema for {method} {string}', async ({ apiContext, config }, method, path) => {
136
+ Then('the response should match the OpenAPI schema for {method} {string}', async ({ apiContext, config, env }, method, openApiPath) => {
137
+ // Single-brace `{id}` path parameters are not placeholders, so they pass through untouched.
138
+ const path = renderStrict(openApiPath, apiContext.vars.toObject(), env.vars);
107
139
  const last = apiContext.last();
108
140
  const result = await validateAgainstOpenApi(config, method, path, last.response.status, last.response.body);
109
141
  expect(result.ok, `OpenAPI ${method} ${path} violations:\n${result.errors.join('\n')}`).toBe(true);
110
142
  });
111
143
  /* ── chaining & polling ───────────────────────────────────────────────── */
112
- When('I save the response JSON path {string} as {string}', async ({ apiContext }, path, name) => {
144
+ When('I save the response JSON path {string} as {string}', async ({ apiContext, env }, jsonPath, name) => {
145
+ // `name` is the variable being written, an identifier rather than a template.
146
+ const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
113
147
  const value = getPath(apiContext.last().response.body, path);
114
148
  if (value === undefined)
115
149
  throw new SdodsError('RUN_FAILED', `JSON path ${path} is undefined in the last response; cannot save as {{${name}}}.`);
116
150
  apiContext.vars.set(name, value);
117
151
  });
118
152
  Given('I set the variable {string} to {string}', async ({ apiContext, env }, name, value) => {
119
- apiContext.vars.set(name, coerce(render(value, apiContext.vars.toObject(), env.vars)));
153
+ apiContext.vars.set(name, coerce(renderStrict(value, apiContext.vars.toObject(), env.vars)));
120
154
  });
121
155
  When('I poll {method} {string} until JSON path {string} equals {string} within {int} seconds', async ({ api, apiContext, env }, method, path, jsonPath, expected, seconds) => {
122
156
  const scopes = [apiContext.vars.toObject(), env.vars];
123
- const want = coerce(render(expected, ...scopes));
124
- await pollUntil(() => api.send(method, render(path, ...scopes)), (snap) => JSON.stringify(getPath(snap.response.body, jsonPath)) === JSON.stringify(want), {
157
+ // Rendered before polling, so an unset variable fails at once rather than inside every attempt.
158
+ const want = coerce(renderStrict(expected, ...scopes));
159
+ const url = renderStrict(path, ...scopes);
160
+ const at = renderStrict(jsonPath, ...scopes);
161
+ await pollUntil(() => api.send(method, url), (snap) => JSON.stringify(getPath(snap.response.body, at)) === JSON.stringify(want), {
125
162
  timeoutMs: seconds * 1000,
126
163
  intervalMs: 1000,
127
164
  description: `${method} ${path} → ${jsonPath} == ${expected}`,
128
165
  });
129
166
  });
130
167
  When('I poll {method} {string} until the status is {int} within {int} seconds', async ({ api, apiContext, env }, method, path, status, seconds) => {
131
- await pollUntil(() => api.send(method, render(path, apiContext.vars.toObject(), env.vars)), (snap) => snap.response.status === status, {
168
+ await pollUntil(() => api.send(method, renderStrict(path, apiContext.vars.toObject(), env.vars)), (snap) => snap.response.status === status, {
132
169
  timeoutMs: seconds * 1000,
133
170
  intervalMs: 1000,
134
171
  description: `${method} ${path} → HTTP ${status}`,
@@ -1,6 +1,6 @@
1
1
  import { expect } from '@playwright/test';
2
2
  import { Given, Then, When } from '../fixtures/test.js';
3
- import { render } from '../api/template.js';
3
+ import { renderStrict } from '../api/template.js';
4
4
  import { SdodsError } from '../errors.js';
5
5
  /**
6
6
  * Browser-state steps: cookies, web storage, viewport, colour scheme, locale and console.
@@ -10,7 +10,8 @@ import { SdodsError } from '../errors.js';
10
10
  * step PROVES; where a step exists because of a specific trap, the trap is named.
11
11
  *
12
12
  * Two rules govern the whole file:
13
- * * every string argument is interpolated through `render()`, so `{{vars}}` work everywhere;
13
+ * * every string argument is interpolated through `renderStrict()`, so `{{vars}}` work everywhere
14
+ * and an unset one fails the step instead of being matched literally;
14
15
  * * no assertion may pass over an empty set. A selector that matches nothing, a body that
15
16
  * rendered no text, a recorder that was never armed and a page that never navigated are all
16
17
  * failures, because a green step that observed nothing is worse than no step at all.
@@ -43,8 +44,8 @@ Given('I set the browser cookie {string} to {string}', async ({ page, apiContext
43
44
  const scopes = scopesOf(apiContext, env);
44
45
  await page.context().addCookies([
45
46
  {
46
- name: render(name, ...scopes),
47
- value: render(value, ...scopes),
47
+ name: renderStrict(name, ...scopes),
48
+ value: renderStrict(value, ...scopes),
48
49
  url: baseUrlOf(env, 'I set the browser cookie'),
49
50
  },
50
51
  ]);
@@ -54,7 +55,7 @@ Given('I set the browser cookie {string} to {string}', async ({ page, apiContext
54
55
  // read-filter-restore. Clearing the jar to drop a session cookie would also drop the consent and
55
56
  // locale cookies the scenario set up, and the failure would look like a bug in the app.
56
57
  Given('I clear the browser cookie {string}', async ({ page, apiContext, env }, name) => {
57
- const wanted = render(name, ...scopesOf(apiContext, env));
58
+ const wanted = renderStrict(name, ...scopesOf(apiContext, env));
58
59
  const context = page.context();
59
60
  const all = await context.cookies();
60
61
  const survivors = all.filter((c) => c.name !== wanted);
@@ -71,19 +72,19 @@ Given('I clear all browser cookies', async ({ page }) => {
71
72
  // `document.cookie`, so the browser context is the only place its value is observable at all.
72
73
  Then('the browser cookie {string} should equal {string}', async ({ page, apiContext, env }, name, expected) => {
73
74
  const scopes = scopesOf(apiContext, env);
74
- const wanted = render(name, ...scopes);
75
+ const wanted = renderStrict(name, ...scopes);
75
76
  const cookie = await findCookie(page, wanted);
76
77
  expect(cookie, `cookie "${wanted}" is not in the jar`).toBeTruthy();
77
- expect(cookie.value, `cookie "${wanted}"`).toBe(render(expected, ...scopes));
78
+ expect(cookie.value, `cookie "${wanted}"`).toBe(renderStrict(expected, ...scopes));
78
79
  });
79
80
  // Proves the cookie was issued at all, for cases where the value is opaque or random.
80
81
  Then('the browser cookie {string} should exist', async ({ page, apiContext, env }, name) => {
81
- const wanted = render(name, ...scopesOf(apiContext, env));
82
+ const wanted = renderStrict(name, ...scopesOf(apiContext, env));
82
83
  expect(await findCookie(page, wanted), `cookie "${wanted}" is not in the jar`).toBeTruthy();
83
84
  });
84
85
  // Proves a logout, a rejected consent or a declined attribution left nothing behind.
85
86
  Then('the browser cookie {string} should not exist', async ({ page, apiContext, env }, name) => {
86
- const wanted = render(name, ...scopesOf(apiContext, env));
87
+ const wanted = renderStrict(name, ...scopesOf(apiContext, env));
87
88
  expect(await findCookie(page, wanted), `unexpected cookie "${wanted}"`).toBeUndefined();
88
89
  });
89
90
  /** The attributes Playwright actually reports. Anything else is an authoring mistake. */
@@ -94,8 +95,8 @@ const COOKIE_ATTRIBUTES = ['domain', 'path', 'expires', 'httpOnly', 'secure', 's
94
95
  // string "undefined", so the step would pass while asserting nothing; the allowlist refuses it.
95
96
  Then('the browser cookie {string} should carry {string} equal to {string}', async ({ page, apiContext, env }, name, attribute, value) => {
96
97
  const scopes = scopesOf(apiContext, env);
97
- const wanted = render(name, ...scopes);
98
- const attr = render(attribute, ...scopes);
98
+ const wanted = renderStrict(name, ...scopes);
99
+ const attr = renderStrict(attribute, ...scopes);
99
100
  if (!COOKIE_ATTRIBUTES.includes(attr))
100
101
  throw new SdodsError('RUN_FAILED', `"${attr}" is not a cookie attribute.`, {
101
102
  hint: `Use one of: ${COOKIE_ATTRIBUTES.join(', ')}.`,
@@ -103,7 +104,7 @@ Then('the browser cookie {string} should carry {string} equal to {string}', asyn
103
104
  const cookie = await findCookie(page, wanted);
104
105
  expect(cookie, `cookie "${wanted}" is not in the jar`).toBeTruthy();
105
106
  const actual = cookie[attr];
106
- expect(String(actual), `${wanted}.${attr}`).toBe(render(value, ...scopes));
107
+ expect(String(actual), `${wanted}.${attr}`).toBe(renderStrict(value, ...scopes));
107
108
  });
108
109
  async function findCookie(page, name) {
109
110
  return (await page.context().cookies()).find((c) => c.name === name);
@@ -172,17 +173,17 @@ async function readStorage(page, kind, key) {
172
173
  // banner — reaches it before the first paint rather than after it.
173
174
  Given('I seed local storage {string} with {string}', async ({ page, apiContext, env }, key, value) => {
174
175
  const scopes = scopesOf(apiContext, env);
175
- await seedStorage(page, 'local', render(key, ...scopes), render(value, ...scopes));
176
+ await seedStorage(page, 'local', renderStrict(key, ...scopes), renderStrict(value, ...scopes));
176
177
  });
177
178
  // Same contract as local storage, for the per-tab values an app keeps out of the persistent store.
178
179
  Given('I seed session storage {string} with {string}', async ({ page, apiContext, env }, key, value) => {
179
180
  const scopes = scopesOf(apiContext, env);
180
- await seedStorage(page, 'session', render(key, ...scopes), render(value, ...scopes));
181
+ await seedStorage(page, 'session', renderStrict(key, ...scopes), renderStrict(value, ...scopes));
181
182
  });
182
183
  // Proves the DEFAULT path: what the app does for a visitor with no stored preference. Removal is
183
184
  // armed as an init script as well as applied in place, so the next navigation cannot resurrect it.
184
185
  Given('I clear the local storage key {string}', async ({ page, apiContext, env }, key) => {
185
- const wanted = render(key, ...scopesOf(apiContext, env));
186
+ const wanted = renderStrict(key, ...scopesOf(apiContext, env));
186
187
  await page.addInitScript((k) => {
187
188
  try {
188
189
  window.localStorage.removeItem(k);
@@ -228,25 +229,25 @@ Given('I clear all browser storage', async ({ page }) => {
228
229
  // reaches storage looks identical on screen until the next page load.
229
230
  Then('local storage {string} should equal {string}', async ({ page, apiContext, env }, key, expected) => {
230
231
  const scopes = scopesOf(apiContext, env);
231
- const k = render(key, ...scopes);
232
- expect(await readStorage(page, 'local', k), `local storage "${k}"`).toBe(render(expected, ...scopes));
232
+ const k = renderStrict(key, ...scopes);
233
+ expect(await readStorage(page, 'local', k), `local storage "${k}"`).toBe(renderStrict(expected, ...scopes));
233
234
  });
234
235
  // Proves a sign-out, a reset or a decline actually removed the key rather than blanking the UI.
235
236
  Then('local storage {string} should be absent', async ({ page, apiContext, env }, key) => {
236
- const k = render(key, ...scopesOf(apiContext, env));
237
+ const k = renderStrict(key, ...scopesOf(apiContext, env));
237
238
  expect(await readStorage(page, 'local', k), `local storage "${k}"`).toBeNull();
238
239
  });
239
240
  // Per-tab equivalent of the local-storage absence assertion, so a tab-scoped value that outlives
240
241
  // the flow which set it fails as loudly as a persisted one.
241
242
  Then('session storage {string} should be absent', async ({ page, apiContext, env }, key) => {
242
- const k = render(key, ...scopesOf(apiContext, env));
243
+ const k = renderStrict(key, ...scopesOf(apiContext, env));
243
244
  expect(await readStorage(page, 'session', k), `session storage "${k}"`).toBeNull();
244
245
  });
245
246
  // Per-tab equivalent of the local-storage assertion.
246
247
  Then('session storage {string} should equal {string}', async ({ page, apiContext, env }, key, expected) => {
247
248
  const scopes = scopesOf(apiContext, env);
248
- const k = render(key, ...scopes);
249
- expect(await readStorage(page, 'session', k), `session storage "${k}"`).toBe(render(expected, ...scopes));
249
+ const k = renderStrict(key, ...scopes);
250
+ expect(await readStorage(page, 'session', k), `session storage "${k}"`).toBe(renderStrict(expected, ...scopes));
250
251
  });
251
252
  /* ── viewport and reflow ──────────────────────────────────────────────── */
252
253
  // Proves a layout at a width the project's fixed viewport never visits. WCAG 2.2 reflow is a
@@ -300,7 +301,7 @@ Then('the page should not scroll horizontally', async ({ page }) => {
300
301
  // to scroll internally must not push the page. Fails when the selector matches nothing, because a
301
302
  // containment claim about an element that is not on the page is not a claim.
302
303
  Then('the element matching {string} should not overflow horizontally', async ({ page, apiContext, env }, selector) => {
303
- const sel = render(selector, ...scopesOf(apiContext, env));
304
+ const sel = renderStrict(selector, ...scopesOf(apiContext, env));
304
305
  const info = (await page.evaluate((s) => {
305
306
  const el = document.querySelector(s);
306
307
  if (!el)
@@ -316,7 +317,7 @@ const COLOUR_SCHEMES = ['light', 'dark', 'no-preference'];
316
317
  // its own preference is seeded with `Given I seed local storage …` instead, and a complete dark-mode
317
318
  // scenario usually needs both.
318
319
  Given('I emulate the {string} colour scheme', async ({ page, apiContext, env }, scheme) => {
319
- const wanted = render(scheme, ...scopesOf(apiContext, env));
320
+ const wanted = renderStrict(scheme, ...scopesOf(apiContext, env));
320
321
  if (!COLOUR_SCHEMES.includes(wanted))
321
322
  throw new SdodsError('RUN_FAILED', `"${wanted}" is not a colour scheme.`, {
322
323
  hint: `Use one of: ${COLOUR_SCHEMES.join(', ')}.`,
@@ -386,13 +387,13 @@ function expectLight(painted, subject) {
386
387
  // Proves the CLASS was applied. Necessary but never sufficient: a `dark` class with no CSS behind
387
388
  // it is not dark mode, which is why the painted-background steps exist alongside it.
388
389
  Then('the html element should carry the {string} class', async ({ page, apiContext, env }, cls) => {
389
- const wanted = render(cls, ...scopesOf(apiContext, env));
390
+ const wanted = renderStrict(cls, ...scopesOf(apiContext, env));
390
391
  const classes = await page.evaluate(() => document.documentElement.className);
391
392
  expect(String(classes).split(/\s+/).filter(Boolean), `html class list is "${classes}"`).toContain(wanted);
392
393
  });
393
394
  // Proves the theme class was REMOVED — the direction that catches a toggle which only ever adds.
394
395
  Then('the html element should not carry the {string} class', async ({ page, apiContext, env }, cls) => {
395
- const wanted = render(cls, ...scopesOf(apiContext, env));
396
+ const wanted = renderStrict(cls, ...scopesOf(apiContext, env));
396
397
  const classes = await page.evaluate(() => document.documentElement.className);
397
398
  expect(String(classes).split(/\s+/).filter(Boolean), `html class list is "${classes}"`).not.toContain(wanted);
398
399
  });
@@ -408,7 +409,7 @@ Then('the painted page background should be light', async ({ page }) => {
408
409
  // Proves ONE surface themed — a panel, a modal, a sidebar. Whole-page checks miss the component
409
410
  // that kept a hardcoded white while everything around it went dark.
410
411
  Then('the element matching {string} should paint a dark background', async ({ page, apiContext, env }, selector) => {
411
- const sel = render(selector, ...scopesOf(apiContext, env));
412
+ const sel = renderStrict(selector, ...scopesOf(apiContext, env));
412
413
  expectDark(await paintedColour(page, sel, `the element matching "${sel}"`), `"${sel}"`);
413
414
  });
414
415
  /** Where the first-frame sample is parked. Stable by design: the assertion step reads it back. */
@@ -477,8 +478,8 @@ Given('I switch the interface locale to {string} using the {string} cookie', asy
477
478
  requireNavigated(page, 'I switch the interface locale');
478
479
  await page.context().addCookies([
479
480
  {
480
- name: render(cookieName, ...scopes),
481
- value: render(locale, ...scopes),
481
+ name: renderStrict(cookieName, ...scopes),
482
+ value: renderStrict(locale, ...scopes),
482
483
  url: baseUrlOf(env, 'I switch the interface locale'),
483
484
  },
484
485
  ]);
@@ -491,14 +492,14 @@ Given('I switch the interface locale to {string} using the {string} cookie', asy
491
492
  Given('I request the locale {string} with the Accept-Language header', async ({ page, apiContext, env }, locale) => {
492
493
  requireNavigated(page, 'I request the locale with the Accept-Language header');
493
494
  await page.setExtraHTTPHeaders({
494
- 'Accept-Language': render(locale, ...scopesOf(apiContext, env)),
495
+ 'Accept-Language': renderStrict(locale, ...scopesOf(apiContext, env)),
495
496
  });
496
497
  await page.reload({ waitUntil: 'domcontentloaded' });
497
498
  });
498
499
  // Proves the document declared the language it served. Screen readers, hyphenation and every
499
500
  // `:lang()` rule key off this, and it is the one machine-checkable trace of the negotiation.
500
501
  Then('the html lang attribute should be {string}', async ({ page, apiContext, env }, lang) => {
501
- const wanted = render(lang, ...scopesOf(apiContext, env));
502
+ const wanted = renderStrict(lang, ...scopesOf(apiContext, env));
502
503
  const actual = await page.evaluate(() => document.documentElement.getAttribute('lang'));
503
504
  expect(actual, 'html lang attribute').toBe(wanted);
504
505
  });
@@ -623,7 +624,7 @@ Then('no console error should have been recorded', async ({ apiContext }) => {
623
624
  // hydration mismatch, a blocked request — while tolerating noise the team has accepted.
624
625
  Then('no console error should match {string}', async ({ apiContext, env }, pattern) => {
625
626
  const recording = recordingFor(apiContext, 'no console error should match');
626
- const rendered = render(pattern, apiContext.vars.toObject(), env.vars);
627
+ const rendered = renderStrict(pattern, apiContext.vars.toObject(), env.vars);
627
628
  const re = compilePattern(rendered, 'no console error should match');
628
629
  expect(recording.errors.filter((line) => re.test(line)), `console errors matching /${rendered}/`).toEqual([]);
629
630
  });
@@ -631,7 +632,7 @@ Then('no console error should match {string}', async ({ apiContext, env }, patte
631
632
  // deprecated API — never at error level.
632
633
  Then('no console warning should match {string}', async ({ apiContext, env }, pattern) => {
633
634
  const recording = recordingFor(apiContext, 'no console warning should match');
634
- const rendered = render(pattern, apiContext.vars.toObject(), env.vars);
635
+ const rendered = renderStrict(pattern, apiContext.vars.toObject(), env.vars);
635
636
  const re = compilePattern(rendered, 'no console warning should match');
636
637
  expect(recording.warnings.filter((line) => re.test(line)), `console warnings matching /${rendered}/`).toEqual([]);
637
638
  });
@@ -640,7 +641,7 @@ Then('no console warning should match {string}', async ({ apiContext, env }, pat
640
641
  // a recorder that silently stopped working would leave every "no console error" step green.
641
642
  Then('a console error matching {string} should have been recorded', async ({ apiContext, env }, pattern) => {
642
643
  const recording = recordingFor(apiContext, 'a console error matching … should have been recorded');
643
- const rendered = render(pattern, apiContext.vars.toObject(), env.vars);
644
+ const rendered = renderStrict(pattern, apiContext.vars.toObject(), env.vars);
644
645
  const re = compilePattern(rendered, 'a console error matching … should have been recorded');
645
646
  expect(recording.errors.filter((line) => re.test(line)).length, `no console error matched /${rendered}/. Recorded: ${recording.errors.slice(0, 10).join(' | ') || '(none)'}`).toBeGreaterThan(0);
646
647
  });