// The JS↔wasm boundary: every call the page makes is declared by the bundle, // with the arity it declares. Needs `web/build.sh` to have run. import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; const web = (name) => fileURLToPath(new URL(`../../${name}`, import.meta.url)); /// Declared methods and free functions, as `name -> [min, max]` arity. A /// missing bundle is a FAILURE and never a skip: this file exists to be run /// after `web/build.sh`, and a silent pass would report exactly nothing. function declarations(dts) { const text = readFileSync(web(dts), "utf8"); const found = new Map(); const decl = /(?:^|\n)\s*(?:export function |static )?([a-z_][A-Za-z0-9_]*)\(([^)]*)\)\s*:/g; for (const [, name, params] of text.matchAll(decl)) { const list = params.trim() === "" ? [] : params.split(","); const required = list.filter((p) => !p.includes("?:")).length; found.set(name, [required, list.length]); } assert.ok(found.size > 0, `${dts} declares nothing — was web/build.sh run?`); return found; } /// Every `.(...)` call in a source file, with how many /// top-level arguments it passes. function callsOn(file, receiver) { const text = readFileSync(web(file), "utf8"); const calls = []; const head = new RegExp(`${receiver}\\.([a-z_][A-Za-z0-9_]*)\\(`, "g"); for (const match of text.matchAll(head)) { let depth = 1; let i = match.index + match[0].length; let commas = 0; let body = ""; while (depth > 0 && i < text.length) { const ch = text[i]; if ("([{".includes(ch)) depth += 1; else if (")]}".includes(ch)) depth -= 1; else if (ch === "," && depth === 1) commas += 1; if (depth > 0) body += ch; i += 1; } assert.equal(depth, 0, `unbalanced call to ${match[1]} in ${file}`); calls.push({ name: match[1], args: body.trim() === "" ? 0 : commas + 1 }); } return calls; } test("every client call web/app.js makes is declared, with that arity", () => { const declared = declarations("pkg/improvise_wasm_client.d.ts"); const calls = callsOn("app.js", "client"); assert.ok(calls.length >= 6, `found only ${calls.length} client calls`); for (const call of calls) { const arity = declared.get(call.name); assert.ok(arity, `web/app.js calls client.${call.name}, which the bundle does not declare`); const [min, max] = arity; assert.ok( call.args >= min && call.args <= max, `client.${call.name} takes ${min}..${max} arguments; app.js passes ${call.args}`, ); } }); test("the page still reaches the client through a handful of calls", () => { const names = new Set(callsOn("app.js", "client").map((c) => c.name)); // The named surface, so a call added without an argument fails here and // has to come and say so. `viewport` is deliberately absent: `set_viewport` // is the one that records the window (gotcha 47). assert.deepEqual( [...names].sort(), [ "click_category", "click_cell", "click_formula", // improvise-rw6l: picking an item off a page axis. The page holds no // `Basis`, so composing `SetPageSelection` cannot happen above this. "click_page_item", "click_tile", "click_view", // improvise-ugj4: the editor shows the seed its target captured, not the paint. "edit_seed", // improvise-2wu3/0j0f: what an editor edits, captured when it opens, so // its commit names that record at that version and not a position. "edit_target", // improvise-jibp: the first edit of the marks the target touched. "edited_target", "on_frame", "on_key_event", "render_model", "set_viewport", // improvise-2mqz: download frames stay in the Rust fold until the page // takes the bytes and hands them to Blob + . "take_pending_download", "typed_cell", ], ); }); /// Methods on `Client` the page does not call, each with the reason it is /// still exported. A door with no caller and no entry here is improvise-seik: /// the dangerous kind, because calling the wrong one of a pair is silent. // What this CANNOT see, exactly as UNPAINTED cannot: `callsOn` is a source // scan, so a `client.foo()` inside a function nothing calls reads as called. const UNCALLED = { free: "wasm-bindgen's own destructor, not ours to remove", initSync: "wasm-bindgen's own module loader, beside the default export", revision: "on_frame returns it; a page that stores it can skip a repaint", }; test("the bundle exports no client door the page has no reason to hold", () => { const declared = declarations("pkg/improvise_wasm_client.d.ts"); const called = new Set(callsOn("app.js", "client").map((c) => c.name)); // `constructor` is `new Client()`, which `callsOn` cannot see as a call. const spare = [...declared.keys()].filter( (name) => name !== "constructor" && !called.has(name), ); assert.deepEqual( spare.sort(), Object.keys(UNCALLED).sort(), "an exported method nothing calls — delete it, or add it to UNCALLED with the reason", ); }); test("every worker call web/worker.js makes is declared, with that arity", () => { const declared = declarations("worker-pkg/improvise_worker_server.d.ts"); const calls = callsOn("worker.js", "server"); assert.ok(calls.length >= 2, `found only ${calls.length} server calls`); for (const call of calls) { const arity = declared.get(call.name); assert.ok(arity, `web/worker.js calls server.${call.name}, undeclared by the bundle`); assert.ok( call.args >= arity[0] && call.args <= arity[1], `server.${call.name} takes ${arity[0]}..${arity[1]}; worker.js passes ${call.args}`, ); } // `start` is a free function rather than a method, and takes the seed AND // why it is missing, so an empty document can say so (improvise-iobc). assert.deepEqual(declared.get("start"), [1, 3]); });