From 73e702defc1f7fc88db5618bac88e3a2658e3332 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Fri, 7 Aug 2026 00:02:13 -0400 Subject: [PATCH] js: ship the jco-transpile wrapper as a bin; export the shared driver glue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuation of #5's one-harness consolidation: the case loop moved upstream, but three consumer-glue pieces stayed copied per repo and diverged — the jco-transpile CLI wrapper (three copies, existing only because @bytecodealliance/jco-transpile publishes the library without its CLI; the webrtc copy's no-eager-subtask-return option folded in), the wasi/test-context import binding for -I async instantiation, and the suite.replaceAll("-","_") lockfile-identity reconciliation in every runner. - js/jco-transpile.mjs, packaged as the component-test-jco-transpile bin; resolves @bytecodealliance/jco-transpile from the invoking package so each consumer keeps pinning its own toolchain. - js/viewer/imports.mjs (exports ./imports): bindImports over the caller's preview2-shim namespaces + explicit environment + SUT imports, bare and versioned spellings both (wasiVersions covers components built against later wasi 0.2 minors). - envelope() normalizes the suite name to the wasm-stem identity; a no-op for the already-normalized names every caller passes. verify-imports (plain node, in `all`) pins the glue; verify-node's byte-for-byte goldens pin the envelope no-op. Fixes #58. --- js/jco-transpile.mjs | 85 ++++++++++++++++++++++++++++++++++++++ js/viewer/harness.mjs | 9 +++- js/viewer/imports.mjs | 84 +++++++++++++++++++++++++++++++++++++ js/viewer/imports.test.mjs | 44 ++++++++++++++++++++ justfile | 7 +++- package.json | 8 +++- 6 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 js/jco-transpile.mjs create mode 100644 js/viewer/imports.mjs create mode 100644 js/viewer/imports.test.mjs diff --git a/js/jco-transpile.mjs b/js/jco-transpile.mjs new file mode 100644 index 0000000..3b0d46a --- /dev/null +++ b/js/jco-transpile.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Thin CLI over @bytecodealliance/jco-transpile, covering the two jco +// commands the conformance consumers use: `transpile` (component to ES +// module) and `types` (host-side type definitions for a WIT world). +// jco-transpile is the transpilation half of jco, published without +// the componentization toolchain (componentize-js, weval) that the +// full jco CLI drags in and nothing here runs. +// +// The library is resolved from the invoking package's node_modules — +// each consumer pins its own toolchain version — so this bin must run +// with the consumer's package directory as the working directory, +// which is how package.json scripts invoke it. +// +// The option spellings match the jco CLI's, and the library applies +// the same defaults the CLI did (name derivation, the wasi-shim map +// entries, output-path prefixing), so the generated trees are +// bit-identical to `jco transpile` / `jco types` output for these +// invocations. + +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; + +const require = createRequire(join(process.cwd(), "package.json")); +const { transpile, generateHostTypes, writeFiles } = await import( + pathToFileURL(require.resolve("@bytecodealliance/jco-transpile")).href +); + +const [command, path, ...rest] = process.argv.slice(2); +const { values } = parseArgs({ + args: rest, + options: { + name: { type: "string" }, + "async-mode": { type: "string" }, + instantiation: { type: "string", short: "I" }, + map: { type: "string", multiple: true }, + "world-name": { type: "string" }, + "out-dir": { type: "string", short: "o" }, + // Never answer an async-lowered import with a bare RETURNED status; + // required for componentize-js guests, whose lowering does not + // implement the returned-immediately case (see the option's doc in + // jco-transpile). + "no-eager-subtask-return": { type: "boolean" }, + // `types` only: WIT `@unstable` gates to enable, like `jco types + // --feature` (repeatable). + feature: { type: "string", multiple: true }, + }, +}); + +switch (command) { + case "transpile": { + const map = Object.fromEntries( + (values.map ?? []).map((entry) => { + const eq = entry.indexOf("="); + if (eq === -1) { + throw new Error(`--map entry has no '=': ${entry}`); + } + return [entry.slice(0, eq), entry.slice(eq + 1)]; + }), + ); + const { files } = await transpile(path, { + name: values.name, + asyncMode: values["async-mode"], + instantiation: values.instantiation, + map, + outDir: values["out-dir"], + noEagerSubtaskReturn: values["no-eager-subtask-return"], + }); + await writeFiles(files); + break; + } + case "types": { + const files = await generateHostTypes(path, { + worldName: values["world-name"], + asyncMode: values["async-mode"], + outDir: values["out-dir"], + features: values.feature, + }); + await writeFiles(files); + break; + } + default: + throw new Error(`unknown command: ${command} (expected transpile or types)`); +} diff --git a/js/viewer/harness.mjs b/js/viewer/harness.mjs index 699b84f..d8f7e9c 100644 --- a/js/viewer/harness.mjs +++ b/js/viewer/harness.mjs @@ -82,12 +82,17 @@ export function applies(tags, missing) { ); } -/** The results-JSONL envelope line for one target × suite run. */ +/** + * The results-JSONL envelope line for one target × suite run. The + * suite name is normalized to the lockfile identity — the wasm file + * stem, underscores — so callers can pass the kebab-case package name + * as-is. + */ export function envelope(target, suite) { return { "component-test-results": "0.1", target, - suite: { name: suite }, + suite: { name: suite.replaceAll("-", "_") }, run: { segment: 0 }, }; } diff --git a/js/viewer/imports.mjs b/js/viewer/imports.mjs new file mode 100644 index 0000000..e872a4a --- /dev/null +++ b/js/viewer/imports.mjs @@ -0,0 +1,84 @@ +// The suite import object for jco `-I async` instantiation, shared by +// the consumers' Node and browser drivers (#58): the wasi 0.2 +// interfaces from the caller's preview2-shim namespaces, the upstream +// test-context provider, an explicit environment, and the caller's +// system-under-test imports. Browser-safe: no Node APIs; the caller +// supplies the shim namespaces (Node or browser build). + +import { Context } from "./context.js"; + +/** + * One environment interface for every leg (explicit beats + * shim-internal state): `vars` is the `[name, value]` pair list the + * suite reads through `wasi:cli/environment`. + */ +export function envInterface(vars) { + return { + getEnvironment: () => vars, + getArguments: () => [], + initialCwd: () => undefined, + }; +} + +/** + * Build the import object for an `-I async`-transpiled suite. + * + * Every interface is bound under its bare name and each versioned + * spelling (generated cores mix the two, and the versioned spelling + * carries the exact minor the component was built against — pass + * `wasiVersions` when the default does not match the generated code's + * import names). + * + * - `wasi`: preview2-shim namespaces (`{ cli, clocks, io, random, + * filesystem }`); absent members are simply not bound. + * - `env`: `[name, value]` pairs served through `wasi:cli/environment` + * (always the explicit list, never the shim's ambient environment). + * - `sut`: system-under-test imports by interface name, e.g. + * `{ "polymorph:websocket/connections": connections }`, bound bare + * and with each `sutVersions` suffix. + * + * `polymorph:test/test-context` is always bound to the upstream + * [`Context`] provider at the contract's version. + */ +export function bindImports({ + wasi = {}, + env = [], + sut = {}, + wasiVersions = ["0.2.0"], + sutVersions = ["0.1.0"], +}) { + const imports = {}; + const bind = (name, impl, versions) => { + if (!impl) return; + imports[name] = impl; + for (const v of versions) { + imports[`${name}@${v}`] = impl; + } + }; + const { cli = {}, clocks = {}, io = {}, random = {}, filesystem = {} } = wasi; + bind("wasi:cli/environment", envInterface(env), wasiVersions); + bind("wasi:cli/exit", cli.exit, wasiVersions); + bind("wasi:cli/stdin", cli.stdin, wasiVersions); + bind("wasi:cli/stdout", cli.stdout, wasiVersions); + bind("wasi:cli/stderr", cli.stderr, wasiVersions); + bind("wasi:cli/terminal-input", cli.terminalInput, wasiVersions); + bind("wasi:cli/terminal-output", cli.terminalOutput, wasiVersions); + bind("wasi:cli/terminal-stdin", cli.terminalStdin, wasiVersions); + bind("wasi:cli/terminal-stdout", cli.terminalStdout, wasiVersions); + bind("wasi:cli/terminal-stderr", cli.terminalStderr, wasiVersions); + bind("wasi:clocks/monotonic-clock", clocks.monotonicClock, wasiVersions); + bind("wasi:clocks/wall-clock", clocks.wallClock, wasiVersions); + bind("wasi:io/error", io.error, wasiVersions); + bind("wasi:io/poll", io.poll, wasiVersions); + bind("wasi:io/streams", io.streams, wasiVersions); + bind("wasi:random/random", random.random, wasiVersions); + bind("wasi:random/insecure", random.insecure, wasiVersions); + bind("wasi:random/insecure-seed", random.insecureSeed, wasiVersions); + bind("wasi:filesystem/types", filesystem.types, wasiVersions); + bind("wasi:filesystem/preopens", filesystem.preopens, wasiVersions); + bind("polymorph:test/test-context", { Context }, ["0.1.0"]); + for (const [name, impl] of Object.entries(sut)) { + bind(name, impl, sutVersions); + } + return imports; +} diff --git a/js/viewer/imports.test.mjs b/js/viewer/imports.test.mjs new file mode 100644 index 0000000..972ae0e --- /dev/null +++ b/js/viewer/imports.test.mjs @@ -0,0 +1,44 @@ +// Unit checks for the shared consumer glue: the import-object builder +// (js/viewer/imports.mjs) and the envelope's suite-name normalization. +// Plain node, no wasm: `just verify-imports`. + +import assert from "node:assert/strict"; + +import { envelope } from "./harness.mjs"; +import { bindImports, envInterface } from "./imports.mjs"; + +// envelope: kebab-case package names normalize to the wasm-stem +// lockfile identity; already-normalized names pass through. +assert.equal(envelope("t", "conformance-guest-ct").suite.name, "conformance_guest_ct"); +assert.equal(envelope("t", "conformance_guest_ct").suite.name, "conformance_guest_ct"); + +// bindImports: bare + versioned spellings, absent shim members skipped, +// explicit environment, SUT suffixing, test-context always present. +const exit = { exit: () => {} }; +const streams = {}; +const sutImpl = {}; +const imports = bindImports({ + wasi: { cli: { exit }, io: { streams } }, + env: [["A", "1"]], + sut: { "polymorph:websocket/connections": sutImpl }, + wasiVersions: ["0.2.0", "0.2.6"], +}); + +assert.equal(imports["wasi:cli/exit"], exit); +assert.equal(imports["wasi:cli/exit@0.2.0"], exit); +assert.equal(imports["wasi:cli/exit@0.2.6"], exit); +assert.equal(imports["wasi:io/streams@0.2.6"], streams); +assert.ok(!("wasi:clocks/monotonic-clock" in imports), "absent shim members are not bound"); +assert.deepEqual(imports["wasi:cli/environment"].getEnvironment(), [["A", "1"]]); +assert.deepEqual(imports["wasi:cli/environment@0.2.6"].getArguments(), []); +assert.equal(imports["polymorph:websocket/connections"], sutImpl); +assert.equal(imports["polymorph:websocket/connections@0.1.0"], sutImpl); +assert.ok(imports["polymorph:test/test-context"].Context, "test-context provider bound"); +assert.ok(imports["polymorph:test/test-context@0.1.0"].Context); + +// envInterface: explicit list, no arguments, no cwd. +const env = envInterface([["B", "2"]]); +assert.deepEqual(env.getEnvironment(), [["B", "2"]]); +assert.equal(env.initialCwd(), undefined); + +console.log("imports selftest OK"); diff --git a/justfile b/justfile index 3b51748..7513346 100644 --- a/justfile +++ b/justfile @@ -9,7 +9,7 @@ _default: @just --list --unsorted # Everything: host tests, component builds, all four verification paths. -all: build test test-wasm lock-check verify-embed verify-compose verify-node verify-pipeline verify-aggregate verify-viewer verify-emit +all: build test test-wasm lock-check verify-embed verify-compose verify-node verify-pipeline verify-aggregate verify-viewer verify-imports verify-emit # CI's native job: formatting, clippy, host tests, WIT validation. host-checks: fmt-check lint test wit-check @@ -226,6 +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. +verify-imports: + node js/viewer/imports.test.mjs + # Serve the viewer over the repository root (demo fixtures + transpiled # suites resolve by relative path): http://127.0.0.1:8123/ viewer-serve: viewer-build diff --git a/package.json b/package.json index 2b6dc59..efa5079 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,17 @@ "files": [ "js/viewer/harness.mjs", "js/viewer/context.js", - "js/viewer/worker.mjs" + "js/viewer/imports.mjs", + "js/viewer/worker.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" + }, + "bin": { + "component-test-jco-transpile": "./js/jco-transpile.mjs" } }