@sdods/core 0.8.0 → 0.10.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/api/template.js +1 -1
- package/dist/config/defaults.js +2 -0
- package/dist/config/resolve.js +1 -1
- 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/auth.js +1 -1
- package/dist/fixtures/scenario.d.ts +11 -0
- package/dist/fixtures/scenario.js +15 -0
- package/dist/fixtures/test.js +24 -3
- package/dist/fixtures/types.d.ts +2 -0
- package/dist/har/api-har.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/matrix/index.d.ts +13 -0
- package/dist/matrix/index.js +25 -4
- package/dist/perf/vitals.d.ts +118 -0
- package/dist/perf/vitals.js +236 -0
- package/dist/quality/a11y-scenario.d.ts +110 -0
- package/dist/quality/a11y-scenario.js +229 -0
- package/dist/quality/gates.d.ts +87 -0
- package/dist/quality/gates.js +233 -0
- package/dist/quality/hooks.d.ts +2 -0
- package/dist/quality/hooks.js +51 -0
- package/dist/quality/index.d.ts +4 -0
- package/dist/quality/index.js +2 -0
- package/dist/quality/perf-scenario.d.ts +149 -0
- package/dist/quality/perf-scenario.js +265 -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/data.steps.js +17 -5
- package/dist/steps/hooks.steps.d.ts +1 -0
- package/dist/steps/hooks.steps.js +3 -1
- package/dist/steps/index.d.ts +1 -0
- package/dist/steps/index.js +1 -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 +7 -7
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/data.steps.js
CHANGED
|
@@ -3,6 +3,7 @@ import './params.js';
|
|
|
3
3
|
import { Given, When } from '../fixtures/test.js';
|
|
4
4
|
import { tokenFileFor } from '../auth/capture.js';
|
|
5
5
|
import { renderStrict } from '../api/template.js';
|
|
6
|
+
import { scenarioLeaseClock } from '../data/user-pool.js';
|
|
6
7
|
import { SdodsError } from '../errors.js';
|
|
7
8
|
import { Logger } from '../logger.js';
|
|
8
9
|
const log = new Logger('data');
|
|
@@ -20,8 +21,8 @@ Given('I generate a {string} from the factory as {string}', async ({ data, apiCo
|
|
|
20
21
|
apiContext.vars.set(as, await data.factory(factory));
|
|
21
22
|
});
|
|
22
23
|
/* ── user pool ────────────────────────────────────────────────────────── */
|
|
23
|
-
Given('I use a leased user with role {string}', async ({ userPool, authCache, auth, sdods, apiContext, page, context, browser, $testInfo }, role) => {
|
|
24
|
-
const user = await userPool
|
|
24
|
+
Given('I use a leased user with role {string}', async ({ userPool, authCache, auth, sdods, apiContext, page, context, browser, config, $testInfo }, role) => {
|
|
25
|
+
const user = await leaseInScenario(userPool, config, role, $testInfo);
|
|
25
26
|
apiContext.vars.setAll({
|
|
26
27
|
username: user.username,
|
|
27
28
|
password: user.password,
|
|
@@ -33,12 +34,12 @@ Given('I use a leased user with role {string}', async ({ userPool, authCache, au
|
|
|
33
34
|
if (sdods.layer !== 'api')
|
|
34
35
|
await authCache.apply({ context, page, user, auth, browser });
|
|
35
36
|
});
|
|
36
|
-
Given('I use the user {string} from the pool', async ({ userPool, apiContext, $testInfo }, username) => {
|
|
37
|
+
Given('I use the user {string} from the pool', async ({ userPool, apiContext, config, $testInfo }, username) => {
|
|
37
38
|
const status = await userPool.status();
|
|
38
39
|
const entry = status.find((u) => u.username === username);
|
|
39
40
|
if (!entry)
|
|
40
41
|
throw new SdodsError('DATASET_ROW_NOT_FOUND', `No pool user named "${username}".`);
|
|
41
|
-
const user = await userPool
|
|
42
|
+
const user = await leaseInScenario(userPool, config, entry.role, $testInfo);
|
|
42
43
|
apiContext.vars.setAll({
|
|
43
44
|
username: user.username,
|
|
44
45
|
password: user.password,
|
|
@@ -84,7 +85,7 @@ Given('I use the user {string} from the pool', async ({ userPool, apiContext, $t
|
|
|
84
85
|
* Either way the attached credential is logged at info.
|
|
85
86
|
*/
|
|
86
87
|
Given('I use a leased user with role {string} for API calls', async ({ userPool, apiContext, auth, config, $testInfo }, role) => {
|
|
87
|
-
const user = await userPool
|
|
88
|
+
const user = await leaseInScenario(userPool, config, role, $testInfo);
|
|
88
89
|
apiContext.vars.setAll({
|
|
89
90
|
username: user.username,
|
|
90
91
|
password: user.password,
|
|
@@ -126,6 +127,17 @@ Given("I authenticate the API with the leased user's token", async ({ apiContext
|
|
|
126
127
|
});
|
|
127
128
|
/** The user the scenario leased, so the explicit token step authenticates as that same account. */
|
|
128
129
|
const LEASED = new WeakMap();
|
|
130
|
+
/**
|
|
131
|
+
* Lease for this scenario. With `leaseScope: scenario` the wait for a free account is added to the
|
|
132
|
+
* scenario's timeout, and `$sdodsScenarioLeases` releases the account when the scenario ends.
|
|
133
|
+
*/
|
|
134
|
+
async function leaseInScenario(userPool, config, role, testInfo) {
|
|
135
|
+
// `data` is always present on a resolved config; unit tests hand steps a partial one.
|
|
136
|
+
const clock = scenarioLeaseClock(config.project.data?.userPool, testInfo);
|
|
137
|
+
const user = await userPool.lease(role, testInfo.parallelIndex);
|
|
138
|
+
clock.settle();
|
|
139
|
+
return user;
|
|
140
|
+
}
|
|
129
141
|
function apiTokenMode(config, auth) {
|
|
130
142
|
return config.project.auth?.apiToken ?? (auth?.strategy === 'custom' ? 'explicit' : 'implicit');
|
|
131
143
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// Registers the screenshot-narrative, finalize, browser-skip and
|
|
1
|
+
// Registers the screenshot-narrative, finalize, browser-skip, HAR, @a11y and @perf hooks when bddgen loads the core step glob.
|
|
2
2
|
import '../shots/hooks.js';
|
|
3
|
+
// After the screenshot hooks: after-hooks run in reverse order, so these run before sdods:finalize.
|
|
4
|
+
import '../quality/hooks.js';
|
|
3
5
|
import { registerHarHooks } from '../har/hooks.js';
|
|
4
6
|
import { Before } from '../fixtures/test.js';
|
|
5
7
|
registerHarHooks({ Before });
|
package/dist/steps/index.d.ts
CHANGED
|
@@ -20,5 +20,6 @@ import './perf.steps.js';
|
|
|
20
20
|
import './email.steps.js';
|
|
21
21
|
import './canvas.steps.js';
|
|
22
22
|
import '../shots/hooks.js';
|
|
23
|
+
import '../quality/hooks.js';
|
|
23
24
|
export { coreStepsGlob, coreStepsPatterns, coreStepNames, coreStepsDir } from './glob.js';
|
|
24
25
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/steps/index.js
CHANGED
|
@@ -20,5 +20,6 @@ import './perf.steps.js';
|
|
|
20
20
|
import './email.steps.js';
|
|
21
21
|
import './canvas.steps.js';
|
|
22
22
|
import '../shots/hooks.js';
|
|
23
|
+
import '../quality/hooks.js';
|
|
23
24
|
export { coreStepsGlob, coreStepsPatterns, coreStepNames, coreStepsDir } from './glob.js';
|
|
24
25
|
//# sourceMappingURL=index.js.map
|
|
@@ -1,136 +1,11 @@
|
|
|
1
1
|
import './params.js';
|
|
2
2
|
import type { Page, TestInfo } from '@playwright/test';
|
|
3
|
-
import type { EnvConfig
|
|
3
|
+
import type { EnvConfig } from '@sdods/contracts';
|
|
4
4
|
import type { ApiClient, HttpMethod } from '../api/client.js';
|
|
5
5
|
import type { ApiContext } from '../fixtures/api-context.js';
|
|
6
6
|
import type { ScenarioMeta } from '../fixtures/scenario.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*
|
|
10
|
-
* `PerfBudgetsSchema` (pageLoadMs / lcpMs / fcpMs / ttfbMs / apiP95Ms), the `perf.budgets` block in
|
|
11
|
-
* the project and env yaml, the `perfBudgets: true` release gate and `attachmentNames.perf` all
|
|
12
|
-
* existed before this file; nothing read any of them. These steps close that loop.
|
|
13
|
-
*
|
|
14
|
-
* Two rules run through every step here, both learned from hand-written project steps that got
|
|
15
|
-
* them wrong:
|
|
16
|
-
*
|
|
17
|
-
* 1. A BUDGET IS READ FROM CONFIG, NEVER FROM THE FEATURE FILE. A number typed into a `.feature`
|
|
18
|
-
* is a number that drifts from the environment it runs on — staging and a laptop do not share
|
|
19
|
-
* a page-load budget. `Then the response time should be under {int} ms` (api.steps.ts) is the
|
|
20
|
-
* literal-threshold form and stays exactly as it is; the budget-driven forms below are new
|
|
21
|
-
* patterns, not a widening of it. A local literal is still reachable where a scenario genuinely
|
|
22
|
-
* needs one — `should be under {int} ms` — so the escape hatch is visible in the step text.
|
|
23
|
-
* 2. AN UNMEASURED VITAL FAILS. LCP does not fire on a page with no contentful paint, and WebKit
|
|
24
|
-
* does not implement it at all; paint timings and the navigation entry can be missing too.
|
|
25
|
-
* Recording those as `0` makes every budget assertion pass — a whole perf module
|
|
26
|
-
* goes green while measuring nothing. So a vital that did not fire is recorded as `null`
|
|
27
|
-
* together with the reason, and asserting on it FAILS naming that reason. A real navigation
|
|
28
|
-
* cannot produce a genuine 0 ms vital (they are all offsets from navigation start), so treating
|
|
29
|
-
* "absent or 0" as "not measured" loses no honest signal.
|
|
30
|
-
*
|
|
31
|
-
* A budget that is not configured is likewise a hard `CONFIG_INVALID` naming the missing key, not
|
|
32
|
-
* a skip: silently skipping is the same vacuous green in a different costume.
|
|
33
|
-
*
|
|
34
|
-
* The two comparison operators are deliberately different and deliberately visible in the step
|
|
35
|
-
* text: `under {int} ms` is strict `<` (matching `the response time should be under {int} ms` in
|
|
36
|
-
* api.steps.ts), `within its configured budget` is `<=` — a budget is a ceiling you may sit on.
|
|
37
|
-
*/
|
|
38
|
-
/** The page vitals SDODS records. Each name is also its key in `perf.budgets`. */
|
|
39
|
-
export declare const VITAL_KEYS: readonly ["pageLoadMs", "lcpMs", "fcpMs", "ttfbMs"];
|
|
40
|
-
export type VitalKey = (typeof VITAL_KEYS)[number];
|
|
41
|
-
/** The `perf.budgets` key a sampled API latency distribution is judged against. */
|
|
42
|
-
export declare const API_P95_BUDGET_KEY = "apiP95Ms";
|
|
43
|
-
type BudgetLayer = {
|
|
44
|
-
budgets?: Record<string, number | undefined>;
|
|
45
|
-
} | undefined;
|
|
46
|
-
/** The slice of a resolved config these steps read. Nothing here writes config. */
|
|
47
|
-
export interface BudgetConfig {
|
|
48
|
-
project: {
|
|
49
|
-
perf?: BudgetLayer;
|
|
50
|
-
};
|
|
51
|
-
env: {
|
|
52
|
-
name?: string;
|
|
53
|
-
perf?: BudgetLayer;
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* Effective budgets, env overriding project per key.
|
|
58
|
-
*
|
|
59
|
-
* `resolveConfig` already deep-merges `env.perf` into `config.project.perf`, so on a resolved
|
|
60
|
-
* config the project side is the whole answer. Merging the env side again is idempotent and keeps
|
|
61
|
-
* this correct for a config assembled by hand. Non-numeric values are dropped rather than spread —
|
|
62
|
-
* an explicit `undefined` in the env layer would otherwise erase a real project budget and turn a
|
|
63
|
-
* configured assertion into an unconfigured one.
|
|
64
|
-
*/
|
|
65
|
-
export declare function resolvedBudgets(config: BudgetConfig): Record<string, number>;
|
|
66
|
-
/**
|
|
67
|
-
* The configured budget for one key, or a hard error naming it.
|
|
68
|
-
*
|
|
69
|
-
* PROVES the assertion that follows is gated on something real. A missing budget must never
|
|
70
|
-
* degrade to a skip or to `Infinity`: an unconfigured budget is the single failure mode that lets
|
|
71
|
-
* an entire perf suite report success while asserting nothing at all.
|
|
72
|
-
*/
|
|
73
|
-
export declare function requireBudget(config: BudgetConfig, key: string): number;
|
|
74
|
-
/** One `When I record the page vitals`. `null` in `vitals` means NOT MEASURED, never "0 ms". */
|
|
75
|
-
export interface VitalsRecording {
|
|
76
|
-
url: string;
|
|
77
|
-
vitals: Record<VitalKey, number | null>;
|
|
78
|
-
/** Why a vital is null, keyed by vital. Surfaced verbatim in the failure message. */
|
|
79
|
-
unmeasured: Partial<Record<VitalKey, string>>;
|
|
80
|
-
/** The contract shape written to `sdods/perf/<step>` and ingested into `steps.perf_json`. */
|
|
81
|
-
metrics: PerformanceMetrics;
|
|
82
|
-
}
|
|
83
|
-
export interface LatencySample {
|
|
84
|
-
status: number;
|
|
85
|
-
ms: number;
|
|
86
|
-
}
|
|
87
|
-
export interface LatencySampleSet {
|
|
88
|
-
method: string;
|
|
89
|
-
path: string;
|
|
90
|
-
samples: LatencySample[];
|
|
91
|
-
p95Ms: number;
|
|
92
|
-
}
|
|
93
|
-
interface PerfStore {
|
|
94
|
-
recordings: VitalsRecording[];
|
|
95
|
-
sampleSets: LatencySampleSet[];
|
|
96
|
-
}
|
|
97
|
-
export declare function perfStore(key: object): PerfStore;
|
|
98
|
-
/**
|
|
99
|
-
* Nearest-rank p95 — deterministic for a given set of samples, with no interpolation to argue
|
|
100
|
-
* about. Below 20 samples the nearest rank IS the maximum; the sample count travels in the
|
|
101
|
-
* attachment and in every failure message so a reader can see when "p95" means "slowest of five".
|
|
102
|
-
*
|
|
103
|
-
* Throws rather than returning 0 on an empty sample: a p95 of 0 slides under every budget.
|
|
104
|
-
*/
|
|
105
|
-
export declare function p95Of(msValues: readonly number[]): number;
|
|
106
|
-
/** What the page hands back. Every timing is `number | null`; null always means "did not fire". */
|
|
107
|
-
export interface RawVitals {
|
|
108
|
-
hasNavigationEntry: boolean;
|
|
109
|
-
navigationStartEpochMs: number | null;
|
|
110
|
-
pageLoadMs: number | null;
|
|
111
|
-
ttfbMs: number | null;
|
|
112
|
-
fcpMs: number | null;
|
|
113
|
-
lcpMs: number | null;
|
|
114
|
-
domContentLoadedMs: number | null;
|
|
115
|
-
totalResources: number;
|
|
116
|
-
totalResourceSizeKB: number;
|
|
117
|
-
lcpSupported: boolean;
|
|
118
|
-
fcpSupported: boolean;
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* Runs inside the page. Serialised by Playwright, so it closes over nothing but its argument — the
|
|
122
|
-
* type aliases above are erased at compile time and are safe to reference; the constants are not,
|
|
123
|
-
* which is why the settle window is passed in.
|
|
124
|
-
*/
|
|
125
|
-
export declare function collectRawVitals(settleMs: number): Promise<RawVitals>;
|
|
126
|
-
/**
|
|
127
|
-
* Turns the page's answer into a recording, capturing WHY a vital is null while the browser's
|
|
128
|
-
* capabilities are still in hand. By the time a `Then` step fails, "lcpMs was not measured" on its
|
|
129
|
-
* own sends the reader hunting for a bug in a page that is merely running in a browser that does
|
|
130
|
-
* not implement LCP (WebKit). Support is read from `PerformanceObserver.supportedEntryTypes`, never
|
|
131
|
-
* assumed from the browser name.
|
|
132
|
-
*/
|
|
133
|
-
export declare function toRecording(raw: RawVitals, url: string, browserName?: string): VitalsRecording;
|
|
7
|
+
import { type BudgetConfig } from '../perf/vitals.js';
|
|
8
|
+
export { API_P95_BUDGET_KEY, VITAL_KEYS, collectRawVitals, p95Of, perfStore, requireBudget, resolvedBudgets, toRecording, type BudgetConfig, type LatencySample, type LatencySampleSet, type RawVitals, type VitalKey, type VitalsRecording, } from '../perf/vitals.js';
|
|
134
9
|
interface VitalsFixtures {
|
|
135
10
|
page: Page;
|
|
136
11
|
config: BudgetConfig;
|
|
@@ -244,5 +119,4 @@ export declare const assertP95NoWorseThanPrevious: ({ scenario }: {
|
|
|
244
119
|
export declare const assertEverySampleStatus: ({ scenario }: {
|
|
245
120
|
scenario: ScenarioMeta;
|
|
246
121
|
}, status: number) => Promise<void>;
|
|
247
|
-
export {};
|
|
248
122
|
//# sourceMappingURL=perf.steps.d.ts.map
|