Files
scriptc/tests/harness/wasm-library.test.ts
Chris Tate a07998f667 Expand static three.js workflows and host integration (#621)
* Expand static three.js workflows and host integration

* Preserve JavaScript class state in static three.js workflows

* Preserve inferred JavaScript behavior across static workflows

* Preserve inferred callables and native compatibility boundaries

* Retain annotated JavaScript factory exports and bootstrap-safe call spreads

* Preserve Buffer and child callback identities in static packages

* Clarify JavaScript symbol spread support
2026-10-02 19:56:34 -05:00

236 lines
12 KiB
TypeScript

import { execFileSync, spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { WASI } from "node:wasi";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { compileLibrary as compileNodeLibrary, type CompileLibraryOptions } from "@scriptc/compiler";
import { NativeCompiler } from "../../packages/compiler/src/native/compiler.js";
import { nativeCodegenTarget, WASM32_WASI_TARGET } from "../../packages/compiler/src/backend/targets.js";
import { ts7Executable } from "../../packages/compiler/src/frontend/ts7/rpc-api.js";
// Run both compiler drivers against the same real checker transport in this
// harness. The production bootstrap separately exercises the native transport.
vi.mock("../../packages/compiler/src/frontend/pipeline-native.js", async () => {
const { runFrontend } = await import("../../packages/compiler/src/frontend/pipeline.js");
const { loadProgram } = await import("../../packages/compiler/src/frontend/program-node.js");
return { runNativeFrontend: (entry: string, _executable: string, npmStatic?: readonly string[] | "auto" | "lib",
externalTypes?: Readonly<Record<string, string>>, _evaluate?: unknown, libraryNpmStatic?: readonly string[]) =>
runFrontend(entry, loadProgram, npmStatic, externalTypes, libraryNpmStatic) };
});
const fixture = join(import.meta.dirname, "../library-mode/wasm");
const hasZig = spawnSync("zig", ["version"]).status === 0;
type Api = Record<string, (...args: number[]) => number> & { memory: WebAssembly.Memory };
describe.skipIf(!hasZig).each(["node", "native"] as const)("Wasm library embedding (%s driver)", (driver) => {
let directory: string;
let module: WebAssembly.Module;
let oldTarget: string | undefined;
let profile: any;
let native: NativeCompiler;
const compileLibrary = (options: CompileLibraryOptions) => driver === "node"
? compileNodeLibrary(options) : Promise.resolve(native.compileLibrary(options));
beforeAll(async () => {
oldTarget = process.env["SCRIPTC_TARGET"];
if (driver === "native") {
const host = nativeCodegenTarget()!;
const root = join(import.meta.dirname, "../..");
const helperPackageRoot = join(root, "packages", host.helper.packageName.replace("@scriptc/", ""));
const compilerPackage = JSON.parse(await readFile(join(root, "packages/compiler/package.json"), "utf8"));
native = new NativeCompiler({
compilerVersion: compilerPackage.version, target: WASM32_WASI_TARGET, helper: host.helper,
helperPackageRoot, helperExecutable: join(helperPackageRoot, "bin", process.platform === "win32" ? "scriptc-llvm-codegen.exe" : "scriptc-llvm-codegen"),
runtimePackRoot: join(root, "packages/runtime-wasm32-wasi"), ts7Executable: ts7Executable(),
linker: "zig", linkerArgs: ["cc"], dsymutil: "unused",
});
}
process.env["SCRIPTC_TARGET"] = "wasm32-wasi";
directory = await mkdtemp("/tmp/scriptc-wasm-library-test-");
profile = JSON.parse(await readFile(join(fixture, "profile.json"), "utf8"));
profile.entry = join(fixture, "lib.ts");
const result = await compileLibrary({ profilePath: join(fixture, "profile.json"), outDir: directory });
if (!result.ok) throw new Error(JSON.stringify(result.diagnostics));
expect(result.archivePath.endsWith(".wasm")).toBe(true);
module = new WebAssembly.Module(await readFile(result.archivePath));
});
afterAll(async () => {
if (oldTarget === undefined) delete process.env["SCRIPTC_TARGET"];
else process.env["SCRIPTC_TARGET"] = oldTarget;
if (directory) await rm(directory, { recursive: true, force: true });
});
function instantiate(adjust?: (value: number, api: Api) => number) {
const wasi = new WASI({ version: "preview1" });
let api: Api;
const reports: unknown[] = [];
const panics: string[] = [];
const text = (ptr: number, len: number) => new TextDecoder().decode(new Uint8Array(api.memory.buffer, ptr, len));
const instance = new WebAssembly.Instance(module, {
...wasi.getImportObject(),
scriptc: {
adjust: (value: number) => adjust ? adjust(value, api) : value * 2,
report: (p: number, n: number, b: number, size: number, flag: number) => {
reports.push([text(p, n), Array.from(new Uint8Array(api.memory.buffer, b, size)), flag !== 0]);
},
panic: (p: number, n: number) => { const message = text(p, n); panics.push(message); throw new Error(message); },
},
});
api = instance.exports as Api;
wasi.initialize(instance);
api.app_init();
return { api, reports, panics, text };
}
test("exports only the callable ABI and requires named host imports", () => {
expect(WebAssembly.Module.exports(module).map((e) => e.name).sort()).toEqual([
"_initialize", "memory", "scriptc_alloc", "scriptc_free", "app_init", "app_collect", "app_reset_results",
"app_step", "app_echo", "app_bytes", "app_fail",
].sort());
expect(WebAssembly.Module.imports(module).filter((e) => e.module === "scriptc").map((e) => e.name).sort()).toEqual(["adjust", "panic", "report"]);
expect(() => new WebAssembly.Instance(module, { scriptc: {}, wasi_snapshot_preview1: {} })).toThrow();
});
test("matches Node across stateful calls, strings, buffers, and host callbacks", () => {
const { api, reports, text } = instantiate();
const values = [api.app_step(3), api.app_step(-2), api.app_step(0.5)];
const input = new TextEncoder().encode("λ\0hello");
const ptr = api.scriptc_alloc(input.length + 8);
new Uint8Array(api.memory.buffer, ptr, input.length).set(input);
const out = ptr + input.length;
api.app_echo(ptr, input.length, out, out + 4);
let view = new DataView(api.memory.buffer);
const resultPtr = view.getUint32(out, true), resultLen = view.getUint32(out + 4, true);
const echo = text(resultPtr, resultLen);
new Uint8Array(api.memory.buffer, ptr, 3).set([0, 254, 255]);
api.app_bytes(ptr, 3, out, out + 4);
view = new DataView(api.memory.buffer);
const bytes = Array.from(new Uint8Array(api.memory.buffer, view.getUint32(out, true), view.getUint32(out + 4, true)));
// An explicit reset entry keeps earlier results live across later calls.
expect(text(resultPtr, resultLen)).toBe(echo);
api.scriptc_free(ptr);
api.app_reset_results();
api.app_collect();
const reference = execFileSync(process.execPath, ["--no-warnings", "--input-type=module", "-e", `
const reports=[];
globalThis.adjust=x=>x*2;
globalThis.report=(s,b,f)=>reports.push([s,Array.from(b),f]);
const api=await import(${JSON.stringify(join(fixture, "lib.ts"))});
const values=[api.step(3),api.step(-2),api.step(0.5)];
const echo=api.echo("λ\\0hello");
const bytes=Array.from(api.bytes(new Uint8Array([0,254,255])));
console.log(JSON.stringify({values,echo,bytes,reports}));
`], { encoding: "utf8" });
expect({ values, echo, bytes, reports }).toEqual(JSON.parse(reference));
});
test("instances isolate state and initialization resets the session", () => {
const a = instantiate().api, b = instantiate().api;
expect(a.memory).not.toBe(b.memory);
expect(a.app_step(5)).toBe(17);
expect(b.app_step(1)).toBe(9);
a.app_init();
expect(a.app_step(0)).toBe(7);
expect(b.app_step(0)).toBe(9);
});
test("host allocations survive memory growth and collection", () => {
const { api } = instantiate();
const p = api.scriptc_alloc(16);
new Uint8Array(api.memory.buffer, p, 16).fill(42);
const original = api.memory.buffer;
const big = api.scriptc_alloc(original.byteLength + 65536);
expect(big).not.toBe(0);
expect(api.memory.buffer).not.toBe(original);
api.app_collect();
expect(Array.from(new Uint8Array(api.memory.buffer, p, 16))).toEqual(Array(16).fill(42));
api.scriptc_free(big);
api.scriptc_free(p);
});
test("escaped exceptions poison only their instance", () => {
const a = instantiate(), b = instantiate();
expect(() => a.api.app_fail()).toThrow(/reactor failure/);
expect(a.panics).toHaveLength(1);
expect(a.panics[0]).toContain("app_fail");
expect(() => a.api.app_step(1)).toThrow();
expect(() => a.api.scriptc_alloc(8)).toThrow();
expect(b.api.app_step(1)).toBe(9);
});
test.each(["app_init", "app_collect", "app_reset_results", "scriptc_alloc", "scriptc_free", "app_step"])("rejects callback reentry through %s", (entry) => {
const { api, panics } = instantiate((value, api) => api[entry](value));
expect(() => api.app_step(2)).toThrow(/SC4026/);
expect(panics[0]).toContain(entry);
});
test("an escaping host exception prevents reuse of the suspended instance", () => {
const { api } = instantiate(() => { throw new Error("host failure"); });
expect(() => api.app_step(1)).toThrow("host failure");
expect(() => api.app_init()).toThrow(/SC4026/);
});
test.each(["instance_per_thread", "localize_runtime"])("refuses native-only %s", async (key) => {
const path = join(directory, `${key}.json`);
await writeFile(path, JSON.stringify({ ...profile, abi: { ...profile.abi, [key]: true } }));
const result = await compileLibrary({ profilePath: path, outDir: directory });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.diagnostics[0]?.code).toBe("SC3002");
});
test("refuses collisions with built-in Wasm exports and imports", async () => {
for (const change of [
{ abi: { ...profile.abi, prefix: "scriptc_", init_symbol: "scriptc_alloc", sink_register_symbol: "scriptc_sink", callback_register_symbol: "scriptc_callback", collect_symbol: null, result_reset_symbol: null }, exports: [] },
{ callbacks: [{ name: "panic", params: [], returns: "void" }] },
]) {
const path = join(directory, "reserved.json");
await writeFile(path, JSON.stringify({ ...profile, ...change }));
const result = await compileLibrary({ profilePath: path, outDir: directory });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.diagnostics[0]?.message).toMatch(/reserved/);
}
});
test.each(["release", "dev"])("published three.js computes animated camera-space vertices in %s", async (optimization) => {
const threeProfile = JSON.parse(await readFile(join(fixture, "three.json"), "utf8"));
const profilePath = join(directory, `three-${optimization}.json`);
await writeFile(profilePath, JSON.stringify({ ...threeProfile, entry: join(fixture, "three.mjs"), optimization }));
const result = await compileLibrary({ profilePath, outDir: directory });
if (!result.ok) throw new Error(JSON.stringify(result.diagnostics));
const wasi = new WASI({ version: "preview1" });
let api: Api;
const vertices: number[][] = [];
const surfaces: number[][] = [];
const resources: number[][] = [];
const instance = new WebAssembly.Instance(new WebAssembly.Module(await readFile(result.archivePath)), {
...wasi.getImportObject(),
scriptc: {
vertex: (...args: number[]) => vertices.push(args.map((x) => Number(x.toFixed(9)))),
surface: (...args: number[]) => surfaces.push(args.map((x) => Number(x.toFixed(9)))),
resource: (...args: number[]) => resources.push(args),
panic: (p: number, n: number) => { throw new Error(new TextDecoder().decode(new Uint8Array(api.memory.buffer, p, n))); },
},
});
api = instance.exports as Api;
wasi.initialize(instance);
api.app_init();
for (const time of [0, 123, 2000]) expect(api.app_frame(time, 1.5)).toBe(24);
api.app_dispose();
const reference = spawnSync(process.execPath, ["--no-warnings", "--input-type=module", "-e", `
const vertices=[],surfaces=[],resources=[];
globalThis.vertex=(...args)=>vertices.push(args.map(x=>Number(x.toFixed(9))));
globalThis.surface=(...args)=>surfaces.push(args.map(x=>Number(x.toFixed(9))));
globalThis.resource=(...args)=>resources.push(args);
const {frame,dispose}=await import(${JSON.stringify(join(fixture, "three.mjs"))});
for (const time of [0,123,2000]) frame(time,1.5);
dispose();
console.log(JSON.stringify({vertices,surfaces,resources}));
`], { encoding: "utf8" });
expect({ stdout: JSON.stringify({vertices,surfaces,resources}) + "\n", stderr: "", status: 0 }).toEqual({ stdout: reference.stdout, stderr: reference.stderr, status: reference.status });
api.app_collect();
api.app_init();
expect(api.app_frame(0, 1)).toBe(24);
});
});