game-harness 1.0.0 → 1.1.1
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 +4 -0
- package/CHANGELOG.md +25 -2
- package/MIGRATION.md +144 -0
- package/README.md +39 -10
- package/dist/cjs/bin/visual-battery.js +19 -1
- package/dist/cjs/chromium-launch.js +18 -3
- package/dist/cjs/release-ladder.js +1 -1
- package/dist/cjs/visual-battery.js +70 -6
- package/dist/esm/bin/visual-battery.js +19 -1
- package/dist/esm/chromium-launch.js +17 -3
- package/dist/esm/release-ladder.js +1 -1
- package/dist/esm/visual-battery.js +71 -7
- package/dist/types/chromium-launch.d.cts +17 -3
- package/dist/types/chromium-launch.d.ts +17 -3
- package/dist/types/index.d.cts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/release-ladder.d.cts +1 -1
- package/dist/types/release-ladder.d.ts +1 -1
- package/dist/types/visual-battery.d.cts +8 -5
- package/dist/types/visual-battery.d.ts +8 -5
- package/docs/architecture.md +8 -4
- package/docs/decisions.md +165 -0
- package/docs/getting-started.md +1 -1
- package/docs/guides/chromium-and-silent-qa.md +9 -0
- package/docs/guides/visual-battery.md +23 -2
- package/docs/introduction.md +1 -1
- package/docs/reference/troubleshooting.md +3 -2
- package/docs/sourcey.config.ts +1 -1
- package/llms.txt +1 -0
- package/maestro/smoke.template.yaml +1 -1
- package/package.json +13 -7
package/AGENTS.md
CHANGED
|
@@ -117,6 +117,10 @@ hardcoded version string into a workflow file.
|
|
|
117
117
|
clean-tarball consumer smoke tests. This is what CI runs; run it before
|
|
118
118
|
every commit.
|
|
119
119
|
- `pnpm test` — unit and contract tests only, for fast iteration.
|
|
120
|
+
- `pnpm test:chromium` — real headed Chromium checks of the launch profile
|
|
121
|
+
(needs `pnpm exec playwright install chromium`, and `xvfb-run -a` on a
|
|
122
|
+
Linux host without a display). CI runs it in its own `chromium` job; it is
|
|
123
|
+
not part of `pnpm verify`, so publishing never depends on a browser.
|
|
120
124
|
- `pnpm --filter game-harness-docs validate` / `pnpm --filter
|
|
121
125
|
game-harness-docs dev` — build or preview the Sourcey docs site in isolation.
|
|
122
126
|
- `pnpm format` — apply Prettier.
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ All notable changes are recorded here. Releases follow
|
|
|
4
4
|
[Semantic Versioning](https://semver.org/) and are generated from Conventional
|
|
5
5
|
Commits by release-please.
|
|
6
6
|
|
|
7
|
+
## [1.1.1](https://github.com/jbcom/game-harness/compare/game-harness-v1.1.0...game-harness-v1.1.1) (2026-10-07)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Bug Fixes
|
|
11
|
+
|
|
12
|
+
* **visual-battery:** tolerate rasterization noise while failing real drift ([e2ef323](https://github.com/jbcom/game-harness/commit/e2ef323643dc6b09a4991489f579f068c915d078))
|
|
13
|
+
|
|
14
|
+
## [1.1.0](https://github.com/jbcom/game-harness/compare/game-harness-v1.0.0...game-harness-v1.1.0) (2026-10-07)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* accept Vitest 5 as a peer alongside Vitest 4 ([5d7a86c](https://github.com/jbcom/game-harness/commit/5d7a86c9e0b393e5e00818f89b03160cdd2c7370))
|
|
20
|
+
* **chromium:** keep background and occluded windows on schedule ([ba72078](https://github.com/jbcom/game-harness/commit/ba72078f8d3af5151d63e331eb84253213ac3a08))
|
|
21
|
+
* converge test-harness into game-harness and keep background windows on schedule ([b5295f9](https://github.com/jbcom/game-harness/commit/b5295f9c6fa421198e171b95358f19459a6f5f99))
|
|
22
|
+
* export VisualBatteryCommand from the package root ([ca6dd88](https://github.com/jbcom/game-harness/commit/ca6dd884935bd91ae811168a88e5a8bbbda0629c))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Bug Fixes
|
|
26
|
+
|
|
27
|
+
* harden release package install ([c74160d](https://github.com/jbcom/game-harness/commit/c74160dc78d43811c94c8249fae40ab9e6bb8632))
|
|
28
|
+
* publish releases from cd workflow ([f6f3b3e](https://github.com/jbcom/game-harness/commit/f6f3b3e5ac1ab8cf3670fb47cb98523f1ce4d1be))
|
|
29
|
+
* publish releases from cd workflow ([c7560ac](https://github.com/jbcom/game-harness/commit/c7560ac921b20ef706f761c14955088bfd07a7f7))
|
|
30
|
+
|
|
7
31
|
## [1.0.0](https://github.com/jbcom/game-harness/compare/game-harness-v0.5.0...game-harness-v1.0.0) (2026-08-24)
|
|
8
32
|
|
|
9
33
|
|
|
@@ -62,8 +86,7 @@ Commits by release-please.
|
|
|
62
86
|
|
|
63
87
|
### Added
|
|
64
88
|
|
|
65
|
-
- Initial standalone public package
|
|
66
|
-
verification harness.
|
|
89
|
+
- Initial standalone public package for browser-game verification.
|
|
67
90
|
- Peer-isolated Playwright and Vitest Browser Mode configuration entry points.
|
|
68
91
|
- Fail-closed silent-QA, production-runtime, visual-regression, Lighthouse, and
|
|
69
92
|
release-ladder primitives.
|
package/MIGRATION.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Migrating from a scoped `test-harness` package
|
|
2
|
+
|
|
3
|
+
Game Harness grew out of a scoped package published privately as
|
|
4
|
+
`@your-scope/test-harness`. Every capability of that package now lives here, under
|
|
5
|
+
the same export names and subpaths. This guide maps each import, option and
|
|
6
|
+
binary, and lists the few places where Game Harness is stricter. The reasoning
|
|
7
|
+
behind each difference is in [docs/decisions.md](docs/decisions.md).
|
|
8
|
+
|
|
9
|
+
## 1. Swap the dependency
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm remove @your-scope/test-harness
|
|
13
|
+
pnpm add -D game-harness
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Game Harness is on the public npm registry, so a registry mapping for the old
|
|
17
|
+
scope is no longer needed once nothing else uses it. Keep the peers you
|
|
18
|
+
already install: `@playwright/test` for `/playwright` and
|
|
19
|
+
`/production-runtime`, and `vitest` with `@vitest/browser-playwright` for
|
|
20
|
+
`/vitest`. Vitest 4 (from 4.1.10) and Vitest 5 are both supported. Node 22 or
|
|
21
|
+
newer is required.
|
|
22
|
+
|
|
23
|
+
## 2. Rewrite imports
|
|
24
|
+
|
|
25
|
+
Only the package name changes. Every subpath and named export is the same.
|
|
26
|
+
|
|
27
|
+
| Before | After |
|
|
28
|
+
| --------------------------------------------- | --------------------------------- |
|
|
29
|
+
| `@your-scope/test-harness` | `game-harness` |
|
|
30
|
+
| `@your-scope/test-harness/vitest` | `game-harness/vitest` |
|
|
31
|
+
| `@your-scope/test-harness/playwright` | `game-harness/playwright` |
|
|
32
|
+
| `@your-scope/test-harness/silent-qa` | `game-harness/silent-qa` |
|
|
33
|
+
| `@your-scope/test-harness/production-runtime` | `game-harness/production-runtime` |
|
|
34
|
+
| `@your-scope/test-harness/chromium` | `game-harness/chromium` |
|
|
35
|
+
| `@your-scope/test-harness/lighthouse` | `game-harness/lighthouse` |
|
|
36
|
+
| `@your-scope/test-harness/release-ladder` | `game-harness/release-ladder` |
|
|
37
|
+
| `@your-scope/test-harness/visual-battery` | `game-harness/visual-battery` |
|
|
38
|
+
| `@your-scope/test-harness/package.json` | `game-harness/package.json` |
|
|
39
|
+
|
|
40
|
+
A single search and replace of the specifier prefix is enough:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
git grep -lz '@your-scope/test-harness' | xargs -0 perl -pi -e 's#\@your-scope/test-harness#game-harness#g'
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Replace `your-scope` in these commands with the scope your project installed
|
|
47
|
+
the package under.
|
|
48
|
+
|
|
49
|
+
### Export by export
|
|
50
|
+
|
|
51
|
+
| Subpath | Exports (identical names) |
|
|
52
|
+
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
53
|
+
| root | `lighthouseAssertions`, `LighthouseAssertionsOverrides`, `LighthouseCiConfig`, `verifyReleaseLadder`, `ReleaseLadderStep`, `ReleaseLadderResult`, `VerifyReleaseLadderOptions`, `runVisualBattery`, `VisualBatteryError`, `VisualBatteryOptions` |
|
|
54
|
+
| `/vitest` | `defineBrowserTestConfig`, `BrowserTestConfigOptions`, `BrowserInstance` |
|
|
55
|
+
| `/playwright` | `definePlaywrightConfig`, `PlaywrightConfigOptions`, `DeviceTier`, `resolvePlaywrightPort`, `ResolvePlaywrightPortOptions`, `silentTestUrl`, `SilentTestUrlOptions`, `SilentQueryValue`, `openSilentGame`, `OpenSilentGameOptions` |
|
|
56
|
+
| `/silent-qa` | `activateSilentQa`, `isSilentQaRequested`, `isSilentQaActive`, `_resetSilentQaForTests`, `ActivateSilentQaOptions`, `SilentQaMarkerTarget`, `SILENT_QA_QUERY_PARAMETER`, `SILENT_QA_QUERY_VALUE`, `SILENT_QA_MARKER_ATTRIBUTE`, `SILENT_QA_MARKER_VALUE` |
|
|
57
|
+
| `/production-runtime` | `verifyProductionRuntime`, `ProductionRuntimeOptions`, `ProductionRuntimeServerOptions`, `ProductionRuntimeResult`, `ProductionRuntimeIssue`, `ProductionRuntimeVerificationError`, `findAvailableProductionPort`, `AvailableProductionPortOptions`, `readWebGLRenderer`, `requireHardwareWebGL`, `WebGLRendererInfo` |
|
|
58
|
+
| `/chromium` | `createChromiumLaunchProfile`, `ChromiumLaunchProfileOptions`, `ChromiumLaunchProfile`, `ChromiumGpuMode`, `ChromiumEnvironment` |
|
|
59
|
+
| `/lighthouse` | `lighthouseAssertions`, `LighthouseAssertionsOverrides`, `LighthouseCiConfig`, `LighthousePreset` |
|
|
60
|
+
| `/release-ladder` | `verifyReleaseLadder`, `ReleaseLadderStep`, `ReleaseLadderResult`, `VerifyReleaseLadderOptions` |
|
|
61
|
+
| `/visual-battery` | `runVisualBattery`, `VisualBatteryError`, `VisualBatteryOptions` |
|
|
62
|
+
|
|
63
|
+
New in Game Harness: `activateSilentQaAsync` (`/silent-qa`),
|
|
64
|
+
`VisualBatteryCommand` (`/visual-battery` and root) and
|
|
65
|
+
`CHROMIUM_ANTI_THROTTLING_ARGS` (`/chromium`).
|
|
66
|
+
|
|
67
|
+
Modified PNG baselines now use decoded RGBA comparisons instead of byte equality.
|
|
68
|
+
`maxChannelDelta` defaults to 2 and `maxDifferentPixelRatio` to 0, so every
|
|
69
|
+
pixel beyond ±2 is drift. Noise-only files are restored to committed bytes;
|
|
70
|
+
dimensions and new/deleted files still fail. Use `maxChannelDelta: 0` for exact
|
|
71
|
+
decoded pixels. CLI flags are `--max-channel-delta` and
|
|
72
|
+
`--max-different-pixel-ratio`; see the visual-battery guide for their ranges.
|
|
73
|
+
|
|
74
|
+
## 3. Options
|
|
75
|
+
|
|
76
|
+
Every option keeps its name and meaning:
|
|
77
|
+
|
|
78
|
+
| Options type | Options |
|
|
79
|
+
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
80
|
+
| `BrowserTestConfigOptions` | `contextOptions`, `gpuArgs`, `gpuMode`, `headless`, `ui`, `instances`, `optimizeDeps`, `setupFiles`, `name`, `include`, `fileParallelism` |
|
|
81
|
+
| `PlaywrightConfigOptions` | `testDir`, `basePath`, `port`, `webServerCommand`, `gpuMode`, `headless`, `deviceTiers`, `extraProjects`, `journeySpecs`, `ciTimeoutMultiplier`, `overrides` |
|
|
82
|
+
| `SilentTestUrlOptions` | `muteQueryParameter`, `muteQueryValue` |
|
|
83
|
+
| `OpenSilentGameOptions` | `markerSelector`, `markerAttribute`, `markerValue`, `markerTimeout`, `navigationOptions` |
|
|
84
|
+
| `ResolvePlaywrightPortOptions` | `localPort`, `environment` |
|
|
85
|
+
| `ProductionRuntimeOptions` | `url`, `server`, `browserLaunchOptions`, `gpuMode`, `pageOptions`, `silentParameters`, `silentOptions`, `localStorageSentinels`, `assertReady`, `assertSilentState`, `settleTimeMs` |
|
|
86
|
+
| `ProductionRuntimeServerOptions` | `command`, `args`, `cwd`, `env`, `readyUrl`, `startupTimeoutMs`, `shutdownTimeoutMs` |
|
|
87
|
+
| `AvailableProductionPortOptions` | `host` |
|
|
88
|
+
| `ActivateSilentQaOptions` | `search`, `queryParameter`, `markerTarget`, `markerAttribute`, `markerValue` |
|
|
89
|
+
| `VisualBatteryOptions` | `ci`, `cwd`, `testCommand`, `baselinesDir`, `baselineProfile`, `isolatedHarnessFiles`, `maxChannelDelta`, `maxDifferentPixelRatio`, `log`, `error` |
|
|
90
|
+
| `LighthouseAssertionsOverrides` | `staticDistDir`, `url`, `numberOfRuns`, `assertions` |
|
|
91
|
+
| `ChromiumLaunchProfileOptions` | `gpuMode`, `args`, `env` |
|
|
92
|
+
| `VerifyReleaseLadderOptions` | `log`, `error` |
|
|
93
|
+
|
|
94
|
+
Environment variables are unchanged: `CI`, `MULTIVIEW`, `VISUAL`, `JOURNEY`,
|
|
95
|
+
`PLAYWRIGHT_PORT`, `PW_PORT`, `PW_HEADLESS`, `PW_REUSE_SERVER`,
|
|
96
|
+
`PW_CHROMIUM_CHANNEL` and `VITE_VISUAL_BASELINE_PROFILE`.
|
|
97
|
+
|
|
98
|
+
## 4. Binary
|
|
99
|
+
|
|
100
|
+
| Before | After |
|
|
101
|
+
| ----------------------------- | ----------------------------- |
|
|
102
|
+
| `test-harness-visual-battery` | `game-harness-visual-battery` |
|
|
103
|
+
|
|
104
|
+
The old binary name is still installed as an alias, so scripts keep working,
|
|
105
|
+
but new scripts should use `game-harness-visual-battery`. Arguments are the
|
|
106
|
+
same (`[harness-directory] [--ci]`). Unknown flags or a second directory now
|
|
107
|
+
exit with code 2 instead of being ignored.
|
|
108
|
+
|
|
109
|
+
## 5. Where Game Harness is stricter
|
|
110
|
+
|
|
111
|
+
These are the only changes that can make a previously passing setup fail.
|
|
112
|
+
Each one fails loudly at configuration time with a message naming the
|
|
113
|
+
problem.
|
|
114
|
+
|
|
115
|
+
| Situation | What to do |
|
|
116
|
+
| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
117
|
+
| `testCommand` relies on a shell: `'FOO=1 pnpm test:browser'`, `&&`, pipes or quoted arguments | Move the shell logic into a package script and pass its name (`'pnpm test:visual'`), or pass `{ command: 'pnpm', args: ['test:browser'] }`. No shell is ever started. |
|
|
118
|
+
| `PLAYWRIGHT_PORT` or `PW_PORT` set to a non-port value | Fix or unset it. Game Harness throws instead of falling back to the derived port. |
|
|
119
|
+
| `activateSilentQa` given a callback that returns a promise | Use `await activateSilentQaAsync(mute)`. The readiness marker then appears only after the mute has resolved. |
|
|
120
|
+
| `basePath` containing `?`, `#`, `.`/`..` segments or encoded slashes | Pass a plain pathname. Leading, trailing and repeated slashes are normalized for you. |
|
|
121
|
+
| Empty `deviceTiers`, an unknown tier, or `ciTimeoutMultiplier` that is not positive | Pass at least one of `desktop`, `mobile`, `tablet`, `foldable`, `ultrawide`, and a positive multiplier. |
|
|
122
|
+
| Empty Vitest `name`, `instances` or `include` | Omit the option to get the default, or pass a non-empty value. |
|
|
123
|
+
| Lighthouse override with an empty `staticDistDir`, an empty `url` list, or `numberOfRuns < 1` | Omit the override or pass a valid value. |
|
|
124
|
+
| Code that builds a `LighthouseCiConfig` by hand without `ci.collect.staticDistDir` | Add it. The result of `lighthouseAssertions()` always includes it. |
|
|
125
|
+
| `baselinesDir` absolute, or harness or baseline directory outside `cwd` | Use a path relative to, and inside, `cwd`. |
|
|
126
|
+
| A visual-battery run that writes no PNG baselines | It now fails. Make sure the harness actually takes screenshots. |
|
|
127
|
+
|
|
128
|
+
## 6. Behaviour you get for free
|
|
129
|
+
|
|
130
|
+
- Every launch profile adds `--disable-background-timer-throttling`,
|
|
131
|
+
`--disable-renderer-backgrounding` and
|
|
132
|
+
`--disable-backgrounding-occluded-windows`, so background and occluded
|
|
133
|
+
windows keep their timers on schedule when several headed browsers run at
|
|
134
|
+
once. If a custom launch passes its own `args`, they are merged with these,
|
|
135
|
+
and `--mute-audio` is still applied last.
|
|
136
|
+
- Harness files run in a sorted, deterministic order.
|
|
137
|
+
- Visual-battery commands run without a shell, with Windows `.cmd` shims
|
|
138
|
+
resolved for you.
|
|
139
|
+
|
|
140
|
+
## 7. Verify
|
|
141
|
+
|
|
142
|
+
Run the consumer's own full gate (type check, unit tests, browser tests and
|
|
143
|
+
end-to-end tests). A green unit run alone does not prove the browser
|
|
144
|
+
configuration still loads.
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
Release-grade browser QA primitives for TypeScript games. Game Harness turns a
|
|
10
10
|
successful build into evidence: fresh silent browser sessions, deterministic
|
|
11
|
-
device tiers,
|
|
11
|
+
device tiers, pixel-tolerant screenshot gates, production-runtime assertions,
|
|
12
12
|
Lighthouse policy, and an ordered release ladder.
|
|
13
13
|
|
|
14
14
|
It is intentionally a focused library rather than a test framework. Your game
|
|
@@ -24,7 +24,8 @@ and Vitest Browser Mode.
|
|
|
24
24
|
commands use strict-port semantics, and production verification refuses an
|
|
25
25
|
already-reachable readiness URL.
|
|
26
26
|
- **Visual evidence that fails closed.** Screenshot baselines are scoped,
|
|
27
|
-
profile-aware, and checked
|
|
27
|
+
profile-aware, and checked against Git with bounded channel tolerance.
|
|
28
|
+
Every pixel beyond that tolerance fails by default; commands avoid shell
|
|
28
29
|
interpolation.
|
|
29
30
|
- **Real package boundaries.** Framework peers stay optional and isolated to
|
|
30
31
|
subpath exports, with clean ESM, CommonJS, type, CLI, and install smoke tests.
|
|
@@ -127,7 +128,7 @@ supported. CI pins the primary gate to the version in `.nvmrc` (currently
|
|
|
127
128
|
24.19.0, the latest Node 24 LTS patch) and additionally runs the full test
|
|
128
129
|
and build suite against Node 22 on Linux to prove the floor of that range,
|
|
129
130
|
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
|
+
conformance matrix is Playwright 1.62.1 and Vitest Browser 4.1.10 and 5.0.3.
|
|
131
132
|
Package-boundary consumers run with a credential-free home directory and npm
|
|
132
133
|
configuration, install only the peer family needed by each entry point, and
|
|
133
134
|
exercise ESM, CommonJS, the CLI, silent runtime markers, and Chromium launch
|
|
@@ -257,7 +258,14 @@ SwiftShader explicitly; `linux-hardware-vulkan` applies the reviewed
|
|
|
257
258
|
Mesa/ANGLE flags plus `EGL_PLATFORM=surfaceless` for a runner exposing
|
|
258
259
|
`/dev/dri/renderD128`) and always de-duplicates and appends `--mute-audio`
|
|
259
260
|
last, so a caller-supplied arg list can never accidentally drop the silence
|
|
260
|
-
guard.
|
|
261
|
+
guard. Every profile also carries `CHROMIUM_ANTI_THROTTLING_ARGS`
|
|
262
|
+
(`--disable-background-timer-throttling`, `--disable-renderer-backgrounding`,
|
|
263
|
+
`--disable-backgrounding-occluded-windows`), so background and occluded
|
|
264
|
+
windows keep their timers on schedule when several headed browsers run at
|
|
265
|
+
once; Page Visibility still reports `hidden`.
|
|
266
|
+
|
|
267
|
+
Moving from the scoped `test-harness` package this one grew out of? See
|
|
268
|
+
[MIGRATION.md](MIGRATION.md).
|
|
261
269
|
|
|
262
270
|
Every browser launched by `definePlaywrightConfig()` or
|
|
263
271
|
`defineBrowserTestConfig()` receives Chromium's `--mute-audio` argument as a
|
|
@@ -309,8 +317,8 @@ Before publishing, run `pnpm verify` under the pinned release toolchain. The
|
|
|
309
317
|
package verifier uses npm 11.17.0 directly from the package directory, packs a
|
|
310
318
|
tarball, and installs it into credential-free temporary consumers for the
|
|
311
319
|
peer-free root, Playwright/production-runtime, and Vitest Browser boundaries.
|
|
312
|
-
The release
|
|
313
|
-
provenance.
|
|
320
|
+
The tagged-release job in `cd.yml` repeats the full gate before publishing
|
|
321
|
+
with npm provenance through npm trusted publishing (OIDC).
|
|
314
322
|
|
|
315
323
|
## Production runtime verification
|
|
316
324
|
|
|
@@ -370,6 +378,26 @@ server to bind with strict-port semantics so an external race fails closed.
|
|
|
370
378
|
|
|
371
379
|
## Visual battery contract
|
|
372
380
|
|
|
381
|
+
Modified PNGs are decoded and compared pixel by pixel. `maxChannelDelta`
|
|
382
|
+
(default 2, integer 0–255) tolerates that maximum RGBA channel delta.
|
|
383
|
+
`maxDifferentPixelRatio` (default 0, range 0–1) limits the fraction of pixels
|
|
384
|
+
beyond the channel tolerance: any such pixel fails by default. Dimension
|
|
385
|
+
changes, new/deleted files, and unreadable PNGs always count as drift.
|
|
386
|
+
Accepted renders are restored to committed bytes; noise-only files are logged
|
|
387
|
+
as `rasterization noise (N px within ±2)`. The CI dirty-start check remains strict.
|
|
388
|
+
Use `maxChannelDelta: 0` for exact decoded pixel equality. Raising the ratio
|
|
389
|
+
deliberately permits pixels beyond the channel tolerance.
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
runVisualBattery('tests/harness', {
|
|
393
|
+
ci: true,
|
|
394
|
+
maxChannelDelta: 2,
|
|
395
|
+
maxDifferentPixelRatio: 0,
|
|
396
|
+
});
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
The CLI exposes `--max-channel-delta 2` and `--max-different-pixel-ratio 0`.
|
|
400
|
+
|
|
373
401
|
Run the default harness directory in update mode, or enforce committed
|
|
374
402
|
baselines in CI:
|
|
375
403
|
|
|
@@ -390,8 +418,8 @@ battery rejects any second `__screenshots__` directory nested elsewhere under
|
|
|
390
418
|
the harness tree; otherwise an apparently green run could leave an important
|
|
391
419
|
screenshot outside the Git diff gate.
|
|
392
420
|
|
|
393
|
-
When
|
|
394
|
-
|
|
421
|
+
When renderers produce genuinely different pixels, keep separate profiles.
|
|
422
|
+
Pass `baselineProfile: 'linux'`; the
|
|
395
423
|
battery compares `__screenshots__/linux/` and exposes the same value to Vite as
|
|
396
424
|
`VITE_VISUAL_BASELINE_PROFILE`. Screenshot helpers should include that optional
|
|
397
425
|
directory in their path:
|
|
@@ -556,8 +584,9 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution and commit guidance.
|
|
|
556
584
|
## Releases and support
|
|
557
585
|
|
|
558
586
|
Conventional commits on `main` are collected into a release pull request by
|
|
559
|
-
release-please. Merging that pull request creates the GitHub release
|
|
560
|
-
|
|
587
|
+
release-please. Merging that pull request creates the GitHub release; its
|
|
588
|
+
published-release event starts `cd.yml`, which verifies and publishes the exact
|
|
589
|
+
tag to npm through OIDC with provenance.
|
|
561
590
|
Changes are recorded in [CHANGELOG.md](CHANGELOG.md).
|
|
562
591
|
|
|
563
592
|
Report defects and feature requests through the repository issue forms. Report
|
|
@@ -15,11 +15,29 @@ Arguments:
|
|
|
15
15
|
|
|
16
16
|
Options:
|
|
17
17
|
--ci Fail on drift and refuse a dirty starting state
|
|
18
|
+
--max-channel-delta N RGBA channel tolerance, integer 0–255 (default: 2)
|
|
19
|
+
--max-different-pixel-ratio N Allowed fraction beyond tolerance (default: 0)
|
|
18
20
|
-h, --help Show this help
|
|
19
21
|
|
|
20
22
|
The legacy executable name test-harness-visual-battery remains available.`);
|
|
21
23
|
process.exit(0);
|
|
22
24
|
}
|
|
25
|
+
const thresholds = {};
|
|
26
|
+
for (const [flag, key] of [
|
|
27
|
+
['--max-channel-delta', 'maxChannelDelta'],
|
|
28
|
+
['--max-different-pixel-ratio', 'maxDifferentPixelRatio'],
|
|
29
|
+
]) {
|
|
30
|
+
const index = args.indexOf(flag);
|
|
31
|
+
if (index === -1)
|
|
32
|
+
continue;
|
|
33
|
+
const value = args[index + 1];
|
|
34
|
+
if (value === undefined || value.trim() === '' || !Number.isFinite(Number(value))) {
|
|
35
|
+
console.error(`${flag} requires a finite numeric value.`);
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
thresholds[key] = Number(value);
|
|
39
|
+
args.splice(index, 2);
|
|
40
|
+
}
|
|
23
41
|
const unknownFlags = args.filter((argument) => argument.startsWith('-') && argument !== '--ci');
|
|
24
42
|
if (unknownFlags.length > 0) {
|
|
25
43
|
console.error(`Unknown option(s): ${unknownFlags.join(', ')}. Use --help for usage.`);
|
|
@@ -33,7 +51,7 @@ if (positionalArgs.length > 1) {
|
|
|
33
51
|
const ci = args.includes('--ci');
|
|
34
52
|
const harnessDirArg = positionalArgs[0] ?? 'tests/harness';
|
|
35
53
|
try {
|
|
36
|
-
(0, visual_battery_js_1.runVisualBattery)(harnessDirArg, { ci });
|
|
54
|
+
(0, visual_battery_js_1.runVisualBattery)(harnessDirArg, { ci, ...thresholds });
|
|
37
55
|
}
|
|
38
56
|
catch (err) {
|
|
39
57
|
if (err instanceof visual_battery_js_1.VisualBatteryError) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CHROMIUM_ANTI_THROTTLING_ARGS = void 0;
|
|
3
4
|
exports.createChromiumLaunchProfile = createChromiumLaunchProfile;
|
|
4
5
|
const GPU_ARGS = {
|
|
5
6
|
auto: [],
|
|
@@ -12,13 +13,27 @@ const GPU_ARGS = {
|
|
|
12
13
|
],
|
|
13
14
|
};
|
|
14
15
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* Keeps timers and rendering on schedule in pages Chromium considers
|
|
17
|
+
* backgrounded. Several headed windows commonly run at once during a test
|
|
18
|
+
* suite; without these switches a background tab's timers are coalesced to
|
|
19
|
+
* one wake-up per second, and an occluded window or backgrounded renderer is
|
|
20
|
+
* deprioritised, so suites time out for reasons unrelated to the game.
|
|
21
|
+
* Page Visibility still reports `hidden`, so visibility handling stays
|
|
22
|
+
* testable.
|
|
23
|
+
*/
|
|
24
|
+
exports.CHROMIUM_ANTI_THROTTLING_ARGS = Object.freeze([
|
|
25
|
+
'--disable-background-timer-throttling',
|
|
26
|
+
'--disable-renderer-backgrounding',
|
|
27
|
+
'--disable-backgrounding-occluded-windows',
|
|
28
|
+
]);
|
|
29
|
+
/**
|
|
30
|
+
* Builds the shared Chromium renderer, scheduling and silence profile without
|
|
31
|
+
* deciding whether the browser is headed. Callers own that explicit choice.
|
|
17
32
|
*/
|
|
18
33
|
function createChromiumLaunchProfile(options = {}) {
|
|
19
34
|
const gpuMode = options.gpuMode ?? 'auto';
|
|
20
35
|
const args = [
|
|
21
|
-
...new Set([...GPU_ARGS[gpuMode], ...(options.args ?? [])].filter((argument) => argument !== '--mute-audio')),
|
|
36
|
+
...new Set([...GPU_ARGS[gpuMode], ...exports.CHROMIUM_ANTI_THROTTLING_ARGS, ...(options.args ?? [])].filter((argument) => argument !== '--mute-audio')),
|
|
22
37
|
'--mute-audio',
|
|
23
38
|
];
|
|
24
39
|
if (gpuMode === 'linux-hardware-vulkan') {
|
|
@@ -7,7 +7,7 @@ exports.verifyReleaseLadder = verifyReleaseLadder;
|
|
|
7
7
|
* screenshots → native sync, or whatever a given repo's ladder is), run in
|
|
8
8
|
* sequence and stopped at the first failure with a labeled summary.
|
|
9
9
|
*
|
|
10
|
-
* Generalizes the
|
|
10
|
+
* Generalizes the common pattern of many discrete
|
|
11
11
|
* `node scripts/verify-X.mjs` files composed via a shell `&&` chain into a
|
|
12
12
|
* single reusable primitive: each step is a plain function (sync or async),
|
|
13
13
|
* so a repo can inline its logic or delegate to existing scripts via
|
|
@@ -9,6 +9,8 @@ const node_child_process_1 = require("node:child_process");
|
|
|
9
9
|
const node_fs_1 = require("node:fs");
|
|
10
10
|
const node_path_1 = require("node:path");
|
|
11
11
|
const cross_spawn_1 = __importDefault(require("cross-spawn"));
|
|
12
|
+
const pngjs_1 = require("pngjs");
|
|
13
|
+
const which_1 = __importDefault(require("which"));
|
|
12
14
|
/**
|
|
13
15
|
* Thrown by every `runVisualBattery()` failure path — a missing harness dir,
|
|
14
16
|
* no discovered `.browser.test.ts(x)` files, a misplaced `__screenshots__`
|
|
@@ -59,9 +61,9 @@ function canonicalizePath(target) {
|
|
|
59
61
|
*
|
|
60
62
|
* Runs every `.browser.test.tsx` harness file under `harnessDir`, then
|
|
61
63
|
* diffs the resulting `__screenshots__/*.png` baselines against what's
|
|
62
|
-
* committed in git.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
64
|
+
* committed in git. Modified PNGs tolerate RGBA channel deltas up to 2 by
|
|
65
|
+
* default; any pixel beyond that precise tolerance is drift. Noise-only
|
|
66
|
+
* renders are restored to committed bytes. New/deleted baselines always drift.
|
|
65
67
|
*
|
|
66
68
|
* - Update mode (`ci: false`, the default): runs the harnesses, lets new
|
|
67
69
|
* baselines land on disk, reports what changed so a human can review
|
|
@@ -76,11 +78,19 @@ function canonicalizePath(target) {
|
|
|
76
78
|
* exits the process.
|
|
77
79
|
*/
|
|
78
80
|
function runVisualBattery(harnessDir, options = {}) {
|
|
79
|
-
const { ci = false, cwd = process.cwd(), testCommand = 'pnpm test:browser', baselineProfile, isolatedHarnessFiles = [], log = defaultLog, error = defaultError, } = options;
|
|
81
|
+
const { ci = false, cwd = process.cwd(), testCommand = 'pnpm test:browser', baselineProfile, isolatedHarnessFiles = [], log = defaultLog, error = defaultError, maxChannelDelta = 2, maxDifferentPixelRatio = 0, } = options;
|
|
80
82
|
const die = (msg) => {
|
|
81
83
|
error(msg);
|
|
82
84
|
throw new VisualBatteryError(msg);
|
|
83
85
|
};
|
|
86
|
+
if (!Number.isInteger(maxChannelDelta) || maxChannelDelta < 0 || maxChannelDelta > 255) {
|
|
87
|
+
die('maxChannelDelta must be an integer between 0 and 255');
|
|
88
|
+
}
|
|
89
|
+
if (!Number.isFinite(maxDifferentPixelRatio) ||
|
|
90
|
+
maxDifferentPixelRatio < 0 ||
|
|
91
|
+
maxDifferentPixelRatio > 1) {
|
|
92
|
+
die('maxDifferentPixelRatio must be between 0 and 1');
|
|
93
|
+
}
|
|
84
94
|
const resolvedCwd = (0, node_path_1.resolve)(cwd);
|
|
85
95
|
const canonicalCwd = (() => {
|
|
86
96
|
try {
|
|
@@ -141,10 +151,20 @@ function runVisualBattery(harnessDir, options = {}) {
|
|
|
141
151
|
log(`running ${harnessFiles.length} harness file(s):`);
|
|
142
152
|
for (const f of harnessFiles)
|
|
143
153
|
log(` - ${f}`);
|
|
154
|
+
// Resolve the consumer's Git installation once, including PATHEXT on Windows.
|
|
155
|
+
// All Git subprocesses use the resulting absolute executable path.
|
|
156
|
+
const gitExecutable = (() => {
|
|
157
|
+
try {
|
|
158
|
+
return (0, node_path_1.resolve)(which_1.default.sync('git'));
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
return die(`git executable not found: ${err}`);
|
|
162
|
+
}
|
|
163
|
+
})();
|
|
144
164
|
if (ci) {
|
|
145
165
|
let beforeStatus = '';
|
|
146
166
|
try {
|
|
147
|
-
beforeStatus = (0, node_child_process_1.execFileSync)(
|
|
167
|
+
beforeStatus = (0, node_child_process_1.execFileSync)(gitExecutable, ['status', '--porcelain', '--', relativeBaselinesDir], {
|
|
148
168
|
cwd,
|
|
149
169
|
encoding: 'utf-8',
|
|
150
170
|
});
|
|
@@ -214,7 +234,7 @@ function runVisualBattery(harnessDir, options = {}) {
|
|
|
214
234
|
log(`baseline screenshots produced: ${baselineCount}`);
|
|
215
235
|
let afterStatus = '';
|
|
216
236
|
try {
|
|
217
|
-
afterStatus = (0, node_child_process_1.execFileSync)(
|
|
237
|
+
afterStatus = (0, node_child_process_1.execFileSync)(gitExecutable, ['status', '--porcelain', '-z', '--untracked-files=all', '--', relativeBaselinesDir], {
|
|
218
238
|
cwd,
|
|
219
239
|
encoding: 'utf-8',
|
|
220
240
|
});
|
|
@@ -222,6 +242,50 @@ function runVisualBattery(harnessDir, options = {}) {
|
|
|
222
242
|
catch (err) {
|
|
223
243
|
die(`git status failed after harness run: ${err}`);
|
|
224
244
|
}
|
|
245
|
+
const drift = [];
|
|
246
|
+
const entries = afterStatus.split('\0').filter(Boolean);
|
|
247
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
248
|
+
const entry = entries[index];
|
|
249
|
+
const status = entry.slice(0, 2);
|
|
250
|
+
const path = entry.slice(3);
|
|
251
|
+
// Renames/copies have a second NUL-delimited path; neither is noise.
|
|
252
|
+
if (/[RC]/.test(status))
|
|
253
|
+
index += 1;
|
|
254
|
+
if (status !== ' M' || !path.endsWith('.png')) {
|
|
255
|
+
drift.push(entry);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const committed = pngjs_1.PNG.sync.read((0, node_child_process_1.execFileSync)(gitExecutable, ['show', `HEAD:./${path}`], { cwd }));
|
|
260
|
+
const current = pngjs_1.PNG.sync.read((0, node_fs_1.readFileSync)((0, node_path_1.resolve)(cwd, path)));
|
|
261
|
+
if (committed.width !== current.width || committed.height !== current.height) {
|
|
262
|
+
drift.push(entry);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
let changed = 0;
|
|
266
|
+
let beyond = 0;
|
|
267
|
+
for (let offset = 0; offset < current.data.length; offset += 4) {
|
|
268
|
+
const delta = Math.max(Math.abs(current.data[offset] - committed.data[offset]), Math.abs(current.data[offset + 1] - committed.data[offset + 1]), Math.abs(current.data[offset + 2] - committed.data[offset + 2]), Math.abs(current.data[offset + 3] - committed.data[offset + 3]));
|
|
269
|
+
if (delta > 0)
|
|
270
|
+
changed += 1;
|
|
271
|
+
if (delta > maxChannelDelta)
|
|
272
|
+
beyond += 1;
|
|
273
|
+
}
|
|
274
|
+
if (beyond / (current.width * current.height) > maxDifferentPixelRatio) {
|
|
275
|
+
drift.push(entry);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
(0, node_child_process_1.execFileSync)(gitExecutable, ['checkout', '--', path], { cwd });
|
|
279
|
+
log(beyond === 0
|
|
280
|
+
? `${path}: rasterization noise (${changed} px within ±${maxChannelDelta})`
|
|
281
|
+
: `${path}: accepted pixel tolerance (${beyond} px beyond ±${maxChannelDelta})`);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// Missing/unreadable committed PNGs or failed restoration must fail closed.
|
|
285
|
+
drift.push(entry);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
afterStatus = drift.join('\n');
|
|
225
289
|
if (afterStatus.trim().length === 0) {
|
|
226
290
|
log('baselines clean — no visual drift detected.');
|
|
227
291
|
return;
|
|
@@ -13,11 +13,29 @@ Arguments:
|
|
|
13
13
|
|
|
14
14
|
Options:
|
|
15
15
|
--ci Fail on drift and refuse a dirty starting state
|
|
16
|
+
--max-channel-delta N RGBA channel tolerance, integer 0–255 (default: 2)
|
|
17
|
+
--max-different-pixel-ratio N Allowed fraction beyond tolerance (default: 0)
|
|
16
18
|
-h, --help Show this help
|
|
17
19
|
|
|
18
20
|
The legacy executable name test-harness-visual-battery remains available.`);
|
|
19
21
|
process.exit(0);
|
|
20
22
|
}
|
|
23
|
+
const thresholds = {};
|
|
24
|
+
for (const [flag, key] of [
|
|
25
|
+
['--max-channel-delta', 'maxChannelDelta'],
|
|
26
|
+
['--max-different-pixel-ratio', 'maxDifferentPixelRatio'],
|
|
27
|
+
]) {
|
|
28
|
+
const index = args.indexOf(flag);
|
|
29
|
+
if (index === -1)
|
|
30
|
+
continue;
|
|
31
|
+
const value = args[index + 1];
|
|
32
|
+
if (value === undefined || value.trim() === '' || !Number.isFinite(Number(value))) {
|
|
33
|
+
console.error(`${flag} requires a finite numeric value.`);
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
thresholds[key] = Number(value);
|
|
37
|
+
args.splice(index, 2);
|
|
38
|
+
}
|
|
21
39
|
const unknownFlags = args.filter((argument) => argument.startsWith('-') && argument !== '--ci');
|
|
22
40
|
if (unknownFlags.length > 0) {
|
|
23
41
|
console.error(`Unknown option(s): ${unknownFlags.join(', ')}. Use --help for usage.`);
|
|
@@ -31,7 +49,7 @@ if (positionalArgs.length > 1) {
|
|
|
31
49
|
const ci = args.includes('--ci');
|
|
32
50
|
const harnessDirArg = positionalArgs[0] ?? 'tests/harness';
|
|
33
51
|
try {
|
|
34
|
-
runVisualBattery(harnessDirArg, { ci });
|
|
52
|
+
runVisualBattery(harnessDirArg, { ci, ...thresholds });
|
|
35
53
|
}
|
|
36
54
|
catch (err) {
|
|
37
55
|
if (err instanceof VisualBatteryError) {
|
|
@@ -9,13 +9,27 @@ const GPU_ARGS = {
|
|
|
9
9
|
],
|
|
10
10
|
};
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Keeps timers and rendering on schedule in pages Chromium considers
|
|
13
|
+
* backgrounded. Several headed windows commonly run at once during a test
|
|
14
|
+
* suite; without these switches a background tab's timers are coalesced to
|
|
15
|
+
* one wake-up per second, and an occluded window or backgrounded renderer is
|
|
16
|
+
* deprioritised, so suites time out for reasons unrelated to the game.
|
|
17
|
+
* Page Visibility still reports `hidden`, so visibility handling stays
|
|
18
|
+
* testable.
|
|
19
|
+
*/
|
|
20
|
+
export const CHROMIUM_ANTI_THROTTLING_ARGS = Object.freeze([
|
|
21
|
+
'--disable-background-timer-throttling',
|
|
22
|
+
'--disable-renderer-backgrounding',
|
|
23
|
+
'--disable-backgrounding-occluded-windows',
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* Builds the shared Chromium renderer, scheduling and silence profile without
|
|
27
|
+
* deciding whether the browser is headed. Callers own that explicit choice.
|
|
14
28
|
*/
|
|
15
29
|
export function createChromiumLaunchProfile(options = {}) {
|
|
16
30
|
const gpuMode = options.gpuMode ?? 'auto';
|
|
17
31
|
const args = [
|
|
18
|
-
...new Set([...GPU_ARGS[gpuMode], ...(options.args ?? [])].filter((argument) => argument !== '--mute-audio')),
|
|
32
|
+
...new Set([...GPU_ARGS[gpuMode], ...CHROMIUM_ANTI_THROTTLING_ARGS, ...(options.args ?? [])].filter((argument) => argument !== '--mute-audio')),
|
|
19
33
|
'--mute-audio',
|
|
20
34
|
];
|
|
21
35
|
if (gpuMode === 'linux-hardware-vulkan') {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* screenshots → native sync, or whatever a given repo's ladder is), run in
|
|
5
5
|
* sequence and stopped at the first failure with a labeled summary.
|
|
6
6
|
*
|
|
7
|
-
* Generalizes the
|
|
7
|
+
* Generalizes the common pattern of many discrete
|
|
8
8
|
* `node scripts/verify-X.mjs` files composed via a shell `&&` chain into a
|
|
9
9
|
* single reusable primitive: each step is a plain function (sync or async),
|
|
10
10
|
* so a repo can inline its logic or delegate to existing scripts via
|