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