5d: the review's image swap, teardown trap and fixture-name assertions
CI / gate (push) Successful in 1m25s
CI / publish (push) Has been skipped

This commit is contained in:
2026-09-04 10:57:35 +02:00
parent edf11304e3
commit 9e688f4b00
6 changed files with 60 additions and 42 deletions
+5 -2
View File
@@ -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` 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 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. 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 A WebDriver session is what carries a verdict back out, the driver and the page's server sharing
network namespace so each is the other's `127.0.0.1`; `--headless --screenshot` has no such 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. 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 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 functions, and a branch floor that only ever moves upward. It sits below 100 because the guards
+4 -4
View File
@@ -1,11 +1,11 @@
try { try {
const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js') const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js')
window.convertCorpus = (corpus) => ({ window.convertCorpus = (corpus) => ({
errors: corpus.errors.map(({ markdown }) => markdownToAdf(markdown)), errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
normalization: corpus.normalization.map(({ markdown }) => markdownToAdf(markdown)), normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
roundTrip: corpus.roundTrip.map(({ json, markdown }) => { roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
const adf = JSON.parse(json) 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) { } catch (cause) {
+24 -15
View File
@@ -9,7 +9,8 @@ const repoRoot = join(import.meta.dirname, '..')
const corpusRoot = join(repoRoot, 'corpus') const corpusRoot = join(repoRoot, 'corpus')
const corpusEntries = readdirSync(corpusRoot, { encoding: 'utf8', recursive: true }) 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 { try {
assertions() assertions()
} catch (cause) { } catch (cause) {
@@ -57,7 +58,7 @@ async function whenDriverAnswers() {
for (;;) { for (;;) {
const status = await command('GET', '/status').catch(() => undefined) const status = await command('GET', '/status').catch(() => undefined)
if (status?.ready === true) return 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)) await new Promise((resolve) => setTimeout(resolve, 200))
} }
} }
@@ -67,6 +68,14 @@ const corpus = {
normalization: fixtureNames('normalization', '.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 })), 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`) 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 server = createServer((request, response) => {
@@ -88,9 +97,9 @@ const results = await command('POST', `/session/${session.sessionId}/execute/syn
}) })
server.close() server.close()
for (const [index, result] of results.roundTrip.entries()) { for (const [index, { json, markdown, name }] of corpus.roundTrip.entries()) {
const { json, markdown, name } = corpus.roundTrip[index] const result = results.roundTrip[index]
checking(name, () => { checking(name, result, () => {
assert.ok(result.isDocument, `${name}.json is no ADF document`) assert.ok(result.isDocument, `${name}.json is no ADF document`)
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`) assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
assert.equal(result.emitted.value, markdown) 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()) { for (const [index, { name }] of corpus.normalization.entries()) {
const { name } = corpus.normalization[index] const result = results.normalization[index]
checking(name, () => { checking(name, result, () => {
assert.ok(result.ok, `it did not parse — ${refusal(result)}`) assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.deepEqual(result.value, JSON.parse(fixture(name, '.json'))) assert.deepEqual(result.parsed.value, JSON.parse(fixture(name, '.json')))
}) })
} }
for (const [index, result] of results.errors.entries()) { for (const [index, { name }] of corpus.errors.entries()) {
const { name } = corpus.errors[index] const result = results.errors[index]
checking(name, () => { checking(name, result, () => {
assert.ok(!result.ok, `it was not refused, it built ${JSON.stringify(result.value)}`) assert.ok(!result.parsed.ok, `it was not refused, it built ${JSON.stringify(result.parsed.value)}`)
assert.equal(result.error.code, fixture(name, '.error').trimEnd()) assert.equal(result.parsed.error.code, fixture(name, '.error').trimEnd())
}) })
} }
+6 -5
View File
@@ -1,20 +1,21 @@
bun_image=oven/bun:1.4.0-alpine bun_image=oven/bun:1.4.0-alpine
deno_image=denoland/deno:2.9.6 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 floor_image=node:18.20.8-alpine3.21
node_image=node:24.19.0-alpine3.24 node_image=node:24.19.0-alpine3.24
in_image() { in_image() {
local image=$1 entrypoint=$2 local image=$1 entrypoint=$2
shift 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() { with_firefox() {
local container network status=0 local container in_image_network status=0
container=$(docker run -d --rm "$firefox_image") 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=$? "$@" || status=$?
docker rm -f "$container" >/dev/null
return $status return $status
} }
+9 -9
View File
@@ -11,15 +11,6 @@
"matchStrings": ["denoland/deno:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["denoland/deno:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker" "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:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker"
},
{ {
"customType": "regex", "customType": "regex",
"datasourceTemplate": "docker", "datasourceTemplate": "docker",
@@ -56,6 +47,15 @@
"managerFilePatterns": [".gitea/workflows/renovate.yml"], "managerFilePatterns": [".gitea/workflows/renovate.yml"],
"matchStrings": ["renovate/renovate:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["renovate/renovate:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker" "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:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker"
} }
], ],
"extends": ["config:recommended"], "extends": ["config:recommended"],
+12 -7
View File
@@ -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 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 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 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 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 geckodriver `--allow-hosts` entry is wanted; its `EXIT INT TERM` trap bakes in the container
one `execute/sync` and asserts the results against the corpus with the Node-side id, since the `local` holding it is gone by the time the trap fires. `browser-tests/run.js`
`assert.deepEqual` the corpus runner uses, so the browser page holds no second copy of the serves the repo, drives one `execute/sync` and asserts the results against the corpus with the
comparison. The whole corpus fits: 118 fixtures in 2.9s warm, 14s cold, against a 120s Node-side `assert.deepEqual` the corpus runner uses, so the browser page holds no second copy
script timeout — no slice was worth choosing. A `try` around the dynamic import is what turns of the comparison. The whole corpus fits: 118 fixtures in 8s warm against a 120s script
a broken build into SpiderMonkey's own message rather than an undefined global. 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.