open-standards/wai/wai-vector/browser/run.mjs
Transaction Science 5189e5f50b wai-vector: the wasm exports and the three-engine browser leg (D7-8)
- wai/wai-vector/wasm: the sink's exports (wv_alloc, wv_free, wv_open,
  wv_close, wv_render, wv_out_ptr, wv_out_len, wv_sha256, wv_cost,
  wv_transcript, wv_projection), no_std with its own allocator, built for
  wasm32v1-none so the module is WebAssembly 1.0 only; check-mvp.mjs refuses
  any post-1.0 feature, and fails on the wasm32-unknown-unknown build.
- build-pkg.sh: the package a page serves.
- browser/: a harness that renders every listed frame of every vector in
  Chromium, WebKit and Firefox, hashes it in the page twice, re-hashes sampled
  frames in Node, recomputes the arithmetic vectors and authoring forms, and
  compares each a11y payload's transcript and projections with its text record.
  A missing engine fails the run.
- wai-vector.yml: the browser job, in a Playwright container pinned by digest,
  on the linux runner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 02:44:44 -04:00

439 lines
21 KiB
JavaScript

// The sink of wai.graphics.vector in three browser engines, against
// wai/vector-conformance.
//
// node run.mjs --engines chromium,webkit,firefox [--pkg <dir>] [--recompute <module.wasm>]
// <vector-conformance dir>
//
// It serves page.html, the two modules and the corpus from a local server, launches
// each engine named, and in each one:
//
// 1. Through the package (<pkg dir>/wai_vector.wasm, which wasm/build-pkg.sh builds),
// every payload of the corpus:
// - it loads to the expected file's code: accepted, or the first refusal;
// - an accepted payload's SHA-256 is the expected `instr`, and each listed
// size's cost is the expected one;
// - in the groups that render, frames come through the page's hook,
// window.__wai_overlay.frame(size, rate, g), which returns the renderer's own
// buffer: every listed frame at sizes up to 1280 x 720, and at larger sizes the
// sampled frames. The page hashes each frame twice, with the module's SHA-256
// and with the platform's digest, and both must be the expected digest;
// - the sampled frames (the first, the last, the middle one, and the frames on
// each side of every key tick and of every item's window edges) are also sent
// here as bytes, and Node hashes them itself: the run does not rest on a hash
// the page computed;
// - a frame outside the range, a frame number above 2^32, an unlisted size and
// an unlisted rate are each refused with their code;
// - where the expected file states a text record, the sink's transcript and its
// projection at every frame of every listed rate, written out as the record's
// lines, are the committed <name>.a11y.jsonl byte for byte; and a projection
// outside the range or at an unlisted rate is refused with its code.
// 2. Through the recomputation module (the build verify.mjs runs under Node), every
// vector's expected file and every payload's authoring form, byte for byte.
//
// A missing engine is a failure, never a skip: the run passes only when chromium,
// webkit and firefox each ran and passed. --engines says which to launch; any of the
// three left out, or that cannot launch, fails the run.
//
// Nothing is read from a canvas, and nothing waits for a length of time.
//
// --lying-page is for testing this script. The page then changes one byte of every
// frame and reports the expected digest as both of its hashes; Node's own hash of the
// bytes sent must report every frame it was sent. A run with it never passes.
//
// Exit status 0: all three engines pass; 1: a failure; 2: usage.
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { createServer } from 'node:http';
import { dirname, join, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const REQUIRED = ['chromium', 'webkit', 'firefox'];
// The groups whose vectors are arithmetic inputs, not payloads or outline sets.
const INPUT_GROUPS = ['arith', 'flatten'];
// Every listed frame is rendered at sizes up to this many pixels; sampled frames above.
const FULL = 1280 * 720;
function usage(why) {
if (why) console.error(`run.mjs: ${why}`);
console.error('usage: node run.mjs --engines chromium,webkit,firefox [--pkg <dir>] [--recompute <module.wasm>] <vector-conformance dir>');
process.exit(2);
}
let engines = null, pkgDir = join(HERE, '..', 'wasm', 'dist', 'pkg-vector');
let recomputePath = join(HERE, '..', 'wasm', 'target', 'wasm32-unknown-unknown', 'release', 'wai_vector_wasm.wasm');
let root = null, lying = false;
for (let a = process.argv.slice(2), i = 0; i < a.length; i++) {
if (a[i] === '--engines' && i + 1 < a.length) engines = [...new Set(a[++i].split(',').filter(Boolean))];
else if (a[i] === '--pkg' && i + 1 < a.length) pkgDir = a[++i];
else if (a[i] === '--recompute' && i + 1 < a.length) recomputePath = a[++i];
else if (a[i] === '--lying-page') lying = true;
else if (!a[i].startsWith('--') && root === null) root = a[i];
else usage(`unexpected argument ${a[i]}`);
}
if (engines === null || engines.length === 0) usage('--engines is required');
if (root === null) usage('the vector-conformance directory is required');
for (const e of engines) if (!REQUIRED.includes(e)) usage(`no engine named ${e}`);
root = resolve(root);
const pkgPath = resolve(pkgDir, 'wai_vector.wasm');
recomputePath = resolve(recomputePath);
const pagePath = join(HERE, 'page.html');
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
const firstLine = (s) => String(s).split('\n')[0];
const failuresOf = (n) => `${n} failure${n === 1 ? '' : 's'}`;
for (const [what, p] of [['the package', pkgPath], ['the recomputation module', recomputePath], ['the corpus', join(root, 'vectors')]]) {
if (!existsSync(p)) {
console.error(`run.mjs: ${what} is not at ${p}`);
process.exit(1);
}
}
console.log(`the package: ${pkgPath}, ${statSync(pkgPath).size} bytes, SHA-256 ${sha256(readFileSync(pkgPath))}`);
console.log(`the recomputation module: ${recomputePath}, ${statSync(recomputePath).size} bytes, SHA-256 ${sha256(readFileSync(recomputePath))}`);
// ---- the server ---------------------------------------------------------------------
// The frames the page sent: id -> what Node read, hashed here.
const received = new Map();
function fileFor(path) {
if (path === '/' || path === '/page.html') return [pagePath, 'text/html; charset=utf-8'];
if (path === '/pkg/wai_vector.wasm') return [pkgPath, 'application/wasm'];
if (path === '/recompute.wasm') return [recomputePath, 'application/wasm'];
if (path.startsWith('/corpus/')) {
const f = resolve(root, path.slice('/corpus/'.length));
if (f.startsWith(root + sep)) return [f, 'application/octet-stream'];
}
return null;
}
const server = createServer((req, res) => {
let path;
try {
path = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
} catch {
res.writeHead(400).end();
return;
}
if (req.method === 'POST' && path.startsWith('/frame/')) {
const h = createHash('sha256');
let bytes = 0;
req.on('data', (d) => { h.update(d); bytes += d.length; });
req.on('end', () => {
received.set(path.slice('/frame/'.length), { sha256: h.digest('hex'), bytes });
res.writeHead(204).end();
});
return;
}
const f = req.method === 'GET' ? fileFor(path) : null;
if (f === null || !existsSync(f[0]) || !statSync(f[0]).isFile()) {
res.writeHead(404).end();
return;
}
const body = readFileSync(f[0]);
res.writeHead(200, { 'content-type': f[1], 'content-length': body.length, 'cache-control': 'no-store' }).end(body);
});
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
const origin = `http://127.0.0.1:${server.address().port}`;
// ---- the corpus ---------------------------------------------------------------------
// Every vector, in the order verify.mjs reads them: an index file is not a vector, and
// a .json file is one only in the input groups (elsewhere it is an authoring form).
const vectors = [];
for (const group of readdirSync(join(root, 'vectors')).sort()) {
for (const f of readdirSync(join(root, 'vectors', group)).sort()) {
const dot = f.lastIndexOf('.');
const name = f.slice(0, dot), ext = f.slice(dot + 1);
if (name === 'index' || (ext === 'json' && !INPUT_GROUPS.includes(group))) continue;
vectors.push({ group, name, ext, id: `${group}/${name}` });
}
}
const setNames = readdirSync(join(root, 'sets')).filter((n) => n.endsWith('.wos1')).sort();
const expectedBytes = (v) => readFileSync(join(root, 'expected', v.group, `${v.name}.json`));
const key = (f) => `${f.size[0]}x${f.size[1]}@${f.rate[0]}/${f.rate[1]}#${f.g}`;
// The sampled frames of a list: the first, the last, the middle one, and the frame at
// or after each tick with the frame before it.
function sampled(gs, ticks, ticksPerFrame) {
const first = gs[0], last = gs[gs.length - 1];
const s = new Set([first, last, gs[gs.length >> 1]]);
for (const t of ticks) {
const g = Math.ceil(t / ticksPerFrame);
for (const c of [g - 1, g]) if (c >= first && c <= last) s.add(c);
}
return s;
}
// The first pair [n, 1] that `listed` does not hold.
function unlisted(listed) {
for (let n = 1; ; n++) if (!listed.some((p) => p[0] === n && p[1] === 1)) return [n, 1];
}
// What to ask of a payload, from its expected file: the sizes whose cost is asked, the
// frames to render (with each one's expected digest, and an id when it is also sent),
// the requests that must be refused (with each one's code), and, where the expected
// file states a text record, every projection the record holds and the projections
// that must be refused.
let nextId = 0;
function planOf(v, expected) {
const plan = { sizes: [], frames: [], requests: [], text: null };
if (expected.code !== 'accepted') return plan;
plan.sizes = expected.sizes.map((s) => [s[0], s[1]]);
if (expected.frames) {
const form = JSON.parse(readFileSync(join(root, 'vectors', v.group, `${v.name}.json`), 'utf8'));
const ticks = [...form.keys.map((k) => k[0]), ...form.items.flatMap((i) => [i.t_on, i.t_off])];
for (const s of expected.frames) {
if (s.frames.length === 0) continue;
const rate = expected.rates.find((r) => r[0] === s.num && r[1] === s.den);
const sample = sampled(s.frames.map((f) => f[0]), ticks, rate[2]);
const full = s.w * s.h <= FULL;
for (const [g, digest] of s.frames) {
if (!full && !sample.has(g)) continue;
plan.frames.push({ size: [s.w, s.h], rate: [s.num, s.den], g, send: sample.has(g) ? nextId++ : null, digest });
}
}
}
if (plan.sizes.length > 0 && expected.rates.length > 0) {
const size = plan.sizes[0];
for (const [num, den, , first, last] of expected.rates) {
plan.requests.push({ size, rate: [num, den], g: last + 1, want: 'frame_range' });
if (first > 0) plan.requests.push({ size, rate: [num, den], g: first - 1, want: 'frame_range' });
}
const [num, den, , first] = expected.rates[0];
// A frame number whose low half alone would be in range.
plan.requests.push({ size, rate: [num, den], g: 4294967296 + Math.max(first, 0), want: 'frame_range' });
plan.requests.push({ size: unlisted(plan.sizes), rate: [num, den], g: Math.max(first, 0), want: 'size_unlisted' });
plan.requests.push({ size, rate: unlisted(expected.rates), g: Math.max(first, 0), want: 'rate_unlisted' });
}
if (expected.text_record !== undefined) {
// The record's order: each listed rate, and within it each frame of the document.
plan.text = { projections: [], requests: [] };
for (const [num, den, , first, last] of expected.rates) {
for (let g = first; g <= last; g++) plan.text.projections.push({ rate: [num, den], g });
plan.text.requests.push({ rate: [num, den], g: last + 1, want: 'frame_range' });
}
if (expected.rates.length > 0) {
const [num, den, , first] = expected.rates[0];
plan.text.requests.push({ rate: [num, den], g: 4294967296 + Math.max(first, 0), want: 'frame_range' });
plan.text.requests.push({ rate: unlisted(expected.rates), g: Math.max(first, 0), want: 'rate_unlisted' });
}
}
return plan;
}
// A text record's line for a projection.
const recordLine = (q, projection) => `{"den":${q.rate[1]},"g":${q.g},"num":${q.rate[0]},"projection":${projection}}`;
// Two lines that differ, each shown around the first character at which they do.
function differing(got, want) {
if (got === undefined || want === undefined) return `the sink gives ${got === undefined ? 'no such line' : 'a line'}; the committed record has ${want === undefined ? 'no such line' : 'one'}`;
let at = 0;
while (at < got.length && at < want.length && got[at] === want[at]) at++;
const from = Math.max(0, at - 60);
const part = (s) => `${from > 0 ? '…' : ''}${s.slice(from, at + 60)}${s.length > at + 60 ? '…' : ''}`;
return `at character ${at} the sink gives ${part(got)}; expected ${part(want)}`;
}
// ---- one engine ---------------------------------------------------------------------
async function runEngine(playwright, name) {
const failures = [];
const fail = (where, what) => {
failures.push(`${where}: ${what}`);
console.error(`FAIL ${name} ${where}: ${what}`);
};
const n = { payloads: 0, accepted: 0, frames: 0, sent: 0, sentBytes: 0, records: 0, lines: 0, recomputed: 0, forms: 0 };
let browser;
try {
browser = await playwright[name].launch();
} catch (e) {
fail('launch', firstLine(e.message));
return { failures, version: null };
}
const version = browser.version();
try {
const page = await browser.newPage();
let dead = false;
page.on('pageerror', (e) => fail('the page', `uncaught: ${firstLine(e.message)}`));
page.on('crash', () => { dead = true; fail('the page', 'crashed'); });
await page.goto(`${origin}/page.html`);
await page.waitForFunction(() => window.__wai_ready !== undefined);
const ready = await page.evaluate(() => window.__wai_ready);
if (!ready.platformDigest) fail('the page', 'has no platform digest (crypto.subtle)');
await page.evaluate((names) => window.__wai_harness.sets(names), setNames);
if (lying) {
await page.evaluate(() => {
const h = window.__wai_harness;
const honest = h.check.bind(h);
h.check = async (size, rate, g) => {
const r = await honest(size, rate, g);
const claim = window.__wai_claims[`${size[0]}x${size[1]}@${rate[0]}/${rate[1]}#${g}`];
r.bytes[r.bytes.length >> 1] ^= 0x01;
return { bytes: r.bytes, inModule: claim, platform: claim };
};
});
}
// 1. Every payload through the package.
for (const v of vectors.filter((x) => x.ext === 'wvg1')) {
if (dead) break;
const expected = JSON.parse(expectedBytes(v).toString('utf8'));
const plan = planOf(v, expected);
const asked = {
sizes: plan.sizes,
frames: plan.frames.map((f) => ({ size: f.size, rate: f.rate, g: f.g, send: f.send })),
requests: plan.requests.map((q) => ({ size: q.size, rate: q.rate, g: q.g })),
text: plan.text === null ? null : {
projections: plan.text.projections,
requests: plan.text.requests.map((q) => ({ rate: q.rate, g: q.g })),
},
};
const claims = lying ? Object.fromEntries(plan.frames.map((f) => [key(f), f.digest])) : null;
let got;
try {
got = await page.evaluate(([path, p, c]) => {
if (c !== null) window.__wai_claims = c;
return window.__wai_harness.payload(path, p);
}, [`vectors/${v.group}/${v.name}.wvg1`, asked, claims]);
} catch (e) {
fail(v.id, firstLine(e.message));
continue;
}
n.payloads += 1;
if (got.code !== expected.code) {
fail(v.id, `loads as ${got.code}; expected ${expected.code}`);
continue;
}
if (expected.code !== 'accepted') continue;
n.accepted += 1;
if (got.instr.inModule !== expected.instr) fail(v.id, `the module's SHA-256 of the payload is ${got.instr.inModule}; expected ${expected.instr}`);
if (got.instr.platform !== expected.instr) fail(v.id, `the platform's digest of the payload is ${got.instr.platform}; expected ${expected.instr}`);
expected.sizes.forEach(([w, h, cost], i) => {
if (got.costs[i] !== cost) fail(v.id, `the cost at ${w}x${h} is ${got.costs[i]}; expected ${cost}`);
});
plan.frames.forEach((f, i) => {
const r = got.frames[i];
const where = `${v.id} frame ${key(f)}`;
n.frames += 1;
if (r === undefined) { fail(where, 'not rendered'); return; }
if (r.bytes !== 4 * f.size[0] * f.size[1]) fail(where, `${r.bytes} bytes; expected ${4 * f.size[0] * f.size[1]}`);
if (r.inModule !== f.digest) fail(where, `the module's SHA-256 is ${r.inModule}; expected ${f.digest}`);
if (r.platform !== f.digest) fail(where, `the platform's digest is ${r.platform}; expected ${f.digest}`);
if (f.send === null) return;
const sent = received.get(String(f.send));
received.delete(String(f.send));
if (sent === undefined) { fail(where, 'its bytes did not arrive'); return; }
n.sent += 1;
n.sentBytes += sent.bytes;
if (sent.sha256 !== f.digest) fail(where, `Node's SHA-256 of the bytes sent is ${sent.sha256}; expected ${f.digest}`);
});
plan.requests.forEach((q, i) => {
if (got.requests[i] !== q.want) fail(`${v.id} request ${key(q)}`, `${got.requests[i]}; expected ${q.want}`);
});
if (plan.text === null) continue;
// The text record: the transcript and every projection, as the record's lines,
// against the committed file's bytes.
const recordPath = join(root, 'expected', v.group, `${v.name}.a11y.jsonl`);
if (!existsSync(recordPath)) {
fail(v.id, `its expected file states a text record, and expected/${v.group}/${v.name}.a11y.jsonl is missing`);
continue;
}
const want = readFileSync(recordPath);
if (sha256(want) !== expected.text_record) fail(v.id, `the committed text record's SHA-256 is ${sha256(want)}; its expected file states ${expected.text_record}`);
if (got.text === null) {
fail(v.id, 'the page returned no transcript and no projection');
continue;
}
const lines = [got.text.transcript, ...plan.text.projections.map((q, i) => recordLine(q, got.text.projections[i]))];
n.records += 1;
n.lines += lines.length;
if (Buffer.compare(Buffer.from(lines.map((l) => `${l}\n`).join(''), 'utf8'), want) !== 0) {
const wantLines = want.toString('utf8').split('\n');
if (wantLines[wantLines.length - 1] === '') wantLines.pop();
const differ = [];
for (let i = 0; i < Math.max(lines.length, wantLines.length); i++) if (lines[i] !== wantLines[i]) differ.push(i);
if (differ.length === 0) {
fail(`${v.id} text record`, 'every line is the committed one, and the bytes differ: the committed file does not end each line with one U+000A');
} else {
const i = differ[0];
const q = plan.text.projections[i - 1];
const which = i === 0 ? 'the transcript' : q === undefined ? `line ${i + 1}, which the sink was not asked for` : `the projection at ${q.rate.join('/')}#${q.g}`;
fail(
`${v.id} text record`,
`${differ.length} of ${wantLines.length} lines differ (${differ.includes(0) ? 'the transcript and ' : ''}${differ.filter((d) => d > 0).length} projections); `
+ `the first is ${which}: ${differing(lines[i], wantLines[i])}`,
);
}
}
plan.text.requests.forEach((q, i) => {
if (got.text.requests[i] !== q.want) fail(`${v.id} projection request ${q.rate.join('/')}#${q.g}`, `${got.text.requests[i]}; expected ${q.want}`);
});
}
// 2. Every vector through the recomputation.
for (const v of vectors) {
if (dead) break;
let got;
try {
got = await page.evaluate(([g, name, ext]) => window.__wai_harness.recompute(g, name, ext), [v.group, v.name, v.ext]);
} catch (e) {
fail(v.id, `recomputation: ${firstLine(e.message)}`);
continue;
}
n.recomputed += 1;
if (got.status !== 0) fail(`${v.id}.${v.ext}`, `recomputation: ${got.expected}`);
else if (Buffer.compare(Buffer.from(got.expected, 'utf8'), expectedBytes(v)) !== 0) fail(`${v.id}.${v.ext}`, 'the recomputed expected file differs');
if (v.ext === 'wvg1') {
n.forms += 1;
const form = readFileSync(join(root, 'vectors', v.group, `${v.name}.json`));
if (Buffer.compare(Buffer.from(got.form, 'utf8'), form) !== 0) fail(`${v.id}.${v.ext}`, 'the authoring form differs');
}
}
} catch (e) {
fail('the run', firstLine(e.message));
} finally {
await browser.close();
}
console.log(
`${name} ${version}: ${n.payloads} payloads through the package, ${n.accepted} accepted; `
+ `${n.frames} frames rendered and hashed twice in the page, ${n.sent} of them hashed again in Node (${n.sentBytes} bytes); `
+ `${n.records} text records, ${n.lines} lines; `
+ `${n.recomputed} vectors and ${n.forms} authoring forms recomputed; ${failuresOf(failures.length)}`,
);
return { failures, version, n };
}
// ---- the run ------------------------------------------------------------------------
let playwright;
try {
playwright = await import('playwright');
} catch (e) {
console.error(`run.mjs: playwright is not installed here (npm ci): ${firstLine(e.message)}`);
process.exit(1);
}
if (lying) console.log('--lying-page: the page changes one byte of every frame and reports the expected digest; this run cannot pass');
const results = new Map();
for (const name of engines) results.set(name, await runEngine(playwright, name));
server.close();
let bad = 0;
for (const name of REQUIRED) {
const r = results.get(name);
if (r === undefined) {
bad += 1;
console.error(`FAIL ${name}: not run. A missing engine is a failure: chromium, webkit and firefox must each run.`);
} else if (r.failures.length > 0) {
bad += 1;
console.error(`FAIL ${name}: ${failuresOf(r.failures.length)}, the first: ${r.failures[0]}`);
}
}
if (lying && bad === 0) {
console.error('FAIL --lying-page: no engine reported the changed frames');
bad += 1;
}
if (bad > 0) process.exit(1);
console.log('vector-conformance: every payload, frame, text record and vector verifies in chromium, webkit and firefox');