@moku-labs/game 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +89 -21
  2. package/dist/assets.d.mts +58 -6
  3. package/dist/assets.mjs +1630 -234
  4. package/dist/commands-BOxqlv7w.d.mts +99 -0
  5. package/dist/control.d.mts +3 -92
  6. package/dist/control.mjs +3 -757
  7. package/dist/define-BMo0pHe_.d.mts +51 -0
  8. package/dist/{registry-DlpRCibU.mjs → dev-CcwFrwok.mjs} +30 -1
  9. package/dist/index.d.mts +217 -29
  10. package/dist/index.mjs +6179 -727
  11. package/dist/inspect.d.mts +10 -8
  12. package/dist/inspect.mjs +2 -518
  13. package/dist/jsx-dev-runtime.d.mts +1 -1
  14. package/dist/jsx-runtime.d.mts +1 -1
  15. package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-8H4AJuhK.mjs} +1 -0
  16. package/dist/run-tlcn1w7y.mjs +944 -0
  17. package/dist/{runtime-DiOTkDZz.d.mts → runtime-BstIkrhH.d.mts} +1 -1
  18. package/dist/sources-BooL2MsX.mjs +545 -0
  19. package/dist/testing.d.mts +305 -3
  20. package/dist/testing.mjs +2056 -2
  21. package/dist/types-BTycq8_Z.d.mts +1196 -0
  22. package/dist/{types-yg_ywtT-.d.mts → types-C0SLQsx0.d.mts} +230 -31
  23. package/dist/types-Cjf3ywai.d.mts +5982 -0
  24. package/dist/{define-sFoO3y6X.d.mts → types-D0uYTTC3.d.mts} +180 -51
  25. package/dist/{types-DWILGrPn.d.mts → types-DgwbzDLP.d.mts} +87 -19
  26. package/dist/types-KlE_GL1z.d.mts +1444 -0
  27. package/dist/{visual-BDUHSRvf.mjs → visual-NPrDkvUk.mjs} +19 -13
  28. package/package.json +17 -2
  29. package/dist/headless-CvamCUcR.mjs +0 -186
  30. package/dist/headless-KaSWcd0s.d.mts +0 -181
  31. package/dist/types-BxkNNYul.d.mts +0 -2840
  32. package/dist/types-CTPS9GBu.d.mts +0 -742
  33. package/dist/types-DD-QrG_z.d.mts +0 -588
  34. package/dist/types-DYLnSgMI.d.mts +0 -570
  35. package/dist/types-JNc_UQBo.d.mts +0 -2896
package/dist/testing.mjs CHANGED
@@ -1,5 +1,9 @@
1
1
  import { n as saveOf, t as memory } from "./memory-CvgdnsQO.mjs";
2
- import { i as stepFrames, r as runRepro, t as createHeadless } from "./headless-CvamCUcR.mjs";
2
+ import { a as stepFrames, i as runRepro, n as commands, r as createHeadless, t as run } from "./run-tlcn1w7y.mjs";
3
+ import { n as read, t as sources } from "./sources-BooL2MsX.mjs";
4
+ import { createBrandConsole } from "@moku-labs/common/cli";
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import path from "node:path";
3
7
  //#region src/plugins/clock/fake.ts
4
8
  /**
5
9
  * Removes and returns the timer that is due first, up to and including `until`.
@@ -86,4 +90,2054 @@ function fakeClock(start = 0) {
86
90
  };
87
91
  }
88
92
  //#endregion
89
- export { createHeadless, fakeClock, memory, runRepro, saveOf, stepFrames };
93
+ //#region src/visual/steps.ts
94
+ /**
95
+ * @file visual — the steps of a visual test: each one is checked at definition and run through
96
+ * the `/control` catalogue, by its short name, with `run`, so the dev guard, the journal of a
97
+ * cheat and the envelope are the ones an editor gets. The step names come from the catalogue: a
98
+ * command it gains is a step with no change here.
99
+ */
100
+ /** The catalogue seen as step runners. */
101
+ const stepCommands = commands;
102
+ /**
103
+ * Tells the three commands of the catalogue that are not steps: `bookmark` only reads, `capture`
104
+ * is what the runner does by itself, and `debug` drawing would enter a baseline.
105
+ *
106
+ * @param name - A short name of the catalogue.
107
+ * @returns True for `bookmark`, `capture` and `debug`.
108
+ * @example
109
+ * ```ts
110
+ * isLeftOut("capture"); // true
111
+ * ```
112
+ */
113
+ function isLeftOut(name) {
114
+ return name === "bookmark" || name === "capture" || name === "debug";
115
+ }
116
+ /**
117
+ * Tells whether a name is a step command: in the catalogue and not one of the three left out.
118
+ *
119
+ * @param name - The key of a step.
120
+ * @returns True for `answer`, `tap`, `walk` and the other step commands.
121
+ * @example
122
+ * ```ts
123
+ * isStepCommand("tap"); // true
124
+ * isStepCommand("capture"); // false
125
+ * ```
126
+ */
127
+ function isStepCommand(name) {
128
+ return Object.hasOwn(commands, name) && !isLeftOut(name);
129
+ }
130
+ /**
131
+ * Tells whether the value of a command step is an input object.
132
+ *
133
+ * @param value - The value under the command's name.
134
+ * @returns True for an object that is neither `null` nor an array.
135
+ * @example
136
+ * ```ts
137
+ * isInputObject({ key: "play" }); // true
138
+ * ```
139
+ */
140
+ function isInputObject(value) {
141
+ return typeof value === "object" && value !== null && !Array.isArray(value);
142
+ }
143
+ /**
144
+ * Names the step commands, for a message: the catalogue's order minus the three left out.
145
+ *
146
+ * @returns `answer, tap, drag, ...`.
147
+ */
148
+ function stepNames() {
149
+ return Object.keys(commands).filter((name) => isStepCommand(name)).join(", ");
150
+ }
151
+ /**
152
+ * Takes one step apart and checks its shape: exactly one key, either `checkpoint` or a step
153
+ * command with an input object. The checkpoint name is checked by the caller.
154
+ *
155
+ * @param test - The test name, for the message.
156
+ * @param step - The step.
157
+ * @param index - Its index in the steps, from 0.
158
+ * @returns The checkpoint or the command with its input.
159
+ * @throws {Error} When the step holds no key or more than one, names something that is not a
160
+ * step, gives a command no input object, or a checkpoint no string.
161
+ * @example
162
+ * ```ts
163
+ * stepEntry("reward-popup", { tap: { key: "play" } }, 0); // { kind: "command", name: "tap", input: { key: "play" } }
164
+ * ```
165
+ */
166
+ function stepEntry(test, step, index) {
167
+ const fields = step;
168
+ const [name, ...others] = Object.keys(fields);
169
+ const at = `[game] Visual test "${test}", step ${index + 1}`;
170
+ if (name === void 0 || others.length > 0) throw new Error(`${at} is not one command.\n Write one command by its short name, or { checkpoint: "name" }.`);
171
+ const value = fields[name];
172
+ if (name === "checkpoint") {
173
+ if (typeof value === "string") return {
174
+ kind: "checkpoint",
175
+ name: value
176
+ };
177
+ throw new Error(`${at}: the checkpoint name is not a string.\n Write the checkpoint as { checkpoint: "name" }.`);
178
+ }
179
+ if (!isStepCommand(name)) throw new Error(`${at} names "${name}", which is not a step.\n Use a checkpoint or one of: ${stepNames()}.`);
180
+ if (!isInputObject(value)) throw new Error(`${at} (${name}) has no input object.\n Pass the input of the command, like { tap: { key: "play" } }.`);
181
+ return {
182
+ kind: "command",
183
+ name,
184
+ input: value
185
+ };
186
+ }
187
+ /**
188
+ * Runs a command step through `run`, the way an editor does.
189
+ *
190
+ * @param app - The running app.
191
+ * @param name - The short name of the command.
192
+ * @param input - Its input.
193
+ * @returns What `run` resolves with.
194
+ * @throws {Error} Whatever the command throws.
195
+ */
196
+ function runStep(app, name, input) {
197
+ return run(app, stepCommands[name], input);
198
+ }
199
+ //#endregion
200
+ //#region src/visual/define.ts
201
+ /**
202
+ * @file visual — `defineVisualTest`: a name, where the test starts and its steps, checked and
203
+ * frozen. Nothing runs at definition.
204
+ */
205
+ /** A file name: lowercase letters and digits, words joined by single dashes. */
206
+ const fileName = /^[\da-z]+(?:-[\da-z]+)*$/u;
207
+ /** The second line of every file name message. */
208
+ const FILE_NAME_RULE = "Use lowercase letters, digits and dashes.";
209
+ /**
210
+ * Freezes a value and everything it holds.
211
+ *
212
+ * @param value - A copy nobody else holds.
213
+ * @returns The same value, frozen all the way down.
214
+ * @example
215
+ * ```ts
216
+ * Object.isFrozen(freezeDeep({ steps: [{ checkpoint: "open" }] }).steps[0]); // true
217
+ * ```
218
+ */
219
+ function freezeDeep(value) {
220
+ if (typeof value === "object" && value !== null) {
221
+ for (const child of Object.values(value)) freezeDeep(child);
222
+ Object.freeze(value);
223
+ }
224
+ return value;
225
+ }
226
+ /**
227
+ * Checks the steps: each holds one command or one checkpoint, and every checkpoint name is a
228
+ * file name used once in the test.
229
+ *
230
+ * @param test - The test name.
231
+ * @param steps - The steps.
232
+ * @throws {Error} When a step is not one command, or a checkpoint name is not a file name or
233
+ * is used twice.
234
+ */
235
+ function checkSteps(test, steps) {
236
+ const checkpoints = [];
237
+ for (const [index, step] of steps.entries()) {
238
+ const entry = stepEntry(test, step, index);
239
+ if (entry.kind === "command") continue;
240
+ const at = `[game] Visual test "${test}", step ${index + 1}`;
241
+ if (!fileName.test(entry.name)) throw new Error(`${at}: the checkpoint name "${entry.name}" is not a file name.\n ${FILE_NAME_RULE}`);
242
+ if (checkpoints.includes(entry.name)) throw new Error(`${at}: the checkpoint "${entry.name}" is already a step.\n Give every checkpoint of a test its own name.`);
243
+ checkpoints.push(entry.name);
244
+ }
245
+ }
246
+ /**
247
+ * Checks a visual test: its name and its checkpoint names are file names, no checkpoint name is
248
+ * used twice, and every step is one step command. `defineVisualTest` checks at definition, and
249
+ * the runner checks again, so a descriptor written by hand cannot reach outside its folder.
250
+ *
251
+ * @param name - The test name.
252
+ * @param steps - The steps.
253
+ * @throws {Error} When a name is not a file name, a checkpoint name is used twice, or a step is
254
+ * not one step command.
255
+ */
256
+ function checkVisualTest(name, steps) {
257
+ if (!fileName.test(name)) throw new Error(`[game] The visual test name "${name}" is not a file name.\n ${FILE_NAME_RULE}`);
258
+ checkSteps(name, steps);
259
+ }
260
+ /**
261
+ * Defines a visual test: where it starts, what `game.restore` loads, and its steps. A step is a
262
+ * `/control` command by its short name with that command's input, or `{ checkpoint: name }`,
263
+ * where the runner saves `state.json` and `describe.json` next to the test. `walk` runs fast: a
264
+ * popup its last node opens never mounts, so the step before a checkpoint that shows a node's
265
+ * result is `answer` or `tap`. The answer is a frozen copy; nothing runs here.
266
+ *
267
+ * @param name - The test name, also its folder: lowercase letters, digits and dashes.
268
+ * @param test - Where it starts and its steps.
269
+ * @param test.start - The player, and optionally the session, the rng and the checkpoint.
270
+ * @param test.steps - The commands and the checkpoints, in order.
271
+ * @returns The test, frozen.
272
+ * @throws {Error} When the name or a checkpoint name is not a file name, a checkpoint name is
273
+ * used twice, or a step is not one step command.
274
+ * @example
275
+ * ```ts
276
+ * // tests/visual/reward-popup.visual.ts: Play, deliver the first order, claim the reward.
277
+ * export const rewardPopup = defineVisualTest("reward-popup", {
278
+ * start: { player: fixtures.ready, checkpoint: "home" },
279
+ * steps: [{ tap: { key: "play" } }, { answer: { intent: "deliver", payload: { orderId: "o1" } } },
280
+ * { checkpoint: "open" }, { tap: { key: "claim" } }, { checkpoint: "claimed" }]
281
+ * });
282
+ * ```
283
+ */
284
+ function defineVisualTest(name, test) {
285
+ checkVisualTest(name, test.steps);
286
+ return freezeDeep(structuredClone({
287
+ name,
288
+ start: test.start,
289
+ steps: test.steps
290
+ }));
291
+ }
292
+ //#endregion
293
+ //#region src/visual/compare.ts
294
+ /** The path `compareJson` names when the two values differ at the top. */
295
+ const ROOT = "(root)";
296
+ /** A key that can follow a dot in a path: an identifier. */
297
+ const identifier = /^[A-Za-z_$][\w$]*$/u;
298
+ /**
299
+ * Tells a JSON object from the other JSON values.
300
+ *
301
+ * @param value - A JSON value.
302
+ * @returns True for an object that is neither `null` nor an array.
303
+ * @example
304
+ * ```ts
305
+ * isJsonObject({ coins: 3 }); // true
306
+ * ```
307
+ */
308
+ function isJsonObject(value) {
309
+ return typeof value === "object" && value !== null && !Array.isArray(value);
310
+ }
311
+ /**
312
+ * Adds a key to a path: after a dot when it is an identifier, in brackets otherwise.
313
+ *
314
+ * @param path - The path so far, empty at the top.
315
+ * @param key - The key.
316
+ * @returns The longer path.
317
+ * @example
318
+ * ```ts
319
+ * keyPath("views", "board.items"); // 'views["board.items"]'
320
+ * ```
321
+ */
322
+ function keyPath(path, key) {
323
+ if (!identifier.test(key)) return `${path}[${JSON.stringify(key)}]`;
324
+ return path === "" ? key : `${path}.${key}`;
325
+ }
326
+ /**
327
+ * Finds the first difference of two arrays, element by element: past the shorter array, the
328
+ * missing element is the difference.
329
+ *
330
+ * @param expected - The array of the baseline.
331
+ * @param actual - The array read now.
332
+ * @param path - The path of the two arrays.
333
+ * @returns The path of the first difference, or `undefined` when they are equal.
334
+ */
335
+ function compareArrays(expected, actual, path) {
336
+ const length = Math.max(expected.length, actual.length);
337
+ for (let index = 0; index < length; index += 1) {
338
+ const found = compareAt(expected[index], actual[index], `${path}[${index}]`);
339
+ if (found !== void 0) return found;
340
+ }
341
+ }
342
+ /**
343
+ * Finds the first difference of two objects, key by key in sorted order: a key only one side
344
+ * has, or a value that differs.
345
+ *
346
+ * @param expected - The object of the baseline.
347
+ * @param actual - The object read now.
348
+ * @param path - The path of the two objects.
349
+ * @returns The path of the first difference, or `undefined` when they are equal.
350
+ */
351
+ function compareObjects(expected, actual, path) {
352
+ const keys = [.../* @__PURE__ */ new Set([...Object.keys(expected), ...Object.keys(actual)])].toSorted();
353
+ for (const key of keys) {
354
+ const found = compareAt(expected[key], actual[key], keyPath(path, key));
355
+ if (found !== void 0) return found;
356
+ }
357
+ }
358
+ /**
359
+ * Finds the first difference of two JSON values at a path. `undefined` stands for a key or an
360
+ * element only the other side has.
361
+ *
362
+ * @param expected - The value of the baseline.
363
+ * @param actual - The value read now.
364
+ * @param path - The path of the two values, empty at the top.
365
+ * @returns The path of the first difference, or `undefined` when they are equal.
366
+ */
367
+ function compareAt(expected, actual, path) {
368
+ const here = path === "" ? ROOT : path;
369
+ if (expected === void 0 || actual === void 0) return here;
370
+ if (Array.isArray(expected) || Array.isArray(actual)) {
371
+ if (!Array.isArray(expected) || !Array.isArray(actual)) return here;
372
+ return compareArrays(expected, actual, path);
373
+ }
374
+ if (isJsonObject(expected) || isJsonObject(actual)) {
375
+ if (!isJsonObject(expected) || !isJsonObject(actual)) return here;
376
+ return compareObjects(expected, actual, path);
377
+ }
378
+ return expected === actual ? void 0 : here;
379
+ }
380
+ /**
381
+ * Compares two JSON values exactly and names the first difference: keys in sorted order, the
382
+ * way the baseline file lists them, and array elements in order. A key only one side has, a
383
+ * changed type and a changed value are each a difference.
384
+ *
385
+ * @param expected - The value of the baseline.
386
+ * @param actual - The value read now.
387
+ * @returns The JSON path of the first difference, `"(root)"` for the top, or `undefined` when
388
+ * the two are equal.
389
+ * @example
390
+ * ```ts
391
+ * compareJson({ ui: { children: [{ rect: { y: 1 } }] } }, { ui: { children: [{ rect: { y: 2 } }] } });
392
+ * // "ui.children[0].rect.y"
393
+ * ```
394
+ */
395
+ function compareJson(expected, actual) {
396
+ return compareAt(expected, actual, "");
397
+ }
398
+ /**
399
+ * Rebuilds a value with the keys of every object in sorted order, so `JSON.stringify` writes
400
+ * them sorted.
401
+ *
402
+ * @param value - The value to write.
403
+ * @returns The same value with sorted objects.
404
+ * @example
405
+ * ```ts
406
+ * Object.keys(sortKeys({ b: 1, a: 2 }) as object); // ["a", "b"]
407
+ * ```
408
+ */
409
+ function sortKeys(value) {
410
+ if (Array.isArray(value)) return value.map((item) => sortKeys(item));
411
+ if (typeof value !== "object" || value === null) return value;
412
+ const entries = Object.entries(value).toSorted(([left], [right]) => left < right ? -1 : 1);
413
+ return Object.fromEntries(entries.map(([key, item]) => [key, sortKeys(item)]));
414
+ }
415
+ /**
416
+ * Writes a value as the text of a baseline file: JSON with sorted keys, a two-space indent and
417
+ * a final newline. A field that is `undefined` is left out, as `JSON.stringify` does.
418
+ *
419
+ * @param value - What a reader answered: plain data.
420
+ * @returns The text.
421
+ * @example
422
+ * ```ts
423
+ * stableJson({ b: 1, a: true }); // '{\n "a": true,\n "b": 1\n}\n'
424
+ * ```
425
+ */
426
+ function stableJson(value) {
427
+ return `${JSON.stringify(sortKeys(value), void 0, 2)}\n`;
428
+ }
429
+ /**
430
+ * Reads the text of a baseline file as JSON.
431
+ *
432
+ * @param text - The text.
433
+ * @param file - The file it came from, for the message.
434
+ * @returns The JSON value.
435
+ * @throws {Error} When the text is not JSON, say a merge conflict left markers in it.
436
+ * @example
437
+ * ```ts
438
+ * parseJson('{ "path": "home" }', "tests/visual/home/rest/state.json"); // { path: "home" }
439
+ * ```
440
+ */
441
+ function parseJson(text, file) {
442
+ try {
443
+ return JSON.parse(text);
444
+ } catch {
445
+ throw new Error(`[game] The baseline "${file}" is not JSON.\n Fix the file, or write it again with --update.`);
446
+ }
447
+ }
448
+ //#endregion
449
+ //#region src/visual/files.ts
450
+ /**
451
+ * @file visual — the baseline files next to the tests: `<dir>/<test>/<checkpoint>/state.json`,
452
+ * `describe.json` and `screen.webp`, with `screen.actual.webp` and `screen.diff.webp` beside them
453
+ * on a pixel difference. The pictures are lossless WebP, encoded in the page. The only module of
454
+ * the runner that touches the disk, so `node:fs/promises` and `node:path` are imported here and
455
+ * nowhere else; the `./testing` entry is node and bun only.
456
+ */
457
+ /**
458
+ * Names a baseline file of a checkpoint.
459
+ *
460
+ * @param dir - The folder of the visual tests, `"tests/visual"` by default.
461
+ * @param test - The test name.
462
+ * @param checkpoint - The checkpoint name.
463
+ * @param file - The file name, such as `"state.json"`.
464
+ * @returns The path.
465
+ * @example
466
+ * ```ts
467
+ * baselineFile("tests/visual", "reward-popup", "open", "state.json"); // "tests/visual/reward-popup/open/state.json"
468
+ * ```
469
+ */
470
+ function baselineFile(dir, test, checkpoint, file) {
471
+ return path.join(dir, test, checkpoint, file);
472
+ }
473
+ /** What every picture of the leg starts with: the page encodes the capture as lossless WebP. */
474
+ const WEBP_URL = "data:image/webp;base64,";
475
+ /**
476
+ * Tells a missing file from the other errors of a read.
477
+ *
478
+ * @param error - What the read threw.
479
+ * @returns True when the file does not exist.
480
+ */
481
+ function isMissing(error) {
482
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
483
+ }
484
+ /**
485
+ * Reads a baseline file as bytes.
486
+ *
487
+ * @param file - The path.
488
+ * @returns Its bytes, or `undefined` when there is no such file.
489
+ * @throws {Error} Any read error other than a missing file.
490
+ */
491
+ async function readBytes(file) {
492
+ try {
493
+ return await readFile(file);
494
+ } catch (error) {
495
+ if (isMissing(error)) return void 0;
496
+ throw error;
497
+ }
498
+ }
499
+ /**
500
+ * Writes a baseline file, its folders included.
501
+ *
502
+ * @param file - The path.
503
+ * @param data - The text or the bytes.
504
+ */
505
+ async function writeBaseline(file, data) {
506
+ await mkdir(path.dirname(file), { recursive: true });
507
+ await writeFile(file, data);
508
+ }
509
+ /**
510
+ * Compares the text of a baseline file with what a checkpoint read, as JSON.
511
+ *
512
+ * @param file - The path of the baseline, for the message.
513
+ * @param baseline - The text of the file.
514
+ * @param text - What the checkpoint read, as `stableJson` wrote it.
515
+ * @returns `"same"`, or `"different"` with the path of the first difference.
516
+ * @throws {Error} When the baseline is not JSON.
517
+ */
518
+ function compareText(file, baseline, text) {
519
+ const first = compareJson(parseJson(baseline, file), parseJson(text, file));
520
+ return first === void 0 ? { outcome: "same" } : {
521
+ outcome: "different",
522
+ first
523
+ };
524
+ }
525
+ /**
526
+ * Compares what a checkpoint read with its baseline file. A missing file is written and
527
+ * answered `"written"`, never a failure; `update` rewrites the file whatever it holds. A
528
+ * difference leaves the baseline as it is.
529
+ *
530
+ * @param file - The path of the baseline.
531
+ * @param text - What the checkpoint read, as `stableJson` wrote it.
532
+ * @param update - Whether to rewrite the baseline.
533
+ * @returns The outcome, and the path of the first difference for `"different"`.
534
+ * @throws {Error} When the baseline is not JSON, or the disk refused.
535
+ */
536
+ async function checkBaseline(file, text, update) {
537
+ const baseline = update ? void 0 : await readBytes(file);
538
+ if (baseline === void 0) {
539
+ await writeBaseline(file, text);
540
+ return { outcome: "written" };
541
+ }
542
+ return compareText(file, baseline.toString("utf8"), text);
543
+ }
544
+ /**
545
+ * Compares what the page read with a baseline file the headless leg owns, and never writes it.
546
+ * The browser leg checks `state.json` this way.
547
+ *
548
+ * @param file - The path of the baseline.
549
+ * @param text - What the page read, as `stableJson` wrote it.
550
+ * @returns The outcome, or `undefined` when there is no baseline to compare with.
551
+ * @throws {Error} When the baseline is not JSON, or the disk refused.
552
+ */
553
+ async function compareBaseline(file, text) {
554
+ const baseline = await readBytes(file);
555
+ return baseline === void 0 ? void 0 : compareText(file, baseline.toString("utf8"), text);
556
+ }
557
+ /**
558
+ * Reads a picture of a checkpoint, such as `screen.webp`, the way the page hands one over: a
559
+ * WebP data URL.
560
+ *
561
+ * @param file - The path.
562
+ * @returns The data URL, or `undefined` when there is no such file.
563
+ * @throws {Error} Any read error other than a missing file.
564
+ */
565
+ async function readScreen(file) {
566
+ const bytes = await readBytes(file);
567
+ return bytes === void 0 ? void 0 : `${WEBP_URL}${bytes.toString("base64")}`;
568
+ }
569
+ /**
570
+ * Writes a picture of a checkpoint: the bytes of the lossless WebP data URL the page encoded, its
571
+ * folders included.
572
+ *
573
+ * @param file - The path: `screen.webp`, `screen.actual.webp` or `screen.diff.webp`.
574
+ * @param url - The WebP data URL.
575
+ * @throws {Error} When the URL is not a WebP data URL, or the disk refused.
576
+ */
577
+ async function writeScreen(file, url) {
578
+ if (!url.startsWith(WEBP_URL)) throw new Error("[game] The page gave a picture that is not a WebP data URL.\n Run the pixel leg in Chrome: the baselines are lossless WebP.");
579
+ await writeBaseline(file, Buffer.from(url.slice(23), "base64"));
580
+ }
581
+ //#endregion
582
+ //#region src/visual/capture.ts
583
+ /**
584
+ * @file visual — the two readers of a checkpoint. Both read through the `/inspect` sources only,
585
+ * never a plugin internal: `stateOf` the graph position and the committed model, `describeOf`
586
+ * the ui tree and the keyed views. Entity ids and skipped components (the renderer's `Display`)
587
+ * are dropped and a `Parent` names its entity by address, so the entity numbering never enters a
588
+ * baseline.
589
+ */
590
+ /**
591
+ * Orders two views by projection, then by key, in code unit order.
592
+ *
593
+ * @param left - One view.
594
+ * @param right - The other view.
595
+ * @returns A negative number when `left` comes first, a positive one otherwise.
596
+ * @example
597
+ * ```ts
598
+ * byAddress({ projection: "hud", key: "a" }, { projection: "board", key: "z" }); // 1
599
+ * ```
600
+ */
601
+ function byAddress(left, right) {
602
+ if (left.projection !== right.projection) return left.projection < right.projection ? -1 : 1;
603
+ return left.key < right.key ? -1 : 1;
604
+ }
605
+ /**
606
+ * Turns the projection keys of the world into a lookup from entity to address.
607
+ *
608
+ * @param keys - What the projections source reads: projection, key, entity.
609
+ * @returns The address of every keyed entity.
610
+ */
611
+ function addressesOf(keys) {
612
+ const addresses = /* @__PURE__ */ new Map();
613
+ for (const [projection, byKey] of Object.entries(keys)) for (const [key, entity] of Object.entries(byKey)) addresses.set(entity, {
614
+ projection,
615
+ key
616
+ });
617
+ return addresses;
618
+ }
619
+ /**
620
+ * Reads the entity a `Parent` component points at.
621
+ *
622
+ * @param parent - The JSON of the component, if the view has one.
623
+ * @returns The parent entity, or `undefined` when there is no `Parent` of the usual shape.
624
+ * @example
625
+ * ```ts
626
+ * parentOf({ entity: 1048614 }); // 1048614
627
+ * ```
628
+ */
629
+ function parentOf(parent) {
630
+ if (typeof parent !== "object" || parent === null || Array.isArray(parent)) return void 0;
631
+ return typeof parent.entity === "number" ? parent.entity : void 0;
632
+ }
633
+ /**
634
+ * Names the parent of a view by its projection and key instead of its entity id, which depends
635
+ * on the order the world spawned and freed its entities. A parent no projection names is `{}`.
636
+ *
637
+ * @param components - The JSON components of the view.
638
+ * @param addresses - The address of every keyed entity.
639
+ * @returns The components, with `Parent` as an address.
640
+ */
641
+ function withParentAddress(components, addresses) {
642
+ const parent = parentOf(components.Parent);
643
+ if (parent === void 0) return components;
644
+ const address = addresses.get(parent);
645
+ return {
646
+ ...components,
647
+ Parent: address === void 0 ? {} : { ...address }
648
+ };
649
+ }
650
+ /**
651
+ * Reads the state a checkpoint saves: where the graph rests, then the committed player,
652
+ * session and rng.
653
+ *
654
+ * @param app - The running app.
655
+ * @returns `{ path, player, session, rng }`.
656
+ */
657
+ function stateOf(app) {
658
+ return {
659
+ path: read(app, sources.position).path,
660
+ ...read(app, sources.model)
661
+ };
662
+ }
663
+ /**
664
+ * Reads the screen a checkpoint saves: the ui tree, and every entity a projection names as
665
+ * `{ projection, key, components }`, sorted by projection then key. Ui parts, rings and unkeyed
666
+ * views appear through the tree only, or not at all.
667
+ *
668
+ * @param app - The running app.
669
+ * @returns `{ ui, views }`.
670
+ */
671
+ function describeOf(app) {
672
+ const addresses = addressesOf(read(app, sources.projections));
673
+ const views = [];
674
+ for (const entity of read(app, sources.entities)) {
675
+ const address = addresses.get(entity.id);
676
+ if (address !== void 0) views.push({
677
+ ...address,
678
+ components: withParentAddress(entity.components, addresses)
679
+ });
680
+ }
681
+ return {
682
+ ui: read(app, sources.ui),
683
+ views: views.toSorted(byAddress)
684
+ };
685
+ }
686
+ //#endregion
687
+ //#region src/visual/settle.ts
688
+ /** The length of one frame at 60 fps. */
689
+ const FRAME_MS = 1e3 / 60;
690
+ /**
691
+ * Yields the task queue once: a timer of 0 ms.
692
+ *
693
+ * @returns A promise that settles in the next task.
694
+ */
695
+ function nextTask() {
696
+ return new Promise((resolve) => {
697
+ setTimeout(resolve, 0);
698
+ });
699
+ }
700
+ /**
701
+ * Runs frames by hand, each followed by one task.
702
+ *
703
+ * @param app - The started app.
704
+ * @param count - How many frames.
705
+ */
706
+ async function frames(app, count) {
707
+ for (let frame = 0; frame < count; frame += 1) {
708
+ app.time.step(FRAME_MS);
709
+ await nextTask();
710
+ }
711
+ }
712
+ /**
713
+ * Runs frames until the graph rests at a gate or stops: at least one, at most `limit`. One more
714
+ * frame follows, because a screen the gate opened builds its ui in the frame after it.
715
+ *
716
+ * @param app - The started app.
717
+ * @param limit - The most frames to run, `settleFrames` of the run.
718
+ * @param place - The test and the step, for the message.
719
+ * @throws {Error} When the graph neither rests at a gate nor stops within `limit` frames.
720
+ */
721
+ async function settle(app, limit, place) {
722
+ for (let frame = 0; frame < limit; frame += 1) {
723
+ await frames(app, 1);
724
+ const state = app.flow.state();
725
+ if (state.pending.gate !== void 0 || !state.running) {
726
+ await frames(app, 1);
727
+ return;
728
+ }
729
+ }
730
+ throw new Error(`[game] Visual test "${place.test}", ${place.where} did not settle in ${limit} frames.\n The graph stands at "${app.flow.state().path}"; add a step that answers what it waits for.`);
731
+ }
732
+ //#endregion
733
+ //#region src/visual/leg-headless.ts
734
+ /**
735
+ * @file visual — the headless leg: plain Bun, the renderer inert, the mode `live`. Each test gets
736
+ * a fresh app: start, restore its start through `game.restore`, play its steps through the
737
+ * `/control` commands, and at each checkpoint land every motion and compare `state.json` and
738
+ * `describe.json` with their baselines, or write them.
739
+ */
740
+ /** The frames run after `finishAll`, so the landed pose is laid out and synced. */
741
+ const REST_FRAMES = 2;
742
+ /**
743
+ * Sets the dev flag for a run: the `/control` commands run in dev builds only, and this is a test
744
+ * tool a game calls on purpose.
745
+ *
746
+ * @returns Puts the flag back as it was.
747
+ */
748
+ function enableDevelopment() {
749
+ const had = Object.hasOwn(globalThis, "__MOKU_GAME_DEV__");
750
+ const before = globalThis.__MOKU_GAME_DEV__;
751
+ globalThis.__MOKU_GAME_DEV__ = true;
752
+ return () => {
753
+ if (had) globalThis.__MOKU_GAME_DEV__ = before;
754
+ else Reflect.deleteProperty(globalThis, "__MOKU_GAME_DEV__");
755
+ };
756
+ }
757
+ /**
758
+ * Reads the message of whatever a test threw.
759
+ *
760
+ * @param error - What was thrown.
761
+ * @returns The message of an error, the value as text otherwise.
762
+ * @example
763
+ * ```ts
764
+ * messageOf(new Error("[game] Boom.\n Fix it.")); // "[game] Boom.\n Fix it."
765
+ * ```
766
+ */
767
+ function messageOf(error) {
768
+ return error instanceof Error ? error.message : String(error);
769
+ }
770
+ /**
771
+ * Turns the message of a failure into the second line of the test's error: one sentence or
772
+ * more, without the `[game]` mark, ending with a period.
773
+ *
774
+ * @param error - What the command threw.
775
+ * @returns The reason.
776
+ * @example
777
+ * ```ts
778
+ * reasonOf(new Error("[game] No element.\n Read sources.ui.")); // "No element. Read sources.ui."
779
+ * ```
780
+ */
781
+ function reasonOf(error) {
782
+ const text = messageOf(error).replace(/^\[game\] /u, "").replaceAll(/\n\s*/gu, " ").trim();
783
+ return text.endsWith(".") ? text : `${text}.`;
784
+ }
785
+ /**
786
+ * Builds the error of a step that failed: the test, the step and the reason. Both legs use it.
787
+ *
788
+ * @param test - The test name.
789
+ * @param where - The step, such as `step 2 (tap)`.
790
+ * @param error - What the step threw, or the message of it.
791
+ * @returns The error, ready to throw.
792
+ * @example
793
+ * ```ts
794
+ * stepFailure("reward-popup", "step 1 (tap)", "Target crashed").message;
795
+ * // '[game] Visual test "reward-popup", step 1 (tap) failed.\n Target crashed.'
796
+ * ```
797
+ */
798
+ function stepFailure(test, where, error) {
799
+ return /* @__PURE__ */ new Error(`[game] Visual test "${test}", ${where} failed.\n ${reasonOf(error)}`);
800
+ }
801
+ /**
802
+ * Runs `work`, and turns its rejection into the error of a step.
803
+ *
804
+ * @param test - The test name.
805
+ * @param where - The step, such as `step 2 (tap)`.
806
+ * @param work - What the step does.
807
+ * @throws {Error} `[game] Visual test "<test>", <where> failed.` with the reason below it.
808
+ */
809
+ async function failing(test, where, work) {
810
+ try {
811
+ await work();
812
+ } catch (error) {
813
+ throw stepFailure(test, where, error);
814
+ }
815
+ }
816
+ /**
817
+ * Starts the one loop unless the app's own `onStart` did, keeping its rejection.
818
+ *
819
+ * @param app - The started app.
820
+ * @returns Where the rejection waits.
821
+ */
822
+ function startLoop(app) {
823
+ const loop = { error: void 0 };
824
+ if (app.flow.state().running) return loop;
825
+ app.flow.run().catch((error) => {
826
+ loop.error = error;
827
+ });
828
+ return loop;
829
+ }
830
+ /**
831
+ * Settles, then throws the rejection of the loop if it came.
832
+ *
833
+ * @param play - The test being played.
834
+ * @param where - The step, for the message.
835
+ * @throws {Error} When the graph does not settle, or the loop failed.
836
+ */
837
+ async function settleAt(play, where) {
838
+ await settle(play.app, play.run.settleFrames, {
839
+ test: play.test.name,
840
+ where
841
+ });
842
+ if (play.loop.error !== void 0) throw play.loop.error;
843
+ }
844
+ /**
845
+ * Plays a checkpoint: settle, land every track and loop, two frames, then read and compare.
846
+ *
847
+ * @param play - The test being played.
848
+ * @param name - The checkpoint name.
849
+ * @param where - The step, for the message.
850
+ * @returns The outcome of the checkpoint; `pixels` is skipped in this leg.
851
+ */
852
+ async function checkpoint$1(play, name, where) {
853
+ const { app, test, run: options } = play;
854
+ await settleAt(play, where);
855
+ app.anim.finishAll();
856
+ await frames(app, REST_FRAMES);
857
+ const file = (kind) => baselineFile(options.dir, test.name, name, kind);
858
+ const state = await checkBaseline(file("state.json"), stableJson(stateOf(app)), options.update);
859
+ const describe = await checkBaseline(file("describe.json"), stableJson(describeOf(app)), options.update);
860
+ const first = state.first ?? describe.first;
861
+ const result = {
862
+ name,
863
+ state: state.outcome,
864
+ describe: describe.outcome,
865
+ pixels: "skipped"
866
+ };
867
+ return first === void 0 ? result : {
868
+ ...result,
869
+ first
870
+ };
871
+ }
872
+ /**
873
+ * Plays the start and the steps of a test on a started app.
874
+ *
875
+ * @param play - The test being played.
876
+ * @param checkpoints - Where each checkpoint's outcome goes, as soon as it is known.
877
+ */
878
+ async function playSteps$1(play, checkpoints) {
879
+ const { app, test } = play;
880
+ const repro = {
881
+ ...test.start,
882
+ route: []
883
+ };
884
+ await settleAt(play, "the start");
885
+ await failing(test.name, "the start", () => run(app, commands.restore, { repro }));
886
+ await settleAt(play, "the start");
887
+ for (const [index, step] of test.steps.entries()) {
888
+ const entry = stepEntry(test.name, step, index);
889
+ const where = `step ${index + 1} (${entry.kind === "checkpoint" ? "checkpoint" : entry.name})`;
890
+ if (entry.kind === "checkpoint") {
891
+ checkpoints.push(await checkpoint$1(play, entry.name, where));
892
+ continue;
893
+ }
894
+ await failing(test.name, where, () => runStep(app, entry.name, entry.input));
895
+ await settleAt(play, where);
896
+ }
897
+ }
898
+ /**
899
+ * Plays one test on a fresh app, live, and stops the app at the end.
900
+ *
901
+ * @param setup - How the runner builds a game.
902
+ * @param test - The test.
903
+ * @param options - The run options.
904
+ * @param checkpoints - Where each checkpoint's outcome goes.
905
+ */
906
+ async function playTest(setup, test, options, checkpoints) {
907
+ const app = setup.app();
908
+ app.flow.setMode("live");
909
+ await app.start();
910
+ try {
911
+ await playSteps$1({
912
+ app,
913
+ test,
914
+ run: options,
915
+ loop: startLoop(app)
916
+ }, checkpoints);
917
+ } finally {
918
+ await app.stop();
919
+ }
920
+ }
921
+ /**
922
+ * Plays one test and turns its outcome into a result. A test that fails keeps the checkpoints it
923
+ * reached and carries the error instead of throwing it.
924
+ *
925
+ * @param setup - How the runner builds a game.
926
+ * @param test - The test to play.
927
+ * @param options - The run options.
928
+ * @returns The result of the test.
929
+ */
930
+ async function playOne(setup, test, options) {
931
+ const checkpoints = [];
932
+ try {
933
+ await playTest(setup, test, options, checkpoints);
934
+ return {
935
+ name: test.name,
936
+ checkpoints
937
+ };
938
+ } catch (error) {
939
+ return {
940
+ name: test.name,
941
+ checkpoints,
942
+ error: messageOf(error)
943
+ };
944
+ }
945
+ }
946
+ /**
947
+ * Runs the headless leg over the selected tests, one after the other. The dev flag is on for the
948
+ * run and put back after it. A test that fails keeps the checkpoints it reached and carries the
949
+ * error; the next test still runs.
950
+ *
951
+ * @param setup - How the runner builds a game.
952
+ * @param tests - The selected tests.
953
+ * @param options - The run options.
954
+ * @returns One result per test, in order.
955
+ */
956
+ async function runHeadlessLeg(setup, tests, options) {
957
+ const restore = enableDevelopment();
958
+ const results = [];
959
+ try {
960
+ for (const test of tests) results.push(await playOne(setup, test, options));
961
+ } finally {
962
+ restore();
963
+ }
964
+ return results;
965
+ }
966
+ //#endregion
967
+ //#region src/visual/page-script.ts
968
+ /**
969
+ * Tells whether the dev page exposes both handles yet. The leg waits on it after `goto`.
970
+ *
971
+ * @returns True once `globalThis.game` and `globalThis.doors` are set.
972
+ */
973
+ function pageReady() {
974
+ return Reflect.get(globalThis, "game") !== void 0 && Reflect.get(globalThis, "doors") !== void 0;
975
+ }
976
+ /**
977
+ * Tells the game of the page contract from any other value: an object with a graph, motions and
978
+ * a renderer.
979
+ *
980
+ * @param value - What `globalThis.game` holds.
981
+ * @returns True for the app the dev page set.
982
+ */
983
+ function isPageGame(value) {
984
+ return typeof value === "object" && value !== null && "flow" in value && "anim" in value && "renderer" in value;
985
+ }
986
+ /**
987
+ * Tells the doors of the page contract from any other value.
988
+ *
989
+ * @param value - What `globalThis.doors` holds.
990
+ * @returns True for an object with `read`, `run`, `sources` and `commands`.
991
+ */
992
+ function isPageDoors(value) {
993
+ return typeof value === "object" && value !== null && "read" in value && "run" in value && "sources" in value && "commands" in value;
994
+ }
995
+ /**
996
+ * Reads the two handles of the page contract.
997
+ *
998
+ * @returns The game and the doors.
999
+ * @throws {Error} When the page set either of them to something else, or not at all.
1000
+ */
1001
+ function pageHandles() {
1002
+ const game = Reflect.get(globalThis, "game");
1003
+ const doors = Reflect.get(globalThis, "doors");
1004
+ if (isPageGame(game) && isPageDoors(doors)) return {
1005
+ game,
1006
+ doors
1007
+ };
1008
+ throw new Error("[game] The page exposes no game and doors.\n Set globalThis.game and globalThis.doors in the dev page.");
1009
+ }
1010
+ /**
1011
+ * Waits for the next frame of the browser. It moves no game time while the leg holds the pause.
1012
+ *
1013
+ * @returns A promise that settles in the next `requestAnimationFrame`.
1014
+ */
1015
+ function pageFrame() {
1016
+ return new Promise((resolve) => {
1017
+ requestAnimationFrame(() => {
1018
+ resolve();
1019
+ });
1020
+ });
1021
+ }
1022
+ /**
1023
+ * Yields the task queue once, so every promise chain that can move does.
1024
+ *
1025
+ * @returns A promise that settles in the next task.
1026
+ */
1027
+ function pageTask() {
1028
+ return new Promise((resolve) => {
1029
+ setTimeout(resolve, 0);
1030
+ });
1031
+ }
1032
+ /**
1033
+ * Reads the message of whatever a page call threw.
1034
+ *
1035
+ * @param error - What was thrown.
1036
+ * @returns The message of an error, the value as text otherwise.
1037
+ * @example
1038
+ * ```ts
1039
+ * pageMessage(new Error("The source image could not be decoded.")); // "The source image could not be decoded."
1040
+ * ```
1041
+ */
1042
+ function pageMessage(error) {
1043
+ return error instanceof Error ? error.message : String(error);
1044
+ }
1045
+ /**
1046
+ * Runs one frame of the game through the `step` command: the six phases, the render included,
1047
+ * also while the game is paused.
1048
+ *
1049
+ * @param handles - The game and the doors.
1050
+ * @param deltaMs - The game time of the frame; 1000/60 ms when omitted, the frame of the
1051
+ * headless leg.
1052
+ */
1053
+ async function pageStepFrame(handles, deltaMs) {
1054
+ await handles.doors.run(handles.game, handles.doors.commands.step, {
1055
+ frames: 1,
1056
+ deltaMs: deltaMs ?? 1e3 / 60
1057
+ });
1058
+ }
1059
+ /**
1060
+ * Runs one frame of a test: a stepped frame of 1000/60 ms, then the browser's next frame, so a
1061
+ * load and a promise chain progress before the graph is read.
1062
+ *
1063
+ * @param handles - The game and the doors.
1064
+ */
1065
+ async function pageTick(handles) {
1066
+ await pageStepFrame(handles);
1067
+ await pageFrame();
1068
+ }
1069
+ /**
1070
+ * Runs frames until the graph rests at a gate or stops: at least one frame, at most `limit`, then
1071
+ * one frame more so the ui of a screen the gate opened is built. The settle of the headless leg,
1072
+ * on the same stepped frames.
1073
+ *
1074
+ * @param handles - The game and the doors.
1075
+ * @param limit - The most frames to run, `settleFrames` of the run.
1076
+ * @param place - The test and the step, for the message.
1077
+ * @returns The error message when the graph did not rest, `undefined` when it did.
1078
+ */
1079
+ async function pageSettle(handles, limit, place) {
1080
+ const { game } = handles;
1081
+ for (let frame = 0; frame < limit; frame += 1) {
1082
+ await pageTick(handles);
1083
+ const state = game.flow.state();
1084
+ if (state.pending.gate !== void 0 || !state.running) {
1085
+ await pageTick(handles);
1086
+ return;
1087
+ }
1088
+ }
1089
+ return `[game] Visual test "${place.test}", ${place.where} did not settle in ${limit} frames.\n The graph stands at "${game.flow.state().path}"; add a step that answers what it waits for.`;
1090
+ }
1091
+ /**
1092
+ * Runs one `/control` command through the doors, the way an editor does, and runs frames while it
1093
+ * waits for them: a `restore` resolves only once the graph rests, and that takes frames. A
1094
+ * command that waits for no frame is done before the first one.
1095
+ *
1096
+ * @param handles - The game and the doors.
1097
+ * @param command - The command by its short name, and its input.
1098
+ * @param command.name - The short name of the command.
1099
+ * @param command.input - Its input.
1100
+ * @param limit - The most frames to run, `settleFrames` of the run.
1101
+ * @param place - The test and the step, for the message.
1102
+ * @returns The error message when the command did not finish, `undefined` when it did.
1103
+ * @throws {unknown} Whatever the command threw.
1104
+ */
1105
+ async function pageRun(handles, command, limit, place) {
1106
+ const { game, doors } = handles;
1107
+ const runners = doors.commands;
1108
+ const ran = {
1109
+ done: false,
1110
+ failure: void 0
1111
+ };
1112
+ const finished = doors.run(game, runners[command.name], command.input).then(() => {
1113
+ ran.done = true;
1114
+ }, (error) => {
1115
+ ran.done = true;
1116
+ ran.failure = { error };
1117
+ });
1118
+ let frames = 0;
1119
+ await pageTask();
1120
+ while (!ran.done) {
1121
+ if (frames === limit) return `[game] Visual test "${place.test}", ${place.where} did not finish in ${limit} frames.\n The graph stands at "${game.flow.state().path}"; the command waits for something the stepped frames never bring.`;
1122
+ await pageTick(handles);
1123
+ frames += 1;
1124
+ }
1125
+ await finished;
1126
+ if (ran.failure !== void 0) throw ran.failure.error;
1127
+ }
1128
+ /**
1129
+ * Waits until the page's own graph runs and rests at a gate, the way the dev page starts it, and
1130
+ * reads the backend the renderer chose. The game runs on the browser's frames here: the leg
1131
+ * pauses it later, before the start of a test.
1132
+ *
1133
+ * @param argument - The page URL, for the message, and the most frames to wait.
1134
+ * @param argument.url - The URL of the dev page.
1135
+ * @param argument.settleFrames - The most frames to wait.
1136
+ * @returns The renderer kind, or why the page did not start.
1137
+ */
1138
+ async function pageStart(argument) {
1139
+ try {
1140
+ const { game } = pageHandles();
1141
+ for (let frame = 0; frame < argument.settleFrames; frame += 1) {
1142
+ const state = game.flow.state();
1143
+ if (state.running && state.pending.gate !== void 0) return {
1144
+ ok: true,
1145
+ value: game.renderer.host.kind()
1146
+ };
1147
+ await pageFrame();
1148
+ }
1149
+ return {
1150
+ ok: false,
1151
+ error: `[game] The page at ${argument.url} did not rest at a gate in ${argument.settleFrames} frames.\n The graph stands at "${game.flow.state().path}"; start the app and run its graph in the dev page.`
1152
+ };
1153
+ } catch (error) {
1154
+ return {
1155
+ ok: false,
1156
+ reason: pageMessage(error)
1157
+ };
1158
+ }
1159
+ }
1160
+ /**
1161
+ * Holds the game for a test: the `pause` command, the editor's pause, so the browser's frames no
1162
+ * longer move game time; then stepped frames up to the start time, 1000/60 ms each and the last
1163
+ * one shorter, so the game lands on it to the last bit. The page's own start took a different
1164
+ * time on every run; from here on every test starts at the same game time.
1165
+ *
1166
+ * @param argument - The test and the step, and the game time a test starts at.
1167
+ * @param argument.startMs - The game time a test starts at, in milliseconds; a page whose clock
1168
+ * is already past it starts at the next multiple of it.
1169
+ * @returns The game time the test starts at, or why the page refused the pause.
1170
+ */
1171
+ async function pagePause(argument) {
1172
+ try {
1173
+ const handles = pageHandles();
1174
+ const { game, doors } = handles;
1175
+ const { startMs } = argument;
1176
+ const frameMs = 1e3 / 60;
1177
+ await doors.run(game, doors.commands.pause);
1178
+ const start = Math.max(startMs, Math.ceil(game.time.snapshot().elapsed / startMs) * startMs);
1179
+ for (let left = start - game.time.snapshot().elapsed; left > 0; left = start - game.time.snapshot().elapsed) await pageStepFrame(handles, Math.min(left, frameMs));
1180
+ return {
1181
+ ok: true,
1182
+ value: game.time.snapshot().elapsed
1183
+ };
1184
+ } catch (error) {
1185
+ return {
1186
+ ok: false,
1187
+ reason: pageMessage(error)
1188
+ };
1189
+ }
1190
+ }
1191
+ /**
1192
+ * Runs one `/control` command through the doors of the page, the way an editor does, with frames
1193
+ * stepped while it waits for them, then settles. The leg restores the start of a test with it
1194
+ * too, as the command `restore`.
1195
+ *
1196
+ * @param argument - The test and the step, the command by its short name, its input, and the most
1197
+ * frames to settle.
1198
+ * @param argument.name - The short name of the command.
1199
+ * @param argument.input - Its input.
1200
+ * @param argument.settleFrames - The most frames to settle.
1201
+ * @returns Nothing, or why the command or the settle failed.
1202
+ */
1203
+ async function pageStep(argument) {
1204
+ try {
1205
+ const handles = pageHandles();
1206
+ const unsettled = await pageRun(handles, argument, argument.settleFrames, argument) ?? await pageSettle(handles, argument.settleFrames, argument);
1207
+ return unsettled === void 0 ? {
1208
+ ok: true,
1209
+ value: void 0
1210
+ } : {
1211
+ ok: false,
1212
+ error: unsettled
1213
+ };
1214
+ } catch (error) {
1215
+ return {
1216
+ ok: false,
1217
+ reason: pageMessage(error)
1218
+ };
1219
+ }
1220
+ }
1221
+ /**
1222
+ * Plays a checkpoint in the page: settle, land every track and loop, two stepped frames, then the
1223
+ * picture of the whole canvas as lossless WebP and the state through the doors. The game is
1224
+ * paused, so the picture is the stage after the last stepped frame.
1225
+ *
1226
+ * @param argument - The test and the step, and the most frames to settle.
1227
+ * @param argument.settleFrames - The most frames to settle.
1228
+ * @returns The state and the picture, or why the checkpoint failed.
1229
+ */
1230
+ async function pageCheckpoint(argument) {
1231
+ try {
1232
+ const handles = pageHandles();
1233
+ const { game, doors } = handles;
1234
+ const unsettled = await pageSettle(handles, argument.settleFrames, argument);
1235
+ if (unsettled !== void 0) return {
1236
+ ok: false,
1237
+ error: unsettled
1238
+ };
1239
+ game.anim.finishAll();
1240
+ await pageTick(handles);
1241
+ await pageTick(handles);
1242
+ const captured = await game.renderer.capture();
1243
+ const screen = captured === void 0 ? void 0 : await pageWebp(captured);
1244
+ return {
1245
+ ok: true,
1246
+ value: {
1247
+ state: {
1248
+ path: doors.read(game, doors.sources.position).path,
1249
+ ...doors.read(game, doors.sources.model)
1250
+ },
1251
+ screen
1252
+ }
1253
+ };
1254
+ } catch (error) {
1255
+ return {
1256
+ ok: false,
1257
+ reason: pageMessage(error)
1258
+ };
1259
+ }
1260
+ }
1261
+ /**
1262
+ * Measures how far apart two pixels are: the largest delta of their four channels.
1263
+ *
1264
+ * @param expected - The bytes of the baseline.
1265
+ * @param actual - The bytes of the capture.
1266
+ * @param at - The index of the pixel's first byte.
1267
+ * @returns The largest channel delta, from 0 to 255.
1268
+ * @example
1269
+ * ```ts
1270
+ * channelDelta(new Uint8ClampedArray([10, 20, 30, 255]), new Uint8ClampedArray([12, 20, 0, 255]), 0); // 30
1271
+ * ```
1272
+ */
1273
+ function channelDelta(expected, actual, at) {
1274
+ let delta = 0;
1275
+ for (let channel = at; channel < at + 4; channel += 1) delta = Math.max(delta, Math.abs((expected[channel] ?? 0) - (actual[channel] ?? 0)));
1276
+ return delta;
1277
+ }
1278
+ /**
1279
+ * Compares two pictures pixel by pixel. A pixel differs when the largest delta of its channels
1280
+ * exceeds `threshold`. The diff picture is red where a pixel differs and the baseline faded
1281
+ * three quarters towards white elsewhere. Two sizes are not compared.
1282
+ *
1283
+ * @param expected - The baseline.
1284
+ * @param actual - The capture.
1285
+ * @param threshold - The largest channel delta that still counts as the same pixel.
1286
+ * @returns The counts and the diff picture.
1287
+ * @example
1288
+ * ```ts
1289
+ * const black = { width: 1, height: 1, data: new Uint8ClampedArray([0, 0, 0, 255]) };
1290
+ * comparePixels(black, { ...black, data: new Uint8ClampedArray([30, 0, 0, 255]) }, 24).differing; // 1
1291
+ * ```
1292
+ */
1293
+ function comparePixels(expected, actual, threshold) {
1294
+ if (expected.width !== actual.width || expected.height !== actual.height) return {
1295
+ sameSize: false,
1296
+ differing: 0,
1297
+ total: 0,
1298
+ diff: /* @__PURE__ */ new Uint8ClampedArray(0)
1299
+ };
1300
+ const total = expected.width * expected.height;
1301
+ const diff = new Uint8ClampedArray(total * 4);
1302
+ const threeQuartersWhite = 765;
1303
+ let differing = 0;
1304
+ for (let at = 0; at < diff.length; at += 4) {
1305
+ if (channelDelta(expected.data, actual.data, at) > threshold) {
1306
+ differing += 1;
1307
+ diff.set([
1308
+ 255,
1309
+ 0,
1310
+ 0,
1311
+ 255
1312
+ ], at);
1313
+ continue;
1314
+ }
1315
+ for (let channel = at; channel < at + 3; channel += 1) diff[channel] = ((expected.data[channel] ?? 0) + threeQuartersWhite) / 4;
1316
+ diff[at + 3] = 255;
1317
+ }
1318
+ return {
1319
+ sameSize: true,
1320
+ differing,
1321
+ total,
1322
+ diff
1323
+ };
1324
+ }
1325
+ /**
1326
+ * Gets a 2D context of an `OffscreenCanvas`, the one canvas the compare draws on.
1327
+ *
1328
+ * @param width - Pixels across.
1329
+ * @param height - Pixels down.
1330
+ * @returns The canvas and its context.
1331
+ * @throws {Error} When the browser gives no 2D context.
1332
+ */
1333
+ function pageCanvas(width, height) {
1334
+ const canvas = new OffscreenCanvas(width, height);
1335
+ const context = canvas.getContext("2d", { willReadFrequently: true });
1336
+ if (context === null) throw new Error("The browser gave no 2D context for an OffscreenCanvas.");
1337
+ return {
1338
+ canvas,
1339
+ context
1340
+ };
1341
+ }
1342
+ /**
1343
+ * Decodes a PNG or WebP data URL into its pixels, with no colour conversion.
1344
+ *
1345
+ * @param url - The data URL.
1346
+ * @returns The pixels.
1347
+ */
1348
+ async function pageDecode(url) {
1349
+ const blob = await (await fetch(url)).blob();
1350
+ const bitmap = await createImageBitmap(blob, {
1351
+ colorSpaceConversion: "none",
1352
+ premultiplyAlpha: "none"
1353
+ });
1354
+ const { context } = pageCanvas(bitmap.width, bitmap.height);
1355
+ context.drawImage(bitmap, 0, 0);
1356
+ const image = context.getImageData(0, 0, bitmap.width, bitmap.height);
1357
+ return {
1358
+ width: image.width,
1359
+ height: image.height,
1360
+ data: image.data
1361
+ };
1362
+ }
1363
+ /**
1364
+ * Encodes pixels as a lossless WebP data URL: Chrome encodes WebP at quality 1 losslessly, so an
1365
+ * opaque picture decodes to the same bytes.
1366
+ *
1367
+ * @param pixels - The pixels.
1368
+ * @returns The data URL.
1369
+ * @throws {Error} When the browser answers another type: it encodes no WebP.
1370
+ */
1371
+ async function pageEncode(pixels) {
1372
+ const { canvas, context } = pageCanvas(pixels.width, pixels.height);
1373
+ context.putImageData(new ImageData(pixels.data, pixels.width, pixels.height), 0, 0);
1374
+ const blob = await canvas.convertToBlob({
1375
+ type: "image/webp",
1376
+ quality: 1
1377
+ });
1378
+ if (blob.type !== "image/webp") throw new Error(`The browser does not encode WebP: it answered ${blob.type}.`);
1379
+ const bytes = new Uint8Array(await blob.arrayBuffer());
1380
+ const chunkBytes = 32768;
1381
+ let binary = "";
1382
+ for (let start = 0; start < bytes.length; start += chunkBytes) binary += String.fromCodePoint(...bytes.subarray(start, start + chunkBytes));
1383
+ return `data:image/webp;base64,${btoa(binary)}`;
1384
+ }
1385
+ /**
1386
+ * Turns the PNG data URL of `renderer.capture()` into the lossless WebP of the baselines.
1387
+ *
1388
+ * @param url - The PNG data URL.
1389
+ * @returns The WebP data URL of the same pixels.
1390
+ */
1391
+ async function pageWebp(url) {
1392
+ return pageEncode(await pageDecode(url));
1393
+ }
1394
+ /**
1395
+ * Compares the baseline picture with the capture inside the page, on an `OffscreenCanvas`, so
1396
+ * the Bun side needs no image library. The diff picture comes back only when a pixel differs.
1397
+ *
1398
+ * @param argument - The test and the step, the two WebP data URLs and the channel threshold.
1399
+ * @param argument.expected - The baseline `screen.webp`.
1400
+ * @param argument.actual - The capture.
1401
+ * @param argument.threshold - The largest channel delta that still counts as the same pixel.
1402
+ * @returns The counts and the diff picture, or why the pictures could not be compared.
1403
+ */
1404
+ async function pageCompare(argument) {
1405
+ try {
1406
+ const [expected, actual] = await Promise.all([pageDecode(argument.expected), pageDecode(argument.actual)]);
1407
+ const found = comparePixels(expected, actual, argument.threshold);
1408
+ const counts = {
1409
+ sameSize: found.sameSize,
1410
+ differing: found.differing,
1411
+ total: found.total
1412
+ };
1413
+ if (found.differing === 0) return {
1414
+ ok: true,
1415
+ value: counts
1416
+ };
1417
+ const diff = await pageEncode({
1418
+ width: expected.width,
1419
+ height: expected.height,
1420
+ data: found.diff
1421
+ });
1422
+ return {
1423
+ ok: true,
1424
+ value: {
1425
+ ...counts,
1426
+ diff
1427
+ }
1428
+ };
1429
+ } catch (error) {
1430
+ return {
1431
+ ok: false,
1432
+ reason: pageMessage(error)
1433
+ };
1434
+ }
1435
+ }
1436
+ /** Every function of the page script, in the order the page gets them. */
1437
+ const pageFunctions = [
1438
+ pageReady,
1439
+ isPageGame,
1440
+ isPageDoors,
1441
+ pageHandles,
1442
+ pageFrame,
1443
+ pageTask,
1444
+ pageMessage,
1445
+ pageStepFrame,
1446
+ pageTick,
1447
+ pageSettle,
1448
+ pageRun,
1449
+ pageStart,
1450
+ pagePause,
1451
+ pageStep,
1452
+ pageCheckpoint,
1453
+ channelDelta,
1454
+ comparePixels,
1455
+ pageCanvas,
1456
+ pageDecode,
1457
+ pageEncode,
1458
+ pageWebp,
1459
+ pageCompare
1460
+ ];
1461
+ /**
1462
+ * Writes the script that installs the page functions in the dev page, each under its own name on
1463
+ * `globalThis`. The leg adds it as an init script, so it runs before the page's own code, on
1464
+ * every load.
1465
+ *
1466
+ * @returns The source of the script.
1467
+ */
1468
+ function pageScript() {
1469
+ return `(() => {\n${pageFunctions.flatMap((fn) => [String(fn), `globalThis[${JSON.stringify(fn.name)}] = ${fn.name};`]).join("\n")}\n})();\n`;
1470
+ }
1471
+ //#endregion
1472
+ //#region src/visual/leg-browser.ts
1473
+ /** The Chrome flags of `.planning/e2e/run.mjs`: WebGPU on Metal, on any GPU. */
1474
+ const WEBGPU_FLAGS = [
1475
+ "--enable-unsafe-webgpu",
1476
+ "--use-angle=metal",
1477
+ "--enable-gpu",
1478
+ "--ignore-gpu-blocklist"
1479
+ ];
1480
+ /** The page width in CSS px when the setup names none: a phone. */
1481
+ const DEFAULT_WIDTH = 390;
1482
+ /** The device scale when the setup names none. */
1483
+ const DEFAULT_SCALE = 2;
1484
+ /** How long the page may take to expose `game` and `doors`, and to stop loading, in milliseconds. */
1485
+ const READY_MS = 15e3;
1486
+ /**
1487
+ * The game time every test starts at, in milliseconds: the page reaches it on stepped frames after
1488
+ * the pause, whatever its own start took. A page whose clock is past it starts at the next
1489
+ * multiple.
1490
+ */
1491
+ const START_MS = 5e3;
1492
+ /** The baseline picture of a checkpoint, and the two the leg leaves beside it on a difference. */
1493
+ const SCREEN = {
1494
+ baseline: "screen.webp",
1495
+ actual: "screen.actual.webp",
1496
+ diff: "screen.diff.webp"
1497
+ };
1498
+ /**
1499
+ * Reads the first line of a Playwright error, without the name of the call that failed.
1500
+ *
1501
+ * @param error - What Playwright threw.
1502
+ * @returns The line, without a final period.
1503
+ * @example
1504
+ * ```ts
1505
+ * firstLine(new Error("page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:3000/\nCall log:")); // "net::ERR_CONNECTION_REFUSED at http://localhost:3000/"
1506
+ * ```
1507
+ */
1508
+ function firstLine(error) {
1509
+ const [line = ""] = messageOf(error).split("\n");
1510
+ return line.replace(/^[\w.]+: /u, "").replace(/\.$/u, "");
1511
+ }
1512
+ /**
1513
+ * Loads `chromium` from `playwright-core`, the optional peer, with a dynamic import: the package
1514
+ * never reaches a bundle that does not run the pixel leg.
1515
+ *
1516
+ * @returns The `chromium` of Playwright.
1517
+ * @throws {Error} When `playwright-core` is not installed.
1518
+ */
1519
+ async function loadChromium() {
1520
+ try {
1521
+ return (await import("playwright-core")).chromium;
1522
+ } catch {
1523
+ throw new Error("[game] The pixel leg needs playwright-core.\n Add it: bun add -d playwright-core, or run with --no-pixels.");
1524
+ }
1525
+ }
1526
+ /**
1527
+ * Launches one Chrome, keeping why it did not start.
1528
+ *
1529
+ * @param chromium - The `chromium` of Playwright.
1530
+ * @param options - The launch options.
1531
+ * @returns The browser, or the reason.
1532
+ */
1533
+ async function attempt(chromium, options) {
1534
+ try {
1535
+ return { browser: await chromium.launch(options) };
1536
+ } catch (error) {
1537
+ return { reason: firstLine(error) };
1538
+ }
1539
+ }
1540
+ /**
1541
+ * Launches Chrome with the four WebGPU flags: the Chrome of `page.browser` when the setup names
1542
+ * one, else Playwright's own Chromium, else the system Chrome. Playwright's Chromium comes first
1543
+ * because its version is pinned by `playwright-core`, so the baselines do not move with a Chrome
1544
+ * update.
1545
+ *
1546
+ * @param chromium - The `chromium` of Playwright.
1547
+ * @param browser - The `browser` of the page, if any.
1548
+ * @returns The browser.
1549
+ * @throws {Error} When the named Chrome, or every default one, does not launch.
1550
+ */
1551
+ async function launchChrome(chromium, browser) {
1552
+ const args = [...WEBGPU_FLAGS];
1553
+ if (browser !== void 0) {
1554
+ const given = await attempt(chromium, {
1555
+ ...browser,
1556
+ args
1557
+ });
1558
+ if ("browser" in given) return given.browser;
1559
+ throw new Error(`[game] Chrome did not launch from page.browser: ${given.reason}.\n Fix page.browser, or run with --no-pixels.`);
1560
+ }
1561
+ const own = await attempt(chromium, { args });
1562
+ if ("browser" in own) return own.browser;
1563
+ const system = await attempt(chromium, {
1564
+ channel: "chrome",
1565
+ args
1566
+ });
1567
+ if ("browser" in system) return system.browser;
1568
+ throw new Error(`[game] No Chrome launched: ${own.reason}.\n Install Playwright's Chromium (bunx playwright-core install chromium), set page.browser, or run with --no-pixels.`);
1569
+ }
1570
+ /**
1571
+ * Sizes the page: `page.width` CSS px wide (390), as tall as the inert frame of the game is at
1572
+ * that width, so the reference frame of the page equals the one of the headless leg; a phone
1573
+ * with touch at `page.deviceScaleFactor` (2). Builds one app of the setup, never started, to read
1574
+ * its inert frame.
1575
+ *
1576
+ * @param setup - The setup, for a fresh app.
1577
+ * @param page - The page.
1578
+ * @returns The options of the browser context.
1579
+ */
1580
+ function pageSize(setup, page) {
1581
+ const width = page.width ?? DEFAULT_WIDTH;
1582
+ const frame = setup.app().renderer.viewport.size();
1583
+ return {
1584
+ viewport: {
1585
+ width,
1586
+ height: Math.round(width * frame.height / frame.width)
1587
+ },
1588
+ deviceScaleFactor: page.deviceScaleFactor ?? DEFAULT_SCALE,
1589
+ isMobile: true,
1590
+ hasTouch: true
1591
+ };
1592
+ }
1593
+ /**
1594
+ * Decides the pixel outcome of a checkpoint: two sizes are `"different"` at `"size"`; else the
1595
+ * share of differing pixels is `"different"` over `tolerance.ratio`, `"same"` at or under it.
1596
+ *
1597
+ * @param compared - What the page counted.
1598
+ * @param tolerance - The tolerance of the run.
1599
+ * @returns The outcome, with `pixelRatio` or `first` on a difference.
1600
+ * @example
1601
+ * ```ts
1602
+ * pixelOutcome({ sameSize: true, differing: 2, total: 1000 }, { ratio: 0.001, threshold: 24 }); // { outcome: "different", pixelRatio: 0.002 }
1603
+ * ```
1604
+ */
1605
+ function pixelOutcome(compared, tolerance) {
1606
+ if (!compared.sameSize) return {
1607
+ outcome: "different",
1608
+ first: "size"
1609
+ };
1610
+ const pixelRatio = compared.differing / compared.total;
1611
+ return pixelRatio > tolerance.ratio ? {
1612
+ outcome: "different",
1613
+ pixelRatio
1614
+ } : { outcome: "same" };
1615
+ }
1616
+ /**
1617
+ * Adds what the browser leg found to the checkpoint result of the headless leg. A state the page
1618
+ * reached differently is a state difference. `first` stays the state's before the describe's,
1619
+ * then names `"size"`. A pixel difference gets a verdict: `"rendering"` when the state and the
1620
+ * describe were the same in this run, the regression a renderer change makes, `"behaviour"`
1621
+ * otherwise.
1622
+ *
1623
+ * @param result - The checkpoint result of the headless leg.
1624
+ * @param found - What the browser leg found there.
1625
+ * @returns The checkpoint result with its pixel fields.
1626
+ * @example
1627
+ * ```ts
1628
+ * const headless = { name: "open", state: "same", describe: "same", pixels: "skipped" } as const;
1629
+ * withPixels(headless, { state: { outcome: "same" }, pixels: { outcome: "different", pixelRatio: 0.004 } });
1630
+ * // { name: "open", state: "same", describe: "same", pixels: "different", pixelRatio: 0.004, verdict: "rendering" }
1631
+ * ```
1632
+ */
1633
+ function withPixels(result, found) {
1634
+ const state = found.state?.outcome === "different" ? "different" : result.state;
1635
+ const stateFirst = result.state === "different" ? result.first : found.state?.first;
1636
+ const describeFirst = result.state === "different" ? void 0 : result.first;
1637
+ const first = stateFirst ?? describeFirst ?? found.pixels.first;
1638
+ const merged = {
1639
+ name: result.name,
1640
+ state,
1641
+ describe: result.describe,
1642
+ pixels: found.pixels.outcome
1643
+ };
1644
+ if (first !== void 0) merged.first = first;
1645
+ if (found.pixels.outcome !== "different") return merged;
1646
+ if (found.pixels.pixelRatio !== void 0) merged.pixelRatio = found.pixels.pixelRatio;
1647
+ merged.verdict = state === "same" && result.describe === "same" ? "rendering" : "behaviour";
1648
+ return merged;
1649
+ }
1650
+ /**
1651
+ * Runs one page function and takes its answer. A page that breaks, and a failure the function
1652
+ * answered as a reason, become the error of the step; a whole message is thrown as it is.
1653
+ *
1654
+ * @param page - The page.
1655
+ * @param fn - The page function.
1656
+ * @param argument - Its argument, with the test and the step.
1657
+ * @returns The value the function answered.
1658
+ * @throws {Error} When the page broke or the function failed.
1659
+ */
1660
+ async function callPage(page, fn, argument) {
1661
+ const answer = await page.evaluate(fn, argument).catch((error) => {
1662
+ throw stepFailure(argument.test, argument.where, firstLine(error));
1663
+ });
1664
+ if (answer.ok) return answer.value;
1665
+ throw "error" in answer ? new Error(answer.error) : stepFailure(argument.test, argument.where, answer.reason);
1666
+ }
1667
+ /**
1668
+ * Waits in the page for its graph to rest at its first gate, and reads the renderer kind.
1669
+ *
1670
+ * @param page - The page, its handles exposed.
1671
+ * @param url - The URL of the dev page, for the message.
1672
+ * @param run - The run options, for `settleFrames`.
1673
+ * @returns The renderer kind.
1674
+ * @throws {Error} When the graph does not rest at a gate, or the page breaks.
1675
+ */
1676
+ async function startPage(page, url, run) {
1677
+ const settleFrames = run.settleFrames;
1678
+ const started = await page.evaluate(pageStart, {
1679
+ url,
1680
+ settleFrames
1681
+ }).catch((error) => ({
1682
+ ok: false,
1683
+ reason: firstLine(error)
1684
+ }));
1685
+ if (started.ok) return started.value;
1686
+ throw new Error("error" in started ? started.error : `[game] The page at ${url} did not start.\n ${reasonOf(started.reason)}`);
1687
+ }
1688
+ /**
1689
+ * Opens the dev page for one test: the page script first, then the URL, then the wait for
1690
+ * `game` and `doors`, for the graph at its first gate, the check that it draws with WebGPU, and
1691
+ * the wait until no request ran for half a second, so no load is left for the stepped frames.
1692
+ *
1693
+ * @param context - The browser context.
1694
+ * @param url - The URL of the dev page.
1695
+ * @param run - The run options, for `settleFrames`.
1696
+ * @returns The open page.
1697
+ * @throws {Error} When the page does not open, never exposes `game` and `doors`, does not start,
1698
+ * does not draw with WebGPU or never stops loading. The page is closed then.
1699
+ */
1700
+ async function openPage(context, url, run) {
1701
+ const page = await context.newPage();
1702
+ try {
1703
+ await page.addInitScript({ content: pageScript() });
1704
+ await page.goto(url).catch((error) => {
1705
+ throw new Error(`[game] The page at ${url} did not open: ${firstLine(error)}.\n Serve the dev page (bun ./web/serve.ts), or run with --no-pixels.`);
1706
+ });
1707
+ await page.waitForFunction(pageReady, void 0, { timeout: READY_MS }).catch(() => {
1708
+ throw new Error(`[game] The page at ${url} exposes no game and doors.\n Serve the dev page (bun ./web/serve.ts) and set globalThis.game and globalThis.doors in it.`);
1709
+ });
1710
+ const kind = await startPage(page, url, run);
1711
+ if (kind !== "webgpu") throw new Error(`[game] The page at ${url} draws with ${kind}, not WebGPU.\n The pixel baselines are WebGPU only: use a Chrome with WebGPU on a Mac, or run with --no-pixels.`);
1712
+ await page.waitForLoadState("networkidle", { timeout: READY_MS }).catch(() => {
1713
+ throw new Error(`[game] The page at ${url} did not stop loading in ${READY_MS / 1e3} s.\n Let it finish its requests: the leg pauses the game once nothing loads.`);
1714
+ });
1715
+ return page;
1716
+ } catch (error) {
1717
+ await page.close();
1718
+ throw error;
1719
+ }
1720
+ }
1721
+ /**
1722
+ * Checks the picture of a checkpoint against `screen.webp`: a missing baseline, or `--update`,
1723
+ * writes it; a difference writes `screen.actual.webp`, and `screen.diff.webp` when the sizes
1724
+ * match.
1725
+ *
1726
+ * @param play - The test being played.
1727
+ * @param file - Names a file of the checkpoint.
1728
+ * @param where - The step, for the message.
1729
+ * @param screen - The picture the page took.
1730
+ * @returns The pixel outcome.
1731
+ */
1732
+ async function checkScreen(play, file, where, screen) {
1733
+ const { run } = play;
1734
+ const baseline = run.update ? void 0 : await readScreen(file(SCREEN.baseline));
1735
+ if (baseline === void 0) {
1736
+ await writeScreen(file(SCREEN.baseline), screen);
1737
+ return { outcome: "written" };
1738
+ }
1739
+ const compared = await callPage(play.page, pageCompare, {
1740
+ test: play.test.name,
1741
+ where,
1742
+ expected: baseline,
1743
+ actual: screen,
1744
+ threshold: run.tolerance.threshold
1745
+ });
1746
+ const found = pixelOutcome(compared, run.tolerance);
1747
+ if (found.outcome === "different") {
1748
+ await writeScreen(file(SCREEN.actual), screen);
1749
+ if (compared.diff !== void 0) await writeScreen(file(SCREEN.diff), compared.diff);
1750
+ }
1751
+ return found;
1752
+ }
1753
+ /**
1754
+ * Plays a checkpoint in the page and checks what it found: the state against `state.json` the
1755
+ * headless leg wrote, the picture against `screen.webp`.
1756
+ *
1757
+ * @param play - The test being played.
1758
+ * @param name - The checkpoint name.
1759
+ * @param where - The step, for the message.
1760
+ * @returns What the browser leg found there.
1761
+ * @throws {Error} When the page did not settle, gave no picture, or broke.
1762
+ */
1763
+ async function checkpoint(play, name, where) {
1764
+ const { test, run } = play;
1765
+ const shot = await callPage(play.page, pageCheckpoint, {
1766
+ test: test.name,
1767
+ where,
1768
+ settleFrames: run.settleFrames
1769
+ });
1770
+ if (shot.screen === void 0) throw new Error(`[game] Visual test "${test.name}", ${where}: the page gave no picture.\n Serve a dev build with WebGPU: renderer.capture() answers only there.`);
1771
+ const file = (kind) => baselineFile(run.dir, test.name, name, kind);
1772
+ return {
1773
+ state: await compareBaseline(file("state.json"), stableJson(shot.state)),
1774
+ pixels: await checkScreen(play, file, where, shot.screen)
1775
+ };
1776
+ }
1777
+ /**
1778
+ * Plays the start and the steps of a test in the open page, the way the headless leg does: pause
1779
+ * the game and step it to the start time, restore the start, run each command and settle on
1780
+ * stepped frames, and check each checkpoint.
1781
+ *
1782
+ * @param play - The test being played.
1783
+ * @param found - Where what each checkpoint found goes, as soon as it is known.
1784
+ */
1785
+ async function playSteps(play, found) {
1786
+ const { test, run } = play;
1787
+ const repro = {
1788
+ ...test.start,
1789
+ route: []
1790
+ };
1791
+ const base = {
1792
+ test: test.name,
1793
+ settleFrames: run.settleFrames
1794
+ };
1795
+ await callPage(play.page, pagePause, {
1796
+ test: test.name,
1797
+ where: "the start",
1798
+ startMs: START_MS
1799
+ });
1800
+ await callPage(play.page, pageStep, {
1801
+ ...base,
1802
+ where: "the start",
1803
+ name: "restore",
1804
+ input: { repro }
1805
+ });
1806
+ for (const [index, step] of test.steps.entries()) {
1807
+ const entry = stepEntry(test.name, step, index);
1808
+ const where = `step ${index + 1} (${entry.kind === "checkpoint" ? "checkpoint" : entry.name})`;
1809
+ if (entry.kind === "checkpoint") {
1810
+ found.set(entry.name, await checkpoint(play, entry.name, where));
1811
+ continue;
1812
+ }
1813
+ await callPage(play.page, pageStep, {
1814
+ ...base,
1815
+ where,
1816
+ name: entry.name,
1817
+ input: entry.input
1818
+ });
1819
+ }
1820
+ }
1821
+ /**
1822
+ * Plays one test in a page of its own and adds the pixel fields to its headless result. A test
1823
+ * that fails keeps what the checkpoints it reached found, and carries the error.
1824
+ *
1825
+ * @param context - The browser context.
1826
+ * @param play - The test and the run options; the page is opened here.
1827
+ * @param play.test - The test.
1828
+ * @param play.run - The run options.
1829
+ * @param url - The URL of the dev page.
1830
+ * @param headless - The result of the test in the headless leg.
1831
+ * @returns The result of the test with its pixel fields.
1832
+ * @throws {Error} When the page fails to open: that fails the leg.
1833
+ */
1834
+ async function browserTest(context, play, url, headless) {
1835
+ const page = await openPage(context, url, play.run);
1836
+ const found = /* @__PURE__ */ new Map();
1837
+ let failed;
1838
+ try {
1839
+ await playSteps({
1840
+ ...play,
1841
+ page
1842
+ }, found);
1843
+ } catch (error) {
1844
+ failed = messageOf(error);
1845
+ } finally {
1846
+ await page.close();
1847
+ }
1848
+ const checkpoints = headless.checkpoints.map((result) => {
1849
+ const there = found.get(result.name);
1850
+ return there === void 0 ? result : withPixels(result, there);
1851
+ });
1852
+ return failed === void 0 ? {
1853
+ name: headless.name,
1854
+ checkpoints
1855
+ } : {
1856
+ name: headless.name,
1857
+ checkpoints,
1858
+ error: failed
1859
+ };
1860
+ }
1861
+ /**
1862
+ * Runs the browser leg over the tests the headless leg played, one page per test, and answers
1863
+ * their results with the pixel fields. A test the headless leg failed is left as it is. The
1864
+ * browser closes at the end, also after an error.
1865
+ *
1866
+ * @param setup - The setup: its `page`, and its app for the size of the page.
1867
+ * @param tests - The selected tests.
1868
+ * @param run - The run options.
1869
+ * @param headless - The results of the headless leg, in the order of `tests`.
1870
+ * @param load - Loads `chromium`; `playwright-core` by default.
1871
+ * @returns One result per test, in order.
1872
+ * @throws {Error} When the setup has no page, `playwright-core` or Chrome is missing, or the page
1873
+ * does not open, expose `game` and `doors`, start, draw with WebGPU or stop loading.
1874
+ */
1875
+ async function runBrowserLeg(setup, tests, run, headless, load = loadChromium) {
1876
+ const page = setup.page;
1877
+ if (page === void 0) throw new Error("[game] The pixel leg needs the dev page.\n Pass page: { url } in the setup, or run with --no-pixels.");
1878
+ const chromium = await load();
1879
+ const options = pageSize(setup, page);
1880
+ const browser = await launchChrome(chromium, page.browser);
1881
+ try {
1882
+ const context = await browser.newContext(options);
1883
+ const results = [];
1884
+ for (const test of tests) {
1885
+ const before = headless.find((result) => result.name === test.name) ?? {
1886
+ name: test.name,
1887
+ checkpoints: []
1888
+ };
1889
+ results.push(before.error === void 0 ? await browserTest(context, {
1890
+ test,
1891
+ run
1892
+ }, page.url, before) : before);
1893
+ }
1894
+ return results;
1895
+ } finally {
1896
+ await browser.close();
1897
+ }
1898
+ }
1899
+ //#endregion
1900
+ //#region src/visual/run.ts
1901
+ /**
1902
+ * @file visual — `runVisualTests`, the runner a game's script calls, and `parseVisualArgv`, its
1903
+ * command line. It resolves the options, selects the tests, runs the headless leg and then, with
1904
+ * `pixels` on, the browser leg, prints one line per checkpoint through the branded console of
1905
+ * `@moku-labs/common/cli` and answers one report. The game's script sets the exit code.
1906
+ */
1907
+ /** The folder of the visual tests, relative to where the script runs. */
1908
+ const DEFAULT_DIR = "tests/visual";
1909
+ /** The most frames a settle runs: ten seconds at 60 fps. */
1910
+ const DEFAULT_SETTLE_FRAMES = 600;
1911
+ /** A pixel differs above a channel delta of 24; one in a thousand may. */
1912
+ const DEFAULT_TOLERANCE = {
1913
+ ratio: .001,
1914
+ threshold: 24
1915
+ };
1916
+ /**
1917
+ * Reads the value after a flag that takes one.
1918
+ *
1919
+ * @param argv - The arguments.
1920
+ * @param index - The index of the flag.
1921
+ * @param placeholder - What the value is, for the message: `<name>`.
1922
+ * @returns The value.
1923
+ * @throws {Error} When the flag is last, or another flag follows it.
1924
+ */
1925
+ function valueAfter(argv, index, placeholder) {
1926
+ const value = argv[index + 1];
1927
+ if (value !== void 0 && !value.startsWith("--")) return value;
1928
+ const flag = argv[index];
1929
+ throw new Error(`[game] The flag ${flag} needs a value.\n Write it as ${flag} ${placeholder}.`);
1930
+ }
1931
+ /**
1932
+ * Reads the flags of the visual tests from a command line: `--update`, `--no-pixels`,
1933
+ * `--only <name>` (repeatable) and `--dir <path>`. An argument it does not know is left to the
1934
+ * script that got it, so a test runner's own arguments pass through.
1935
+ *
1936
+ * @param argv - The arguments after the script name.
1937
+ * @returns The flags that were given, and nothing else.
1938
+ * @throws {Error} When `--only` or `--dir` has no value.
1939
+ * @example
1940
+ * ```ts
1941
+ * // `bun tests/visual/run.ts --update --only reward-popup`
1942
+ * parseVisualArgv(["--update", "--only", "reward-popup"]); // { update: true, only: ["reward-popup"] }
1943
+ * parseVisualArgv(["--no-pixels", "--dir", "shots"]); // { pixels: false, dir: "shots" }
1944
+ * ```
1945
+ */
1946
+ function parseVisualArgv(argv) {
1947
+ const flags = {};
1948
+ const only = [];
1949
+ for (const [index, argument] of argv.entries()) {
1950
+ if (argument === "--update") flags.update = true;
1951
+ if (argument === "--no-pixels") flags.pixels = false;
1952
+ if (argument === "--only") only.push(valueAfter(argv, index, "<name>"));
1953
+ if (argument === "--dir") flags.dir = valueAfter(argv, index, "<path>");
1954
+ }
1955
+ return only.length === 0 ? flags : {
1956
+ ...flags,
1957
+ only
1958
+ };
1959
+ }
1960
+ /**
1961
+ * Fills in the options of a run: an explicit option wins over a flag of `argv`, a flag over the
1962
+ * default. `pixels` is on by default only with a `page` on a Mac.
1963
+ *
1964
+ * @param setup - The setup, for its `page`.
1965
+ * @param options - The options the script passed.
1966
+ * @returns The options with every default filled in.
1967
+ * @throws {Error} When a flag of `argv` misses its value.
1968
+ */
1969
+ function resolveVisualOptions(setup, options = {}) {
1970
+ const flags = parseVisualArgv(options.argv ?? process.argv.slice(2));
1971
+ const only = options.only ?? flags.only;
1972
+ const run = {
1973
+ dir: options.dir ?? flags.dir ?? DEFAULT_DIR,
1974
+ update: options.update ?? flags.update ?? false,
1975
+ pixels: options.pixels ?? flags.pixels ?? (setup.page !== void 0 && process.platform === "darwin"),
1976
+ settleFrames: options.settleFrames ?? DEFAULT_SETTLE_FRAMES,
1977
+ tolerance: options.tolerance ?? { ...DEFAULT_TOLERANCE }
1978
+ };
1979
+ return only === void 0 ? run : {
1980
+ ...run,
1981
+ only
1982
+ };
1983
+ }
1984
+ /**
1985
+ * Picks the tests a run plays: all of them, or the ones `only` names, in the order given.
1986
+ *
1987
+ * @param tests - Every test of the script.
1988
+ * @param only - The names to run, if any.
1989
+ * @returns The selected tests.
1990
+ * @throws {Error} When a test is not well formed, two tests share a name, or `only` names no
1991
+ * test.
1992
+ */
1993
+ function selectTests(tests, only) {
1994
+ for (const test of tests) checkVisualTest(test.name, test.steps);
1995
+ const names = tests.map((test) => test.name);
1996
+ const twice = names.find((name, index) => names.indexOf(name) !== index);
1997
+ if (twice !== void 0) throw new Error(`[game] Two visual tests are named "${twice}".\n Give every visual test its own name.`);
1998
+ const unknown = only?.find((name) => !names.includes(name));
1999
+ if (unknown !== void 0) throw new Error(`[game] No visual test is named "${unknown}".\n Use one of: ${names.join(", ")}.`);
2000
+ return only === void 0 ? [...tests] : tests.filter((test) => only.includes(test.name));
2001
+ }
2002
+ /**
2003
+ * Writes one comparison of a checkpoint for its line.
2004
+ *
2005
+ * @param kind - `state`, `describe` or `pixels`.
2006
+ * @param outcome - What the comparison found.
2007
+ * @param first - The path of the first difference, for `state` and `describe`.
2008
+ * @returns `state same`, or `state different at player.coins`.
2009
+ * @example
2010
+ * ```ts
2011
+ * outcomeText("state", "different", "player.coins"); // "state different at player.coins"
2012
+ * ```
2013
+ */
2014
+ function outcomeText(kind, outcome, first) {
2015
+ return outcome === "different" && first !== void 0 ? `${kind} ${outcome} at ${first}` : `${kind} ${outcome}`;
2016
+ }
2017
+ /**
2018
+ * Writes the pixel comparison of a checkpoint for its line: on a difference, the share of
2019
+ * differing pixels, or `at size`, and the verdict.
2020
+ *
2021
+ * @param result - The checkpoint's outcome.
2022
+ * @returns `pixels same`, or `pixels different 0.42% (rendering)`.
2023
+ * @example
2024
+ * ```ts
2025
+ * pixelText({ name: "open", state: "same", describe: "same", pixels: "different", pixelRatio: 0.0042, verdict: "rendering" });
2026
+ * // "pixels different 0.42% (rendering)"
2027
+ * ```
2028
+ */
2029
+ function pixelText(result) {
2030
+ if (result.pixels !== "different") return `pixels ${result.pixels}`;
2031
+ return `pixels different ${result.pixelRatio === void 0 ? "at size" : `${(result.pixelRatio * 100).toFixed(2)}%`} (${result.verdict ?? "behaviour"})`;
2032
+ }
2033
+ /**
2034
+ * Writes the line of one checkpoint. The first difference is the state's when the state
2035
+ * differs, the describe's otherwise.
2036
+ *
2037
+ * @param test - The test name.
2038
+ * @param result - The checkpoint's outcome.
2039
+ * @returns `reward-popup/open: state same · describe same · pixels skipped`.
2040
+ */
2041
+ function checkpointLine(test, result) {
2042
+ const stateFirst = result.state === "different" ? result.first : void 0;
2043
+ const describeFirst = result.state === "different" ? void 0 : result.first;
2044
+ return [
2045
+ `${test}/${result.name}:`,
2046
+ outcomeText("state", result.state, stateFirst),
2047
+ "·",
2048
+ outcomeText("describe", result.describe, describeFirst),
2049
+ "·",
2050
+ pixelText(result)
2051
+ ].join(" ");
2052
+ }
2053
+ /**
2054
+ * Tells whether a checkpoint passed: no comparison found a difference.
2055
+ *
2056
+ * @param result - The checkpoint's outcome.
2057
+ * @returns True when nothing is `"different"`.
2058
+ */
2059
+ function passed(result) {
2060
+ return [
2061
+ result.state,
2062
+ result.describe,
2063
+ result.pixels
2064
+ ].every((outcome) => outcome !== "different");
2065
+ }
2066
+ /**
2067
+ * Prints one line per checkpoint, and the error of a test that ended early.
2068
+ *
2069
+ * @param ui - Where the lines go.
2070
+ * @param results - The results of the run.
2071
+ */
2072
+ function printResults(ui, results) {
2073
+ for (const result of results) {
2074
+ for (const checkpoint of result.checkpoints) ui.check(passed(checkpoint), checkpointLine(result.name, checkpoint));
2075
+ if (result.error !== void 0) ui.error(result.error);
2076
+ }
2077
+ }
2078
+ /**
2079
+ * Runs the browser leg after the headless one. When the leg fails as a whole, the lines of the
2080
+ * headless leg are printed before its error is thrown, so they are not lost.
2081
+ *
2082
+ * @param ui - Where the lines go.
2083
+ * @param setup - How to build a game, and the dev page.
2084
+ * @param tests - The selected tests.
2085
+ * @param run - The run options.
2086
+ * @param headless - The results of the headless leg.
2087
+ * @returns The results with their pixel fields.
2088
+ * @throws {Error} Whatever fails the browser leg.
2089
+ */
2090
+ async function withPixelLeg(ui, setup, tests, run, headless) {
2091
+ try {
2092
+ return await runBrowserLeg(setup, tests, run, headless);
2093
+ } catch (error) {
2094
+ printResults(ui, headless);
2095
+ throw error;
2096
+ }
2097
+ }
2098
+ /**
2099
+ * Runs visual tests: the headless leg over every selected test, then the browser leg over the
2100
+ * same tests when `pixels` is on (by default only with a `page` on a Mac). A checkpoint saves
2101
+ * `state.json` and `describe.json` in `<dir>/<test>/<checkpoint>/`, and the browser leg
2102
+ * `screen.webp`, lossless: a missing file is written, `--update` rewrites every file of the tests
2103
+ * run, a JSON file is compared exactly. The browser leg opens the dev page in Chrome with WebGPU
2104
+ * through `playwright-core`, pauses its game, plays each test there on stepped frames of 1000/60
2105
+ * ms, so two runs see the same game time, checks the page's state against `state.json` and
2106
+ * compares the picture in the page: a pixel differs above `tolerance.threshold`, and the screen
2107
+ * differs when more than `tolerance.ratio` of its pixels do; `screen.actual.webp` and
2108
+ * `screen.diff.webp` are written beside the baseline then. A pixel difference over the same state
2109
+ * and describe is a rendering regression (`verdict: "rendering"`). One line per checkpoint goes
2110
+ * to the branded console; the script sets the exit code from `ok`.
2111
+ *
2112
+ * @param setup - How to build a game: a fresh, unstarted app per test, and the dev page.
2113
+ * @param tests - Every visual test of the game.
2114
+ * @param options - Overrides of the flags of `argv` and of the defaults.
2115
+ * @returns The report: `ok` when no test failed and nothing is `"different"`.
2116
+ * @throws {Error} When a test is not well formed, two tests share a name, `only` names no test,
2117
+ * a flag misses its value, or the browser leg fails as a whole: no `page`, no
2118
+ * `playwright-core`, no Chrome, or a page that does not open, expose `game` and `doors`, start,
2119
+ * draw with WebGPU or stop loading.
2120
+ * @example
2121
+ * ```ts
2122
+ * // tests/visual/run.ts of a game, `bun tests/visual/run.ts --update` on a Mac
2123
+ * const report = await runVisualTests(
2124
+ * { app: () => createScreenGame().app, page: { url: "http://localhost:3000/" } },
2125
+ * [rewardPopup]
2126
+ * );
2127
+ * report.tests[0]?.checkpoints[0]; // { name: "open", state: "written", describe: "written", pixels: "written" }
2128
+ * ```
2129
+ */
2130
+ async function runVisualTests(setup, tests, options = {}) {
2131
+ const run = resolveVisualOptions(setup, options);
2132
+ const selected = selectTests(tests, run.only);
2133
+ const ui = createBrandConsole();
2134
+ const headless = await runHeadlessLeg(setup, selected, run);
2135
+ const results = run.pixels ? await withPixelLeg(ui, setup, selected, run, headless) : headless;
2136
+ printResults(ui, results);
2137
+ return {
2138
+ ok: results.every((result) => result.error === void 0 && result.checkpoints.every((checkpoint) => passed(checkpoint))),
2139
+ tests: results
2140
+ };
2141
+ }
2142
+ //#endregion
2143
+ export { createHeadless, defineVisualTest, fakeClock, memory, parseVisualArgv, runRepro, runVisualTests, saveOf, stepFrames };