@sdods/core 0.9.0 → 0.11.1
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/api/template.js +1 -1
- package/dist/fixtures/auth.js +1 -1
- package/dist/har/api-har.js +1 -1
- package/dist/matrix/index.js +1 -1
- package/dist/quality/a11y-scenario.d.ts +46 -4
- package/dist/quality/a11y-scenario.js +168 -2
- package/dist/quality/gates.d.ts +37 -4
- package/dist/quality/gates.js +103 -14
- package/dist/quality/hooks.js +15 -2
- package/dist/quality/index.d.ts +2 -2
- package/dist/quality/index.js +1 -1
- package/dist/quality/perf-scenario.d.ts +17 -0
- package/dist/quality/perf-scenario.js +36 -12
- package/dist/shots/baselines.js +1 -1
- package/package.json +9 -9
package/dist/api/template.js
CHANGED
|
@@ -59,7 +59,7 @@ export function renderJson(template, ...scopes) {
|
|
|
59
59
|
return JSON.parse(text);
|
|
60
60
|
}
|
|
61
61
|
catch (e) {
|
|
62
|
-
throw new Error(`Body is not valid JSON after rendering templates: ${e.message}\n${text}
|
|
62
|
+
throw new Error(`Body is not valid JSON after rendering templates: ${e.message}\n${text}`, { cause: e });
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
//# sourceMappingURL=template.js.map
|
package/dist/fixtures/auth.js
CHANGED
package/dist/har/api-har.js
CHANGED
package/dist/matrix/index.js
CHANGED
|
@@ -10,34 +10,61 @@ import { type A11yResults } from '../a11y/audit.js';
|
|
|
10
10
|
* `a11y.failOn`. The report is attached as `sdods/a11y-scenario` pass or fail, and it is also what
|
|
11
11
|
* the `a11y` process gate reads.
|
|
12
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
|
+
*
|
|
13
23
|
* What it deliberately does not do:
|
|
14
24
|
* · audit a scenario that already failed. The page it stopped on is wherever the failure left it,
|
|
15
25
|
* and a violation list on top of the real error only buries the error;
|
|
16
26
|
* · audit a URL an explicit whole-page audit step already covered in the same scenario. That step
|
|
17
27
|
* ran with the threshold its author chose, and it passed (a failed step would have failed the
|
|
18
|
-
* scenario);
|
|
19
|
-
* ·
|
|
20
|
-
* page checked writes the explicit step after each navigation.
|
|
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.
|
|
21
30
|
*/
|
|
22
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
|
+
}
|
|
23
44
|
/** What `sdods/a11y-scenario` contains, and what the `a11y` process gate reads back. */
|
|
24
45
|
export interface A11yScenarioReport {
|
|
25
46
|
kind: 'a11y-scenario';
|
|
26
47
|
runnerProject: string;
|
|
27
48
|
fingerprint: string;
|
|
28
49
|
retry: number;
|
|
50
|
+
/** The URL the scenario ended on. */
|
|
29
51
|
url: string;
|
|
30
52
|
status: A11yScenarioStatus;
|
|
31
53
|
reason?: string;
|
|
54
|
+
/** Absent in reports written before `a11y.scope` existed, which were all `final`. */
|
|
55
|
+
scope?: A11yConfig['scope'];
|
|
32
56
|
failOn: A11yConfig['failOn'];
|
|
33
57
|
include: string[];
|
|
34
58
|
exclude: string[];
|
|
35
|
-
/** Every violation axe reported, at any impact. */
|
|
59
|
+
/** Every violation axe reported, at any impact (summed over `pages` under `every-page`). */
|
|
36
60
|
violations: number;
|
|
37
61
|
/** Violations at or above `failOn`: the ones that fail the scenario. */
|
|
38
62
|
blocking: number;
|
|
39
63
|
blockingRules: string[];
|
|
64
|
+
/** The axe results of the final page (`final` scope only; `every-page` keeps them per page). */
|
|
40
65
|
results?: A11yResults;
|
|
66
|
+
/** Every URL the scenario reached, in order (`every-page` scope only). */
|
|
67
|
+
pages?: A11yPageReport[];
|
|
41
68
|
}
|
|
42
69
|
export interface A11yScenarioDeps {
|
|
43
70
|
page: Page;
|
|
@@ -64,5 +91,20 @@ export interface A11yScenarioDeps {
|
|
|
64
91
|
}
|
|
65
92
|
/** Effective a11y settings with the schema defaults, so a hand-built config behaves like a parsed one. */
|
|
66
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>;
|
|
67
109
|
export declare function auditScenarioEnd(deps: A11yScenarioDeps): Promise<A11yScenarioReport>;
|
|
68
110
|
//# sourceMappingURL=a11y-scenario.d.ts.map
|
|
@@ -2,22 +2,125 @@ import { expect } from '@playwright/test';
|
|
|
2
2
|
import { attachmentNames, scenarioFiles } from '@sdods/contracts';
|
|
3
3
|
import { IMPACT_ORDER, assertAxeChecked, describeViolations, parseImpactFloor, runAxe, violationsAtOrAbove, wasPageAudited, writeAndAttachJson, } from '../a11y/audit.js';
|
|
4
4
|
import { SdodsError } from '../errors.js';
|
|
5
|
+
import { waitForSettle } from './perf-scenario.js';
|
|
5
6
|
/** Effective a11y settings with the schema defaults, so a hand-built config behaves like a parsed one. */
|
|
6
7
|
export function a11ySettings(config) {
|
|
7
8
|
const a = config.project.a11y ?? {};
|
|
8
|
-
return {
|
|
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
|
+
}
|
|
9
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 ─────────────────────────────────────────────── */
|
|
10
111
|
export async function auditScenarioEnd(deps) {
|
|
11
112
|
const { page, scenario, testInfo } = deps;
|
|
12
113
|
const settings = a11ySettings(deps.config);
|
|
13
114
|
const floor = parseImpactFloor(settings.failOn);
|
|
14
115
|
const url = page.url();
|
|
116
|
+
const everyPage = settings.scope === 'every-page';
|
|
15
117
|
const base = {
|
|
16
118
|
kind: 'a11y-scenario',
|
|
17
119
|
runnerProject: scenario.data.runnerProject,
|
|
18
120
|
fingerprint: scenario.data.fingerprint,
|
|
19
121
|
retry: scenario.data.retry,
|
|
20
122
|
url,
|
|
123
|
+
...(everyPage ? { scope: settings.scope } : {}),
|
|
21
124
|
failOn: settings.failOn,
|
|
22
125
|
include: settings.include,
|
|
23
126
|
exclude: settings.exclude,
|
|
@@ -34,11 +137,14 @@ export async function auditScenarioEnd(deps) {
|
|
|
34
137
|
reason: `the scenario ${testInfo.status} before the audit, so the page it stopped on proves nothing`,
|
|
35
138
|
});
|
|
36
139
|
}
|
|
37
|
-
|
|
140
|
+
const watch = everyPage ? watchOf(scenario) : undefined;
|
|
141
|
+
if (!auditable(url) && !watch?.pages.size) {
|
|
38
142
|
throw new SdodsError('RUN_FAILED', 'This @a11y scenario never navigated anywhere, so there is no page to audit.', {
|
|
39
143
|
hint: 'Navigate to the page under test in the scenario, or drop @a11y from a scenario that has no page.',
|
|
40
144
|
});
|
|
41
145
|
}
|
|
146
|
+
if (watch)
|
|
147
|
+
return auditEveryPageEnd({ ...deps, settings, url, watch, base, write });
|
|
42
148
|
if (wasPageAudited(scenario, url)) {
|
|
43
149
|
return write({
|
|
44
150
|
...base,
|
|
@@ -60,4 +166,64 @@ export async function auditScenarioEnd(deps) {
|
|
|
60
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);
|
|
61
167
|
return report;
|
|
62
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
|
+
}
|
|
63
229
|
//# sourceMappingURL=a11y-scenario.js.map
|
package/dist/quality/gates.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GateResult, GateRow, ProcessGates } from '@sdods/contracts';
|
|
1
|
+
import type { GateResult, GateRow, ProcessConfig, ProcessGates, RunManifest } from '@sdods/contracts';
|
|
2
2
|
import type { A11yScenarioReport } from './a11y-scenario.js';
|
|
3
3
|
import type { PerfScenarioReport } from './perf-scenario.js';
|
|
4
4
|
export type { GateResult, GateRow };
|
|
@@ -45,10 +45,43 @@ export declare function evaluateGates(input: {
|
|
|
45
45
|
evidence: GateEvidence;
|
|
46
46
|
}): GateResult;
|
|
47
47
|
/**
|
|
48
|
-
* Reads the `@a11y` and `@perf` scenario reports out of
|
|
48
|
+
* Reads the `@a11y` and `@perf` scenario reports out of one or more run directories
|
|
49
49
|
* (`<runDir>/<slug>/<fingerprint>/r<retry>/{a11y,perf}/scenario--<runner project>.json`), keeping
|
|
50
50
|
* only the highest retry of each scenario on each runner project: an attempt that was retried is not
|
|
51
|
-
* the scenario's verdict.
|
|
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.
|
|
52
53
|
*/
|
|
53
|
-
export declare function collectGateEvidence(
|
|
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;
|
|
54
87
|
//# sourceMappingURL=gates.d.ts.map
|
package/dist/quality/gates.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { attachmentNames } from '@sdods/contracts';
|
|
3
4
|
/** True when the process declares at least one gate worth evaluating. */
|
|
4
5
|
export function hasGates(gates) {
|
|
5
6
|
return Boolean(gates &&
|
|
@@ -45,21 +46,22 @@ export function evaluateGates(input) {
|
|
|
45
46
|
});
|
|
46
47
|
}
|
|
47
48
|
if (gates.a11y) {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
const
|
|
49
|
+
const reports = evidence.a11y.filter((r) => r.status !== 'skipped');
|
|
50
|
+
// Under `a11y.scope: every-page` one scenario report holds an audit per URL; each is counted.
|
|
51
|
+
const audits = reports.reduce((n, r) => n + auditsIn(r), 0);
|
|
52
|
+
const blocking = reports.reduce((n, r) => n + r.blocking, 0);
|
|
53
|
+
const failing = reports.flatMap((r) => (r.pages ?? [r])
|
|
54
|
+
.filter((p) => p.blocking > 0)
|
|
55
|
+
.map((p) => `${p.url} [${r.runnerProject}]: ${p.blockingRules.join(', ')}`));
|
|
51
56
|
rows.push({
|
|
52
57
|
gate: 'a11y',
|
|
53
58
|
threshold: 'no blocking violation',
|
|
54
|
-
actual: `${blocking} blocking in ${
|
|
55
|
-
passed:
|
|
56
|
-
detail:
|
|
59
|
+
actual: `${blocking} blocking in ${audits} audit(s)`,
|
|
60
|
+
passed: audits > 0 && blocking === 0,
|
|
61
|
+
detail: audits === 0
|
|
57
62
|
? 'no @a11y audit ran in this run, so the gate has nothing to prove'
|
|
58
63
|
: failing.length
|
|
59
|
-
? failing
|
|
60
|
-
.slice(0, 5)
|
|
61
|
-
.map((r) => `${r.url} [${r.runnerProject}]: ${r.blockingRules.join(', ')}`)
|
|
62
|
-
.join('; ')
|
|
64
|
+
? failing.slice(0, 5).join('; ')
|
|
63
65
|
: undefined,
|
|
64
66
|
});
|
|
65
67
|
}
|
|
@@ -83,16 +85,25 @@ export function evaluateGates(input) {
|
|
|
83
85
|
}
|
|
84
86
|
return { process: input.process, passed: rows.every((r) => r.passed), rows };
|
|
85
87
|
}
|
|
88
|
+
/** Audits one scenario report stands for: one per URL under `every-page`, otherwise one. */
|
|
89
|
+
function auditsIn(report) {
|
|
90
|
+
if (!report.pages)
|
|
91
|
+
return 1;
|
|
92
|
+
return report.pages.filter((p) => p.status !== 'not-audited').length;
|
|
93
|
+
}
|
|
86
94
|
function formatPct(n) {
|
|
87
95
|
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
|
88
96
|
}
|
|
89
97
|
/**
|
|
90
|
-
* Reads the `@a11y` and `@perf` scenario reports out of
|
|
98
|
+
* Reads the `@a11y` and `@perf` scenario reports out of one or more run directories
|
|
91
99
|
* (`<runDir>/<slug>/<fingerprint>/r<retry>/{a11y,perf}/scenario--<runner project>.json`), keeping
|
|
92
100
|
* only the highest retry of each scenario on each runner project: an attempt that was retried is not
|
|
93
|
-
* the scenario's verdict.
|
|
101
|
+
* the scenario's verdict. Several directories are how the shards of one run are read together: each
|
|
102
|
+
* shard wrote its own scenarios, and the same scenario never ran on two shards.
|
|
94
103
|
*/
|
|
95
|
-
export function collectGateEvidence(
|
|
104
|
+
export function collectGateEvidence(runDirs,
|
|
105
|
+
/** Reports read from elsewhere (the attachments of a merged report), judged the same way. */
|
|
106
|
+
extra = []) {
|
|
96
107
|
const latest = new Map();
|
|
97
108
|
const keep = (report) => {
|
|
98
109
|
const key = `${report.kind}|${report.fingerprint}|${report.runnerProject}`;
|
|
@@ -103,7 +114,8 @@ export function collectGateEvidence(runDir) {
|
|
|
103
114
|
? { retry: report.retry, a11y: report }
|
|
104
115
|
: { retry: report.retry, perf: report });
|
|
105
116
|
};
|
|
106
|
-
|
|
117
|
+
const dirs = [...new Set(typeof runDirs === 'string' ? [runDirs] : runDirs)];
|
|
118
|
+
for (const file of dirs.flatMap(scenarioReportFiles)) {
|
|
107
119
|
try {
|
|
108
120
|
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
109
121
|
if (parsed.kind === 'a11y-scenario' || parsed.kind === 'perf-scenario')
|
|
@@ -113,12 +125,61 @@ export function collectGateEvidence(runDir) {
|
|
|
113
125
|
/* a half-written report is not evidence */
|
|
114
126
|
}
|
|
115
127
|
}
|
|
128
|
+
for (const report of extra)
|
|
129
|
+
keep(report);
|
|
116
130
|
const values = [...latest.values()];
|
|
117
131
|
return {
|
|
118
132
|
a11y: values.flatMap((v) => (v.a11y ? [v.a11y] : [])),
|
|
119
133
|
perf: values.flatMap((v) => (v.perf ? [v.perf] : [])),
|
|
120
134
|
};
|
|
121
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* The `sdods/a11y-scenario` and `sdods/perf-scenario` reports attached to the tests of a runner JSON
|
|
138
|
+
* report. After `playwright merge-reports` their paths point into the directory the blob reports
|
|
139
|
+
* were unpacked into, which lives only as long as the merge, so read them before it is removed. A
|
|
140
|
+
* missing or unreadable file is skipped; the run directories are the other source of the same
|
|
141
|
+
* evidence.
|
|
142
|
+
*/
|
|
143
|
+
export function gateReportsFromRunnerJson(report) {
|
|
144
|
+
const names = new Set([attachmentNames.a11yScenario, attachmentNames.perfScenario]);
|
|
145
|
+
const out = [];
|
|
146
|
+
const walk = (suite) => {
|
|
147
|
+
for (const spec of suite.specs ?? [])
|
|
148
|
+
for (const test of spec.tests ?? [])
|
|
149
|
+
for (const result of test.results ?? [])
|
|
150
|
+
for (const a of result.attachments ?? []) {
|
|
151
|
+
if (!names.has(a.name) || !a.path || !existsSync(a.path))
|
|
152
|
+
continue;
|
|
153
|
+
try {
|
|
154
|
+
const parsed = JSON.parse(readFileSync(a.path, 'utf8'));
|
|
155
|
+
if (parsed.kind === 'a11y-scenario' || parsed.kind === 'perf-scenario')
|
|
156
|
+
out.push(parsed);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
/* not evidence */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const child of suite.suites ?? [])
|
|
163
|
+
walk(child);
|
|
164
|
+
};
|
|
165
|
+
for (const suite of report?.suites ?? [])
|
|
166
|
+
walk(suite);
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Gate totals from a runner JSON report's `stats` (what `playwright merge-reports --reporter json`
|
|
171
|
+
* writes for the merged shards). The same counting as the run dashboard: one row per test, retries
|
|
172
|
+
* folded into `flaky`.
|
|
173
|
+
*/
|
|
174
|
+
export function gateTotalsFromRunnerStats(stats) {
|
|
175
|
+
if (!stats)
|
|
176
|
+
return undefined;
|
|
177
|
+
const passed = stats.expected ?? 0;
|
|
178
|
+
const failed = stats.unexpected ?? 0;
|
|
179
|
+
const flaky = stats.flaky ?? 0;
|
|
180
|
+
const skipped = stats.skipped ?? 0;
|
|
181
|
+
return { total: passed + failed + flaky + skipped, passed, failed, skipped, flaky };
|
|
182
|
+
}
|
|
122
183
|
function scenarioReportFiles(runDir) {
|
|
123
184
|
const out = [];
|
|
124
185
|
const dirs = (p) => existsSync(p) ? readdirSync(p).filter((n) => statSync(join(p, n)).isDirectory()) : [];
|
|
@@ -141,4 +202,32 @@ function scenarioReportFiles(runDir) {
|
|
|
141
202
|
}
|
|
142
203
|
return out;
|
|
143
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* The gate judge ingest runs when a run directory carries no `gates.json`: the process the run's
|
|
207
|
+
* manifest names, over the totals ingest just computed and the evidence in the run directory.
|
|
208
|
+
* `undefined` when there is nothing it may judge: no process, a process without gates, a cancelled
|
|
209
|
+
* run, or one shard of several (a shard is not the run; `sdods report merge --process` judges it).
|
|
210
|
+
*/
|
|
211
|
+
export function ingestGateJudge(input) {
|
|
212
|
+
const { manifest, runDir } = input;
|
|
213
|
+
if (!manifest?.process || !manifest.projectSlug)
|
|
214
|
+
return undefined;
|
|
215
|
+
if ((manifest.shardTotal ?? 1) > 1 || manifest.exitCode === 130)
|
|
216
|
+
return undefined;
|
|
217
|
+
let proc;
|
|
218
|
+
try {
|
|
219
|
+
proc = input.processOf(manifest.projectSlug, manifest.process);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return undefined; // a process this checkout no longer defines has no gates to judge
|
|
223
|
+
}
|
|
224
|
+
if (!hasGates(proc.gates))
|
|
225
|
+
return undefined;
|
|
226
|
+
return (totals) => evaluateGates({
|
|
227
|
+
process: proc.name,
|
|
228
|
+
gates: proc.gates,
|
|
229
|
+
totals,
|
|
230
|
+
evidence: collectGateEvidence(runDir),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
144
233
|
//# sourceMappingURL=gates.js.map
|
package/dist/quality/hooks.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { AfterScenario, BeforeScenario } from '../fixtures/test.js';
|
|
2
|
-
import { auditScenarioEnd } from './a11y-scenario.js';
|
|
1
|
+
import { AfterScenario, AfterStep, BeforeScenario } from '../fixtures/test.js';
|
|
2
|
+
import { auditAfterStep, auditScenarioEnd, watchA11yPages } from './a11y-scenario.js';
|
|
3
3
|
import { judgeScenarioPerf, watchNavigations } from './perf-scenario.js';
|
|
4
4
|
/**
|
|
5
5
|
* `@a11y` and `@perf` tag hooks.
|
|
@@ -32,6 +32,19 @@ BeforeScenario({ name: 'sdods:perf:watch', tags: `@perf and ${PAGE}` }, async ({
|
|
|
32
32
|
AfterScenario({ name: 'sdods:perf', tags: '@perf' }, async ({ config, scenario, apiContext, $testInfo }) => {
|
|
33
33
|
await noting(scenario, () => judgeScenarioPerf({ config, scenario, apiContext, testInfo: $testInfo }));
|
|
34
34
|
});
|
|
35
|
+
// `a11y.scope: every-page` only; both are no-ops under the default `final`.
|
|
36
|
+
BeforeScenario({ name: 'sdods:a11y:watch', tags: `@a11y and ${PAGE}` }, async ({ page, config, scenario }) => {
|
|
37
|
+
watchA11yPages({ page, config, scenario });
|
|
38
|
+
});
|
|
39
|
+
AfterStep({ name: 'sdods:a11y:page', tags: `@a11y and ${PAGE}` }, async ({ page, config, scenario, $bddContext }) => {
|
|
40
|
+
await noting(scenario, () => auditAfterStep({
|
|
41
|
+
page,
|
|
42
|
+
config,
|
|
43
|
+
scenario,
|
|
44
|
+
step: $bddContext.step.title,
|
|
45
|
+
failed: $bddContext.step.error !== undefined,
|
|
46
|
+
}));
|
|
47
|
+
});
|
|
35
48
|
AfterScenario({ name: 'sdods:a11y', tags: `@a11y and ${PAGE}` }, async ({ page, config, scenario, $testInfo }) => {
|
|
36
49
|
await noting(scenario, () => auditScenarioEnd({ page, config, scenario, testInfo: $testInfo }));
|
|
37
50
|
});
|
package/dist/quality/index.d.ts
CHANGED
|
@@ -1,4 +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';
|
|
1
|
+
export { collectGateEvidence, evaluateGates, gateReportsFromRunnerJson, gateTotalsFromRunnerStats, hasGates, ingestGateJudge, type GateEvidence, type GateResult, type GateRow, type GateTotals, } from './gates.js';
|
|
2
|
+
export type { A11yPageReport, A11yScenarioReport, A11yScenarioStatus } from './a11y-scenario.js';
|
|
3
3
|
export type { PerfBreach, PerfScenarioReport } from './perf-scenario.js';
|
|
4
4
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/quality/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { collectGateEvidence, evaluateGates, hasGates, } from './gates.js';
|
|
1
|
+
export { collectGateEvidence, evaluateGates, gateReportsFromRunnerJson, gateTotalsFromRunnerStats, hasGates, ingestGateJudge, } from './gates.js';
|
|
2
2
|
//# sourceMappingURL=index.js.map
|
|
@@ -41,12 +41,29 @@ export declare const PAINT_WAIT_MS = 5000;
|
|
|
41
41
|
export declare const REPORT_WAIT_MS: number;
|
|
42
42
|
/** The binding each document reports through. Namespaced so it cannot collide with the app. */
|
|
43
43
|
export declare const VITALS_BINDING = "__sdodsReportVitals";
|
|
44
|
+
/**
|
|
45
|
+
* Resolves once the document has painted its first content, or after `paintWaitMs` when it never
|
|
46
|
+
* does. Runs in the page: it is serialised into the init script below and into `waitForSettle`, so
|
|
47
|
+
* the `@perf` and `@a11y` hooks agree on when a page has settled.
|
|
48
|
+
*/
|
|
49
|
+
export declare function firstPaintOrTimeout(paintWaitMs: number): Promise<void>;
|
|
44
50
|
/**
|
|
45
51
|
* The script every document in the page runs. Only the top-level document reports: after its load
|
|
46
52
|
* event, once it has painted (or `paintWaitMs` has passed), and after the same LCP settle window the
|
|
47
53
|
* explicit `I record the page vitals` step uses, so both measure a page the same way.
|
|
48
54
|
*/
|
|
49
55
|
export declare function vitalsInitScript(settleMs?: number, paintWaitMs?: number, binding?: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* Waits, from Node, for the point at which the `@perf` init script reads a document: its load event
|
|
58
|
+
* (bounded by `LOAD_WAIT_MS`), its first contentful paint (bounded by `PAINT_WAIT_MS`), then the LCP
|
|
59
|
+
* settle window. Never throws: a page that will not settle is audited as it stands, and a page that
|
|
60
|
+
* navigates away while this waits is the caller's to notice.
|
|
61
|
+
*/
|
|
62
|
+
export declare function waitForSettle(page: Page, opts?: {
|
|
63
|
+
paintWaitMs?: number;
|
|
64
|
+
settleMs?: number;
|
|
65
|
+
loadWaitMs?: number;
|
|
66
|
+
}): Promise<void>;
|
|
50
67
|
/**
|
|
51
68
|
* Starts recording the vitals of every document the page's main frame loads. Must run before the
|
|
52
69
|
* scenario's first navigation, which is why the hook that calls it is a `BeforeScenario`.
|