@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,19 +1,19 @@
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
  * DOM assertions the rest of the library cannot express.
8
8
  *
9
- * The library ships exactly one negative UI step (`I should not see the text`), so the deny
9
+ * Before this file the library's only negative UI step was `I should not see the text`, so the deny
10
10
  * direction of an authorisation scenario — the only direction worth asserting against a
11
11
  * default-allow policy — is unwritable. These steps close that, plus the ordinal, counting,
12
12
  * focus and overlay vocabulary that a real onboarding had to hand-write.
13
13
  *
14
14
  * Two conventions hold throughout this file, both of them load-bearing:
15
15
  *
16
- * 1. EVERY string argument goes through `render()`, ids and role names included. A step that
16
+ * 1. EVERY string argument goes through `renderStrict()`, ids and role names included. A step that
17
17
  * silently drops `{{vars}}` fails much later, in a scenario that looks unrelated; rendering a
18
18
  * string with no placeholder in it is a no-op, so there is no cost to doing it everywhere.
19
19
  *
@@ -164,24 +164,24 @@ function bag(key) {
164
164
  // Proves the element is not on screen. Passes when it is absent too, because "a user cannot see
165
165
  // it" is the invariant — use the "should not exist" form when the DOM node itself is the point.
166
166
  Then('the element with test id {string} should not be visible', async ({ page, apiContext, env }, id) => {
167
- await expect(page.getByTestId(render(id, ...scopesOf(apiContext, env)))).toBeHidden();
167
+ await expect(page.getByTestId(renderStrict(id, ...scopesOf(apiContext, env)))).toBeHidden();
168
168
  });
169
169
  // Proves the node was never rendered — not merely hidden with CSS. The distinction is the point of
170
170
  // a deny: a control the server refused to send is a different posture from one the client chose
171
171
  // not to paint, which is one CSS toggle or one devtools edit away from being usable.
172
172
  Then('the element with test id {string} should not exist', async ({ page, apiContext, env }, id) => {
173
- await expect(page.getByTestId(render(id, ...scopesOf(apiContext, env)))).toHaveCount(0);
173
+ await expect(page.getByTestId(renderStrict(id, ...scopesOf(apiContext, env)))).toHaveCount(0);
174
174
  });
175
175
  // Proves a named control is not on screen — the deny half of `the {string} {role} should be
176
176
  // visible`, which had no counterpart.
177
177
  Then('the {string} {role} should not be visible', async ({ page, apiContext, env }, name, role) => {
178
- const n = render(name, ...scopesOf(apiContext, env));
178
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
179
179
  await expect(page.getByRole(role, { name: n })).toBeHidden();
180
180
  });
181
181
  // Proves the app rendered no such control at all. Distinct from "not visible": this is what an
182
182
  // authorisation scenario wants when the claim is that the affordance was never offered.
183
183
  Then('no {string} {role} should exist', async ({ page, apiContext, env }, name, role) => {
184
- const n = render(name, ...scopesOf(apiContext, env));
184
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
185
185
  await expect(page.getByRole(role, { name: n })).toHaveCount(0);
186
186
  });
187
187
  // Proves the absence is the SETTLED state, not the loading state. This is the trap that makes
@@ -190,7 +190,7 @@ Then('no {string} {role} should exist', async ({ page, apiContext, env }, name,
190
190
  // passes for everyone as long as it lands first. Holding it across a window outlives the fetch.
191
191
  Then('the {string} {role} should stay absent for {int} seconds', async ({ page, apiContext, env }, name, role, seconds) => {
192
192
  assertPositiveDuration(seconds);
193
- const n = render(name, ...scopesOf(apiContext, env));
193
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
194
194
  const locator = page.getByRole(role, { name: n });
195
195
  const deadline = Date.now() + seconds * 1000;
196
196
  do {
@@ -205,7 +205,7 @@ Then('the {string} {role} should stay absent for {int} seconds', async ({ page,
205
205
  // placeholder, a title or alt. `I should see the text` resolves through `getByText`, which reads
206
206
  // text nodes only, so a value prefilled into a field is invisible to it.
207
207
  Then('the text {string} should appear anywhere on the page', async ({ page, apiContext, env }, text) => {
208
- const wanted = render(text, ...scopesOf(apiContext, env));
208
+ const wanted = renderStrict(text, ...scopesOf(apiContext, env));
209
209
  await expect
210
210
  .poll(async () => readableText(page), { message: `page never rendered "${wanted}"` })
211
211
  .toContain(wanted);
@@ -215,7 +215,7 @@ Then('the text {string} should appear anywhere on the page', async ({ page, apiC
215
215
  // The blank-page guard is the point: on a surface that rendered nothing this check would pass for
216
216
  // every string in existence, which is the vacuous pass this library refuses to ship.
217
217
  Then('the text {string} should not appear anywhere on the page', async ({ page, apiContext, env }, text) => {
218
- const unwanted = render(text, ...scopesOf(apiContext, env));
218
+ const unwanted = renderStrict(text, ...scopesOf(apiContext, env));
219
219
  const haystack = await readableText(page);
220
220
  if (haystack.length === 0) {
221
221
  throw new SdodsError('RUN_FAILED', 'The page has rendered no readable text at all.', {
@@ -228,14 +228,14 @@ Then('the text {string} should not appear anywhere on the page', async ({ page,
228
228
  // Proves the control is offered AND actionable. Healed, on the same terms as the built-in
229
229
  // visibility assertion — this is the allow direction. The deny direction below is not healed.
230
230
  Then('the {string} {role} should be enabled', async ({ page, heal, apiContext, env }, name, role) => {
231
- const n = render(name, ...scopesOf(apiContext, env));
231
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
232
232
  const locator = await heal.resolve(page.getByRole(role, { name: n }), { role: role, name: n, text: n, description: `${role} "${n}"` }, 'assert');
233
233
  await expect(locator).toBeEnabled();
234
234
  });
235
235
  // Proves the control is rendered but refuses to act. NOT healed, deliberately: this is a deny
236
236
  // assertion, and healing onto a different, disabled element would fabricate the pass.
237
237
  Then('the {string} {role} should be disabled', async ({ page, apiContext, env }, name, role) => {
238
- const n = render(name, ...scopesOf(apiContext, env));
238
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
239
239
  await expect(page.getByRole(role, { name: n })).toBeDisabled();
240
240
  });
241
241
  // Proves the disabled state is the settled one. `toBeDisabled()` retries until it passes, so a
@@ -243,7 +243,7 @@ Then('the {string} {role} should be disabled', async ({ page, apiContext, env },
243
243
  // admin too — as long as the first poll lands before the fetch resolves. The hold outlives it.
244
244
  Then('the {string} {role} should stay disabled for {int} seconds', async ({ page, apiContext, env }, name, role, seconds) => {
245
245
  assertPositiveDuration(seconds);
246
- const n = render(name, ...scopesOf(apiContext, env));
246
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
247
247
  const locator = page.getByRole(role, { name: n });
248
248
  const deadline = Date.now() + seconds * 1000;
249
249
  do {
@@ -259,7 +259,7 @@ Then('the {string} {role} should stay disabled for {int} seconds', async ({ page
259
259
  // Known weakness, stated rather than hidden: the absent branch is satisfied by a surface that has
260
260
  // not rendered yet. Where that race is plausible, use "should stay absent for {int} seconds".
261
261
  Then('the {string} {role} should be absent or disabled', async ({ page, apiContext, env }, name, role) => {
262
- const n = render(name, ...scopesOf(apiContext, env));
262
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
263
263
  const locator = page.getByRole(role, { name: n });
264
264
  if ((await locator.count()) === 0)
265
265
  return;
@@ -267,24 +267,24 @@ Then('the {string} {role} should be absent or disabled', async ({ page, apiConte
267
267
  });
268
268
  // Proves an element addressed by test id is actionable.
269
269
  Then('the element with test id {string} should be enabled', async ({ page, heal, apiContext, env }, id) => {
270
- const testId = render(id, ...scopesOf(apiContext, env));
270
+ const testId = renderStrict(id, ...scopesOf(apiContext, env));
271
271
  const locator = await heal.resolve(page.getByTestId(testId), { testId, description: `test id "${testId}"` }, 'assert');
272
272
  await expect(locator).toBeEnabled();
273
273
  });
274
274
  // Proves an element addressed by test id is rendered but inert. Not healed — deny direction.
275
275
  Then('the element with test id {string} should be disabled', async ({ page, apiContext, env }, id) => {
276
- await expect(page.getByTestId(render(id, ...scopesOf(apiContext, env)))).toBeDisabled();
276
+ await expect(page.getByTestId(renderStrict(id, ...scopesOf(apiContext, env)))).toBeDisabled();
277
277
  });
278
278
  // Proves a test-id-addressed action is not actionable, in whichever shape the denial took.
279
279
  Then('the element with test id {string} should be absent or disabled', async ({ page, apiContext, env }, id) => {
280
- const locator = page.getByTestId(render(id, ...scopesOf(apiContext, env)));
280
+ const locator = page.getByTestId(renderStrict(id, ...scopesOf(apiContext, env)));
281
281
  if ((await locator.count()) === 0)
282
282
  return;
283
283
  await expect(locator.first()).toBeDisabled();
284
284
  });
285
285
  // Proves an opt-in is on.
286
286
  Then('the {string} checkbox should be checked', async ({ page, heal, apiContext, env }, label) => {
287
- const l = render(label, ...scopesOf(apiContext, env));
287
+ const l = renderStrict(label, ...scopesOf(apiContext, env));
288
288
  const locator = await heal.resolve(page.getByLabel(l), { label: l, role: 'checkbox', name: l, description: `${l} checkbox` }, 'assert');
289
289
  await expect(locator).toBeChecked();
290
290
  });
@@ -294,7 +294,7 @@ Then('the {string} checkbox should be checked', async ({ page, heal, apiContext,
294
294
  // extra safety; what it buys is the failure MESSAGE — "no such checkbox is rendered" rather than a
295
295
  // retry timeout that reads as though the assertion itself were wrong.
296
296
  Then('the {string} checkbox should not be checked', async ({ page, apiContext, env }, label) => {
297
- const l = render(label, ...scopesOf(apiContext, env));
297
+ const l = renderStrict(label, ...scopesOf(apiContext, env));
298
298
  const locator = page.getByLabel(l);
299
299
  await expect(locator, `no "${l}" checkbox is rendered`).toHaveCount(1);
300
300
  await expect(locator).not.toBeChecked();
@@ -307,7 +307,7 @@ Then('the {string} checkbox should not be checked', async ({ page, apiContext, e
307
307
  // Proves the first of several identical elements is clickable, on a surface where the built-in
308
308
  // step can only report ambiguity.
309
309
  When('I click the first element with test id {string}', async ({ page, apiContext, env }, id) => {
310
- const testId = render(id, ...scopesOf(apiContext, env));
310
+ const testId = renderStrict(id, ...scopesOf(apiContext, env));
311
311
  const locator = page.getByTestId(testId);
312
312
  await expectAtLeast(locator, 1, `elements with test id "${testId}"`);
313
313
  await locator.first().click();
@@ -315,7 +315,7 @@ When('I click the first element with test id {string}', async ({ page, apiContex
315
315
  // Proves a specific row of a repeated list is reachable — the assertion a list scenario needs
316
316
  // before it can act on row 2 at all.
317
317
  When('I click the element with test id {string} at position {int}', async ({ page, apiContext, env }, id, position) => {
318
- const testId = render(id, ...scopesOf(apiContext, env));
318
+ const testId = renderStrict(id, ...scopesOf(apiContext, env));
319
319
  const index = indexOfPosition(position, `elements with test id "${testId}"`);
320
320
  const locator = page.getByTestId(testId);
321
321
  await expectAtLeast(locator, position, `elements with test id "${testId}"`);
@@ -323,14 +323,14 @@ When('I click the element with test id {string} at position {int}', async ({ pag
323
323
  });
324
324
  // Proves the first of several same-named controls is clickable.
325
325
  When('I click the first {string} {role}', async ({ page, apiContext, env }, name, role) => {
326
- const n = render(name, ...scopesOf(apiContext, env));
326
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
327
327
  const locator = page.getByRole(role, { name: n });
328
328
  await expectAtLeast(locator, 1, `"${n}" ${role} elements`);
329
329
  await locator.first().click();
330
330
  });
331
331
  // Proves a specific one of several same-named controls is clickable.
332
332
  When('I click the {string} {role} at position {int}', async ({ page, apiContext, env }, name, role, position) => {
333
- const n = render(name, ...scopesOf(apiContext, env));
333
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
334
334
  const index = indexOfPosition(position, `"${n}" ${role} elements`);
335
335
  const locator = page.getByRole(role, { name: n });
336
336
  await expectAtLeast(locator, position, `"${n}" ${role} elements`);
@@ -339,28 +339,28 @@ When('I click the {string} {role} at position {int}', async ({ page, apiContext,
339
339
  // Proves the first of several identical fields accepts input.
340
340
  When('I fill the first element with test id {string} with {string}', async ({ page, apiContext, env }, id, value) => {
341
341
  const scopes = scopesOf(apiContext, env);
342
- const testId = render(id, ...scopes);
342
+ const testId = renderStrict(id, ...scopes);
343
343
  const locator = page.getByTestId(testId);
344
344
  await expectAtLeast(locator, 1, `elements with test id "${testId}"`);
345
- await locator.first().fill(render(value, ...scopes));
345
+ await locator.first().fill(renderStrict(value, ...scopes));
346
346
  });
347
347
  // Proves a specific row's field accepts input — editing row 3 of a repeated editor.
348
348
  When('I fill the element with test id {string} at position {int} with {string}', async ({ page, apiContext, env }, id, position, value) => {
349
349
  const scopes = scopesOf(apiContext, env);
350
- const testId = render(id, ...scopes);
350
+ const testId = renderStrict(id, ...scopes);
351
351
  const index = indexOfPosition(position, `elements with test id "${testId}"`);
352
352
  const locator = page.getByTestId(testId);
353
353
  await expectAtLeast(locator, position, `elements with test id "${testId}"`);
354
- await locator.nth(index).fill(render(value, ...scopes));
354
+ await locator.nth(index).fill(renderStrict(value, ...scopes));
355
355
  });
356
356
  // Proves at least one such element rendered, where the built-in visibility step would report a
357
357
  // strict-mode violation instead of an answer.
358
358
  Then('the first element with test id {string} should be visible', async ({ page, apiContext, env }, id) => {
359
- await expect(page.getByTestId(render(id, ...scopesOf(apiContext, env))).first()).toBeVisible();
359
+ await expect(page.getByTestId(renderStrict(id, ...scopesOf(apiContext, env))).first()).toBeVisible();
360
360
  });
361
361
  // Proves the Nth repeat rendered — i.e. that the list is at least N long AND that entry is shown.
362
362
  Then('the element with test id {string} at position {int} should be visible', async ({ page, apiContext, env }, id, position) => {
363
- const testId = render(id, ...scopesOf(apiContext, env));
363
+ const testId = renderStrict(id, ...scopesOf(apiContext, env));
364
364
  const index = indexOfPosition(position, `elements with test id "${testId}"`);
365
365
  await expect(page.getByTestId(testId).nth(index), `no element with test id "${testId}" at position ${position}`).toBeVisible();
366
366
  });
@@ -368,18 +368,18 @@ Then('the element with test id {string} at position {int} should be visible', as
368
368
  // ordering or pagination scenario mean something.
369
369
  Then('the element with test id {string} at position {int} should contain {string}', async ({ page, apiContext, env }, id, position, text) => {
370
370
  const scopes = scopesOf(apiContext, env);
371
- const testId = render(id, ...scopes);
371
+ const testId = renderStrict(id, ...scopes);
372
372
  const index = indexOfPosition(position, `elements with test id "${testId}"`);
373
- await expect(page.getByTestId(testId).nth(index), `element with test id "${testId}" at position ${position}`).toContainText(render(text, ...scopes));
373
+ await expect(page.getByTestId(testId).nth(index), `element with test id "${testId}" at position ${position}`).toContainText(renderStrict(text, ...scopes));
374
374
  });
375
375
  // Proves at least one same-named control rendered and is shown.
376
376
  Then('the first {string} {role} should be visible', async ({ page, apiContext, env }, name, role) => {
377
- const n = render(name, ...scopesOf(apiContext, env));
377
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
378
378
  await expect(page.getByRole(role, { name: n }).first()).toBeVisible();
379
379
  });
380
380
  // Proves the Nth same-named control rendered and is shown.
381
381
  Then('the {string} {role} at position {int} should be visible', async ({ page, apiContext, env }, name, role, position) => {
382
- const n = render(name, ...scopesOf(apiContext, env));
382
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
383
383
  const index = indexOfPosition(position, `"${n}" ${role} elements`);
384
384
  await expect(page.getByRole(role, { name: n }).nth(index), `no "${n}" ${role} at position ${position}`).toBeVisible();
385
385
  });
@@ -387,24 +387,24 @@ Then('the {string} {role} at position {int} should be visible', async ({ page, a
387
387
  // Proves the collection is exactly this size — the assertion that catches a duplicated render or
388
388
  // a leaked row that a "should be visible" check happily ignores.
389
389
  Then('the element with test id {string} should have exactly {int} matches', async ({ page, apiContext, env }, id, count) => {
390
- await expect(page.getByTestId(render(id, ...scopesOf(apiContext, env)))).toHaveCount(count);
390
+ await expect(page.getByTestId(renderStrict(id, ...scopesOf(apiContext, env)))).toHaveCount(count);
391
391
  });
392
392
  // Proves the collection reached a floor, for lists whose exact length is not the point. A floor of
393
393
  // 0 is refused: it would pass on a blank page, which is not an assertion.
394
394
  Then('the element with test id {string} should have at least {int} matches', async ({ page, apiContext, env }, id, count) => {
395
- const testId = render(id, ...scopesOf(apiContext, env));
395
+ const testId = renderStrict(id, ...scopesOf(apiContext, env));
396
396
  assertMeaningfulFloor(count, `elements with test id "${testId}"`);
397
397
  await expectAtLeast(page.getByTestId(testId), count, `elements with test id "${testId}"`);
398
398
  });
399
399
  // Proves exactly N same-named controls rendered — e.g. that a "Delete" appears on every row and
400
400
  // nowhere else.
401
401
  Then('there should be exactly {int} {string} {role} elements', async ({ page, apiContext, env }, count, name, role) => {
402
- const n = render(name, ...scopesOf(apiContext, env));
402
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
403
403
  await expect(page.getByRole(role, { name: n })).toHaveCount(count);
404
404
  });
405
405
  // Proves at least N same-named controls rendered. A floor of 0 is refused for the same reason.
406
406
  Then('there should be at least {int} {string} {role} elements', async ({ page, apiContext, env }, count, name, role) => {
407
- const n = render(name, ...scopesOf(apiContext, env));
407
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
408
408
  assertMeaningfulFloor(count, `"${n}" ${role} elements`);
409
409
  await expectAtLeast(page.getByRole(role, { name: n }), count, `"${n}" ${role} elements`);
410
410
  });
@@ -416,7 +416,7 @@ Then('there should be at least {int} {string} {role} elements', async ({ page, a
416
416
  // dialog contract. The body-focus guard is essential: `document.activeElement` falls back to
417
417
  // `<body>`, whose text is the whole page, so an unguarded name match passes for almost any string.
418
418
  Then('the focused element should be named {string}', async ({ page, apiContext, env }, name) => {
419
- const wanted = render(name, ...scopesOf(apiContext, env));
419
+ const wanted = renderStrict(name, ...scopesOf(apiContext, env));
420
420
  const info = await readFocus(page);
421
421
  expect(info.focused, `nothing is focused — <${info.tag}> holds focus, so no element is named "${wanted}"`).toBe(true);
422
422
  expect(info.name.toLowerCase(), `focus is on <${info.tag}> named "${info.name}", not on "${wanted}"`).toContain(wanted.toLowerCase());
@@ -439,7 +439,7 @@ Then('the focused element should be inside the open dialog', async ({ page }) =>
439
439
  // Proves focus is on one specific named control, resolved the same way a user's screen reader
440
440
  // would name it.
441
441
  Then('focus should be on the {string} {role}', async ({ page, apiContext, env }, name, role) => {
442
- const n = render(name, ...scopesOf(apiContext, env));
442
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
443
443
  await expect(page.locator(':focus'), 'nothing on the page is focused').toHaveCount(1);
444
444
  await expect(page.getByRole(role, { name: n }).first()).toBeFocused();
445
445
  });
@@ -450,7 +450,7 @@ When('I replace the focused field with {string}', async ({ page, apiContext, env
450
450
  const focused = page.locator('input:focus, textarea:focus, [contenteditable="true"]:focus');
451
451
  await expect(focused, 'no editable element is focused — did the rename affordance open?').toHaveCount(1);
452
452
  await page.keyboard.press('ControlOrMeta+a');
453
- await page.keyboard.type(render(value, ...scopesOf(apiContext, env)));
453
+ await page.keyboard.type(renderStrict(value, ...scopesOf(apiContext, env)));
454
454
  });
455
455
  /* ── keyboard ─────────────────────────────────────────────────────────── */
456
456
  // Proves a repeated key press is expressible at all — walking a tab ring or a listbox needs it,
@@ -461,7 +461,7 @@ When('I press {string} {int} times', async ({ page, apiContext, env }, key, time
461
461
  hint: 'Press counts are whole numbers, 1 or more. A 0-press step does nothing, and any assertion that follows it would be about an untouched page.',
462
462
  });
463
463
  }
464
- const k = render(key, ...scopesOf(apiContext, env));
464
+ const k = renderStrict(key, ...scopesOf(apiContext, env));
465
465
  for (let i = 0; i < times; i += 1)
466
466
  await page.keyboard.press(k);
467
467
  });
@@ -473,7 +473,7 @@ When('I press {string} {int} times recording every focused element', async ({ pa
473
473
  hint: 'A focus walk of 0 presses records nothing, and "every recorded focus stayed inside" would then be true of an empty list.',
474
474
  });
475
475
  }
476
- const k = render(key, ...scopesOf(apiContext, env));
476
+ const k = renderStrict(key, ...scopesOf(apiContext, env));
477
477
  const walk = [];
478
478
  for (let i = 0; i < times; i += 1) {
479
479
  await page.keyboard.press(k);
@@ -503,13 +503,13 @@ Then('every recorded focus should have stayed inside the dialog', async ({ apiCo
503
503
  // the loop checks before it presses, because blurring first can dismiss the very overlay under
504
504
  // test.
505
505
  When('I tab to the first focusable element inside the {string} {role}', async ({ page, heal, apiContext, env }, name, role) => {
506
- const n = render(name, ...scopesOf(apiContext, env));
506
+ const n = renderStrict(name, ...scopesOf(apiContext, env));
507
507
  const region = await heal.resolve(page.getByRole(role, { name: n }), { role: role, name: n, text: n, description: `${role} "${n}"` }, 'assert');
508
508
  await tabToFirstFocusable(page, region.first(), `the "${n}" ${role}`);
509
509
  });
510
510
  // Proves the same for a region that carries a test id rather than an accessible name.
511
511
  When('I tab to the first focusable element inside the element with test id {string}', async ({ page, heal, apiContext, env }, id) => {
512
- const testId = render(id, ...scopesOf(apiContext, env));
512
+ const testId = renderStrict(id, ...scopesOf(apiContext, env));
513
513
  const region = await heal.resolve(page.getByTestId(testId), { testId, description: `test id "${testId}"` }, 'assert');
514
514
  await tabToFirstFocusable(page, region.first(), `the element with test id "${testId}"`);
515
515
  });
@@ -543,29 +543,29 @@ Then('no dialog should be open', async ({ page }) => {
543
543
  });
544
544
  // Proves the dialog's single unlabelled field accepts input.
545
545
  When('I fill the open dialog field with {string}', async ({ page, apiContext, env }, value) => {
546
- await fillOverlayField(page.getByRole('dialog').last(), 'dialog', render(value, ...scopesOf(apiContext, env)));
546
+ await fillOverlayField(page.getByRole('dialog').last(), 'dialog', renderStrict(value, ...scopesOf(apiContext, env)));
547
547
  });
548
548
  // Proves the same for an alert dialog. Kept separate from `dialog` because a confirm prompt is
549
549
  // routinely stacked ON TOP of an already-open dialog, and only the role tells them apart.
550
550
  When('I fill the open alert dialog field with {string}', async ({ page, apiContext, env }, value) => {
551
- await fillOverlayField(page.getByRole('alertdialog').last(), 'alert dialog', render(value, ...scopesOf(apiContext, env)));
551
+ await fillOverlayField(page.getByRole('alertdialog').last(), 'alert dialog', renderStrict(value, ...scopesOf(apiContext, env)));
552
552
  });
553
553
  // Proves an unlabelled popover editor accepts input — a loop-count or filter popper typically
554
554
  // holds one bare <input> with no label, id or test id.
555
555
  When('I fill the open popover field with {string}', async ({ page, apiContext, env }, value) => {
556
- await fillOverlayField(page.locator(POPOVER_SELECTOR).last(), 'popover', render(value, ...scopesOf(apiContext, env)));
556
+ await fillOverlayField(page.locator(POPOVER_SELECTOR).last(), 'popover', renderStrict(value, ...scopesOf(apiContext, env)));
557
557
  });
558
558
  // Proves a NAMED field inside the open dialog accepts input. Needed even though the field has a
559
559
  // label: the form behind the overlay usually carries the same label, which makes an unscoped
560
560
  // `getByLabel` ambiguous rather than wrong.
561
561
  When('I fill the {string} field of the open dialog with {string}', async ({ page, apiContext, env }, label, value) => {
562
562
  const scopes = scopesOf(apiContext, env);
563
- const l = render(label, ...scopes);
563
+ const l = renderStrict(label, ...scopes);
564
564
  const dialog = page.getByRole('dialog').last();
565
565
  await expect(dialog, 'no dialog is open').toBeVisible();
566
566
  const field = dialog.getByLabel(l);
567
567
  await expect(field, `the open dialog has no "${l}" field`).toHaveCount(1);
568
- await field.fill(render(value, ...scopes));
568
+ await field.fill(renderStrict(value, ...scopes));
569
569
  });
570
570
  /**
571
571
  * The topmost overlay of a kind is the LAST in DOM order — portals append to <body>, so a confirm
@@ -2,17 +2,21 @@ import { expect } from '@playwright/test';
2
2
  import './params.js';
3
3
  import { Then, When } from '../fixtures/test.js';
4
4
  import { getPath } from '../api/json-path.js';
5
- import { render, renderJson } from '../api/template.js';
5
+ import { renderJson, renderStrict } from '../api/template.js';
6
6
  /* Hybrid glue: seed through the API, verify in the browser. */
7
7
  When('I seed via {method} {string} with body:', async ({ api, apiContext, env }, method, path, body) => {
8
8
  const scopes = [apiContext.vars.toObject(), env.vars];
9
- await api.send(method, render(path, ...scopes), { body: renderJson(body, ...scopes) });
9
+ await api.send(method, renderStrict(path, ...scopes), { body: renderJson(body, ...scopes) });
10
10
  });
11
- Then('the UI should show the text from JSON path {string}', async ({ page, apiContext }, jsonPath) => {
12
- const value = getPath(apiContext.last().response.body, jsonPath);
11
+ Then('the UI should show the text from JSON path {string}', async ({ page, apiContext, env }, jsonPath) => {
12
+ const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
13
+ const value = getPath(apiContext.last().response.body, path);
13
14
  await expect(page.getByText(String(value), { exact: false }).first()).toBeVisible();
14
15
  });
15
- When('I replay HAR {string} for {string}', async ({ page, config }, har, urlGlob) => {
16
+ When('I replay HAR {string} for {string}', async ({ page, config, apiContext, env }, harName, glob) => {
17
+ const scopes = [apiContext.vars.toObject(), env.vars];
18
+ const har = renderStrict(harName, ...scopes);
19
+ const urlGlob = renderStrict(glob, ...scopes);
16
20
  const { resolve, isAbsolute } = await import('node:path');
17
21
  const file = isAbsolute(har)
18
22
  ? har
@@ -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
  * Iframe steps.
@@ -31,7 +31,7 @@ function currentFrame(page) {
31
31
  }
32
32
  /* ── entering and leaving ─────────────────────────────────────────────── */
33
33
  Given('I enter the frame {string}', async ({ page, apiContext, env }, selector) => {
34
- const resolved = render(selector, ...scopesOf(apiContext, env));
34
+ const resolved = renderStrict(selector, ...scopesOf(apiContext, env));
35
35
  // A bare word is treated as a name/title/id, which is how frames are
36
36
  // usually identified in markup; anything else is a CSS selector.
37
37
  const css = /^[\w-]+$/.test(resolved)
@@ -53,32 +53,32 @@ When('I fill the frame field {string} with {string}', async ({ page, apiContext,
53
53
  const scopes = scopesOf(apiContext, env);
54
54
  const frame = currentFrame(page);
55
55
  await frame
56
- .getByLabel(render(label, ...scopes))
57
- .or(frame.getByPlaceholder(render(label, ...scopes)))
56
+ .getByLabel(renderStrict(label, ...scopes))
57
+ .or(frame.getByPlaceholder(renderStrict(label, ...scopes)))
58
58
  .first()
59
- .fill(render(value, ...scopes));
59
+ .fill(renderStrict(value, ...scopes));
60
60
  });
61
61
  When('I click the frame element {string}', async ({ page, apiContext, env }, selector) => {
62
62
  await currentFrame(page)
63
- .locator(render(selector, ...scopesOf(apiContext, env)))
63
+ .locator(renderStrict(selector, ...scopesOf(apiContext, env)))
64
64
  .first()
65
65
  .click();
66
66
  });
67
67
  When('I click the frame {role} {string}', async ({ page, apiContext, env }, role, name) => {
68
68
  await currentFrame(page)
69
- .getByRole(role, { name: render(name, ...scopesOf(apiContext, env)) })
69
+ .getByRole(role, { name: renderStrict(name, ...scopesOf(apiContext, env)) })
70
70
  .first()
71
71
  .click();
72
72
  });
73
73
  /* ── asserting inside the frame ───────────────────────────────────────── */
74
74
  Then('the frame should contain the text {string}', async ({ page, apiContext, env }, text) => {
75
75
  await expect(currentFrame(page)
76
- .getByText(render(text, ...scopesOf(apiContext, env)))
76
+ .getByText(renderStrict(text, ...scopesOf(apiContext, env)))
77
77
  .first()).toBeVisible();
78
78
  });
79
79
  Then('the frame element {string} should be visible', async ({ page, apiContext, env }, selector) => {
80
80
  await expect(currentFrame(page)
81
- .locator(render(selector, ...scopesOf(apiContext, env)))
81
+ .locator(renderStrict(selector, ...scopesOf(apiContext, env)))
82
82
  .first()).toBeVisible();
83
83
  });
84
84
  Then('the page should have {int} frame(s)', async ({ page }, count) => {
@@ -3,6 +3,7 @@ import type { APIRequestContext, TestInfo } from '@playwright/test';
3
3
  import type { EnvConfig } from '@sdods/contracts';
4
4
  import type { ResolvedConfig } from '../config/resolve.js';
5
5
  import type { ApiContext } from '../fixtures/api-context.js';
6
+ import { type ApiClient } from '../api/client.js';
6
7
  export interface RawSnapshot {
7
8
  method: string;
8
9
  url: string;
@@ -15,6 +16,15 @@ export interface RawSnapshot {
15
16
  setCookies: string[];
16
17
  body: string;
17
18
  }
19
+ interface RawDeps {
20
+ request: APIRequestContext;
21
+ /** The scenario's ApiClient; supplies the isolated request context when one is in use. */
22
+ api?: Pick<ApiClient, 'requestContext'>;
23
+ apiContext: ApiContext;
24
+ env: EnvConfig;
25
+ config: ResolvedConfig;
26
+ testInfo?: Pick<TestInfo, 'attach'>;
27
+ }
18
28
  export interface SendRawOptions {
19
29
  body?: string;
20
30
  form?: Record<string, string>;
@@ -23,13 +33,7 @@ export interface SendRawOptions {
23
33
  * One request with `maxRedirects: 0`. The whole point is to keep the 3xx instead of chasing it,
24
34
  * and to keep `Set-Cookie` unredacted — the two things the recorded ApiSnapshot cannot carry.
25
35
  */
26
- export declare function sendRaw(deps: {
27
- request: APIRequestContext;
28
- apiContext: ApiContext;
29
- env: EnvConfig;
30
- config: ResolvedConfig;
31
- testInfo?: Pick<TestInfo, 'attach'>;
32
- }, method: string, pathOrUrl: string, opts?: SendRawOptions): Promise<RawSnapshot>;
36
+ export declare function sendRaw(deps: RawDeps, method: string, pathOrUrl: string, opts?: SendRawOptions): Promise<RawSnapshot>;
33
37
  export declare function redactForReport(headers: Record<string, string>): Record<string, string>;
34
38
  export interface StreamEvent {
35
39
  /** The SSE `event:` field, else the parsed JSON payload's `type`, else the raw sentinel. */
@@ -60,4 +64,5 @@ export declare function parseEventStream(text: string): Pick<StreamCapture, 'eve
60
64
  export declare function globToRegExp(glob: string): RegExp;
61
65
  /** Every credential shape found in `text`, named. Exported so the patterns are testable. */
62
66
  export declare function credentialFindings(text: string): string[];
67
+ export {};
63
68
  //# sourceMappingURL=net.steps.d.ts.map