game-harness 1.0.0

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 (69) hide show
  1. package/AGENTS.md +140 -0
  2. package/CHANGELOG.md +69 -0
  3. package/LICENSE +21 -0
  4. package/README.md +568 -0
  5. package/bin/test-harness-visual-battery.mjs +6 -0
  6. package/dist/cjs/bin/visual-battery.js +43 -0
  7. package/dist/cjs/browser-config.js +83 -0
  8. package/dist/cjs/chromium-launch.js +37 -0
  9. package/dist/cjs/index.js +10 -0
  10. package/dist/cjs/lighthouse.js +73 -0
  11. package/dist/cjs/package.json +3 -0
  12. package/dist/cjs/playwright-config.js +303 -0
  13. package/dist/cjs/production-runtime.js +315 -0
  14. package/dist/cjs/release-ladder.js +40 -0
  15. package/dist/cjs/silent-qa.js +75 -0
  16. package/dist/cjs/visual-battery.js +247 -0
  17. package/dist/esm/bin/visual-battery.js +41 -0
  18. package/dist/esm/browser-config.js +80 -0
  19. package/dist/esm/chromium-launch.js +34 -0
  20. package/dist/esm/index.js +3 -0
  21. package/dist/esm/lighthouse.js +70 -0
  22. package/dist/esm/playwright-config.js +292 -0
  23. package/dist/esm/production-runtime.js +304 -0
  24. package/dist/esm/release-ladder.js +37 -0
  25. package/dist/esm/silent-qa.js +67 -0
  26. package/dist/esm/visual-battery.js +239 -0
  27. package/dist/types/bin/visual-battery.d.cts +2 -0
  28. package/dist/types/bin/visual-battery.d.ts +2 -0
  29. package/dist/types/browser-config.d.cts +100 -0
  30. package/dist/types/browser-config.d.ts +100 -0
  31. package/dist/types/chromium-launch.d.cts +27 -0
  32. package/dist/types/chromium-launch.d.ts +27 -0
  33. package/dist/types/index.d.cts +3 -0
  34. package/dist/types/index.d.ts +3 -0
  35. package/dist/types/lighthouse.d.cts +44 -0
  36. package/dist/types/lighthouse.d.ts +44 -0
  37. package/dist/types/playwright-config.d.cts +122 -0
  38. package/dist/types/playwright-config.d.ts +122 -0
  39. package/dist/types/production-runtime.d.cts +105 -0
  40. package/dist/types/production-runtime.d.ts +105 -0
  41. package/dist/types/release-ladder.d.cts +38 -0
  42. package/dist/types/release-ladder.d.ts +38 -0
  43. package/dist/types/silent-qa.d.cts +47 -0
  44. package/dist/types/silent-qa.d.ts +47 -0
  45. package/dist/types/visual-battery.d.cts +69 -0
  46. package/dist/types/visual-battery.d.ts +69 -0
  47. package/docs/404.md +17 -0
  48. package/docs/agent-guide.md +10 -0
  49. package/docs/architecture.md +81 -0
  50. package/docs/assets/game-harness-hero.webp +0 -0
  51. package/docs/changelog.md +9 -0
  52. package/docs/contributing.md +21 -0
  53. package/docs/entry-points.md +41 -0
  54. package/docs/getting-started.md +42 -0
  55. package/docs/guides/chromium-and-silent-qa.md +72 -0
  56. package/docs/guides/lighthouse-and-release-ladder.md +62 -0
  57. package/docs/guides/playwright.md +58 -0
  58. package/docs/guides/production-runtime.md +61 -0
  59. package/docs/guides/visual-battery.md +58 -0
  60. package/docs/guides/vitest.md +41 -0
  61. package/docs/introduction.md +29 -0
  62. package/docs/package.json +13 -0
  63. package/docs/quick-start.md +47 -0
  64. package/docs/reference/troubleshooting.md +33 -0
  65. package/docs/security.md +15 -0
  66. package/docs/sourcey.config.ts +79 -0
  67. package/llms.txt +32 -0
  68. package/maestro/smoke.template.yaml +11 -0
  69. package/package.json +196 -0
package/README.md ADDED
@@ -0,0 +1,568 @@
1
+ # game-harness
2
+
3
+ ![A browser-game diorama passing through a precision test gantry, with device previews, a muted-audio control, and a lighthouse verification beam](docs/assets/game-harness-hero.webp)
4
+
5
+ [![CI](https://github.com/jbcom/game-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/jbcom/game-harness/actions/workflows/ci.yml)
6
+ [![Node 22+](https://img.shields.io/badge/Node.js-22%2B-417e38)](package.json)
7
+ [![MIT license](https://img.shields.io/badge/license-MIT-0f766e)](LICENSE)
8
+
9
+ Release-grade browser QA primitives for TypeScript games. Game Harness turns a
10
+ successful build into evidence: fresh silent browser sessions, deterministic
11
+ device tiers, byte-exact screenshot gates, production-runtime assertions,
12
+ Lighthouse policy, and an ordered release ladder.
13
+
14
+ It is intentionally a focused library rather than a test framework. Your game
15
+ keeps its own journeys, assertions, art direction, and audio engine; Game
16
+ Harness supplies the reusable safety and orchestration layer around Playwright
17
+ and Vitest Browser Mode.
18
+
19
+ ## Why use it?
20
+
21
+ - **Silent by construction.** Chromium is launched with `--mute-audio`, and
22
+ tests wait for an application-owned runtime mute marker before interacting.
23
+ - **No accidental server reuse.** CI ports are deterministic per job, preview
24
+ commands use strict-port semantics, and production verification refuses an
25
+ already-reachable readiness URL.
26
+ - **Visual evidence that fails closed.** Screenshot baselines are scoped,
27
+ profile-aware, and checked through Git without fuzzy thresholds or shell
28
+ interpolation.
29
+ - **Real package boundaries.** Framework peers stay optional and isolated to
30
+ subpath exports, with clean ESM, CommonJS, type, CLI, and install smoke tests.
31
+ - **Useful defaults with escape hatches.** Headed Chromium, device tiers,
32
+ timeouts, renderer profiles, and Lighthouse assertions are explicit and
33
+ composable.
34
+
35
+ ## Installation
36
+
37
+ ### Package name migration
38
+
39
+ `game-harness` is the canonical public package. The earlier
40
+ `@jbdevprimary/game-harness` package is a personal-user scope and is being
41
+ retired; update existing dependency declarations and imports to `game-harness`.
42
+
43
+ Node 22 or newer is required. Install the package plus only the peer family for
44
+ the integration you use:
45
+
46
+ ```sh
47
+ # Playwright config and production-runtime verification
48
+ pnpm add -D game-harness @playwright/test
49
+ pnpm exec playwright install chromium
50
+
51
+ # Vitest Browser Mode instead
52
+ pnpm add -D game-harness vitest @vitest/browser-playwright playwright
53
+ pnpm exec playwright install chromium
54
+
55
+ # Peer-free Lighthouse, release-ladder, or visual-battery utilities
56
+ pnpm add -D game-harness
57
+ ```
58
+
59
+ ## Quick start
60
+
61
+ Activate the runtime-only mute before the application restores saved audio
62
+ preferences or creates anything that can play sound:
63
+
64
+ ```ts
65
+ // src/silent-qa.ts
66
+ import { activateSilentQa } from 'game-harness/silent-qa';
67
+ import { Howler } from 'howler';
68
+
69
+ export const silentQaActive = activateSilentQa(() => Howler.mute(true));
70
+ ```
71
+
72
+ Then use the shared Playwright config and open the game through the fail-closed
73
+ navigation helper:
74
+
75
+ ```ts
76
+ // playwright.config.ts
77
+ import { definePlaywrightConfig } from 'game-harness/playwright';
78
+
79
+ export default definePlaywrightConfig({
80
+ port: 4391,
81
+ webServerCommand: (port) =>
82
+ `pnpm build && pnpm exec vite preview --host 127.0.0.1 --port ${port} --strictPort`,
83
+ });
84
+ ```
85
+
86
+ ```ts
87
+ // tests/e2e/boot.spec.ts
88
+ import { expect, test } from '@playwright/test';
89
+ import { openSilentGame } from 'game-harness/playwright';
90
+
91
+ test('boots the intended game silently', async ({ page }) => {
92
+ await openSilentGame(page, '/my-game/', { scenario: 'new-game' });
93
+ await expect(page.getByRole('heading', { name: 'My Game' })).toBeVisible();
94
+ await expect(page.locator('canvas')).toBeVisible();
95
+ });
96
+ ```
97
+
98
+ The application marker is part of the contract: `openSilentGame()` does not
99
+ return until `<html data-audio-mode="muted-test">` exists.
100
+
101
+ ## Entry points
102
+
103
+ Import only the entry point a game uses:
104
+
105
+ - `game-harness/playwright` for Playwright projects and strict
106
+ preview-server configuration; install `@playwright/test`;
107
+ - `game-harness/silent-qa` for a peer-free,
108
+ audio-engine-agnostic application-side runtime mute adapter;
109
+ - `game-harness/production-runtime` for a fresh, silent
110
+ production-artifact or exact-live boot; install `@playwright/test`;
111
+ - `game-harness/chromium` for peer-free Chromium renderer and
112
+ silence launch profiles;
113
+ - `game-harness/vitest` for Vitest Browser Mode; install
114
+ `vitest` and `@vitest/browser-playwright`;
115
+ - `game-harness`, `/lighthouse`, `/release-ladder`, and
116
+ `/visual-battery` for peer-free verification utilities.
117
+
118
+ Framework peers are intentionally optional at install time and are never loaded
119
+ by the package root. Each consumer must install the peers required by the
120
+ framework entry point it imports. A Playwright-only game, for example, installs
121
+ `@playwright/test` but does not need Vitest or `@vitest/browser-playwright`.
122
+
123
+ ## Current release matrix
124
+
125
+ `engines.node` declares `>=22`, the earliest Node LTS line still actively
126
+ supported. CI pins the primary gate to the version in `.nvmrc` (currently
127
+ 24.19.0, the latest Node 24 LTS patch) and additionally runs the full test
128
+ and build suite against Node 22 on Linux to prove the floor of that range,
129
+ alongside macOS and Windows portability on the pinned version. The current
130
+ conformance matrix is Playwright 1.62.1 and Vitest Browser 4.1.10.
131
+ Package-boundary consumers run with a credential-free home directory and npm
132
+ configuration, install only the peer family needed by each entry point, and
133
+ exercise ESM, CommonJS, the CLI, silent runtime markers, and Chromium launch
134
+ profiles. `publint` and `@arethetypeswrong/cli` independently validate
135
+ package metadata and declarations.
136
+
137
+ Every packed release carries this README, the architecture guide, changelog,
138
+ hero artwork, and the package-local MIT license.
139
+
140
+ ## Playwright example
141
+
142
+ ```ts
143
+ import { definePlaywrightConfig } from 'game-harness/playwright';
144
+
145
+ export default definePlaywrightConfig({
146
+ port: 4391,
147
+ deviceTiers: ['desktop', 'mobile'],
148
+ gpuMode: process.env.CI ? 'linux-hardware-vulkan' : 'auto',
149
+ webServerCommand: (port) =>
150
+ `pnpm build && pnpm exec vite preview --host 127.0.0.1 --port ${port} --strictPort`,
151
+ });
152
+ ```
153
+
154
+ The configured `port` is the stable local-development port. On GitHub or Gitea
155
+ Actions, the factory derives a deterministic port from repository, run, and job
156
+ identity so concurrent workflows cannot accidentally share the same preview.
157
+ Every Playwright config reload in the parent and worker processes resolves the
158
+ same value. `PLAYWRIGHT_PORT` or `PW_PORT` remains an exact override. A rare
159
+ cross-process/hash collision still fails closed because the preview must use
160
+ strict-port semantics; it never reuses a reachable process.
161
+
162
+ Playwright 1.62 forces coloured output in its web-server and worker children.
163
+ If the invoking shell exports `NO_COLOR`, Node warns that the variable is
164
+ ignored once Playwright adds `FORCE_COLOR=1`. During config evaluation the
165
+ factory therefore removes only the already-ignored `NO_COLOR` value from the
166
+ Playwright process environment before those children are spawned. This does
167
+ not modify the parent shell, and every other environment variable and warning
168
+ remains intact.
169
+
170
+ Use `webServerCommand(port)` when a consumer needs build or asset-preparation
171
+ steps around its preview. The callback receives the already-resolved local or
172
+ CI-isolated port. A literal `overrides.webServer.command` remains supported,
173
+ but a command that hard-codes its own port bypasses isolation and is not valid
174
+ release evidence.
175
+
176
+ Chromium is headed by default locally and in CI. Linux CI must provide a
177
+ display with `xvfb-run`; it should not change the browser to headless simply to
178
+ make WebGL start. The default `auto` profile lets Chromium select the native
179
+ renderer (including Metal on macOS). `software` is an explicit SwiftShader
180
+ fallback. `linux-hardware-vulkan` applies the reviewed Mesa/ANGLE flags and
181
+ `EGL_PLATFORM=surfaceless` for a runner that exposes `/dev/dri/renderD128`.
182
+
183
+ Vitest's interactive UI is disabled by default even though the Chromium window
184
+ remains visible. This gives Playwright a fixed viewport and a deterministic
185
+ device scale of 1, so a headed macOS run does not silently recapture visual
186
+ baselines at the host Retina scale. Pass `ui: true` only for an interactive
187
+ debugging session.
188
+
189
+ A Gitea runner job using the hardware profile must install
190
+ `mesa-vulkan-drivers` and `xvfb`, require the render device with
191
+ `test -c /dev/dri/renderD128`, and run the browser command under
192
+ `xvfb-run --auto-servernum`. Use `requireHardwareWebGL()` in the journey itself
193
+ so a green test cannot silently fall back to SwiftShader or llvmpipe. The
194
+ assertion also fails closed when the browser withholds its unmasked renderer.
195
+
196
+ `deviceTiers` declares the available matrix, while the default fast gate runs
197
+ only its first tier. Run `MULTIVIEW=1 pnpm exec playwright test` (or
198
+ `VISUAL=1 ...`) to include every declared tier.
199
+
200
+ ## Vitest Browser Mode example
201
+
202
+ ```ts
203
+ import { defineConfig } from 'vitest/config';
204
+ import { defineBrowserTestConfig } from 'game-harness/vitest';
205
+
206
+ export default defineConfig({
207
+ test: {
208
+ projects: [
209
+ {
210
+ extends: true,
211
+ test: { name: 'unit', environment: 'node', include: ['tests/unit/**'] },
212
+ },
213
+ {
214
+ extends: true,
215
+ test: defineBrowserTestConfig({
216
+ optimizeDeps: ['three/examples/jsm/utils/SkeletonUtils.js'],
217
+ }),
218
+ },
219
+ ],
220
+ },
221
+ });
222
+ ```
223
+
224
+ `defineBrowserTestConfig()` builds the `test` fragment for a real-Chromium
225
+ Vitest Browser Mode project: headed by default (both locally and in CI, same
226
+ as `definePlaywrightConfig()`), silent at the Chromium launch boundary, and a
227
+ fixed viewport with `deviceScaleFactor: 1` so a headed macOS run never
228
+ recaptures visual baselines at the host's Retina scale. Pass `headless:
229
+ 'ci-only'` only for a hosted runner genuinely without a display; CI should
230
+ normally keep the headed default and run under `xvfb-run`. Requires `vitest`
231
+ and `@vitest/browser-playwright` installed as peers.
232
+
233
+ `optimizeDeps` surfaces module specifiers (deep imports Vite's scanner won't
234
+ discover on its own) that must also be merged into your top-level
235
+ `vite.config.ts`'s `optimizeDeps.include` — read them off the returned
236
+ fragment's `__optimizeDepsInclude` field, since Vitest's `test` block has no
237
+ `optimizeDeps` field of its own.
238
+
239
+ ## Chromium launch profile
240
+
241
+ ```ts
242
+ import { chromium } from '@playwright/test';
243
+ import { createChromiumLaunchProfile } from 'game-harness/chromium';
244
+
245
+ const { args, env } = createChromiumLaunchProfile({
246
+ gpuMode: process.env.CI ? 'linux-hardware-vulkan' : 'auto',
247
+ });
248
+ const browser = await chromium.launch({ args, env, headless: false });
249
+ ```
250
+
251
+ `createChromiumLaunchProfile()` is the peer-free primitive behind both
252
+ `definePlaywrightConfig()` and `defineBrowserTestConfig()` — use it directly
253
+ when driving Chromium yourself (a custom launch script, `production-runtime`'s
254
+ own internals, or a non-Playwright automation layer). It resolves one of three
255
+ renderer policies (`auto` leaves selection to Chromium; `software` opts into
256
+ SwiftShader explicitly; `linux-hardware-vulkan` applies the reviewed
257
+ Mesa/ANGLE flags plus `EGL_PLATFORM=surfaceless` for a runner exposing
258
+ `/dev/dri/renderD128`) and always de-duplicates and appends `--mute-audio`
259
+ last, so a caller-supplied arg list can never accidentally drop the silence
260
+ guard.
261
+
262
+ Every browser launched by `definePlaywrightConfig()` or
263
+ `defineBrowserTestConfig()` receives Chromium's `--mute-audio` argument as a
264
+ defense-in-depth guard, including projects with custom launch options. The
265
+ application must also expose a non-persistent mute mode so tests fail closed
266
+ before interacting with it:
267
+
268
+ ```ts
269
+ import { activateSilentQa } from 'game-harness/silent-qa';
270
+ import { Howler } from 'howler';
271
+
272
+ // Evaluate before the rest of the application/audio graph.
273
+ export const silentQaActive = activateSilentQa(() => Howler.mute(true));
274
+ ```
275
+
276
+ `activateSilentQa()` detects `?muted` by presence, invokes the consumer-owned
277
+ audio-engine callback, and only then publishes
278
+ `<html data-audio-mode="muted-test">`. It never reads or writes saved audio
279
+ preferences. Consumers use its boolean return value or `isSilentQaActive()` to
280
+ prevent later preference restoration from overriding the page-lifetime mute.
281
+ If the audio engine mutes asynchronously, await `activateSilentQaAsync()`;
282
+ passing a promise-returning callback to the synchronous function throws instead
283
+ of publishing a premature readiness marker.
284
+
285
+ ```ts
286
+ import { openSilentGame } from 'game-harness/playwright';
287
+
288
+ test('starts a game without audible QA', async ({ page }) => {
289
+ await openSilentGame(page, '/my-game/', { scenario: 'new-game' });
290
+ // The helper returns only after html[data-audio-mode="muted-test"] exists.
291
+ });
292
+ ```
293
+
294
+ `openSilentGame()` adds `?muted=1` (preserving other query parameters and the
295
+ fragment), navigates, and requires the application to set
296
+ `data-audio-mode="muted-test"` on `<html>`. The mute mode is runtime-only: it
297
+ must mute audio before any sound objects can play and must never write the
298
+ player's saved audio preference. Use `silentTestUrl()` when a test needs the
299
+ URL without navigating. Custom marker/query names are supported for legacy
300
+ adapters, but new games use these defaults. There is no audible-debug
301
+ exception: verify audio behavior through programmatic state, mocks, or analyser
302
+ assertions while agent-controlled playback remains muted.
303
+
304
+ Keep `reuseExistingServer` false for release evidence and assert the game
305
+ identity before exercising a journey. A process from another repository on a
306
+ familiar port must never be accepted as proof.
307
+
308
+ Before publishing, run `pnpm verify` under the pinned release toolchain. The
309
+ package verifier uses npm 11.17.0 directly from the package directory, packs a
310
+ tarball, and installs it into credential-free temporary consumers for the
311
+ peer-free root, Playwright/production-runtime, and Vitest Browser boundaries.
312
+ The release workflow repeats the full gate before publishing with npm
313
+ provenance.
314
+
315
+ ## Production runtime verification
316
+
317
+ `verifyProductionRuntime()` turns a successful build into runtime evidence. It
318
+ always launches a fresh headed Chromium process with `--mute-audio`, navigates through
319
+ `openSilentGame()`, and fails on page errors, console errors, failed requests,
320
+ HTTP errors, an inactive silent-QA marker, or a changed local-storage sentinel.
321
+ The required `assertReady` callback pins game identity and the primary UI or
322
+ canvas instead of accepting any app that happens to answer on the same port.
323
+
324
+ ```ts
325
+ import {
326
+ findAvailableProductionPort,
327
+ requireHardwareWebGL,
328
+ verifyProductionRuntime,
329
+ } from 'game-harness/production-runtime';
330
+
331
+ const port = await findAvailableProductionPort();
332
+
333
+ await verifyProductionRuntime({
334
+ url: `http://127.0.0.1:${port}/`,
335
+ gpuMode: process.env.CI ? 'linux-hardware-vulkan' : 'auto',
336
+ server: {
337
+ command: process.execPath,
338
+ args: [
339
+ 'node_modules/vite/bin/vite.js',
340
+ 'preview',
341
+ '--host=127.0.0.1',
342
+ `--port=${port}`,
343
+ '--strictPort',
344
+ ],
345
+ },
346
+ localStorageSentinels: { 'settings::muted': 'false' },
347
+ assertReady: async (page) => {
348
+ await page.getByRole('heading', { name: 'My Game' }).waitFor();
349
+ await page.locator('canvas').waitFor({ state: 'visible' });
350
+ const { renderer } = await requireHardwareWebGL(page);
351
+ console.log(`WebGL renderer: ${renderer}`);
352
+ },
353
+ });
354
+ ```
355
+
356
+ Pass `browserLaunchOptions: { headless: true }` only for a deliberately
357
+ constrained non-visual check. Release and final gameplay proof remain headed.
358
+
359
+ When `server` is present, its readiness URL must be unreachable before launch;
360
+ the verifier never reuses an arbitrary process. It owns that child process,
361
+ waits for readiness, and terminates it after either success or failure. Omit
362
+ `server` to apply the same strict gate to an already-deployed exact-live URL.
363
+ Every reachable readiness probe cancels its response body after recording the
364
+ status, including non-OK retry responses. A long polling loop must not retain
365
+ response streams or connections while it waits for the owned server.
366
+ Use `findAvailableProductionPort()` for CI or any shared runner instead of a
367
+ hard-coded port. It delegates selection to `get-port`, reserves that selection
368
+ against parallel calls in the current process, and still requires the owned
369
+ server to bind with strict-port semantics so an external race fails closed.
370
+
371
+ ## Visual battery contract
372
+
373
+ Run the default harness directory in update mode, or enforce committed
374
+ baselines in CI:
375
+
376
+ ```sh
377
+ pnpm exec game-harness-visual-battery tests/harness
378
+ pnpm exec game-harness-visual-battery tests/harness --ci
379
+ ```
380
+
381
+ `test-harness-visual-battery` remains as a compatibility alias. Programmatic
382
+ callers can pass `testCommand: { command, args }`; the executable and discovered
383
+ harness paths are invoked directly rather than interpolated through a shell.
384
+
385
+ `runVisualBattery()` owns one canonical `__screenshots__` tree directly under
386
+ the configured harness directory. Vitest screenshot paths are relative to the
387
+ test file, so a harness must write `__screenshots__/name.png`, not a
388
+ repository-relative path such as `tests/harness/__screenshots__/name.png`. The
389
+ battery rejects any second `__screenshots__` directory nested elsewhere under
390
+ the harness tree; otherwise an apparently green run could leave an important
391
+ screenshot outside the Git diff gate.
392
+
393
+ When two renderers cannot produce byte-identical PNGs, keep strict profiles
394
+ instead of adding a pixel threshold. Pass `baselineProfile: 'linux'`; the
395
+ battery compares `__screenshots__/linux/` and exposes the same value to Vite as
396
+ `VITE_VISUAL_BASELINE_PROFILE`. Screenshot helpers should include that optional
397
+ directory in their path:
398
+
399
+ ```ts
400
+ const profile = import.meta.env.VITE_VISUAL_BASELINE_PROFILE?.trim();
401
+ const path = profile ? `__screenshots__/${profile}/scene.png` : '__screenshots__/scene.png';
402
+ ```
403
+
404
+ `baselinesDir` names the baseline root. When it is combined with
405
+ `baselineProfile`, the profile is always appended below that root. For example,
406
+ `{ baselinesDir: 'visual-baselines', baselineProfile: 'linux' }` owns and diffs
407
+ `visual-baselines/linux/`.
408
+
409
+ For WebGL scenes, capture the canvas locator instead of the full browser page:
410
+
411
+ ```ts
412
+ const canvas = document.querySelector('canvas');
413
+ if (!(canvas instanceof HTMLCanvasElement)) throw new Error('canvas missing');
414
+ await page.elementLocator(canvas).screenshot({ path: '__screenshots__/scene.png' });
415
+ ```
416
+
417
+ If a canvas baseline is stable alone but changes after other harnesses have run
418
+ in the same long-lived Chromium process, isolate that file so it gets a fresh
419
+ browser process while the remaining files stay in one fast batch:
420
+
421
+ ```ts
422
+ runVisualBattery('tests/harness', {
423
+ isolatedHarnessFiles: ['scene.browser.test.tsx'],
424
+ });
425
+ ```
426
+
427
+ ## Lighthouse CI presets
428
+
429
+ ```ts
430
+ // lighthouserc.mjs
431
+ import { lighthouseAssertions } from 'game-harness/lighthouse';
432
+
433
+ export default lighthouseAssertions('game-default', {
434
+ url: ['http://localhost/index.html', 'http://localhost/settings/index.html'],
435
+ assertions: { 'categories:performance': ['warn', { minScore: 0.5 }] },
436
+ });
437
+ ```
438
+
439
+ `lighthouseAssertions()` returns a `lighthouserc.json`-shaped config object
440
+ for a named preset, with `overrides` merged on top (assertion overrides are
441
+ merged key-by-key on top of the preset's own; every other override field
442
+ replaces it wholesale). The only shipped preset, `'game-default'`, is a
443
+ production `lighthouserc.json` verbatim: performance/accessibility/best-practices
444
+ assertions at warn level (a score dip surfaces in CI logs without hard-blocking
445
+ a merge on Lighthouse's inherent run-to-run variance), with SEO and PWA
446
+ assertions off since these are single-page game shells with no SEO surface and
447
+ no installable-PWA requirement. To keep `lighthouserc.json` as static JSON
448
+ instead of a `.mjs` config, run this once locally and paste the printed
449
+ object — the factory has no runtime dependency on the consumer's environment
450
+ beyond the `overrides` you pass.
451
+
452
+ ## Release ladder orchestrator
453
+
454
+ ```ts
455
+ import { verifyReleaseLadder } from 'game-harness/release-ladder';
456
+ import { execSync } from 'node:child_process';
457
+
458
+ const result = await verifyReleaseLadder([
459
+ { name: 'lint', run: () => execSync('pnpm lint', { stdio: 'inherit' }) },
460
+ {
461
+ name: 'typecheck',
462
+ run: () => execSync('pnpm typecheck', { stdio: 'inherit' }),
463
+ },
464
+ { name: 'test', run: () => execSync('pnpm test', { stdio: 'inherit' }) },
465
+ { name: 'build', run: () => execSync('pnpm build', { stdio: 'inherit' }) },
466
+ ]);
467
+
468
+ process.exit(result.ok ? 0 : 1);
469
+ ```
470
+
471
+ `verifyReleaseLadder()` is a thin orchestrator for a `verify:*` release
472
+ ladder — an ordered list of named steps, each a plain sync or async function,
473
+ run in sequence and stopped at the first failure with a labeled summary. It
474
+ generalizes the pattern of many discrete `node scripts/verify-X.mjs` files
475
+ composed via a shell `&&` chain into one reusable primitive: a step can inline
476
+ its logic or delegate to an existing script via `execSync`. It never throws —
477
+ it returns a `ReleaseLadderResult` (`{ ok, ranSteps, failedStep?, error? }`) so
478
+ the caller decides how to report or exit; `process.exit(result.ok ? 0 : 1)` is
479
+ the CLI convention. Pass `{ log, error }` to redirect the default
480
+ `console.log`/`console.error` output (both prefixed with `[verify]`).
481
+
482
+ ## Architecture
483
+
484
+ The package root contains only peer-free orchestration utilities. Framework
485
+ integrations live behind explicit subpath exports, so importing Lighthouse or
486
+ the release ladder cannot accidentally load Playwright or Vitest. The
487
+ production-runtime entry point depends on the Playwright entry point because it
488
+ composes `openSilentGame()` into a fresh-browser lifecycle; no dependency points
489
+ back toward the root.
490
+
491
+ The complete module map, safety boundaries, and runtime-verification sequence
492
+ are documented in [docs/architecture.md](docs/architecture.md), and the full
493
+ guide set is published at
494
+ [jonbogaty.com/game-harness](https://jonbogaty.com/game-harness/).
495
+
496
+ An agent integrating this package should read [AGENTS.md](AGENTS.md) first;
497
+ [llms.txt](llms.txt) indexes every published guide page.
498
+
499
+ ## Development
500
+
501
+ Use the pinned Node and pnpm versions so the local gate matches CI. With
502
+ [mise](https://mise.jdx.dev) (recommended — it also reads `.nvmrc` and keeps
503
+ pnpm current via `mise.toml`):
504
+
505
+ ```sh
506
+ mise install
507
+ pnpm install --frozen-lockfile
508
+ pnpm verify
509
+ ```
510
+
511
+ Without mise, `nvm` plus Corepack works the same way:
512
+
513
+ ```sh
514
+ nvm use
515
+ corepack enable
516
+ pnpm install --frozen-lockfile
517
+ pnpm verify
518
+ ```
519
+
520
+ `pnpm verify` runs formatting, Oxlint, strict TypeScript checking, the full test
521
+ suite with 100% line/branch/function/statement coverage, both module builds,
522
+ `publint`, `@arethetypeswrong/cli`, and clean packed-consumer smoke tests. CI
523
+ repeats the full gate on Ubuntu and the code/build subset on macOS and Windows.
524
+
525
+ Useful focused commands:
526
+
527
+ ```sh
528
+ pnpm test # unit and contract tests
529
+ pnpm coverage # tests plus the 100% coverage gate
530
+ pnpm build # ESM, CommonJS, and declarations
531
+ pnpm package:check # publint and declaration/export analysis
532
+ pnpm test:package # clean tarball consumer installs
533
+ pnpm format # apply repository formatting
534
+ ```
535
+
536
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution and commit guidance.
537
+
538
+ ## Troubleshooting
539
+
540
+ - **A headed browser cannot start on Linux CI:** install Chromium dependencies
541
+ and run the browser command with `xvfb-run --auto-servernum`; do not silently
542
+ switch release evidence to headless mode.
543
+ - **`PLAYWRIGHT_PORT` or `PW_PORT` is rejected:** overrides must be integer TCP
544
+ ports from 1 through 65535. Invalid explicit values fail instead of falling
545
+ back to another port.
546
+ - **The visual battery reports zero PNGs:** each harness must write into its
547
+ canonical `__screenshots__` directory. An existing but empty directory is not
548
+ release evidence.
549
+ - **`test:package` reports an npm version too old:** the package-boundary test
550
+ requires npm 10 or newer (bundled with every Node version in the supported
551
+ `>=22` range); use the Node version from `.nvmrc` or any newer supported LTS.
552
+ - **A local Chrome channel is unavailable:** leave `PW_CHROMIUM_CHANNEL` unset
553
+ on CI to use Playwright's bundled Chromium, or set it explicitly to an
554
+ installed supported channel for a local branded-browser run.
555
+
556
+ ## Releases and support
557
+
558
+ Conventional commits on `main` are collected into a release pull request by
559
+ release-please. Merging that pull request creates the GitHub release and
560
+ publishes the exact tag to npm with provenance after `pnpm verify` passes.
561
+ Changes are recorded in [CHANGELOG.md](CHANGELOG.md).
562
+
563
+ Report defects and feature requests through the repository issue forms. Report
564
+ vulnerabilities privately as described in [SECURITY.md](SECURITY.md).
565
+
566
+ ## License
567
+
568
+ [MIT](LICENSE) © 2026 Jon Bogaty.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Keep the workspace bin target present before pnpm runs lifecycle scripts.
4
+ // The compiled CLI is generated by the root prepare step for local consumers
5
+ // and by this package's prepack step for registry consumers.
6
+ await import('../dist/esm/bin/visual-battery.js');
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const visual_battery_js_1 = require("../visual-battery.js");
5
+ const args = process.argv.slice(2);
6
+ if (args.includes('--help') || args.includes('-h')) {
7
+ console.log(`Usage: game-harness-visual-battery [harness-directory] [--ci]
8
+
9
+ Runs the configured Vitest browser harness and fails when committed screenshot
10
+ baselines drift. --ci also refuses to start from a dirty baseline directory.
11
+
12
+ Arguments:
13
+ harness-directory Directory containing *.browser.test.ts(x) files
14
+ (default: tests/harness)
15
+
16
+ Options:
17
+ --ci Fail on drift and refuse a dirty starting state
18
+ -h, --help Show this help
19
+
20
+ The legacy executable name test-harness-visual-battery remains available.`);
21
+ process.exit(0);
22
+ }
23
+ const unknownFlags = args.filter((argument) => argument.startsWith('-') && argument !== '--ci');
24
+ if (unknownFlags.length > 0) {
25
+ console.error(`Unknown option(s): ${unknownFlags.join(', ')}. Use --help for usage.`);
26
+ process.exit(2);
27
+ }
28
+ const positionalArgs = args.filter((argument) => !argument.startsWith('-'));
29
+ if (positionalArgs.length > 1) {
30
+ console.error('Expected at most one harness-directory. Use --help for usage.');
31
+ process.exit(2);
32
+ }
33
+ const ci = args.includes('--ci');
34
+ const harnessDirArg = positionalArgs[0] ?? 'tests/harness';
35
+ try {
36
+ (0, visual_battery_js_1.runVisualBattery)(harnessDirArg, { ci });
37
+ }
38
+ catch (err) {
39
+ if (err instanceof visual_battery_js_1.VisualBatteryError) {
40
+ process.exit(1);
41
+ }
42
+ throw err;
43
+ }