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>, _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 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); }); });