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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/26-the-engine-loads-without-a-browser-and-a → main 2 commits fork
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. | ||
| 70 | 70 | (hashed value noise, fbm and an atlas painter), `sound` (a lazy `AudioContext` |
| 71 | 71 | with noise bursts and tones), `loop` (a fixed step with a clamp on a long |
| 72 | 72 | 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. | |
| 73 | 76 | `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. | |
| 77 | 89 | - `src/engine/index.ts` — the door, and the rule is that a second game imports |
| 78 | 90 | `src/engine/index.ts` and nothing deeper; inside the engine, modules import |
| 79 | 91 | 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. | ||
| 101 | 113 | recipes for the engine's three asset tools, and `binding` is its key table and |
| 102 | 114 | the one function from a held action to an `Input`. |
| 103 | 115 | - `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 | |
| 106 | 118 | plumbing**: `binding.ts` is its own key table (50 Hz, its own actions — no buy, |
| 107 | 119 | no scoreboard, no weapon slots) and the one function from held actions to an |
| 108 | 120 | 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. | ||
| 112 | 124 | game, and #13 builds them on this. `independence.test.ts` reads every source in |
| 113 | 125 | here and fails on an import that leaves those three places, reusing the |
| 114 | 126 | `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, | |
| 116 | 129 | the intent and the drawing headlessly, the second with a stub that records every |
| 117 | 130 | 2D call; and `boot.test.ts` boots the entry itself against the stub page in |
| 118 | 131 | `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[] { | ||
| 80 | 80 | |
| 81 | 81 | /** |
| 82 | 82 | * 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. | |
| 84 | 88 | */ |
| 85 | 89 | export function reachesPastTheDoor(file: string, specifier: string): string | null { |
| 86 | 90 | if (!specifier.startsWith(".")) return null; |
| @@ -88,6 +92,7 @@ export function reachesPastTheDoor(file: string, specifier: string): string | nu | ||
| 88 | 92 | const inside = relative(ENGINE, target); |
| 89 | 93 | if (inside === "" || inside.startsWith("..")) return null; |
| 90 | 94 | if (inside === "index.ts") return null; |
| 95 | if (file.endsWith(".test.ts") && inside.endsWith(".test.ts")) return null; | |
| 91 | 96 | return `${file} imports "${specifier}"; the engine's door is src/engine/index.ts`; |
| 92 | 97 | } |
| 93 | 98 | |
| @@ -172,12 +177,20 @@ test("the door rule is about the door and nothing else", () => { | ||
| 172 | 177 | expect(reachesPastTheDoor("src/client/world.ts", "./physics.ts")).toBeNull(); |
| 173 | 178 | expect(reachesPastTheDoor("src/client/world.ts", "bun:test")).toBeNull(); |
| 174 | 179 | 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/); | |
| 175 | 184 | }); |
| 176 | 185 | |
| 177 | 186 | test("every module in the engine is behind the door, and the door holds nothing else", () => { |
| 178 | 187 | const door = readFileSync(join(ENGINE, "index.ts"), "utf8"); |
| 179 | 188 | 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 | ); | |
| 181 | 194 | expect(modules.length).toBeGreaterThan(0); |
| 182 | 195 | expect([...behind].sort()).toEqual(modules); |
| 183 | 196 | // 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 @@ | ||
| 8 | 8 | * everything a mouse does. Nothing in here imports the game. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { clamp } from "../engine/math.ts"; | |
| 11 | import { clamp } from "../engine/index.ts"; | |
| 12 | 12 | |
| 13 | 13 | /** `KeyboardEvent.code` to action. The range's own table, not cs's. */ |
| 14 | 14 | export const RANGE_KEYS: Record<string, string> = { |
src/range/independence.test.ts +5
| @@ -6,6 +6,11 @@ | ||
| 6 | 6 | * would notice in review — not `src/rl/`, and no package, because there is |
| 7 | 7 | * nothing to install. |
| 8 | 8 | * |
| 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 | * | |
| 9 | 14 | * It reads the sources with the same two helpers `src/engine/boundary.test.ts` |
| 10 | 15 | * uses, imported rather than copied: one regular expression for imports in this |
| 11 | 16 | * 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 @@ | ||
| 11 | 11 | |
| 12 | 12 | import { RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, heldActions, readIntent } from "./binding.ts"; |
| 13 | 13 | 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"; | |
| 17 | 15 | |
| 18 | 16 | /** The range's own step: 50 Hz, deliberately not the shooter's 64. */ |
| 19 | 17 | const TICK = 1 / 50; |
src/range/readout.ts +1 −1
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | * without a cast. It reads a `Readout` and draws; it changes nothing. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { angleDelta, clamp } from "../engine/math.ts"; | |
| 12 | import { angleDelta, clamp } from "../engine/index.ts"; | |
| 13 | 13 | |
| 14 | 14 | /** The 2D calls this module makes, and nothing else. */ |
| 15 | 15 | export type Paint = { |
static/range.06e0ed221e.js added +1
Diff not shown — a line 7,058 characters wide (limit 2,000). +1
View filestatic/range.2543880d32.js deleted −1
Diff not shown — a line 7,058 characters wide (limit 2,000). −1
View file
new above
offline