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

Merged opened this

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

Merged

vesper/cs-engine:vesper/43-what-ships-is-what-runs-for-the-shooter → main 1 commit fork

  • Merged. vesper/43-what-ships-is-what-runs-for-the-shooter was merged into main as 5ab54e3.

Review

Sign in to review this change.
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.
215215 that world should do next — so a click on **play**, the two 64 Hz steps a 1/30 s frame is worth,
216216 the buy overlay suppressing fire but not movement, a lost pointer, the end of
217217 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
219229 seventeen members of a 2D context it uses — which is how that stub can be a
220230 recorder and the page can still hand it a canvas. `hud.test.ts` drives it
221231 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.
283293 for, so the room, one draw per standing plate and the tracer are counted — and
284294 a whole run played with the mouse (turn onto the blip the fallback drew, click,
285295 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,
287298 and this is what checks that the file a browser downloads boots, sizes its
288299 canvases, draws its ticks, answers a key and — given WebGL2 — uploads its
289300 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 @@
1313 * recorders have exactly the members `boot` and `hud.ts` call, so a stub that
1414 * has to be maintained is smaller than the frame it checks.
1515 *
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
1724 * the same idea for the other game — it cannot import this one, because
1825 * `src/independence.test.ts` forbids either game from reaching into the other,
1926 * but a third game copies whichever is nearer and adds its own ids.
@@ -144,26 +151,84 @@ export function recorder(): Recorder {
144151 } as Recorder;
145152 }
146153
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
147208 export type FakeCanvas = FakeElement & {
148209 clientWidth: number;
149210 clientHeight: number;
150211 width: number;
151212 height: number;
152213 paint: Recorder;
214 /** The WebGL2 context this canvas hands out, or null for a browser with none. */
215 gl: GlRecorder | null;
153216 /** How many times the game asked for the pointer. */
154217 locks: number;
155 getContext(kind: string): Recorder;
218 getContext(kind: string): Recorder | GlRecorder | null;
156219 requestPointerLock(): void;
157220 };
158221
159 export function canvas(id: string): FakeCanvas {
222 export function canvas(id: string, gl: GlRecorder | null = null): FakeCanvas {
160223 return {
161224 ...element({ id, tag: "canvas" }),
162225 clientWidth: 800, clientHeight: 450, width: 0, height: 0,
163226 paint: recorder(),
227 gl,
164228 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;
167232 },
168233 requestPointerLock() {
169234 this.locks += 1;
@@ -177,10 +242,25 @@ export type Post = { url: string; method: string; headers: Record<string, string
177242 /** One draw call the frame made, with the scene it was given. */
178243 export type Drawn = { scene: Scene; dt: number };
179244
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
180260 export type FakeStage = {
181261 /** What `boot` is handed. */
182262 host: Host;
183 document: Fake & { pointerLockElement: unknown };
263 document: FakeDocument;
184264 window: Fake & { devicePixelRatio: number };
185265 scene: FakeCanvas;
186266 hud: FakeCanvas;
@@ -205,6 +285,15 @@ export type FakeStage = {
205285 store: Map<string, string>;
206286 /** The match ids handed out, newest last. */
207287 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;
208297 };
209298
210299 /**
@@ -220,13 +309,24 @@ const HIDDEN = ["pause", "buy", "touch"];
220309 /** The `[data-action]` buttons the shell renders for this game. */
221310 export const PAGE_ACTIONS = ["duck", "jump", "use", "reload", "buy", "fire"];
222311
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
223320 /**
224321 * The whole page the shooter's entry expects, plus the host around it. Pass
225322 * `webgl: false` for a browser that cannot give the renderer a context, and
226323 * `coarse: true` for a phone.
227324 */
228325 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());
230330 const hud = canvas("hud");
231331 const buttons = PAGE_ACTIONS.map((action) => element({ action }));
232332 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 } = {}):
242342 let clock = 1000;
243343 let uuid = 0;
244344
245 const document = {
345 const document: FakeDocument = {
246346 ...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",
247350 pointerLockElement: null as unknown,
248351 getElementById: (id: string): FakeElement | null => elements.get(id) ?? null,
249352 querySelectorAll: (_selector: string): FakeElement[] => buttons,
@@ -314,6 +417,21 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}):
314417 stub.hold(code, false);
315418 },
316419 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 },
317435 };
318436 return stub;
319437 }
src/independence.test.ts +14 −4
@@ -42,7 +42,13 @@ const ROOT = resolve(SRC, "..");
4242 const RANGE = join(SRC, "range");
4343 const ENGINE = join(SRC, "engine");
4444 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")];
4652
4753 /**
4854 * 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 {
196202 const fromSrc = relative(ENGINE, target);
197203 if (fromSrc !== "" && !fromSrc.startsWith("..")) return null;
198204 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
200206 // called — `bundle.test.ts` boots the committed artifact — because a
201207 // `.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;
203209 return `"${specifier}" resolves outside src/range/, src/engine/ and src/pages/shell.ts`;
204210 }
205211
@@ -244,10 +250,14 @@ test("the engine, the shell and a sibling are what the range may reach for", ()
244250 expect(leavesRange("binding.test.ts", "bun:test")).toBeNull();
245251 expect(leavesRange("bundle.test.ts", "node:path")).toBeNull();
246252 expect(leavesRange("bundle.test.ts", "../../scripts/build.ts")).toBeNull();
253 expect(leavesRange("bundle.test.ts", "../../scripts/bundle.test-helper.ts")).toBeNull();
247254 // 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.
249256 expect(leavesRange("main.ts", "node:path")).toMatch(/installs nothing/);
250257 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/);
251261 });
252262
253263 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 @@
66 * fails here rather than in the preview.
77 *
88 * 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.
1013 */
1114
1215 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";
1817 import { page, type FakePage } from "./dom.ts";
1918
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);
4020
4121 /** Whether this process had a `document` before any of this ran. It does not. */
4222 const HAD_DOCUMENT = "document" in globalThis;
@@ -49,13 +29,10 @@ const HAD_DOCUMENT = "document" in globalThis;
4929 async function booted(
5030 options: { has2d?: boolean; webgl?: boolean } = {},
5131 ): Promise<{ p: FakePage; undo: () => void }> {
52 const load = (loads += 1);
53 const copy = join(copies, `range.${load}.js`);
54 copyFileSync(bundlePath(), copy);
5532 const p = page(options);
5633 const undo = p.install();
5734 try {
58 await import(`${pathToFileURL(copy).href}?load=${load}`);
35 await importBundle("range");
5936 } catch (error) {
6037 undo();
6138 throw error;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs