@sdods/core 0.8.0 → 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/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/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/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/matrix/index.d.ts +13 -0
- package/dist/matrix/index.js +24 -3
- 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/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 +3 -3
|
@@ -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,68 @@
|
|
|
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
|
+
* What it deliberately does not do:
|
|
14
|
+
* · audit a scenario that already failed. The page it stopped on is wherever the failure left it,
|
|
15
|
+
* and a violation list on top of the real error only buries the error;
|
|
16
|
+
* · audit a URL an explicit whole-page audit step already covered in the same scenario. That step
|
|
17
|
+
* ran with the threshold its author chose, and it passed (a failed step would have failed the
|
|
18
|
+
* scenario);
|
|
19
|
+
* · audit every page the scenario passed through. It audits the final page. A flow that needs each
|
|
20
|
+
* page checked writes the explicit step after each navigation.
|
|
21
|
+
*/
|
|
22
|
+
export type A11yScenarioStatus = 'audited' | 'covered-by-step' | 'skipped';
|
|
23
|
+
/** What `sdods/a11y-scenario` contains, and what the `a11y` process gate reads back. */
|
|
24
|
+
export interface A11yScenarioReport {
|
|
25
|
+
kind: 'a11y-scenario';
|
|
26
|
+
runnerProject: string;
|
|
27
|
+
fingerprint: string;
|
|
28
|
+
retry: number;
|
|
29
|
+
url: string;
|
|
30
|
+
status: A11yScenarioStatus;
|
|
31
|
+
reason?: string;
|
|
32
|
+
failOn: A11yConfig['failOn'];
|
|
33
|
+
include: string[];
|
|
34
|
+
exclude: string[];
|
|
35
|
+
/** Every violation axe reported, at any impact. */
|
|
36
|
+
violations: number;
|
|
37
|
+
/** Violations at or above `failOn`: the ones that fail the scenario. */
|
|
38
|
+
blocking: number;
|
|
39
|
+
blockingRules: string[];
|
|
40
|
+
results?: A11yResults;
|
|
41
|
+
}
|
|
42
|
+
export interface A11yScenarioDeps {
|
|
43
|
+
page: Page;
|
|
44
|
+
config: {
|
|
45
|
+
project: {
|
|
46
|
+
a11y?: Partial<A11yConfig>;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
scenario: {
|
|
50
|
+
data: {
|
|
51
|
+
runnerProject: string;
|
|
52
|
+
fingerprint: string;
|
|
53
|
+
retry: number;
|
|
54
|
+
};
|
|
55
|
+
file(rel: string): string;
|
|
56
|
+
};
|
|
57
|
+
testInfo: {
|
|
58
|
+
status?: string;
|
|
59
|
+
attach(name: string, opts: {
|
|
60
|
+
path: string;
|
|
61
|
+
contentType: string;
|
|
62
|
+
}): Promise<void>;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Effective a11y settings with the schema defaults, so a hand-built config behaves like a parsed one. */
|
|
66
|
+
export declare function a11ySettings(config: A11yScenarioDeps['config']): A11yConfig;
|
|
67
|
+
export declare function auditScenarioEnd(deps: A11yScenarioDeps): Promise<A11yScenarioReport>;
|
|
68
|
+
//# sourceMappingURL=a11y-scenario.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
/** Effective a11y settings with the schema defaults, so a hand-built config behaves like a parsed one. */
|
|
6
|
+
export function a11ySettings(config) {
|
|
7
|
+
const a = config.project.a11y ?? {};
|
|
8
|
+
return { failOn: a.failOn ?? 'serious', include: a.include ?? [], exclude: a.exclude ?? [] };
|
|
9
|
+
}
|
|
10
|
+
export async function auditScenarioEnd(deps) {
|
|
11
|
+
const { page, scenario, testInfo } = deps;
|
|
12
|
+
const settings = a11ySettings(deps.config);
|
|
13
|
+
const floor = parseImpactFloor(settings.failOn);
|
|
14
|
+
const url = page.url();
|
|
15
|
+
const base = {
|
|
16
|
+
kind: 'a11y-scenario',
|
|
17
|
+
runnerProject: scenario.data.runnerProject,
|
|
18
|
+
fingerprint: scenario.data.fingerprint,
|
|
19
|
+
retry: scenario.data.retry,
|
|
20
|
+
url,
|
|
21
|
+
failOn: settings.failOn,
|
|
22
|
+
include: settings.include,
|
|
23
|
+
exclude: settings.exclude,
|
|
24
|
+
violations: 0,
|
|
25
|
+
blocking: 0,
|
|
26
|
+
blockingRules: [],
|
|
27
|
+
};
|
|
28
|
+
const file = scenario.file(scenarioFiles.a11yScenarioJson(scenario.data.runnerProject));
|
|
29
|
+
const write = (report) => writeAndAttachJson(testInfo, attachmentNames.a11yScenario, file, report).then(() => report);
|
|
30
|
+
if (testInfo.status && testInfo.status !== 'passed') {
|
|
31
|
+
return write({
|
|
32
|
+
...base,
|
|
33
|
+
status: 'skipped',
|
|
34
|
+
reason: `the scenario ${testInfo.status} before the audit, so the page it stopped on proves nothing`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (!url || url === 'about:blank') {
|
|
38
|
+
throw new SdodsError('RUN_FAILED', 'This @a11y scenario never navigated anywhere, so there is no page to audit.', {
|
|
39
|
+
hint: 'Navigate to the page under test in the scenario, or drop @a11y from a scenario that has no page.',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (wasPageAudited(scenario, url)) {
|
|
43
|
+
return write({
|
|
44
|
+
...base,
|
|
45
|
+
status: 'covered-by-step',
|
|
46
|
+
reason: 'an explicit whole-page accessibility step already audited this URL in the scenario',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const results = await runAxe(page, { include: settings.include, exclude: settings.exclude });
|
|
50
|
+
const blocking = violationsAtOrAbove(results.violations, floor);
|
|
51
|
+
const report = await write({
|
|
52
|
+
...base,
|
|
53
|
+
status: 'audited',
|
|
54
|
+
violations: results.violations.length,
|
|
55
|
+
blocking: blocking.length,
|
|
56
|
+
blockingRules: blocking.map((v) => v.id),
|
|
57
|
+
results,
|
|
58
|
+
});
|
|
59
|
+
assertAxeChecked(results, `on ${url}`);
|
|
60
|
+
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);
|
|
61
|
+
return report;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=a11y-scenario.js.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { GateResult, GateRow, ProcessGates } 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 a run directory
|
|
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.
|
|
52
|
+
*/
|
|
53
|
+
export declare function collectGateEvidence(runDir: string): GateEvidence;
|
|
54
|
+
//# sourceMappingURL=gates.d.ts.map
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/** True when the process declares at least one gate worth evaluating. */
|
|
4
|
+
export function hasGates(gates) {
|
|
5
|
+
return Boolean(gates &&
|
|
6
|
+
(gates.minPassRate !== undefined ||
|
|
7
|
+
gates.maxFlaky !== undefined ||
|
|
8
|
+
gates.a11y === true ||
|
|
9
|
+
gates.perfBudgets === true));
|
|
10
|
+
}
|
|
11
|
+
/** Pure: the verdict for one process's gates over a run's totals and evidence. */
|
|
12
|
+
export function evaluateGates(input) {
|
|
13
|
+
const { gates = {}, totals, evidence } = input;
|
|
14
|
+
const rows = [];
|
|
15
|
+
if (gates.minPassRate !== undefined) {
|
|
16
|
+
const executed = totals ? totals.total - totals.skipped : 0;
|
|
17
|
+
if (!totals || executed <= 0) {
|
|
18
|
+
rows.push({
|
|
19
|
+
gate: 'minPassRate',
|
|
20
|
+
threshold: `≥ ${gates.minPassRate}%`,
|
|
21
|
+
actual: 'n/a',
|
|
22
|
+
passed: false,
|
|
23
|
+
detail: totals
|
|
24
|
+
? 'no scenario executed, so there is no pass rate to meet the gate'
|
|
25
|
+
: 'the run produced no results to compute a pass rate from',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
const rate = ((totals.passed + totals.flaky) / executed) * 100;
|
|
30
|
+
rows.push({
|
|
31
|
+
gate: 'minPassRate',
|
|
32
|
+
threshold: `≥ ${gates.minPassRate}%`,
|
|
33
|
+
actual: `${formatPct(rate)}% (${totals.passed + totals.flaky}/${executed})`,
|
|
34
|
+
passed: rate >= gates.minPassRate,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (gates.maxFlaky !== undefined) {
|
|
39
|
+
rows.push({
|
|
40
|
+
gate: 'maxFlaky',
|
|
41
|
+
threshold: `≤ ${gates.maxFlaky}`,
|
|
42
|
+
actual: totals ? String(totals.flaky) : 'n/a',
|
|
43
|
+
passed: Boolean(totals) && (totals?.flaky ?? 0) <= gates.maxFlaky,
|
|
44
|
+
detail: totals ? undefined : 'the run produced no results to count flaky scenarios from',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (gates.a11y) {
|
|
48
|
+
const audited = evidence.a11y.filter((r) => r.status !== 'skipped');
|
|
49
|
+
const blocking = audited.reduce((n, r) => n + r.blocking, 0);
|
|
50
|
+
const failing = audited.filter((r) => r.blocking > 0);
|
|
51
|
+
rows.push({
|
|
52
|
+
gate: 'a11y',
|
|
53
|
+
threshold: 'no blocking violation',
|
|
54
|
+
actual: `${blocking} blocking in ${audited.length} audit(s)`,
|
|
55
|
+
passed: audited.length > 0 && blocking === 0,
|
|
56
|
+
detail: audited.length === 0
|
|
57
|
+
? 'no @a11y audit ran in this run, so the gate has nothing to prove'
|
|
58
|
+
: failing.length
|
|
59
|
+
? failing
|
|
60
|
+
.slice(0, 5)
|
|
61
|
+
.map((r) => `${r.url} [${r.runnerProject}]: ${r.blockingRules.join(', ')}`)
|
|
62
|
+
.join('; ')
|
|
63
|
+
: undefined,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (gates.perfBudgets) {
|
|
67
|
+
const judged = evidence.perf.filter((r) => r.status === 'judged' && r.judged > 0);
|
|
68
|
+
const breaches = judged.flatMap((r) => r.breaches.map((b) => ({ ...b, runnerProject: r.runnerProject })));
|
|
69
|
+
rows.push({
|
|
70
|
+
gate: 'perfBudgets',
|
|
71
|
+
threshold: 'no budget breach',
|
|
72
|
+
actual: `${breaches.length} breach(es) in ${judged.length} scenario(s)`,
|
|
73
|
+
passed: judged.length > 0 && breaches.length === 0,
|
|
74
|
+
detail: judged.length === 0
|
|
75
|
+
? 'no @perf scenario was judged against a budget in this run, so the gate has nothing to prove'
|
|
76
|
+
: breaches.length
|
|
77
|
+
? breaches
|
|
78
|
+
.slice(0, 5)
|
|
79
|
+
.map((b) => `${b.budget} ${b.measuredMs ?? 'unmeasured'}/${b.limitMs} ms at ${b.where}`)
|
|
80
|
+
.join('; ')
|
|
81
|
+
: undefined,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return { process: input.process, passed: rows.every((r) => r.passed), rows };
|
|
85
|
+
}
|
|
86
|
+
function formatPct(n) {
|
|
87
|
+
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Reads the `@a11y` and `@perf` scenario reports out of a run directory
|
|
91
|
+
* (`<runDir>/<slug>/<fingerprint>/r<retry>/{a11y,perf}/scenario--<runner project>.json`), keeping
|
|
92
|
+
* only the highest retry of each scenario on each runner project: an attempt that was retried is not
|
|
93
|
+
* the scenario's verdict.
|
|
94
|
+
*/
|
|
95
|
+
export function collectGateEvidence(runDir) {
|
|
96
|
+
const latest = new Map();
|
|
97
|
+
const keep = (report) => {
|
|
98
|
+
const key = `${report.kind}|${report.fingerprint}|${report.runnerProject}`;
|
|
99
|
+
const prev = latest.get(key);
|
|
100
|
+
if (prev && prev.retry > report.retry)
|
|
101
|
+
return;
|
|
102
|
+
latest.set(key, report.kind === 'a11y-scenario'
|
|
103
|
+
? { retry: report.retry, a11y: report }
|
|
104
|
+
: { retry: report.retry, perf: report });
|
|
105
|
+
};
|
|
106
|
+
for (const file of scenarioReportFiles(runDir)) {
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
109
|
+
if (parsed.kind === 'a11y-scenario' || parsed.kind === 'perf-scenario')
|
|
110
|
+
keep(parsed);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* a half-written report is not evidence */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const values = [...latest.values()];
|
|
117
|
+
return {
|
|
118
|
+
a11y: values.flatMap((v) => (v.a11y ? [v.a11y] : [])),
|
|
119
|
+
perf: values.flatMap((v) => (v.perf ? [v.perf] : [])),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function scenarioReportFiles(runDir) {
|
|
123
|
+
const out = [];
|
|
124
|
+
const dirs = (p) => existsSync(p) ? readdirSync(p).filter((n) => statSync(join(p, n)).isDirectory()) : [];
|
|
125
|
+
for (const slug of dirs(runDir)) {
|
|
126
|
+
// Fingerprints are 16 hex characters; this keeps the walk out of the report directories.
|
|
127
|
+
for (const fp of dirs(join(runDir, slug)).filter((n) => /^[0-9a-f]{16}$/.test(n))) {
|
|
128
|
+
for (const attempt of dirs(join(runDir, slug, fp))) {
|
|
129
|
+
if (!/^r\d+$/.test(attempt))
|
|
130
|
+
continue;
|
|
131
|
+
for (const kind of ['a11y', 'perf']) {
|
|
132
|
+
const dir = join(runDir, slug, fp, attempt, kind);
|
|
133
|
+
if (!existsSync(dir))
|
|
134
|
+
continue;
|
|
135
|
+
for (const name of readdirSync(dir))
|
|
136
|
+
if (name.startsWith('scenario--') && name.endsWith('.json'))
|
|
137
|
+
out.push(join(dir, name));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=gates.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AfterScenario, BeforeScenario } from '../fixtures/test.js';
|
|
2
|
+
import { auditScenarioEnd } from './a11y-scenario.js';
|
|
3
|
+
import { judgeScenarioPerf, watchNavigations } from './perf-scenario.js';
|
|
4
|
+
/**
|
|
5
|
+
* `@a11y` and `@perf` tag hooks.
|
|
6
|
+
*
|
|
7
|
+
* Tag-filtered scenario hooks rather than auto fixtures, for the reason `shots/hooks.ts` gives: a
|
|
8
|
+
* hook's fixtures are requested only by the scenarios its tag expression matches (playwright-bdd
|
|
9
|
+
* decides that when it generates the spec), so an `@api @perf` scenario never opens a browser to be
|
|
10
|
+
* judged. playwright-bdd reads the fixture names from the destructuring below, so each hook lists
|
|
11
|
+
* its fixtures literally and hands them to a plain function the unit tests call directly.
|
|
12
|
+
*
|
|
13
|
+
* Registered after the screenshot hooks and before nothing else: after-hooks run in reverse
|
|
14
|
+
* registration order, so these run BEFORE `sdods:finalize` and can leave their verdict on the
|
|
15
|
+
* scenario for it to record. A failure here is otherwise invisible to `meta.json`, because
|
|
16
|
+
* Playwright only marks the test failed once every after-hook has returned.
|
|
17
|
+
*/
|
|
18
|
+
const PAGE = '(@ui or @hybrid)';
|
|
19
|
+
async function noting(scenario, run) {
|
|
20
|
+
try {
|
|
21
|
+
return await run();
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
scenario.noteFailure(e instanceof Error ? e.message : String(e));
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
BeforeScenario({ name: 'sdods:perf:watch', tags: `@perf and ${PAGE}` }, async ({ page, scenario }) => {
|
|
29
|
+
await watchNavigations({ page, scenario });
|
|
30
|
+
});
|
|
31
|
+
// Registered before the a11y hook, so it runs after it: the audit's axe injection is not a navigation.
|
|
32
|
+
AfterScenario({ name: 'sdods:perf', tags: '@perf' }, async ({ config, scenario, apiContext, $testInfo }) => {
|
|
33
|
+
await noting(scenario, () => judgeScenarioPerf({ config, scenario, apiContext, testInfo: $testInfo }));
|
|
34
|
+
});
|
|
35
|
+
AfterScenario({ name: 'sdods:a11y', tags: `@a11y and ${PAGE}` }, async ({ page, config, scenario, $testInfo }) => {
|
|
36
|
+
await noting(scenario, () => auditScenarioEnd({ page, config, scenario, testInfo: $testInfo }));
|
|
37
|
+
});
|
|
38
|
+
//# sourceMappingURL=hooks.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { collectGateEvidence, evaluateGates, hasGates, type GateEvidence, type GateResult, type GateRow, type GateTotals, } from './gates.js';
|
|
2
|
+
export type { A11yScenarioReport, A11yScenarioStatus } from './a11y-scenario.js';
|
|
3
|
+
export type { PerfBreach, PerfScenarioReport } from './perf-scenario.js';
|
|
4
|
+
//# sourceMappingURL=index.d.ts.map
|