From 54c26879846d6d1edae8778e92bb7b8378e56201 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Fri, 7 Aug 2026 15:33:07 -0400 Subject: [PATCH] js: upstream the node suite-runner loop (#59's node half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four consumers carry near-identical copies of the same driver skeleton: load a transpiled suite's cores, read the tag inventory, instantiate, resolve the tests export across its spellings, run the case loop with fresh instances and a case timeout, and emit envelope/events/terminator as results JSONL. The loop moves upstream: - harness.mjs gains runSuiteJsonl — the sequential-driver shape, browser-safe, with the JSPI fresh-instance-per-case default and a Context override for drivers with their own diagnostic transport; pool topologies keep composing runCases + mergeCounts directly. - ./node-runner (node-only): loadCoreModules (prefix-filtered, name order, bytes + compiled modules), resolveTestsExport, and writeResultsFile. What stays per-consumer is the frame: argv, SUT and environment wiring, concurrency topology. Also documents the transpile-stamp convention next to the canonical recipes: the stamp must cover the jco package.json, where the flags and the pinned transpiler live — a wasm-only stamp has demonstrably shipped stale output across a flag change. Stub-driven unit tests join verify-imports; verify-node's goldens and the viewer selftest pin that the harness change is additive. --- actions/README.md | 17 ++++++ js/node-runner.mjs | 61 ++++++++++++++++++++++ js/node-runner.test.mjs | 113 ++++++++++++++++++++++++++++++++++++++++ js/viewer/harness.mjs | 62 ++++++++++++++++++++++ justfile | 5 +- package.json | 4 +- 6 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 js/node-runner.mjs create mode 100644 js/node-runner.test.mjs diff --git a/actions/README.md b/actions/README.md index 0f75fed..b9e8ccc 100644 --- a/actions/README.md +++ b/actions/README.md @@ -59,6 +59,23 @@ _ct-tools: (Append `--js-lock ` for each JS lockfile the repo carries; drop `component-test-runner` if the runner is embedded as a library.) +### Transpile stamps + +Consumers guard their jco transpiles with a content stamp so `just` +runs skip redundant work. The stamp must cover the suite artifacts +**and the jco tree's `package.json`**: the transpile flags and the +pinned transpiler both live there, and either changing must invalidate +the generated tree — a stamp keyed on the wasm alone has demonstrably +shipped stale output across a flag change. + +```just +stamp=$(cat "{{suite}}" jco/package.json | sha256sum | cut -d' ' -f1) +if [ "$(cat jco/generated/.stamp 2>/dev/null || true)" != "$stamp" ]; then + (cd jco && npm run --silent transpile) + printf '%s' "$stamp" > jco/generated/.stamp +fi +``` + ## `aggregate` Validates per-target results-JSONL against a lockfile + target diff --git a/js/node-runner.mjs b/js/node-runner.mjs new file mode 100644 index 0000000..950db88 --- /dev/null +++ b/js/node-runner.mjs @@ -0,0 +1,61 @@ +// Node-only conveniences for jco-transpiled suite drivers (#59's node +// half): core-module loading, the tests-export spellings, results-file +// writing. The case loop itself is `runSuiteJsonl` in ./viewer/harness.mjs +// (browser-safe); what stays in each consumer is its frame — argv, SUT +// and environment wiring, concurrency topology. + +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Compile a transpiled suite's core modules from `dir`, in name order: + * `.core*.wasm` when `prefix` is given (several suites may + * share one generated tree), every `*.wasm` otherwise. Returns + * `modules` (name → WebAssembly.Module, for `instantiate`'s + * getCoreModule) and `coreBytes` (for `inventoryLookup` — the tags + * custom section rides the suite's core module through composition + * and transpilation). + */ +export async function loadCoreModules(dir, prefix) { + const modules = new Map(); + const coreBytes = []; + for (const name of (await readdir(dir)).sort()) { + if (!name.endsWith(".wasm")) continue; + if (prefix !== undefined && !name.startsWith(`${prefix}.core`)) continue; + const bytes = new Uint8Array(await readFile(join(dir, name))); + coreBytes.push(bytes); + modules.set(name, await WebAssembly.compile(bytes)); + } + if (modules.size === 0) { + throw new Error( + `no ${prefix === undefined ? "" : `${prefix}.core`}*.wasm under ${dir} (transpile first)`, + ); + } + return { modules, coreBytes }; +} + +/** + * The suite's `tests` interface from an instantiated component, + * whichever spelling the transpile used. Throws with the instance's + * export names when none matches. + */ +export function resolveTestsExport(instance) { + const tests = + instance.tests ?? instance["polymorph:test/tests@0.1.0"] ?? instance["polymorph:test/tests"]; + if (!tests) { + throw new Error(`suite instance exports no tests interface: ${Object.keys(instance)}`); + } + return tests; +} + +/** + * Write one target's results stream to `/.jsonl` + * (trailing newline included), creating `dir` as needed. Returns the + * path. + */ +export async function writeResultsFile({ dir, target, lines }) { + await mkdir(dir, { recursive: true }); + const path = join(dir, `${target}.jsonl`); + await writeFile(path, `${lines.join("\n")}\n`); + return path; +} diff --git a/js/node-runner.test.mjs b/js/node-runner.test.mjs new file mode 100644 index 0000000..e077a11 --- /dev/null +++ b/js/node-runner.test.mjs @@ -0,0 +1,113 @@ +// Unit checks for the node driver helpers (js/node-runner.mjs) and the +// shared suite-runner loop (runSuiteJsonl). Plain node, no transpiled +// suites: cases and instances are stubs. `just verify-imports`. + +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runSuiteJsonl } from "./viewer/harness.mjs"; +import { loadCoreModules, resolveTestsExport, writeResultsFile } from "./node-runner.mjs"; + +// loadCoreModules: prefix filtering, name order, non-wasm noise +// ignored, empty is an error. The 8-byte header is a valid (empty) +// core module, so WebAssembly.compile accepts it. +const EMPTY_MODULE = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]); +const dir = await mkdtemp(join(tmpdir(), "node-runner-test-")); +try { + for (const name of ["a.core.wasm", "a.core2.wasm", "b.core.wasm"]) { + await writeFile(join(dir, name), EMPTY_MODULE); + } + await writeFile(join(dir, "a.js"), "// not wasm"); + + const a = await loadCoreModules(dir, "a"); + assert.deepEqual([...a.modules.keys()], ["a.core.wasm", "a.core2.wasm"]); + assert.equal(a.coreBytes.length, 2); + assert.ok(a.modules.get("a.core.wasm") instanceof WebAssembly.Module); + + const all = await loadCoreModules(dir); + assert.equal(all.modules.size, 3, "no prefix loads every wasm"); + + await assert.rejects(() => loadCoreModules(dir, "zzz"), /zzz\.core\*\.wasm under/); +} finally { + await rm(dir, { recursive: true, force: true }); +} + +// resolveTestsExport: every spelling, and the error names the exports. +const tests = { all: async () => [] }; +assert.equal(resolveTestsExport({ tests }), tests); +assert.equal(resolveTestsExport({ "polymorph:test/tests@0.1.0": tests }), tests); +assert.equal(resolveTestsExport({ "polymorph:test/tests": tests }), tests); +assert.throws(() => resolveTestsExport({ other: 1 }), /no tests interface: other/); + +// runSuiteJsonl: envelope first (name normalized), one line per case, +// terminator last; scheduling against missing; fresh instances per +// case; counts returned; zero cases is an error. +const stubCase = (name, body) => ({ name: () => name, run: body ?? (async () => {}) }); +let instances = 0; +const newTests = async () => { + instances += 1; + return { + all: async () => [ + stubCase("basic/pass"), + stubCase("basic/fail", async () => { + throw { payload: { tag: "failed", val: "boom" } }; + }), + stubCase("gated/probe"), + ], + }; +}; +const tags = { "basic/pass": [], "basic/fail": [], "gated/probe": ["hsm"] }; +const lines = []; +const counts = await runSuiteJsonl({ + newTests, + tagsOf: (name) => tags[name], + target: "stub-target", + suiteName: "sample-suite", + missing: ["hsm"], + emit: (line) => lines.push(line), +}); +assert.deepEqual(counts, { passed: 1, failed: 1, skipped: 0, na: 1, total: 3 }); +assert.equal(lines.length, 5, "envelope + three events + terminator"); +const head = JSON.parse(lines[0]); +assert.equal(head.suite.name, "sample_suite", "envelope normalizes the transpile name"); +assert.equal(head.target, "stub-target"); +assert.equal(lines.at(-1), '{"segment-end":true}'); +const events = lines.slice(1, -1).map((l) => JSON.parse(l)); +assert.deepEqual( + events.map((e) => e.status), + ["pass", "fail", "not-applicable"], +); +assert.equal(events[1].detail, "boom"); +// census + one fresh instance per executed case (the N/A case never runs) +assert.equal(instances, 3); + +await assert.rejects( + () => + runSuiteJsonl({ + newTests: async () => ({ all: async () => [] }), + tagsOf: () => [], + target: "t", + suiteName: "s", + emit: () => {}, + }), + /empty selection is a run error/, +); + +// writeResultsFile: creates the dir, returns the path, trailing newline. +const outDir = await mkdtemp(join(tmpdir(), "node-runner-out-")); +try { + const path = await writeResultsFile({ + dir: join(outDir, "nested"), + target: "stub-target", + lines: ["a", "b"], + }); + const { readFile: rf } = await import("node:fs/promises"); + assert.equal(await rf(path, "utf8"), "a\nb\n"); + assert.ok(path.endsWith("stub-target.jsonl")); +} finally { + await rm(outDir, { recursive: true, force: true }); +} + +console.log("node-runner selftest OK"); diff --git a/js/viewer/harness.mjs b/js/viewer/harness.mjs index d8f7e9c..9558537 100644 --- a/js/viewer/harness.mjs +++ b/js/viewer/harness.mjs @@ -9,6 +9,8 @@ // Browser-safe by construction: no Node builtins; callers supply the // core-wasm bytes and the transpiled suite module. +import { Context } from "./context.js"; + export const TAGS_SECTION = "component-test:tags@0.1"; /** Custom sections named `wanted` from a core wasm module's bytes. */ @@ -250,3 +252,63 @@ export function mergeCounts(parts) { export function workerCount(available) { return Math.max(1, Math.min(available ?? 1, 8)); } + +/** + * Run one suite's whole case loop and emit a complete results-JSONL + * stream: envelope, one serialized event per case, terminator. The + * sequential-driver shape shared by the consumers' Node legs and + * browser workers; pool topologies compose [`runCases`] + + * [`mergeCounts`] directly instead. + * + * Browser-safe: the caller supplies instantiation and I/O. + * + * - `newTests`: async () => the suite's tests interface on a *fresh* + * instance. Called once for the census and — with `freshCases`, the + * default — once per case: JSPI attempts cannot be cancelled, so a + * timed-out case's instance may be wedged mid-suspension, and a + * fresh instance per case also contains trap poisoning. + * - `suiteName` may be the kebab-case transpile name; the envelope + * normalizes to the lockfile identity. + * - `emit(line, index?)` receives each JSONL line (the envelope and + * terminator carry no index). + * - `Context` defaults to the upstream provider; a driver with its own + * diagnostic transport passes its class. + * + * Returns [`runCases`]' counts. Throws when the census is empty (an + * empty selection is a run error, per the results contract). + */ +export async function runSuiteJsonl({ + newTests, + tagsOf, + target, + suiteName, + missing = [], + only, + shard, + emit, + caseTimeoutMs, + freshCases = true, + Context: ContextClass = Context, + log, +}) { + emit(JSON.stringify(envelope(target, suiteName))); + const counts = await runCases({ + cases: await (await newTests()).all(), + Context: ContextClass, + tagsOf, + missing, + only, + shard, + emit: (event, index) => { + emit(JSON.stringify(event), index); + log?.(`${event.case} … ${event.status}`); + }, + caseTimeoutMs, + ...(freshCases ? { freshCases: async () => (await newTests()).all() } : {}), + }); + if (counts.total === 0) { + throw new Error("suite enumerated zero cases (empty selection is a run error)"); + } + emit('{"segment-end":true}'); + return counts; +} diff --git a/justfile b/justfile index 7513346..c0c26af 100644 --- a/justfile +++ b/justfile @@ -226,10 +226,11 @@ verify-viewer: viewer-build "$tmp/tests.lock" examples/aggregate/targets.toml \ "$tmp/native.jsonl" "$tmp/sim.jsonl" -# The shared consumer glue (import binding, envelope normalization): -# plain node, no wasm. +# The shared consumer glue (import binding, envelope normalization, +# the suite-runner loop, the node driver helpers): plain node, no wasm. verify-imports: node js/viewer/imports.test.mjs + node js/node-runner.test.mjs # Serve the viewer over the repository root (demo fixtures + transpiled # suites resolve by relative path): http://127.0.0.1:8123/ diff --git a/package.json b/package.json index efa5079..3b9a8c0 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,15 @@ "js/viewer/context.js", "js/viewer/imports.mjs", "js/viewer/worker.mjs", + "js/node-runner.mjs", "js/jco-transpile.mjs" ], "exports": { "./harness": "./js/viewer/harness.mjs", "./context": "./js/viewer/context.js", "./imports": "./js/viewer/imports.mjs", - "./worker": "./js/viewer/worker.mjs" + "./worker": "./js/viewer/worker.mjs", + "./node-runner": "./js/node-runner.mjs" }, "bin": { "component-test-jco-transpile": "./js/jco-transpile.mjs"