@sdods/core 0.2.1 → 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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/analyze/detectors.js +236 -24
- package/dist/analyze/propose.js +32 -10
- package/dist/analyze/scan.js +19 -1
- package/dist/api/client.js +7 -1
- package/dist/auth/capture.js +21 -7
- package/dist/auth/index.js +71 -12
- package/dist/config/resolve.d.ts +13 -0
- package/dist/config/resolve.js +1 -0
- package/dist/config/runner.js +3 -0
- package/dist/config/tags.d.ts +29 -1
- package/dist/config/tags.js +46 -0
- package/dist/data/provider.js +5 -1
- package/dist/data/user-pool.js +35 -3
- package/dist/fixtures/api-context.d.ts +14 -1
- package/dist/fixtures/api-context.js +13 -0
- package/dist/fixtures/auth.d.ts +9 -1
- package/dist/fixtures/auth.js +13 -5
- package/dist/fixtures/test.js +42 -1
- package/dist/fixtures/types.d.ts +2 -0
- package/dist/har/api-har.d.ts +1 -0
- package/dist/har/api-har.js +1 -1
- package/dist/har/index.d.ts +1 -0
- package/dist/har/index.js +1 -0
- package/dist/har/scrub.d.ts +17 -0
- package/dist/har/scrub.js +60 -0
- package/dist/reporters/dashboard.d.ts +86 -0
- package/dist/reporters/dashboard.js +319 -61
- package/dist/steps/a11y.steps.d.ts +180 -0
- package/dist/steps/a11y.steps.js +598 -0
- package/dist/steps/api.steps.js +5 -1
- package/dist/steps/browser.steps.d.ts +27 -0
- package/dist/steps/browser.steps.js +653 -0
- package/dist/steps/clock.steps.d.ts +4 -0
- package/dist/steps/clock.steps.js +73 -0
- package/dist/steps/data.steps.js +50 -2
- package/dist/steps/db.steps.d.ts +5 -0
- package/dist/steps/db.steps.js +105 -0
- package/dist/steps/dom.steps.d.ts +2 -0
- package/dist/steps/dom.steps.js +583 -0
- package/dist/steps/iframe.steps.d.ts +2 -0
- package/dist/steps/iframe.steps.js +93 -0
- package/dist/steps/index.d.ts +10 -0
- package/dist/steps/index.js +10 -0
- package/dist/steps/net.steps.d.ts +63 -0
- package/dist/steps/net.steps.js +728 -0
- package/dist/steps/perf.steps.d.ts +248 -0
- package/dist/steps/perf.steps.js +514 -0
- package/dist/steps/tabs.steps.d.ts +5 -0
- package/dist/steps/tabs.steps.js +109 -0
- package/dist/steps/webhook.steps.d.ts +46 -0
- package/dist/steps/webhook.steps.js +129 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -4
|
@@ -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
|