mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-23 06:10:02 +08:00
- Enforce oxlint and oxfmt for owned JavaScript and TypeScript with independent CI checks. - Preserve compiler import boundaries and protected fixtures while cleaning up lint findings. - Standardize source formatting and validate import, fixture, and embedded-source protections.
369 lines
14 KiB
TypeScript
369 lines
14 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);
|
|
},
|
|
);
|
|
},
|
|
);
|