@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
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { SdodsError } from '../errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Page vitals, latency percentiles and the budgets they are judged against, shared by the explicit
|
|
4
|
+
* perf steps (`steps/perf.steps.ts`) and the end-of-scenario check every `@perf` scenario gets
|
|
5
|
+
* (`quality/hooks.ts`). Outside the step file for the reason `a11y/audit.ts` gives: importing a step
|
|
6
|
+
* file registers its phrases, and `steps.core.exclude: [perf]` must stay able to switch them off.
|
|
7
|
+
* The rules these functions keep are written up at the top of `steps/perf.steps.ts`.
|
|
8
|
+
*/
|
|
9
|
+
/* ── budgets ──────────────────────────────────────────────────────────── */
|
|
10
|
+
/** The page vitals SDODS records. Each name is also its key in `perf.budgets`. */
|
|
11
|
+
export const VITAL_KEYS = ['pageLoadMs', 'lcpMs', 'fcpMs', 'ttfbMs'];
|
|
12
|
+
/** The `perf.budgets` key a sampled API latency distribution is judged against. */
|
|
13
|
+
export const API_P95_BUDGET_KEY = 'apiP95Ms';
|
|
14
|
+
/**
|
|
15
|
+
* Effective budgets, env overriding project per key.
|
|
16
|
+
*
|
|
17
|
+
* `resolveConfig` already deep-merges `env.perf` into `config.project.perf`, so on a resolved
|
|
18
|
+
* config the project side is the whole answer. Merging the env side again is idempotent and keeps
|
|
19
|
+
* this correct for a config assembled by hand. Non-numeric values are dropped rather than spread —
|
|
20
|
+
* an explicit `undefined` in the env layer would otherwise erase a real project budget and turn a
|
|
21
|
+
* configured assertion into an unconfigured one.
|
|
22
|
+
*/
|
|
23
|
+
export function resolvedBudgets(config) {
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const layer of [config.project.perf?.budgets, config.env.perf?.budgets]) {
|
|
26
|
+
for (const [key, value] of Object.entries(layer ?? {})) {
|
|
27
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
28
|
+
out[key] = value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The configured budget for one key, or a hard error naming it.
|
|
35
|
+
*
|
|
36
|
+
* PROVES the assertion that follows is gated on something real. A missing budget must never
|
|
37
|
+
* degrade to a skip or to `Infinity`: an unconfigured budget is the single failure mode that lets
|
|
38
|
+
* an entire perf suite report success while asserting nothing at all.
|
|
39
|
+
*/
|
|
40
|
+
export function requireBudget(config, key) {
|
|
41
|
+
const budget = resolvedBudgets(config)[key];
|
|
42
|
+
const envName = config.env.name ?? 'unknown';
|
|
43
|
+
if (typeof budget !== 'number' || !(budget > 0)) {
|
|
44
|
+
throw new SdodsError('CONFIG_INVALID', `Perf budget "${key}" is not configured for environment "${envName}".`, {
|
|
45
|
+
hint: `Set perf.budgets.${key} in the project's sdods.project.yaml, or override it in envs/${envName}.yaml. Without it this assertion would pass no matter how slow the application is.`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return budget;
|
|
49
|
+
}
|
|
50
|
+
export function requireVitalKey(name) {
|
|
51
|
+
if (VITAL_KEYS.includes(name))
|
|
52
|
+
return name;
|
|
53
|
+
throw new SdodsError('CONFIG_INVALID', `"${name}" is not a page vital SDODS records.`, {
|
|
54
|
+
hint: `Known vitals: ${VITAL_KEYS.join(', ')}. Each is also its key in perf.budgets.`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Measurements have to survive from a `When` step to a `Then` step. They do not go in
|
|
59
|
+
* `apiContext.vars`: that store doubles as a `{{…}}` template scope, so an array of samples parked
|
|
60
|
+
* there would leak into every later rendering. Keyed on the per-test `scenario` object, so two
|
|
61
|
+
* scenarios in parallel workers can never read each other's numbers and nothing outlives the
|
|
62
|
+
* scenario that measured it.
|
|
63
|
+
*/
|
|
64
|
+
const stores = new WeakMap();
|
|
65
|
+
export function perfStore(key) {
|
|
66
|
+
let store = stores.get(key);
|
|
67
|
+
if (!store) {
|
|
68
|
+
store = { recordings: [], sampleSets: [] };
|
|
69
|
+
stores.set(key, store);
|
|
70
|
+
}
|
|
71
|
+
return store;
|
|
72
|
+
}
|
|
73
|
+
export function lastRecording(key) {
|
|
74
|
+
const recording = perfStore(key).recordings.at(-1);
|
|
75
|
+
if (!recording) {
|
|
76
|
+
throw new SdodsError('RUN_FAILED', 'No page vitals have been recorded in this scenario.', {
|
|
77
|
+
hint: 'Add `When I record the page vitals` after the navigation you want to measure.',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return recording;
|
|
81
|
+
}
|
|
82
|
+
export function lastSampleSet(key) {
|
|
83
|
+
const set = perfStore(key).sampleSets.at(-1);
|
|
84
|
+
if (!set) {
|
|
85
|
+
throw new SdodsError('RUN_FAILED', 'No latency samples have been taken in this scenario.', {
|
|
86
|
+
hint: 'Add `When I sample the latency of GET "/path" over 20 requests` before asserting a p95.',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return set;
|
|
90
|
+
}
|
|
91
|
+
/* ── statistics ───────────────────────────────────────────────────────── */
|
|
92
|
+
/**
|
|
93
|
+
* Nearest-rank p95 — deterministic for a given set of samples, with no interpolation to argue
|
|
94
|
+
* about. Below 20 samples the nearest rank IS the maximum; the sample count travels in the
|
|
95
|
+
* attachment and in every failure message so a reader can see when "p95" means "slowest of five".
|
|
96
|
+
*
|
|
97
|
+
* Throws rather than returning 0 on an empty sample: a p95 of 0 slides under every budget.
|
|
98
|
+
*/
|
|
99
|
+
export function p95Of(msValues) {
|
|
100
|
+
if (msValues.length === 0) {
|
|
101
|
+
throw new SdodsError('RUN_FAILED', 'Cannot compute a p95 over an empty sample.', {
|
|
102
|
+
hint: 'Sample at least one request before asserting a p95.',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const sorted = [...msValues].sort((a, b) => a - b);
|
|
106
|
+
const index = Math.max(0, Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1));
|
|
107
|
+
return sorted[index];
|
|
108
|
+
}
|
|
109
|
+
/* ── browser-side collection ──────────────────────────────────────────── */
|
|
110
|
+
/**
|
|
111
|
+
* How long the LCP observer is given to settle. LCP arrives as a stream of ever-larger candidates;
|
|
112
|
+
* `buffered: true` replays the ones already emitted and this window catches any still in flight.
|
|
113
|
+
* A fixed constant rather than a computed wait, so two runs of one scenario observe the same
|
|
114
|
+
* window and a baseline stays comparable.
|
|
115
|
+
*/
|
|
116
|
+
export const LCP_SETTLE_MS = 500;
|
|
117
|
+
/** Bounded wait for the load event before reading `loadEventEnd`. */
|
|
118
|
+
export const LOAD_WAIT_MS = 5_000;
|
|
119
|
+
/**
|
|
120
|
+
* Runs inside the page. Serialised by Playwright, so it closes over nothing but its argument — the
|
|
121
|
+
* type aliases above are erased at compile time and are safe to reference; the constants are not,
|
|
122
|
+
* which is why the settle window is passed in.
|
|
123
|
+
*/
|
|
124
|
+
export async function collectRawVitals(settleMs) {
|
|
125
|
+
const perf = performance;
|
|
126
|
+
const Observer = globalThis
|
|
127
|
+
.PerformanceObserver;
|
|
128
|
+
// Every vital is an offset from navigation start, so a genuine measurement is always > 0.
|
|
129
|
+
const positive = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : null;
|
|
130
|
+
const supported = Observer?.supportedEntryTypes ?? [];
|
|
131
|
+
const lcpSupported = supported.includes('largest-contentful-paint');
|
|
132
|
+
const fcpSupported = supported.includes('paint');
|
|
133
|
+
// TRAP: paint timings can land AFTER the load event. Reading the paint entry eagerly while only
|
|
134
|
+
// LCP waited for the settle window reported `fcpMs: null` beside a real `lcpMs` on a page that
|
|
135
|
+
// had painted perfectly well — impossible, since first paint always precedes the largest one —
|
|
136
|
+
// and a budget assertion would then have failed it as "not measured". So nothing is read until
|
|
137
|
+
// the window closes, and the window is opened whether or not LCP is supported.
|
|
138
|
+
let largestPaint = 0;
|
|
139
|
+
await new Promise((resolve) => {
|
|
140
|
+
let observer;
|
|
141
|
+
try {
|
|
142
|
+
if (Observer && lcpSupported) {
|
|
143
|
+
observer = new Observer((list) => {
|
|
144
|
+
for (const entry of list.getEntries()) {
|
|
145
|
+
const start = entry.startTime;
|
|
146
|
+
if (typeof start === 'number' && start > largestPaint)
|
|
147
|
+
largestPaint = start;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
observer.observe({ type: 'largest-contentful-paint', buffered: true });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
observer = undefined;
|
|
155
|
+
}
|
|
156
|
+
setTimeout(() => {
|
|
157
|
+
try {
|
|
158
|
+
observer?.disconnect();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* already gone */
|
|
162
|
+
}
|
|
163
|
+
resolve();
|
|
164
|
+
}, settleMs);
|
|
165
|
+
});
|
|
166
|
+
const nav = perf.getEntriesByType('navigation')[0];
|
|
167
|
+
const fcpEntry = perf.getEntriesByName('first-contentful-paint')[0];
|
|
168
|
+
const lcpMs = largestPaint > 0 ? Math.round(largestPaint) : null;
|
|
169
|
+
let totalResources = 0;
|
|
170
|
+
let totalBytes = 0;
|
|
171
|
+
for (const entry of perf.getEntriesByType('resource')) {
|
|
172
|
+
totalResources++;
|
|
173
|
+
const size = entry.transferSize;
|
|
174
|
+
if (typeof size === 'number' && Number.isFinite(size))
|
|
175
|
+
totalBytes += size;
|
|
176
|
+
}
|
|
177
|
+
const startTime = nav && typeof nav.startTime === 'number' ? nav.startTime : 0;
|
|
178
|
+
return {
|
|
179
|
+
hasNavigationEntry: Boolean(nav),
|
|
180
|
+
navigationStartEpochMs: nav && typeof perf.timeOrigin === 'number' ? Math.round(perf.timeOrigin + startTime) : null,
|
|
181
|
+
pageLoadMs: nav ? positive(nav.loadEventEnd) : null,
|
|
182
|
+
ttfbMs: nav ? positive(nav.responseStart) : null,
|
|
183
|
+
fcpMs: fcpEntry ? positive(fcpEntry.startTime) : null,
|
|
184
|
+
lcpMs,
|
|
185
|
+
domContentLoadedMs: nav ? positive(nav.domContentLoadedEventEnd) : null,
|
|
186
|
+
totalResources,
|
|
187
|
+
totalResourceSizeKB: Math.round(totalBytes / 1024),
|
|
188
|
+
lcpSupported,
|
|
189
|
+
fcpSupported,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Turns the page's answer into a recording, capturing WHY a vital is null while the browser's
|
|
194
|
+
* capabilities are still in hand. By the time a `Then` step fails, "lcpMs was not measured" on its
|
|
195
|
+
* own sends the reader hunting for a bug in a page that is merely running in a browser that does
|
|
196
|
+
* not implement LCP (WebKit). Support is read from `PerformanceObserver.supportedEntryTypes`, never
|
|
197
|
+
* assumed from the browser name.
|
|
198
|
+
*/
|
|
199
|
+
export function toRecording(raw, url, browserName) {
|
|
200
|
+
const vitals = {
|
|
201
|
+
pageLoadMs: raw.pageLoadMs,
|
|
202
|
+
lcpMs: raw.lcpMs,
|
|
203
|
+
fcpMs: raw.fcpMs,
|
|
204
|
+
ttfbMs: raw.ttfbMs,
|
|
205
|
+
};
|
|
206
|
+
const where = browserName ? ` in ${browserName}` : ' in this browser';
|
|
207
|
+
const unmeasured = {};
|
|
208
|
+
if (raw.pageLoadMs === null)
|
|
209
|
+
unmeasured.pageLoadMs = `the load event had not fired ${LOAD_WAIT_MS} ms after the vitals step began`;
|
|
210
|
+
if (raw.ttfbMs === null)
|
|
211
|
+
unmeasured.ttfbMs = 'the navigation entry reported no response start';
|
|
212
|
+
if (raw.fcpMs === null)
|
|
213
|
+
unmeasured.fcpMs = raw.fcpSupported
|
|
214
|
+
? 'the page painted no contentful frame'
|
|
215
|
+
: `paint timings are not implemented${where}`;
|
|
216
|
+
if (raw.lcpMs === null)
|
|
217
|
+
unmeasured.lcpMs = raw.lcpSupported
|
|
218
|
+
? 'the page produced no largest-contentful-paint candidate'
|
|
219
|
+
: `largest-contentful-paint is not implemented${where} — restrict the scenario to a browser that supports it`;
|
|
220
|
+
// The contract's three non-nullable fields carry 0 for an unmeasured value; the nullable
|
|
221
|
+
// `budgets.measured` block written alongside is what the assertions read, so a 0 here can never
|
|
222
|
+
// be mistaken for a measurement.
|
|
223
|
+
const metrics = {
|
|
224
|
+
url,
|
|
225
|
+
timestamp: new Date(raw.navigationStartEpochMs ?? Date.now()).toISOString(),
|
|
226
|
+
domContentLoaded: raw.domContentLoadedMs ?? 0,
|
|
227
|
+
pageLoadTime: raw.pageLoadMs ?? 0,
|
|
228
|
+
timeToFirstByte: raw.ttfbMs ?? 0,
|
|
229
|
+
totalResources: raw.totalResources,
|
|
230
|
+
totalResourceSizeKB: raw.totalResourceSizeKB,
|
|
231
|
+
firstContentfulPaint: raw.fcpMs,
|
|
232
|
+
largestContentfulPaint: raw.lcpMs,
|
|
233
|
+
};
|
|
234
|
+
return { url, vitals, unmeasured, metrics };
|
|
235
|
+
}
|
|
236
|
+
//# sourceMappingURL=vitals.js.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { type Page } from '@playwright/test';
|
|
2
|
+
import { type A11yConfig } from '@sdods/contracts';
|
|
3
|
+
import { type A11yResults } from '../a11y/audit.js';
|
|
4
|
+
/**
|
|
5
|
+
* The audit an `@a11y` tag buys. Before this existed the tag was a label: the linter accepted it,
|
|
6
|
+
* the docs said it ran axe, and nothing read it.
|
|
7
|
+
*
|
|
8
|
+
* At the END of an `@a11y` UI or hybrid scenario, the page the scenario finished on is audited with
|
|
9
|
+
* axe (WCAG 2.x A/AA, as the explicit steps are) and the scenario fails on any violation at or above
|
|
10
|
+
* `a11y.failOn`. The report is attached as `sdods/a11y-scenario` pass or fail, and it is also what
|
|
11
|
+
* the `a11y` process gate reads.
|
|
12
|
+
*
|
|
13
|
+
* `a11y.scope: every-page` widens that to every distinct URL the scenario reaches. Each one is
|
|
14
|
+
* audited after the STEP that reached it, once the page has settled at the point the `@perf` hook
|
|
15
|
+
* reads a document (load, first paint, the LCP settle window). The trigger is the step boundary
|
|
16
|
+
* rather than the page's own load event on purpose: an audit started from a load listener runs
|
|
17
|
+
* beside the next step's clicks, and a step that leaves the page mid-audit destroys the context axe
|
|
18
|
+
* runs in, so whether a page got audited would depend on timing. Between two steps nothing else
|
|
19
|
+
* drives the page. Violations are collected per URL and fail the scenario once, at its end, naming
|
|
20
|
+
* every URL that has them. A URL the scenario passed through inside a single step (a redirect, or a
|
|
21
|
+
* step that clicks through two pages) is listed as `not-audited`, so the gap is visible.
|
|
22
|
+
*
|
|
23
|
+
* What it deliberately does not do:
|
|
24
|
+
* · audit a scenario that already failed. The page it stopped on is wherever the failure left it,
|
|
25
|
+
* and a violation list on top of the real error only buries the error;
|
|
26
|
+
* · audit a URL an explicit whole-page audit step already covered in the same scenario. That step
|
|
27
|
+
* ran with the threshold its author chose, and it passed (a failed step would have failed the
|
|
28
|
+
* scenario). Under `every-page` this holds even when the step ran after the tag's own audit;
|
|
29
|
+
* · with the default `scope: final`, audit any page but the last one.
|
|
30
|
+
*/
|
|
31
|
+
export type A11yScenarioStatus = 'audited' | 'covered-by-step' | 'skipped';
|
|
32
|
+
/** One URL's audit under `a11y.scope: every-page`. */
|
|
33
|
+
export interface A11yPageReport {
|
|
34
|
+
url: string;
|
|
35
|
+
status: 'audited' | 'covered-by-step' | 'not-audited';
|
|
36
|
+
reason?: string;
|
|
37
|
+
/** When the page was audited: after the named step, or at the end of the scenario. */
|
|
38
|
+
auditedAfter?: string;
|
|
39
|
+
violations: number;
|
|
40
|
+
blocking: number;
|
|
41
|
+
blockingRules: string[];
|
|
42
|
+
results?: A11yResults;
|
|
43
|
+
}
|
|
44
|
+
/** What `sdods/a11y-scenario` contains, and what the `a11y` process gate reads back. */
|
|
45
|
+
export interface A11yScenarioReport {
|
|
46
|
+
kind: 'a11y-scenario';
|
|
47
|
+
runnerProject: string;
|
|
48
|
+
fingerprint: string;
|
|
49
|
+
retry: number;
|
|
50
|
+
/** The URL the scenario ended on. */
|
|
51
|
+
url: string;
|
|
52
|
+
status: A11yScenarioStatus;
|
|
53
|
+
reason?: string;
|
|
54
|
+
/** Absent in reports written before `a11y.scope` existed, which were all `final`. */
|
|
55
|
+
scope?: A11yConfig['scope'];
|
|
56
|
+
failOn: A11yConfig['failOn'];
|
|
57
|
+
include: string[];
|
|
58
|
+
exclude: string[];
|
|
59
|
+
/** Every violation axe reported, at any impact (summed over `pages` under `every-page`). */
|
|
60
|
+
violations: number;
|
|
61
|
+
/** Violations at or above `failOn`: the ones that fail the scenario. */
|
|
62
|
+
blocking: number;
|
|
63
|
+
blockingRules: string[];
|
|
64
|
+
/** The axe results of the final page (`final` scope only; `every-page` keeps them per page). */
|
|
65
|
+
results?: A11yResults;
|
|
66
|
+
/** Every URL the scenario reached, in order (`every-page` scope only). */
|
|
67
|
+
pages?: A11yPageReport[];
|
|
68
|
+
}
|
|
69
|
+
export interface A11yScenarioDeps {
|
|
70
|
+
page: Page;
|
|
71
|
+
config: {
|
|
72
|
+
project: {
|
|
73
|
+
a11y?: Partial<A11yConfig>;
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
scenario: {
|
|
77
|
+
data: {
|
|
78
|
+
runnerProject: string;
|
|
79
|
+
fingerprint: string;
|
|
80
|
+
retry: number;
|
|
81
|
+
};
|
|
82
|
+
file(rel: string): string;
|
|
83
|
+
};
|
|
84
|
+
testInfo: {
|
|
85
|
+
status?: string;
|
|
86
|
+
attach(name: string, opts: {
|
|
87
|
+
path: string;
|
|
88
|
+
contentType: string;
|
|
89
|
+
}): Promise<void>;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** Effective a11y settings with the schema defaults, so a hand-built config behaves like a parsed one. */
|
|
93
|
+
export declare function a11ySettings(config: A11yScenarioDeps['config']): A11yConfig;
|
|
94
|
+
/**
|
|
95
|
+
* Under `every-page`, starts listing the URLs the page's main frame reaches, so a URL no step ended
|
|
96
|
+
* on still shows up in the report. A no-op under `final`.
|
|
97
|
+
*/
|
|
98
|
+
export declare function watchA11yPages(deps: Pick<A11yScenarioDeps, 'page' | 'config' | 'scenario'>): void;
|
|
99
|
+
/**
|
|
100
|
+
* Under `every-page`, audits the page after a step when the step left it on a URL not yet audited
|
|
101
|
+
* or covered. Violations are kept for the end of the scenario; a configuration defect (an `include`
|
|
102
|
+
* that matches nothing, an audit that examined nothing) fails the step it follows.
|
|
103
|
+
*/
|
|
104
|
+
export declare function auditAfterStep(deps: Pick<A11yScenarioDeps, 'page' | 'config' | 'scenario'> & {
|
|
105
|
+
step: string;
|
|
106
|
+
/** True when the step failed: its page proves nothing, as at the end of a failed scenario. */
|
|
107
|
+
failed?: boolean;
|
|
108
|
+
}): Promise<A11yPageReport | undefined>;
|
|
109
|
+
export declare function auditScenarioEnd(deps: A11yScenarioDeps): Promise<A11yScenarioReport>;
|
|
110
|
+
//# sourceMappingURL=a11y-scenario.d.ts.map
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { expect } from '@playwright/test';
|
|
2
|
+
import { attachmentNames, scenarioFiles } from '@sdods/contracts';
|
|
3
|
+
import { IMPACT_ORDER, assertAxeChecked, describeViolations, parseImpactFloor, runAxe, violationsAtOrAbove, wasPageAudited, writeAndAttachJson, } from '../a11y/audit.js';
|
|
4
|
+
import { SdodsError } from '../errors.js';
|
|
5
|
+
import { waitForSettle } from './perf-scenario.js';
|
|
6
|
+
/** Effective a11y settings with the schema defaults, so a hand-built config behaves like a parsed one. */
|
|
7
|
+
export function a11ySettings(config) {
|
|
8
|
+
const a = config.project.a11y ?? {};
|
|
9
|
+
return {
|
|
10
|
+
failOn: a.failOn ?? 'serious',
|
|
11
|
+
include: a.include ?? [],
|
|
12
|
+
exclude: a.exclude ?? [],
|
|
13
|
+
scope: a.scope ?? 'final',
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const pageWatches = new WeakMap();
|
|
17
|
+
function watchOf(scenario) {
|
|
18
|
+
let watch = pageWatches.get(scenario);
|
|
19
|
+
if (!watch)
|
|
20
|
+
pageWatches.set(scenario, (watch = { visited: [], pages: new Map() }));
|
|
21
|
+
return watch;
|
|
22
|
+
}
|
|
23
|
+
const auditable = (url) => Boolean(url) && url !== 'about:blank';
|
|
24
|
+
/**
|
|
25
|
+
* Under `every-page`, starts listing the URLs the page's main frame reaches, so a URL no step ended
|
|
26
|
+
* on still shows up in the report. A no-op under `final`.
|
|
27
|
+
*/
|
|
28
|
+
export function watchA11yPages(deps) {
|
|
29
|
+
if (a11ySettings(deps.config).scope !== 'every-page')
|
|
30
|
+
return;
|
|
31
|
+
const watch = watchOf(deps.scenario);
|
|
32
|
+
deps.page.on('framenavigated', (frame) => {
|
|
33
|
+
if (frame !== deps.page.mainFrame())
|
|
34
|
+
return;
|
|
35
|
+
const url = frame.url();
|
|
36
|
+
if (auditable(url) && !watch.visited.includes(url))
|
|
37
|
+
watch.visited.push(url);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** A navigation that replaced the document axe was running in: not a defect of the page. */
|
|
41
|
+
const LEFT_THE_PAGE = /execution context was destroyed|frame was detached|navigat/i;
|
|
42
|
+
async function auditPage(page, settings, url, auditedAfter) {
|
|
43
|
+
const results = await runAxe(page, { include: settings.include, exclude: settings.exclude });
|
|
44
|
+
assertAxeChecked(results, `on ${url}`);
|
|
45
|
+
const blocking = violationsAtOrAbove(results.violations, parseImpactFloor(settings.failOn));
|
|
46
|
+
return {
|
|
47
|
+
url,
|
|
48
|
+
status: 'audited',
|
|
49
|
+
auditedAfter,
|
|
50
|
+
violations: results.violations.length,
|
|
51
|
+
blocking: blocking.length,
|
|
52
|
+
blockingRules: blocking.map((v) => v.id),
|
|
53
|
+
results,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Under `every-page`, audits the page after a step when the step left it on a URL not yet audited
|
|
58
|
+
* or covered. Violations are kept for the end of the scenario; a configuration defect (an `include`
|
|
59
|
+
* that matches nothing, an audit that examined nothing) fails the step it follows.
|
|
60
|
+
*/
|
|
61
|
+
export async function auditAfterStep(deps) {
|
|
62
|
+
const { page, scenario } = deps;
|
|
63
|
+
const settings = a11ySettings(deps.config);
|
|
64
|
+
if (settings.scope !== 'every-page' || deps.failed)
|
|
65
|
+
return undefined;
|
|
66
|
+
const watch = watchOf(scenario);
|
|
67
|
+
watch.fresh = undefined;
|
|
68
|
+
const before = page.url();
|
|
69
|
+
if (!auditable(before) || watch.pages.has(before))
|
|
70
|
+
return undefined;
|
|
71
|
+
if (wasPageAudited(scenario, before))
|
|
72
|
+
return covered(watch, before);
|
|
73
|
+
await waitForSettle(page);
|
|
74
|
+
// The page may have redirected while it settled; audit where it is now.
|
|
75
|
+
const url = page.url();
|
|
76
|
+
if (!auditable(url) || watch.pages.has(url))
|
|
77
|
+
return undefined;
|
|
78
|
+
if (wasPageAudited(scenario, url))
|
|
79
|
+
return covered(watch, url);
|
|
80
|
+
if (!watch.visited.includes(url))
|
|
81
|
+
watch.visited.push(url);
|
|
82
|
+
try {
|
|
83
|
+
const report = await auditPage(page, settings, url, deps.step);
|
|
84
|
+
watch.pages.set(url, report);
|
|
85
|
+
watch.fresh = url;
|
|
86
|
+
return report;
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
// A client-side redirect after the settle point: the next step boundary, or the end of the
|
|
90
|
+
// scenario, audits wherever it went. This URL is listed as not audited.
|
|
91
|
+
if (e instanceof Error && LEFT_THE_PAGE.test(e.message) && page.url() !== url)
|
|
92
|
+
return undefined;
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function covered(watch, url) {
|
|
97
|
+
const report = {
|
|
98
|
+
url,
|
|
99
|
+
status: 'covered-by-step',
|
|
100
|
+
reason: 'an explicit whole-page accessibility step already audited this URL in the scenario',
|
|
101
|
+
violations: 0,
|
|
102
|
+
blocking: 0,
|
|
103
|
+
blockingRules: [],
|
|
104
|
+
};
|
|
105
|
+
if (!watch.visited.includes(url))
|
|
106
|
+
watch.visited.push(url);
|
|
107
|
+
watch.pages.set(url, report);
|
|
108
|
+
return report;
|
|
109
|
+
}
|
|
110
|
+
/* ── the end of the scenario ─────────────────────────────────────────────── */
|
|
111
|
+
export async function auditScenarioEnd(deps) {
|
|
112
|
+
const { page, scenario, testInfo } = deps;
|
|
113
|
+
const settings = a11ySettings(deps.config);
|
|
114
|
+
const floor = parseImpactFloor(settings.failOn);
|
|
115
|
+
const url = page.url();
|
|
116
|
+
const everyPage = settings.scope === 'every-page';
|
|
117
|
+
const base = {
|
|
118
|
+
kind: 'a11y-scenario',
|
|
119
|
+
runnerProject: scenario.data.runnerProject,
|
|
120
|
+
fingerprint: scenario.data.fingerprint,
|
|
121
|
+
retry: scenario.data.retry,
|
|
122
|
+
url,
|
|
123
|
+
...(everyPage ? { scope: settings.scope } : {}),
|
|
124
|
+
failOn: settings.failOn,
|
|
125
|
+
include: settings.include,
|
|
126
|
+
exclude: settings.exclude,
|
|
127
|
+
violations: 0,
|
|
128
|
+
blocking: 0,
|
|
129
|
+
blockingRules: [],
|
|
130
|
+
};
|
|
131
|
+
const file = scenario.file(scenarioFiles.a11yScenarioJson(scenario.data.runnerProject));
|
|
132
|
+
const write = (report) => writeAndAttachJson(testInfo, attachmentNames.a11yScenario, file, report).then(() => report);
|
|
133
|
+
if (testInfo.status && testInfo.status !== 'passed') {
|
|
134
|
+
return write({
|
|
135
|
+
...base,
|
|
136
|
+
status: 'skipped',
|
|
137
|
+
reason: `the scenario ${testInfo.status} before the audit, so the page it stopped on proves nothing`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const watch = everyPage ? watchOf(scenario) : undefined;
|
|
141
|
+
if (!auditable(url) && !watch?.pages.size) {
|
|
142
|
+
throw new SdodsError('RUN_FAILED', 'This @a11y scenario never navigated anywhere, so there is no page to audit.', {
|
|
143
|
+
hint: 'Navigate to the page under test in the scenario, or drop @a11y from a scenario that has no page.',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (watch)
|
|
147
|
+
return auditEveryPageEnd({ ...deps, settings, url, watch, base, write });
|
|
148
|
+
if (wasPageAudited(scenario, url)) {
|
|
149
|
+
return write({
|
|
150
|
+
...base,
|
|
151
|
+
status: 'covered-by-step',
|
|
152
|
+
reason: 'an explicit whole-page accessibility step already audited this URL in the scenario',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const results = await runAxe(page, { include: settings.include, exclude: settings.exclude });
|
|
156
|
+
const blocking = violationsAtOrAbove(results.violations, floor);
|
|
157
|
+
const report = await write({
|
|
158
|
+
...base,
|
|
159
|
+
status: 'audited',
|
|
160
|
+
violations: results.violations.length,
|
|
161
|
+
blocking: blocking.length,
|
|
162
|
+
blockingRules: blocking.map((v) => v.id),
|
|
163
|
+
results,
|
|
164
|
+
});
|
|
165
|
+
assertAxeChecked(results, `on ${url}`);
|
|
166
|
+
expect(blocking.length, `@a11y: ${blocking.length} violation(s) at ${IMPACT_ORDER[floor]} or worse on ${url} (of ${results.violations.length} total; a11y.failOn is "${settings.failOn}"):\n${describeViolations(blocking)}`).toBe(0);
|
|
167
|
+
return report;
|
|
168
|
+
}
|
|
169
|
+
async function auditEveryPageEnd(input) {
|
|
170
|
+
const { page, scenario, settings, url, watch, base, write } = input;
|
|
171
|
+
const floor = parseImpactFloor(settings.failOn);
|
|
172
|
+
// The final page is audited as it stands at the end, as under `final`: the steps after the one
|
|
173
|
+
// that reached it may have changed what is on it. Unless the last step's own audit is that state.
|
|
174
|
+
if (auditable(url) && watch.fresh !== url) {
|
|
175
|
+
if (!watch.visited.includes(url))
|
|
176
|
+
watch.visited.push(url);
|
|
177
|
+
if (wasPageAudited(scenario, url))
|
|
178
|
+
covered(watch, url);
|
|
179
|
+
else
|
|
180
|
+
watch.pages.set(url, await auditPage(page, settings, url, 'the end of the scenario'));
|
|
181
|
+
}
|
|
182
|
+
const pages = watch.visited.map((visited) => {
|
|
183
|
+
const audit = watch.pages.get(visited);
|
|
184
|
+
// An explicit step that audited this URL AFTER the tag did has the last word, as it does under `final`.
|
|
185
|
+
if (audit?.status === 'audited' && wasPageAudited(scenario, visited))
|
|
186
|
+
return {
|
|
187
|
+
url: visited,
|
|
188
|
+
status: 'covered-by-step',
|
|
189
|
+
reason: 'an explicit whole-page accessibility step audited this URL later in the scenario, with the threshold its author chose',
|
|
190
|
+
violations: 0,
|
|
191
|
+
blocking: 0,
|
|
192
|
+
blockingRules: [],
|
|
193
|
+
};
|
|
194
|
+
return (audit ?? {
|
|
195
|
+
url: visited,
|
|
196
|
+
status: 'not-audited',
|
|
197
|
+
reason: 'the scenario left this URL within the step that reached it, so no step boundary found it',
|
|
198
|
+
violations: 0,
|
|
199
|
+
blocking: 0,
|
|
200
|
+
blockingRules: [],
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
const audited = pages.filter((p) => p.status === 'audited');
|
|
204
|
+
const failing = audited.filter((p) => p.blocking > 0);
|
|
205
|
+
const report = await write({
|
|
206
|
+
...base,
|
|
207
|
+
status: audited.length ? 'audited' : 'covered-by-step',
|
|
208
|
+
...(audited.length
|
|
209
|
+
? {}
|
|
210
|
+
: {
|
|
211
|
+
reason: 'explicit whole-page accessibility steps covered every URL the scenario reached',
|
|
212
|
+
}),
|
|
213
|
+
violations: audited.reduce((n, p) => n + p.violations, 0),
|
|
214
|
+
blocking: audited.reduce((n, p) => n + p.blocking, 0),
|
|
215
|
+
blockingRules: [...new Set(failing.flatMap((p) => p.blockingRules))],
|
|
216
|
+
pages,
|
|
217
|
+
});
|
|
218
|
+
if (failing.length) {
|
|
219
|
+
const byUrl = failing
|
|
220
|
+
.map((p) => {
|
|
221
|
+
const blocking = violationsAtOrAbove(p.results?.violations ?? [], floor);
|
|
222
|
+
return `${p.url} — ${p.blocking} of ${p.violations}:\n${describeViolations(blocking)}`;
|
|
223
|
+
})
|
|
224
|
+
.join('\n');
|
|
225
|
+
expect(report.blocking, `@a11y: ${report.blocking} violation(s) at ${IMPACT_ORDER[floor]} or worse on ${failing.length} of ${audited.length} audited page(s) (a11y.scope is "every-page", a11y.failOn is "${settings.failOn}"):\n${byUrl}`).toBe(0);
|
|
226
|
+
}
|
|
227
|
+
return report;
|
|
228
|
+
}
|
|
229
|
+
//# sourceMappingURL=a11y-scenario.js.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { GateResult, GateRow, ProcessConfig, ProcessGates, RunManifest } from '@sdods/contracts';
|
|
2
|
+
import type { A11yScenarioReport } from './a11y-scenario.js';
|
|
3
|
+
import type { PerfScenarioReport } from './perf-scenario.js';
|
|
4
|
+
export type { GateResult, GateRow };
|
|
5
|
+
/**
|
|
6
|
+
* Process gates: `gates:` on a process in `sdods.project.yaml`.
|
|
7
|
+
*
|
|
8
|
+
* Until this existed the web UI saved `minPassRate`, `maxFlaky`, `a11y` and `perfBudgets`, the
|
|
9
|
+
* release process declared all four, and nothing anywhere evaluated them. A gate that is read by
|
|
10
|
+
* nothing is worse than no gate: it is the reason a release goes out believing it was checked.
|
|
11
|
+
*
|
|
12
|
+
* Evaluated by `sdods run --process <name>` from the run it just finished:
|
|
13
|
+
* · `minPassRate` — (passed + flaky) / (total − skipped), as a percentage, the same arithmetic as
|
|
14
|
+
* the run dashboard. A scenario that passed only on retry counts as passed here and as flaky
|
|
15
|
+
* below. A run that executed nothing has no pass rate, and fails the gate.
|
|
16
|
+
* · `maxFlaky` — scenarios that failed and then passed on a retry.
|
|
17
|
+
* · `a11y` — every `@a11y` audit in the run found nothing at or above `a11y.failOn`, AND at least
|
|
18
|
+
* one audit ran. Violations already fail their scenarios; what this gate adds is the proof that
|
|
19
|
+
* the audits happened at all, because a process declaring `a11y: true` over a selection with no
|
|
20
|
+
* `@a11y` scenario would otherwise pass having checked nothing.
|
|
21
|
+
* · `perfBudgets` — the same for `@perf`: no breach in any judged scenario, and at least one
|
|
22
|
+
* scenario was judged.
|
|
23
|
+
*
|
|
24
|
+
* The a11y and perf evidence is read from the scenario reports the tag hooks write into the run
|
|
25
|
+
* directory, taking the last attempt of each scenario on each runner project.
|
|
26
|
+
*/
|
|
27
|
+
export interface GateTotals {
|
|
28
|
+
total: number;
|
|
29
|
+
passed: number;
|
|
30
|
+
failed: number;
|
|
31
|
+
skipped: number;
|
|
32
|
+
flaky: number;
|
|
33
|
+
}
|
|
34
|
+
export interface GateEvidence {
|
|
35
|
+
a11y: A11yScenarioReport[];
|
|
36
|
+
perf: PerfScenarioReport[];
|
|
37
|
+
}
|
|
38
|
+
/** True when the process declares at least one gate worth evaluating. */
|
|
39
|
+
export declare function hasGates(gates: Partial<ProcessGates> | undefined): boolean;
|
|
40
|
+
/** Pure: the verdict for one process's gates over a run's totals and evidence. */
|
|
41
|
+
export declare function evaluateGates(input: {
|
|
42
|
+
process: string;
|
|
43
|
+
gates: Partial<ProcessGates> | undefined;
|
|
44
|
+
totals: GateTotals | undefined;
|
|
45
|
+
evidence: GateEvidence;
|
|
46
|
+
}): GateResult;
|
|
47
|
+
/**
|
|
48
|
+
* Reads the `@a11y` and `@perf` scenario reports out of one or more run directories
|
|
49
|
+
* (`<runDir>/<slug>/<fingerprint>/r<retry>/{a11y,perf}/scenario--<runner project>.json`), keeping
|
|
50
|
+
* only the highest retry of each scenario on each runner project: an attempt that was retried is not
|
|
51
|
+
* the scenario's verdict. Several directories are how the shards of one run are read together: each
|
|
52
|
+
* shard wrote its own scenarios, and the same scenario never ran on two shards.
|
|
53
|
+
*/
|
|
54
|
+
export declare function collectGateEvidence(runDirs: string | readonly string[],
|
|
55
|
+
/** Reports read from elsewhere (the attachments of a merged report), judged the same way. */
|
|
56
|
+
extra?: readonly (A11yScenarioReport | PerfScenarioReport)[]): GateEvidence;
|
|
57
|
+
/**
|
|
58
|
+
* The `sdods/a11y-scenario` and `sdods/perf-scenario` reports attached to the tests of a runner JSON
|
|
59
|
+
* report. After `playwright merge-reports` their paths point into the directory the blob reports
|
|
60
|
+
* were unpacked into, which lives only as long as the merge, so read them before it is removed. A
|
|
61
|
+
* missing or unreadable file is skipped; the run directories are the other source of the same
|
|
62
|
+
* evidence.
|
|
63
|
+
*/
|
|
64
|
+
export declare function gateReportsFromRunnerJson(report: unknown): (A11yScenarioReport | PerfScenarioReport)[];
|
|
65
|
+
/**
|
|
66
|
+
* Gate totals from a runner JSON report's `stats` (what `playwright merge-reports --reporter json`
|
|
67
|
+
* writes for the merged shards). The same counting as the run dashboard: one row per test, retries
|
|
68
|
+
* folded into `flaky`.
|
|
69
|
+
*/
|
|
70
|
+
export declare function gateTotalsFromRunnerStats(stats: {
|
|
71
|
+
expected?: number;
|
|
72
|
+
unexpected?: number;
|
|
73
|
+
flaky?: number;
|
|
74
|
+
skipped?: number;
|
|
75
|
+
} | undefined): GateTotals | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* The gate judge ingest runs when a run directory carries no `gates.json`: the process the run's
|
|
78
|
+
* manifest names, over the totals ingest just computed and the evidence in the run directory.
|
|
79
|
+
* `undefined` when there is nothing it may judge: no process, a process without gates, a cancelled
|
|
80
|
+
* run, or one shard of several (a shard is not the run; `sdods report merge --process` judges it).
|
|
81
|
+
*/
|
|
82
|
+
export declare function ingestGateJudge(input: {
|
|
83
|
+
manifest: Partial<RunManifest> | null | undefined;
|
|
84
|
+
runDir: string;
|
|
85
|
+
processOf: (slug: string, name: string) => ProcessConfig;
|
|
86
|
+
}): ((totals: GateTotals) => GateResult | undefined) | undefined;
|
|
87
|
+
//# sourceMappingURL=gates.d.ts.map
|