From 48a66f00b66a1086c4bd2fa3bc0b3ab416cab239 Mon Sep 17 00:00:00 2001 From: Tane Morgan <464864+tanem@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:02:58 +1200 Subject: [PATCH] Commit the screen-reader harness as test/manual The suite is jsdom, so every accessibility claim this package makes is pinned as markup and nothing observes what a screen reader does with it. That is this repo's blind spot rather than a shared one, since svg-injector ships no ARIA behaviour of its own. The harness that answered the loading-announcement question was built outside the repo and would have been rebuilt from scratch next time, along with the wrong turns it already encodes: the instrument check that distinguishes insertion from mutation, the request-count check behind a cached remount, and the port choice WebKit blocks. React now comes out of node_modules rather than esm.sh. It ships no ES module build, so the server wraps the CJS files it does ship and the page loads them as classic scripts ahead of any module, with generated ESM shims behind the import map. The harness needs no network and has no second React version to keep in step with package.json. AGENTS.md gains the paragraph stating the blind spot and pointing at the harness, which is the half of this that did not exist before. --- .prettierignore | 3 +- AGENTS.md | 12 ++ eslint.config.mjs | 31 ++++- test/manual/README.md | 166 +++++++++++++++++++++++ test/manual/app.mjs | 301 +++++++++++++++++++++++++++++++++++++++++ test/manual/index.html | 177 ++++++++++++++++++++++++ test/manual/server.mjs | 292 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 977 insertions(+), 5 deletions(-) create mode 100644 test/manual/README.md create mode 100644 test/manual/app.mjs create mode 100644 test/manual/index.html create mode 100644 test/manual/server.mjs diff --git a/.prettierignore b/.prettierignore index 3b5c344f7..bb2c18fb4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,7 +4,6 @@ build coverage dist package.json -# Git-excluded scratch area holding manual-check harnesses, which are written -# against the browser rather than this project's config. Absent for anyone +# Git-excluded scratch area, outside this project's config. Absent for anyone # else, so this entry is inert for them and keeps `npm test` passing here. roadmap \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index c76a61461..fded2f239 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,18 @@ through `await waitFor(...)`. The warm-cache loading test is the one deliberate exception: it needs the cache to hit, so it uses a fixed URL no other test touches. +jsdom has no accessibility layer and no paint, so the suite pins the ARIA +wiring as markup and can go no further: whether a screen reader does anything +with that markup is outside what any test here can answer. That is this repo's +blind spot rather than a shared one, because the wiring lives here — +svg-injector ships no ARIA behaviour of its own. `test/manual/` is the check for +those questions: a node server and a page driven by hand under VoiceOver. It is +not run by `npm test` and not wired into CI, deliberately, since the instrument +is a real screen reader and automating it would mean simulating the thing it +exists to escape. Run it and record the result in the PR when you change the +ARIA wiring or the `loading` element's lifecycle, and update its recorded run in +the same commit as any deliberate change to either. + Raising a `size-limit` budget in `package.json` is a decision, not a fix. Find what grew first, and say why in the commit message. diff --git a/eslint.config.mjs b/eslint.config.mjs index e99bec523..c8c23a7b9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,9 +14,8 @@ export default tseslint.config( '**/coverage/', '**/dist/', '**/node_modules/', - // Git-excluded scratch area, outside every tsconfig and written for the - // browser and node rather than this project's globals. Absent for - // anyone else. + // Git-excluded scratch area, outside every tsconfig and this project's + // globals. Absent for anyone else. 'roadmap/', ], }, @@ -70,5 +69,31 @@ export default tseslint.config( '@typescript-eslint/no-require-imports': 'off', }, }, + { + // The hand-run screen-reader harness. Neither file is part of the build or + // the suite: one is a node server that reports on stdout, the other runs in + // the browser. `sort-keys` is off because app.mjs's step table is in the + // order the steps are run in, which is the order it has to be read in. + files: ['test/manual/*.mjs'], + + rules: { + 'no-console': 'off', + 'sort-keys': 'off', + }, + }, + { + files: ['test/manual/app.mjs'], + + languageOptions: { + globals: globals.browser, + }, + }, + { + files: ['test/manual/server.mjs'], + + languageOptions: { + globals: globals.node, + }, + }, eslintConfigPrettier, ) diff --git a/test/manual/README.md b/test/manual/README.md new file mode 100644 index 000000000..9bc33bfe3 --- /dev/null +++ b/test/manual/README.md @@ -0,0 +1,166 @@ +# Manual screen-reader checks + +The suite runs in jsdom, which has no accessibility layer and no paint. Every +accessibility claim this package makes is therefore pinned as markup: that +`role="img"` is set, that the `` and `<desc>` nodes exist, that +`aria-labelledby` points at their IDs. Nothing in the suite observes what +assistive technology does with any of it. + +That gap matters here more than in most repos. svg-injector ships no ARIA +behaviour of its own, so the wiring in `beforeEach` and the `loading` element +this package mounts and unmounts are the accessibility contract, and this repo +owns all of it. + +This harness is how that contract gets checked. It is deliberately not wired +into CI: the instrument is a real screen reader, and automating it would mean +simulating the thing it exists to escape. + +It currently covers one question, the one it was built for. Extending it to the +`role="img"` / `<title>` / `<desc>` / `aria-labelledby` path is the obvious next +use and has not been done. + +## The question it answers + +Does a briefly-mounted `loading` element announce? + +svg-injector defers its callbacks, so on a second mount of the same `src` +react-svg commits the `loading` element to the DOM and removes it a couple of +milliseconds later. `should render the specified loader for a cached src` in +`test/browser.spec.tsx` pins that DOM behaviour. Whether a screen reader queues an announcement for an element with +that lifetime is what the suite cannot answer, since assistive technology +observes the DOM rather than the screen and paint timing has nothing to do with +it. + +A cold load mounts and unmounts `loading` in every version, and always has. What +changed is how often: every cached mount now does what previously only a genuine +load did. Report any finding as a frequency change, not as a new defect. + +## Running it + +``` +npm run build +node test/manual/server.mjs +``` + +Then open <http://localhost:4191>, with: + +- **VoiceOver on, caption panel open** (`VO`+`Command`+`F10`). The panel shows + queued announcements as text, which is the whole reason it is the instrument + of choice here. +- **The window foregrounded** for the entire run. + +Safari is the representative pairing for VoiceOver. Worth a second run in Chrome +if the two disagree, since the AT-to-browser bridge differs. + +Everything the page loads comes from the working tree: react-svg from `dist/`, +svg-injector and React from `node_modules/`. So it tests the build in front of +you, it needs no network, and there is no second React version to keep in step +with `package.json`. + +React ships no ES module build, which is why `server.mjs` wraps the CJS files it +does ship and `index.html` loads them as classic scripts before anything else. +The import map then points `react` and `react-dom/client` at generated shims +over those globals, so `dist/react-svg.mjs` resolves `react` to the same +instance react-dom is using. + +## The steps + +Run them in order. Each prints a DOM log; the caption panel is what you are +actually reading. + +- **0 — check the instrument.** Changes the text of a live region that has been + in the DOM since page load. No react-svg involved. This is the canonical + live-region pattern, the one screen readers reliably announce, so it must + appear in the caption panel. If it does not, VoiceOver is not reaching the + panel and nothing else here can be read. +- **0b — insert a populated live region.** Inserts a `role="status"` element + that arrives with its text already in it, again with no react-svg involved. + Read against step 0 it says whether any silence is about insertion versus + mutation. That is structurally what react-svg does with a `loading` component, + minus React and svg-injector, so a silent 0b puts the finding on the platform + rather than on this package. +- **1 — warm the cache.** Mounts eight icons with no `loading` component at all, + waits for injection, unmounts. Nothing here can announce; it exists only to + leave svg-injector's cache holding all eight. +- **A — cached remount, `role="status"`.** Remounts the same eight `src`s with a + loading component carrying live-region semantics, which is how loading + indicators are conventionally written. The log must report `0 requests + served`; anything else means the remount refetched and the run is void. +- **B — cached remount, plain span.** The same again with no live-region + semantics. +- **4 — slow cold load, `role="status"`.** A cold load held open for ~2.5 + seconds, so the loading element is mounted for a human-scale stretch rather + than a couple of milliseconds. + +Step 4 is **not** the instrument check, though an earlier version of this +harness treated it as one. It inserts an element that already carries +`role="status"` and its text, and whether assistive technology announces a +*newly inserted* live region is an open question in its own right — the same one +the cached cases turn on. A silent step 4 therefore cannot tell a broken setup +apart from a real finding. Step 0 exists to do that job, and it is the only step +that can. + +## What the DOM log is and is not + +It records each loading element entering and leaving the DOM, and how long it +stayed. That proves the elements existed, which is what tells an *absence of +announcements* apart from an *absence of loading elements*. It does not prove +anything was announced. Do not report a finding from the log alone. + +## The pieces that look incidental + +Each one is a wrong turn already taken once. + +| Piece | Covers | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Step 0 against step 0b | Mutation versus insertion. Without it a silent run cannot be told from a broken setup, which is exactly what happened first. | +| The `/hits` delta | Proves a cached remount did not refetch. The screen looks identical either way, so nothing else would catch it. | +| The MutationObserver log | Tells "nothing announced" apart from "no loading element ever appeared". | +| Port 4191, not 4190 | 4190 is ManageSieve, on the Fetch standard's blocked port list. WebKit refuses to connect and shows `about:blank`; Chromium loads it and hides the problem. | +| Visually hidden live regions | A visible region gets read as an announcement. This produced a contradictory reading before the region was hidden. | + +## Recording a run + +| Case | Loading elements in DOM | Median lifetime | Caption panel | +| ------------------------------ | ----------------------- | --------------- | ------------- | +| 0 — instrument check | n/a | n/a | | +| 0b — inserted populated region | n/a | n/a | | +| A — cached, `role="status"` | | | | +| B — cached, plain span | | | | +| 4 — slow cold, `role="status"` | | | | + +Browser and version: +VoiceOver / macOS version: + +## Last run + +Recorded so a later run has something to compare against. + +19.0.0, 2026-08-04, Safari 26.5 on macOS 26.5, VoiceOver with the caption panel +open: + +| Case | Caption panel | DOM | +| ------------------------------------------------------- | ------------- | ------------------------------------ | +| 0 — existing live region, text changed | announces | n/a | +| 0b — `role="status"` element inserted already-populated | silent | n/a | +| A — cached remount, `role="status"` | silent | 8 elements, 0 requests | +| B — cached remount, plain span | silent | 8 elements, median 2.0ms, 0 requests | +| 4 — `loading`, `role="status"`, ~2.5s mounted | silent | 1 element | + +**No announcement, and lifetime is not the variable.** VoiceOver announces a +live region whose content changes and ignores one that arrives with its content +already in it. Step 0b establishes that with neither React nor svg-injector in +the picture, so it is platform behaviour react-svg inherits. React mounts a +`loading` component as a complete element, which is always the second shape, and +a `role="status"` element mounted for a full 2.5 seconds was as silent as the +two-millisecond ones. Live-region semantics made no difference either: A and B +were equally silent. + +Re-run this against a different browser or screen reader, or if the mounting +behaviour changes. + +The mechanics were re-checked in Chrome 151 on 2026-08-04, after the move here +and to React from `node_modules`: all six steps ran, both cached remounts served +0 requests, the control held its loading element for 2509ms, and the console was +clean apart from React's DevTools notice. No screen reader was running, so that +run says nothing about announcements — it only says the harness works. diff --git a/test/manual/app.mjs b/test/manual/app.mjs new file mode 100644 index 000000000..93ccb3e83 --- /dev/null +++ b/test/manual/app.mjs @@ -0,0 +1,301 @@ +// The screen-reader harness. See README.md for how to run it and what to +// record. No JSX: the page is served as-is with no build step, so the harness +// always exercises whatever `npm run build` last produced rather than a +// separately-compiled copy. + +import * as React from 'react' +import { createRoot } from 'react-dom/client' +import { ReactSVG } from 'react-svg' + +const h = React.createElement + +// Eight is enough for "one announcement per icon" to be unmistakable in the +// caption panel without flooding it. +const ICON_COUNT = 8 +const iconSrcs = Array.from( + { length: ICON_COUNT }, + (_, index) => `/icons/${index + 1}.svg`, +) + +const stage = document.getElementById('stage') +const logElement = document.getElementById('log') + +let root = null +let warmed = false + +const log = (line) => { + logElement.textContent += `\n${line}` + logElement.scrollTop = logElement.scrollHeight +} + +const clearLog = (line) => { + logElement.textContent = line +} + +// The loading components. Case A carries live-region semantics, which is how +// loading indicators are conventionally written; case B carries none. The +// answer plausibly differs between them, and that difference is what any +// documentation would have to say. +const statusLoading = (label) => () => + h('span', { 'data-loading': label, role: 'status' }, `Loading ${label}`) + +const plainLoading = (label) => () => + h('span', { 'data-loading': label }, `Loading ${label}`) + +const Grid = ({ makeLoading, srcs }) => + h( + 'div', + { className: 'grid' }, + srcs.map((src, index) => + h(ReactSVG, { + key: src, + loading: makeLoading ? makeLoading(index + 1) : undefined, + src, + }), + ), + ) + +// Records every loading element entering and leaving the DOM, with how long it +// stayed. Assistive technology observes the DOM rather than the screen, so +// this is the closest thing to the input AT actually receives — but it says +// nothing about whether an announcement was queued, which is why the caption +// panel is the real instrument. +const enteredAt = new Map() +const lifetimes = [] + +const noteLoadingNodes = (node, kind) => { + if (node.nodeType !== Node.ELEMENT_NODE) { + return + } + const matches = [ + ...(node.matches('[data-loading]') ? [node] : []), + ...node.querySelectorAll('[data-loading]'), + ] + for (const element of matches) { + const label = element.dataset.loading + if (kind === 'added') { + enteredAt.set(label, performance.now()) + log(` + loading ${label} entered the DOM`) + } else { + const start = enteredAt.get(label) + if (start === undefined) { + log(` - loading ${label} left the DOM (never seen entering)`) + continue + } + const lifetime = performance.now() - start + lifetimes.push(lifetime) + enteredAt.delete(label) + log(` - loading ${label} left the DOM after ${lifetime.toFixed(1)}ms`) + } + } +} + +new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + noteLoadingNodes(node, 'added') + } + for (const node of record.removedNodes) { + noteLoadingNodes(node, 'removed') + } + } +}).observe(stage, { childList: true, subtree: true }) + +const readHits = async () => { + const response = await fetch('/hits', { cache: 'no-store' }) + return (await response.json()).total +} + +const mount = (element) => { + root = createRoot(stage) + root.render(element) +} + +const unmount = () => { + root?.unmount() + root = null +} + +const waitForInjections = (expected) => + new Promise((resolve, reject) => { + const deadline = performance.now() + 15000 + const poll = () => { + if (stage.querySelectorAll('.injected-svg').length >= expected) { + resolve() + return + } + if (performance.now() > deadline) { + reject(new Error(`timed out waiting for ${expected} injections`)) + return + } + // setTimeout rather than requestAnimationFrame: rAF is paused in a + // background tab, and the harness should not silently stall if the + // window loses focus mid-run. + setTimeout(poll, 16) + } + poll() + }) + +const summarise = () => { + if (lifetimes.length === 0) { + log(' no loading element entered the DOM at all') + return + } + const sorted = [...lifetimes].sort((a, b) => a - b) + const median = sorted[Math.floor(sorted.length / 2)] + log( + ` ${lifetimes.length} loading element(s), median lifetime ${median.toFixed(1)}ms, ` + + `range ${sorted[0].toFixed(1)}–${sorted[sorted.length - 1].toFixed(1)}ms`, + ) +} + +const resetRun = () => { + lifetimes.length = 0 + enteredAt.clear() +} + +const announcer = document.getElementById('announcer') +const insertionPoint = document.getElementById('insertion-point') + +const steps = { + // The instrument check, and it deliberately does not go near react-svg. It + // changes the text of a live region that has been in the DOM since page + // load, which is the pattern screen readers reliably announce. If this is + // silent, VoiceOver is not reaching the caption panel and nothing else on + // this page can be interpreted. + // + // Step 4 cannot do this job: it inserts an element that already carries + // role="status" and its text, and whether AT announces a newly-inserted + // live region is itself an open question — the same one the cached cases + // turn on. Conflating the two would make a silent run unreadable. + instrument() { + unmount() + resetRun() + clearLog('Step 0: instrument check, no react-svg involved.') + announcer.textContent = 'Instrument check, one two three' + log(' live region text set; the caption panel must show it') + setTimeout(() => { + announcer.textContent = '' + }, 5000) + }, + + // The mechanism check, and like step 0 it stays away from react-svg. Where + // step 0 mutates a region already in the DOM, this inserts one that arrives + // already carrying role="status" and its text — which is structurally what + // react-svg does with a `loading` component, minus React and svg-injector. + // + // Read against step 0 it says whether any silence is about insertion versus + // mutation, rather than about this package. If step 0 announces and this + // does not, the finding belongs to the platform and react-svg merely + // inherits it. + inserted() { + unmount() + resetRun() + clearLog('Step 0b: inserting a populated live region, no react-svg.') + insertionPoint.replaceChildren() + const region = document.createElement('div') + region.setAttribute('role', 'status') + region.textContent = 'Inserted region, four five six' + insertionPoint.append(region) + log(' populated role="status" element inserted') + setTimeout(() => { + insertionPoint.replaceChildren() + }, 5000) + }, + + // Mounts the grid with no loading component at all, so nothing in this step + // can announce, then unmounts. Its only job is to leave svg-injector's cache + // holding all eight icons. + async warm() { + unmount() + resetRun() + clearLog('Step 1: warming the cache.') + const before = await readHits() + mount(h(Grid, { srcs: iconSrcs })) + await waitForInjections(ICON_COUNT) + const after = await readHits() + unmount() + log(` ${ICON_COUNT} icons injected, ${after - before} request(s) served`) + log(' stage cleared, cache warm') + warmed = true + for (const button of document.querySelectorAll( + '[data-step="status"], [data-step="plain"]', + )) { + button.disabled = false + } + log('Ready. Watch the caption panel, then run step 2.') + }, + + async status() { + await cachedRemount('A', statusLoading, 'role="status"') + }, + + async plain() { + await cachedRemount('B', plainLoading, 'plain span, no live region') + }, + + // A cold load held open for seconds, so the loading element is mounted for + // a human-scale stretch rather than a couple of milliseconds. Read against + // the cached cases this separates two different reasons for silence: if this + // announces and the cached cases do not, lifetime is what matters; if + // neither announces while step 0 does, react-svg's loading element never + // announces at all, because it is always freshly inserted rather than a + // region already present whose content changed. + async control() { + unmount() + resetRun() + clearLog('Step 4: slow cold load, role="status", ~2.5s response.') + const src = `/slow.svg?ms=2500&n=${performance.now().toFixed(0)}` + mount( + h(Grid, { + makeLoading: () => statusLoading('slow control'), + srcs: [src], + }), + ) + await waitForInjections(1) + summarise() + log(' read this one against step 0 and the cached cases') + }, +} + +// Both cached cases are the same procedure with a different loading component, +// and the request delta is the part that matters: it has to be 0, or the +// remount refetched and is not the cache-hit path this ticket is about. +const cachedRemount = async (name, makeLoading, description) => { + if (!warmed) { + log('Run step 1 first.') + return + } + unmount() + resetRun() + clearLog(`Case ${name}: cached remount, ${description}.`) + const before = await readHits() + mount(h(Grid, { makeLoading, srcs: iconSrcs })) + await waitForInjections(ICON_COUNT) + const after = await readHits() + const requests = after - before + summarise() + log( + requests === 0 + ? ' 0 requests served: every injection came from the cache' + : ` ${requests} request(s) served — NOT a cache hit, this run is void`, + ) +} + +document.querySelector('.steps').addEventListener('click', async (event) => { + const step = event.target.dataset?.step + if (!step) { + return + } + if (step === 'reset') { + unmount() + resetRun() + clearLog('Reset. Run step 1.') + return + } + try { + await steps[step]() + } catch (error) { + log(` failed: ${error.message}`) + } +}) diff --git a/test/manual/index.html b/test/manual/index.html new file mode 100644 index 000000000..93cb32792 --- /dev/null +++ b/test/manual/index.html @@ -0,0 +1,177 @@ +<!doctype html> +<html lang="en-NZ"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Does a briefly-mounted loading element announce? + + + + + + + + + + + + + +

Does a briefly-mounted loading element announce?

+ +

+ svg-injector defers its callbacks, so on a second mount of the same + src react-svg commits the loading element to the + DOM and removes it a moment later. Nothing paints differently. The + question this harness answers is whether assistive technology still + queues an announcement, and whether that depends on the loading component + carrying live-region semantics. +

+ +

+ Run this with VoiceOver on and its caption panel open + (VO+Command+F10), so queued + announcements are visible as text. The DOM log below is a + proxy: it proves the element entered and left the DOM, not that + anything was announced. The caption panel is the instrument; the log is + there so an absence of announcements can be told apart from an absence of + loading elements. +

+ +

Run the steps in order

+
+ + + + + + + +
+ + +
+ + +
+ +
+ +

DOM log

+
Waiting for step 1.
+ + + + diff --git a/test/manual/server.mjs b/test/manual/server.mjs new file mode 100644 index 000000000..1e09b3c7d --- /dev/null +++ b/test/manual/server.mjs @@ -0,0 +1,292 @@ +// Serves the screen-reader harness. See README.md. +// +// A plain node server rather than Vite, for the same reason svg-injector's +// manual checks use one: the responses have to be genuine. The control case +// needs a real slow response, and the cached cases need the browser's own +// caching and svg-injector's cache to behave as they would in production. +// +// npm run build +// node test/manual/server.mjs +// +// Everything the page loads comes out of the working tree: react-svg from +// dist/, and svg-injector and React from node_modules/. Nothing is fetched +// from a CDN, so the harness runs offline and always against the versions +// package.json pins. + +import { createReadStream } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' +import { createServer } from 'node:http' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const require = createRequire(import.meta.url) +const here = dirname(fileURLToPath(import.meta.url)) +const repoRoot = join(here, '..', '..') +// Not 4190: that is ManageSieve, which sits on the Fetch standard's blocked +// port list. WebKit enforces it by refusing to open a socket at all, so Safari +// lands on about:blank with nothing reaching the server, while Chromium loads +// the page and hides the problem. +const port = 4191 + +// The two ES modules the page imports by bare specifier. dist/ so the harness +// tests the build being released, and node_modules/ so svg-injector is exactly +// the version package.json pins. +const vendored = { + 'react-svg.mjs': join(repoRoot, 'dist', 'react-svg.mjs'), + 'react-svg.mjs.map': join(repoRoot, 'dist', 'react-svg.mjs.map'), + 'svg-injector.mjs': join( + repoRoot, + 'node_modules', + '@tanem', + 'svg-injector', + 'dist', + 'svg-injector.mjs', + ), + 'svg-injector.mjs.map': join( + repoRoot, + 'node_modules', + '@tanem', + 'svg-injector', + 'dist', + 'svg-injector.mjs.map', + ), +} + +// React ships no ES module build, so it cannot be imported by a browser the +// way the two above are. Rather than fetch an ESM-converted copy from a CDN, +// which needs network and pins a second React version alongside the one in +// package.json, the page loads these CJS files as classic scripts under the +// wrapper below. +// +// Listed in dependency order, which is the order index.html loads them in: +// each one requires only the ones before it. The development builds, because +// their warnings are worth having in a hand-run harness. +const reactCjs = { + react: ['react', 'cjs/react.development.js'], + scheduler: ['scheduler', 'cjs/scheduler.development.js'], + 'react-dom': ['react-dom', 'cjs/react-dom.development.js'], + 'react-dom/client': ['react-dom', 'cjs/react-dom-client.development.js'], +} + +// Located through each package's `package.json`, which every exports map +// exposes, rather than by resolving the CJS file directly: React's exports map +// does not list `./cjs/*`, so asking node for it throws. +const cjsPath = ([packageName, file]) => + join(dirname(require.resolve(`${packageName}/package.json`)), file) + +// Each CJS file, wrapped so it runs in a browser: a `module`/`exports` pair to +// assign to, a `require` reading back what the earlier scripts registered, and +// a `process` for React's `NODE_ENV` checks. All four are declared inside the +// function, so nothing lands on `window` except the registry. +const wrapCjs = (name, source) => `;(function () { + globalThis.__cjs = globalThis.__cjs ?? {} + const module = { exports: {} } + const exports = module.exports + const process = { env: { NODE_ENV: 'development' } } + const require = (id) => { + if (!(id in globalThis.__cjs)) { + throw new Error( + \`the harness CJS loader has no "\${id}"; check the script order in index.html\`, + ) + } + return globalThis.__cjs[id] + } +${source} + globalThis.__cjs[${JSON.stringify(name)}] = module.exports +})() +` + +// The ES module the import map points `react` and `react-dom/client` at. Its +// named exports are read off the real module as it is served rather than +// listed here, so a React upgrade that adds one needs no edit. +// +// The named exports are what `import * as React from 'react'` in +// dist/react-svg.mjs needs; a default-only shim would hand it an empty +// namespace. +const shimEsm = (name) => { + const names = Object.keys(require(name)).filter((key) => + /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key), + ) + return `// Generated by test/manual/server.mjs from the CJS ${name} that index.html loads. +const loaded = globalThis.__cjs[${JSON.stringify(name)}] +if (!loaded) { + throw new Error( + '${name} is not loaded; index.html has to run the CJS scripts before any module', + ) +} +export default loaded +export const { ${names.join(', ')} } = loaded +` +} + +const icon = ( + label, + hue, +) => ` + + ${label} + +` + +// Request counter. A cached remount that quietly refetched would still look +// right on screen, so the harness reads this before and after each case and +// reports the delta. +const hits = new Map() +const countHit = (path) => { + hits.set(path, (hits.get(path) ?? 0) + 1) +} + +const sleep = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + +const sendJavaScript = (res, source) => { + res.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/javascript; charset=utf-8', + }) + res.end(source) +} + +const sendFile = async (res, path, contentType) => { + try { + await stat(path) + } catch { + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end(`Not built: ${path}\n\nRun \`npm run build\` in the repo root.`) + return + } + res.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': contentType, + }) + createReadStream(path).pipe(res) +} + +const handle = async (req, res) => { + const url = new URL(req.url, `http://localhost:${port}`) + const path = url.pathname + + // Access log. A browser showing a blank page is ambiguous until you know + // whether it asked for anything, so the harness says what it was asked for. + res.on('finish', () => { + console.log(`${res.statusCode} ${req.method} ${req.url}`) + }) + + if (path === '/' || path === '/index.html') { + await sendFile(res, join(here, 'index.html'), 'text/html; charset=utf-8') + return + } + + if (path === '/app.mjs') { + await sendFile(res, join(here, 'app.mjs'), 'text/javascript; charset=utf-8') + return + } + + const cjsMatch = path.match(/^\/vendor\/cjs\/(.+)\.js$/) + if (cjsMatch && reactCjs[cjsMatch[1]]) { + const name = cjsMatch[1] + sendJavaScript( + res, + wrapCjs(name, await readFile(cjsPath(reactCjs[name]), 'utf8')), + ) + return + } + + const shimMatch = path.match(/^\/vendor\/(react|react-dom\/client)\.mjs$/) + if (shimMatch) { + sendJavaScript(res, shimEsm(shimMatch[1])) + return + } + + const vendorMatch = path.match(/^\/vendor\/(.+)$/) + if (vendorMatch && vendored[vendorMatch[1]]) { + const contentType = vendorMatch[1].endsWith('.map') + ? 'application/json; charset=utf-8' + : 'text/javascript; charset=utf-8' + await sendFile(res, vendored[vendorMatch[1]], contentType) + return + } + + if (path === '/hits') { + res.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + }) + res.end( + JSON.stringify({ + hits: Object.fromEntries(hits), + total: [...hits.values()].reduce((a, b) => a + b, 0), + }), + ) + return + } + + // Cached-case icons. Each index is a distinct URL, so svg-injector caches + // them separately and a remount of the same index is a cache hit. + const iconMatch = path.match(/^\/icons\/(\d+)\.svg$/) + if (iconMatch) { + const index = Number(iconMatch[1]) + countHit(path) + res.writeHead(200, { + // The browser cache must not stand in for svg-injector's. Only the + // second injection of a given index should be free of a request, and + // that has to be svg-injector's doing. + 'Cache-Control': 'no-store', + 'Content-Type': 'image/svg+xml', + }) + res.end(icon(String(index), (index * 47) % 360)) + return + } + + // Control case. `n` busts svg-injector's cache so every run is a cold load, + // and `ms` holds the response open long enough that the loading element is + // unambiguously present for a human-observable stretch. + if (path === '/slow.svg') { + countHit(path) + const ms = Number(url.searchParams.get('ms') ?? 2500) + await sleep(ms) + res.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': 'image/svg+xml', + }) + res.end(icon('S', 200)) + return + } + + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not found') +} + +// A rejection inside the handler would otherwise take the process down with +// it, which in a hand-run harness costs the whole run rather than one +// response. Report it on the failing request instead, and keep serving. +const server = createServer((req, res) => { + handle(req, res).catch((error) => { + console.log(`-- ${req.url} failed: ${error.stack}`) + res.writeHead(500, { 'Content-Type': 'text/plain' }) + res.end(String(error.stack)) + }) +}) + +// Connection-level logging, one level below the access log. A browser that +// silently upgraded to https opens a socket and then speaks TLS, which the +// access log cannot show because the request never parses. +server.on('connection', (socket) => { + console.log(`-- socket from ${socket.remoteAddress}`) +}) + +server.on('clientError', (error, socket) => { + console.log( + `-- unparseable request (${error.code ?? error.message}); a TLS handshake here means the browser asked for https`, + ) + socket.destroy() +}) + +server.listen(port, () => { + console.log(`Screen-reader harness on http://localhost:${port}`) + console.log(`Serving React ${require('react').version} from node_modules`) + console.log('Open it with VoiceOver running and its caption panel visible.') +})