@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,653 @@
1
+ import { expect } from '@playwright/test';
2
+ import { Given, Then, When } from '../fixtures/test.js';
3
+ import { render } from '../api/template.js';
4
+ import { SdodsError } from '../errors.js';
5
+ /**
6
+ * Browser-state steps: cookies, web storage, viewport, colour scheme, locale and console.
7
+ *
8
+ * Every step here is generic capability that any web application needs and that the library
9
+ * previously lacked, which forced each onboarding to hand-write it. Each comment states what the
10
+ * step PROVES; where a step exists because of a specific trap, the trap is named.
11
+ *
12
+ * Two rules govern the whole file:
13
+ * * every string argument is interpolated through `render()`, so `{{vars}}` work everywhere;
14
+ * * no assertion may pass over an empty set. A selector that matches nothing, a body that
15
+ * rendered no text, a recorder that was never armed and a page that never navigated are all
16
+ * failures, because a green step that observed nothing is worse than no step at all.
17
+ */
18
+ const scopesOf = (apiContext, env) => [apiContext.vars.toObject(), env.vars];
19
+ /** A page that never navigated has no origin: storage throws, and a reload proves nothing. */
20
+ function requireNavigated(page, step) {
21
+ const url = page.url();
22
+ if (!url || url === 'about:blank')
23
+ throw new SdodsError('RUN_FAILED', `"${step}" ran before the page had navigated anywhere.`, {
24
+ hint: 'Navigate first (`Given I navigate to the "..." page`). Steps that must run BEFORE the first navigation — seeding storage, planting a cookie, arming the console recorder — are the `Given I seed …` / `Given I set the browser cookie …` / `Given I start recording …` family.',
25
+ });
26
+ return url;
27
+ }
28
+ function baseUrlOf(env, step) {
29
+ const url = env.ui?.baseUrl;
30
+ if (!url)
31
+ throw new SdodsError('CONFIG_INVALID', `"${step}" needs env.ui.baseUrl, which is not set.`, {
32
+ hint: 'Add `ui: { baseUrl: https://… }` to the environment yaml, or run this scenario on the ui layer.',
33
+ });
34
+ return url;
35
+ }
36
+ /* ── cookies ──────────────────────────────────────────────────────────── */
37
+ // Proves a session, consent or attribution gate can be seeded before the app ever loads.
38
+ // TRAP: the cookie is scoped to `env.ui.baseUrl`, never to `page.url()`. Before the first
39
+ // navigation the page is on `about:blank`, and Playwright rejects a cookie scoped to it — so a
40
+ // step written against `page.url()` works only in the middle of a scenario and fails in a
41
+ // Background, which is exactly where session seeding belongs.
42
+ Given('I set the browser cookie {string} to {string}', async ({ page, apiContext, env }, name, value) => {
43
+ const scopes = scopesOf(apiContext, env);
44
+ await page.context().addCookies([
45
+ {
46
+ name: render(name, ...scopes),
47
+ value: render(value, ...scopes),
48
+ url: baseUrlOf(env, 'I set the browser cookie'),
49
+ },
50
+ ]);
51
+ });
52
+ // Proves the app re-gates on a missing cookie rather than serving a cached authenticated shell.
53
+ // TRAP: Playwright exposes only `clearCookies()` for the WHOLE context, so removing one cookie is
54
+ // read-filter-restore. Clearing the jar to drop a session cookie would also drop the consent and
55
+ // locale cookies the scenario set up, and the failure would look like a bug in the app.
56
+ Given('I clear the browser cookie {string}', async ({ page, apiContext, env }, name) => {
57
+ const wanted = render(name, ...scopesOf(apiContext, env));
58
+ const context = page.context();
59
+ const all = await context.cookies();
60
+ const survivors = all.filter((c) => c.name !== wanted);
61
+ if (survivors.length === all.length)
62
+ return; // nothing to clear; do not disturb the jar
63
+ await context.clearCookies();
64
+ await context.addCookies(survivors);
65
+ });
66
+ // Proves a first-visit experience: no session, no consent record, no attribution.
67
+ Given('I clear all browser cookies', async ({ page }) => {
68
+ await page.context().clearCookies();
69
+ });
70
+ // Proves the app wrote the value it claims to write. An httpOnly cookie is invisible to
71
+ // `document.cookie`, so the browser context is the only place its value is observable at all.
72
+ Then('the browser cookie {string} should equal {string}', async ({ page, apiContext, env }, name, expected) => {
73
+ const scopes = scopesOf(apiContext, env);
74
+ const wanted = render(name, ...scopes);
75
+ const cookie = await findCookie(page, wanted);
76
+ expect(cookie, `cookie "${wanted}" is not in the jar`).toBeTruthy();
77
+ expect(cookie.value, `cookie "${wanted}"`).toBe(render(expected, ...scopes));
78
+ });
79
+ // Proves the cookie was issued at all, for cases where the value is opaque or random.
80
+ Then('the browser cookie {string} should exist', async ({ page, apiContext, env }, name) => {
81
+ const wanted = render(name, ...scopesOf(apiContext, env));
82
+ expect(await findCookie(page, wanted), `cookie "${wanted}" is not in the jar`).toBeTruthy();
83
+ });
84
+ // Proves a logout, a rejected consent or a declined attribution left nothing behind.
85
+ Then('the browser cookie {string} should not exist', async ({ page, apiContext, env }, name) => {
86
+ const wanted = render(name, ...scopesOf(apiContext, env));
87
+ expect(await findCookie(page, wanted), `unexpected cookie "${wanted}"`).toBeUndefined();
88
+ });
89
+ /** The attributes Playwright actually reports. Anything else is an authoring mistake. */
90
+ const COOKIE_ATTRIBUTES = ['domain', 'path', 'expires', 'httpOnly', 'secure', 'sameSite'];
91
+ // Proves the FLAGS, not just the value — the flags are the security contract. A session cookie
92
+ // readable from script is a theft surface and `SameSite=None` lets any third-party embed replay it.
93
+ // TRAP: an unrecognised attribute name would read back as `undefined` and compare equal to the
94
+ // string "undefined", so the step would pass while asserting nothing; the allowlist refuses it.
95
+ Then('the browser cookie {string} should carry {string} equal to {string}', async ({ page, apiContext, env }, name, attribute, value) => {
96
+ const scopes = scopesOf(apiContext, env);
97
+ const wanted = render(name, ...scopes);
98
+ const attr = render(attribute, ...scopes);
99
+ if (!COOKIE_ATTRIBUTES.includes(attr))
100
+ throw new SdodsError('RUN_FAILED', `"${attr}" is not a cookie attribute.`, {
101
+ hint: `Use one of: ${COOKIE_ATTRIBUTES.join(', ')}.`,
102
+ });
103
+ const cookie = await findCookie(page, wanted);
104
+ expect(cookie, `cookie "${wanted}" is not in the jar`).toBeTruthy();
105
+ const actual = cookie[attr];
106
+ expect(String(actual), `${wanted}.${attr}`).toBe(render(value, ...scopes));
107
+ });
108
+ async function findCookie(page, name) {
109
+ return (await page.context().cookies()).find((c) => c.name === name);
110
+ }
111
+ /**
112
+ * Seeding is an init script FIRST, because the values an app reads during its blocking inline
113
+ * script — a theme preference, a feature-flag bootstrap, a dismissed banner — are read before the
114
+ * first frame. A seed written with `evaluate` after navigating is already too late: the app has
115
+ * read the empty value and rendered against it. The in-page write is the second half, so that
116
+ * seeding mid-scenario (with the page already open) is not silently a no-op until the next reload.
117
+ */
118
+ async function seedStorage(page, kind, key, value) {
119
+ await page.addInitScript((args) => {
120
+ try {
121
+ const store = args.kind === 'local' ? window.localStorage : window.sessionStorage;
122
+ store.setItem(args.key, args.value);
123
+ }
124
+ catch {
125
+ /* about:blank, or a context with storage blocked — the in-page write reports it instead */
126
+ }
127
+ }, { kind, key, value });
128
+ if (isNavigated(page)) {
129
+ const result = (await page.evaluate((args) => {
130
+ try {
131
+ const store = args.kind === 'local' ? window.localStorage : window.sessionStorage;
132
+ store.setItem(args.key, args.value);
133
+ return { ok: true };
134
+ }
135
+ catch (e) {
136
+ return { ok: false, reason: String(e?.message ?? e) };
137
+ }
138
+ }, { kind, key, value }));
139
+ if (!result.ok)
140
+ throw new SdodsError('RUN_FAILED', `Could not write ${kind} storage "${key}": ${result.reason}`, {
141
+ hint: 'The browser context blocks storage for this origin (third-party cookie blocking, or a file:// page).',
142
+ });
143
+ }
144
+ }
145
+ function isNavigated(page) {
146
+ const url = page.url();
147
+ return Boolean(url) && url !== 'about:blank';
148
+ }
149
+ /**
150
+ * Reading is deliberately NOT tolerant. A storage-blocked or never-navigated context returns no
151
+ * value, and treating that as "the key is absent" would make `should be absent` pass in exactly
152
+ * the situation where the step observed nothing at all.
153
+ */
154
+ async function readStorage(page, kind, key) {
155
+ requireNavigated(page, `reading ${kind} storage`);
156
+ const result = (await page.evaluate((args) => {
157
+ try {
158
+ const store = args.kind === 'local' ? window.localStorage : window.sessionStorage;
159
+ return { ok: true, value: store.getItem(args.key) };
160
+ }
161
+ catch (e) {
162
+ return { ok: false, reason: String(e?.message ?? e) };
163
+ }
164
+ }, { kind, key }));
165
+ if (!result.ok)
166
+ throw new SdodsError('RUN_FAILED', `Could not read ${kind} storage "${key}": ${result.reason}`, {
167
+ hint: 'The browser context blocks storage for this origin, so no assertion about it can mean anything.',
168
+ });
169
+ return result.value ?? null;
170
+ }
171
+ // Proves a value the app reads during its first blocking script — theme, flag bootstrap, dismissed
172
+ // banner — reaches it before the first paint rather than after it.
173
+ Given('I seed local storage {string} with {string}', async ({ page, apiContext, env }, key, value) => {
174
+ const scopes = scopesOf(apiContext, env);
175
+ await seedStorage(page, 'local', render(key, ...scopes), render(value, ...scopes));
176
+ });
177
+ // Same contract as local storage, for the per-tab values an app keeps out of the persistent store.
178
+ Given('I seed session storage {string} with {string}', async ({ page, apiContext, env }, key, value) => {
179
+ const scopes = scopesOf(apiContext, env);
180
+ await seedStorage(page, 'session', render(key, ...scopes), render(value, ...scopes));
181
+ });
182
+ // Proves the DEFAULT path: what the app does for a visitor with no stored preference. Removal is
183
+ // armed as an init script as well as applied in place, so the next navigation cannot resurrect it.
184
+ Given('I clear the local storage key {string}', async ({ page, apiContext, env }, key) => {
185
+ const wanted = render(key, ...scopesOf(apiContext, env));
186
+ await page.addInitScript((k) => {
187
+ try {
188
+ window.localStorage.removeItem(k);
189
+ }
190
+ catch {
191
+ /* about:blank, or storage blocked */
192
+ }
193
+ }, wanted);
194
+ if (isNavigated(page))
195
+ await page.evaluate((k) => {
196
+ try {
197
+ window.localStorage.removeItem(k);
198
+ }
199
+ catch {
200
+ /* storage blocked */
201
+ }
202
+ }, wanted);
203
+ });
204
+ // Proves a genuinely first-visit state. Init scripts run in registration order, so a `seed` step
205
+ // written after this one still wins on the next navigation — clear first, then seed.
206
+ Given('I clear all browser storage', async ({ page }) => {
207
+ await page.addInitScript(() => {
208
+ try {
209
+ window.localStorage.clear();
210
+ window.sessionStorage.clear();
211
+ }
212
+ catch {
213
+ /* about:blank, or storage blocked */
214
+ }
215
+ });
216
+ if (isNavigated(page))
217
+ await page.evaluate(() => {
218
+ try {
219
+ window.localStorage.clear();
220
+ window.sessionStorage.clear();
221
+ }
222
+ catch {
223
+ /* storage blocked */
224
+ }
225
+ });
226
+ });
227
+ // Proves the app PERSISTED the choice, not merely that the UI moved. A preference that never
228
+ // reaches storage looks identical on screen until the next page load.
229
+ Then('local storage {string} should equal {string}', async ({ page, apiContext, env }, key, expected) => {
230
+ const scopes = scopesOf(apiContext, env);
231
+ const k = render(key, ...scopes);
232
+ expect(await readStorage(page, 'local', k), `local storage "${k}"`).toBe(render(expected, ...scopes));
233
+ });
234
+ // Proves a sign-out, a reset or a decline actually removed the key rather than blanking the UI.
235
+ Then('local storage {string} should be absent', async ({ page, apiContext, env }, key) => {
236
+ const k = render(key, ...scopesOf(apiContext, env));
237
+ expect(await readStorage(page, 'local', k), `local storage "${k}"`).toBeNull();
238
+ });
239
+ // Per-tab equivalent of the local-storage absence assertion, so a tab-scoped value that outlives
240
+ // the flow which set it fails as loudly as a persisted one.
241
+ Then('session storage {string} should be absent', async ({ page, apiContext, env }, key) => {
242
+ const k = render(key, ...scopesOf(apiContext, env));
243
+ expect(await readStorage(page, 'session', k), `session storage "${k}"`).toBeNull();
244
+ });
245
+ // Per-tab equivalent of the local-storage assertion.
246
+ Then('session storage {string} should equal {string}', async ({ page, apiContext, env }, key, expected) => {
247
+ const scopes = scopesOf(apiContext, env);
248
+ const k = render(key, ...scopes);
249
+ expect(await readStorage(page, 'session', k), `session storage "${k}"`).toBe(render(expected, ...scopes));
250
+ });
251
+ /* ── viewport and reflow ──────────────────────────────────────────────── */
252
+ // Proves a layout at a width the project's fixed viewport never visits. WCAG 2.2 reflow is a
253
+ // property of a DIFFERENT viewport (320 CSS pixels), so without a per-scenario resize it is
254
+ // unwritable — the run would simply re-test the one size already covered.
255
+ When('I resize the viewport to {int} by {int}', async ({ page }, width, height) => {
256
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1)
257
+ throw new SdodsError('RUN_FAILED', `Viewport ${width}x${height} is not a usable size.`, {
258
+ hint: 'Both dimensions must be positive integers, e.g. `When I resize the viewport to 320 by 800`.',
259
+ });
260
+ await page.setViewportSize({ width, height });
261
+ });
262
+ /**
263
+ * Measures the DOCUMENT, so a legitimately scrollable inner container never trips it — only
264
+ * page-level overflow does. Naming the offending elements is the point: "the page scrolls
265
+ * sideways" is unactionable, "nav.header-actions extends to 412px in a 320px viewport" is a fix.
266
+ */
267
+ function probeHorizontalOverflow() {
268
+ const doc = document.documentElement;
269
+ const limit = doc.clientWidth;
270
+ const offenders = [];
271
+ if (doc.scrollWidth > limit + 1) {
272
+ const all = Array.from(document.querySelectorAll('body *'));
273
+ for (const el of all) {
274
+ const rect = el.getBoundingClientRect();
275
+ if (rect.right > limit + 1 && rect.width > 0) {
276
+ const cls = String(el.className || '').split(/\s+/)[0];
277
+ offenders.push(`${el.tagName.toLowerCase()}${cls ? `.${cls}` : ''} right=${Math.round(rect.right)}`);
278
+ if (offenders.length >= 8)
279
+ break;
280
+ }
281
+ }
282
+ }
283
+ return {
284
+ bodyChildren: document.body ? document.body.children.length : 0,
285
+ scrollWidth: doc.scrollWidth,
286
+ clientWidth: limit,
287
+ offenders,
288
+ };
289
+ }
290
+ // Proves the 320px reflow contract: no content is reachable only by scrolling sideways.
291
+ // TRAP: a blank document also does not scroll sideways. The body-children guard is what stops this
292
+ // step going green on a page that failed to render, which is the one case it must not bless.
293
+ Then('the page should not scroll horizontally', async ({ page }) => {
294
+ requireNavigated(page, 'the page should not scroll horizontally');
295
+ const report = (await page.evaluate(probeHorizontalOverflow));
296
+ expect(report.bodyChildren, 'the document body is empty — a blank page cannot prove anything about reflow').toBeGreaterThan(0);
297
+ expect(report.scrollWidth, `the document is ${report.scrollWidth}px wide inside a ${report.clientWidth}px viewport. Offenders: ${report.offenders.join(', ') || '(none measurable)'}`).toBeLessThanOrEqual(report.clientWidth + 1);
298
+ });
299
+ // Proves ONE container contains its own width — a table, a code block or a chart that is allowed
300
+ // to scroll internally must not push the page. Fails when the selector matches nothing, because a
301
+ // containment claim about an element that is not on the page is not a claim.
302
+ Then('the element matching {string} should not overflow horizontally', async ({ page, apiContext, env }, selector) => {
303
+ const sel = render(selector, ...scopesOf(apiContext, env));
304
+ const info = (await page.evaluate((s) => {
305
+ const el = document.querySelector(s);
306
+ if (!el)
307
+ return { found: false, scrollWidth: 0, clientWidth: 0 };
308
+ return { found: true, scrollWidth: el.scrollWidth, clientWidth: el.clientWidth };
309
+ }, sel));
310
+ expect(info.found, `no element matches "${sel}"`).toBe(true);
311
+ expect(info.scrollWidth, `"${sel}" lays out ${info.scrollWidth}px of content in a ${info.clientWidth}px box`).toBeLessThanOrEqual(info.clientWidth + 1);
312
+ });
313
+ /* ── colour scheme and the painted theme ─────────────────────────────── */
314
+ const COLOUR_SCHEMES = ['light', 'dark', 'no-preference'];
315
+ // Proves the app honours the OS-level preference. This is the media query only; an app that stores
316
+ // its own preference is seeded with `Given I seed local storage …` instead, and a complete dark-mode
317
+ // scenario usually needs both.
318
+ Given('I emulate the {string} colour scheme', async ({ page, apiContext, env }, scheme) => {
319
+ const wanted = render(scheme, ...scopesOf(apiContext, env));
320
+ if (!COLOUR_SCHEMES.includes(wanted))
321
+ throw new SdodsError('RUN_FAILED', `"${wanted}" is not a colour scheme.`, {
322
+ hint: `Use one of: ${COLOUR_SCHEMES.join(', ')}.`,
323
+ });
324
+ await page.emulateMedia({ colorScheme: wanted });
325
+ });
326
+ /** Max channel below this is unambiguously a dark surface. */
327
+ export const DARK_MAX_CHANNEL = 90;
328
+ /** Min channel above this is unambiguously a light surface. */
329
+ export const LIGHT_MIN_CHANNEL = 160;
330
+ /**
331
+ * The first layer in an element-to-`<html>` chain that actually paints.
332
+ *
333
+ * TRAP: `background-color: transparent` computes to `rgba(0, 0, 0, 0)`. Read naively that is pure
334
+ * black, so a page with a transparent body reads as "dark" and every dark-mode assertion passes
335
+ * over a surface that was never painted. Alpha zero means "not painted here" — keep walking.
336
+ */
337
+ export function firstPaintedColour(chain) {
338
+ for (const layer of chain) {
339
+ const match = /^rgba?\(([^)]+)\)/.exec((layer.colour ?? '').trim());
340
+ if (!match?.[1])
341
+ continue;
342
+ // Both syntaxes reach here: the legacy `rgba(9, 9, 9, 0.5)` and the modern
343
+ // `rgb(9 9 9 / 0.5)` some engines now return from getComputedStyle.
344
+ const parts = match[1]
345
+ .split(/[\s,/]+/)
346
+ .filter(Boolean)
347
+ .map((p) => Number.parseFloat(p));
348
+ const [r, g, b, a] = parts;
349
+ if (r === undefined || g === undefined || b === undefined)
350
+ continue;
351
+ if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b))
352
+ continue;
353
+ if (a !== undefined && a === 0)
354
+ continue;
355
+ return { rgb: [r, g, b], from: layer.tag };
356
+ }
357
+ return null;
358
+ }
359
+ /** Collects the raw computed backgrounds; every judgement about them is made in Node. */
360
+ function collectBackgroundChain(selector) {
361
+ const start = selector ? document.querySelector(selector) : document.body;
362
+ if (!start)
363
+ return { found: false, chain: [] };
364
+ const chain = [];
365
+ let el = start;
366
+ while (el) {
367
+ chain.push({ tag: el.tagName.toLowerCase(), colour: getComputedStyle(el).backgroundColor });
368
+ el = el.parentElement;
369
+ }
370
+ return { found: true, chain };
371
+ }
372
+ async function paintedColour(page, selector, step) {
373
+ requireNavigated(page, step);
374
+ const result = (await page.evaluate(collectBackgroundChain, selector));
375
+ expect(result.found, `no element matches "${selector}"`).toBe(true);
376
+ const painted = firstPaintedColour(result.chain);
377
+ expect(painted, `every element from <${result.chain[0]?.tag ?? '?'}> up to <html> paints a transparent background, so nothing was painted to judge`).not.toBeNull();
378
+ return painted;
379
+ }
380
+ function expectDark(painted, subject) {
381
+ expect(Math.max(...painted.rgb), `${subject} painted rgb(${painted.rgb.join(', ')}) from <${painted.from}> — that is not a dark surface`).toBeLessThan(DARK_MAX_CHANNEL);
382
+ }
383
+ function expectLight(painted, subject) {
384
+ expect(Math.min(...painted.rgb), `${subject} painted rgb(${painted.rgb.join(', ')}) from <${painted.from}> — that is not a light surface`).toBeGreaterThan(LIGHT_MIN_CHANNEL);
385
+ }
386
+ // Proves the CLASS was applied. Necessary but never sufficient: a `dark` class with no CSS behind
387
+ // it is not dark mode, which is why the painted-background steps exist alongside it.
388
+ Then('the html element should carry the {string} class', async ({ page, apiContext, env }, cls) => {
389
+ const wanted = render(cls, ...scopesOf(apiContext, env));
390
+ const classes = await page.evaluate(() => document.documentElement.className);
391
+ expect(String(classes).split(/\s+/).filter(Boolean), `html class list is "${classes}"`).toContain(wanted);
392
+ });
393
+ // Proves the theme class was REMOVED — the direction that catches a toggle which only ever adds.
394
+ Then('the html element should not carry the {string} class', async ({ page, apiContext, env }, cls) => {
395
+ const wanted = render(cls, ...scopesOf(apiContext, env));
396
+ const classes = await page.evaluate(() => document.documentElement.className);
397
+ expect(String(classes).split(/\s+/).filter(Boolean), `html class list is "${classes}"`).not.toContain(wanted);
398
+ });
399
+ // Proves what the user actually SAW. This is the assertion a class check cannot make: the class
400
+ // can be present while the stylesheet that acts on it never loaded, and the page renders white.
401
+ Then('the painted page background should be dark', async ({ page }) => {
402
+ expectDark(await paintedColour(page, null, 'the painted page background should be dark'), 'body');
403
+ });
404
+ // The light-mode direction, so a theme stuck in dark fails as loudly as one stuck in light.
405
+ Then('the painted page background should be light', async ({ page }) => {
406
+ expectLight(await paintedColour(page, null, 'the painted page background should be light'), 'body');
407
+ });
408
+ // Proves ONE surface themed — a panel, a modal, a sidebar. Whole-page checks miss the component
409
+ // that kept a hardcoded white while everything around it went dark.
410
+ Then('the element matching {string} should paint a dark background', async ({ page, apiContext, env }, selector) => {
411
+ const sel = render(selector, ...scopesOf(apiContext, env));
412
+ expectDark(await paintedColour(page, sel, `the element matching "${sel}"`), `"${sel}"`);
413
+ });
414
+ /** Where the first-frame sample is parked. Stable by design: the assertion step reads it back. */
415
+ const FIRST_FRAME_KEY = '__sdodsFirstPaintedBackground';
416
+ // Arms a one-shot sample of the FIRST rendered frame, before the app's own scripts can repaint.
417
+ // TRAP: a flash of white is invisible to every assertion taken after load — by then the theme has
418
+ // resolved and the page looks correct. Catching a flash requires sampling before it is over, so
419
+ // this step must be written BEFORE the navigation whose first frame it is about to judge.
420
+ Given('I record the background painted on the first frame', async ({ page }) => {
421
+ await page.addInitScript((key) => {
422
+ // The walk is repeated here rather than shared, because an init script is serialised on its
423
+ // own and cannot reference a module-scope helper. It collects raw colours only; the judgement
424
+ // of which layer counts as painted lives in one place, `firstPaintedColour()`.
425
+ const sample = () => {
426
+ requestAnimationFrame(() => {
427
+ const store = window;
428
+ if (store[key] !== undefined)
429
+ return;
430
+ const chain = [];
431
+ let el = document.body;
432
+ while (el) {
433
+ chain.push({
434
+ tag: el.tagName.toLowerCase(),
435
+ colour: getComputedStyle(el).backgroundColor,
436
+ });
437
+ el = el.parentElement;
438
+ }
439
+ store[key] = chain;
440
+ });
441
+ };
442
+ if (document.readyState === 'loading') {
443
+ document.addEventListener('DOMContentLoaded', sample, { once: true });
444
+ }
445
+ else {
446
+ sample();
447
+ }
448
+ }, FIRST_FRAME_KEY);
449
+ });
450
+ async function firstFramePainted(page) {
451
+ requireNavigated(page, 'the first painted frame assertions');
452
+ const chain = (await page.evaluate((key) => window[key], FIRST_FRAME_KEY));
453
+ if (!chain)
454
+ throw new SdodsError('RUN_FAILED', 'No first-frame background was sampled.', {
455
+ hint: 'Add `Given I record the background painted on the first frame` BEFORE the navigation you want to judge — arming it afterwards samples nothing.',
456
+ });
457
+ const painted = firstPaintedColour(chain);
458
+ expect(painted, 'the first frame painted nothing: every layer from <body> up to <html> was transparent').not.toBeNull();
459
+ return painted;
460
+ }
461
+ // Proves there was NO light flash before the dark theme resolved — the defect a post-load
462
+ // assertion structurally cannot see.
463
+ Then('the first painted frame should have been dark', async ({ page }) => {
464
+ expectDark(await firstFramePainted(page), 'the first painted frame');
465
+ });
466
+ // The same guarantee for a light theme: no dark flash on the way in.
467
+ Then('the first painted frame should have been light', async ({ page }) => {
468
+ expectLight(await firstFramePainted(page), 'the first painted frame');
469
+ });
470
+ /* ── locale ───────────────────────────────────────────────────────────── */
471
+ // Proves the app resolves the locale from ITS OWN cookie, which is what a language switcher
472
+ // actually writes. The cookie name is an argument because it is app-specific.
473
+ // TRAP: a reload on `about:blank` succeeds and proves nothing, so the step refuses it — plant the
474
+ // cookie with `Given I set the browser cookie` if you need it before the first navigation.
475
+ Given('I switch the interface locale to {string} using the {string} cookie', async ({ page, apiContext, env }, locale, cookieName) => {
476
+ const scopes = scopesOf(apiContext, env);
477
+ requireNavigated(page, 'I switch the interface locale');
478
+ await page.context().addCookies([
479
+ {
480
+ name: render(cookieName, ...scopes),
481
+ value: render(locale, ...scopes),
482
+ url: baseUrlOf(env, 'I switch the interface locale'),
483
+ },
484
+ ]);
485
+ await page.reload({ waitUntil: 'domcontentloaded' });
486
+ });
487
+ // Proves negotiation from the browser's own preference, which is the path a first-time visitor
488
+ // takes before any cookie exists.
489
+ // TRAP: `setExtraHTTPHeaders` REPLACES the page's whole extra-header map on every call, so a
490
+ // second call elsewhere in the scenario silently drops this one. Set the locale once.
491
+ Given('I request the locale {string} with the Accept-Language header', async ({ page, apiContext, env }, locale) => {
492
+ requireNavigated(page, 'I request the locale with the Accept-Language header');
493
+ await page.setExtraHTTPHeaders({
494
+ 'Accept-Language': render(locale, ...scopesOf(apiContext, env)),
495
+ });
496
+ await page.reload({ waitUntil: 'domcontentloaded' });
497
+ });
498
+ // Proves the document declared the language it served. Screen readers, hyphenation and every
499
+ // `:lang()` rule key off this, and it is the one machine-checkable trace of the negotiation.
500
+ Then('the html lang attribute should be {string}', async ({ page, apiContext, env }, lang) => {
501
+ const wanted = render(lang, ...scopesOf(apiContext, env));
502
+ const actual = await page.evaluate(() => document.documentElement.getAttribute('lang'));
503
+ expect(actual, 'html lang attribute').toBe(wanted);
504
+ });
505
+ // Proves no right-to-left locale was added without the layout work behind it. An `dir="rtl"`
506
+ // document in a layout built for LTR is broken in a way no text assertion notices.
507
+ Then('the html dir attribute should be absent or ltr', async ({ page }) => {
508
+ const dir = await page.evaluate(() => document.documentElement.getAttribute('dir'));
509
+ expect(dir === null || dir === '' || dir === 'ltr', `html dir="${dir}" — a right-to-left locale is being served into a left-to-right layout`).toBe(true);
510
+ });
511
+ /** Dotted tokens that are not message keys: file names, hostnames and version strings. */
512
+ const NOT_A_KEY_SUFFIX = /\.(com|io|ai|dev|org|net|app|co|uk|de|jp|cn|json|js|mjs|cjs|ts|tsx|jsx|css|scss|html|xml|yml|yaml|md|txt|pdf|png|jpg|jpeg|gif|svg|webp|ico|woff|woff2)$/i;
513
+ /**
514
+ * A missing translation surfaces as the raw catalogue key rendered into the page — `nav.settings`
515
+ * where "Settings" belongs. Nothing throws, nothing logs, and the layout is unchanged, so only the
516
+ * shape of the text gives it away.
517
+ */
518
+ export function looksLikeMessageKey(text) {
519
+ const token = text.trim();
520
+ if (!/^[a-z][A-Za-z0-9]*(?:\.[A-Za-z0-9_]+)+$/.test(token))
521
+ return false;
522
+ if (NOT_A_KEY_SUFFIX.test(token))
523
+ return false;
524
+ const segments = token.split('.');
525
+ // `v1.2.3`, `x.0` — a version, not a key.
526
+ if (segments.slice(1).every((s) => /^\d+$/.test(s)))
527
+ return false;
528
+ return true;
529
+ }
530
+ /** Returns the visible single-word tokens plus how much text was examined at all. */
531
+ function collectVisibleTokens() {
532
+ const skip = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'TITLE'];
533
+ const tokens = new Set();
534
+ let sampled = 0;
535
+ if (!document.body)
536
+ return { sampled, tokens: [] };
537
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
538
+ let node = walker.nextNode();
539
+ while (node) {
540
+ const parent = node.parentElement;
541
+ const text = (node.textContent ?? '').trim();
542
+ if (text && parent && skip.indexOf(parent.tagName) === -1) {
543
+ const el = parent;
544
+ const visible = typeof el.checkVisibility === 'function' ? el.checkVisibility() : el.offsetParent !== null;
545
+ if (visible) {
546
+ sampled += 1;
547
+ if (text.length < 80 && !/\s/.test(text))
548
+ tokens.add(text);
549
+ }
550
+ }
551
+ node = walker.nextNode();
552
+ }
553
+ return { sampled, tokens: Array.from(tokens) };
554
+ }
555
+ // Proves the catalogue actually resolved for this locale. A framework that deep-merges English
556
+ // under every locale hides a missing translation completely; remove that fallback and the raw key
557
+ // is what ships, and this is the only assertion that sees it.
558
+ // TRAP: a page that rendered no text at all would satisfy "no key-shaped text" trivially. The
559
+ // sampled-count guard turns that case into a failure instead of a free pass.
560
+ Then('no visible text should look like a raw message key', async ({ page }) => {
561
+ requireNavigated(page, 'no visible text should look like a raw message key');
562
+ const seen = (await page.evaluate(collectVisibleTokens));
563
+ expect(seen.sampled, 'the page rendered no visible text at all, so this step examined nothing').toBeGreaterThan(0);
564
+ const suspects = seen.tokens.filter(looksLikeMessageKey);
565
+ expect(suspects, 'raw message keys rendered as visible text').toEqual([]);
566
+ });
567
+ /**
568
+ * Per-scenario, keyed on the scenario's own `apiContext` — never a module-level array, which would
569
+ * leak one scenario's console output into the next and into every parallel worker.
570
+ */
571
+ const recordings = new WeakMap();
572
+ function recordingFor(apiContext, step) {
573
+ const found = recordings.get(apiContext);
574
+ if (!found)
575
+ throw new SdodsError('RUN_FAILED', `"${step}" ran without a console recording.`, {
576
+ hint: 'Add `Given I start recording console messages` BEFORE the navigation that would emit — a recorder armed afterwards has already missed everything.',
577
+ });
578
+ return found;
579
+ }
580
+ /**
581
+ * A pattern that will not compile is an authoring mistake, not a test failure. Left bare it
582
+ * surfaces as a `SyntaxError` from deep inside the step, naming neither the step nor the pattern.
583
+ */
584
+ function compilePattern(pattern, step) {
585
+ try {
586
+ return new RegExp(pattern);
587
+ }
588
+ catch (e) {
589
+ throw new SdodsError('RUN_FAILED', `"${step}" was given the unusable pattern /${pattern}/.`, {
590
+ hint: 'The pattern is a JavaScript regular expression; escape literal dots, brackets and parentheses.',
591
+ cause: e,
592
+ });
593
+ }
594
+ }
595
+ // Arms the recorder. Buckets are kept separate on purpose: a failed third-party beacon logs to
596
+ // `console.error` and is not an uncaught exception, and folding warnings into errors would widen
597
+ // "no console error" into something no team can keep green.
598
+ // TRAP: listeners attached after the navigation see nothing that the navigation emitted, so this
599
+ // belongs before it. Calling it twice is a no-op rather than a second set of listeners, so a
600
+ // Background and a Scenario can both declare it without double-counting every message.
601
+ Given('I start recording console messages', async ({ page, apiContext }) => {
602
+ if (recordings.has(apiContext))
603
+ return;
604
+ const recording = { errors: [], warnings: [], pageErrors: [] };
605
+ recordings.set(apiContext, recording);
606
+ page.on('console', (msg) => {
607
+ if (msg.type() === 'error')
608
+ recording.errors.push(msg.text());
609
+ else if (msg.type() === 'warning')
610
+ recording.warnings.push(msg.text());
611
+ });
612
+ page.on('pageerror', (err) => {
613
+ recording.pageErrors.push(String(err?.message ?? err));
614
+ });
615
+ });
616
+ // Proves the page rendered without complaining. "The empty state rendered" and "the component
617
+ // threw and rendered nothing" look identical to every visual assertion; the console tells them apart.
618
+ Then('no console error should have been recorded', async ({ apiContext }) => {
619
+ const recording = recordingFor(apiContext, 'no console error should have been recorded');
620
+ expect(recording.errors, 'console errors').toEqual([]);
621
+ });
622
+ // The targeted form: proves one named class of error is absent — a missing translation key, a
623
+ // hydration mismatch, a blocked request — while tolerating noise the team has accepted.
624
+ Then('no console error should match {string}', async ({ apiContext, env }, pattern) => {
625
+ const recording = recordingFor(apiContext, 'no console error should match');
626
+ const rendered = render(pattern, apiContext.vars.toObject(), env.vars);
627
+ const re = compilePattern(rendered, 'no console error should match');
628
+ expect(recording.errors.filter((line) => re.test(line)), `console errors matching /${rendered}/`).toEqual([]);
629
+ });
630
+ // The same for warnings, which is where several frameworks report a missing message or a
631
+ // deprecated API — never at error level.
632
+ Then('no console warning should match {string}', async ({ apiContext, env }, pattern) => {
633
+ const recording = recordingFor(apiContext, 'no console warning should match');
634
+ const rendered = render(pattern, apiContext.vars.toObject(), env.vars);
635
+ const re = compilePattern(rendered, 'no console warning should match');
636
+ expect(recording.warnings.filter((line) => re.test(line)), `console warnings matching /${rendered}/`).toEqual([]);
637
+ });
638
+ // The POSITIVE direction, and the reason the negative steps above can be trusted: a scenario that
639
+ // deliberately breaks something asserts the error WAS reported. Without one of these in the suite,
640
+ // a recorder that silently stopped working would leave every "no console error" step green.
641
+ Then('a console error matching {string} should have been recorded', async ({ apiContext, env }, pattern) => {
642
+ const recording = recordingFor(apiContext, 'a console error matching … should have been recorded');
643
+ const rendered = render(pattern, apiContext.vars.toObject(), env.vars);
644
+ const re = compilePattern(rendered, 'a console error matching … should have been recorded');
645
+ expect(recording.errors.filter((line) => re.test(line)).length, `no console error matched /${rendered}/. Recorded: ${recording.errors.slice(0, 10).join(' | ') || '(none)'}`).toBeGreaterThan(0);
646
+ });
647
+ // Proves nothing THREW. Distinct from a console error: an uncaught exception stops a render, and
648
+ // a page that threw is broken whatever it managed to paint before it did.
649
+ Then('no uncaught page error should have been recorded', async ({ apiContext }) => {
650
+ const recording = recordingFor(apiContext, 'no uncaught page error should have been recorded');
651
+ expect(recording.pageErrors, 'uncaught page errors').toEqual([]);
652
+ });
653
+ //# sourceMappingURL=browser.steps.js.map
@@ -0,0 +1,4 @@
1
+ import './params.js';
2
+ /** ISO-8601, or a relative offset like "+2 days" / "-30 minutes". */
3
+ export declare function resolveTime(spec: string, from: Date): Date;
4
+ //# sourceMappingURL=clock.steps.d.ts.map