@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
@@ -1,25 +1,24 @@
1
1
  import { expect } from '@playwright/test';
2
2
  import './params.js';
3
3
  import { Given, Then, When } from '../fixtures/test.js';
4
+ import { applyAuth, encodeBody, resolveAuth, } from '../api/client.js';
4
5
  import { coerce, getPath } from '../api/json-path.js';
5
- import { render } from '../api/template.js';
6
+ import { render, renderStrict } from '../api/template.js';
6
7
  import { SdodsError } from '../errors.js';
7
8
  const scopesOf = (apiContext, env) => [
8
9
  apiContext.vars.toObject(),
9
10
  env.vars,
10
11
  ];
11
- /**
12
- * Render, then refuse a surviving `{{var}}`. Used for every argument whose non-resolution would
13
- * produce a PASS rather than a failure: negative assertions, URL globs and request paths.
14
- */
15
- function renderStrict(value, what, ...scopes) {
16
- const out = render(value, ...scopes);
17
- if (out.includes('{{')) {
18
- throw new SdodsError('CONFIG_UNRESOLVED_VAR', `${what} did not resolve: "${out}".`, {
19
- 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 — an unresolved placeholder here would make the assertion pass without testing anything.',
20
- });
12
+ /** `renderStrict`, with the message naming which argument held the unresolved variable. */
13
+ function renderArg(value, what, ...scopes) {
14
+ try {
15
+ return renderStrict(value, ...scopes);
16
+ }
17
+ catch (e) {
18
+ if (e instanceof SdodsError)
19
+ e.message = `${what}: ${e.message}`;
20
+ throw e;
21
21
  }
22
- return out;
23
22
  }
24
23
  /** Comma-separated Gherkin list → trimmed, rendered, non-empty items. */
25
24
  function renderList(raw, what, ...scopes) {
@@ -27,7 +26,7 @@ function renderList(raw, what, ...scopes) {
27
26
  .split(',')
28
27
  .map((s) => s.trim())
29
28
  .filter(Boolean)
30
- .map((s) => renderStrict(s, what, ...scopes));
29
+ .map((s) => renderArg(s, what, ...scopes));
31
30
  if (items.length === 0)
32
31
  throw new SdodsError('RUN_FAILED', `${what} is empty.`, {
33
32
  hint: 'List at least one comma-separated value; an empty list would make this assertion pass over nothing.',
@@ -70,28 +69,17 @@ function savedBodies(apiContext) {
70
69
  }
71
70
  return bag;
72
71
  }
73
- /** The auth the ApiClient would have applied, so a raw request is not silently anonymous. */
74
- function envAuth(auth) {
75
- switch (auth.type) {
76
- case 'bearer':
77
- return { type: 'bearer', token: auth.token };
78
- case 'basic':
79
- return { type: 'basic', username: auth.username, password: auth.password };
80
- case 'header':
81
- return { type: 'header', name: auth.name, value: auth.value };
82
- default:
83
- return undefined;
84
- }
85
- }
86
72
  /**
87
- * Mirrors `ApiClient.buildHeaders` — env headers, then the scenario's pending headers, then auth.
88
- * A raw request that dropped auth would report the anonymous redirect instead of the
89
- * authenticated one, which is the same class of defect as a step that does not interpolate.
73
+ * Mirrors `ApiClient.buildHeaders` — env headers, then the scenario's pending headers, then auth,
74
+ * resolved by the same function the client uses (so `I use no authentication` and every
75
+ * `env.api.auth` type behave identically here). A raw request that dropped auth would report the
76
+ * anonymous redirect instead of the authenticated one, which is the same class of defect as a step
77
+ * that does not interpolate.
90
78
  * `accept: application/json` is the default; override it with
91
79
  * `Given I set the request header "accept" to "text/html"` when the gate under test negotiates
92
80
  * on content type.
93
81
  */
94
- function rawHeaders(apiContext, env, hasBody, isForm) {
82
+ async function rawHeaders(apiContext, env, hasBody, isForm) {
95
83
  const headers = { accept: 'application/json' };
96
84
  for (const [k, v] of Object.entries(env.api.headers ?? {}))
97
85
  headers[k.toLowerCase()] = v;
@@ -99,17 +87,20 @@ function rawHeaders(apiContext, env, hasBody, isForm) {
99
87
  headers[k.toLowerCase()] = v;
100
88
  if (hasBody && !isForm && !headers['content-type'])
101
89
  headers['content-type'] = 'application/json';
102
- const auth = apiContext.auth ?? envAuth(env.api.auth);
103
- if (auth) {
104
- if (auth.type === 'bearer')
105
- headers.authorization = `Bearer ${auth.token}`;
106
- else if (auth.type === 'basic')
107
- headers.authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`;
108
- else if (auth.type === 'header')
109
- headers[auth.name.toLowerCase()] = auth.value;
110
- }
90
+ applyAuth(headers, await resolveAuth(undefined, apiContext.auth, env.api.auth));
111
91
  return headers;
112
92
  }
93
+ /** The shared `request` fixture, or the isolated context after `I use an isolated API client`. */
94
+ async function rawRequest(deps) {
95
+ if (!deps.apiContext.isolated)
96
+ return deps.request;
97
+ if (!deps.api) {
98
+ throw new SdodsError('NOT_SUPPORTED', 'No isolated request context is available here.', {
99
+ hint: 'Raw requests reach the isolated client through the `api` fixture; pass it in the step fixtures.',
100
+ });
101
+ }
102
+ return deps.api.requestContext();
103
+ }
113
104
  /** Mirrors `ApiClient.resolveUrl`, so `Given I set the query parameter …` applies here too. */
114
105
  function rawUrl(apiContext, env, pathOrUrl) {
115
106
  const base = env.api.baseUrl.replace(/\/+$/, '');
@@ -126,11 +117,13 @@ function rawUrl(apiContext, env, pathOrUrl) {
126
117
  */
127
118
  export async function sendRaw(deps, method, pathOrUrl, opts = {}) {
128
119
  const url = rawUrl(deps.apiContext, deps.env, pathOrUrl);
129
- const headers = rawHeaders(deps.apiContext, deps.env, opts.body !== undefined || opts.form !== undefined, opts.form !== undefined);
130
- const res = await deps.request.fetch(url, {
120
+ const headers = await rawHeaders(deps.apiContext, deps.env, opts.body !== undefined || opts.form !== undefined, opts.form !== undefined);
121
+ const request = await rawRequest(deps);
122
+ const res = await request.fetch(url, {
131
123
  method,
132
124
  headers,
133
- data: opts.form ? undefined : opts.body,
125
+ // A Buffer, so Playwright does not JSON-encode a body that does not parse.
126
+ data: opts.form ? undefined : encodeBody(opts.body),
134
127
  form: opts.form,
135
128
  maxRedirects: 0,
136
129
  failOnStatusCode: false,
@@ -173,25 +166,25 @@ const SECRET_HEADERS = /^(authorization|cookie|set-cookie|x-api-key|proxy-author
173
166
  export function redactForReport(headers) {
174
167
  return Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, SECRET_HEADERS.test(k) ? '***' : v]));
175
168
  }
176
- When('I send a {method} request to {string} without following redirects', async ({ request, apiContext, env, config, $testInfo }, method, path) => {
169
+ When('I send a {method} request to {string} without following redirects', async ({ request, api, apiContext, env, config, $testInfo }, method, path) => {
177
170
  // PROVES the edge itself answered. The built-in step would follow the 3xx and report the
178
171
  // page it landed on, so an auth/locale/maintenance gate is unobservable through it.
179
- await sendRaw({ request, apiContext, env, config, testInfo: $testInfo }, method, renderStrict(path, 'the request path', ...scopesOf(apiContext, env)));
172
+ await sendRaw({ request, api, apiContext, env, config, testInfo: $testInfo }, method, renderArg(path, 'the request path', ...scopesOf(apiContext, env)));
180
173
  });
181
- When('I send a {method} request to {string} without following redirects with body:', async ({ request, apiContext, env, config, $testInfo }, method, path, body) => {
174
+ When('I send a {method} request to {string} without following redirects with body:', async ({ request, api, apiContext, env, config, $testInfo }, method, path, body) => {
182
175
  // Body stays TEXT, never re-serialised JSON: the byte-identical comparison below is only an
183
176
  // oracle for enumeration safety if nothing normalises whitespace or key order on the way in.
184
177
  const scopes = scopesOf(apiContext, env);
185
- await sendRaw({ request, apiContext, env, config, testInfo: $testInfo }, method, renderStrict(path, 'the request path', ...scopes), { body: render(body, ...scopes) });
178
+ await sendRaw({ request, api, apiContext, env, config, testInfo: $testInfo }, method, renderArg(path, 'the request path', ...scopes), { body: render(body, ...scopes) });
186
179
  });
187
- When('I send a {method} request to {string} without following redirects with form:', async ({ request, apiContext, env, config, $testInfo }, method, path, table) => {
180
+ When('I send a {method} request to {string} without following redirects with form:', async ({ request, api, apiContext, env, config, $testInfo }, method, path, table) => {
188
181
  // PROVES a form POST's gate. Most real redirect gates — sign-in, consent, locale switch —
189
182
  // are urlencoded form posts, so a JSON-only raw request cannot reach the common case.
190
183
  const scopes = scopesOf(apiContext, env);
191
184
  const form = {};
192
185
  for (const row of table.raw())
193
- form[String(row[0] ?? '')] = render(String(row[1] ?? ''), ...scopes);
194
- await sendRaw({ request, apiContext, env, config, testInfo: $testInfo }, method, renderStrict(path, 'the request path', ...scopes), { form });
186
+ form[String(row[0] ?? '')] = renderStrict(String(row[1] ?? ''), ...scopes);
187
+ await sendRaw({ request, api, apiContext, env, config, testInfo: $testInfo }, method, renderArg(path, 'the request path', ...scopes), { form });
195
188
  });
196
189
  Then('the raw response status should be {int}', async ({ apiContext }, status) => {
197
190
  // PROVES the gate fired at this hop. A 307 that a following client turns into a 200 is the
@@ -215,39 +208,39 @@ Then('the raw response Location should be {string}', async ({ apiContext, env },
215
208
  // Exact, not substring: the surviving return-to/callback parameter IS the assertion, and a
216
209
  // `contains` check would pass on a truncated one that silently drops where the user came from.
217
210
  const snap = rawOf(apiContext);
218
- expect(snap.headers.location ?? '(no Location header)', `Location of ${snap.url}`).toBe(render(expected, ...scopesOf(apiContext, env)));
211
+ expect(snap.headers.location ?? '(no Location header)', `Location of ${snap.url}`).toBe(renderStrict(expected, ...scopesOf(apiContext, env)));
219
212
  });
220
213
  Then('the raw response Location should contain {string}', async ({ apiContext, env }, expected) => {
221
214
  const snap = rawOf(apiContext);
222
- expect(snap.headers.location ?? '(no Location header)', `Location of ${snap.url}`).toContain(render(expected, ...scopesOf(apiContext, env)));
215
+ expect(snap.headers.location ?? '(no Location header)', `Location of ${snap.url}`).toContain(renderStrict(expected, ...scopesOf(apiContext, env)));
223
216
  });
224
217
  Then('the raw response header {string} should contain {string}', async ({ apiContext, env }, name, value) => {
225
218
  const scopes = scopesOf(apiContext, env);
226
- const wanted = render(name, ...scopes);
219
+ const wanted = renderStrict(name, ...scopes);
227
220
  const snap = rawOf(apiContext);
228
- expect(snap.headers[wanted.toLowerCase()] ?? `(no ${wanted} header)`, `${wanted} of ${snap.url}`).toContain(render(value, ...scopes));
221
+ expect(snap.headers[wanted.toLowerCase()] ?? `(no ${wanted} header)`, `${wanted} of ${snap.url}`).toContain(renderStrict(value, ...scopes));
229
222
  });
230
223
  Then('the raw response should have no {string} header', async ({ apiContext, env }, name) => {
231
224
  // The deny direction, and NOT the same as "does not contain": an `Allow` header present but
232
225
  // empty, or a `Set-Cookie` present but rejected, are both absences a contains-check would miss.
233
- const wanted = renderStrict(name, 'the header name', ...scopesOf(apiContext, env));
226
+ const wanted = renderArg(name, 'the header name', ...scopesOf(apiContext, env));
234
227
  const snap = rawOf(apiContext);
235
228
  expect(snap.headers[wanted.toLowerCase()], `unexpected ${wanted} header on ${snap.url}`).toBeUndefined();
236
229
  });
237
230
  Then('the raw response body should contain {string}', async ({ apiContext, env }, text) => {
238
- expect(rawOf(apiContext).body).toContain(render(text, ...scopesOf(apiContext, env)));
231
+ expect(rawOf(apiContext).body).toContain(renderStrict(text, ...scopesOf(apiContext, env)));
239
232
  });
240
233
  Then('the raw response should set the {string} cookie', async ({ apiContext, env }, name) => {
241
234
  // PROVES the credential was actually issued. Unreachable through the built-in header step,
242
235
  // which sees `set-cookie: ***` because the ApiSnapshot redacts it.
243
- const wanted = renderStrict(name, 'the cookie name', ...scopesOf(apiContext, env));
236
+ const wanted = renderArg(name, 'the cookie name', ...scopesOf(apiContext, env));
244
237
  const snap = rawOf(apiContext);
245
238
  expect(snap.setCookies.some((c) => c.startsWith(`${wanted}=`)), `Set-Cookie for "${wanted}" (got ${JSON.stringify(snap.setCookies.map((c) => c.split('=')[0]))})`).toBe(true);
246
239
  });
247
240
  Then('the raw response should not set the {string} cookie', async ({ apiContext, env }, name) => {
248
241
  // PROVES a refusal left no credential behind. A `Max-Age=0` line is a deletion, not a grant,
249
242
  // so it must not count as one — otherwise a correct sign-out would read as a session leak.
250
- const wanted = renderStrict(name, 'the cookie name', ...scopesOf(apiContext, env));
243
+ const wanted = renderArg(name, 'the cookie name', ...scopesOf(apiContext, env));
251
244
  const snap = rawOf(apiContext);
252
245
  expect(snap.setCookies.filter((c) => c.startsWith(`${wanted}=`) && !isCleared(c)), `no Set-Cookie granting "${wanted}"`).toEqual([]);
253
246
  });
@@ -255,8 +248,8 @@ Then('the raw response cookie {string} should carry {string}', async ({ apiConte
255
248
  // HttpOnly / Secure / SameSite=Lax / Path=/ — the session contract every downstream gate
256
249
  // reads. Matched case-insensitively because servers disagree on the casing of attributes.
257
250
  const scopes = scopesOf(apiContext, env);
258
- const wanted = renderStrict(name, 'the cookie name', ...scopes);
259
- const attr = render(attribute, ...scopes);
251
+ const wanted = renderArg(name, 'the cookie name', ...scopes);
252
+ const attr = renderStrict(attribute, ...scopes);
260
253
  const cookie = rawOf(apiContext).setCookies.find((c) => c.startsWith(`${wanted}=`));
261
254
  expect(cookie, `Set-Cookie for "${wanted}"`).toBeTruthy();
262
255
  expect(String(cookie).toLowerCase(), `attributes of the "${wanted}" cookie`).toContain(attr.toLowerCase());
@@ -264,7 +257,7 @@ Then('the raw response cookie {string} should carry {string}', async ({ apiConte
264
257
  Then('the raw response cookie {string} should be cleared', async ({ apiContext, env }, name) => {
265
258
  // PROVES sign-out server-side: a deletion is a Set-Cookie with an immediate expiry, which is
266
259
  // what "the browser no longer holds the credential" actually means over the wire.
267
- const wanted = renderStrict(name, 'the cookie name', ...scopesOf(apiContext, env));
260
+ const wanted = renderArg(name, 'the cookie name', ...scopesOf(apiContext, env));
268
261
  const cookie = rawOf(apiContext).setCookies.find((c) => c.startsWith(`${wanted}=`));
269
262
  expect(cookie, `Set-Cookie clearing "${wanted}"`).toBeTruthy();
270
263
  expect(isCleared(String(cookie)), `expiry of the "${wanted}" cookie`).toBe(true);
@@ -325,32 +318,33 @@ async function captureDownload(page, timeout, click) {
325
318
  When('I click the {string} {role} and capture the download', async ({ page, heal, config, apiContext, env }, name, role) => {
326
319
  // PROVES an export produced a file at all. Without this the whole export feature can only be
327
320
  // asserted as "the click did not throw", which is true of a button wired to nothing.
328
- const n = render(name, ...scopesOf(apiContext, env));
321
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
329
322
  const loc = await heal.resolve(page.getByRole(role, { name: n }), { role: role, name: n, text: n, description: `${role} "${n}"` }, 'click');
330
323
  await captureDownload(page, config.project.timeouts.navigation, () => loc.click());
331
324
  });
332
- When('I click the element with test id {string} and capture the download', async ({ page, heal, config }, id) => {
333
- const loc = await heal.resolve(page.getByTestId(id), { testId: id, description: `test id "${id}"` }, 'click');
325
+ When('I click the element with test id {string} and capture the download', async ({ page, heal, config, apiContext, env }, id) => {
326
+ const testId = renderStrict(id, ...scopesOf(apiContext, env));
327
+ const loc = await heal.resolve(page.getByTestId(testId), { testId, description: `test id "${testId}"` }, 'click');
334
328
  await captureDownload(page, config.project.timeouts.navigation, () => loc.click());
335
329
  });
336
330
  Then('the downloaded file name should end with {string}', async ({ page, apiContext, env }, suffix) => {
337
331
  // `endsWith`, not `contains`: a ".json" that appears mid-name (report.json.txt) is the exact
338
332
  // mis-typed export this assertion exists to catch.
339
- const wanted = render(suffix, ...scopesOf(apiContext, env));
333
+ const wanted = renderStrict(suffix, ...scopesOf(apiContext, env));
340
334
  const actual = downloadOf(page).suggestedFilename();
341
335
  expect(actual.endsWith(wanted), `download filename "${actual}" should end with "${wanted}"`).toBe(true);
342
336
  });
343
337
  Then('the downloaded file name should contain {string}', async ({ page, apiContext, env }, part) => {
344
- expect(downloadOf(page).suggestedFilename(), 'download filename').toContain(render(part, ...scopesOf(apiContext, env)));
338
+ expect(downloadOf(page).suggestedFilename(), 'download filename').toContain(renderStrict(part, ...scopesOf(apiContext, env)));
345
339
  });
346
340
  Then('the downloaded text first line should be {string}', async ({ page, apiContext, env }, expected) => {
347
341
  // PROVES the header row of a CSV export matches the columns the UI promised. Exact, because a
348
342
  // reordered or renamed column silently breaks every consumer of the file.
349
343
  const text = await downloadedText(page);
350
- expect(text.split(/\r?\n/)[0] ?? '', 'first line of the downloaded file').toBe(render(expected, ...scopesOf(apiContext, env)));
344
+ expect(text.split(/\r?\n/)[0] ?? '', 'first line of the downloaded file').toBe(renderStrict(expected, ...scopesOf(apiContext, env)));
351
345
  });
352
346
  Then('the downloaded text should contain {string}', async ({ page, apiContext, env }, text) => {
353
- expect(await downloadedText(page), 'downloaded file').toContain(render(text, ...scopesOf(apiContext, env)));
347
+ expect(await downloadedText(page), 'downloaded file').toContain(renderStrict(text, ...scopesOf(apiContext, env)));
354
348
  });
355
349
  Then('the downloaded text should have at least {int} data rows', async ({ page }, min) => {
356
350
  expect(dataRows(await downloadedText(page)), 'data rows in the downloaded file').toBeGreaterThanOrEqual(min);
@@ -380,7 +374,7 @@ Then('the downloaded JSON path {string} should equal {string}', async ({ page, a
380
374
  // PROVES the export/import round trip, not just that a file arrived: a scenario that can
381
375
  // export but never read what it exported proves only that a click did not throw.
382
376
  const actual = getPath(await downloadedJson(page), path);
383
- expect(actual, `downloaded JSON path ${path}`).toEqual(coerce(render(expected, ...scopesOf(apiContext, env))));
377
+ expect(actual, `downloaded JSON path ${path}`).toEqual(coerce(renderStrict(expected, ...scopesOf(apiContext, env))));
384
378
  });
385
379
  Then('the downloaded JSON path {string} should have {int} items', async ({ page }, path, count) => {
386
380
  // Arrays and keyed records both count, because exports model collections either way; anything
@@ -446,16 +440,17 @@ function streamOf(apiContext) {
446
440
  return capture;
447
441
  }
448
442
  async function readStream(deps, method, pathOrUrl, body) {
449
- const headers = rawHeaders(deps.apiContext, deps.env, body !== undefined, false);
443
+ const headers = await rawHeaders(deps.apiContext, deps.env, body !== undefined, false);
450
444
  headers.accept = 'text/event-stream';
451
445
  const url = rawUrl(deps.apiContext, deps.env, pathOrUrl);
452
446
  const timeout = STREAM_TIMEOUT.get(deps.apiContext) ?? deps.config.project.timeouts.api;
447
+ const request = await rawRequest(deps);
453
448
  let capture;
454
449
  try {
455
- const res = await deps.request.fetch(url, {
450
+ const res = await request.fetch(url, {
456
451
  method,
457
452
  headers,
458
- data: body,
453
+ data: encodeBody(body),
459
454
  maxRedirects: 0,
460
455
  failOnStatusCode: false,
461
456
  timeout,
@@ -495,12 +490,12 @@ Given('I allow {int} seconds for the event stream', async ({ apiContext }, secon
495
490
  // exactly like a product bug — the worst kind of false failure.
496
491
  STREAM_TIMEOUT.set(apiContext, seconds * 1000);
497
492
  });
498
- When('I read the event stream from a {method} request to {string}', async ({ request, apiContext, env, config, $testInfo }, method, path) => {
499
- await readStream({ request, apiContext, env, config, testInfo: $testInfo }, method, renderStrict(path, 'the request path', ...scopesOf(apiContext, env)));
493
+ When('I read the event stream from a {method} request to {string}', async ({ request, api, apiContext, env, config, $testInfo }, method, path) => {
494
+ await readStream({ request, api, apiContext, env, config, testInfo: $testInfo }, method, renderArg(path, 'the request path', ...scopesOf(apiContext, env)));
500
495
  });
501
- When('I read the event stream from a {method} request to {string} with body:', async ({ request, apiContext, env, config, $testInfo }, method, path, body) => {
496
+ When('I read the event stream from a {method} request to {string} with body:', async ({ request, api, apiContext, env, config, $testInfo }, method, path, body) => {
502
497
  const scopes = scopesOf(apiContext, env);
503
- await readStream({ request, apiContext, env, config, testInfo: $testInfo }, method, renderStrict(path, 'the request path', ...scopes), render(body, ...scopes));
498
+ await readStream({ request, api, apiContext, env, config, testInfo: $testInfo }, method, renderArg(path, 'the request path', ...scopes), render(body, ...scopes));
504
499
  });
505
500
  Then('the stream status should be {int}', async ({ apiContext }, status) => {
506
501
  const capture = streamOf(apiContext);
@@ -510,7 +505,7 @@ Then('the stream content type should be {string}', async ({ apiContext, env }, e
510
505
  // PROVES the endpoint actually streamed. An endpoint that quietly fell back to a buffered
511
506
  // JSON response still returns 200, and every event assertion below would then fail
512
507
  // confusingly.
513
- expect(streamOf(apiContext).contentType, 'stream content type').toContain(render(expected, ...scopesOf(apiContext, env)));
508
+ expect(streamOf(apiContext).contentType, 'stream content type').toContain(renderStrict(expected, ...scopesOf(apiContext, env)));
514
509
  });
515
510
  Then('the stream should have at least {int} events', async ({ apiContext }, min) => {
516
511
  if (min < 1)
@@ -522,14 +517,14 @@ Then('the stream should have at least {int} events', async ({ apiContext }, min)
522
517
  Then('the first stream event type should be {string}', async ({ apiContext, env }, expected) => {
523
518
  const { events } = streamOf(apiContext);
524
519
  expect(events.length, 'the stream carried no events at all').toBeGreaterThan(0);
525
- expect(events[0]?.type, 'first stream event type').toBe(render(expected, ...scopesOf(apiContext, env)));
520
+ expect(events[0]?.type, 'first stream event type').toBe(renderStrict(expected, ...scopesOf(apiContext, env)));
526
521
  });
527
522
  Then('the last stream event type should be {string}', async ({ apiContext, env }, expected) => {
528
523
  // PROVES the turn finished. A streaming endpoint returns 200 on its first byte, so the status
529
524
  // says nothing about whether the model/producer ever reached its terminal event.
530
525
  const { events } = streamOf(apiContext);
531
526
  expect(events.length, 'the stream carried no events at all').toBeGreaterThan(0);
532
- expect(events[events.length - 1]?.type, 'last stream event type — a 200 that never reaches this is a stream that died mid-flight').toBe(render(expected, ...scopesOf(apiContext, env)));
527
+ expect(events[events.length - 1]?.type, 'last stream event type — a 200 that never reaches this is a stream that died mid-flight').toBe(renderStrict(expected, ...scopesOf(apiContext, env)));
533
528
  });
534
529
  Then('the stream should have terminated cleanly', async ({ apiContext }) => {
535
530
  // Three clauses, and all three are load-bearing: no transport error, at least one frame parsed
@@ -572,7 +567,7 @@ export function globToRegExp(glob) {
572
567
  * whole section exists to prevent.
573
568
  */
574
569
  function urlGlob(raw, scopes) {
575
- const pattern = renderStrict(raw, 'the URL pattern', ...scopes);
570
+ const pattern = renderArg(raw, 'the URL pattern', ...scopes);
576
571
  if (!/[*?]/.test(pattern) && !/^(https?:\/\/|\/)/.test(pattern))
577
572
  throw new SdodsError('RUN_FAILED', `"${pattern}" cannot match a request URL.`, {
578
573
  hint: 'These steps use Playwright route globs, matched against the whole URL. Write "**analytics**" for a fragment, "**/api/users" for a path, or a full URL.',
@@ -611,10 +606,10 @@ When('I start counting requests to {string}', async ({ page, apiContext, env },
611
606
  // Start it before navigating if the initial page load's requests are part of the count.
612
607
  urlGlob(glob, scopesOf(apiContext, env));
613
608
  const log = ensureLog(page);
614
- log.counters.set(render(glob, ...scopesOf(apiContext, env)), log.entries.length);
609
+ log.counters.set(renderStrict(glob, ...scopesOf(apiContext, env)), log.entries.length);
615
610
  });
616
611
  function counted(page, glob, scopes, why) {
617
- const rendered = renderStrict(glob, 'the URL pattern', ...scopes);
612
+ const rendered = renderArg(glob, 'the URL pattern', ...scopes);
618
613
  const log = logOf(page, why);
619
614
  const from = log.counters.get(rendered);
620
615
  if (from === undefined)
@@ -664,13 +659,13 @@ Then('the response should have no {string} header', async ({ apiContext, env },
664
659
  // Absence, which is NOT "does not contain": a header present with an empty or unexpected value
665
660
  // satisfies a contains-check written as a negative, and this is the assertion behind
666
661
  // "no cache header was set" and "the debug header never reaches production".
667
- const wanted = renderStrict(name, 'the header name', ...scopesOf(apiContext, env));
662
+ const wanted = renderArg(name, 'the header name', ...scopesOf(apiContext, env));
668
663
  expect(apiContext.last().response.headers[wanted.toLowerCase()], `unexpected ${wanted} header`).toBeUndefined();
669
664
  });
670
665
  Then('the response JSON path {string} should not exist', async ({ apiContext, env }, path) => {
671
666
  // PROVES a field was withheld — the password hash, the internal id, the other tenant's row.
672
667
  // Strict rendering matters most here: an unresolved path would be absent by construction.
673
- const wanted = renderStrict(path, 'the JSON path', ...scopesOf(apiContext, env));
668
+ const wanted = renderArg(path, 'the JSON path', ...scopesOf(apiContext, env));
674
669
  expect(getPath(apiContext.last().response.body, wanted), `JSON path ${wanted}`).toBeUndefined();
675
670
  });
676
671
  Then('the response should not contain any of {string}', async ({ apiContext, env }, list) => {
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test';
3
3
  import './params.js';
4
4
  import { Then, When } from '../fixtures/test.js';
5
5
  import { attachmentNames, pad2, scenarioFiles } from '@sdods/contracts';
6
- import { render, renderJson } from '../api/template.js';
6
+ import { renderJson, renderStrict } from '../api/template.js';
7
7
  import { SdodsError } from '../errors.js';
8
8
  /**
9
9
  * Performance budgets.
@@ -323,7 +323,7 @@ When('I record the page vitals', recordPageVitals);
323
323
  */
324
324
  export const assertVitalWithinBudget = async ({ scenario, config, apiContext, env }, metricName) => {
325
325
  const recording = lastRecording(scenario);
326
- const metric = requireVitalKey(render(metricName, apiContext.vars.toObject(), env.vars));
326
+ const metric = requireVitalKey(renderStrict(metricName, apiContext.vars.toObject(), env.vars));
327
327
  const budget = requireBudget(config, metric);
328
328
  const value = measured(recording, metric);
329
329
  expect(value, `${metric} measured ${value} ms on ${recording.url}, against a budget of ${budget} ms for environment "${config.env.name ?? 'unknown'}"`).toBeLessThanOrEqual(budget);
@@ -336,7 +336,7 @@ Then('the recorded {string} should be within its configured budget', assertVital
336
336
  */
337
337
  export const assertVitalUnderThreshold = async ({ scenario, apiContext, env }, metricName, maxMs) => {
338
338
  const recording = lastRecording(scenario);
339
- const metric = requireVitalKey(render(metricName, apiContext.vars.toObject(), env.vars));
339
+ const metric = requireVitalKey(renderStrict(metricName, apiContext.vars.toObject(), env.vars));
340
340
  const value = measured(recording, metric);
341
341
  expect(value, `${metric} measured ${value} ms on ${recording.url}, against a scenario-local limit of ${maxMs} ms`).toBeLessThan(maxMs);
342
342
  };
@@ -351,7 +351,7 @@ Then('the recorded {string} should be under {int} ms', assertVitalUnderThreshold
351
351
  * recording with itself always passes.
352
352
  */
353
353
  export const assertVitalNoWorseThanPrevious = async ({ scenario, apiContext, env }, metricName, toleranceMs) => {
354
- const metric = requireVitalKey(render(metricName, apiContext.vars.toObject(), env.vars));
354
+ const metric = requireVitalKey(renderStrict(metricName, apiContext.vars.toObject(), env.vars));
355
355
  const recordings = perfStore(scenario).recordings;
356
356
  if (recordings.length < 2) {
357
357
  throw new SdodsError('RUN_FAILED', `Only ${recordings.length} page-vitals recording(s) in this scenario; comparing needs two.`, {
@@ -372,7 +372,7 @@ Then('the recorded {string} should be no worse than the previous recording plus
372
372
  * gap fails loudly, once, naming the key.
373
373
  */
374
374
  export const assertBudgetsConfigured = async ({ config, apiContext, env }, names) => {
375
- const wanted = render(names, apiContext.vars.toObject(), env.vars)
375
+ const wanted = renderStrict(names, apiContext.vars.toObject(), env.vars)
376
376
  .split(/[,\s]+/)
377
377
  .filter(Boolean);
378
378
  if (wanted.length === 0) {
@@ -405,7 +405,7 @@ async function collectSamples(fx, method, pathTemplate, times, bodyTemplate) {
405
405
  });
406
406
  }
407
407
  const scopes = [fx.apiContext.vars.toObject(), fx.env.vars];
408
- const path = render(pathTemplate, ...scopes);
408
+ const path = renderStrict(pathTemplate, ...scopes);
409
409
  const body = bodyTemplate === undefined ? undefined : renderJson(bodyTemplate, ...scopes);
410
410
  const samples = [];
411
411
  for (let i = 0; i < times; i++) {
@@ -1,7 +1,7 @@
1
1
  import { expect } from '@playwright/test';
2
2
  import './params.js';
3
3
  import { Given, 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
  * Multi-tab and popup steps.
@@ -44,16 +44,16 @@ async function openedBy(page, action, what) {
44
44
  }
45
45
  /* ── opening ──────────────────────────────────────────────────────────── */
46
46
  When('I open a new tab by clicking the {role} {string}', async ({ page, apiContext, env }, role, name) => {
47
- const label = render(name, ...scopesOf(apiContext, env));
47
+ const label = renderStrict(name, ...scopesOf(apiContext, env));
48
48
  await openedBy(page, () => activePage(page).getByRole(role, { name: label }).first().click(), `clicking the ${role} "${label}"`);
49
49
  });
50
50
  When('I open a new tab by clicking the element {string}', async ({ page, apiContext, env }, selector) => {
51
- const sel = render(selector, ...scopesOf(apiContext, env));
51
+ const sel = renderStrict(selector, ...scopesOf(apiContext, env));
52
52
  await openedBy(page, () => activePage(page).locator(sel).first().click(), `clicking "${sel}"`);
53
53
  });
54
54
  /* ── switching ────────────────────────────────────────────────────────── */
55
55
  Given('I switch to the tab with URL containing {string}', async ({ page, apiContext, env }, part) => {
56
- const needle = render(part, ...scopesOf(apiContext, env));
56
+ const needle = renderStrict(part, ...scopesOf(apiContext, env));
57
57
  const found = page
58
58
  .context()
59
59
  .pages()
@@ -96,14 +96,14 @@ Then('there should be {int} open tab(s)', async ({ page }, count) => {
96
96
  .toBe(count);
97
97
  });
98
98
  Then('the current tab URL should contain {string}', async ({ page, apiContext, env }, part) => {
99
- const needle = render(part, ...scopesOf(apiContext, env));
99
+ const needle = renderStrict(part, ...scopesOf(apiContext, env));
100
100
  await expect
101
101
  .poll(() => activePage(page).url(), { message: `the tab URL should contain "${needle}"` })
102
102
  .toContain(needle);
103
103
  });
104
104
  Then('the current tab should contain the text {string}', async ({ page, apiContext, env }, text) => {
105
105
  await expect(activePage(page)
106
- .getByText(render(text, ...scopesOf(apiContext, env)))
106
+ .getByText(renderStrict(text, ...scopesOf(apiContext, env)))
107
107
  .first()).toBeVisible();
108
108
  });
109
109
  //# sourceMappingURL=tabs.steps.js.map