Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions js/jco-transpile.mjs
Original file line number Diff line number Diff line change
@@ -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)`);
}
9 changes: 7 additions & 2 deletions js/viewer/harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};
}
Expand Down
84 changes: 84 additions & 0 deletions js/viewer/imports.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
44 changes: 44 additions & 0 deletions js/viewer/imports.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
7 changes: 6 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Loading