@sdods/core 0.3.2 → 0.5.2

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 (56) 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/evidence/index.d.ts +3 -0
  16. package/dist/evidence/index.js +3 -0
  17. package/dist/evidence/trace-redact.d.ts +74 -0
  18. package/dist/evidence/trace-redact.js +297 -0
  19. package/dist/evidence/zip.d.ts +36 -0
  20. package/dist/evidence/zip.js +148 -0
  21. package/dist/fixtures/api-context.d.ts +7 -0
  22. package/dist/fixtures/api-context.js +7 -0
  23. package/dist/fixtures/auth.js +72 -5
  24. package/dist/fixtures/test.js +9 -2
  25. package/dist/heal/healer.d.ts +12 -1
  26. package/dist/heal/healer.js +80 -34
  27. package/dist/heal/strategies.d.ts +12 -2
  28. package/dist/heal/strategies.js +42 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/lint/index.d.ts +1 -0
  32. package/dist/lint/index.js +44 -3
  33. package/dist/lint/steps.d.ts +13 -0
  34. package/dist/lint/steps.js +333 -0
  35. package/dist/load-module.d.ts +7 -0
  36. package/dist/load-module.js +70 -0
  37. package/dist/logger.d.ts +1 -0
  38. package/dist/logger.js +9 -3
  39. package/dist/steps/a11y.steps.js +10 -10
  40. package/dist/steps/api.steps.js +64 -27
  41. package/dist/steps/browser.steps.js +34 -33
  42. package/dist/steps/clock.steps.js +5 -5
  43. package/dist/steps/data.steps.js +61 -8
  44. package/dist/steps/db.steps.js +8 -8
  45. package/dist/steps/dom.steps.js +49 -49
  46. package/dist/steps/hybrid.steps.js +9 -5
  47. package/dist/steps/iframe.steps.js +9 -9
  48. package/dist/steps/net.steps.d.ts +12 -7
  49. package/dist/steps/net.steps.js +78 -83
  50. package/dist/steps/perf.steps.js +6 -6
  51. package/dist/steps/tabs.steps.js +6 -6
  52. package/dist/steps/ui.steps.js +94 -45
  53. package/dist/steps/webhook.steps.js +3 -3
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +15 -1
  56. package/package.json +6 -4
@@ -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
  });
@@ -1,7 +1,7 @@
1
1
  import { expect } from '@playwright/test';
2
2
  import './params.js';
3
3
  import { Given, Then } from '../fixtures/test.js';
4
- import { render } from '../api/template.js';
4
+ import { renderStrict } from '../api/template.js';
5
5
  import { SdodsError } from '../errors.js';
6
6
  /**
7
7
  * Clock control.
@@ -40,7 +40,7 @@ Given('I install a fake clock', async ({ page }) => {
40
40
  await page.clock.install();
41
41
  });
42
42
  Given('I install a fake clock set to {string}', async ({ page, apiContext, env }, when) => {
43
- const at = resolveTime(render(when, ...scopesOf(apiContext, env)), new Date());
43
+ const at = resolveTime(renderStrict(when, ...scopesOf(apiContext, env)), new Date());
44
44
  await page.clock.install({ time: at });
45
45
  });
46
46
  /**
@@ -49,12 +49,12 @@ Given('I install a fake clock set to {string}', async ({ page, apiContext, env }
49
49
  * hang the test rather than simulate it.
50
50
  */
51
51
  Given('the clock jumps to {string}', async ({ page, apiContext, env }, when) => {
52
- const at = resolveTime(render(when, ...scopesOf(apiContext, env)), new Date());
52
+ const at = resolveTime(renderStrict(when, ...scopesOf(apiContext, env)), new Date());
53
53
  await page.clock.setFixedTime(at);
54
54
  });
55
55
  /** Runs timers as it goes, so polling and countdowns actually fire. */
56
56
  Given('the clock advances by {string}', async ({ page, apiContext, env }, amount) => {
57
- const spec = render(amount, ...scopesOf(apiContext, env)).trim();
57
+ const spec = renderStrict(amount, ...scopesOf(apiContext, env)).trim();
58
58
  const normalised = /^[+-]/.test(spec) ? spec : `+${spec}`;
59
59
  const target = resolveTime(normalised, new Date(0));
60
60
  await page.clock.runFor(target.getTime());
@@ -63,7 +63,7 @@ Given('the clock resumes', async ({ page }) => {
63
63
  await page.clock.resume();
64
64
  });
65
65
  Then('the page clock should read {string}', async ({ page, apiContext, env }, iso) => {
66
- const expected = resolveTime(render(iso, ...scopesOf(apiContext, env)), new Date());
66
+ const expected = resolveTime(renderStrict(iso, ...scopesOf(apiContext, env)), new Date());
67
67
  const actual = await page.evaluate(() => Date.now());
68
68
  // A second of tolerance: the assertion is about which DAY or HOUR the page
69
69
  // believes it is, and demanding millisecond equality would make it flaky for
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import './params.js';
3
3
  import { Given, When } from '../fixtures/test.js';
4
4
  import { tokenFileFor } from '../auth/capture.js';
5
- import { render } from '../api/template.js';
5
+ import { renderStrict } from '../api/template.js';
6
6
  import { SdodsError } from '../errors.js';
7
7
  import { Logger } from '../logger.js';
8
8
  const log = new Logger('data');
@@ -29,6 +29,7 @@ Given('I use a leased user with role {string}', async ({ userPool, authCache, au
29
29
  role: user.role,
30
30
  ...user.extra,
31
31
  });
32
+ LEASED.set(apiContext, user);
32
33
  if (sdods.layer !== 'api')
33
34
  await authCache.apply({ context, page, user, auth, browser });
34
35
  });
@@ -44,6 +45,7 @@ Given('I use the user {string} from the pool', async ({ userPool, apiContext, $t
44
45
  userId: user.id,
45
46
  role: user.role,
46
47
  });
48
+ LEASED.set(apiContext, user);
47
49
  });
48
50
  /**
49
51
  * API-layer variant: leases the account, exposes its credentials as variables,
@@ -68,6 +70,18 @@ Given('I use the user {string} from the pool', async ({ userPool, apiContext, $t
68
70
  * meaningful and one mint is reused across a sharded run), then a live
69
71
  * `token()` call. A strategy with no `token()` leaves auth unset and says so,
70
72
  * rather than silently continuing anonymous.
73
+ *
74
+ * Attaching the token is itself a choice of credential class, though, and it
75
+ * must not be made behind the scenario's back (#87). A `custom` strategy's
76
+ * `token()` can mint a personal API key for an app whose scenarios also sign
77
+ * in with a session; attached silently, such a scenario sends BOTH, and "a key
78
+ * is refused on a session-only route" passes on whichever one the server reads
79
+ * first. So `auth.apiToken` decides: `implicit` attaches here, `explicit` only
80
+ * leases and the scenario says `I authenticate the API with the leased user's
81
+ * token`. Unset, `custom` is explicit and every other strategy keeps the
82
+ * implicit behaviour #18 introduced — for `token` and
83
+ * `oauth-client-credentials` the token is the only credential there is.
84
+ * Either way the attached credential is logged at info.
71
85
  */
72
86
  Given('I use a leased user with role {string} for API calls', async ({ userPool, apiContext, auth, config, $testInfo }, role) => {
73
87
  const user = await userPool.lease(role, $testInfo.parallelIndex);
@@ -78,17 +92,56 @@ Given('I use a leased user with role {string} for API calls', async ({ userPool,
78
92
  role: user.role,
79
93
  ...user.extra,
80
94
  });
81
- const token = readCachedToken(config, user) ?? (await auth?.token?.({ config, user }));
82
- if (token)
83
- apiContext.auth = { type: 'bearer', token };
95
+ LEASED.set(apiContext, user);
96
+ if (apiTokenMode(config, auth) === 'explicit') {
97
+ log.info(`leased ${user.username} (${role}) for API calls; no credential attached ` +
98
+ `(auth.apiToken: explicit). Add "I authenticate the API with the leased user's token" to send its token.`);
99
+ return;
100
+ }
101
+ const found = await leasedUserToken(config, auth, user);
102
+ if (found) {
103
+ apiContext.auth = { type: 'bearer', token: found.token };
104
+ log.info(`API calls authenticate as ${user.username} (${role}): bearer token from ${found.source}.`);
105
+ }
84
106
  else if (auth?.strategy && auth.strategy !== 'none') {
85
- log.debug(`auth strategy "${auth.strategy}" provides no token() — API calls for ` +
107
+ log.info(`auth strategy "${auth.strategy}" provides no token() — API calls for ` +
86
108
  `${user.username} (${role}) use the environment credential.`);
87
109
  }
88
110
  });
89
- /** The token `sdods auth capture` wrote for this user, when it is still there. */
90
- function readCachedToken(config, user) {
111
+ Given("I authenticate the API with the leased user's token", async ({ apiContext, auth, config }) => {
112
+ const user = LEASED.get(apiContext);
113
+ if (!user) {
114
+ throw new SdodsError('AUTH_FAILED', 'No user has been leased in this scenario yet.', {
115
+ hint: 'Lease one first with `Given I use a leased user with role "<role>" for API calls` (or `I use a leased user with role "<role>"`).',
116
+ });
117
+ }
118
+ const found = await leasedUserToken(config, auth, user);
119
+ if (!found) {
120
+ throw new SdodsError('AUTH_FAILED', `No API token for ${user.username} (${user.role}): no cached token file and the "${auth?.strategy ?? 'none'}" auth strategy's token() returned none.`, {
121
+ hint: `Run \`sdods auth capture --user ${user.role}\` to cache one, or implement token() in the project's defineAuth.`,
122
+ });
123
+ }
124
+ apiContext.auth = { type: 'bearer', token: found.token };
125
+ log.info(`API calls authenticate as ${user.username} (${user.role}): bearer token from ${found.source}.`);
126
+ });
127
+ /** The user the scenario leased, so the explicit token step authenticates as that same account. */
128
+ const LEASED = new WeakMap();
129
+ function apiTokenMode(config, auth) {
130
+ return config.project.auth?.apiToken ?? (auth?.strategy === 'custom' ? 'explicit' : 'implicit');
131
+ }
132
+ /** The cached `sdods auth capture` token, else a live `token()` — with where it came from. */
133
+ async function leasedUserToken(config, auth, user) {
91
134
  const file = tokenFileFor(config, user);
135
+ const cached = readCachedToken(file);
136
+ if (cached)
137
+ return { token: cached, source: `cached token file ${file}` };
138
+ const minted = await auth?.token?.({ config, user });
139
+ return minted
140
+ ? { token: minted, source: `the "${auth?.strategy}" strategy's token()` }
141
+ : undefined;
142
+ }
143
+ /** The token `sdods auth capture` wrote for this user, when it is still there. */
144
+ function readCachedToken(file) {
92
145
  if (!existsSync(file))
93
146
  return undefined;
94
147
  try {
@@ -102,7 +155,7 @@ function readCachedToken(config, user) {
102
155
  }
103
156
  /* ── cleanup ──────────────────────────────────────────────────────────── */
104
157
  When('I register cleanup {method} {string}', async ({ data, api, apiContext, env }, method, path) => {
105
- const rendered = render(path, apiContext.vars.toObject(), env.vars);
158
+ const rendered = renderStrict(path, apiContext.vars.toObject(), env.vars);
106
159
  data.registerCleanup(async () => {
107
160
  await api.send(method, rendered, { silent: true });
108
161
  }, `${method} ${rendered}`);
@@ -1,7 +1,7 @@
1
1
  import { expect } from '@playwright/test';
2
2
  import './params.js';
3
3
  import { Then, When } from '../fixtures/test.js';
4
- import { render } from '../api/template.js';
4
+ import { renderStrict } from '../api/template.js';
5
5
  import { SdodsError } from '../errors.js';
6
6
  /**
7
7
  * Database assertions over the existing Kysely worker fixture.
@@ -63,28 +63,28 @@ async function rowsWhere(db, table, clause) {
63
63
  /* ── assertions ───────────────────────────────────────────────────────── */
64
64
  Then('the {string} table should have {int} row(s) where {string}', async ({ db, apiContext, env }, table, count, clause) => {
65
65
  const scopes = scopesOf(apiContext, env);
66
- const rows = await rowsWhere(requireDb(db), render(table, ...scopes), render(clause, ...scopes));
66
+ const rows = await rowsWhere(requireDb(db), renderStrict(table, ...scopes), renderStrict(clause, ...scopes));
67
67
  expect(rows, `${table} where ${clause}`).toHaveLength(count);
68
68
  });
69
69
  Then('the {string} table should have a row where {string}', async ({ db, apiContext, env }, table, clause) => {
70
70
  const scopes = scopesOf(apiContext, env);
71
- const rows = await rowsWhere(requireDb(db), render(table, ...scopes), render(clause, ...scopes));
71
+ const rows = await rowsWhere(requireDb(db), renderStrict(table, ...scopes), renderStrict(clause, ...scopes));
72
72
  expect(rows.length, `${table} where ${clause}`).toBeGreaterThan(0);
73
73
  });
74
74
  Then('the {string} table should have no row where {string}', async ({ db, apiContext, env }, table, clause) => {
75
75
  const scopes = scopesOf(apiContext, env);
76
- const rows = await rowsWhere(requireDb(db), render(table, ...scopes), render(clause, ...scopes));
76
+ const rows = await rowsWhere(requireDb(db), renderStrict(table, ...scopes), renderStrict(clause, ...scopes));
77
77
  expect(rows, `${table} where ${clause}`).toHaveLength(0);
78
78
  });
79
79
  Then('the {string} column of the row where {string} in {string} should be {string}', async ({ db, apiContext, env }, column, clause, table, value) => {
80
80
  const scopes = scopesOf(apiContext, env);
81
- const rows = await rowsWhere(requireDb(db), render(table, ...scopes), render(clause, ...scopes));
81
+ const rows = await rowsWhere(requireDb(db), renderStrict(table, ...scopes), renderStrict(clause, ...scopes));
82
82
  if (rows.length !== 1) {
83
83
  throw new SdodsError('CONFIG_INVALID', `Expected exactly one row in ${table} where ${clause}, found ${rows.length}.`, {
84
84
  hint: 'Narrow the condition. Asserting a column across several rows hides which one matched.',
85
85
  });
86
86
  }
87
- expect(String(rows[0][identifier('column', render(column, ...scopes))])).toBe(render(value, ...scopes));
87
+ expect(String(rows[0][identifier('column', renderStrict(column, ...scopes))])).toBe(renderStrict(value, ...scopes));
88
88
  });
89
89
  /**
90
90
  * Polling variant. A write that the API acknowledges is often applied
@@ -94,8 +94,8 @@ Then('the {string} column of the row where {string} in {string} should be {strin
94
94
  When('I wait for the {string} table to have a row where {string}', async ({ db, apiContext, env }, table, clause) => {
95
95
  const scopes = scopesOf(apiContext, env);
96
96
  const handle = requireDb(db);
97
- const t = render(table, ...scopes);
98
- const c = render(clause, ...scopes);
97
+ const t = renderStrict(table, ...scopes);
98
+ const c = renderStrict(clause, ...scopes);
99
99
  await expect
100
100
  .poll(async () => (await rowsWhere(handle, t, c)).length, {
101
101
  message: `${t} never got a row where ${c}`,