Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes · 4 behind
What ships is what runs, for the shooter too: the committed app bundle boots under the stub page #97
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/43-what-ships-is-what-runs-for-the-shooter → main 1 commit fork
6 files
changed +407
−44
README.md +13 −2
| @@ -215,7 +215,17 @@ subtends — and the panel says which of the two you are looking at. | ||
| 215 | 215 | that world should do next — so a click on **play**, the two 64 Hz steps a 1/30 s frame is worth, |
| 216 | 216 | the buy overlay suppressing fire but not movement, a lost pointer, the end of |
| 217 | 217 | a match, one POST per round scored and a browser with no WebGL2 are all |
| 218 | checked with no browser in the room. `hud` draws onto a `Paint` — the | |
| 218 | checked with no browser in the room. `bundle.test.ts` then does the same to | |
| 219 | `static/app.<hash>.js`, the file a browser actually downloads: it installs the | |
| 220 | same stub page as the globals the shipped entry reads for itself and imports | |
| 221 | the committed bundle, so booting on the page's ids, a click on **play** that | |
| 222 | draws a frame through the canvas's own WebGL2 context, the name it writes to | |
| 223 | the store, the POST it makes, a page still parsing that is waited for and the | |
| 224 | **No WebGL2** sentence are all checked against the artifact rather than the | |
| 225 | sources. Finding that artifact and importing it afresh is | |
| 226 | `scripts/bundle.test-helper.ts`, one copy of the trick shared with the range's | |
| 227 | `bundle.test.ts`; neither rebuilds anything, because whether what is committed | |
| 228 | is what the sources produce now is `scripts/build.test.ts`'s rule. `hud` draws onto a `Paint` — the | |
| 219 | 229 | seventeen members of a 2D context it uses — which is how that stub can be a |
| 220 | 230 | recorder and the page can still hand it a canvas. `hud.test.ts` drives it |
| 221 | 231 | with a recorder of its own that writes down the brush as well as the call, so |
| @@ -283,7 +293,8 @@ subtends — and the panel says which of the two you are looking at. | ||
| 283 | 293 | for, so the room, one draw per standing plate and the tracer are counted — and |
| 284 | 294 | a whole run played with the mouse (turn onto the blip the fallback drew, click, |
| 285 | 295 | watch the score move) are all checked with no browser in the room. `bundle.test.ts` then does the same to |
| 286 | `static/range.<hash>.js`: the committed bundle is a build artifact kept in git, | |
| 296 | `static/range.<hash>.js` — through the same | |
| 297 | `scripts/bundle.test-helper.ts` the shooter's uses: the committed bundle is a build artifact kept in git, | |
| 287 | 298 | and this is what checks that the file a browser downloads boots, sizes its |
| 288 | 299 | canvases, draws its ticks, answers a key and — given WebGL2 — uploads its |
| 289 | 300 | atlas and draws the room — so a bundling mistake fails |
scripts/bundle.test-helper.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** | |
| 2 | * The committed bundle, found and imported. Both games boot their minified file | |
| 3 | * in a test — `src/range/bundle.test.ts` and `src/client/bundle.test.ts` — and | |
| 4 | * this is the step the two share: which file in static/ is this game's bundle, | |
| 5 | * and how to import it more than once when the entry boots itself on the way in. | |
| 6 | * | |
| 7 | * It builds nothing. Whether what is committed is what the sources produce now | |
| 8 | * is `scripts/build.test.ts`'s question, and a test that rebuilt the file first | |
| 9 | * would prove nothing about the one a browser downloads. Everything here only | |
| 10 | * ever reads static/ and copies out of it. | |
| 11 | * | |
| 12 | * It ships in no bundle: only `.test.ts` files import it, which is why | |
| 13 | * `src/independence.test.ts` lets a range test reach for it and a range module | |
| 14 | * not. | |
| 15 | */ | |
| 16 | ||
| 17 | import { copyFileSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; | |
| 18 | import { tmpdir } from "node:os"; | |
| 19 | import { join } from "node:path"; | |
| 20 | import { pathToFileURL } from "node:url"; | |
| 21 | import { isBundleOf, STATIC_DIR } from "./build.ts"; | |
| 22 | ||
| 23 | /** | |
| 24 | * The one `<name>.<hash>.js` in `dir`, or a sentence saying what to do about it: | |
| 25 | * none means nobody committed the build, and two means a stale one was left | |
| 26 | * behind and the test would have no way to say which it booted. | |
| 27 | */ | |
| 28 | export function bundlePath(name: string, dir = STATIC_DIR): string { | |
| 29 | const files = readdirSync(dir).filter((file) => isBundleOf(name, file)); | |
| 30 | if (files.length !== 1) | |
| 31 | throw new Error( | |
| 32 | `expected exactly one ${name}.<hash>.js in ${dir}, found ${files.length}` + | |
| 33 | `${files.length === 0 ? " — run \`bun run build\` and commit the bundle" : `: ${files.join(", ")}`}`, | |
| 34 | ); | |
| 35 | return join(dir, files[0] as string); | |
| 36 | } | |
| 37 | ||
| 38 | // An import is cached per specifier, so an entry would boot itself once and | |
| 39 | // every later boot would come out of the module cache. Two defences: a | |
| 40 | // cache-busting query, and — because Bun keys that cache by path and ignores | |
| 41 | // the query — a copy of the committed bytes at a path of its own each time. | |
| 42 | // The copies go to a throwaway directory, never into the repository. | |
| 43 | let copies: string | null = null; | |
| 44 | let loads = 0; | |
| 45 | ||
| 46 | /** | |
| 47 | * Import the committed bundle of `name` as if for the first time: the entry | |
| 48 | * runs, which for both games means it looks for a page and boots on it. Install | |
| 49 | * the stub page *before* awaiting this. | |
| 50 | */ | |
| 51 | export async function importBundle(name: string): Promise<unknown> { | |
| 52 | if (copies === null) copies = mkdtempSync(join(tmpdir(), "bundle-")); | |
| 53 | const load = (loads += 1); | |
| 54 | const copy = join(copies, `${name}.${load}.js`); | |
| 55 | copyFileSync(bundlePath(name), copy); | |
| 56 | return import(`${pathToFileURL(copy).href}?load=${load}`); | |
| 57 | } | |
| 58 | ||
| 59 | /** Throw the copies away. A test file calls this in `afterAll`; the next one remakes them. */ | |
| 60 | export function discardBundleCopies(): void { | |
| 61 | if (copies !== null) rmSync(copies, { recursive: true, force: true }); | |
| 62 | copies = null; | |
| 63 | } |
src/client/bundle.test.ts added +184
| @@ -0,0 +1,184 @@ | ||
| 1 | /** | |
| 2 | * The committed bundle, booted. `main.test.ts` proves the shooter's *sources* | |
| 3 | * run against the stub page in `stage.test-helper.ts`; this proves that | |
| 4 | * `static/app.<hash>.js` does, which is the file a browser actually downloads. A | |
| 5 | * bundling mistake — a mangled name the entry needed, a tree shake that took the | |
| 6 | * boot line with it, a half-written file — fails here rather than in the preview. | |
| 7 | * | |
| 8 | * Nothing under `src/client/` is imported except the stub page, which ships in | |
| 9 | * no bundle: the thing under test is the artifact, found on disk by name by | |
| 10 | * `scripts/bundle.test-helper.ts` — the same finder `src/range/bundle.test.ts` | |
| 11 | * uses. Nothing here rebuilds it; whether what is committed is what the sources | |
| 12 | * produce now is `scripts/build.test.ts`'s rule, and a rebuild would make this | |
| 13 | * test a test of the sources again. | |
| 14 | * | |
| 15 | * A minified file exposes nothing to poke, so every assertion below is about | |
| 16 | * what it did to the stub page: which ids it asked for, what it drew, what it | |
| 17 | * posted, what it wrote to the store, and the sentence it leaves in the menu on | |
| 18 | * a browser with no WebGL2. | |
| 19 | */ | |
| 20 | ||
| 21 | import { afterAll, expect, test } from "bun:test"; | |
| 22 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 23 | import { tmpdir } from "node:os"; | |
| 24 | import { join } from "node:path"; | |
| 25 | import { bundlePath, discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts"; | |
| 26 | import { fakeStage, type FakeStage } from "./stage.test-helper.ts"; | |
| 27 | ||
| 28 | afterAll(discardBundleCopies); | |
| 29 | ||
| 30 | /** Whether this process had a `document` before any of this ran. It does not. */ | |
| 31 | const HAD_DOCUMENT = "document" in globalThis; | |
| 32 | ||
| 33 | /** Bun's own `fetch`, held onto so the last test can see it handed back. */ | |
| 34 | const REAL_FETCH = globalThis.fetch; | |
| 35 | ||
| 36 | const FRAME = 1000 / 30; | |
| 37 | ||
| 38 | /** Every id the shell renders that the entry has to find to run a match. */ | |
| 39 | const NEEDED = ["scene", "hud", "fps", "touch", "play", "name", "length", "side", "sens"]; | |
| 40 | ||
| 41 | type Booted = { | |
| 42 | stage: FakeStage; | |
| 43 | /** Every id the bundle asked the document for, in the order it asked. */ | |
| 44 | asked: string[]; | |
| 45 | undo: () => void; | |
| 46 | }; | |
| 47 | ||
| 48 | /** | |
| 49 | * Install a stub page, import the committed bundle into it — the entry boots | |
| 50 | * itself on the way in, because the page says it is not still loading — and hand | |
| 51 | * back the page, the ids it looked up, and the undo that puts the globals back. | |
| 52 | * `loading: true` is the other page: one still being parsed, which the entry | |
| 53 | * has to wait for. | |
| 54 | */ | |
| 55 | async function booted( | |
| 56 | options: { webgl?: boolean; coarse?: boolean; loading?: boolean } = {}, | |
| 57 | ): Promise<Booted> { | |
| 58 | const stage = fakeStage(options); | |
| 59 | if (options.loading === true) stage.document.readyState = "loading"; | |
| 60 | const asked: string[] = []; | |
| 61 | const find = stage.document.getElementById; | |
| 62 | stage.document.getElementById = (id) => { | |
| 63 | asked.push(id); | |
| 64 | return find(id); | |
| 65 | }; | |
| 66 | const undo = stage.install(); | |
| 67 | try { | |
| 68 | await importBundle("app"); | |
| 69 | } catch (error) { | |
| 70 | undo(); | |
| 71 | throw error; | |
| 72 | } | |
| 73 | return { stage, asked, undo }; | |
| 74 | } | |
| 75 | ||
| 76 | /** Every GL call the scene canvas was given, and the draws among them. */ | |
| 77 | const glCalls = (stage: FakeStage): [string, ...unknown[]][] => stage.scene.gl?.calls ?? []; | |
| 78 | const drawn = (stage: FakeStage): [string, ...unknown[]][] => | |
| 79 | glCalls(stage).filter((call) => call[0] === "drawElements"); | |
| 80 | ||
| 81 | test("the committed bundle boots the page, and a click on play plays a match", async () => { | |
| 82 | const { stage, asked, undo } = await booted({ coarse: true }); | |
| 83 | try { | |
| 84 | // Importing it was enough: it found the page, read the store and asked for | |
| 85 | // a frame, with the menu still up and nothing drawn. | |
| 86 | expect(stage.frames.length).toBe(1); | |
| 87 | expect(stage.by("menu").hidden).toBe(false); | |
| 88 | expect(drawn(stage).length).toBe(0); | |
| 89 | // Its own atlas, uploaded once by the minified file: nine 128² layers. | |
| 90 | expect(glCalls(stage).find((call) => call[0] === "texImage3D")?.slice(4, 7)).toEqual([128, 128, 9]); | |
| 91 | // The touch buttons the shell renders were bound too, every one of them. | |
| 92 | expect(stage.buttons.every((button) => button.listening.size > 0)).toBe(true); | |
| 93 | ||
| 94 | stage.by("name").value = "Vesper"; | |
| 95 | stage.by("side").value = "t"; | |
| 96 | stage.by("length").value = "short"; | |
| 97 | stage.by("sens").value = "4"; | |
| 98 | stage.by("play").fire("click"); | |
| 99 | ||
| 100 | // The menu went, the pointer was asked for, the phone's pad came up — this | |
| 101 | // page says its pointer is coarse — and the name went to `localStorage`. | |
| 102 | expect(stage.by("menu").hidden).toBe(true); | |
| 103 | expect(stage.scene.locks).toBe(1); | |
| 104 | expect(stage.by("touch").hidden).toBe(false); | |
| 105 | expect(stage.store.get("cs.name")).toBe("Vesper"); | |
| 106 | ||
| 107 | // One frame: the room and the players drawn through the canvas's own WebGL2 | |
| 108 | // context, and the HUD painted onto the recording `Paint` beside it. | |
| 109 | stage.hold("Tab", true); | |
| 110 | stage.step(FRAME); | |
| 111 | expect(drawn(stage).length).toBeGreaterThan(1); | |
| 112 | expect(stage.hud.paint.calls[0]?.[0]).toBe("clearRect"); | |
| 113 | expect([stage.hud.width, stage.hud.height]).toEqual([800, 450]); | |
| 114 | expect(stage.by("fps").textContent).toContain("ms"); | |
| 115 | // Drawn text, so minification has nothing to hide behind — and the side and | |
| 116 | // the name typed into the menu are what the scoreboard reads back. | |
| 117 | expect(stage.hud.paint.texts).toContain("$800"); | |
| 118 | expect(stage.hud.paint.texts).toContain("T Vesper (you)"); | |
| 119 | ||
| 120 | // Every id the page carries for this game was looked up by name. | |
| 121 | for (const id of NEEDED) expect(asked).toContain(id); | |
| 122 | ||
| 123 | // The leaderboard was posted to the page's own `fetch`, which is this stub: | |
| 124 | // no network was reached for and nothing was written anywhere. | |
| 125 | expect(stage.posts.map((post) => post.url)).toEqual(["/api/match"]); | |
| 126 | expect(JSON.parse(stage.posts[0]?.body ?? "{}").name).toBe("Vesper"); | |
| 127 | } finally { | |
| 128 | undo(); | |
| 129 | } | |
| 130 | }); | |
| 131 | ||
| 132 | test("a bundled page with no WebGL2 is a sentence in the menu, not an exception", async () => { | |
| 133 | const { stage, undo } = await booted({ webgl: false }); | |
| 134 | try { | |
| 135 | // The same sentence `main.test.ts` pins, out of the minified file. | |
| 136 | expect(stage.by("menu").innerHTML).toContain("No WebGL2"); | |
| 137 | expect(stage.by("menu").hidden).toBe(false); | |
| 138 | // Nothing was started: no frame was asked for and no listener attached. | |
| 139 | expect(stage.frames.length).toBe(0); | |
| 140 | expect(stage.window.listening.size).toBe(0); | |
| 141 | expect(stage.document.listening.size).toBe(0); | |
| 142 | expect(stage.posts.length).toBe(0); | |
| 143 | } finally { | |
| 144 | undo(); | |
| 145 | } | |
| 146 | }); | |
| 147 | ||
| 148 | test("a page still being parsed is waited for, not booted on", async () => { | |
| 149 | const { stage, undo } = await booted({ loading: true }); | |
| 150 | try { | |
| 151 | // The bundle is in the page's head: the ids it wants are not there yet, so | |
| 152 | // the entry asks to be told when they are and nothing runs until then. | |
| 153 | expect(stage.frames.length).toBe(0); | |
| 154 | expect(stage.document.listening.has("DOMContentLoaded")).toBe(true); | |
| 155 | expect(stage.by("fps").textContent).toBe(""); | |
| 156 | stage.document.fire("DOMContentLoaded"); | |
| 157 | expect(stage.frames.length).toBe(1); | |
| 158 | expect(stage.by("menu").hidden).toBe(false); | |
| 159 | } finally { | |
| 160 | undo(); | |
| 161 | } | |
| 162 | }); | |
| 163 | ||
| 164 | test("a bundle that is missing or doubled is a sentence saying what to do about it", () => { | |
| 165 | const dir = mkdtempSync(join(tmpdir(), "bundle-finder-")); | |
| 166 | try { | |
| 167 | expect(() => bundlePath("app", dir)).toThrow(/found 0 — run `bun run build`/); | |
| 168 | writeFileSync(join(dir, "app.0123456789.js"), ""); | |
| 169 | expect(bundlePath("app", dir)).toBe(join(dir, "app.0123456789.js")); | |
| 170 | writeFileSync(join(dir, "app.abcdef0123.js"), ""); | |
| 171 | expect(() => bundlePath("app", dir)).toThrow(/found 2: /); | |
| 172 | } finally { | |
| 173 | rmSync(dir, { recursive: true, force: true }); | |
| 174 | } | |
| 175 | }); | |
| 176 | ||
| 177 | test("the stub page leaves nothing of itself behind", () => { | |
| 178 | // Every boot above undid its install, so no other test inherits a document — | |
| 179 | // nor a `fetch` that answers every URL with a leaderboard. | |
| 180 | expect("document" in globalThis).toBe(HAD_DOCUMENT); | |
| 181 | expect("window" in globalThis).toBe(false); | |
| 182 | expect("localStorage" in globalThis).toBe(false); | |
| 183 | expect(globalThis.fetch).toBe(REAL_FETCH); | |
| 184 | }); |
src/client/stage.test-helper.ts +126 −8
| @@ -13,7 +13,14 @@ | ||
| 13 | 13 | * recorders have exactly the members `boot` and `hud.ts` call, so a stub that |
| 14 | 14 | * has to be maintained is smaller than the frame it checks. |
| 15 | 15 | * |
| 16 | * It ships in no bundle: only `main.test.ts` imports it. `src/range/dom.ts` is | |
| 16 | * Two tests boot on it and they reach it from opposite sides: `main.test.ts` | |
| 17 | * calls `boot(stage.host)` and hands the page straight over, while | |
| 18 | * `bundle.test.ts` calls `install()` and lets the committed bundle find the | |
| 19 | * same objects as `document`, `window`, `localStorage` and `fetch` for itself. | |
| 20 | * That second way is why the scene canvas can answer `getContext("webgl2")` | |
| 21 | * with a `glRecorder`: the shipped entry builds its own renderer. | |
| 22 | * | |
| 23 | * It ships in no bundle: only those two import it. `src/range/dom.ts` is | |
| 17 | 24 | * the same idea for the other game — it cannot import this one, because |
| 18 | 25 | * `src/independence.test.ts` forbids either game from reaching into the other, |
| 19 | 26 | * but a third game copies whichever is nearer and adds its own ids. |
| @@ -144,26 +151,84 @@ export function recorder(): Recorder { | ||
| 144 | 151 | } as Recorder; |
| 145 | 152 | } |
| 146 | 153 | |
| 154 | /** | |
| 155 | * Every GL call the engine's renderer makes, written down in order, and every | |
| 156 | * enum it names — numbered, so a recorded call reads back. It is the same trick | |
| 157 | * the `Recorder` above plays for 2D: not a fake GPU, only a list of what was | |
| 158 | * asked for. | |
| 159 | * | |
| 160 | * `main.test.ts` never sees one, because `boot(host)` is handed a renderer that | |
| 161 | * keeps the scene it was given. `bundle.test.ts` does: the committed bundle | |
| 162 | * builds its own renderer out of the canvas it finds, so that canvas has to | |
| 163 | * answer `getContext("webgl2")` with something. | |
| 164 | */ | |
| 165 | export type GlRecorder = Record<string, unknown> & { calls: [string, ...unknown[]][] }; | |
| 166 | ||
| 167 | const GL_ENUMS = [ | |
| 168 | "ARRAY_BUFFER", "BACK", "COLOR_BUFFER_BIT", "COMPILE_STATUS", "CULL_FACE", "DEPTH_BUFFER_BIT", | |
| 169 | "DEPTH_TEST", "DYNAMIC_DRAW", "ELEMENT_ARRAY_BUFFER", "FLOAT", "FRAGMENT_SHADER", "LINEAR", | |
| 170 | "LINEAR_MIPMAP_LINEAR", "LINES", "LINK_STATUS", "REPEAT", "RGBA", "RGBA8", "STATIC_DRAW", | |
| 171 | "TEXTURE0", "TEXTURE_2D_ARRAY", "TEXTURE_MAG_FILTER", "TEXTURE_MIN_FILTER", "TEXTURE_WRAP_S", | |
| 172 | "TEXTURE_WRAP_T", "TRIANGLES", "UNSIGNED_BYTE", "UNSIGNED_INT", "VERTEX_SHADER", | |
| 173 | ]; | |
| 174 | ||
| 175 | const GL_VOIDS = [ | |
| 176 | "shaderSource", "compileShader", "attachShader", "linkProgram", "bindTexture", "texImage3D", | |
| 177 | "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData", "bufferSubData", | |
| 178 | "enableVertexAttribArray", "vertexAttribPointer", "enable", "cullFace", "viewport", "clearColor", | |
| 179 | "clear", "useProgram", "activeTexture", "uniform1i", "uniform3f", "uniform3fv", | |
| 180 | "uniformMatrix4fv", "drawElements", "drawArrays", | |
| 181 | ]; | |
| 182 | ||
| 183 | export function glRecorder(): GlRecorder { | |
| 184 | const calls: [string, ...unknown[]][] = []; | |
| 185 | const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]); | |
| 186 | let made = 0; | |
| 187 | const hand = (kind: string) => (...args: unknown[]): { kind: string; id: number } => { | |
| 188 | calls.push([kind === "shader" ? "createShader" : `create${kind}`, ...args]); | |
| 189 | return { kind, id: (made += 1) }; | |
| 190 | }; | |
| 191 | return { | |
| 192 | calls, | |
| 193 | ...Object.fromEntries(GL_ENUMS.map((name, i) => [name, i + 1])), | |
| 194 | ...Object.fromEntries(GL_VOIDS.map((name) => [name, note(name)])), | |
| 195 | createShader: hand("shader"), | |
| 196 | createProgram: hand("Program"), | |
| 197 | createTexture: hand("Texture"), | |
| 198 | createBuffer: hand("Buffer"), | |
| 199 | createVertexArray: hand("VertexArray"), | |
| 200 | getUniformLocation: (_program: unknown, name: string) => ({ kind: name, id: 0 }), | |
| 201 | getShaderParameter: () => true, | |
| 202 | getProgramParameter: () => true, | |
| 203 | getShaderInfoLog: () => "", | |
| 204 | getProgramInfoLog: () => "", | |
| 205 | }; | |
| 206 | } | |
| 207 | ||
| 147 | 208 | export type FakeCanvas = FakeElement & { |
| 148 | 209 | clientWidth: number; |
| 149 | 210 | clientHeight: number; |
| 150 | 211 | width: number; |
| 151 | 212 | height: number; |
| 152 | 213 | paint: Recorder; |
| 214 | /** The WebGL2 context this canvas hands out, or null for a browser with none. */ | |
| 215 | gl: GlRecorder | null; | |
| 153 | 216 | /** How many times the game asked for the pointer. */ |
| 154 | 217 | locks: number; |
| 155 | getContext(kind: string): Recorder; | |
| 218 | getContext(kind: string): Recorder | GlRecorder | null; | |
| 156 | 219 | requestPointerLock(): void; |
| 157 | 220 | }; |
| 158 | 221 | |
| 159 | export function canvas(id: string): FakeCanvas { | |
| 222 | export function canvas(id: string, gl: GlRecorder | null = null): FakeCanvas { | |
| 160 | 223 | return { |
| 161 | 224 | ...element({ id, tag: "canvas" }), |
| 162 | 225 | clientWidth: 800, clientHeight: 450, width: 0, height: 0, |
| 163 | 226 | paint: recorder(), |
| 227 | gl, | |
| 164 | 228 | locks: 0, |
| 165 | getContext() { | |
| 166 | return this.paint; | |
| 229 | getContext(kind) { | |
| 230 | if (kind === "webgl2") return this.gl; | |
| 231 | return kind === "2d" ? this.paint : null; | |
| 167 | 232 | }, |
| 168 | 233 | requestPointerLock() { |
| 169 | 234 | this.locks += 1; |
| @@ -177,10 +242,25 @@ export type Post = { url: string; method: string; headers: Record<string, string | ||
| 177 | 242 | /** One draw call the frame made, with the scene it was given. */ |
| 178 | 243 | export type Drawn = { scene: Scene; dt: number }; |
| 179 | 244 | |
| 245 | /** | |
| 246 | * The document, written out rather than left to the host's type, because a test | |
| 247 | * that installs this page as a global reads it back: which ids the entry asked | |
| 248 | * for, and — through `readyState` — whether it boots on import or waits for | |
| 249 | * `DOMContentLoaded`. | |
| 250 | */ | |
| 251 | export type FakeDocument = Fake & { | |
| 252 | readyState: string; | |
| 253 | pointerLockElement: unknown; | |
| 254 | getElementById(id: string): FakeElement | null; | |
| 255 | querySelectorAll(selector: string): FakeElement[]; | |
| 256 | createElement(tag: string): FakeElement; | |
| 257 | exitPointerLock(): void; | |
| 258 | }; | |
| 259 | ||
| 180 | 260 | export type FakeStage = { |
| 181 | 261 | /** What `boot` is handed. */ |
| 182 | 262 | host: Host; |
| 183 | document: Fake & { pointerLockElement: unknown }; | |
| 263 | document: FakeDocument; | |
| 184 | 264 | window: Fake & { devicePixelRatio: number }; |
| 185 | 265 | scene: FakeCanvas; |
| 186 | 266 | hud: FakeCanvas; |
| @@ -205,6 +285,15 @@ export type FakeStage = { | ||
| 205 | 285 | store: Map<string, string>; |
| 206 | 286 | /** The match ids handed out, newest last. */ |
| 207 | 287 | ids: string[]; |
| 288 | /** | |
| 289 | * Install this page as the globals the shipped entry reads for itself — | |
| 290 | * `document`, `window`, `localStorage` and `fetch` — and hand back the undo | |
| 291 | * that puts every one of them back. `main.test.ts` never needs it: it calls | |
| 292 | * `boot(stage.host)` and hands the page over directly. `bundle.test.ts` does, | |
| 293 | * because the committed bundle builds its own host out of those four names | |
| 294 | * and there is no seam to reach into. | |
| 295 | */ | |
| 296 | install(): () => void; | |
| 208 | 297 | }; |
| 209 | 298 | |
| 210 | 299 | /** |
| @@ -220,13 +309,24 @@ const HIDDEN = ["pause", "buy", "touch"]; | ||
| 220 | 309 | /** The `[data-action]` buttons the shell renders for this game. */ |
| 221 | 310 | export const PAGE_ACTIONS = ["duck", "jump", "use", "reload", "buy", "fire"]; |
| 222 | 311 | |
| 312 | /** | |
| 313 | * The browser globals the last lines of `main.ts` name, and so the whole of what | |
| 314 | * `install()` puts in front of the shipped entry. Nothing else is touched: | |
| 315 | * `crypto.randomUUID` is real, and no `AudioContext` is installed, which is a | |
| 316 | * browser that makes no sound rather than one that throws. | |
| 317 | */ | |
| 318 | const GLOBALS = ["document", "window", "localStorage", "fetch"]; | |
| 319 | ||
| 223 | 320 | /** |
| 224 | 321 | * The whole page the shooter's entry expects, plus the host around it. Pass |
| 225 | 322 | * `webgl: false` for a browser that cannot give the renderer a context, and |
| 226 | 323 | * `coarse: true` for a phone. |
| 227 | 324 | */ |
| 228 | 325 | export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): FakeStage { |
| 229 | const scene = canvas("scene"); | |
| 326 | // The same switch on both sides of the seam: `webgl: false` is a browser with | |
| 327 | // no context to give, whether the frame asks this page's `host` for a renderer | |
| 328 | // or — as the committed bundle does — asks the canvas itself. | |
| 329 | const scene = canvas("scene", options.webgl === false ? null : glRecorder()); | |
| 230 | 330 | const hud = canvas("hud"); |
| 231 | 331 | const buttons = PAGE_ACTIONS.map((action) => element({ action })); |
| 232 | 332 | const elements = new Map<string, FakeElement>(PAGE_IDS.map((id) => [id, element({ id })])); |
| @@ -242,8 +342,11 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): | ||
| 242 | 342 | let clock = 1000; |
| 243 | 343 | let uuid = 0; |
| 244 | 344 | |
| 245 | const document = { | |
| 345 | const document: FakeDocument = { | |
| 246 | 346 | ...fake(), |
| 347 | // The entry boots on import unless the page is still parsing; a test that | |
| 348 | // wants the other path sets this to "loading" before it installs the page. | |
| 349 | readyState: "complete", | |
| 247 | 350 | pointerLockElement: null as unknown, |
| 248 | 351 | getElementById: (id: string): FakeElement | null => elements.get(id) ?? null, |
| 249 | 352 | querySelectorAll: (_selector: string): FakeElement[] => buttons, |
| @@ -314,6 +417,21 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): | ||
| 314 | 417 | stub.hold(code, false); |
| 315 | 418 | }, |
| 316 | 419 | answer: () => Promise.resolve({ ok: true, text: () => Promise.resolve("<h2>Leaderboard</h2>") }), |
| 420 | install() { | |
| 421 | const globals = globalThis as Record<string, unknown>; | |
| 422 | // Saved one key at a time, and whether it was there at all: `fetch` is | |
| 423 | // Bun's own and the next test file wants it back exactly as it was. | |
| 424 | const was = GLOBALS.map((key) => ({ key, there: key in globals, value: globals[key] })); | |
| 425 | globals.document = document; | |
| 426 | globals.window = window; | |
| 427 | globals.localStorage = stub.host.storage; | |
| 428 | globals.fetch = (url: string, init: Parameters<Host["fetch"]>[1]) => stub.host.fetch(url, init); | |
| 429 | return () => { | |
| 430 | for (const { key, there, value } of was) | |
| 431 | if (there) globals[key] = value; | |
| 432 | else delete globals[key]; | |
| 433 | }; | |
| 434 | }, | |
| 317 | 435 | }; |
| 318 | 436 | return stub; |
| 319 | 437 | } |
src/independence.test.ts +14 −4
| @@ -42,7 +42,13 @@ const ROOT = resolve(SRC, ".."); | ||
| 42 | 42 | const RANGE = join(SRC, "range"); |
| 43 | 43 | const ENGINE = join(SRC, "engine"); |
| 44 | 44 | const SHELL = join(SRC, "pages", "shell.ts"); |
| 45 | const BUILD = resolve(ROOT, "scripts", "build.ts"); | |
| 45 | /** | |
| 46 | * The files under `scripts/` a range *test* may read: the build script, which | |
| 47 | * knows where the bundles are and what one is called, and the helper beside it | |
| 48 | * that finds a committed bundle and imports it. Both ship in no bundle and | |
| 49 | * neither is a game, so a test may reach for them; a module may not. | |
| 50 | */ | |
| 51 | const TOOLS = [resolve(ROOT, "scripts", "build.ts"), resolve(ROOT, "scripts", "bundle.test-helper.ts")]; | |
| 46 | 52 | |
| 47 | 53 | /** |
| 48 | 54 | * The directories that are a game's without being the game: today `src/rl/` is |
| @@ -196,10 +202,10 @@ export function leavesRange(file: string, specifier: string): string | null { | ||
| 196 | 202 | const fromSrc = relative(ENGINE, target); |
| 197 | 203 | if (fromSrc !== "" && !fromSrc.startsWith("..")) return null; |
| 198 | 204 | if (target === SHELL) return null; |
| 199 | // A test may ask the build script where the bundles are and what one is | |
| 205 | // A test may ask the build's tools where the bundles are and what one is | |
| 200 | 206 | // called — `bundle.test.ts` boots the committed artifact — because a |
| 201 | 207 | // `.test.ts` ships in no bundle. A module that ships may not. |
| 202 | if (isTest && target === BUILD) return null; | |
| 208 | if (isTest && TOOLS.includes(target)) return null; | |
| 203 | 209 | return `"${specifier}" resolves outside src/range/, src/engine/ and src/pages/shell.ts`; |
| 204 | 210 | } |
| 205 | 211 | |
| @@ -244,10 +250,14 @@ test("the engine, the shell and a sibling are what the range may reach for", () | ||
| 244 | 250 | expect(leavesRange("binding.test.ts", "bun:test")).toBeNull(); |
| 245 | 251 | expect(leavesRange("bundle.test.ts", "node:path")).toBeNull(); |
| 246 | 252 | expect(leavesRange("bundle.test.ts", "../../scripts/build.ts")).toBeNull(); |
| 253 | expect(leavesRange("bundle.test.ts", "../../scripts/bundle.test-helper.ts")).toBeNull(); | |
| 247 | 254 | // A test file's licence is its own: a module that ships may not reach for node, |
| 248 | // nor for the script that bundles it. | |
| 255 | // nor for the script that bundles it, nor for the helper that boots what it built. | |
| 249 | 256 | expect(leavesRange("main.ts", "node:path")).toMatch(/installs nothing/); |
| 250 | 257 | expect(leavesRange("main.ts", "../../scripts/build.ts")).toMatch(/outside/); |
| 258 | expect(leavesRange("main.ts", "../../scripts/bundle.test-helper.ts")).toMatch(/outside/); | |
| 259 | // And the licence is those two files, not the directory they are in. | |
| 260 | expect(leavesRange("bundle.test.ts", "../../scripts/train.ts")).toMatch(/outside/); | |
| 251 | 261 | }); |
| 252 | 262 | |
| 253 | 263 | test("a range file that reached into the shooter is caught by reading it", () => { |
src/range/bundle.test.ts +7 −30
| @@ -6,37 +6,17 @@ | ||
| 6 | 6 | * fails here rather than in the preview. |
| 7 | 7 | * |
| 8 | 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. | |
| 9 | * bundle: the thing under test is the artifact, found on disk by name. Finding | |
| 10 | * it and importing it afresh is `scripts/bundle.test-helper.ts`, shared with the | |
| 11 | * shooter's `src/client/bundle.test.ts` — the same file, the same trick, one | |
| 12 | * copy of it. | |
| 10 | 13 | */ |
| 11 | 14 | |
| 12 | 15 | 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"; | |
| 16 | import { discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts"; | |
| 18 | 17 | import { page, type FakePage } from "./dom.ts"; |
| 19 | 18 | |
| 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 })); | |
| 19 | afterAll(discardBundleCopies); | |
| 40 | 20 | |
| 41 | 21 | /** Whether this process had a `document` before any of this ran. It does not. */ |
| 42 | 22 | const HAD_DOCUMENT = "document" in globalThis; |
| @@ -49,13 +29,10 @@ const HAD_DOCUMENT = "document" in globalThis; | ||
| 49 | 29 | async function booted( |
| 50 | 30 | options: { has2d?: boolean; webgl?: boolean } = {}, |
| 51 | 31 | ): Promise<{ p: FakePage; undo: () => void }> { |
| 52 | const load = (loads += 1); | |
| 53 | const copy = join(copies, `range.${load}.js`); | |
| 54 | copyFileSync(bundlePath(), copy); | |
| 55 | 32 | const p = page(options); |
| 56 | 33 | const undo = p.install(); |
| 57 | 34 | try { |
| 58 | await import(`${pathToFileURL(copy).href}?load=${load}`); | |
| 35 | await importBundle("range"); | |
| 59 | 36 | } catch (error) { |
| 60 | 37 | undo(); |
| 61 | 38 | throw error; |
new above
offline