From 9e688f4b00198e579a47633f9204b1fcbbaa5b64 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 4 Sep 2026 10:57:35 +0200 Subject: [PATCH] 5d: the review's image swap, teardown trap and fixture-name assertions --- AGENTS.md | 7 ++++-- browser-tests/convert-corpus.js | 8 +++---- browser-tests/run.js | 39 ++++++++++++++++++++------------- docker-runner.sh | 11 +++++----- renovate.json | 18 +++++++-------- todo-history.md | 19 ++++++++++------ 6 files changed, 60 insertions(+), 42 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ebeeb0e..10fdf43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,9 +200,12 @@ resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` s 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 +A WebDriver session 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 leg re-checks the conversions and nothing else — each fixture's emitted markdown, its parsed +document, its error code — leaving the corpus's pairing, uniqueness, source positions and +byte-level equality to the Node suite that owns them. 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 diff --git a/browser-tests/convert-corpus.js b/browser-tests/convert-corpus.js index 894a71d..0d44e36 100644 --- a/browser-tests/convert-corpus.js +++ b/browser-tests/convert-corpus.js @@ -1,11 +1,11 @@ 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 }) => { + errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })), + normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })), + roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => { const adf = JSON.parse(json) - return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), parsed: markdownToAdf(markdown) } + return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: markdownToAdf(markdown) } }), }) } catch (cause) { diff --git a/browser-tests/run.js b/browser-tests/run.js index 644f793..0d0c9d8 100644 --- a/browser-tests/run.js +++ b/browser-tests/run.js @@ -9,7 +9,8 @@ const repoRoot = join(import.meta.dirname, '..') const corpusRoot = join(repoRoot, 'corpus') const corpusEntries = readdirSync(corpusRoot, { encoding: 'utf8', recursive: true }) -function checking(name, assertions) { +function checking(name, result, assertions) { + assert.equal(result?.name, name, `the browser answered ${JSON.stringify(result?.name)} where ${name} was sent`) try { assertions() } catch (cause) { @@ -57,7 +58,7 @@ async function whenDriverAnswers() { 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`) + if (Date.now() > deadline) throw new Error(`no WebDriver answered ${driver}/status within 60s`) await new Promise((resolve) => setTimeout(resolve, 200)) } } @@ -67,6 +68,14 @@ const corpus = { 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 })), } +assert.deepEqual( + readdirSync(corpusRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(), + ['errors', 'normalization', 'round-trip'], + 'a corpus kind the browser leg does not convert', +) 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) => { @@ -88,9 +97,9 @@ const results = await command('POST', `/session/${session.sessionId}/execute/syn }) server.close() -for (const [index, result] of results.roundTrip.entries()) { - const { json, markdown, name } = corpus.roundTrip[index] - checking(name, () => { +for (const [index, { json, markdown, name }] of corpus.roundTrip.entries()) { + const result = results.roundTrip[index] + checking(name, result, () => { 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) @@ -99,19 +108,19 @@ for (const [index, result] of results.roundTrip.entries()) { }) } -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, { name }] of corpus.normalization.entries()) { + const result = results.normalization[index] + checking(name, result, () => { + assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`) + assert.deepEqual(result.parsed.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()) +for (const [index, { name }] of corpus.errors.entries()) { + const result = results.errors[index] + checking(name, result, () => { + assert.ok(!result.parsed.ok, `it was not refused, it built ${JSON.stringify(result.parsed.value)}`) + assert.equal(result.parsed.error.code, fixture(name, '.error').trimEnd()) }) } diff --git a/docker-runner.sh b/docker-runner.sh index 36383a9..0b2ccc6 100644 --- a/docker-runner.sh +++ b/docker-runner.sh @@ -1,20 +1,21 @@ bun_image=oven/bun:1.4.0-alpine deno_image=denoland/deno:2.9.6 -firefox_image=instrumentisto/geckodriver:149.0.2 +firefox_image=selenium/standalone-firefox:153.0.4 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 ${network:+--network "$network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@" + docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@" } with_firefox() { - local container network status=0 + local container in_image_network status=0 container=$(docker run -d --rm "$firefox_image") - network="container:$container" + # The id is baked in: the trap fires after this function's locals are gone. + trap "docker rm -f $container >/dev/null 2>&1" EXIT INT TERM + in_image_network="container:$container" "$@" || status=$? - docker rm -f "$container" >/dev/null return $status } diff --git a/renovate.json b/renovate.json index f958d75..daf367a 100644 --- a/renovate.json +++ b/renovate.json @@ -11,15 +11,6 @@ "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", @@ -56,6 +47,15 @@ "managerFilePatterns": [".gitea/workflows/renovate.yml"], "matchStrings": ["renovate/renovate:(?[0-9][^\\s\"']*)"], "versioningTemplate": "docker" + }, + { + "customType": "regex", + "datasourceTemplate": "docker", + "depNameTemplate": "selenium/standalone-firefox", + "description": "Pin the Firefox image the gate's browser leg runs", + "managerFilePatterns": ["docker-runner.sh"], + "matchStrings": ["selenium/standalone-firefox:(?[0-9][^\\s\"']*)"], + "versioningTemplate": "docker" } ], "extends": ["config:recommended"], diff --git a/todo-history.md b/todo-history.md index a026056..1442fbf 100644 --- a/todo-history.md +++ b/todo-history.md @@ -519,11 +519,16 @@ Under **3 — `markdownToAdf` (`0.1.0`)**: 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 + Firefox 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. + geckodriver `--allow-hosts` entry is wanted; its `EXIT INT TERM` trap bakes in the container + id, since the `local` holding it is gone by the time the trap fires. `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 8s warm 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. + **Settled** (the maintainer, 2026-09-04): `selenium/standalone-firefox` over the smaller + `instrumentisto/geckodriver`, currency over size — the leg's whole worth is a real + SpiderMonkey, which decays the moment the pin stops moving, and the smaller image was four + Firefox majors behind with a publisher that may go quiet while Renovate stays silent.