From edf11304e36ecdf4f56fd0b2678375682130fec3 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 4 Sep 2026 09:49:44 +0200 Subject: [PATCH] 5d: the browser leg --- AGENTS.md | 7 ++ README.md | 3 +- browser-tests/convert-corpus.js | 13 ++++ browser-tests/page.html | 4 ++ browser-tests/run.js | 120 ++++++++++++++++++++++++++++++++ ci.sh | 2 + docker-runner.sh | 12 +++- renovate.json | 9 +++ todo-history.md | 15 ++++ todo.md | 8 +-- 10 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 browser-tests/convert-corpus.js create mode 100644 browser-tests/page.html create mode 100644 browser-tests/run.js diff --git a/AGENTS.md b/AGENTS.md index 1b88a31..ebeeb0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,6 +197,13 @@ self-reference would resolve against. `consumer.ts` typechecks the emitted `.d.t resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` stays unproven. `node-floor.js` round-trips the installed package under a Node pinned to `engines.node`'s floor. +A fourth engine reads the build rather than the source: a headless Firefox loads `dist/index.js` +over HTTP and converts the whole corpus, which is §6's browser half and the only SpiderMonkey +there is — the gate's other three engines are two V8s and a JavaScriptCore that is not Safari's. +geckodriver is what carries a verdict back out, the driver and the page's server sharing one +network namespace so each is the other's `127.0.0.1`; `--headless --screenshot` has no such +channel, and loading `dist/index.js` in a globals-stripped realm buys one by not running a browser. + The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and functions, and a branch floor that only ever moves upward. It sits below 100 because the guards `noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index diff --git a/README.md b/README.md index 557bd58..4b2c793 100644 --- a/README.md +++ b/README.md @@ -148,4 +148,5 @@ Personas, never named consumers (AGENTS.md §7): ESM only, no runtime dependencies, public npmjs. Built JavaScript with `.d.ts` beside it. Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs under Node, -Deno and Bun. Contract: `AGENTS.md` §5–6. +Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint. +Contract: `AGENTS.md` §5–6. diff --git a/browser-tests/convert-corpus.js b/browser-tests/convert-corpus.js new file mode 100644 index 0000000..894a71d --- /dev/null +++ b/browser-tests/convert-corpus.js @@ -0,0 +1,13 @@ +try { + const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js') + window.convertCorpus = (corpus) => ({ + errors: corpus.errors.map(({ markdown }) => markdownToAdf(markdown)), + normalization: corpus.normalization.map(({ markdown }) => markdownToAdf(markdown)), + roundTrip: corpus.roundTrip.map(({ json, markdown }) => { + const adf = JSON.parse(json) + return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), parsed: markdownToAdf(markdown) } + }), + }) +} catch (cause) { + window.adfCodecFault = `/dist/index.js did not load: ${cause}` +} diff --git a/browser-tests/page.html b/browser-tests/page.html new file mode 100644 index 0000000..e11fcaf --- /dev/null +++ b/browser-tests/page.html @@ -0,0 +1,4 @@ + + +@larvit/adf-codec in a browser + diff --git a/browser-tests/run.js b/browser-tests/run.js new file mode 100644 index 0000000..644f793 --- /dev/null +++ b/browser-tests/run.js @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import { createServer } from 'node:http' +import { extname, join } from 'node:path' + +const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' } +const driver = 'http://127.0.0.1:4444' +const repoRoot = join(import.meta.dirname, '..') +const corpusRoot = join(repoRoot, 'corpus') +const corpusEntries = readdirSync(corpusRoot, { encoding: 'utf8', recursive: true }) + +function checking(name, assertions) { + try { + assertions() + } catch (cause) { + throw new Error(`${name} in the browser — ${cause.message}`, { cause }) + } +} + +async function command(method, path, body) { + const response = await fetch(`${driver}${path}`, { + body: body === undefined ? undefined : JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + method, + signal: AbortSignal.timeout(180_000), + }) + const payload = await response.json() + if (!response.ok) throw new Error(`webdriver ${method} ${path}: ${JSON.stringify(payload.value)}`) + return payload.value +} + +function fileBody(file) { + try { + return readFileSync(file) + } catch { + return undefined + } +} + +function fixture(name, extension) { + return readFileSync(join(corpusRoot, `${name}${extension}`), 'utf8') +} + +function fixtureNames(kind, extension) { + return corpusEntries + .filter((name) => name.startsWith(`${kind}/`) && name.endsWith(extension)) + .map((name) => name.slice(0, -extension.length)) + .sort() +} + +function refusal(result) { + return result.ok ? '' : `${result.error.code}: ${result.error.message}` +} + +async function whenDriverAnswers() { + const deadline = Date.now() + 60_000 + for (;;) { + const status = await command('GET', '/status').catch(() => undefined) + if (status?.ready === true) return + if (Date.now() > deadline) throw new Error(`no geckodriver answered ${driver}/status within 60s`) + await new Promise((resolve) => setTimeout(resolve, 200)) + } +} + +const corpus = { + errors: fixtureNames('errors', '.md').map((name) => ({ markdown: fixture(name, '.md'), name })), + normalization: fixtureNames('normalization', '.md').map((name) => ({ markdown: fixture(name, '.md'), name })), + roundTrip: fixtureNames('round-trip', '.json').map((name) => ({ json: fixture(name, '.json'), markdown: fixture(name, '.md'), name })), +} +for (const [kind, fixtures] of Object.entries(corpus)) assert.ok(fixtures.length > 0, `the browser leg found no ${kind} fixture to convert`) + +const server = createServer((request, response) => { + const file = join(repoRoot, new URL(request.url ?? '/', 'http://127.0.0.1').pathname) + const body = file.startsWith(repoRoot) ? fileBody(file) : undefined + if (body === undefined) response.writeHead(404).end() + else response.writeHead(200, { 'content-type': contentTypes[extname(file)] ?? 'application/octet-stream' }).end(body) +}) +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + +await whenDriverAnswers() +const session = await command('POST', '/session', { + capabilities: { alwaysMatch: { browserName: 'firefox', 'moz:firefoxOptions': { args: ['-headless'] }, timeouts: { pageLoad: 60_000, script: 120_000 } } }, +}) +await command('POST', `/session/${session.sessionId}/url`, { url: `http://127.0.0.1:${server.address().port}/browser-tests/page.html` }) +const results = await command('POST', `/session/${session.sessionId}/execute/sync`, { + args: [corpus], + script: 'if (!window.convertCorpus) throw new Error(window.adfCodecFault ?? "the page defined no convertCorpus"); return window.convertCorpus(arguments[0])', +}) +server.close() + +for (const [index, result] of results.roundTrip.entries()) { + const { json, markdown, name } = corpus.roundTrip[index] + checking(name, () => { + assert.ok(result.isDocument, `${name}.json is no ADF document`) + assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`) + assert.equal(result.emitted.value, markdown) + assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`) + assert.deepEqual(result.parsed.value, JSON.parse(json)) + }) +} + +for (const [index, result] of results.normalization.entries()) { + const { name } = corpus.normalization[index] + checking(name, () => { + assert.ok(result.ok, `it did not parse — ${refusal(result)}`) + assert.deepEqual(result.value, JSON.parse(fixture(name, '.json'))) + }) +} + +for (const [index, result] of results.errors.entries()) { + const { name } = corpus.errors[index] + checking(name, () => { + assert.ok(!result.ok, `it was not refused, it built ${JSON.stringify(result.value)}`) + assert.equal(result.error.code, fixture(name, '.error').trimEnd()) + }) +} + +console.log( + `Firefox ${session.capabilities.browserVersion} converted ${corpus.roundTrip.length} round-trip, ${corpus.normalization.length} normalization and ${corpus.errors.length} error fixtures`, +) diff --git a/ci.sh b/ci.sh index d389e45..5ab9650 100755 --- a/ci.sh +++ b/ci.sh @@ -26,3 +26,5 @@ in_image "$node_image" sh -c 'set -e npm install --no-audit --no-fund --no-package-lock --no-save --offline --prefix package-tests /tmp/*.tgz >/dev/null' in_image "$node_image" npx tsc -p package-tests in_image "$floor_image" node package-tests/node-floor.js + +with_firefox in_image "$node_image" node browser-tests/run.js diff --git a/docker-runner.sh b/docker-runner.sh index 66ed149..36383a9 100644 --- a/docker-runner.sh +++ b/docker-runner.sh @@ -1,10 +1,20 @@ bun_image=oven/bun:1.4.0-alpine deno_image=denoland/deno:2.9.6 +firefox_image=instrumentisto/geckodriver:149.0.2 floor_image=node:18.20.8-alpine3.21 node_image=node:24.19.0-alpine3.24 in_image() { local image=$1 entrypoint=$2 shift 2 - docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@" + docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${network:+--network "$network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@" +} + +with_firefox() { + local container network status=0 + container=$(docker run -d --rm "$firefox_image") + network="container:$container" + "$@" || status=$? + docker rm -f "$container" >/dev/null + return $status } diff --git a/renovate.json b/renovate.json index 1c30dd2..f958d75 100644 --- a/renovate.json +++ b/renovate.json @@ -11,6 +11,15 @@ "matchStrings": ["denoland/deno:(?[0-9][^\\s\"']*)"], "versioningTemplate": "docker" }, + { + "customType": "regex", + "datasourceTemplate": "docker", + "depNameTemplate": "instrumentisto/geckodriver", + "description": "Pin the Firefox image the gate's browser leg runs", + "managerFilePatterns": ["docker-runner.sh"], + "matchStrings": ["instrumentisto/geckodriver:(?[0-9][^\\s\"']*)"], + "versioningTemplate": "docker" + }, { "customType": "regex", "datasourceTemplate": "docker", diff --git a/todo-history.md b/todo-history.md index 68b9b53..a026056 100644 --- a/todo-history.md +++ b/todo-history.md @@ -512,3 +512,18 @@ Under **3 — `markdownToAdf` (`0.1.0`)**: canonical-fixpoint sentence claiming more than `0.1.0` keeps — 3e names three shapes that parse and then refuse — so it now says a parse succeeding is no promise of a way back, and names them. +- [x] **5d — The browser leg.** §6's browser half is checkable on the emitted `dist/index.js` a + browser can load — the compile gate names no host API, and a real page converting the corpus + is the other half. Headless Firefox is that page, settling both at once: the browser proof, + and the only SpiderMonkey there is, the gate's three engine legs being two V8s and a + JavaScriptCore that is not Safari's. The mechanism is the decision this item opens with: a + browser leg wants an image, a driver and a way to carry a verdict back out, none of which + the gate's plain `docker run` per engine has. The answer is `with_firefox`, which runs the + geckodriver image beside the node one in a shared network namespace, so the page's server and + the driver are each other's `127.0.0.1` and no user-defined network, container name or + geckodriver `--allow-hosts` entry is wanted. `browser-tests/run.js` serves the repo, drives + one `execute/sync` and asserts the results against the corpus with the Node-side + `assert.deepEqual` the corpus runner uses, so the browser page holds no second copy of the + comparison. The whole corpus fits: 118 fixtures in 2.9s warm, 14s cold, against a 120s + script timeout — no slice was worth choosing. A `try` around the dynamic import is what turns + a broken build into SpiderMonkey's own message rather than an undefined global. diff --git a/todo.md b/todo.md index 0140fa1..8c5559a 100644 --- a/todo.md +++ b/todo.md @@ -129,13 +129,7 @@ The numbering is the order the work was planned in, not the order it ships. - [x] **5b3 — The code list and the flavour's gaps.** - [x] **5b4 — The README's consumer surface.** - [x] **5c — The build and the release pipeline.** -- [ ] **5d — The browser leg (`0.1.0`).** §6's browser half is checkable on the emitted - `dist/index.js` a browser can load — the compile gate names no host API, and a real page - converting the corpus is the other half. Headless Firefox is that page, settling both at - once: the browser proof, and the only SpiderMonkey there is, the gate's three engine legs - being two V8s and a JavaScriptCore that is not Safari's. The mechanism is the decision this - item opens with: a browser leg wants an image, a driver and a way to carry a verdict back - out, none of which the gate's plain `docker run` per engine has. +- [x] **5d — The browser leg.** - [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts. - [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /