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.
- package/AGENTS.md +140 -0
- package/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +568 -0
- package/bin/test-harness-visual-battery.mjs +6 -0
- package/dist/cjs/bin/visual-battery.js +43 -0
- package/dist/cjs/browser-config.js +83 -0
- package/dist/cjs/chromium-launch.js +37 -0
- package/dist/cjs/index.js +10 -0
- package/dist/cjs/lighthouse.js +73 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/playwright-config.js +303 -0
- package/dist/cjs/production-runtime.js +315 -0
- package/dist/cjs/release-ladder.js +40 -0
- package/dist/cjs/silent-qa.js +75 -0
- package/dist/cjs/visual-battery.js +247 -0
- package/dist/esm/bin/visual-battery.js +41 -0
- package/dist/esm/browser-config.js +80 -0
- package/dist/esm/chromium-launch.js +34 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/lighthouse.js +70 -0
- package/dist/esm/playwright-config.js +292 -0
- package/dist/esm/production-runtime.js +304 -0
- package/dist/esm/release-ladder.js +37 -0
- package/dist/esm/silent-qa.js +67 -0
- package/dist/esm/visual-battery.js +239 -0
- package/dist/types/bin/visual-battery.d.cts +2 -0
- package/dist/types/bin/visual-battery.d.ts +2 -0
- package/dist/types/browser-config.d.cts +100 -0
- package/dist/types/browser-config.d.ts +100 -0
- package/dist/types/chromium-launch.d.cts +27 -0
- package/dist/types/chromium-launch.d.ts +27 -0
- package/dist/types/index.d.cts +3 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/lighthouse.d.cts +44 -0
- package/dist/types/lighthouse.d.ts +44 -0
- package/dist/types/playwright-config.d.cts +122 -0
- package/dist/types/playwright-config.d.ts +122 -0
- package/dist/types/production-runtime.d.cts +105 -0
- package/dist/types/production-runtime.d.ts +105 -0
- package/dist/types/release-ladder.d.cts +38 -0
- package/dist/types/release-ladder.d.ts +38 -0
- package/dist/types/silent-qa.d.cts +47 -0
- package/dist/types/silent-qa.d.ts +47 -0
- package/dist/types/visual-battery.d.cts +69 -0
- package/dist/types/visual-battery.d.ts +69 -0
- package/docs/404.md +17 -0
- package/docs/agent-guide.md +10 -0
- package/docs/architecture.md +81 -0
- package/docs/assets/game-harness-hero.webp +0 -0
- package/docs/changelog.md +9 -0
- package/docs/contributing.md +21 -0
- package/docs/entry-points.md +41 -0
- package/docs/getting-started.md +42 -0
- package/docs/guides/chromium-and-silent-qa.md +72 -0
- package/docs/guides/lighthouse-and-release-ladder.md +62 -0
- package/docs/guides/playwright.md +58 -0
- package/docs/guides/production-runtime.md +61 -0
- package/docs/guides/visual-battery.md +58 -0
- package/docs/guides/vitest.md +41 -0
- package/docs/introduction.md +29 -0
- package/docs/package.json +13 -0
- package/docs/quick-start.md +47 -0
- package/docs/reference/troubleshooting.md +33 -0
- package/docs/security.md +15 -0
- package/docs/sourcey.config.ts +79 -0
- package/llms.txt +32 -0
- package/maestro/smoke.template.yaml +11 -0
- package/package.json +196 -0
package/README.md
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
# game-harness
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
[](https://github.com/jbcom/game-harness/actions/workflows/ci.yml)
|
|
6
|
+
[](package.json)
|
|
7
|
+
[](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
|
+
}
|