Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
What ships is what runs: the committed range bundle boots under the stub page #36
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/35-what-ships-is-what-runs-the-committed-ra → main 1 commit fork
3 files
changed +134
−2
README.md +5 −1
| @@ -147,7 +147,11 @@ page shows is the loop and the controls themselves, as numbers that move. | ||
| 147 | 147 | 2D call; and `boot.test.ts` boots the entry itself against the stub page in |
| 148 | 148 | `dom.ts` — a document of plain objects and a clock the test moves — so a key, a |
| 149 | 149 | button, a thumb stick, a locked mouse and a canvas that gives no context are all |
| 150 | checked with no browser in the room. `src/pages/range.ts` is its page: its own title, prose and overlay | |
| 150 | checked with no browser in the room. `bundle.test.ts` then does the same to | |
| 151 | `static/range.<hash>.js`: the committed bundle is a build artifact kept in git, | |
| 152 | and this is what checks that the file a browser downloads boots, sizes its | |
| 153 | canvases, draws its ticks and answers a key — so a bundling mistake fails | |
| 154 | `bun test` rather than the preview. `src/pages/range.ts` is its page: its own title, prose and overlay | |
| 151 | 155 | through the same shell, with no database, no endpoint and no cookie behind it. |
| 152 | 156 | - `src/rl/` — training, which the game never imports: `arena` is the environment |
| 153 | 157 | (a real duel on de_forge with the round and the economy taken out) and `ppo` is |
src/range/bundle.test.ts added +120
| @@ -0,0 +1,120 @@ | ||
| 1 | /** | |
| 2 | * The committed bundle, booted. `boot.test.ts` proves the range's *sources* run | |
| 3 | * against the stub page in `dom.ts`; this proves the minified file in `static/` | |
| 4 | * does, which is the file a browser actually downloads. A bundling mistake — a | |
| 5 | * mangled name the entry needed, a tree shake that took the boot line with it — | |
| 6 | * fails here rather than in the preview. | |
| 7 | * | |
| 8 | * Nothing under `src/range/` is imported except `dom.ts`, which ships in no | |
| 9 | * bundle: the thing under test is the artifact, found on disk by name. | |
| 10 | */ | |
| 11 | ||
| 12 | import { afterAll, expect, test } from "bun:test"; | |
| 13 | import { copyFileSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; | |
| 14 | import { tmpdir } from "node:os"; | |
| 15 | import { join } from "node:path"; | |
| 16 | import { pathToFileURL } from "node:url"; | |
| 17 | import { isBundleOf, STATIC_DIR } from "../../scripts/build.ts"; | |
| 18 | import { page, type FakePage } from "./dom.ts"; | |
| 19 | ||
| 20 | /** The one `range.<hash>.js` in static/, or a sentence saying what to do about it. */ | |
| 21 | function bundlePath(): string { | |
| 22 | const files = readdirSync(STATIC_DIR).filter((file) => isBundleOf("range", file)); | |
| 23 | if (files.length !== 1) | |
| 24 | throw new Error( | |
| 25 | `expected exactly one range.<hash>.js in ${STATIC_DIR}, found ${files.length}` + | |
| 26 | `${files.length === 0 ? " — run \`bun run build\` and commit the bundle" : `: ${files.join(", ")}`}`, | |
| 27 | ); | |
| 28 | return join(STATIC_DIR, files[0] as string); | |
| 29 | } | |
| 30 | ||
| 31 | // An import is cached per specifier, so the entry would boot itself once and | |
| 32 | // every later boot would come out of the module cache. Two defences: a | |
| 33 | // cache-busting query, and — because Bun keys that cache by path and ignores | |
| 34 | // the query — a copy of the committed bytes at a path of its own each time. | |
| 35 | // The copies go to a throwaway directory, never into the repository. | |
| 36 | const copies = mkdtempSync(join(tmpdir(), "range-bundle-")); | |
| 37 | let loads = 0; | |
| 38 | ||
| 39 | afterAll(() => rmSync(copies, { recursive: true, force: true })); | |
| 40 | ||
| 41 | /** Whether this process had a `document` before any of this ran. It does not. */ | |
| 42 | const HAD_DOCUMENT = "document" in globalThis; | |
| 43 | ||
| 44 | /** | |
| 45 | * Install a stub page, import the committed bundle into it — the entry boots | |
| 46 | * itself on the way in — and hand back the page with the undo that puts the | |
| 47 | * globals back. | |
| 48 | */ | |
| 49 | async function booted(options: { has2d?: boolean } = {}): Promise<{ p: FakePage; undo: () => void }> { | |
| 50 | const load = (loads += 1); | |
| 51 | const copy = join(copies, `range.${load}.js`); | |
| 52 | copyFileSync(bundlePath(), copy); | |
| 53 | const p = page(options); | |
| 54 | const undo = p.install(); | |
| 55 | try { | |
| 56 | await import(`${pathToFileURL(copy).href}?load=${load}`); | |
| 57 | } catch (error) { | |
| 58 | undo(); | |
| 59 | throw error; | |
| 60 | } | |
| 61 | return { p, undo }; | |
| 62 | } | |
| 63 | ||
| 64 | const hudTexts = (p: FakePage): string[] => p.hud.paint?.texts ?? []; | |
| 65 | ||
| 66 | test("the committed bundle boots the page and draws what the loop produced", async () => { | |
| 67 | const { p, undo } = await booted(); | |
| 68 | try { | |
| 69 | // Importing it was enough: it found the page and asked for a frame. | |
| 70 | expect(p.frames.length).toBe(1); | |
| 71 | p.step(20); // 20 ms at a 50 Hz step is exactly one tick | |
| 72 | // Both canvases were sized to their box, at this page's 1× ratio. | |
| 73 | expect([p.scene.width, p.scene.height]).toEqual([800, 450]); | |
| 74 | expect([p.hud.width, p.hud.height]).toEqual([800, 450]); | |
| 75 | // Drawn text, so minification has nothing to hide behind. | |
| 76 | expect(hudTexts(p)).toContain("tick 1 · 50 Hz fixed step"); | |
| 77 | expect(hudTexts(p)).toContain("yaw 0.0° · pitch 0.0°"); | |
| 78 | expect(p.by("fps").textContent).toContain("ms"); | |
| 79 | // The clock is the test's: another frame is another tick, and the loop asked | |
| 80 | // for the next one before this one ran. | |
| 81 | expect(p.frames.length).toBe(1); | |
| 82 | p.step(20); | |
| 83 | expect(hudTexts(p)).toContain("tick 2 · 50 Hz fixed step"); | |
| 84 | } finally { | |
| 85 | undo(); | |
| 86 | } | |
| 87 | }); | |
| 88 | ||
| 89 | test("a key down on a bound key changes what the bundled panel says", async () => { | |
| 90 | const { p, undo } = await booted(); | |
| 91 | try { | |
| 92 | p.step(20); | |
| 93 | expect(hudTexts(p)).toContain("held nothing"); | |
| 94 | p.window.fire("keydown", { code: "KeyW" }); | |
| 95 | p.step(20); | |
| 96 | expect(hudTexts(p)).toContain("held forward"); | |
| 97 | p.window.fire("keyup", { code: "KeyW" }); | |
| 98 | p.step(20); | |
| 99 | expect(hudTexts(p)).toContain("held nothing"); | |
| 100 | } finally { | |
| 101 | undo(); | |
| 102 | } | |
| 103 | }); | |
| 104 | ||
| 105 | test("a bundled canvas with no context is a sentence on the page, not an exception", async () => { | |
| 106 | const { p, undo } = await booted({ has2d: false }); | |
| 107 | try { | |
| 108 | expect(p.by("note").innerHTML).toContain("No 2D canvas"); | |
| 109 | expect(p.by("note").hidden).toBe(false); | |
| 110 | expect(p.frames.length).toBe(0); | |
| 111 | expect(p.window.listening.size).toBe(0); | |
| 112 | } finally { | |
| 113 | undo(); | |
| 114 | } | |
| 115 | }); | |
| 116 | ||
| 117 | test("the stub page leaves nothing of itself behind", () => { | |
| 118 | // Every boot above undid its install, so no other test inherits a document. | |
| 119 | expect("document" in globalThis).toBe(HAD_DOCUMENT); | |
| 120 | }); |
src/range/independence.test.ts +9 −1
| @@ -27,6 +27,7 @@ const RANGE = import.meta.dir; | ||
| 27 | 27 | const SRC = resolve(RANGE, ".."); |
| 28 | 28 | const ENGINE = join(SRC, "engine"); |
| 29 | 29 | const SHELL = join(SRC, "pages", "shell.ts"); |
| 30 | const BUILD = resolve(SRC, "..", "scripts", "build.ts"); | |
| 30 | 31 | |
| 31 | 32 | /** `sources` names what it finds relative to the engine; these want their own names. */ |
| 32 | 33 | const rangeSources = (): string[] => sources(RANGE).map((p) => relative(RANGE, resolve(ENGINE, p))); |
| @@ -44,6 +45,10 @@ export function leavesRange(file: string, specifier: string): string | null { | ||
| 44 | 45 | const fromSrc = relative(ENGINE, target); |
| 45 | 46 | if (fromSrc !== "" && !fromSrc.startsWith("..")) return null; |
| 46 | 47 | if (target === SHELL) return null; |
| 48 | // A test may ask the build script where the bundles are and what one is | |
| 49 | // called — `bundle.test.ts` boots the committed artifact — because a | |
| 50 | // `.test.ts` ships in no bundle. A module that ships may not. | |
| 51 | if (isTest && target === BUILD) return null; | |
| 47 | 52 | return `"${specifier}" resolves outside src/range/, src/engine/ and src/pages/shell.ts`; |
| 48 | 53 | } |
| 49 | 54 | |
| @@ -90,8 +95,11 @@ test("the engine, the shell and a sibling are what it may reach for", () => { | ||
| 90 | 95 | expect(leavesRange("main.ts", "../pages/shell.ts")).toBeNull(); |
| 91 | 96 | expect(leavesRange("binding.test.ts", "bun:test")).toBeNull(); |
| 92 | 97 | expect(leavesRange("independence.test.ts", "node:path")).toBeNull(); |
| 93 | // A test file's licence is its own: a module that ships may not reach for node. | |
| 98 | expect(leavesRange("bundle.test.ts", "../../scripts/build.ts")).toBeNull(); | |
| 99 | // A test file's licence is its own: a module that ships may not reach for node, | |
| 100 | // nor for the script that bundles it. | |
| 94 | 101 | expect(leavesRange("main.ts", "node:path")).toMatch(/installs nothing/); |
| 102 | expect(leavesRange("main.ts", "../../scripts/build.ts")).toMatch(/outside/); | |
| 95 | 103 | }); |
| 96 | 104 | |
| 97 | 105 | test("a file that reached into the shooter is caught by reading it", () => { |
new above
offline