@sdods/core 0.2.2 → 0.3.0

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 (52) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/analyze/detectors.js +236 -24
  3. package/dist/analyze/index.d.ts +0 -1
  4. package/dist/analyze/index.js +0 -1
  5. package/dist/analyze/propose.js +80 -38
  6. package/dist/analyze/scan.js +19 -1
  7. package/dist/api/client.js +7 -1
  8. package/dist/auth/capture.js +19 -6
  9. package/dist/auth/index.js +23 -2
  10. package/dist/config/resolve.d.ts +13 -0
  11. package/dist/config/resolve.js +1 -0
  12. package/dist/config/tags.d.ts +29 -1
  13. package/dist/config/tags.js +46 -0
  14. package/dist/data/provider.js +5 -1
  15. package/dist/data/user-pool.js +35 -3
  16. package/dist/fixtures/api-context.d.ts +14 -1
  17. package/dist/fixtures/api-context.js +13 -0
  18. package/dist/fixtures/scenario.js +1 -4
  19. package/dist/fixtures/test.js +42 -1
  20. package/dist/fixtures/types.d.ts +2 -0
  21. package/dist/reporters/dashboard.d.ts +86 -0
  22. package/dist/reporters/dashboard.js +319 -61
  23. package/dist/shots/hooks.js +0 -10
  24. package/dist/steps/a11y.steps.d.ts +180 -0
  25. package/dist/steps/a11y.steps.js +598 -0
  26. package/dist/steps/api.steps.js +5 -1
  27. package/dist/steps/browser.steps.d.ts +27 -0
  28. package/dist/steps/browser.steps.js +653 -0
  29. package/dist/steps/clock.steps.d.ts +4 -0
  30. package/dist/steps/clock.steps.js +73 -0
  31. package/dist/steps/data.steps.js +50 -2
  32. package/dist/steps/db.steps.d.ts +5 -0
  33. package/dist/steps/db.steps.js +105 -0
  34. package/dist/steps/dom.steps.d.ts +2 -0
  35. package/dist/steps/dom.steps.js +583 -0
  36. package/dist/steps/iframe.steps.d.ts +2 -0
  37. package/dist/steps/iframe.steps.js +93 -0
  38. package/dist/steps/index.d.ts +10 -0
  39. package/dist/steps/index.js +10 -0
  40. package/dist/steps/net.steps.d.ts +63 -0
  41. package/dist/steps/net.steps.js +728 -0
  42. package/dist/steps/perf.steps.d.ts +248 -0
  43. package/dist/steps/perf.steps.js +514 -0
  44. package/dist/steps/tabs.steps.d.ts +5 -0
  45. package/dist/steps/tabs.steps.js +109 -0
  46. package/dist/steps/webhook.steps.d.ts +46 -0
  47. package/dist/steps/webhook.steps.js +129 -0
  48. package/package.json +3 -4
  49. package/dist/analyze/modules.d.ts +0 -74
  50. package/dist/analyze/modules.js +0 -353
  51. package/dist/config/playwright.d.ts +0 -37
  52. package/dist/config/playwright.js +0 -262
@@ -0,0 +1,583 @@
1
+ import { expect } from '@playwright/test';
2
+ import './params.js';
3
+ import { Then, When } from '../fixtures/test.js';
4
+ import { render } from '../api/template.js';
5
+ import { SdodsError } from '../errors.js';
6
+ /**
7
+ * DOM assertions the rest of the library cannot express.
8
+ *
9
+ * The library ships exactly one negative UI step (`I should not see the text`), so the deny
10
+ * direction of an authorisation scenario — the only direction worth asserting against a
11
+ * default-allow policy — is unwritable. These steps close that, plus the ordinal, counting,
12
+ * focus and overlay vocabulary that a real onboarding had to hand-write.
13
+ *
14
+ * Two conventions hold throughout this file, both of them load-bearing:
15
+ *
16
+ * 1. EVERY string argument goes through `render()`, ids and role names included. A step that
17
+ * silently drops `{{vars}}` fails much later, in a scenario that looks unrelated; rendering a
18
+ * string with no placeholder in it is a no-op, so there is no cost to doing it everywhere.
19
+ *
20
+ * 2. Self-healing is applied on the ALLOW direction only — "this control is present / enabled /
21
+ * checked" — on the same terms as the built-in visibility assertion, where a heal onto a moved
22
+ * control keeps a true scenario green. It is withheld on the DENY direction, because a heal
23
+ * onto a similarly named element that happens to be disabled would manufacture the very pass
24
+ * an authorisation scenario exists to disprove. Ordinals and counts are never healed at all:
25
+ * healing rewrites the selector wholesale, which would silently renumber the match an ordinal
26
+ * points at, and change the population a count is over.
27
+ */
28
+ const scopesOf = (apiContext, env) => [apiContext.vars.toObject(), env.vars];
29
+ /** Any open modal surface, in any of the three shapes React overlay libraries emit. */
30
+ const DIALOG_SELECTOR = '[role="dialog"], [role="alertdialog"], dialog[open]';
31
+ /**
32
+ * Radix/shadcn poppers, plus anything that declares itself a tooltip. Portalled overlays render
33
+ * OUTSIDE the trigger's DOM subtree, so a locator scoped to the trigger misses them entirely.
34
+ */
35
+ const POPOVER_SELECTOR = '[data-radix-popper-content-wrapper], [data-testid="popover-content"], [role="tooltip"]';
36
+ /** Everything the browser will stop on for a Tab press. Disabled controls are skipped. */
37
+ const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"])';
38
+ /** Bound on the keyboard walk, so a region that is unreachable fails instead of hanging. */
39
+ const MAX_TAB_STOPS = 40;
40
+ /* ── shared helpers ───────────────────────────────────────────────────── */
41
+ /**
42
+ * 1-based ordinals, validated. Playwright reads `nth(-1)` as "the last match", so an unchecked
43
+ * `at position 0` would quietly assert against the WRONG element and pass — the exact class of
44
+ * silent-pass this file exists to remove.
45
+ */
46
+ function indexOfPosition(position, what) {
47
+ if (!Number.isInteger(position) || position < 1) {
48
+ throw new SdodsError('RUN_FAILED', `"${position}" is not a valid position for ${what}.`, {
49
+ hint: 'Positions are 1-based: the first match is position 1. Playwright treats a negative index as "the last match", so 0 or below would assert against the wrong element rather than failing.',
50
+ });
51
+ }
52
+ return position - 1;
53
+ }
54
+ /** A count floor of 0 is satisfied by every page ever rendered — refuse it rather than lie. */
55
+ function assertMeaningfulFloor(count, what) {
56
+ if (!Number.isInteger(count) || count < 1) {
57
+ throw new SdodsError('RUN_FAILED', `"at least ${count}" is not an assertion about ${what}.`, {
58
+ hint: 'An "at least 0" check passes on a blank page. Use "should have exactly 0 matches" or a "should not exist" step if absence is what you mean.',
59
+ });
60
+ }
61
+ }
62
+ function assertPositiveDuration(seconds) {
63
+ if (!Number.isInteger(seconds) || seconds < 1) {
64
+ throw new SdodsError('RUN_FAILED', `"${seconds}" is not a duration to hold an assertion for.`, {
65
+ hint: 'Hold windows are whole seconds, 1 or more — a 0-second window is the plain assertion, which is what this step exists to strengthen.',
66
+ });
67
+ }
68
+ }
69
+ /** Fails naming the shortfall, rather than letting `nth()` time out against nothing. */
70
+ async function expectAtLeast(locator, wanted, what) {
71
+ await expect
72
+ .poll(async () => locator.count(), { message: `expected at least ${wanted} ${what}` })
73
+ .toBeGreaterThanOrEqual(wanted);
74
+ }
75
+ /**
76
+ * Reads the focused element in one round trip. The `focused` flag exists because
77
+ * `document.activeElement` defaults to `<body>`: treating that as "the focused element" makes
78
+ * every "focus should be named X" assertion match the whole page's text, which passes for
79
+ * essentially any X. Body focus is reported as no focus.
80
+ */
81
+ async function readFocus(page) {
82
+ return page.evaluate((dialogSelector) => {
83
+ const el = document.activeElement;
84
+ const dialogPresent = Boolean(document.querySelector(dialogSelector));
85
+ const isReal = Boolean(el) && el !== document.body && el !== document.documentElement;
86
+ if (!el || !isReal) {
87
+ return {
88
+ focused: false,
89
+ tag: el ? el.tagName.toLowerCase() : 'none',
90
+ name: '',
91
+ rendered: false,
92
+ insideDialog: false,
93
+ dialogPresent,
94
+ };
95
+ }
96
+ const name = [
97
+ el.getAttribute('aria-label') ?? '',
98
+ el.getAttribute('title') ?? '',
99
+ el.getAttribute('placeholder') ?? '',
100
+ el.getAttribute('alt') ?? '',
101
+ (el.innerText || el.textContent || '').trim(),
102
+ ]
103
+ .filter(Boolean)
104
+ .join(' ')
105
+ .replace(/\s+/g, ' ')
106
+ .slice(0, 300);
107
+ const rect = el.getBoundingClientRect();
108
+ const style = getComputedStyle(el);
109
+ return {
110
+ focused: true,
111
+ tag: el.tagName.toLowerCase(),
112
+ name,
113
+ rendered: rect.width > 0 &&
114
+ rect.height > 0 &&
115
+ rect.bottom > 0 &&
116
+ rect.right > 0 &&
117
+ style.visibility !== 'hidden' &&
118
+ style.display !== 'none' &&
119
+ Number(style.opacity) > 0,
120
+ insideDialog: Boolean(el.closest(dialogSelector)),
121
+ dialogPresent,
122
+ };
123
+ }, DIALOG_SELECTOR);
124
+ }
125
+ /**
126
+ * Every rendered string a human could read off the page: the visible text plus the values and
127
+ * text-bearing attributes of rendered controls. Strictly wider than `getByText`, which sees no
128
+ * input value, placeholder, title or alt text at all — so a leaked address sitting in a filled
129
+ * field passes `I should not see the text` while being plainly on screen.
130
+ */
131
+ async function readableText(page) {
132
+ return page.evaluate(() => {
133
+ const body = document.body;
134
+ if (!body)
135
+ return '';
136
+ const parts = [body.innerText || ''];
137
+ const attrs = ['title', 'aria-label', 'placeholder', 'alt', 'value'];
138
+ for (const el of Array.from(document.querySelectorAll('*'))) {
139
+ if (el.getClientRects().length === 0)
140
+ continue;
141
+ const control = el;
142
+ if (typeof control.value === 'string' && control.value)
143
+ parts.push(control.value);
144
+ for (const attr of attrs) {
145
+ const v = el.getAttribute(attr);
146
+ if (v)
147
+ parts.push(v);
148
+ }
149
+ }
150
+ return parts.join('\n').replace(/\s+/g, ' ').trim();
151
+ });
152
+ }
153
+ /** Per-scenario scratch, keyed on the test-scoped apiContext so nothing leaks between runs. */
154
+ const scratch = new WeakMap();
155
+ function bag(key) {
156
+ let existing = scratch.get(key);
157
+ if (!existing) {
158
+ existing = {};
159
+ scratch.set(key, existing);
160
+ }
161
+ return existing;
162
+ }
163
+ /* ── negative visibility and existence ────────────────────────────────── */
164
+ // Proves the element is not on screen. Passes when it is absent too, because "a user cannot see
165
+ // it" is the invariant — use the "should not exist" form when the DOM node itself is the point.
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();
168
+ });
169
+ // Proves the node was never rendered — not merely hidden with CSS. The distinction is the point of
170
+ // a deny: a control the server refused to send is a different posture from one the client chose
171
+ // not to paint, which is one CSS toggle or one devtools edit away from being usable.
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);
174
+ });
175
+ // Proves a named control is not on screen — the deny half of `the {string} {role} should be
176
+ // visible`, which had no counterpart.
177
+ Then('the {string} {role} should not be visible', async ({ page, apiContext, env }, name, role) => {
178
+ const n = render(name, ...scopesOf(apiContext, env));
179
+ await expect(page.getByRole(role, { name: n })).toBeHidden();
180
+ });
181
+ // Proves the app rendered no such control at all. Distinct from "not visible": this is what an
182
+ // authorisation scenario wants when the claim is that the affordance was never offered.
183
+ Then('no {string} {role} should exist', async ({ page, apiContext, env }, name, role) => {
184
+ const n = render(name, ...scopesOf(apiContext, env));
185
+ await expect(page.getByRole(role, { name: n })).toHaveCount(0);
186
+ });
187
+ // Proves the absence is the SETTLED state, not the loading state. This is the trap that makes
188
+ // plain absence assertions untrustworthy: a permissions hook that reports "no" while its fetch is
189
+ // in flight renders the same empty surface for an admin as for a viewer, so a single-shot check
190
+ // passes for everyone as long as it lands first. Holding it across a window outlives the fetch.
191
+ Then('the {string} {role} should stay absent for {int} seconds', async ({ page, apiContext, env }, name, role, seconds) => {
192
+ assertPositiveDuration(seconds);
193
+ const n = render(name, ...scopesOf(apiContext, env));
194
+ const locator = page.getByRole(role, { name: n });
195
+ const deadline = Date.now() + seconds * 1000;
196
+ do {
197
+ await expect(locator, `"${n}" ${role} appeared during the ${seconds}s hold`).toHaveCount(0, {
198
+ timeout: 1000,
199
+ });
200
+ await page.waitForTimeout(250);
201
+ } while (Date.now() < deadline);
202
+ });
203
+ /* ── text anywhere on the page ────────────────────────────────────────── */
204
+ // Proves the string reached the user by ANY rendered route — visible text, an input's value, a
205
+ // placeholder, a title or alt. `I should see the text` resolves through `getByText`, which reads
206
+ // text nodes only, so a value prefilled into a field is invisible to it.
207
+ Then('the text {string} should appear anywhere on the page', async ({ page, apiContext, env }, text) => {
208
+ const wanted = render(text, ...scopesOf(apiContext, env));
209
+ await expect
210
+ .poll(async () => readableText(page), { message: `page never rendered "${wanted}"` })
211
+ .toContain(wanted);
212
+ });
213
+ // Proves the string leaked nowhere — the assertion a data-isolation or redaction scenario needs,
214
+ // and stronger than `I should not see the text` for the same reason as above.
215
+ // The blank-page guard is the point: on a surface that rendered nothing this check would pass for
216
+ // every string in existence, which is the vacuous pass this library refuses to ship.
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));
219
+ const haystack = await readableText(page);
220
+ if (haystack.length === 0) {
221
+ throw new SdodsError('RUN_FAILED', 'The page has rendered no readable text at all.', {
222
+ hint: `Asserting that "${unwanted}" is absent from an empty page proves nothing. Wait for the surface to render first — e.g. assert something that SHOULD be there.`,
223
+ });
224
+ }
225
+ expect(haystack, `"${unwanted}" is rendered somewhere on this page`).not.toContain(unwanted);
226
+ });
227
+ /* ── enabled / disabled / checked ─────────────────────────────────────── */
228
+ // Proves the control is offered AND actionable. Healed, on the same terms as the built-in
229
+ // visibility assertion — this is the allow direction. The deny direction below is not healed.
230
+ Then('the {string} {role} should be enabled', async ({ page, heal, apiContext, env }, name, role) => {
231
+ const n = render(name, ...scopesOf(apiContext, env));
232
+ const locator = await heal.resolve(page.getByRole(role, { name: n }), { role: role, name: n, text: n, description: `${role} "${n}"` }, 'assert');
233
+ await expect(locator).toBeEnabled();
234
+ });
235
+ // Proves the control is rendered but refuses to act. NOT healed, deliberately: this is a deny
236
+ // assertion, and healing onto a different, disabled element would fabricate the pass.
237
+ Then('the {string} {role} should be disabled', async ({ page, apiContext, env }, name, role) => {
238
+ const n = render(name, ...scopesOf(apiContext, env));
239
+ await expect(page.getByRole(role, { name: n })).toBeDisabled();
240
+ });
241
+ // Proves the disabled state is the settled one. `toBeDisabled()` retries until it passes, so a
242
+ // permissions hook that answers "no" for every role while it loads makes a plain check pass for an
243
+ // admin too — as long as the first poll lands before the fetch resolves. The hold outlives it.
244
+ Then('the {string} {role} should stay disabled for {int} seconds', async ({ page, apiContext, env }, name, role, seconds) => {
245
+ assertPositiveDuration(seconds);
246
+ const n = render(name, ...scopesOf(apiContext, env));
247
+ const locator = page.getByRole(role, { name: n });
248
+ const deadline = Date.now() + seconds * 1000;
249
+ do {
250
+ await expect(locator, `"${n}" ${role} became enabled during the ${seconds}s hold`).toBeDisabled({
251
+ timeout: 1000,
252
+ });
253
+ await page.waitForTimeout(250);
254
+ } while (Date.now() < deadline);
255
+ });
256
+ // Proves a denied action is not actionable, without pinning WHICH shape the denial takes. A
257
+ // destructive action legitimately renders either way depending on the surface — hidden on one,
258
+ // greyed on another — and pinning one makes the scenario brittle without making it stronger.
259
+ // Known weakness, stated rather than hidden: the absent branch is satisfied by a surface that has
260
+ // not rendered yet. Where that race is plausible, use "should stay absent for {int} seconds".
261
+ Then('the {string} {role} should be absent or disabled', async ({ page, apiContext, env }, name, role) => {
262
+ const n = render(name, ...scopesOf(apiContext, env));
263
+ const locator = page.getByRole(role, { name: n });
264
+ if ((await locator.count()) === 0)
265
+ return;
266
+ await expect(locator.first()).toBeDisabled();
267
+ });
268
+ // Proves an element addressed by test id is actionable.
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));
271
+ const locator = await heal.resolve(page.getByTestId(testId), { testId, description: `test id "${testId}"` }, 'assert');
272
+ await expect(locator).toBeEnabled();
273
+ });
274
+ // Proves an element addressed by test id is rendered but inert. Not healed — deny direction.
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();
277
+ });
278
+ // Proves a test-id-addressed action is not actionable, in whichever shape the denial took.
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)));
281
+ if ((await locator.count()) === 0)
282
+ return;
283
+ await expect(locator.first()).toBeDisabled();
284
+ });
285
+ // Proves an opt-in is on.
286
+ Then('the {string} checkbox should be checked', async ({ page, heal, apiContext, env }, label) => {
287
+ const l = render(label, ...scopesOf(apiContext, env));
288
+ const locator = await heal.resolve(page.getByLabel(l), { label: l, role: 'checkbox', name: l, description: `${l} checkbox` }, 'assert');
289
+ await expect(locator).toBeChecked();
290
+ });
291
+ // Proves an opt-in defaults OFF — a default-on "trust this device" silently extends a session's
292
+ // blast radius, and "the control exists" does not catch that. Playwright already refuses to
293
+ // resolve `not.toBeChecked()` against a missing or ambiguous locator, so the count check buys no
294
+ // extra safety; what it buys is the failure MESSAGE — "no such checkbox is rendered" rather than a
295
+ // retry timeout that reads as though the assertion itself were wrong.
296
+ Then('the {string} checkbox should not be checked', async ({ page, apiContext, env }, label) => {
297
+ const l = render(label, ...scopesOf(apiContext, env));
298
+ const locator = page.getByLabel(l);
299
+ await expect(locator, `no "${l}" checkbox is rendered`).toHaveCount(1);
300
+ await expect(locator).not.toBeChecked();
301
+ });
302
+ /* ── ordinal selection ────────────────────────────────────────────────── */
303
+ //
304
+ // Every step here exists because Playwright's strict mode refuses to act on an ambiguous locator:
305
+ // a sidebar with two project rows renders two identical triggers, and the built-in step fails on
306
+ // the ambiguity rather than acting on one. None of them heal — see the file header.
307
+ // Proves the first of several identical elements is clickable, on a surface where the built-in
308
+ // step can only report ambiguity.
309
+ When('I click the first element with test id {string}', async ({ page, apiContext, env }, id) => {
310
+ const testId = render(id, ...scopesOf(apiContext, env));
311
+ const locator = page.getByTestId(testId);
312
+ await expectAtLeast(locator, 1, `elements with test id "${testId}"`);
313
+ await locator.first().click();
314
+ });
315
+ // Proves a specific row of a repeated list is reachable — the assertion a list scenario needs
316
+ // before it can act on row 2 at all.
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));
319
+ const index = indexOfPosition(position, `elements with test id "${testId}"`);
320
+ const locator = page.getByTestId(testId);
321
+ await expectAtLeast(locator, position, `elements with test id "${testId}"`);
322
+ await locator.nth(index).click();
323
+ });
324
+ // Proves the first of several same-named controls is clickable.
325
+ When('I click the first {string} {role}', async ({ page, apiContext, env }, name, role) => {
326
+ const n = render(name, ...scopesOf(apiContext, env));
327
+ const locator = page.getByRole(role, { name: n });
328
+ await expectAtLeast(locator, 1, `"${n}" ${role} elements`);
329
+ await locator.first().click();
330
+ });
331
+ // Proves a specific one of several same-named controls is clickable.
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));
334
+ const index = indexOfPosition(position, `"${n}" ${role} elements`);
335
+ const locator = page.getByRole(role, { name: n });
336
+ await expectAtLeast(locator, position, `"${n}" ${role} elements`);
337
+ await locator.nth(index).click();
338
+ });
339
+ // Proves the first of several identical fields accepts input.
340
+ When('I fill the first element with test id {string} with {string}', async ({ page, apiContext, env }, id, value) => {
341
+ const scopes = scopesOf(apiContext, env);
342
+ const testId = render(id, ...scopes);
343
+ const locator = page.getByTestId(testId);
344
+ await expectAtLeast(locator, 1, `elements with test id "${testId}"`);
345
+ await locator.first().fill(render(value, ...scopes));
346
+ });
347
+ // Proves a specific row's field accepts input — editing row 3 of a repeated editor.
348
+ When('I fill the element with test id {string} at position {int} with {string}', async ({ page, apiContext, env }, id, position, value) => {
349
+ const scopes = scopesOf(apiContext, env);
350
+ const testId = render(id, ...scopes);
351
+ const index = indexOfPosition(position, `elements with test id "${testId}"`);
352
+ const locator = page.getByTestId(testId);
353
+ await expectAtLeast(locator, position, `elements with test id "${testId}"`);
354
+ await locator.nth(index).fill(render(value, ...scopes));
355
+ });
356
+ // Proves at least one such element rendered, where the built-in visibility step would report a
357
+ // strict-mode violation instead of an answer.
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();
360
+ });
361
+ // Proves the Nth repeat rendered — i.e. that the list is at least N long AND that entry is shown.
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));
364
+ const index = indexOfPosition(position, `elements with test id "${testId}"`);
365
+ await expect(page.getByTestId(testId).nth(index), `no element with test id "${testId}" at position ${position}`).toBeVisible();
366
+ });
367
+ // Proves the Nth repeat carries the expected content — the row-level assertion that makes an
368
+ // ordering or pagination scenario mean something.
369
+ Then('the element with test id {string} at position {int} should contain {string}', async ({ page, apiContext, env }, id, position, text) => {
370
+ const scopes = scopesOf(apiContext, env);
371
+ const testId = render(id, ...scopes);
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));
374
+ });
375
+ // Proves at least one same-named control rendered and is shown.
376
+ Then('the first {string} {role} should be visible', async ({ page, apiContext, env }, name, role) => {
377
+ const n = render(name, ...scopesOf(apiContext, env));
378
+ await expect(page.getByRole(role, { name: n }).first()).toBeVisible();
379
+ });
380
+ // Proves the Nth same-named control rendered and is shown.
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));
383
+ const index = indexOfPosition(position, `"${n}" ${role} elements`);
384
+ await expect(page.getByRole(role, { name: n }).nth(index), `no "${n}" ${role} at position ${position}`).toBeVisible();
385
+ });
386
+ /* ── counting ─────────────────────────────────────────────────────────── */
387
+ // Proves the collection is exactly this size — the assertion that catches a duplicated render or
388
+ // a leaked row that a "should be visible" check happily ignores.
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);
391
+ });
392
+ // Proves the collection reached a floor, for lists whose exact length is not the point. A floor of
393
+ // 0 is refused: it would pass on a blank page, which is not an assertion.
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));
396
+ assertMeaningfulFloor(count, `elements with test id "${testId}"`);
397
+ await expectAtLeast(page.getByTestId(testId), count, `elements with test id "${testId}"`);
398
+ });
399
+ // Proves exactly N same-named controls rendered — e.g. that a "Delete" appears on every row and
400
+ // nowhere else.
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));
403
+ await expect(page.getByRole(role, { name: n })).toHaveCount(count);
404
+ });
405
+ // Proves at least N same-named controls rendered. A floor of 0 is refused for the same reason.
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));
408
+ assertMeaningfulFloor(count, `"${n}" ${role} elements`);
409
+ await expectAtLeast(page.getByRole(role, { name: n }), count, `"${n}" ${role} elements`);
410
+ });
411
+ /* ── focus ────────────────────────────────────────────────────────────── */
412
+ //
413
+ // Tab order, focus visibility and focus trapping are invisible to a static rule engine: the
414
+ // accessibility tree records what an element IS, never where the caret went.
415
+ // Proves focus landed on the intended control after an interaction — the return-focus half of a
416
+ // dialog contract. The body-focus guard is essential: `document.activeElement` falls back to
417
+ // `<body>`, whose text is the whole page, so an unguarded name match passes for almost any string.
418
+ Then('the focused element should be named {string}', async ({ page, apiContext, env }, name) => {
419
+ const wanted = render(name, ...scopesOf(apiContext, env));
420
+ const info = await readFocus(page);
421
+ expect(info.focused, `nothing is focused — <${info.tag}> holds focus, so no element is named "${wanted}"`).toBe(true);
422
+ expect(info.name.toLowerCase(), `focus is on <${info.tag}> named "${info.name}", not on "${wanted}"`).toContain(wanted.toLowerCase());
423
+ });
424
+ // Proves a keyboard user can SEE where they are: an off-screen, zero-size or transparent control
425
+ // still takes focus, stranding them on something they cannot find.
426
+ Then('the focused element should be visible', async ({ page }) => {
427
+ const info = await readFocus(page);
428
+ expect(info.focused, `nothing is focused — <${info.tag}> holds focus`).toBe(true);
429
+ expect(info.rendered, `the focused <${info.tag}> ("${info.name}") is not painted — a keyboard user cannot see where they are`).toBe(true);
430
+ });
431
+ // Proves focus is inside the modal rather than on the page behind it. Fails distinctly when no
432
+ // dialog is open at all, so "the dialog never opened" cannot read as "focus is fine".
433
+ Then('the focused element should be inside the open dialog', async ({ page }) => {
434
+ const info = await readFocus(page);
435
+ expect(info.dialogPresent, 'no dialog is open, so focus cannot be inside one').toBe(true);
436
+ expect(info.focused, `nothing is focused — <${info.tag}> holds focus`).toBe(true);
437
+ expect(info.insideDialog, `focus escaped to <${info.tag}> ("${info.name}") outside the dialog — Tab leaked to the page behind it`).toBe(true);
438
+ });
439
+ // Proves focus is on one specific named control, resolved the same way a user's screen reader
440
+ // would name it.
441
+ Then('focus should be on the {string} {role}', async ({ page, apiContext, env }, name, role) => {
442
+ const n = render(name, ...scopesOf(apiContext, env));
443
+ await expect(page.locator(':focus'), 'nothing on the page is focused').toHaveCount(1);
444
+ await expect(page.getByRole(role, { name: n }).first()).toBeFocused();
445
+ });
446
+ // Proves an auto-focused, unlabelled field accepts a replacement value. Inline-rename affordances
447
+ // swap the label for a bare focused input with no label, id or test id, so neither `I fill the
448
+ // {string} field` (which resolves through the label) nor a test-id step can reach them.
449
+ When('I replace the focused field with {string}', async ({ page, apiContext, env }, value) => {
450
+ const focused = page.locator('input:focus, textarea:focus, [contenteditable="true"]:focus');
451
+ await expect(focused, 'no editable element is focused — did the rename affordance open?').toHaveCount(1);
452
+ await page.keyboard.press('ControlOrMeta+a');
453
+ await page.keyboard.type(render(value, ...scopesOf(apiContext, env)));
454
+ });
455
+ /* ── keyboard ─────────────────────────────────────────────────────────── */
456
+ // Proves a repeated key press is expressible at all — walking a tab ring or a listbox needs it,
457
+ // and repeating `I press` N times in the feature file hides the intent.
458
+ When('I press {string} {int} times', async ({ page, apiContext, env }, key, times) => {
459
+ if (!Number.isInteger(times) || times < 1) {
460
+ throw new SdodsError('RUN_FAILED', `"${times}" is not a number of key presses.`, {
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
+ });
463
+ }
464
+ const k = render(key, ...scopesOf(apiContext, env));
465
+ for (let i = 0; i < times; i += 1)
466
+ await page.keyboard.press(k);
467
+ });
468
+ // Proves nothing on its own — it RECORDS the walk that the next step judges. Split from the
469
+ // assertion so a scenario reads as "walk the ring, then say what must have held".
470
+ When('I press {string} {int} times recording every focused element', async ({ page, apiContext, env }, key, times) => {
471
+ if (!Number.isInteger(times) || times < 1) {
472
+ throw new SdodsError('RUN_FAILED', `"${times}" is not a number of key presses.`, {
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
+ });
475
+ }
476
+ const k = render(key, ...scopesOf(apiContext, env));
477
+ const walk = [];
478
+ for (let i = 0; i < times; i += 1) {
479
+ await page.keyboard.press(k);
480
+ const info = await readFocus(page);
481
+ walk.push(info.focused && info.insideDialog);
482
+ }
483
+ bag(apiContext).focusWalk = walk;
484
+ });
485
+ // Proves the modal TRAPS focus — the property a single-shot check cannot see, because focus
486
+ // escapes on a PARTICULAR press, not on every press. The empty-walk guard is the whole reason this
487
+ // is a separate step: "every element of []" is true, so without it the assertion would pass
488
+ // hardest exactly when the walk never ran.
489
+ Then('every recorded focus should have stayed inside the dialog', async ({ apiContext }) => {
490
+ const walk = bag(apiContext).focusWalk;
491
+ if (!walk) {
492
+ throw new SdodsError('RUN_FAILED', 'No focus walk has been recorded in this scenario.', {
493
+ hint: 'Run `When I press "Tab" <n> times recording every focused element` before this step.',
494
+ });
495
+ }
496
+ expect(walk.length, 'the recorded focus walk is empty').toBeGreaterThan(0);
497
+ const escapes = walk.filter((inside) => !inside).length;
498
+ expect(escapes, `focus left the dialog on ${escapes} of ${walk.length} presses`).toBe(0);
499
+ });
500
+ // Proves a region is KEYBOARD REACHABLE, by walking real tab stops. Calling `.focus()` instead
501
+ // would pass even if every entry in the region were a click-only <div> — which is precisely the
502
+ // bug this step is for. A region whose first control ALREADY holds focus passes without a walk:
503
+ // the loop checks before it presses, because blurring first can dismiss the very overlay under
504
+ // test.
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));
507
+ const region = await heal.resolve(page.getByRole(role, { name: n }), { role: role, name: n, text: n, description: `${role} "${n}"` }, 'assert');
508
+ await tabToFirstFocusable(page, region.first(), `the "${n}" ${role}`);
509
+ });
510
+ // Proves the same for a region that carries a test id rather than an accessible name.
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));
513
+ const region = await heal.resolve(page.getByTestId(testId), { testId, description: `test id "${testId}"` }, 'assert');
514
+ await tabToFirstFocusable(page, region.first(), `the element with test id "${testId}"`);
515
+ });
516
+ async function tabToFirstFocusable(page, region, description) {
517
+ const first = region.locator(FOCUSABLE_SELECTOR).filter({ visible: true }).first();
518
+ await expect(first, `${description} contains no visible focusable element, so no keyboard user can enter it`).toBeVisible();
519
+ for (let stop = 0; stop < MAX_TAB_STOPS; stop += 1) {
520
+ if (await first.evaluate((el) => el === document.activeElement))
521
+ return;
522
+ await page.keyboard.press('Tab');
523
+ }
524
+ throw new SdodsError('RUN_FAILED', `Tab never reached the first focusable element inside ${description} in ${MAX_TAB_STOPS} stops.`, {
525
+ hint: 'Either the region is off the tab ring (click-only elements, or tabindex="-1"), or something before it traps focus.',
526
+ });
527
+ }
528
+ /* ── open overlays ────────────────────────────────────────────────────── */
529
+ //
530
+ // Portalled overlays — every Radix-derived dialog, alert dialog and popover — render OUTSIDE the
531
+ // trigger's DOM subtree, so a locator scoped to the trigger never finds them. They also routinely
532
+ // hold ONE unlabelled input, which `I fill the {string} field with {string}` cannot reach at all
533
+ // and `I fill the element with test id "input"` matches ambiguously the moment anything else on
534
+ // the page renders one. Scoping to the overlay's role is what makes them addressable.
535
+ // Proves a modal surface actually opened.
536
+ Then('a dialog should be open', async ({ page }) => {
537
+ await expect(page.locator(DIALOG_SELECTOR).first()).toBeVisible();
538
+ });
539
+ // Proves the modal closed — the Escape-dismisses and cancel-discards contracts, which otherwise
540
+ // have no observable.
541
+ Then('no dialog should be open', async ({ page }) => {
542
+ await expect(page.locator(DIALOG_SELECTOR)).toHaveCount(0);
543
+ });
544
+ // Proves the dialog's single unlabelled field accepts input.
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)));
547
+ });
548
+ // Proves the same for an alert dialog. Kept separate from `dialog` because a confirm prompt is
549
+ // routinely stacked ON TOP of an already-open dialog, and only the role tells them apart.
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)));
552
+ });
553
+ // Proves an unlabelled popover editor accepts input — a loop-count or filter popper typically
554
+ // holds one bare <input> with no label, id or test id.
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)));
557
+ });
558
+ // Proves a NAMED field inside the open dialog accepts input. Needed even though the field has a
559
+ // label: the form behind the overlay usually carries the same label, which makes an unscoped
560
+ // `getByLabel` ambiguous rather than wrong.
561
+ When('I fill the {string} field of the open dialog with {string}', async ({ page, apiContext, env }, label, value) => {
562
+ const scopes = scopesOf(apiContext, env);
563
+ const l = render(label, ...scopes);
564
+ const dialog = page.getByRole('dialog').last();
565
+ await expect(dialog, 'no dialog is open').toBeVisible();
566
+ const field = dialog.getByLabel(l);
567
+ await expect(field, `the open dialog has no "${l}" field`).toHaveCount(1);
568
+ await field.fill(render(value, ...scopes));
569
+ });
570
+ /**
571
+ * The topmost overlay of a kind is the LAST in DOM order — portals append to <body>, so a confirm
572
+ * stacked on a dialog sorts after it. Fails loudly when the overlay is not open, rather than
573
+ * filling something on the page behind it.
574
+ */
575
+ async function fillOverlayField(overlay, description, value) {
576
+ const field = overlay
577
+ .locator('input, textarea, [contenteditable="true"]')
578
+ .filter({ visible: true })
579
+ .first();
580
+ await expect(field, `no open ${description} with an editable field`).toBeVisible();
581
+ await field.fill(value);
582
+ }
583
+ //# sourceMappingURL=dom.steps.js.map
@@ -0,0 +1,2 @@
1
+ import './params.js';
2
+ //# sourceMappingURL=iframe.steps.d.ts.map