@sdods/core 0.7.2 → 0.9.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 (94) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/a11y/audit.d.ts +88 -0
  3. package/dist/a11y/audit.js +134 -0
  4. package/dist/analyze/index.d.ts +2 -0
  5. package/dist/analyze/index.js +2 -0
  6. package/dist/analyze/traceability-render.d.ts +5 -0
  7. package/dist/analyze/traceability-render.js +254 -0
  8. package/dist/analyze/traceability.d.ts +87 -0
  9. package/dist/analyze/traceability.js +559 -0
  10. package/dist/api/json-path.d.ts +11 -1
  11. package/dist/api/json-path.js +23 -1
  12. package/dist/config/defaults.js +2 -0
  13. package/dist/config/emulation.d.ts +74 -0
  14. package/dist/config/emulation.js +154 -0
  15. package/dist/config/index.d.ts +1 -0
  16. package/dist/config/index.js +1 -0
  17. package/dist/config/resolve.js +1 -1
  18. package/dist/config/runner.js +4 -1
  19. package/dist/config/tags.d.ts +1 -1
  20. package/dist/config/tags.js +7 -0
  21. package/dist/data/index.d.ts +1 -0
  22. package/dist/data/index.js +1 -0
  23. package/dist/data/pool-capacity.d.ts +58 -0
  24. package/dist/data/pool-capacity.js +138 -0
  25. package/dist/data/user-pool.d.ts +43 -2
  26. package/dist/data/user-pool.js +170 -40
  27. package/dist/errors.d.ts +1 -1
  28. package/dist/fixtures/api-context.d.ts +19 -1
  29. package/dist/fixtures/api-context.js +28 -1
  30. package/dist/fixtures/scenario.d.ts +11 -0
  31. package/dist/fixtures/scenario.js +15 -0
  32. package/dist/fixtures/test.js +60 -3
  33. package/dist/fixtures/types.d.ts +5 -0
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.js +4 -0
  36. package/dist/lint/index.js +86 -0
  37. package/dist/load/index.d.ts +4 -0
  38. package/dist/load/index.js +4 -0
  39. package/dist/load/profile.d.ts +22 -0
  40. package/dist/load/profile.js +109 -0
  41. package/dist/load/run.d.ts +85 -0
  42. package/dist/load/run.js +255 -0
  43. package/dist/load/script.d.ts +27 -0
  44. package/dist/load/script.js +188 -0
  45. package/dist/mail/extract.d.ts +38 -0
  46. package/dist/mail/extract.js +106 -0
  47. package/dist/mail/index.d.ts +8 -0
  48. package/dist/mail/index.js +11 -0
  49. package/dist/mail/mailpit.d.ts +26 -0
  50. package/dist/mail/mailpit.js +124 -0
  51. package/dist/mail/types.d.ts +38 -0
  52. package/dist/mail/types.js +2 -0
  53. package/dist/matrix/index.d.ts +117 -0
  54. package/dist/matrix/index.js +507 -0
  55. package/dist/perf/vitals.d.ts +118 -0
  56. package/dist/perf/vitals.js +236 -0
  57. package/dist/quality/a11y-scenario.d.ts +68 -0
  58. package/dist/quality/a11y-scenario.js +63 -0
  59. package/dist/quality/gates.d.ts +54 -0
  60. package/dist/quality/gates.js +144 -0
  61. package/dist/quality/hooks.d.ts +2 -0
  62. package/dist/quality/hooks.js +38 -0
  63. package/dist/quality/index.d.ts +4 -0
  64. package/dist/quality/index.js +2 -0
  65. package/dist/quality/perf-scenario.d.ts +132 -0
  66. package/dist/quality/perf-scenario.js +241 -0
  67. package/dist/shots/baselines.d.ts +72 -0
  68. package/dist/shots/baselines.js +201 -0
  69. package/dist/shots/hooks.js +2 -2
  70. package/dist/shots/index.d.ts +1 -0
  71. package/dist/shots/index.js +1 -0
  72. package/dist/shots/narrator.d.ts +10 -2
  73. package/dist/shots/narrator.js +106 -10
  74. package/dist/shots/policy.d.ts +5 -1
  75. package/dist/shots/policy.js +2 -0
  76. package/dist/steps/a11y.steps.d.ts +18 -51
  77. package/dist/steps/a11y.steps.js +6 -107
  78. package/dist/steps/api.steps.js +5 -7
  79. package/dist/steps/browser.steps.d.ts +12 -0
  80. package/dist/steps/browser.steps.js +154 -3
  81. package/dist/steps/canvas.steps.d.ts +9 -0
  82. package/dist/steps/canvas.steps.js +331 -0
  83. package/dist/steps/data.steps.js +17 -5
  84. package/dist/steps/email.steps.d.ts +2 -0
  85. package/dist/steps/email.steps.js +182 -0
  86. package/dist/steps/hooks.steps.d.ts +1 -0
  87. package/dist/steps/hooks.steps.js +3 -1
  88. package/dist/steps/hybrid.steps.js +2 -2
  89. package/dist/steps/index.d.ts +3 -0
  90. package/dist/steps/index.js +3 -0
  91. package/dist/steps/perf.steps.d.ts +3 -129
  92. package/dist/steps/perf.steps.js +4 -227
  93. package/dist/steps/ui.steps.js +5 -0
  94. package/package.json +4 -3
@@ -25,7 +25,15 @@ export declare class ScreenshotNarrator {
25
25
  beforeStep(stepIndex: number, _title?: string): Promise<void>;
26
26
  afterStep(stepIndex: number, _title?: string): Promise<void>;
27
27
  private stepShot;
28
- /** Visual baseline check (`@visual`): `expect(page).toHaveScreenshot(name)`. */
29
- visual(name: string, stepIndex: number): Promise<void>;
28
+ /**
29
+ * Visual baseline check (`@visual`): `expect(page).toHaveScreenshot(name)` with the merged mask
30
+ * and threshold (see {@link visualCheckOptions}). A failure leaves a {@link VisualFailure} record
31
+ * and copies of the images in the scenario directory for `sdods baselines` to review and accept.
32
+ */
33
+ visual(name: string, stepIndex: number, extraMask?: readonly string[]): Promise<void>;
34
+ private visualIdentity;
35
+ private recordVisualPass;
36
+ /** Never throws: the check's own error is what the scenario reports. */
37
+ private recordVisualFailure;
30
38
  }
31
39
  //# sourceMappingURL=narrator.d.ts.map
@@ -1,7 +1,9 @@
1
- import { existsSync } from 'node:fs';
1
+ import { copyFileSync, existsSync, writeFileSync } from 'node:fs';
2
+ import { relative } from 'node:path';
2
3
  import { expect } from '@playwright/test';
3
4
  import { attachmentNames, scenarioFiles, } from '@sdods/contracts';
4
5
  import { Logger } from '../logger.js';
6
+ import { baselineKey, parseVisualError, pngSize, visualCheckOptions } from './baselines.js';
5
7
  /** Takes the before/after/scenario screenshots dictated by the policy and attaches them with shared names. */
6
8
  export class ScreenshotNarrator {
7
9
  deps;
@@ -71,17 +73,36 @@ export class ScreenshotNarrator {
71
73
  stepShot(stepIndex, phase) {
72
74
  return this.capture(this.deps.scenario.file(scenarioFiles.stepShot(stepIndex, phase)), attachmentNames.shotStep(stepIndex, phase));
73
75
  }
74
- /** Visual baseline check (`@visual`): `expect(page).toHaveScreenshot(name)`. */
75
- async visual(name, stepIndex) {
76
+ /**
77
+ * Visual baseline check (`@visual`): `expect(page).toHaveScreenshot(name)` with the merged mask
78
+ * and threshold (see {@link visualCheckOptions}). A failure leaves a {@link VisualFailure} record
79
+ * and copies of the images in the scenario directory for `sdods baselines` to review and accept.
80
+ */
81
+ async visual(name, stepIndex, extraMask = []) {
76
82
  const { page, policy, testInfo } = this.deps;
77
83
  const fileName = name.endsWith('.png') ? name : `${name}.png`;
78
- await expect(page).toHaveScreenshot(fileName, {
79
- fullPage: policy.fullPage,
80
- animations: 'disabled',
81
- caret: 'hide',
82
- mask: policy.mask.map((sel) => page.locator(sel)),
83
- maxDiffPixelRatio: 0.01,
84
- });
84
+ const opts = visualCheckOptions(fileName, policy, extraMask);
85
+ const hadBaseline = existsSync(testInfo.snapshotPath(fileName));
86
+ try {
87
+ await expect(page).toHaveScreenshot(fileName, {
88
+ fullPage: policy.fullPage,
89
+ animations: 'disabled',
90
+ caret: 'hide',
91
+ mask: opts.mask.map((sel) => page.locator(sel)),
92
+ maxDiffPixelRatio: opts.maxDiffPixelRatio,
93
+ });
94
+ }
95
+ catch (e) {
96
+ this.recordVisualFailure(fileName, stepIndex, opts.maxDiffPixelRatio, e);
97
+ throw e;
98
+ }
99
+ // With Playwright's default `updateSnapshots: 'missing'` a missing baseline does not throw: it
100
+ // writes the actual image as the baseline and fails the test afterwards (a soft error). That is
101
+ // the first run on a new platform, and exactly the check `sdods baselines` must offer.
102
+ if (!hadBaseline && testInfo.config.updateSnapshots === 'missing')
103
+ this.recordVisualFailure(fileName, stepIndex, opts.maxDiffPixelRatio, new Error("A snapshot doesn't exist, writing actual."));
104
+ else
105
+ this.recordVisualPass(fileName);
85
106
  const snapshot = testInfo.snapshotPath(fileName);
86
107
  if (existsSync(snapshot))
87
108
  await testInfo.attach(attachmentNames.visual(stepIndex, name), {
@@ -89,6 +110,81 @@ export class ScreenshotNarrator {
89
110
  contentType: 'image/png',
90
111
  });
91
112
  }
113
+ visualIdentity(fileName) {
114
+ const { scenario, testInfo } = this.deps;
115
+ return {
116
+ name: baselineKey(fileName),
117
+ runnerProject: testInfo.project.name,
118
+ fingerprint: scenario.fingerprint,
119
+ retry: testInfo.retry,
120
+ };
121
+ }
122
+ recordVisualPass(fileName) {
123
+ const id = this.visualIdentity(fileName);
124
+ try {
125
+ writeFileSync(this.deps.scenario.file(scenarioFiles.visualPassed(id.runnerProject, id.name)), JSON.stringify({ ...id, recordedAt: new Date().toISOString() }));
126
+ }
127
+ catch (err) {
128
+ this.log.debug(`visual pass marker skipped: ${err.message}`);
129
+ }
130
+ }
131
+ /** Never throws: the check's own error is what the scenario reports. */
132
+ recordVisualFailure(fileName, stepIndex, maxDiffPixelRatio, error) {
133
+ const { scenario, testInfo } = this.deps;
134
+ try {
135
+ const id = this.visualIdentity(fileName);
136
+ const base = fileName.replace(/\.png$/i, '');
137
+ // Playwright attaches `<name>-actual.png` etc. and writes them to the test's output dir.
138
+ const source = (phase) => {
139
+ const att = testInfo.attachments.find((a) => a.name === `${base}-${phase}.png`)?.path;
140
+ const p = att ?? testInfo.outputPath(`${base}-${phase}.png`);
141
+ return existsSync(p) ? p : undefined;
142
+ };
143
+ const parsed = parseVisualError(error?.message ?? String(error));
144
+ const copies = {};
145
+ for (const phase of ['actual', 'expected', 'diff']) {
146
+ // A missing baseline's "expected" attachment is the actual image Playwright just wrote.
147
+ if (phase !== 'actual' && parsed.reason === 'missing')
148
+ continue;
149
+ const src = source(phase);
150
+ if (!src)
151
+ continue;
152
+ const dest = scenario.file(scenarioFiles.visualImage(id.runnerProject, id.name, phase));
153
+ copyFileSync(src, dest);
154
+ copies[phase] = relative(scenario.runDir, dest).split('\\').join('/');
155
+ }
156
+ // No actual image means the page could not be captured at all: nothing to accept.
157
+ if (!copies.actual)
158
+ return;
159
+ const size = pngSize(scenario.file(scenarioFiles.visualImage(id.runnerProject, id.name, 'actual')));
160
+ const record = {
161
+ ...id,
162
+ snapshot: fileName,
163
+ project: scenario.data.project,
164
+ platform: process.platform,
165
+ scenarioName: scenario.data.scenarioName,
166
+ featureUri: scenario.data.featureUri,
167
+ stepIndex,
168
+ reason: parsed.reason,
169
+ baseline: relative(scenario.projectRoot, testInfo.snapshotPath(fileName))
170
+ .split('\\')
171
+ .join('/'),
172
+ actual: copies.actual,
173
+ expected: copies.expected,
174
+ diff: copies.diff,
175
+ diffPixels: parsed.diffPixels,
176
+ diffRatio: parsed.diffPixels !== undefined && size
177
+ ? parsed.diffPixels / (size.width * size.height)
178
+ : undefined,
179
+ maxDiffPixelRatio,
180
+ recordedAt: new Date().toISOString(),
181
+ };
182
+ writeFileSync(scenario.file(scenarioFiles.visualFailure(id.runnerProject, id.name)), JSON.stringify(record, null, 2));
183
+ }
184
+ catch (err) {
185
+ this.log.debug(`visual failure record skipped: ${err.message}`);
186
+ }
187
+ }
92
188
  }
93
189
  function safeUrl(page) {
94
190
  try {
@@ -1,9 +1,13 @@
1
- import type { ShotPolicy } from '@sdods/contracts';
1
+ import type { BaselineConfig, ShotPolicy } from '@sdods/contracts';
2
2
  import type { ResolvedConfig } from '../config/resolve.js';
3
3
  export interface ShotPolicyResolved {
4
4
  mode: ShotPolicy;
5
5
  fullPage: boolean;
6
6
  mask: string[];
7
+ /** default share of pixels a visual baseline check may differ by */
8
+ maxDiffPixelRatio: number;
9
+ /** per-baseline mask and threshold, keyed by name without `.png` */
10
+ baselines: Record<string, BaselineConfig>;
7
11
  viewport: {
8
12
  width: number;
9
13
  height: number;
@@ -24,6 +24,8 @@ export function resolvePolicy(tags, config) {
24
24
  mode,
25
25
  fullPage: s.fullPage,
26
26
  mask: s.mask,
27
+ maxDiffPixelRatio: s.maxDiffPixelRatio ?? 0.01,
28
+ baselines: s.baselines ?? {},
27
29
  viewport: s.viewport,
28
30
  scenario: mode === 'scenario' || mode === 'step' || mode === 'visual',
29
31
  step: mode === 'step' || mode === 'visual',
@@ -1,57 +1,24 @@
1
1
  import './params.js';
2
+ import { type A11yResults } from '../a11y/audit.js';
2
3
  /**
3
- * Structural subset of axe-core's result types. Declared locally rather than imported from
4
- * `axe-core`: that package is a transitive dependency of `@axe-core/playwright`, not a direct
5
- * dependency of this one, so importing its types would couple the whole step library's
6
- * compilation to a package a consumer may not have installed.
7
- */
8
- export interface A11yViolationNode {
9
- target: unknown[];
10
- html?: string;
11
- failureSummary?: string;
12
- }
13
- export interface A11yViolation {
14
- id: string;
15
- impact?: string | null;
16
- help?: string;
17
- helpUrl?: string;
18
- nodes: A11yViolationNode[];
19
- }
20
- export interface A11yResults {
21
- violations: A11yViolation[];
22
- passes: {
23
- id: string;
24
- }[];
25
- incomplete: {
26
- id: string;
27
- }[];
28
- inapplicable: {
29
- id: string;
30
- }[];
31
- url?: string;
32
- }
33
- /** axe impact levels, weakest first. `impact: null` is treated as `minor`. */
34
- export declare const IMPACT_ORDER: readonly ["minor", "moderate", "serious", "critical"];
35
- /**
36
- * WCAG 2.0/2.1/2.2 level A and AA, and deliberately nothing else. axe's `best-practice` tag set
37
- * changes between axe releases, so including it would let the same unchanged page pass one week
38
- * and fail the next — a step whose verdict moves on its own teaches teams to ignore it.
39
- */
40
- export declare const WCAG_AA_TAGS: string[];
41
- /** Rank of an axe impact level; an absent impact ranks as `minor` rather than vanishing. */
42
- export declare function impactRank(impact: string | null | undefined): number;
43
- /** Rejects an impact level the feature file invented, instead of silently matching nothing. */
44
- export declare function parseImpactFloor(level: string): number;
45
- export declare function violationsAtOrAbove(violations: A11yViolation[], floor: number): A11yViolation[];
46
- /** Human-readable violation list: rule, impact, help text and the first few offending nodes. */
47
- export declare function describeViolations(violations: A11yViolation[]): string;
48
- /**
49
- * PROVES the audit actually executed. axe reporting zero violations is indistinguishable from
50
- * axe never having run: a nonce'd CSP that blocks the injected source, an `about:blank` page, or
51
- * an `include` that matched nothing all produce an empty, green-looking result. If no rule landed
52
- * in passes, violations or incomplete, then nothing was examined and the verdict means nothing.
4
+ * Accessibility steps: an axe-core audit plus the structural checks a rule engine cannot see.
5
+ *
6
+ * DESIGN — the browser gathers, node judges. Every DOM probe below returns plain serialisable
7
+ * records and nothing else; the pass/fail decision lives in an exported pure function. That split
8
+ * exists so the one rule that matters here is testable without a browser: a step that quantifies
9
+ * over a set MUST fail when the set is empty. "Every image has an alt" over a page with no images
10
+ * and "no violations" inside a region that does not exist are not passes, they are silence, and
11
+ * silence is what makes an accessibility suite worthless. `assertRegion` and `assertGathered` are
12
+ * the guards, and they throw rather than assert so the message can say what to do instead.
13
+ *
14
+ * WHY NO `heal.*` HERE. The rest of the UI library heals because it resolves ONE interactive
15
+ * element by role/name/text, and a near-miss substitute is still the control the author meant.
16
+ * These steps quantify over a POPULATION inside a region: healing a region selector to a
17
+ * different element would silently re-point the audit at a different set of nodes and report a
18
+ * green result for a surface nobody asked about. A missing region is a defect in the step, so it
19
+ * is reported as one.
53
20
  */
54
- export declare function assertAxeChecked(results: A11yResults, where: string): void;
21
+ export { IMPACT_ORDER, WCAG_AA_TAGS, assertAxeChecked, describeViolations, impactRank, parseImpactFloor, violationsAtOrAbove, type A11yResults, type A11yViolation, type A11yViolationNode, } from '../a11y/audit.js';
55
22
  export type RuleBucket = 'violations' | 'passes' | 'incomplete' | 'inapplicable' | 'none';
56
23
  /** Which of axe's four result buckets a rule landed in. `none` means the rule never ran. */
57
24
  export declare function ruleBucket(results: A11yResults, ruleId: string): RuleBucket;
@@ -4,6 +4,7 @@ import { attachmentNames, scenarioFiles } from '@sdods/contracts';
4
4
  import './params.js';
5
5
  import { Then } from '../fixtures/test.js';
6
6
  import { renderStrict } from '../api/template.js';
7
+ import { IMPACT_ORDER, assertAxeChecked, describeViolations, markPageAudited, parseImpactFloor, runAxe, violationsAtOrAbove, } from '../a11y/audit.js';
7
8
  import { SdodsError } from '../errors.js';
8
9
  import { Logger } from '../logger.js';
9
10
  /**
@@ -24,63 +25,11 @@ import { Logger } from '../logger.js';
24
25
  * green result for a surface nobody asked about. A missing region is a defect in the step, so it
25
26
  * is reported as one.
26
27
  */
28
+ // The audit and its judges moved to `../a11y/audit.ts` so the `@a11y` tag hook can share them
29
+ // without importing (and so re-registering) this step file. Re-exported for existing importers.
30
+ export { IMPACT_ORDER, WCAG_AA_TAGS, assertAxeChecked, describeViolations, impactRank, parseImpactFloor, violationsAtOrAbove, } from '../a11y/audit.js';
27
31
  const log = new Logger('a11y');
28
32
  const scopesOf = (apiContext, env) => [apiContext.vars.toObject(), env.vars];
29
- /** axe impact levels, weakest first. `impact: null` is treated as `minor`. */
30
- export const IMPACT_ORDER = ['minor', 'moderate', 'serious', 'critical'];
31
- /**
32
- * WCAG 2.0/2.1/2.2 level A and AA, and deliberately nothing else. axe's `best-practice` tag set
33
- * changes between axe releases, so including it would let the same unchanged page pass one week
34
- * and fail the next — a step whose verdict moves on its own teaches teams to ignore it.
35
- */
36
- export const WCAG_AA_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
37
- /* ── pure judges (exported so the vacuity guards are unit-testable) ─────── */
38
- /** Rank of an axe impact level; an absent impact ranks as `minor` rather than vanishing. */
39
- export function impactRank(impact) {
40
- const i = IMPACT_ORDER.indexOf((impact ?? 'minor'));
41
- return i === -1 ? 0 : i;
42
- }
43
- /** Rejects an impact level the feature file invented, instead of silently matching nothing. */
44
- export function parseImpactFloor(level) {
45
- const i = IMPACT_ORDER.indexOf(level);
46
- if (i === -1)
47
- throw new SdodsError('CONFIG_INVALID', `"${level}" is not an axe impact level.`, {
48
- hint: `Use one of: ${IMPACT_ORDER.join(', ')}.`,
49
- });
50
- return i;
51
- }
52
- export function violationsAtOrAbove(violations, floor) {
53
- return violations.filter((v) => impactRank(v.impact) >= floor);
54
- }
55
- /** Human-readable violation list: rule, impact, help text and the first few offending nodes. */
56
- export function describeViolations(violations) {
57
- return violations
58
- .map((v) => {
59
- const nodes = v.nodes ?? [];
60
- const shown = nodes
61
- .slice(0, 3)
62
- .map((n) => (Array.isArray(n.target) ? n.target.join(' ') : String(n.target)))
63
- .join(' | ');
64
- const more = nodes.length > 3 ? ` (+${nodes.length - 3} more node(s))` : '';
65
- return `${v.id} [${v.impact ?? 'minor'}] ${v.help ?? ''} → ${nodes.length} node(s): ${shown}${more}`;
66
- })
67
- .join('\n');
68
- }
69
- /**
70
- * PROVES the audit actually executed. axe reporting zero violations is indistinguishable from
71
- * axe never having run: a nonce'd CSP that blocks the injected source, an `about:blank` page, or
72
- * an `include` that matched nothing all produce an empty, green-looking result. If no rule landed
73
- * in passes, violations or incomplete, then nothing was examined and the verdict means nothing.
74
- */
75
- export function assertAxeChecked(results, where) {
76
- const checked = (results.passes?.length ?? 0) +
77
- (results.violations?.length ?? 0) +
78
- (results.incomplete?.length ?? 0);
79
- if (checked === 0)
80
- throw new SdodsError('RUN_FAILED', `axe examined nothing ${where}; the audit proves nothing.`, {
81
- hint: 'The page may be blank, the scoped selector may match an empty element, or a Content-Security-Policy may have blocked axe from running. Open the attached sdods/a11y JSON to see what axe reported.',
82
- });
83
- }
84
33
  /** Which of axe's four result buckets a rule landed in. `none` means the rule never ran. */
85
34
  export function ruleBucket(results, ruleId) {
86
35
  if (results.violations?.some((r) => r.id === ruleId))
@@ -354,58 +303,6 @@ export const gatherFocusIndicators = (sel) => {
354
303
  }
355
304
  return { regionFound: true, items: out };
356
305
  };
357
- /**
358
- * Runs axe through `@axe-core/playwright`, which injects the source with `page.evaluate` rather
359
- * than `addScriptTag`. That matters: an app serving a nonce'd CSP blocks an injected `<script>`
360
- * silently, and the audit would report zero violations because it never ran.
361
- *
362
- * The import is lazy so a project that never writes an a11y step does not need the dependency —
363
- * a top-level import would fail the load of this whole step file and take the rest of the core
364
- * library down with it.
365
- */
366
- async function runAxe(page, opts) {
367
- let AxeBuilder;
368
- try {
369
- const mod = await import('@axe-core/playwright');
370
- // Named export first: the package exports the class as both `AxeBuilder` and `default`, and a
371
- // CJS interop path can hand `default` back as the namespace object rather than the class.
372
- AxeBuilder = (mod.AxeBuilder ?? mod.default);
373
- }
374
- catch (cause) {
375
- throw new SdodsError('NOT_SUPPORTED', 'Accessibility steps need @axe-core/playwright.', {
376
- hint: 'Install it in the project that runs these steps: `npm i -D @axe-core/playwright`.',
377
- cause,
378
- });
379
- }
380
- const builder = new AxeBuilder({ page });
381
- if (opts.include)
382
- builder.include(opts.include);
383
- // withTags and withRules are mutually exclusive in axe-core; never set both.
384
- if (opts.rules)
385
- builder.withRules(opts.rules);
386
- else
387
- builder.withTags(opts.tags ?? WCAG_AA_TAGS);
388
- try {
389
- return await builder.analyze();
390
- }
391
- catch (cause) {
392
- // axe raises "unknown rule `x` in options.runOnly" for a rule id that does not exist, and
393
- // "No elements found for include in page Context" for a selector that matched nothing. Both
394
- // are defects in the step rather than in the page under test, so both are re-raised as such.
395
- const message = cause instanceof Error ? cause.message : String(cause);
396
- if (/unknown rule/i.test(message))
397
- throw new SdodsError('CONFIG_INVALID', `axe rejected the rule id: ${message}`, {
398
- hint: 'Check the rule id against https://dequeuniversity.com/rules/axe.',
399
- cause,
400
- });
401
- if (/no elements found for include/i.test(message))
402
- throw new SdodsError('RUN_FAILED', `axe found nothing to scan: ${message}`, {
403
- hint: `The selector "${opts.include ?? ''}" matched no element by the time axe ran. Wait for the region to render before this step.`,
404
- cause,
405
- });
406
- throw new SdodsError('RUN_FAILED', `axe failed to analyse the page: ${message}`, { cause });
407
- }
408
- }
409
306
  /**
410
307
  * Writes the full axe result next to the scenario and attaches it under `sdods/a11y/<step>`.
411
308
  * Always attached, pass or fail: on a failure it is what makes the verdict diagnosable, and on a
@@ -439,6 +336,7 @@ async function requireRegion(page, selector) {
439
336
  */
440
337
  Then('the page should have no accessibility violations', async ({ page, scenario, $bddContext, $testInfo }) => {
441
338
  const results = await runAxe(page, {});
339
+ markPageAudited(scenario, page.url());
442
340
  attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
443
341
  assertAxeChecked(results, `on ${page.url()}`);
444
342
  expect(results.violations.length, `${results.violations.length} WCAG A/AA violation(s) on ${page.url()}:\n${describeViolations(results.violations)}`).toBe(0);
@@ -464,6 +362,7 @@ Then('the page should have no accessibility violations within {string}', async (
464
362
  Then('the page should have no accessibility violations of impact {string} or worse', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, level) => {
465
363
  const floor = parseImpactFloor(renderStrict(level, ...scopesOf(apiContext, env)));
466
364
  const results = await runAxe(page, {});
365
+ markPageAudited(scenario, page.url());
467
366
  attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
468
367
  assertAxeChecked(results, `on ${page.url()}`);
469
368
  const blocking = violationsAtOrAbove(results.violations, floor);
@@ -1,7 +1,7 @@
1
1
  import { expect } from '@playwright/test';
2
2
  import './params.js';
3
3
  import { Given, Then, When } from '../fixtures/test.js';
4
- import { coerce, getPath } from '../api/json-path.js';
4
+ import { coerce, getPath, matchedPath } from '../api/json-path.js';
5
5
  import { renderJson, renderStrict } from '../api/template.js';
6
6
  import { pollUntil } from '../api/poll.js';
7
7
  import { loadJsonSchema, loadZodSchema, validateAgainstOpenApi, validateJsonSchema, validateZod, } from '../api/validate.js';
@@ -104,12 +104,12 @@ Then('the response JSON path {string} should match {string}', async ({ apiContex
104
104
  });
105
105
  Then('the response JSON path {string} should have {int} items', async ({ apiContext, env }, jsonPath, count) => {
106
106
  const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
107
- const actual = getPath(apiContext.last().response.body, path);
107
+ const actual = matchedPath(apiContext.last().response.body, path);
108
108
  expect(Array.isArray(actual) ? actual.length : -1, `JSON path ${path} length`).toBe(count);
109
109
  });
110
110
  Then('the response JSON path {string} should have at least {int} items', async ({ apiContext, env }, jsonPath, count) => {
111
111
  const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
112
- const actual = getPath(apiContext.last().response.body, path);
112
+ const actual = matchedPath(apiContext.last().response.body, path);
113
113
  expect(Array.isArray(actual) ? actual.length : -1, `JSON path ${path} length`).toBeGreaterThanOrEqual(count);
114
114
  });
115
115
  Then('the response body should be an array', async ({ apiContext }) => {
@@ -144,10 +144,8 @@ Then('the response should match the OpenAPI schema for {method} {string}', async
144
144
  When('I save the response JSON path {string} as {string}', async ({ apiContext, env }, jsonPath, name) => {
145
145
  // `name` is the variable being written, an identifier rather than a template.
146
146
  const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
147
- const value = getPath(apiContext.last().response.body, path);
148
- if (value === undefined)
149
- throw new SdodsError('RUN_FAILED', `JSON path ${path} is undefined in the last response; cannot save as {{${name}}}.`);
150
- apiContext.vars.set(name, value);
147
+ // A present `null` or `[]` is saved as is; only a path that matched nothing fails.
148
+ apiContext.vars.set(name, matchedPath(apiContext.last().response.body, path, `{{${name}}}`));
151
149
  });
152
150
  Given('I set the variable {string} to {string}', async ({ apiContext, env }, name, value) => {
153
151
  apiContext.vars.set(name, coerce(renderStrict(value, apiContext.vars.toObject(), env.vars)));
@@ -24,4 +24,16 @@ export declare function firstPaintedColour(chain: BackgroundLayer[]): PaintedCol
24
24
  * shape of the text gives it away.
25
25
  */
26
26
  export declare function looksLikeMessageKey(text: string): boolean;
27
+ /**
28
+ * What counts as an untranslated key in visible text, by default:
29
+ * * a whole text node shaped like a catalogue key — `nav.settings`, `checkout.summary.total`
30
+ * (the same rule as {@link looksLikeMessageKey}: file names, hostnames and versions excluded);
31
+ * * an unrendered interpolation placeholder — `Hello {{name}}`;
32
+ * * a library's own missing marker — `[missing "fr.nav.home" translation]` (i18n-js) or
33
+ * `translation missing: fr.nav.home` (Rails).
34
+ * A project whose keys look different passes its own pattern to the `matching {string}` step,
35
+ * which then replaces these defaults.
36
+ */
37
+ export declare const UNTRANSLATED_MARKERS: readonly RegExp[];
38
+ export declare function findUntranslated(texts: readonly string[], pattern?: RegExp): string[];
27
39
  //# sourceMappingURL=browser.steps.d.ts.map
@@ -1,6 +1,7 @@
1
- import { expect } from '@playwright/test';
1
+ import { devices, expect } from '@playwright/test';
2
2
  import { Given, Then, When } from '../fixtures/test.js';
3
3
  import { renderStrict } from '../api/template.js';
4
+ import { COLOR_SCHEMES, emulationTagProblem, findDevice, parseViewport, } from '../config/emulation.js';
4
5
  import { SdodsError } from '../errors.js';
5
6
  /**
6
7
  * Browser-state steps: cookies, web storage, viewport, colour scheme, locale and console.
@@ -292,11 +293,14 @@ function probeHorizontalOverflow() {
292
293
  // TRAP: a blank document also does not scroll sideways. The body-children guard is what stops this
293
294
  // step going green on a page that failed to render, which is the one case it must not bless.
294
295
  Then('the page should not scroll horizontally', async ({ page }) => {
295
- requireNavigated(page, 'the page should not scroll horizontally');
296
+ await expectNoHorizontalScroll(page, 'the page should not scroll horizontally');
297
+ });
298
+ async function expectNoHorizontalScroll(page, step) {
299
+ requireNavigated(page, step);
296
300
  const report = (await page.evaluate(probeHorizontalOverflow));
297
301
  expect(report.bodyChildren, 'the document body is empty — a blank page cannot prove anything about reflow').toBeGreaterThan(0);
298
302
  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);
299
- });
303
+ }
300
304
  // Proves ONE container contains its own width — a table, a code block or a chart that is allowed
301
305
  // to scroll internally must not push the page. Fails when the selector matches nothing, because a
302
306
  // containment claim about an element that is not on the page is not a claim.
@@ -651,4 +655,151 @@ Then('no uncaught page error should have been recorded', async ({ apiContext })
651
655
  const recording = recordingFor(apiContext, 'no uncaught page error should have been recorded');
652
656
  expect(recording.pageErrors, 'uncaught page errors').toEqual([]);
653
657
  });
658
+ /* ── per-scenario emulation: the `I use …` family (#116) ─────────────── */
659
+ // Locale, timezone and device are browser-CONTEXT options: Playwright fixes them when the context
660
+ // is created and offers no way to change them afterwards. By the time the first step runs, the
661
+ // context is open, so these steps cannot apply a value. The tags `@locale:` `@timezone:` and
662
+ // `@device:` are resolved before the context opens; each step below passes when the context
663
+ // already has the value (it then states the scenario's intent in its text) and otherwise fails,
664
+ // naming the tag to use. Recreating the context was rejected: the page objects, the healer and the
665
+ // screenshot narrator already hold the original page, and would silently act on a closed one.
666
+ // Colour scheme and viewport CAN change on a live page, so those steps apply their value.
667
+ function contextFixed(what, tag, actual) {
668
+ return new SdodsError('RUN_FAILED',
669
+ // The tag is in the MESSAGE, not only the hint: a Playwright report shows the message alone.
670
+ `The browser context was created with ${what} ${actual === undefined ? '(the system default)' : `"${actual}"`}, and Playwright cannot change it on an open context — tag the scenario ${tag}.`, {
671
+ hint: `Tag the scenario ${tag} (or its Feature, or an Examples block) — the tag is applied before the context is created, and this step then passes.`,
672
+ });
673
+ }
674
+ // Proves the scenario runs under this locale: navigator.language, Intl formatting and the
675
+ // Accept-Language header all come from it. Passes only when `@locale:` (or the env's `use.locale`)
676
+ // already set it.
677
+ Given('I use the locale {string}', async ({ locale, apiContext, env }, value) => {
678
+ const wanted = renderStrict(value, ...scopesOf(apiContext, env));
679
+ const problem = emulationTagProblem('locale', wanted);
680
+ if (problem)
681
+ throw new SdodsError('RUN_FAILED', problem);
682
+ const canon = (l) => Intl.getCanonicalLocales(l)[0];
683
+ if (!locale || canon(locale) !== canon(wanted))
684
+ throw contextFixed('locale', `@locale:${wanted}`, locale);
685
+ });
686
+ // Proves the scenario runs in this IANA time zone — the one that decides what "today" and every
687
+ // rendered timestamp mean. Passes only when `@timezone:` (or `use.timezoneId`) already set it.
688
+ Given('I use the timezone {string}', async ({ timezoneId, apiContext, env }, value) => {
689
+ const wanted = renderStrict(value, ...scopesOf(apiContext, env));
690
+ const problem = emulationTagProblem('timezone', wanted);
691
+ if (problem)
692
+ throw new SdodsError('RUN_FAILED', problem);
693
+ if (timezoneId !== wanted)
694
+ throw contextFixed('time zone', `@timezone:${wanted}`, timezoneId);
695
+ });
696
+ // Proves the OS-level colour preference for the rest of the scenario. Applied on the live page, so
697
+ // it works mid-scenario; `@theme:` does the same before the first paint, which is what a
698
+ // flash-of-wrong-theme check needs.
699
+ Given('I use the {string} color scheme', async ({ page, apiContext, env }, scheme) => {
700
+ const wanted = renderStrict(scheme, ...scopesOf(apiContext, env));
701
+ if (!COLOR_SCHEMES.includes(wanted))
702
+ throw new SdodsError('RUN_FAILED', `"${wanted}" is not a color scheme.`, {
703
+ hint: `Use one of: ${COLOR_SCHEMES.join(', ')}.`,
704
+ });
705
+ await page.emulateMedia({ colorScheme: wanted });
706
+ });
707
+ // Resizes the live page. `@viewport:<W>x<H>` sets the size before the first navigation instead,
708
+ // which matters for apps that choose a layout once, on load.
709
+ Given('I use the viewport {int} by {int}', async ({ page }, width, height) => {
710
+ const size = parseViewport(`${width}x${height}`);
711
+ if (!size)
712
+ throw new SdodsError('RUN_FAILED', `Viewport ${width}x${height} is not a usable size.`, {
713
+ hint: 'Both dimensions must be positive integers, e.g. `Given I use the viewport 320 by 640`.',
714
+ });
715
+ await page.setViewportSize(size);
716
+ });
717
+ // Proves the scenario runs with this device's viewport, user agent, pixel ratio and touch. The
718
+ // engine stays the run target's browser. Passes when the context carries the device's user agent —
719
+ // from `@device:`, or from a mobile run target built on that descriptor (mobile-safari is iPhone 15).
720
+ Given('I use the device {string}', async ({ $sdodsEmulation, userAgent, apiContext, env }, value) => {
721
+ const wanted = renderStrict(value, ...scopesOf(apiContext, env));
722
+ const name = findDevice(wanted);
723
+ if (!name)
724
+ throw new SdodsError('RUN_FAILED', `"${wanted}" is not a Playwright device.`, {
725
+ hint: 'Use a descriptor name such as "iPhone 15" or "Pixel 7".',
726
+ });
727
+ if ($sdodsEmulation.device?.name !== name && userAgent !== devices[name]?.userAgent)
728
+ throw contextFixed('device', `@device:${name.replace(/\s+/g, '-')}`, $sdodsEmulation.device?.name ?? 'no device emulation');
729
+ });
730
+ // The WCAG 1.4.10 phrasing of `the page should not scroll horizontally` — same measurement, same
731
+ // blank-page guard. Pair it with `@viewport:320x640`.
732
+ Then('the page should reflow without horizontal scrolling', async ({ page }) => {
733
+ await expectNoHorizontalScroll(page, 'the page should reflow without horizontal scrolling');
734
+ });
735
+ /**
736
+ * What counts as an untranslated key in visible text, by default:
737
+ * * a whole text node shaped like a catalogue key — `nav.settings`, `checkout.summary.total`
738
+ * (the same rule as {@link looksLikeMessageKey}: file names, hostnames and versions excluded);
739
+ * * an unrendered interpolation placeholder — `Hello {{name}}`;
740
+ * * a library's own missing marker — `[missing "fr.nav.home" translation]` (i18n-js) or
741
+ * `translation missing: fr.nav.home` (Rails).
742
+ * A project whose keys look different passes its own pattern to the `matching {string}` step,
743
+ * which then replaces these defaults.
744
+ */
745
+ export const UNTRANSLATED_MARKERS = [
746
+ /\{\{\s*[^{}\s][^{}]*\}\}/,
747
+ /\[missing\b[^\]]*\]/i,
748
+ /\btranslation missing:/i,
749
+ ];
750
+ export function findUntranslated(texts, pattern) {
751
+ const found = new Set();
752
+ for (const raw of texts) {
753
+ const text = raw.trim();
754
+ if (!text)
755
+ continue;
756
+ const hit = pattern
757
+ ? pattern.test(text)
758
+ : (text.length < 80 && !/\s/.test(text) && looksLikeMessageKey(text)) ||
759
+ UNTRANSLATED_MARKERS.some((re) => re.test(text));
760
+ if (hit)
761
+ found.add(text.length > 120 ? `${text.slice(0, 117)}...` : text);
762
+ }
763
+ return [...found];
764
+ }
765
+ /** Every visible text node, whole, plus how many were examined. */
766
+ function collectVisibleTexts() {
767
+ const skip = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'TITLE'];
768
+ const texts = [];
769
+ if (!document.body)
770
+ return { sampled: 0, texts };
771
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
772
+ let node = walker.nextNode();
773
+ while (node) {
774
+ const parent = node.parentElement;
775
+ const text = (node.textContent ?? '').trim();
776
+ if (text && parent && skip.indexOf(parent.tagName) === -1) {
777
+ const el = parent;
778
+ const visible = typeof el.checkVisibility === 'function' ? el.checkVisibility() : el.offsetParent !== null;
779
+ if (visible)
780
+ texts.push(text);
781
+ }
782
+ node = walker.nextNode();
783
+ }
784
+ return { sampled: texts.length, texts };
785
+ }
786
+ async function expectNoUntranslated(page, step, pattern) {
787
+ requireNavigated(page, step);
788
+ const seen = (await page.evaluate(collectVisibleTexts));
789
+ // TRAP: a page that rendered no text has no untranslated keys either — fail instead of passing.
790
+ expect(seen.sampled, 'the page rendered no visible text at all, so this step examined nothing').toBeGreaterThan(0);
791
+ expect(findUntranslated(seen.texts, pattern), `untranslated keys rendered as visible text${pattern ? ` (matching /${pattern.source}/)` : ''}`).toEqual([]);
792
+ }
793
+ // Proves the catalogue resolved for this locale: no raw key, no unrendered `{{placeholder}}` and
794
+ // no library "missing translation" marker is visible. Run it under each `@locale:`.
795
+ Then('the page should have no untranslated keys', async ({ page }) => {
796
+ await expectNoUntranslated(page, 'the page should have no untranslated keys');
797
+ });
798
+ // The same check with the project's own definition of an untranslated key (a JavaScript regular
799
+ // expression tested against each visible text node), e.g. "^[A-Z0-9_]{3,}$" for SCREAMING_CASE keys.
800
+ Then('the page should have no untranslated keys matching {string}', async ({ page, apiContext, env }, pattern) => {
801
+ const step = 'the page should have no untranslated keys matching …';
802
+ const rendered = renderStrict(pattern, ...scopesOf(apiContext, env));
803
+ await expectNoUntranslated(page, step, compilePattern(rendered, step));
804
+ });
654
805
  //# sourceMappingURL=browser.steps.js.map