@sdods/core 0.9.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.
@@ -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
@@ -233,7 +233,7 @@ export class AuthStateCache {
233
233
  catch (e) {
234
234
  if (e.code !== 'EEXIST')
235
235
  throw e;
236
- let stale = false;
236
+ let stale;
237
237
  try {
238
238
  stale = Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS;
239
239
  }
@@ -119,7 +119,7 @@ export function snapshotToEntry(snap) {
119
119
  }
120
120
  export function entryToResponse(entry) {
121
121
  const text = entry.response.content.text ?? '';
122
- let body = text;
122
+ let body;
123
123
  try {
124
124
  body = text.length ? JSON.parse(text) : null;
125
125
  }
@@ -478,7 +478,7 @@ export function poolAccountsByEnv(project, opts = {}) {
478
478
  out.set(env, new Map());
479
479
  continue;
480
480
  }
481
- let rows = [];
481
+ let rows;
482
482
  try {
483
483
  const text = readFileSync(file, 'utf8');
484
484
  if (spec.type === 'csv')
@@ -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
- * · 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.
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 { failOn: a.failOn ?? 'serious', include: a.include ?? [], exclude: a.exclude ?? [] };
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
- if (!url || url === 'about:blank') {
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
@@ -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 a run directory
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(runDir: string): GateEvidence;
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
@@ -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 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);
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 ${audited.length} audit(s)`,
55
- passed: audited.length > 0 && blocking === 0,
56
- detail: audited.length === 0
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 a run directory
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(runDir) {
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
- for (const file of scenarioReportFiles(runDir)) {
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
@@ -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
  });
@@ -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
@@ -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`.