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
@@ -0,0 +1,304 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { chromium } from '@playwright/test';
4
+ import getPort from 'get-port';
5
+ import { createChromiumLaunchProfile } from './chromium-launch.js';
6
+ import { openSilentGame, } from './playwright-config.js';
7
+ /**
8
+ * Thrown by every failure path in this module — an unreachable/occupied
9
+ * readiness URL, a server that never became ready, a masked or
10
+ * software-rendered WebGL context, a changed localStorage sentinel, or one
11
+ * or more recorded {@link ProductionRuntimeIssue}s. Callers can inspect
12
+ * `.issues` for the underlying runtime errors instead of parsing `.message`.
13
+ */
14
+ export class ProductionRuntimeVerificationError extends Error {
15
+ /** Runtime issues recorded before this error was thrown, if any. Empty for pure validation failures. */
16
+ issues;
17
+ constructor(message, issues = [], cause) {
18
+ super(message, cause === undefined ? undefined : { cause });
19
+ this.name = 'ProductionRuntimeVerificationError';
20
+ this.issues = issues;
21
+ }
22
+ }
23
+ function validateHttpUrl(label, value) {
24
+ const trimmed = value.trim();
25
+ if (!trimmed) {
26
+ throw new ProductionRuntimeVerificationError(`${label} must not be empty`);
27
+ }
28
+ let parsed;
29
+ try {
30
+ parsed = new URL(trimmed);
31
+ }
32
+ catch (error) {
33
+ throw new ProductionRuntimeVerificationError(`${label} must be a valid absolute URL`, [], error);
34
+ }
35
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
36
+ throw new ProductionRuntimeVerificationError(`${label} must use http or https`);
37
+ }
38
+ return trimmed;
39
+ }
40
+ function validateDuration(label, value, allowZero) {
41
+ if (value === undefined)
42
+ return;
43
+ if (!Number.isFinite(value) || (allowZero ? value < 0 : value <= 0)) {
44
+ throw new ProductionRuntimeVerificationError(`${label} must be a ${allowZero ? 'non-negative' : 'positive'} finite number`);
45
+ }
46
+ }
47
+ /**
48
+ * Finds and process-reserves an available loopback port for an owned production
49
+ * preview. The reservation prevents parallel verifier setup in this process
50
+ * from selecting the same port; the preview must still bind with strict-port
51
+ * semantics so an external race fails closed.
52
+ */
53
+ export async function findAvailableProductionPort(options = {}) {
54
+ return getPort({ host: options.host ?? '127.0.0.1', reserve: true });
55
+ }
56
+ async function probe(url) {
57
+ try {
58
+ const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
59
+ const result = {
60
+ reachable: true,
61
+ ok: response.ok,
62
+ status: response.status,
63
+ };
64
+ try {
65
+ await response.body?.cancel();
66
+ }
67
+ catch {
68
+ // Reachability is already proven. A stream-cleanup failure must not make
69
+ // an occupied address look free or make a ready server look unreachable.
70
+ }
71
+ return result;
72
+ }
73
+ catch {
74
+ return { reachable: false, ok: false };
75
+ }
76
+ }
77
+ function childExited(child) {
78
+ return child.exitCode !== null || child.signalCode !== null;
79
+ }
80
+ async function waitForChildExit(child, timeoutMs) {
81
+ if (childExited(child))
82
+ return true;
83
+ return new Promise((resolve) => {
84
+ const finish = (exited) => {
85
+ clearTimeout(timer);
86
+ child.off('exit', onExit);
87
+ resolve(exited);
88
+ };
89
+ const onExit = () => finish(true);
90
+ const timer = setTimeout(() => finish(false), timeoutMs);
91
+ child.once('exit', onExit);
92
+ });
93
+ }
94
+ async function stopServer(child, timeoutMs) {
95
+ if (childExited(child))
96
+ return;
97
+ if (!child.kill('SIGTERM'))
98
+ return;
99
+ if (await waitForChildExit(child, timeoutMs))
100
+ return;
101
+ if (!child.kill('SIGKILL'))
102
+ return;
103
+ await waitForChildExit(child, 1_000);
104
+ }
105
+ async function startServer(options, runtimeUrl) {
106
+ const readyUrl = options.readyUrl ?? runtimeUrl;
107
+ const before = await probe(readyUrl);
108
+ if (before.reachable) {
109
+ throw new ProductionRuntimeVerificationError(`runtime readiness URL is already reachable; refusing to reuse another process: ${readyUrl}`);
110
+ }
111
+ const spawnOptions = {
112
+ env: { ...process.env, ...options.env },
113
+ stdio: ['ignore', 'inherit', 'inherit'],
114
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
115
+ };
116
+ const child = spawn(options.command, [...(options.args ?? [])], spawnOptions);
117
+ let spawnError;
118
+ child.once('error', (error) => {
119
+ spawnError = error;
120
+ });
121
+ const timeoutMs = options.startupTimeoutMs ?? 15_000;
122
+ const deadline = Date.now() + timeoutMs;
123
+ try {
124
+ while (Date.now() < deadline) {
125
+ if (spawnError) {
126
+ throw new ProductionRuntimeVerificationError(`runtime server failed to start: ${spawnError.message}`, [], spawnError);
127
+ }
128
+ if (childExited(child)) {
129
+ throw new ProductionRuntimeVerificationError(`runtime server exited before readiness (code=${String(child.exitCode)}, signal=${String(child.signalCode)})`);
130
+ }
131
+ const result = await probe(readyUrl);
132
+ if (result.ok)
133
+ return child;
134
+ await delay(100);
135
+ }
136
+ throw new ProductionRuntimeVerificationError(`runtime server did not become ready within ${timeoutMs} ms: ${readyUrl}`);
137
+ }
138
+ catch (error) {
139
+ await stopServer(child, options.shutdownTimeoutMs ?? 5_000);
140
+ throw error;
141
+ }
142
+ }
143
+ function formatIssues(issues) {
144
+ return issues
145
+ .map((issue) => {
146
+ const location = issue.url ? ` (${issue.url})` : '';
147
+ return `[${issue.kind}] ${issue.message}${location}`;
148
+ })
149
+ .join('\n');
150
+ }
151
+ function mutedLaunchOptions(options, gpuMode) {
152
+ const profile = createChromiumLaunchProfile({
153
+ ...(gpuMode === undefined ? {} : { gpuMode }),
154
+ ...(options?.args === undefined ? {} : { args: options.args }),
155
+ ...(options?.env === undefined ? {} : { env: options.env }),
156
+ });
157
+ return {
158
+ ...options,
159
+ ...profile,
160
+ headless: options?.headless ?? false,
161
+ };
162
+ }
163
+ const SOFTWARE_RENDERER_PATTERN = /swiftshader|llvmpipe|software rasterizer|microsoft basic render driver|angle.*(?:warp|software)/i;
164
+ /** Reads the unmasked WebGL renderer already attached to the selected game canvas. */
165
+ export async function readWebGLRenderer(page, canvasSelector = 'canvas') {
166
+ return page.evaluate((selector) => {
167
+ const canvas = document.querySelector(selector);
168
+ if (!(canvas instanceof HTMLCanvasElement)) {
169
+ throw new Error(`WebGL canvas not found: ${selector}`);
170
+ }
171
+ const context = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
172
+ if (!context)
173
+ throw new Error(`WebGL context unavailable: ${selector}`);
174
+ const extension = context.getExtension('WEBGL_debug_renderer_info');
175
+ const renderer = String(context.getParameter(extension?.UNMASKED_RENDERER_WEBGL ?? context.RENDERER));
176
+ const vendor = String(context.getParameter(extension?.UNMASKED_VENDOR_WEBGL ?? context.VENDOR));
177
+ return { renderer, vendor, unmasked: extension !== null };
178
+ }, canvasSelector);
179
+ }
180
+ /** Requires a real hardware-backed WebGL renderer and returns its identity. */
181
+ export async function requireHardwareWebGL(page, canvasSelector = 'canvas') {
182
+ const info = await readWebGLRenderer(page, canvasSelector);
183
+ if (!info.unmasked || !info.renderer.trim() || SOFTWARE_RENDERER_PATTERN.test(info.renderer)) {
184
+ throw new ProductionRuntimeVerificationError(`hardware WebGL required; received ${info.unmasked ? 'unmasked' : 'masked'} renderer: ${info.renderer || '<empty>'}`);
185
+ }
186
+ return info;
187
+ }
188
+ async function seedLocalStorage(page, sentinels) {
189
+ const entries = Object.entries(sentinels);
190
+ if (entries.length === 0)
191
+ return;
192
+ await page.addInitScript((values) => {
193
+ const storage = globalThis.localStorage;
194
+ for (const [key, value] of values) {
195
+ if (value === null)
196
+ storage.removeItem(key);
197
+ else
198
+ storage.setItem(key, value);
199
+ }
200
+ }, entries);
201
+ }
202
+ async function readLocalStorage(page, keys) {
203
+ if (keys.length === 0)
204
+ return {};
205
+ return page.evaluate((sentinelKeys) => {
206
+ const storage = globalThis.localStorage;
207
+ return Object.fromEntries(sentinelKeys.map((key) => [key, storage.getItem(key)]));
208
+ }, [...keys]);
209
+ }
210
+ /**
211
+ * Boots a built or deployed game in a fresh, fail-closed, silent Chromium and
212
+ * requires game-specific identity/UI proof before accepting the runtime.
213
+ */
214
+ export async function verifyProductionRuntime(options) {
215
+ const runtimeUrl = validateHttpUrl('runtime URL', options.url);
216
+ if (typeof options.assertReady !== 'function') {
217
+ throw new ProductionRuntimeVerificationError('assertReady must be a function');
218
+ }
219
+ validateDuration('settleTimeMs', options.settleTimeMs, true);
220
+ if (options.server) {
221
+ if (!options.server.command.trim()) {
222
+ throw new ProductionRuntimeVerificationError('runtime server command must not be empty');
223
+ }
224
+ if (options.server.readyUrl !== undefined) {
225
+ validateHttpUrl('runtime readiness URL', options.server.readyUrl);
226
+ }
227
+ validateDuration('startupTimeoutMs', options.server.startupTimeoutMs, false);
228
+ validateDuration('shutdownTimeoutMs', options.server.shutdownTimeoutMs, true);
229
+ }
230
+ let child;
231
+ let browser;
232
+ const issues = [];
233
+ try {
234
+ if (options.server)
235
+ child = await startServer(options.server, runtimeUrl);
236
+ browser = await chromium.launch(mutedLaunchOptions(options.browserLaunchOptions, options.gpuMode));
237
+ const page = options.pageOptions
238
+ ? await browser.newPage(options.pageOptions)
239
+ : await browser.newPage();
240
+ page.on('pageerror', (error) => {
241
+ issues.push({ kind: 'pageerror', message: error.message });
242
+ });
243
+ page.on('console', (message) => {
244
+ if (message.type() === 'error') {
245
+ issues.push({ kind: 'console', message: message.text() });
246
+ }
247
+ });
248
+ page.on('requestfailed', (request) => {
249
+ issues.push({
250
+ kind: 'requestfailed',
251
+ message: request.failure()?.errorText ?? 'request failed without an error message',
252
+ url: request.url(),
253
+ });
254
+ });
255
+ page.on('response', (response) => {
256
+ if (response.status() >= 400) {
257
+ issues.push({
258
+ kind: 'http',
259
+ message: `HTTP ${response.status()}`,
260
+ url: response.url(),
261
+ });
262
+ }
263
+ });
264
+ const sentinels = options.localStorageSentinels ?? {};
265
+ await seedLocalStorage(page, sentinels);
266
+ try {
267
+ await openSilentGame(page, runtimeUrl, options.silentParameters ?? {}, {
268
+ markerTimeout: 15_000,
269
+ ...options.silentOptions,
270
+ });
271
+ await options.assertReady(page);
272
+ await options.assertSilentState?.(page);
273
+ const settleTimeMs = options.settleTimeMs ?? 250;
274
+ if (settleTimeMs > 0)
275
+ await page.waitForTimeout(settleTimeMs);
276
+ }
277
+ catch (error) {
278
+ if (issues.length > 0) {
279
+ throw new ProductionRuntimeVerificationError(`production runtime failed while emitting runtime errors:\n${formatIssues(issues)}`, issues, error);
280
+ }
281
+ throw error;
282
+ }
283
+ const localStorage = await readLocalStorage(page, Object.keys(sentinels));
284
+ for (const [key, expected] of Object.entries(sentinels)) {
285
+ const received = localStorage[key] ?? null;
286
+ if (received !== expected) {
287
+ throw new ProductionRuntimeVerificationError(`localStorage sentinel ${key} changed: expected ${String(expected)}, received ${String(received)}`);
288
+ }
289
+ }
290
+ if (issues.length > 0) {
291
+ throw new ProductionRuntimeVerificationError(`production runtime emitted errors:\n${formatIssues(issues)}`, issues);
292
+ }
293
+ return { finalUrl: page.url(), localStorage };
294
+ }
295
+ finally {
296
+ try {
297
+ await browser?.close();
298
+ }
299
+ finally {
300
+ if (child)
301
+ await stopServer(child, options.server?.shutdownTimeoutMs ?? 5_000);
302
+ }
303
+ }
304
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Thin orchestrator for a `verify:*` release ladder — an ordered list of
3
+ * named steps (lint → typecheck → test → build → browser verifies →
4
+ * screenshots → native sync, or whatever a given repo's ladder is), run in
5
+ * sequence and stopped at the first failure with a labeled summary.
6
+ *
7
+ * Generalizes the reach-for-the-sky pattern of ~17 discrete
8
+ * `node scripts/verify-X.mjs` files composed via a shell `&&` chain into a
9
+ * single reusable primitive: each step is a plain function (sync or async),
10
+ * so a repo can inline its logic or delegate to existing scripts via
11
+ * `execSync`.
12
+ *
13
+ * Never throws — returns a result object so callers can decide how to
14
+ * report/exit. The CLI convention is `process.exit(result.ok ? 0 : 1)`.
15
+ */
16
+ export async function verifyReleaseLadder(steps, options = {}) {
17
+ const log = options.log ?? ((msg) => console.log(`[verify] ${msg}`));
18
+ const error = options.error ?? ((msg) => console.error(`[verify] ${msg}`));
19
+ const ranSteps = [];
20
+ for (const step of steps) {
21
+ log(`▶ ${step.name}`);
22
+ try {
23
+ await step.run();
24
+ ranSteps.push(step.name);
25
+ log(`✓ ${step.name}`);
26
+ }
27
+ catch (err) {
28
+ error(`✗ ${step.name} failed`);
29
+ if (err instanceof Error) {
30
+ error(err.message);
31
+ }
32
+ return { ok: false, ranSteps, failedStep: step.name, error: err };
33
+ }
34
+ }
35
+ log(`all ${ranSteps.length} step(s) passed.`);
36
+ return { ok: true, ranSteps };
37
+ }
@@ -0,0 +1,67 @@
1
+ export const SILENT_QA_QUERY_PARAMETER = 'muted';
2
+ export const SILENT_QA_QUERY_VALUE = '1';
3
+ export const SILENT_QA_MARKER_ATTRIBUTE = 'data-audio-mode';
4
+ export const SILENT_QA_MARKER_VALUE = 'muted-test';
5
+ let silentQaActive = false;
6
+ function isPromiseLike(value) {
7
+ return (((typeof value === 'object' && value !== null) || typeof value === 'function') &&
8
+ typeof value.then === 'function');
9
+ }
10
+ function browserSearch() {
11
+ return typeof window === 'undefined' ? '' : window.location.search;
12
+ }
13
+ function browserMarkerTarget() {
14
+ return typeof document === 'undefined' ? null : document.documentElement;
15
+ }
16
+ /**
17
+ * Detects a page-lifetime mute request by parameter presence.
18
+ *
19
+ * The value is intentionally ignored so stale links such as `?muted=0` cannot
20
+ * make an agent-controlled session audible.
21
+ */
22
+ export function isSilentQaRequested(search = browserSearch(), queryParameter = SILENT_QA_QUERY_PARAMETER) {
23
+ return new URLSearchParams(search).has(queryParameter);
24
+ }
25
+ /**
26
+ * Activates runtime-only silent QA through a consumer-owned audio mute callback.
27
+ *
28
+ * The package stays audio-engine agnostic: consumers may mute Howler, WebAudio,
29
+ * HTMLMediaElement, or another engine. The DOM marker is published only after
30
+ * the callback returns successfully, allowing browser tests to fail closed.
31
+ * This helper never reads or writes a player's persisted audio preference.
32
+ */
33
+ export function activateSilentQa(mute, options = {}) {
34
+ if (!isSilentQaRequested(options.search ?? browserSearch(), options.queryParameter ?? SILENT_QA_QUERY_PARAMETER)) {
35
+ return false;
36
+ }
37
+ const result = mute();
38
+ if (isPromiseLike(result)) {
39
+ throw new TypeError('activateSilentQa received an asynchronous mute callback; await activateSilentQaAsync instead');
40
+ }
41
+ const markerTarget = options.markerTarget === undefined ? browserMarkerTarget() : options.markerTarget;
42
+ markerTarget?.setAttribute(options.markerAttribute ?? SILENT_QA_MARKER_ATTRIBUTE, options.markerValue ?? SILENT_QA_MARKER_VALUE);
43
+ silentQaActive = true;
44
+ return true;
45
+ }
46
+ /**
47
+ * Asynchronous counterpart to {@link activateSilentQa}. The readiness marker
48
+ * is not published until the consumer's mute promise fulfills.
49
+ */
50
+ export async function activateSilentQaAsync(mute, options = {}) {
51
+ if (!isSilentQaRequested(options.search ?? browserSearch(), options.queryParameter ?? SILENT_QA_QUERY_PARAMETER)) {
52
+ return false;
53
+ }
54
+ await mute();
55
+ const markerTarget = options.markerTarget === undefined ? browserMarkerTarget() : options.markerTarget;
56
+ markerTarget?.setAttribute(options.markerAttribute ?? SILENT_QA_MARKER_ATTRIBUTE, options.markerValue ?? SILENT_QA_MARKER_VALUE);
57
+ silentQaActive = true;
58
+ return true;
59
+ }
60
+ /** True after this module has successfully activated the page-lifetime override. */
61
+ export function isSilentQaActive() {
62
+ return silentQaActive;
63
+ }
64
+ /** Test hook; production code must never disable a page-lifetime override. */
65
+ export function _resetSilentQaForTests() {
66
+ silentQaActive = false;
67
+ }
@@ -0,0 +1,239 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
3
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import spawn from 'cross-spawn';
5
+ /**
6
+ * Thrown by every `runVisualBattery()` failure path — a missing harness dir,
7
+ * no discovered `.browser.test.ts(x)` files, a misplaced `__screenshots__`
8
+ * directory, a dirty baseline dir in `--ci` mode, a failing harness run, or
9
+ * detected drift while `ci: true`. Callers (tests, other tooling) can catch
10
+ * this specific type instead of `process.exit`, which only the CLI entry
11
+ * point (`bin/test-harness-visual-battery`) calls.
12
+ */
13
+ export class VisualBatteryError extends Error {
14
+ }
15
+ function findUnexpectedBaselineDirectories(root, canonical) {
16
+ const unexpected = [];
17
+ const visit = (directory) => {
18
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
19
+ if (!entry.isDirectory())
20
+ continue;
21
+ const child = resolve(directory, entry.name);
22
+ if (child === canonical)
23
+ continue;
24
+ if (entry.name === '__screenshots__') {
25
+ unexpected.push(child);
26
+ continue;
27
+ }
28
+ visit(child);
29
+ }
30
+ };
31
+ visit(root);
32
+ return unexpected;
33
+ }
34
+ function defaultLog(msg) {
35
+ console.log(`[visual-battery] ${msg}`);
36
+ }
37
+ function defaultError(msg) {
38
+ console.error(`[visual-battery] ERROR: ${msg}`);
39
+ }
40
+ function canonicalizePath(target) {
41
+ const missingSegments = [];
42
+ let existingAncestor = target;
43
+ while (!existsSync(existingAncestor)) {
44
+ missingSegments.unshift(basename(existingAncestor));
45
+ existingAncestor = dirname(existingAncestor);
46
+ }
47
+ return resolve(realpathSync(existingAncestor), ...missingSegments);
48
+ }
49
+ /**
50
+ * Deterministic git-diff-based visual-regression gate.
51
+ *
52
+ * Runs every `.browser.test.tsx` harness file under `harnessDir`, then
53
+ * diffs the resulting `__screenshots__/*.png` baselines against what's
54
+ * committed in git. No pixel-threshold fuzzing, no flaky perceptual
55
+ * comparison — a screenshot either byte-matches the committed baseline
56
+ * (via `git status --porcelain`) or it doesn't.
57
+ *
58
+ * - Update mode (`ci: false`, the default): runs the harnesses, lets new
59
+ * baselines land on disk, reports what changed so a human can review
60
+ * `git diff` and commit intentionally.
61
+ * - CI mode (`ci: true`): refuses to run at all if the baselines dir has
62
+ * uncommitted changes already (an untrusted starting state), then fails
63
+ * the process if the run produces any drift from the committed baseline.
64
+ *
65
+ * Throws `VisualBatteryError` on any failure condition instead of calling
66
+ * `process.exit` directly, so callers (tests, other tooling) can catch it;
67
+ * the CLI entry point (`bin/test-harness-visual-battery`) is the thing that
68
+ * exits the process.
69
+ */
70
+ export function runVisualBattery(harnessDir, options = {}) {
71
+ const { ci = false, cwd = process.cwd(), testCommand = 'pnpm test:browser', baselineProfile, isolatedHarnessFiles = [], log = defaultLog, error = defaultError, } = options;
72
+ const die = (msg) => {
73
+ error(msg);
74
+ throw new VisualBatteryError(msg);
75
+ };
76
+ const resolvedCwd = resolve(cwd);
77
+ const canonicalCwd = (() => {
78
+ try {
79
+ return realpathSync(resolvedCwd);
80
+ }
81
+ catch {
82
+ return die(`cwd not found or not reachable: ${cwd}`);
83
+ }
84
+ })();
85
+ const HARNESS_DIR = resolve(canonicalCwd, harnessDir);
86
+ const relativeHarnessDir = relative(canonicalCwd, HARNESS_DIR).split(sep).join('/') || '.';
87
+ if (baselineProfile && !/^[a-z0-9][a-z0-9_-]*$/i.test(baselineProfile)) {
88
+ throw new VisualBatteryError(`invalid baseline profile: ${baselineProfile}`);
89
+ }
90
+ if (options.baselinesDir && isAbsolute(options.baselinesDir)) {
91
+ die(`baselines dir must be relative to cwd: ${options.baselinesDir}`);
92
+ }
93
+ const relativeBaselinesRoot = options.baselinesDir ?? `${relativeHarnessDir}/__screenshots__`;
94
+ const canonicalBaselinesDir = resolve(canonicalCwd, relativeBaselinesRoot);
95
+ const relativeBaselinesDir = baselineProfile
96
+ ? `${relativeBaselinesRoot.replace(/\/$/, '')}/${baselineProfile}`
97
+ : relativeBaselinesRoot;
98
+ const BASELINES_DIR = resolve(canonicalCwd, relativeBaselinesDir);
99
+ const assertInsideCwd = (label, target) => {
100
+ const pathFromCwd = relative(canonicalCwd, canonicalizePath(target));
101
+ if (pathFromCwd === '..' || pathFromCwd.startsWith(`..${sep}`) || isAbsolute(pathFromCwd)) {
102
+ die(`${label} must stay inside cwd: ${target}`);
103
+ }
104
+ };
105
+ assertInsideCwd('harness dir', HARNESS_DIR);
106
+ assertInsideCwd('baselines dir', BASELINES_DIR);
107
+ if (!existsSync(HARNESS_DIR)) {
108
+ die(`harness dir not found: ${HARNESS_DIR}`);
109
+ }
110
+ if (!statSync(HARNESS_DIR).isDirectory()) {
111
+ die(`harness path is not a directory: ${HARNESS_DIR}`);
112
+ }
113
+ const unexpectedBaselineDirectories = findUnexpectedBaselineDirectories(HARNESS_DIR, canonicalBaselinesDir);
114
+ if (unexpectedBaselineDirectories.length > 0) {
115
+ die(`unexpected screenshot director${unexpectedBaselineDirectories.length === 1 ? 'y' : 'ies'} outside ${relativeBaselinesDir}: ${unexpectedBaselineDirectories
116
+ .map((directory) => relative(cwd, directory))
117
+ .join(', ')}`);
118
+ }
119
+ const harnessFiles = readdirSync(HARNESS_DIR)
120
+ .filter((f) => /\.browser\.test\.(?:ts|tsx)$/.test(f))
121
+ .sort()
122
+ .map((f) => `${relativeHarnessDir}/${f}`);
123
+ if (harnessFiles.length === 0) {
124
+ die('no .browser.test.ts or .browser.test.tsx harness files found');
125
+ }
126
+ const unknownIsolatedHarnessFiles = isolatedHarnessFiles.filter((file) => !harnessFiles.some((harnessFile) => harnessFile.endsWith(`/${file}`)));
127
+ if (unknownIsolatedHarnessFiles.length > 0) {
128
+ die(`isolated harness file(s) not found: ${unknownIsolatedHarnessFiles.join(', ')}`);
129
+ }
130
+ const isolatedHarnessSet = new Set(isolatedHarnessFiles);
131
+ const batchedHarnessFiles = harnessFiles.filter((file) => !isolatedHarnessSet.has(file.slice(file.lastIndexOf('/') + 1)));
132
+ const isolatedHarnessPaths = harnessFiles.filter((file) => isolatedHarnessSet.has(file.slice(file.lastIndexOf('/') + 1)));
133
+ log(`running ${harnessFiles.length} harness file(s):`);
134
+ for (const f of harnessFiles)
135
+ log(` - ${f}`);
136
+ if (ci) {
137
+ let beforeStatus = '';
138
+ try {
139
+ beforeStatus = execFileSync('git', ['status', '--porcelain', '--', relativeBaselinesDir], {
140
+ cwd,
141
+ encoding: 'utf-8',
142
+ });
143
+ }
144
+ catch (err) {
145
+ die(`git status failed: ${err}`);
146
+ }
147
+ if (beforeStatus.trim().length > 0) {
148
+ die(`${relativeBaselinesDir}/ has uncommitted changes before run — commit or reset before --ci mode`);
149
+ }
150
+ }
151
+ const runHarnessFiles = (files, label) => {
152
+ if (files.length === 0)
153
+ return;
154
+ const command = typeof testCommand === 'string'
155
+ ? (() => {
156
+ const parts = testCommand.trim().split(/\s+/u);
157
+ const executable = parts.shift() || die('test command must not be empty');
158
+ return { command: executable, args: parts };
159
+ })()
160
+ : testCommand;
161
+ if (!command.command.trim())
162
+ die('test command executable must not be empty');
163
+ const commandArgs = [...(command.args ?? []), ...files];
164
+ log(`running ${label}: ${[command.command, ...commandArgs].join(' ')}...`);
165
+ const environment = {
166
+ ...process.env,
167
+ ...(baselineProfile ? { VITE_VISUAL_BASELINE_PROFILE: baselineProfile } : {}),
168
+ };
169
+ try {
170
+ if (process.platform === 'win32') {
171
+ // Package managers are commonly exposed as .cmd shims on Windows.
172
+ // cross-spawn resolves those shims without opting into shell: true.
173
+ const result = spawn.sync(command.command, commandArgs, {
174
+ cwd,
175
+ stdio: 'inherit',
176
+ env: environment,
177
+ });
178
+ if (result.error)
179
+ throw result.error;
180
+ if (result.status !== 0)
181
+ throw new Error(`test command exited with ${result.status}`);
182
+ }
183
+ else {
184
+ execFileSync(command.command, commandArgs, {
185
+ cwd,
186
+ stdio: 'inherit',
187
+ env: environment,
188
+ });
189
+ }
190
+ }
191
+ catch {
192
+ die('one or more harnesses failed — fix the failing test before re-running visual battery');
193
+ }
194
+ };
195
+ runHarnessFiles(batchedHarnessFiles, 'batched harnesses');
196
+ for (const isolatedHarnessPath of isolatedHarnessPaths) {
197
+ runHarnessFiles([isolatedHarnessPath], `isolated harness ${isolatedHarnessPath}`);
198
+ }
199
+ if (!existsSync(BASELINES_DIR)) {
200
+ die(`baselines dir not created: ${BASELINES_DIR}`);
201
+ }
202
+ const baselineCount = readdirSync(BASELINES_DIR).filter((f) => f.endsWith('.png')).length;
203
+ if (baselineCount === 0) {
204
+ die(`no PNG baselines produced in: ${BASELINES_DIR}`);
205
+ }
206
+ log(`baseline screenshots produced: ${baselineCount}`);
207
+ let afterStatus = '';
208
+ try {
209
+ afterStatus = execFileSync('git', ['status', '--porcelain', '--', relativeBaselinesDir], {
210
+ cwd,
211
+ encoding: 'utf-8',
212
+ });
213
+ }
214
+ catch (err) {
215
+ die(`git status failed after harness run: ${err}`);
216
+ }
217
+ if (afterStatus.trim().length === 0) {
218
+ log('baselines clean — no visual drift detected.');
219
+ return;
220
+ }
221
+ if (ci) {
222
+ error('DRIFT DETECTED in CI mode:');
223
+ error(afterStatus);
224
+ error(`failing — run visual:battery locally + commit baselines`);
225
+ throw new VisualBatteryError('visual drift detected in CI mode');
226
+ }
227
+ log(`baselines updated — ${afterStatus.split('\n').filter(Boolean).length} file(s) changed:`);
228
+ log(afterStatus);
229
+ log(`review the diff with \`git diff ${relativeBaselinesDir}/\` then commit if intended.`);
230
+ log('CI gate: rerun with `ci: true` will fail until the updated baselines are committed.');
231
+ log('baseline file sizes:');
232
+ for (const f of readdirSync(BASELINES_DIR)) {
233
+ if (!f.endsWith('.png'))
234
+ continue;
235
+ const path = resolve(BASELINES_DIR, f);
236
+ const size = statSync(path).size;
237
+ log(` ${f} ${size} bytes`);
238
+ }
239
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};