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
17 changes: 17 additions & 0 deletions actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,23 @@ _ct-tools:
(Append `--js-lock <lockfile>` 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
Expand Down
61 changes: 61 additions & 0 deletions js/node-runner.mjs
Original file line number Diff line number Diff line change
@@ -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:
* `<prefix>.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 `<dir>/<target>.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;
}
113 changes: 113 additions & 0 deletions js/node-runner.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
62 changes: 62 additions & 0 deletions js/viewer/harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
}
5 changes: 3 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading