628f43121d
improvise-5jna, improvise-0x9u, improvise-seik — three browser-page defects with one shape: a thing that looks done from every check except running it. improvise-5jna. `doc_name`/`doc_dirty`/`doc_stored_as` were projected by z754 and painted by nothing. `render_surface.test.js` could not see it — it is a SUBSTRING SEARCH over the page's source, so a field named by a function nothing calls reads as painted, which is also how `model.minibuffer` shipped dead. So the guard is a harness assertion that loads the page and reads `#doc-name`, proved by writing it first (`element(s) not found`). The joining is `shell.js::documentTitle`, the TUI's own `name (stored_as) [+]` minus the chrome; the `[+]` is a node test, not a harness one, because this deployment persists on every accepted batch and clears the flag in the same round trip, so the marker is transient. UNPAINTED is now empty. improvise-0x9u. From an EDITING state a cell click did nothing at all: the mousedown moved focus, the input's blur ran `close`, `close` repainted, `replaceChildren` destroyed the `<td>` the mousedown landed on, and no `click` was ever dispatched. The old spec pressed Escape between the two clicks, so it only ever clicked from a non-editing page — this repo's characteristic defect. Worth recording: moving to `mousedown` is NECESSARY AND NOT SUFFICIENT, and the first attempt failed the same assertion with the same symptom. Removing a focused `<input>` fires `blur` SYNCHRONOUSLY, so `close` rebuilt the table from inside itself and detached the `<td>` again. `close` therefore no longer repaints, the `blur` arm checks `closed` first, and the handler `preventDefault()`s so no focus change happens at all — otherwise the default action focuses `#grid` after `input.focus()` and blurs the new editor. `close` restores the DRAWN text, since nothing repaints behind it any more. improvise-seik. `Client::viewport` was a wasm-bindgen door with no caller, kept for a reason `web/app.js` falsified in the same wave. It is one letter from `set_viewport` and fails SILENTLY — identical frame, forgotten window, every cursor key an upstream black hole. Deleted, along with the exported `init()` free function that duplicated `new Client()`. The guard is not the deletion: `wasm_surface.test.js` now reads the generated `.d.ts` and requires every declared method to be one `app.js` calls or to be named in `UNCALLED` with its reason. Proved by injecting a spare `static viewport(...)` into the built `.d.ts` and watching it fail by name. Tests: 1405 Rust (unchanged), 27 node + 4 bundle (was 26 + 3), 24 chromium harness (was 23). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
127 lines
5.0 KiB
JavaScript
127 lines
5.0 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.
|
|
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.
|
|
assert.deepEqual(declared.get("start"), [1, 2]);
|
|
});
|