@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/a11y/audit.d.ts +88 -0
- package/dist/a11y/audit.js +134 -0
- package/dist/analyze/index.d.ts +2 -0
- package/dist/analyze/index.js +2 -0
- package/dist/analyze/traceability-render.d.ts +5 -0
- package/dist/analyze/traceability-render.js +254 -0
- package/dist/analyze/traceability.d.ts +87 -0
- package/dist/analyze/traceability.js +559 -0
- package/dist/api/json-path.d.ts +11 -1
- package/dist/api/json-path.js +23 -1
- package/dist/config/defaults.js +2 -0
- package/dist/config/emulation.d.ts +74 -0
- package/dist/config/emulation.js +154 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/resolve.js +1 -1
- package/dist/config/runner.js +4 -1
- package/dist/config/tags.d.ts +1 -1
- package/dist/config/tags.js +7 -0
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.js +1 -0
- package/dist/data/pool-capacity.d.ts +58 -0
- package/dist/data/pool-capacity.js +138 -0
- package/dist/data/user-pool.d.ts +43 -2
- package/dist/data/user-pool.js +170 -40
- package/dist/errors.d.ts +1 -1
- package/dist/fixtures/api-context.d.ts +19 -1
- package/dist/fixtures/api-context.js +28 -1
- package/dist/fixtures/scenario.d.ts +11 -0
- package/dist/fixtures/scenario.js +15 -0
- package/dist/fixtures/test.js +60 -3
- package/dist/fixtures/types.d.ts +5 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lint/index.js +86 -0
- package/dist/load/index.d.ts +4 -0
- package/dist/load/index.js +4 -0
- package/dist/load/profile.d.ts +22 -0
- package/dist/load/profile.js +109 -0
- package/dist/load/run.d.ts +85 -0
- package/dist/load/run.js +255 -0
- package/dist/load/script.d.ts +27 -0
- package/dist/load/script.js +188 -0
- package/dist/mail/extract.d.ts +38 -0
- package/dist/mail/extract.js +106 -0
- package/dist/mail/index.d.ts +8 -0
- package/dist/mail/index.js +11 -0
- package/dist/mail/mailpit.d.ts +26 -0
- package/dist/mail/mailpit.js +124 -0
- package/dist/mail/types.d.ts +38 -0
- package/dist/mail/types.js +2 -0
- package/dist/matrix/index.d.ts +117 -0
- package/dist/matrix/index.js +507 -0
- package/dist/perf/vitals.d.ts +118 -0
- package/dist/perf/vitals.js +236 -0
- package/dist/quality/a11y-scenario.d.ts +68 -0
- package/dist/quality/a11y-scenario.js +63 -0
- package/dist/quality/gates.d.ts +54 -0
- package/dist/quality/gates.js +144 -0
- package/dist/quality/hooks.d.ts +2 -0
- package/dist/quality/hooks.js +38 -0
- package/dist/quality/index.d.ts +4 -0
- package/dist/quality/index.js +2 -0
- package/dist/quality/perf-scenario.d.ts +132 -0
- package/dist/quality/perf-scenario.js +241 -0
- package/dist/shots/baselines.d.ts +72 -0
- package/dist/shots/baselines.js +201 -0
- package/dist/shots/hooks.js +2 -2
- package/dist/shots/index.d.ts +1 -0
- package/dist/shots/index.js +1 -0
- package/dist/shots/narrator.d.ts +10 -2
- package/dist/shots/narrator.js +106 -10
- package/dist/shots/policy.d.ts +5 -1
- package/dist/shots/policy.js +2 -0
- package/dist/steps/a11y.steps.d.ts +18 -51
- package/dist/steps/a11y.steps.js +6 -107
- package/dist/steps/api.steps.js +5 -7
- package/dist/steps/browser.steps.d.ts +12 -0
- package/dist/steps/browser.steps.js +154 -3
- package/dist/steps/canvas.steps.d.ts +9 -0
- package/dist/steps/canvas.steps.js +331 -0
- package/dist/steps/data.steps.js +17 -5
- package/dist/steps/email.steps.d.ts +2 -0
- package/dist/steps/email.steps.js +182 -0
- package/dist/steps/hooks.steps.d.ts +1 -0
- package/dist/steps/hooks.steps.js +3 -1
- package/dist/steps/hybrid.steps.js +2 -2
- package/dist/steps/index.d.ts +3 -0
- package/dist/steps/index.js +3 -0
- package/dist/steps/perf.steps.d.ts +3 -129
- package/dist/steps/perf.steps.js +4 -227
- package/dist/steps/ui.steps.js +5 -0
- package/package.json +4 -3
package/dist/shots/narrator.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
29
|
-
|
|
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
|
package/dist/shots/narrator.js
CHANGED
|
@@ -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
|
-
/**
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 {
|
package/dist/shots/policy.d.ts
CHANGED
|
@@ -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;
|
package/dist/shots/policy.js
CHANGED
|
@@ -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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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;
|
package/dist/steps/a11y.steps.js
CHANGED
|
@@ -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);
|
package/dist/steps/api.steps.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|