From b3b7b8250a2211ffa55a3b7fdfa73e3a7c2b213f Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sun, 9 Aug 2026 15:29:40 -0400 Subject: [PATCH] ct-runner: --translator/DELTIC_TRANSLATOR for consumers outside a checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI's translator lookup was repo-relative (target/...), which cannot work when the runner is imported by URL at a release tag — the consumer migration path (#14): polymorph-test's deltic lane pins a pre- tag and takes the wasm from that release's deltic-translator-shim.wasm asset (#16 interim scheme). Resolution order: --translator, DELTIC_TRANSLATOR, checkout-local build; a non-file: import.meta.url now fails loud with the release-asset hint instead of a readFile error. CLI-level subprocess tests cover all three paths (the fixture suite's deliberate failure keeps exit-1 discipline). Verified remote consumption end to end: runtime+ct-runner graph resolves over raw.githubusercontent at pre-40152e2 with the release-asset translator; fixture counts and L4 envelope exact. --- ct-runner/deno.json | 2 +- ct-runner/src/main.ts | 55 +++++++++++++++++++-- ct-runner/tests/cli_test.ts | 97 +++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 ct-runner/tests/cli_test.ts diff --git a/ct-runner/deno.json b/ct-runner/deno.json index e791a22..d2af31c 100644 --- a/ct-runner/deno.json +++ b/ct-runner/deno.json @@ -8,7 +8,7 @@ "./run": "./src/run-suite.ts" }, "tasks": { - "test": "deno test --allow-read=..", + "test": "deno test --allow-read=..,/tmp --allow-write=/tmp --allow-run", "check": "deno check src tests" } } diff --git a/ct-runner/src/main.ts b/ct-runner/src/main.ts index af068d6..02471a9 100644 --- a/ct-runner/src/main.ts +++ b/ct-runner/src/main.ts @@ -2,13 +2,21 @@ // ct-runner CLI entry. // // deno run -A ct-runner/src/main.ts --out results.jsonl \ -// [--imports ] [--target NAME] [--suite-name NAME] \ +// [--translator ] [--imports ] \ +// [--target NAME] [--suite-name NAME] \ // [--only SUBSTRING] [--case-timeout-ms N] [--no-fresh-cases] [--jspi] // // `--imports ` convention (contracts/embedder-api.md §"Module // wiring and instantiation"): a TS module whose default export is either // the imports record directly, or a factory (sync or async) producing one. // Never test-context — the runner supplies that itself. +// +// `--translator` (or DELTIC_TRANSLATOR in the environment) names the +// translator-shim wasm explicitly — required when this CLI runs outside a +// deltic checkout (e.g. imported by URL at a release tag, with the wasm +// taken from that release's `deltic-translator-shim.wasm` asset; see +// docs/consumers.md and issue #16's interim release scheme). Inside a +// checkout it defaults to the local release build under `target/`. import { Translator } from "../../runtime/src/shim/mod.ts"; import type { ComponentArtifacts } from "../../runtime/src/embedder/mod.ts"; @@ -20,7 +28,8 @@ function usageError(msg: string): never { console.error(`error: ${msg}`); console.error( "usage: deno run -A ct-runner/src/main.ts --out " + - "[--imports ] [--target NAME] [--suite-name NAME] " + + "[--translator ] [--imports ] " + + "[--target NAME] [--suite-name NAME] " + "[--only SUBSTRING] [--case-timeout-ms N] [--no-fresh-cases] [--jspi]", ); Deno.exit(2); @@ -29,6 +38,7 @@ function usageError(msg: string): never { interface Cli { suitePath: string; out: string; + translator?: string; importsModule?: string; target: string; suiteName?: string; @@ -41,6 +51,7 @@ interface Cli { function parseArgs(argv: string[]): Cli { const positional: string[] = []; let out: string | undefined; + let translator: string | undefined; let importsModule: string | undefined; let target = "deltic/host"; let suiteName: string | undefined; @@ -55,6 +66,9 @@ function parseArgs(argv: string[]): Cli { case "--out": out = argv[++i]; break; + case "--translator": + translator = argv[++i]; + break; case "--imports": importsModule = argv[++i]; break; @@ -86,6 +100,7 @@ function parseArgs(argv: string[]): Cli { return { suitePath: positional[0], out, + translator, importsModule, target, suiteName, @@ -109,7 +124,37 @@ async function loadImportsModule(path: string): Promise> return (def ?? {}) as Record; } -async function loadTranslator(): Promise { +/** Resolve the translator-shim wasm: explicit `--translator`, then + * `DELTIC_TRANSLATOR`, then the checkout-local release build. The explicit + * paths exist for consumers running this CLI outside a deltic checkout + * (URL-imported at a release tag): `import.meta.url` is then remote, so the + * repo-relative default cannot work — they point at the release's + * `deltic-translator-shim.wasm` asset instead. */ +async function loadTranslator(explicit?: string): Promise { + const fromEnv = Deno.env.get("DELTIC_TRANSLATOR"); + const path = explicit ?? (fromEnv !== undefined && fromEnv !== "" ? fromEnv : undefined); + if (path !== undefined) { + let bytes: Uint8Array; + try { + bytes = await Deno.readFile(path); + } catch (e) { + console.error( + `error: cannot read translator wasm at ${path}` + + ` (${explicit !== undefined ? "--translator" : "DELTIC_TRANSLATOR"}): ${e}`, + ); + Deno.exit(1); + } + return await Translator.create(bytes); + } + + if (REPO_ROOT.protocol !== "file:") { + console.error( + "error: running outside a deltic checkout — pass --translator " + + " (or set DELTIC_TRANSLATOR); the wasm ships as " + + "a release asset (deltic-translator-shim.wasm).", + ); + Deno.exit(1); + } const rel = "target/wasm32-unknown-unknown/release/translator_shim.wasm"; let bytes: Uint8Array; try { @@ -117,7 +162,7 @@ async function loadTranslator(): Promise { } catch { console.error( `error: missing ${rel} — run: cargo build -p translator-shim --release ` + - `--target wasm32-unknown-unknown`, + `--target wasm32-unknown-unknown (or pass --translator)`, ); Deno.exit(1); } @@ -132,7 +177,7 @@ function suiteNameFrom(path: string): string { async function main() { const cli = parseArgs(Deno.args); const componentBytes = await Deno.readFile(cli.suitePath); - const translator = await loadTranslator(); + const translator = await loadTranslator(cli.translator); const { plan, adapters } = translator.translate(componentBytes); const artifacts: ComponentArtifacts = { plan, componentBytes, adapters }; diff --git a/ct-runner/tests/cli_test.ts b/ct-runner/tests/cli_test.ts new file mode 100644 index 0000000..f8978ac --- /dev/null +++ b/ct-runner/tests/cli_test.ts @@ -0,0 +1,97 @@ +// CLI-level e2e: main.ts as a consumer runs it — a subprocess, with the +// translator named EXPLICITLY (`--translator` / DELTIC_TRANSLATOR) rather +// than found in the checkout. This is the remote-consumption contract +// (docs/consumers.md; polymorph-test's deltic lane pins a release tag and +// passes the release's translator asset), so the repo-relative fallback +// must never be the only path that works. + +import { assertEq } from "../../runtime/tests/support/asserts.ts"; +import { haveFixture, TEST_SUITE_WASM } from "./support.ts"; + +const root = new URL("../../", import.meta.url); +const MAIN = new URL("../src/main.ts", import.meta.url).pathname; +const TRANSLATOR = new URL( + "target/wasm32-unknown-unknown/release/translator_shim.wasm", + root, +).pathname; +const SUITE = new URL(TEST_SUITE_WASM, root).pathname; + +const ready = await haveFixture(TEST_SUITE_WASM); + +interface CliRun { + code: number; + stderr: string; + lines: string[] | null; +} + +async function runCli( + args: string[], + env: Record = {}, +): Promise { + const out = await Deno.makeTempFile({ suffix: ".jsonl" }); + try { + const cmd = new Deno.Command(Deno.execPath(), { + args: ["run", "-A", MAIN, SUITE, "--out", out, ...args], + env, + stdout: "inherit", + stderr: "piped", + }); + const res = await cmd.output(); + let lines: string[] | null = null; + try { + const text = await Deno.readTextFile(out); + // makeTempFile pre-creates the (empty) file; an early CLI exit leaves + // it empty, which callers treat the same as absent. + lines = text === "" ? null : text.trimEnd().split("\n"); + } catch { + // CLI exited before writing — callers assert on code/stderr. + } + return { + code: res.code, + stderr: new TextDecoder().decode(res.stderr), + lines, + }; + } finally { + await Deno.remove(out).catch(() => {}); + } +} + +Deno.test({ + name: "cli: --translator runs the suite (no checkout fallback used)", + ignore: !ready, + fn: async () => { + const { code, lines } = await runCli(["--translator", TRANSLATOR]); + // The fixture suite contains a deliberately failing case, so the CLI's + // contract is exit 1 (same discipline as polymorph-test's verify legs). + assertEq(code, 1); + assertEq(lines !== null, true); + assertEq(lines!.length, 1 + 6 + 1); // envelope + 6 cases + terminator + const envelope = JSON.parse(lines![0]); + assertEq(envelope.target, "deltic/host"); + }, +}); + +Deno.test({ + name: "cli: DELTIC_TRANSLATOR env is honored", + ignore: !ready, + fn: async () => { + const { code, lines } = await runCli([], { DELTIC_TRANSLATOR: TRANSLATOR }); + assertEq(code, 1); + assertEq(lines!.length, 1 + 6 + 1); + }, +}); + +Deno.test({ + name: "cli: unreadable --translator fails loud, names the flag", + ignore: !ready, + fn: async () => { + const { code, stderr, lines } = await runCli([ + "--translator", + "/nonexistent/translator_shim.wasm", + ]); + assertEq(code, 1); + assertEq(lines, null); + assertEq(stderr.includes("--translator"), true); + assertEq(stderr.includes("/nonexistent/translator_shim.wasm"), true); + }, +});