tosijs-ui 1.17.0-beta.2 → 1.17.0-beta.3

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/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Changelog
2
2
 
3
- ## 1.17.0-beta.2
3
+ ## 1.17.0-beta.3
4
+
5
+ > Prerelease, published under the `beta` dist-tag: install with `tosijs-ui@beta`. `latest` stays
6
+ > on 1.16.41. beta.2 was staged and never published; its notes follow.
7
+
8
+ - **The axe audit waits for a page marked `aria-busy`, and one slow page no longer ends it.** A
9
+ page that is still loading (a 3D scene fetching large models, say) marks an element
10
+ `aria-busy="true"`, the standard ARIA signal, and is waited for up to 30 seconds; other pages
11
+ are audited once they have stopped adding elements, or after five seconds. A page that never
12
+ loads, or is still busy at 30 seconds, is listed as not fully checked, and the build makes no
13
+ claim about it. Before, each page load waited for the network to go idle, so a page that kept
14
+ downloading for 30 seconds, or that polled, threw and ended the audit of every page. The doc
15
+ site reference shows a component setting and clearing `aria-busy`.
16
+
17
+ ### 1.17.0-beta.2 (prerelease detail)
4
18
 
5
19
  > Prerelease, published under the `beta` dist-tag: install with `tosijs-ui@beta`. `latest` stays
6
20
  > on 1.16.41. Fixes from Learn 3D's first run of beta.1.
@@ -10,7 +10,7 @@ tosijs-ui. When either is missing, or Chromium is not installed, it says which a
10
10
  NOT_RUN_EXIT, so the build can say the audit did not run instead of reporting a pass.
11
11
 
12
12
  bun a11y-audit-cli.ts <payload.json> # payload = { outputDir, basePath, pages }
13
- stdout: one JSON line, { violations: AxeFinding[] }
13
+ stdout: one JSON line, { violations: AxeFinding[], unsettled: { page, reason }[] }
14
14
 
15
15
  Build-time only. Never import this from browser code.
16
16
  */
@@ -19,7 +19,7 @@ import { createRequire } from 'module';
19
19
  import { existsSync } from 'fs';
20
20
  import { NOT_RUN_EXIT, AXE_TAGS } from './a11y-audit.js';
21
21
  const payloadPath = process.argv[2];
22
- const { outputDir, basePath, pages } = JSON.parse(await Bun.file(payloadPath).text());
22
+ const { outputDir, basePath, pages, slowPageMs } = JSON.parse(await Bun.file(payloadPath).text());
23
23
  const notRun = (why) => {
24
24
  console.error(why);
25
25
  process.exit(NOT_RUN_EXIT);
@@ -69,7 +69,12 @@ Pages are audited a few at a time, each in its own tab: one at a time took Learn
69
69
  to ~73s (#3585). Results are kept in page order, so the report reads the same either way.
70
70
  */
71
71
  const CONCURRENCY = 4;
72
+ /** How long a page may take to fire `load`, and to finish being `aria-busy`. */
73
+ const SLOW_PAGE_MS = slowPageMs ?? 30_000;
74
+ /** How long an un-busy page may keep adding elements before it is audited as it stands. */
75
+ const QUIET_CAP_MS = 5_000;
72
76
  const perPage = [];
77
+ const unsettled = [];
73
78
  try {
74
79
  const context = await browser.newContext();
75
80
  /*
@@ -83,9 +88,32 @@ try {
83
88
  while (next < pages.length) {
84
89
  const index = next++;
85
90
  const pagePath = pages[index];
86
- await page.goto(`http://localhost:${server.port}${prefix}${pagePath}`, {
87
- waitUntil: 'networkidle',
88
- });
91
+ /*
92
+ One slow or broken page must not end the audit of every other page, and must not pass
93
+ unremarked either: it is listed as not fully checked.
94
+ */
95
+ try {
96
+ await page.goto(`http://localhost:${server.port}${prefix}${pagePath}`, {
97
+ waitUntil: 'load',
98
+ timeout: SLOW_PAGE_MS,
99
+ });
100
+ }
101
+ catch (error) {
102
+ const message = error.message.split('\n')[0];
103
+ if (!/timeout/i.test(message)) {
104
+ unsettled.push({ page: pagePath, reason: `not audited: ${message}` });
105
+ perPage[index] = [];
106
+ continue;
107
+ }
108
+ unsettled.push({
109
+ page: pagePath,
110
+ reason: `did not finish loading in ${SLOW_PAGE_MS / 1000}s; audited as it stood`,
111
+ });
112
+ }
113
+ // Best effort: a page that polls or streams never goes idle, and that is not a defect.
114
+ await page
115
+ .waitForLoadState('networkidle', { timeout: 10_000 })
116
+ .catch(() => undefined);
89
117
  // Audit the page a reader uses: hydrated when the bundle loads, static if it does not.
90
118
  if (await page.$('tosi-doc-system'))
91
119
  await page
@@ -94,26 +122,42 @@ try {
94
122
  })
95
123
  .catch(() => undefined);
96
124
  /*
97
- Then until no element has been added or removed for half a second (at most five): live examples render
98
- after hydration, and a fixed pause audited a busy machine's pages half-built, so the
99
- findings changed from run to run.
125
+ Then until the page has settled: no element added or removed for half a second, and
126
+ nothing marked `aria-busy="true"`. A fixed pause audited a busy machine's pages
127
+ half-built. A page that is still loading (a 3D scene fetching its models, say) says so with
128
+ aria-busy, which screen readers also announce, and is waited for up to SLOW_PAGE_MS; one
129
+ that merely keeps changing is audited after QUIET_CAP_MS.
100
130
  */
101
- await page.evaluate(() => new Promise((resolve) => {
102
- let timer = setTimeout(done, 500);
103
- const deadline = setTimeout(done, 5000);
131
+ const settled = await page.evaluate(({ quietCap, busyCap }) => new Promise((resolve) => {
132
+ const start = performance.now();
133
+ let last = start;
104
134
  const observer = new MutationObserver(() => {
105
- clearTimeout(timer);
106
- timer = setTimeout(done, 500);
135
+ last = performance.now();
107
136
  });
108
- function done() {
109
- observer.disconnect();
110
- clearTimeout(timer);
111
- clearTimeout(deadline);
112
- resolve();
113
- }
114
137
  // Elements arriving, not attributes: an animation changes a style forever.
115
138
  observer.observe(document, { subtree: true, childList: true });
116
- }));
139
+ const tick = () => {
140
+ const now = performance.now();
141
+ const busy = !!document.querySelector('[aria-busy="true"]');
142
+ const outcome = now - last >= 500 && !busy
143
+ ? 'settled'
144
+ : !busy && now - start >= quietCap
145
+ ? 'changing'
146
+ : now - start >= busyCap
147
+ ? 'busy'
148
+ : '';
149
+ if (!outcome)
150
+ return void setTimeout(tick, 100);
151
+ observer.disconnect();
152
+ resolve(outcome);
153
+ };
154
+ tick();
155
+ }), { quietCap: QUIET_CAP_MS, busyCap: SLOW_PAGE_MS });
156
+ if (settled === 'busy')
157
+ unsettled.push({
158
+ page: pagePath,
159
+ reason: `still aria-busy after ${SLOW_PAGE_MS / 1000}s; audited as it stood`,
160
+ });
117
161
  await page.addScriptTag({ path: axePath });
118
162
  const found = await page.evaluate(async (tags) => {
119
163
  const result = await window.axe.run(document, {
@@ -136,4 +180,4 @@ finally {
136
180
  server.stop(true);
137
181
  }
138
182
  const violations = perPage.flat();
139
- console.log(JSON.stringify({ violations }));
183
+ console.log(JSON.stringify({ violations, unsettled }));
@@ -11,9 +11,15 @@ export interface AxeFinding {
11
11
  /** a CSS selector for each element that fails the rule */
12
12
  targets: string[];
13
13
  }
14
+ /** A page the audit could not check fully: it never loaded, or never stopped being busy. */
15
+ export interface UnsettledPage {
16
+ page: string;
17
+ reason: string;
18
+ }
14
19
  export type AxeAuditResult = {
15
20
  ran: true;
16
21
  violations: AxeFinding[];
22
+ unsettled: UnsettledPage[];
17
23
  } | {
18
24
  ran: false;
19
25
  reason: string;
@@ -25,6 +31,8 @@ export declare function auditBuiltSite(opts: {
25
31
  pages: string[];
26
32
  /** the project Playwright and axe-core are resolved from (default: the working directory) */
27
33
  projectDir?: string;
34
+ /** how long a page may take to load, or stay `aria-busy="true"` (default 30s) */
35
+ slowPageMs?: number;
28
36
  }): Promise<AxeAuditResult>;
29
37
  /** The report: one line per rule per page, worst impact first. */
30
38
  export declare function formatAxeFindings(violations: AxeFinding[]): string;
@@ -25,6 +25,7 @@ export async function auditBuiltSite(opts) {
25
25
  outputDir: path.resolve(opts.outputDir),
26
26
  basePath: opts.basePath ?? '',
27
27
  pages: opts.pages,
28
+ slowPageMs: opts.slowPageMs,
28
29
  }));
29
30
  // `--no-install`: without it Bun resolves a package the project does not have from its global
30
31
  // cache, so "not installed" would never be said and the audit would use a Playwright nobody chose.
@@ -42,8 +43,10 @@ export async function auditBuiltSite(opts) {
42
43
  return { ran: false, reason: stderr.trim() };
43
44
  const line = stdout.trim().split('\n').pop() ?? '';
44
45
  try {
45
- if (code === 0)
46
- return { ran: true, ...JSON.parse(line) };
46
+ if (code === 0) {
47
+ const { violations, unsettled = [] } = JSON.parse(line);
48
+ return { ran: true, violations, unsettled };
49
+ }
47
50
  }
48
51
  catch {
49
52
  // fall through: a run that printed no result did not run
@@ -1706,12 +1706,25 @@ export async function buildSite(config, opts = {}) {
1706
1706
  console.warn(`\n⚠️ Accessibility: the axe audit did not run, so this build makes no claim\n` +
1707
1707
  ` about what it would find. ${audit.reason}\n`);
1708
1708
  }
1709
- else if (audit.violations.length) {
1710
- misconfigured(config.strict, `\n⚠️ Accessibility: axe found ${audit.violations.length} WCAG 2.2 AA failure(s) in the built pages:\n` +
1711
- `${formatAxeFindings(audit.violations)}\n`);
1712
- }
1713
1709
  else {
1714
- console.log(`♿ axe: no WCAG 2.2 AA failures in ${pages.length} page(s)`);
1710
+ // A page the audit could not check fully is said, never counted as clean.
1711
+ if (audit.unsettled.length)
1712
+ console.warn(`\n⚠️ Accessibility: ${audit.unsettled.length} page(s) were not fully checked, so ` +
1713
+ `this build makes no claim about them. A page that is still loading should mark ` +
1714
+ `itself aria-busy="true"; the audit waits up to 30s for it:\n` +
1715
+ audit.unsettled
1716
+ .map((u) => ` ${u.page} ${u.reason}`)
1717
+ .join('\n') +
1718
+ '\n');
1719
+ const checked = pages.length - audit.unsettled.length;
1720
+ if (audit.violations.length)
1721
+ misconfigured(config.strict, `\n⚠️ Accessibility: axe found ${audit.violations.length} WCAG 2.2 AA failure(s) in the built pages:\n` +
1722
+ `${formatAxeFindings(audit.violations)}\n`);
1723
+ else
1724
+ console.log(`♿ axe: no WCAG 2.2 AA failures in ${checked} fully checked page(s)` +
1725
+ (audit.unsettled.length
1726
+ ? ` (${audit.unsettled.length} not fully checked, above)`
1727
+ : ''));
1715
1728
  }
1716
1729
  }
1717
1730
  console.timeEnd('build');