Files
improvise/web/tests/bundle/wasm_surface.test.js
T
Edward Langley 12714c7527 feat(browser): a boot failure is a state with a reason, not silence
improvise-1wft and the five beads it blocked were one failure: nothing
carried a boot failure's REASON from the worker to the page. The channel
is one rule -- a protocol frame crosses the port as a STRING, a shell
notice as an OBJECT -- so it needs no protocol change and the
discriminator is total.

A boot failure is a STATE, not a rejected promise. A document another
context holds the exclusive handle to now OPENS read-only: measured first
(harness probe, Chromium + Firefox), getFile() reads the bytes while the
handle is held, with the second-handle refusal asserted so the probe
cannot pass vacuously.

- improvise-1wft  the channel: Notice/NoticeKind, boot_notice, take_notice
- improvise-i0v9  read-only boot over vfs::ReadOnly + OpfsFs::read_unattached
- improvise-86nw  start() rejecting for any reason now posts BootFailed
- improvise-9cma  boot::document_name -- and a/b/c.improv was ACCEPTED as
                  c.improv, not rejected, which is worse than the report
- improvise-iobc  vfs::Origin: a seed error is a notice only when the
                  document really had to be created without it
- improvise-vep8  the watchdog arms BEFORE transport.ready; its message
                  stopped claiming the connection was accepted
- improvise-7gip  DedicatedWorkerGlobalScope (needs a web-sys feature)
- improvise-zre1  a persist that fails raises a notice; the trigger is the
                  dirty flag surviving, observed rather than predicted
- improvise-7gf8  the render breaker trips once instead of retrying forever
- improvise-ociy  non-OPEN sends are loud; a viewport announcement is not
- improvise-y01k  the three ws-server drops are logged
- improvise-r4nf  serde failures throw instead of reading as "nothing to send"

Every notice routes through shell.fail, so gotcha 48's disarm half is not
re-broken. Two comments in shell.spec.js stated the opposite of the new
arming order and were corrected rather than left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:51:34 -07:00

130 lines
5.2 KiB
JavaScript

// 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 `<receiver>.<name>(...)` 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",
"click_tile",
"click_view",
"on_frame",
"on_key_event",
"render_model",
"set_viewport",
"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]);
});