5d: the browser leg #49
@@ -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.
|
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.
|
`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
|
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
|
||||||
`noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index
|
`noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index
|
||||||
|
|||||||
@@ -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.
|
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,
|
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.
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>@larvit/adf-codec in a browser</title>
|
||||||
|
<script type="module" src="/browser-tests/convert-corpus.js"></script>
|
||||||
@@ -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`,
|
||||||
|
)
|
||||||
@@ -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'
|
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 "$node_image" npx tsc -p package-tests
|
||||||
in_image "$floor_image" node package-tests/node-floor.js
|
in_image "$floor_image" node package-tests/node-floor.js
|
||||||
|
|
||||||
|
with_firefox in_image "$node_image" node browser-tests/run.js
|
||||||
|
|||||||
+11
-1
@@ -1,10 +1,20 @@
|
|||||||
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
|
||||||
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 -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,15 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -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
|
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
|
parse and then refuse — so it now says a parse succeeding is no promise of a way back, and
|
||||||
names them.
|
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.
|
||||||
|
|||||||
@@ -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] **5b3 — The code list and the flavour's gaps.**
|
||||||
- [x] **5b4 — The README's consumer surface.**
|
- [x] **5b4 — The README's consumer surface.**
|
||||||
- [x] **5c — The build and the release pipeline.**
|
- [x] **5c — The build and the release pipeline.**
|
||||||
- [ ] **5d — The browser leg (`0.1.0`).** §6's browser half is checkable on the emitted
|
- [x] **5d — The browser leg.**
|
||||||
`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.
|
|
||||||
- [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity
|
- [ ] **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.
|
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
|
||||||
- [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /
|
- [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /
|
||||||
|
|||||||
Reference in New Issue
Block a user