Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

The engine loads without a browser, and a test holds it to that #30

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs-engine:vesper/26-the-engine-loads-without-a-browser-and-a → main 2 commits fork

  • Merged. vesper/26-the-engine-loads-without-a-browser-and-a was merged into main as 5b6f94f.

Review

Sign in to review this change.
10 files changed +288 −14
README.md +19 −6
@@ -70,10 +70,22 @@ page shows is the loop and the controls themselves, as numbers that move.
7070 (hashed value noise, fbm and an atlas painter), `sound` (a lazy `AudioContext`
7171 with noise bursts and tones), `loop` (a fixed step with a clamp on a long
7272 frame) and `controls` (a bindings table in, held actions out).
73 Three rules keep it a package, and a test holds each: the engine imports
74 nothing outside itself, it needs nothing from a browser to load, and
75 everything outside it comes in through one door.
7376 `boundary.test.ts` reads the imports of every file in here and fails if one of
74 them points at `src/client/`, at `src/rl/` or at a package. The last two take
75 their clock, their canvas and their window as parameters, so the tests drive
76 them with a stub instead of a browser.
77 them points at `src/client/`, at `src/rl/` or at a package; `purity.test.ts`
78 imports every module that ships in a second process where `window`,
79 `document`, `navigator`, `AudioContext`, `webkitAudioContext`,
80 `requestAnimationFrame` and `localStorage` are getters that record the access,
81 and fails if a module throws or reads one while it loads — coping with a
82 missing browser at load time fails it too, because a module that asks is a
83 module a bench or a server-rendered page cannot import. (The second process is
84 `purity.probe.ts`: a dynamic import is cached per process, so the first load
85 of each module has to happen somewhere the engine's own tests have not been.)
86 The last two take their clock, their canvas and their window as parameters, so
87 the tests drive them with a stub instead of a browser, and `sound` opens its
88 `AudioContext` on the first sound rather than on load.
7789 - `src/engine/index.ts` — the door, and the rule is that a second game imports
7890 `src/engine/index.ts` and nothing deeper; inside the engine, modules import
7991 each other directly. The surface is re-exports and a docstring per module,
@@ -101,8 +113,8 @@ page shows is the loop and the controls themselves, as numbers that move.
101113 recipes for the engine's three asset tools, and `binding` is its key table and
102114 the one function from a held action to an `Input`.
103115 - `src/range/` — the second game, at `/range`, and the proof that the engine is a
104 package: a module in here may import `src/engine/`, `src/pages/shell.ts` and its
105 own siblings, and nothing else. **Today it is the plumbing and only the
116 package: a module in here may import the engine through its door, `src/pages/shell.ts`
117 and its own siblings, and nothing else. **Today it is the plumbing and only the
106118 plumbing**: `binding.ts` is its own key table (50 Hz, its own actions — no buy,
107119 no scoreboard, no weapon slots) and the one function from held actions to an
108120 intent, `readout.ts` draws a horizon and a panel of numbers with plain 2D calls,
@@ -112,7 +124,8 @@ page shows is the loop and the controls themselves, as numbers that move.
112124 game, and #13 builds them on this. `independence.test.ts` reads every source in
113125 here and fails on an import that leaves those three places, reusing the
114126 `sources`/`specifiersOf` helpers from `src/engine/boundary.test.ts` rather than a
115 second copy of the rule; `binding.test.ts` and `readout.test.ts` pin the table,
127 second copy of the rule — a test may read another test's helpers, which is the
128 one path past the door, because a `.test.ts` is in no bundle; `binding.test.ts` and `readout.test.ts` pin the table,
116129 the intent and the drawing headlessly, the second with a stub that records every
117130 2D call; and `boot.test.ts` boots the entry itself against the stub page in
118131 `dom.ts` — a document of plain objects and a clock the test moves — so a key, a
src/engine/boundary.test.ts +15 −2
@@ -80,7 +80,11 @@ export function outsideSources(dirs = OUTSIDE): string[] {
8080
8181 /**
8282 * Why this file outside the engine may not import this specifier, or null if it
83 * may. Only one path into the engine is a path: `index.ts`, the door.
83 * may. Only one path into the engine is a path: `index.ts`, the door. The one
84 * exception is a test reading another test's helpers — `src/range/` checks its
85 * own boundary with the walk and the regular expression in this file rather
86 * than a second copy of them — because the door is about what ships, and a
87 * `.test.ts` is in no bundle.
8488 */
8589 export function reachesPastTheDoor(file: string, specifier: string): string | null {
8690 if (!specifier.startsWith(".")) return null;
@@ -88,6 +92,7 @@ export function reachesPastTheDoor(file: string, specifier: string): string | nu
8892 const inside = relative(ENGINE, target);
8993 if (inside === "" || inside.startsWith("..")) return null;
9094 if (inside === "index.ts") return null;
95 if (file.endsWith(".test.ts") && inside.endsWith(".test.ts")) return null;
9196 return `${file} imports "${specifier}"; the engine's door is src/engine/index.ts`;
9297 }
9398
@@ -172,12 +177,20 @@ test("the door rule is about the door and nothing else", () => {
172177 expect(reachesPastTheDoor("src/client/world.ts", "./physics.ts")).toBeNull();
173178 expect(reachesPastTheDoor("src/client/world.ts", "bun:test")).toBeNull();
174179 expect(reachesPastTheDoor("scripts/build.ts", "../src/engine/trace.ts")).toMatch(/door/);
180 // A test may read another test's helpers; a module may not, test or not.
181 expect(reachesPastTheDoor("src/range/independence.test.ts", "../engine/boundary.test.ts")).toBeNull();
182 expect(reachesPastTheDoor("src/range/main.ts", "../engine/boundary.test.ts")).toMatch(/door/);
183 expect(reachesPastTheDoor("src/range/independence.test.ts", "../engine/math.ts")).toMatch(/door/);
175184 });
176185
177186 test("every module in the engine is behind the door, and the door holds nothing else", () => {
178187 const door = readFileSync(join(ENGINE, "index.ts"), "utf8");
179188 const behind = new Set(specifiersOf(door).map((s) => s.replace(/^\.\//, "")));
180 const modules = sources().filter((f) => !f.endsWith(".test.ts") && f !== "index.ts");
189 // A `.probe.ts` is tooling the tests spawn, not a module that ships, so the
190 // door neither offers it nor should: `purity.test.ts` draws the same line.
191 const modules = sources().filter(
192 (f) => !f.endsWith(".test.ts") && !f.endsWith(".probe.ts") && f !== "index.ts",
193 );
181194 expect(modules.length).toBeGreaterThan(0);
182195 expect([...behind].sort()).toEqual(modules);
183196 // Nothing of its own: the door is re-exports, a docstring and nothing else.
src/engine/purity.probe.ts added +129
@@ -0,0 +1,129 @@
1 /**
2 * The child half of `purity.test.ts`. It runs in a process where nothing has
3 * imported the engine yet — which is the whole point, because a dynamic
4 * `import()` is cached per process and the engine's own tests have already
5 * loaded every module by the time the runner reaches the purity test, so a
6 * second import in there would execute nothing and prove nothing.
7 *
8 * Before it imports anything it installs `window`, `document`, `navigator`,
9 * `AudioContext`, `webkitAudioContext`, `requestAnimationFrame` and
10 * `localStorage` as getters that record the access and answer `undefined`: a
11 * module that reads one while it loads is recorded whether it throws or copes,
12 * because coping is the failure this exists to catch. Then it imports each
13 * module it was given, one at a time, restores the globals whatever happened,
14 * and writes one JSON line of what it saw.
15 *
16 * It takes no arguments: the list of modules arrives on stdin, every name is
17 * checked against `NAME` before it is imported, and the parent asserts the
18 * report names back exactly the files it sent. Nothing here is imported by
19 * anything, and nothing here is exported: importing this file runs it, and it
20 * waits on stdin. `.probe.ts`, like `.test.ts`, is tooling and ships nowhere.
21 */
22
23 /** Every global a browser has and a test, a bench or a server does not. */
24 const WATCHED = [
25 "window",
26 "document",
27 "navigator",
28 "AudioContext",
29 "webkitAudioContext",
30 "requestAnimationFrame",
31 "localStorage",
32 ] as const;
33
34 /** A module name is a relative path of plain segments ending in `.ts`, and nothing else. */
35 const NAME = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*\.ts$/;
36
37 /** One read or write of a watched global, and what was being imported at the time. */
38 type Touch = { global: string; kind: "read" | "write"; during: string };
39
40 type Report = {
41 /** The modules the probe actually imported, in the order it imported them. */
42 modules: string[];
43 touches: Touch[];
44 failures: { module: string; error: string }[];
45 /** Touches the probe caused on purpose, to prove the recorder is alive. */
46 canary: Touch[];
47 /** Globals the undo did not put back the way it found them. Empty is the answer. */
48 unrestored: string[];
49 };
50
51 const CANARY = "(the probe's own canary)";
52
53 const touches: Touch[] = [];
54 let during = "(before any import)";
55
56 const before = new Map<string, PropertyDescriptor | undefined>(
57 WATCHED.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]),
58 );
59
60 /** Names whose descriptor is not the one `before` remembers. */
61 const unrestored = (): string[] =>
62 WATCHED.filter((name) => {
63 const was = before.get(name);
64 const now = Object.getOwnPropertyDescriptor(globalThis, name);
65 if (was === undefined || now === undefined) return was !== now;
66 return was.value !== now.value || was.get !== now.get || was.set !== now.set;
67 });
68
69 /** Install the traps, and hand back the undo. */
70 function trap(): () => void {
71 const undo: (() => void)[] = [];
72 for (const name of WATCHED) {
73 const had = before.get(name);
74 Object.defineProperty(globalThis, name, {
75 configurable: true,
76 get: () => {
77 touches.push({ global: name, kind: "read", during });
78 return undefined;
79 },
80 set: () => {
81 touches.push({ global: name, kind: "write", during });
82 },
83 });
84 undo.push(() => {
85 if (had === undefined) delete (globalThis as Record<string, unknown>)[name];
86 else Object.defineProperty(globalThis, name, had);
87 });
88 }
89 return () => {
90 for (const step of undo) step();
91 };
92 }
93
94 const requested: unknown = JSON.parse(await Bun.stdin.text());
95 if (!Array.isArray(requested) || requested.length === 0)
96 throw new Error("the probe wants a non-empty JSON array of module names on stdin");
97 const modules = requested.map((name) => {
98 if (typeof name !== "string" || !NAME.test(name) || name.split("/").includes(".."))
99 throw new Error(`not a module name: ${JSON.stringify(name)}`);
100 return name;
101 });
102
103 const failures: Report["failures"] = [];
104 const untrap = trap();
105 try {
106 for (const module of modules) {
107 during = module;
108 try {
109 await import(`./${module}`);
110 } catch (error) {
111 failures.push({ module, error: error instanceof Error ? error.message : String(error) });
112 }
113 }
114 // One read on purpose. Without it a trap that silently failed to install
115 // would report a clean run, which is the one answer this test must not give.
116 during = CANARY;
117 void (globalThis as { AudioContext?: unknown }).AudioContext;
118 } finally {
119 untrap();
120 }
121
122 const report: Report = {
123 modules,
124 touches: touches.filter((touch) => touch.during !== CANARY),
125 failures,
126 canary: touches.filter((touch) => touch.during === CANARY),
127 unrestored: unrestored(),
128 };
129 console.log(JSON.stringify(report));
src/engine/purity.test.ts added +116
@@ -0,0 +1,116 @@
1 /**
2 * The second half of what makes `src/engine/` a package: it imports nothing
3 * outside itself — `boundary.test.ts` reads that out of the sources — and it
4 * needs nothing from a browser to load, which is this file. A module that
5 * reaches for `window` or opens an `AudioContext` while it is being evaluated
6 * cannot be imported by a test, a bench or a server-rendered page, and today
7 * both of the engine's browser edges avoid that only by convention:
8 * `sound.ts` opens its context on the first sound and `controls.ts` takes its
9 * window as a parameter. Convention is one line away from being false.
10 *
11 * So every module that ships is imported in a process where those globals are
12 * absent, installed as getters that record the access. The work happens in
13 * `purity.probe.ts` because a dynamic `import()` is cached per process and the
14 * engine's own tests have already loaded every module by the time the runner
15 * gets here: a second import in this process would execute nothing.
16 *
17 * The list of modules comes from `sources()` in `boundary.test.ts` — the same
18 * walk, so a module added tomorrow is covered the day it lands — and crosses to
19 * the child on stdin. The spawn itself is a constant: the bun that is running
20 * this test, and the probe beside it. Importing `boundary.test.ts` registers its
21 * tests here a second time, which costs a millisecond and is cheaper than a
22 * second copy of the walk.
23 */
24
25 import { expect, test } from "bun:test";
26 import { join } from "node:path";
27 import { sources } from "./boundary.test.ts";
28
29 const ENGINE = import.meta.dir;
30 const PROBE = join(ENGINE, "purity.probe.ts");
31
32 type Touch = { global: string; kind: string; during: string };
33 type Report = {
34 modules: string[];
35 touches: Touch[];
36 failures: { module: string; error: string }[];
37 canary: Touch[];
38 unrestored: string[];
39 };
40
41 /**
42 * Every engine module that ships: not a test, and not the probe or anything
43 * else named `.probe.ts`, which is tooling that runs rather than code that
44 * loads.
45 */
46 const shipped = (): string[] =>
47 sources().filter((file) => !file.endsWith(".test.ts") && !file.endsWith(".probe.ts"));
48
49 /** Run the probe over these modules in a fresh process and read its report. */
50 async function probe(modules: string[]): Promise<Report> {
51 const child = Bun.spawn([process.execPath, "run", PROBE], {
52 stdin: new Blob([JSON.stringify(modules)]),
53 stdout: "pipe",
54 stderr: "pipe",
55 });
56 const [out, err, code] = await Promise.all([
57 new Response(child.stdout).text(),
58 new Response(child.stderr).text(),
59 child.exited,
60 ]);
61 if (code !== 0) throw new Error(`the probe exited ${code}:\n${err}${out}`);
62 const line = out.trim().split("\n").at(-1) ?? "";
63 try {
64 return JSON.parse(line) as Report;
65 } catch {
66 throw new Error(`the probe said something that is not a report:\n${out}${err}`);
67 }
68 }
69
70 // One run, read by several tests: the child costs a process, and the point of
71 // the rule is the first load of each module.
72 const report = await probe(shipped());
73
74 test("the modules under test are the engine's own, the tooling aside", () => {
75 const files = shipped();
76 expect(files).toContain("sound.ts");
77 expect(files).toContain("controls.ts");
78 expect(files).toContain("loop.ts");
79 expect(files).not.toContain("boundary.test.ts");
80 expect(files).not.toContain("purity.probe.ts");
81 // Every module the walk found was actually handed to the probe and reported on.
82 expect(report.modules).toEqual(files);
83 });
84
85 test("every engine module loads with no browser in the process", () => {
86 expect(report.failures).toEqual([]);
87 });
88
89 test("no engine module reads a browser global while it loads", () => {
90 const seen = report.touches.map((t) => `${t.during}: ${t.kind} ${t.global}`);
91 expect(seen).toEqual([]);
92 });
93
94 test("the recorder is alive: the read the probe makes on purpose is recorded", () => {
95 // Without this the two tests above would pass just as happily against a trap
96 // that failed to install, which is the one way this file could lie.
97 expect(report.canary).toEqual([
98 { global: "AudioContext", kind: "read", during: "(the probe's own canary)" },
99 ]);
100 });
101
102 test("the globals go back the way the probe found them, whatever happened", () => {
103 // In the child it costs nothing to get this wrong, so the child checks it:
104 // the same undo is what would keep the rest of a run honest if this ever
105 // stopped needing a process of its own.
106 expect(report.unrestored).toEqual([]);
107 // And this process never had them installed in the first place.
108 expect(typeof (globalThis as { AudioContext?: unknown }).AudioContext).toBe("undefined");
109 expect(Object.getOwnPropertyDescriptor(globalThis, "window")).toBeUndefined();
110 });
111
112 test("the probe refuses a module name that is not one", async () => {
113 for (const bad of [["../server.ts"], ["sound.js"], ["/etc/passwd"], [42], []]) {
114 await expect(probe(bad as string[])).rejects.toThrow();
115 }
116 });
src/range/binding.ts +1 −1
@@ -8,7 +8,7 @@
88 * everything a mouse does. Nothing in here imports the game.
99 */
1010
11 import { clamp } from "../engine/math.ts";
11 import { clamp } from "../engine/index.ts";
1212
1313 /** `KeyboardEvent.code` to action. The range's own table, not cs's. */
1414 export const RANGE_KEYS: Record<string, string> = {
src/range/independence.test.ts +5
@@ -6,6 +6,11 @@
66 * would notice in review — not `src/rl/`, and no package, because there is
77 * nothing to install.
88 *
9 * Which module of the engine is a second question, and `boundary.test.ts`
10 * answers it: the way in is `src/engine/index.ts`, the door, and these files go
11 * through it. The rule below is the wider one — where an import may point — so
12 * it still passes a deeper path; nothing in here writes one.
13 *
914 * It reads the sources with the same two helpers `src/engine/boundary.test.ts`
1015 * uses, imported rather than copied: one regular expression for imports in this
1116 * repository, not two that can drift apart. The price is that the engine's four
src/range/main.ts +1 −3
@@ -11,9 +11,7 @@
1111
1212 import { RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, heldActions, readIntent } from "./binding.ts";
1313 import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts";
14 import { attachButtons, createControls } from "../engine/controls.ts";
15 import { createLoop } from "../engine/loop.ts";
16 import { clamp } from "../engine/math.ts";
14 import { attachButtons, clamp, createControls, createLoop } from "../engine/index.ts";
1715
1816 /** The range's own step: 50 Hz, deliberately not the shooter's 64. */
1917 const TICK = 1 / 50;
src/range/readout.ts +1 −1
@@ -9,7 +9,7 @@
99 * without a cast. It reads a `Readout` and draws; it changes nothing.
1010 */
1111
12 import { angleDelta, clamp } from "../engine/math.ts";
12 import { angleDelta, clamp } from "../engine/index.ts";
1313
1414 /** The 2D calls this module makes, and nothing else. */
1515 export type Paint = {
static/range.06e0ed221e.js added +1

Diff not shown — a line 7,058 characters wide (limit 2,000). +1

View file
static/range.2543880d32.js deleted −1

Diff not shown — a line 7,058 characters wide (limit 2,000). −1

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs