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,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defineBrowserTestConfig = defineBrowserTestConfig;
4
+ const browser_playwright_1 = require("@vitest/browser-playwright");
5
+ const chromium_launch_js_1 = require("./chromium-launch.js");
6
+ function resolveHeadless(headless) {
7
+ if (typeof headless === 'boolean')
8
+ return headless;
9
+ // Explicit legacy/hosted-runner mode: headed locally, headless under CI.
10
+ return Boolean(process.env.CI);
11
+ }
12
+ /**
13
+ * Builds a `test` fragment for a Vitest Browser Mode project, wired for
14
+ * real-Chromium (or other Playwright-driven browser) test execution.
15
+ *
16
+ * Encodes a reviewed pattern: headed by default both locally and in
17
+ * CI, silent at the Chromium boundary, and native renderer selection unless a
18
+ * consumer explicitly requests software or the proven Linux Vulkan profile.
19
+ *
20
+ * The returned object is meant to be spread into a Vitest `projects[]`
21
+ * entry's `test` field (or merged into a top-level `test` block for
22
+ * single-project setups):
23
+ *
24
+ * ```ts
25
+ * import { defineBrowserTestConfig } from 'game-harness/vitest';
26
+ *
27
+ * export default defineConfig({
28
+ * test: {
29
+ * projects: [
30
+ * { extends: true, test: { name: 'unit', environment: 'node', include: [...] } },
31
+ * { extends: true, test: defineBrowserTestConfig({
32
+ * optimizeDeps: ['three/examples/jsm/utils/SkeletonUtils.js'],
33
+ * }) },
34
+ * ],
35
+ * },
36
+ * });
37
+ * ```
38
+ *
39
+ * NOTE: a headed browser needs a display server. On Linux CI images without
40
+ * one, wrap the test command in `xvfb-run` rather than forcing headless here.
41
+ */
42
+ function defineBrowserTestConfig(opts = {}) {
43
+ const { contextOptions = {}, gpuArgs = [], gpuMode = 'auto', headless = false, ui = false, instances = [{ browser: 'chromium' }], optimizeDeps = [], setupFiles, name = 'browser', include = ['tests/browser/**/*.browser.test.{ts,tsx}'], fileParallelism = false, } = opts;
44
+ if (!name.trim())
45
+ throw new TypeError('browser project name must not be empty');
46
+ if (instances.length === 0) {
47
+ throw new TypeError('browser instances must contain at least one Playwright browser');
48
+ }
49
+ if (include.length === 0 || include.some((pattern) => !pattern.trim())) {
50
+ throw new TypeError('browser include must contain at least one non-empty test glob');
51
+ }
52
+ const resolvedHeadless = resolveHeadless(headless);
53
+ const launchProfile = (0, chromium_launch_js_1.createChromiumLaunchProfile)({ gpuMode, args: gpuArgs });
54
+ if (ui && contextOptions.deviceScaleFactor !== undefined) {
55
+ throw new Error('Vitest browser UI uses a null viewport, so contextOptions.deviceScaleFactor is not supported when ui is true');
56
+ }
57
+ const resolvedContextOptions = ui ? contextOptions : { deviceScaleFactor: 1, ...contextOptions };
58
+ const test = {
59
+ name,
60
+ include,
61
+ fileParallelism,
62
+ browser: {
63
+ enabled: true,
64
+ headless: resolvedHeadless,
65
+ ui,
66
+ provider: (0, browser_playwright_1.playwright)({
67
+ launchOptions: launchProfile,
68
+ contextOptions: resolvedContextOptions,
69
+ }),
70
+ instances,
71
+ },
72
+ };
73
+ if (setupFiles) {
74
+ test.setupFiles = [...setupFiles];
75
+ }
76
+ if (optimizeDeps.length > 0) {
77
+ // Vitest's `test` fragment has no `optimizeDeps` field (that lives at
78
+ // the top-level Vite config) — surface the caller's list here so a
79
+ // single options object can drive both without duplicating it.
80
+ test.__optimizeDepsInclude = [...new Set(optimizeDeps)];
81
+ }
82
+ return test;
83
+ }
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createChromiumLaunchProfile = createChromiumLaunchProfile;
4
+ const GPU_ARGS = {
5
+ auto: [],
6
+ software: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'],
7
+ 'linux-hardware-vulkan': [
8
+ '--use-gpu-in-tests',
9
+ '--use-gl=angle',
10
+ '--use-angle=vulkan',
11
+ '--ignore-gpu-blocklist',
12
+ ],
13
+ };
14
+ /**
15
+ * Builds the shared Chromium renderer and silence profile without deciding
16
+ * whether the browser is headed. Callers own that explicit choice.
17
+ */
18
+ function createChromiumLaunchProfile(options = {}) {
19
+ const gpuMode = options.gpuMode ?? 'auto';
20
+ const args = [
21
+ ...new Set([...GPU_ARGS[gpuMode], ...(options.args ?? [])].filter((argument) => argument !== '--mute-audio')),
22
+ '--mute-audio',
23
+ ];
24
+ if (gpuMode === 'linux-hardware-vulkan') {
25
+ return {
26
+ args,
27
+ env: {
28
+ ...process.env,
29
+ ...options.env,
30
+ EGL_PLATFORM: options.env?.EGL_PLATFORM ?? 'surfaceless',
31
+ },
32
+ };
33
+ }
34
+ if (options.env)
35
+ return { args, env: { ...process.env, ...options.env } };
36
+ return { args };
37
+ }
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VisualBatteryError = exports.runVisualBattery = exports.verifyReleaseLadder = exports.lighthouseAssertions = void 0;
4
+ var lighthouse_js_1 = require("./lighthouse.js");
5
+ Object.defineProperty(exports, "lighthouseAssertions", { enumerable: true, get: function () { return lighthouse_js_1.lighthouseAssertions; } });
6
+ var release_ladder_js_1 = require("./release-ladder.js");
7
+ Object.defineProperty(exports, "verifyReleaseLadder", { enumerable: true, get: function () { return release_ladder_js_1.verifyReleaseLadder; } });
8
+ var visual_battery_js_1 = require("./visual-battery.js");
9
+ Object.defineProperty(exports, "runVisualBattery", { enumerable: true, get: function () { return visual_battery_js_1.runVisualBattery; } });
10
+ Object.defineProperty(exports, "VisualBatteryError", { enumerable: true, get: function () { return visual_battery_js_1.VisualBatteryError; } });
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.lighthouseAssertions = lighthouseAssertions;
4
+ const PRESETS = {
5
+ // A production lighthouserc.json, verbatim: perf 0.6 / a11y 0.85 /
6
+ // best-practices 0.7 (warn-level, not error — a score dip surfaces in CI
7
+ // logs without hard-blocking a merge on Lighthouse's inherent run-to-run
8
+ // variance), SEO/PWA assertions off since these are single-page game
9
+ // shells with no SEO surface and no installable-PWA requirement.
10
+ 'game-default': {
11
+ ci: {
12
+ collect: {
13
+ staticDistDir: './dist',
14
+ url: ['http://localhost/index.html'],
15
+ numberOfRuns: 3,
16
+ settings: {
17
+ preset: 'desktop',
18
+ chromeFlags: '--no-sandbox --disable-dev-shm-usage --disable-gpu',
19
+ },
20
+ },
21
+ assert: {
22
+ preset: 'lighthouse:no-pwa',
23
+ assertions: {
24
+ 'categories:performance': ['warn', { minScore: 0.6 }],
25
+ 'categories:accessibility': ['warn', { minScore: 0.85 }],
26
+ 'categories:best-practices': ['warn', { minScore: 0.7 }],
27
+ 'categories:seo': 'off',
28
+ 'uses-responsive-images': 'off',
29
+ 'uses-rel-preconnect': 'off',
30
+ 'csp-xss': 'off',
31
+ },
32
+ },
33
+ upload: { target: 'temporary-public-storage' },
34
+ },
35
+ },
36
+ };
37
+ function lighthouseAssertions(preset = 'game-default', overrides = {}) {
38
+ const base = PRESETS[preset];
39
+ if (!base) {
40
+ throw new Error(`unknown lighthouse preset: ${preset}. known presets: ${Object.keys(PRESETS).join(', ')}`);
41
+ }
42
+ const staticDistDir = overrides.staticDistDir ?? base.ci.collect.staticDistDir;
43
+ if (staticDistDir !== undefined && !staticDistDir.trim()) {
44
+ throw new TypeError('Lighthouse staticDistDir must not be empty');
45
+ }
46
+ const url = overrides.url ?? base.ci.collect.url;
47
+ if (url.length === 0 || url.some((entry) => !entry.trim())) {
48
+ throw new TypeError('Lighthouse url must contain at least one non-empty URL');
49
+ }
50
+ const numberOfRuns = overrides.numberOfRuns ?? base.ci.collect.numberOfRuns;
51
+ if (!Number.isInteger(numberOfRuns) || numberOfRuns < 1) {
52
+ throw new TypeError('Lighthouse numberOfRuns must be a positive integer');
53
+ }
54
+ return {
55
+ ci: {
56
+ collect: {
57
+ ...base.ci.collect,
58
+ staticDistDir,
59
+ url: [...url],
60
+ numberOfRuns,
61
+ settings: { ...base.ci.collect.settings },
62
+ },
63
+ assert: {
64
+ ...base.ci.assert,
65
+ assertions: structuredClone({
66
+ ...base.ci.assert.assertions,
67
+ ...overrides.assertions,
68
+ }),
69
+ },
70
+ upload: { ...base.ci.upload },
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,303 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SILENT_QA_QUERY_VALUE = exports.SILENT_QA_QUERY_PARAMETER = exports.SILENT_QA_MARKER_VALUE = exports.SILENT_QA_MARKER_ATTRIBUTE = void 0;
4
+ exports.silentTestUrl = silentTestUrl;
5
+ exports.openSilentGame = openSilentGame;
6
+ exports.resolvePlaywrightPort = resolvePlaywrightPort;
7
+ exports.definePlaywrightConfig = definePlaywrightConfig;
8
+ const test_1 = require("@playwright/test");
9
+ const chromium_launch_js_1 = require("./chromium-launch.js");
10
+ const silent_qa_js_1 = require("./silent-qa.js");
11
+ var silent_qa_js_2 = require("./silent-qa.js");
12
+ Object.defineProperty(exports, "SILENT_QA_MARKER_ATTRIBUTE", { enumerable: true, get: function () { return silent_qa_js_2.SILENT_QA_MARKER_ATTRIBUTE; } });
13
+ Object.defineProperty(exports, "SILENT_QA_MARKER_VALUE", { enumerable: true, get: function () { return silent_qa_js_2.SILENT_QA_MARKER_VALUE; } });
14
+ Object.defineProperty(exports, "SILENT_QA_QUERY_PARAMETER", { enumerable: true, get: function () { return silent_qa_js_2.SILENT_QA_QUERY_PARAMETER; } });
15
+ Object.defineProperty(exports, "SILENT_QA_QUERY_VALUE", { enumerable: true, get: function () { return silent_qa_js_2.SILENT_QA_QUERY_VALUE; } });
16
+ /**
17
+ * Adds a non-persistent mute mode to a relative or absolute URL.
18
+ * Explicit caller parameters replace existing values, and the mute value is
19
+ * applied last so a stale `muted=0` can never make an agent run audible.
20
+ */
21
+ function silentTestUrl(target = '.', parameters = {}, options = {}) {
22
+ const fragmentIndex = target.indexOf('#');
23
+ const fragment = fragmentIndex >= 0 ? target.slice(fragmentIndex) : '';
24
+ const withoutFragment = fragmentIndex >= 0 ? target.slice(0, fragmentIndex) : target;
25
+ const queryIndex = withoutFragment.indexOf('?');
26
+ const pathname = queryIndex >= 0 ? withoutFragment.slice(0, queryIndex) : withoutFragment;
27
+ const query = queryIndex >= 0 ? withoutFragment.slice(queryIndex + 1) : '';
28
+ const search = new URLSearchParams(query);
29
+ for (const [key, value] of Object.entries(parameters))
30
+ search.set(key, String(value));
31
+ search.set(options.muteQueryParameter ?? silent_qa_js_1.SILENT_QA_QUERY_PARAMETER, options.muteQueryValue ?? silent_qa_js_1.SILENT_QA_QUERY_VALUE);
32
+ return `${pathname}?${search.toString()}${fragment}`;
33
+ }
34
+ /**
35
+ * Navigates to a game in runtime-only mute mode and fails closed until the
36
+ * application confirms that audio was muted before test interaction begins.
37
+ */
38
+ async function openSilentGame(page, target = '.', parameters = {}, options = {}) {
39
+ const response = await page.goto(silentTestUrl(target, parameters, {
40
+ ...(options.muteQueryParameter === undefined
41
+ ? {}
42
+ : { muteQueryParameter: options.muteQueryParameter }),
43
+ ...(options.muteQueryValue === undefined ? {} : { muteQueryValue: options.muteQueryValue }),
44
+ }), options.navigationOptions);
45
+ await (0, test_1.expect)(page.locator(options.markerSelector ?? 'html')).toHaveAttribute(options.markerAttribute ?? silent_qa_js_1.SILENT_QA_MARKER_ATTRIBUTE, options.markerValue ?? silent_qa_js_1.SILENT_QA_MARKER_VALUE, { timeout: options.markerTimeout ?? 5_000 });
46
+ return response;
47
+ }
48
+ function mergeMutedLaunchOptions(...sources) {
49
+ const merged = Object.assign({}, ...sources.filter((source) => source !== undefined));
50
+ const args = sources
51
+ .flatMap((source) => source?.args ?? [])
52
+ .filter((argument) => argument !== '--mute-audio');
53
+ return { ...merged, args: [...new Set(args), '--mute-audio'] };
54
+ }
55
+ const DEVICE_TIER_PROJECTS = {
56
+ desktop: [
57
+ {
58
+ name: 'desktop',
59
+ use: {
60
+ ...test_1.devices['Desktop Chrome'],
61
+ viewport: { width: 1280, height: 720 },
62
+ },
63
+ },
64
+ ],
65
+ mobile: [{ name: 'mobile', use: { ...test_1.devices['Pixel 7'] } }],
66
+ tablet: [{ name: 'tablet', use: { ...test_1.devices['iPad Mini'] } }],
67
+ // The foldable form factor sits between tablet and phone — wide CSS-px
68
+ // viewport but Android UA, touch primary, high DPR. Portrait + landscape
69
+ // are separate projects since HUD layout regressions differ by axis.
70
+ foldable: [
71
+ {
72
+ name: 'foldable-portrait',
73
+ use: {
74
+ ...test_1.devices['Pixel 7'],
75
+ viewport: { width: 840, height: 2120 },
76
+ deviceScaleFactor: 3,
77
+ },
78
+ },
79
+ {
80
+ name: 'foldable-landscape',
81
+ use: {
82
+ ...test_1.devices['Pixel 7'],
83
+ viewport: { width: 2120, height: 840 },
84
+ deviceScaleFactor: 3,
85
+ },
86
+ },
87
+ ],
88
+ ultrawide: [
89
+ {
90
+ name: 'ultrawide',
91
+ use: {
92
+ ...test_1.devices['Desktop Chrome'],
93
+ viewport: { width: 3440, height: 1440 },
94
+ },
95
+ },
96
+ ],
97
+ };
98
+ const DEFAULT_PORT = 4173;
99
+ const CI_PORT_START = 20_000;
100
+ const CI_PORT_SPAN = 10_000;
101
+ const LOCAL_TEST_TIMEOUT_MS = 45_000;
102
+ const LOCAL_ACTION_TIMEOUT_MS = 15_000;
103
+ const LOCAL_NAV_TIMEOUT_MS = 15_000;
104
+ function validPort(value) {
105
+ return Number.isInteger(value) && value > 0 && value <= 65_535;
106
+ }
107
+ function stableHash(value) {
108
+ let hash = 2_166_136_261;
109
+ for (let index = 0; index < value.length; index += 1) {
110
+ hash ^= value.charCodeAt(index);
111
+ hash = Math.imul(hash, 16_777_619);
112
+ }
113
+ return hash >>> 0;
114
+ }
115
+ function normalizePlaywrightChildEnvironment() {
116
+ // Playwright 1.62 injects FORCE_COLOR=1 into web-server and worker
117
+ // processes. An inherited NO_COLOR is therefore ignored and only makes
118
+ // Node emit a warning in each child. Removing it here changes this
119
+ // Playwright subprocess only; the invoking shell remains untouched.
120
+ delete process.env.NO_COLOR;
121
+ }
122
+ /**
123
+ * Resolves one stable Playwright preview port for every config reload in an
124
+ * Actions job. Explicit `PLAYWRIGHT_PORT`/`PW_PORT` values win; local runs use
125
+ * `localPort`; GitHub/Gitea CI hashes repository, run, job, and local port into
126
+ * an isolated port range.
127
+ */
128
+ function resolvePlaywrightPort(options = {}) {
129
+ const environment = options.environment ?? process.env;
130
+ const configuredValue = environment.PLAYWRIGHT_PORT ?? environment.PW_PORT;
131
+ if (configuredValue !== undefined) {
132
+ const configuredPort = Number(configuredValue);
133
+ if (!validPort(configuredPort)) {
134
+ throw new TypeError(`PLAYWRIGHT_PORT/PW_PORT must be an integer from 1 to 65535; received ${configuredValue || '<empty>'}`);
135
+ }
136
+ return configuredPort;
137
+ }
138
+ const localPort = options.localPort ?? DEFAULT_PORT;
139
+ if (!validPort(localPort)) {
140
+ throw new TypeError(`Playwright port must be an integer from 1 to 65535; received ${localPort}`);
141
+ }
142
+ const runId = environment.GITHUB_RUN_ID ?? environment.GITHUB_RUN_NUMBER;
143
+ if (!environment.CI || !runId)
144
+ return localPort;
145
+ const identity = [
146
+ environment.GITHUB_REPOSITORY ?? '',
147
+ runId,
148
+ environment.GITHUB_JOB ?? '',
149
+ String(localPort),
150
+ ].join('\0');
151
+ return CI_PORT_START + (stableHash(identity) % CI_PORT_SPAN);
152
+ }
153
+ function normalizeBasePath(basePath) {
154
+ const trimmed = basePath.trim();
155
+ if (!trimmed || trimmed === '/')
156
+ return '/';
157
+ if (trimmed.includes('?') || trimmed.includes('#')) {
158
+ throw new TypeError(`Playwright basePath must be a pathname without a query or fragment; received ${basePath}`);
159
+ }
160
+ const segments = trimmed
161
+ .split('/')
162
+ .filter(Boolean)
163
+ .map((segment) => {
164
+ try {
165
+ return decodeURIComponent(segment);
166
+ }
167
+ catch {
168
+ throw new TypeError(`Playwright basePath contains invalid URL encoding; received ${basePath}`);
169
+ }
170
+ });
171
+ if (segments.some((segment) => segment === '.' || segment === '..' || /[\\/]/u.test(segment))) {
172
+ throw new TypeError(`Playwright basePath must not contain traversal or encoded separators; received ${basePath}`);
173
+ }
174
+ return segments.length === 0 ? '/' : `/${segments.join('/')}/`;
175
+ }
176
+ /**
177
+ * Builds a full Playwright config, encoding a tiered-device +
178
+ * env-gated-suite convention:
179
+ *
180
+ * - `desktop` project always runs; `MULTIVIEW=1` (or `VISUAL=1`) expands to
181
+ * every requested device tier (mobile/tablet/foldable/ultrawide).
182
+ * - `JOURNEY=1` (or `VISUAL=1`) opts into expensive artefact-producing specs
183
+ * that are excluded from the default tier-1 functional gate so CI stays
184
+ * fast.
185
+ * - `VISUAL=1` additionally adds `visualSpecs` to the test match glob.
186
+ * - CI timeouts scale up from local defaults via `ciTimeoutMultiplier`
187
+ * (CI runners run WebGL/render-heavy tests 2-4x slower than local dev).
188
+ */
189
+ function definePlaywrightConfig(opts = {}) {
190
+ normalizePlaywrightChildEnvironment();
191
+ const { testDir = './tests', basePath = '/', port, webServerCommand, gpuMode = 'auto', headless = false, deviceTiers = ['desktop'], extraProjects = [], journeySpecs = [], ciTimeoutMultiplier = 4, overrides = {}, } = opts;
192
+ const IS_CI = Boolean(process.env.CI);
193
+ const IS_HEADLESS = process.env.PW_HEADLESS === '1' || (headless === 'ci-only' ? IS_CI : headless);
194
+ const CHROMIUM_CHANNEL = process.env.PW_CHROMIUM_CHANNEL ?? (!IS_CI && !IS_HEADLESS ? 'chrome' : undefined);
195
+ if (!Number.isFinite(ciTimeoutMultiplier) || ciTimeoutMultiplier <= 0) {
196
+ throw new TypeError('ciTimeoutMultiplier must be a positive finite number');
197
+ }
198
+ if (deviceTiers.length === 0) {
199
+ throw new TypeError('deviceTiers must contain at least one tier');
200
+ }
201
+ const unknownTiers = deviceTiers.filter((tier) => !Object.hasOwn(DEVICE_TIER_PROJECTS, tier));
202
+ if (unknownTiers.length > 0) {
203
+ throw new TypeError(`unknown device tier(s): ${unknownTiers.join(', ')}`);
204
+ }
205
+ const PORT = resolvePlaywrightPort({ localPort: port ?? DEFAULT_PORT });
206
+ const BASE_URL = `http://127.0.0.1:${PORT}${normalizeBasePath(basePath)}`;
207
+ const REUSE_SERVER = !IS_CI && process.env.PW_REUSE_SERVER === '1';
208
+ const includeVisual = process.env.VISUAL === '1';
209
+ const includeMultiview = process.env.MULTIVIEW === '1' || includeVisual;
210
+ const includeJourney = process.env.JOURNEY === '1' || includeVisual;
211
+ // Specs live under e2e/ (+ visual/ when VISUAL=1) relative to testDir.
212
+ const testMatch = includeVisual
213
+ ? ['e2e/**/*.spec.ts', 'visual/**/*.spec.ts']
214
+ : 'e2e/**/*.spec.ts';
215
+ const testIgnore = includeJourney ? [] : journeySpecs;
216
+ const TEST_TIMEOUT_MS = IS_CI
217
+ ? LOCAL_TEST_TIMEOUT_MS * ciTimeoutMultiplier
218
+ : LOCAL_TEST_TIMEOUT_MS;
219
+ const ACTION_TIMEOUT_MS = IS_CI
220
+ ? LOCAL_ACTION_TIMEOUT_MS * ciTimeoutMultiplier
221
+ : LOCAL_ACTION_TIMEOUT_MS;
222
+ const NAV_TIMEOUT_MS = IS_CI ? LOCAL_NAV_TIMEOUT_MS * 2 : LOCAL_NAV_TIMEOUT_MS;
223
+ const uniqueDeviceTiers = [...new Set(deviceTiers)];
224
+ const tiers = includeMultiview
225
+ ? uniqueDeviceTiers
226
+ : uniqueDeviceTiers.slice(0, 1);
227
+ // `DEVICE_TIER_PROJECTS` is typed `Record<DeviceTier, Project[]>`, so every
228
+ // member of the closed `DeviceTier` union is guaranteed present — this
229
+ // fallback only guards a future widening of the type, and is unreachable
230
+ // through any call this factory's own (type-checked) public API permits.
231
+ /* v8 ignore next */
232
+ const tierProjects = tiers.flatMap((tier) => DEVICE_TIER_PROJECTS[tier] ?? []);
233
+ const projects = [...tierProjects, ...extraProjects];
234
+ const launchProfile = (0, chromium_launch_js_1.createChromiumLaunchProfile)({ gpuMode });
235
+ const base = {
236
+ testDir,
237
+ testMatch,
238
+ testIgnore,
239
+ fullyParallel: true,
240
+ forbidOnly: IS_CI,
241
+ retries: IS_CI ? 2 : 0,
242
+ reporter: IS_CI ? 'github' : 'list',
243
+ timeout: TEST_TIMEOUT_MS,
244
+ use: {
245
+ baseURL: BASE_URL,
246
+ headless: IS_HEADLESS,
247
+ trace: 'retain-on-failure',
248
+ actionTimeout: ACTION_TIMEOUT_MS,
249
+ navigationTimeout: NAV_TIMEOUT_MS,
250
+ browserName: 'chromium',
251
+ channel: CHROMIUM_CHANNEL,
252
+ launchOptions: mergeMutedLaunchOptions(launchProfile),
253
+ },
254
+ webServer: {
255
+ command: webServerCommand?.(PORT) ?? `pnpm exec vite --host 127.0.0.1 --port ${PORT} --strictPort`,
256
+ url: BASE_URL,
257
+ reuseExistingServer: REUSE_SERVER,
258
+ timeout: 60_000,
259
+ },
260
+ projects,
261
+ };
262
+ const resolved = (0, test_1.defineConfig)(base);
263
+ // `base.webServer` above is always constructed as a single object literal,
264
+ // and Playwright's own `defineConfig` never turns a single `webServer`
265
+ // into an array — this branch only guards a future Playwright type
266
+ // widening and is unreachable through this factory's own construction.
267
+ /* v8 ignore next */
268
+ const singleWebServer = Array.isArray(resolved.webServer) ? undefined : resolved.webServer;
269
+ /* v8 ignore next 5 */
270
+ const mergedWebServer = Array.isArray(resolved.webServer)
271
+ ? resolved.webServer
272
+ : { ...singleWebServer, ...overrides.webServer };
273
+ const mergedUse = {
274
+ ...resolved.use,
275
+ ...overrides.use,
276
+ launchOptions: mergeMutedLaunchOptions(resolved.use?.launchOptions, overrides.use?.launchOptions),
277
+ };
278
+ // `base.projects` above is always populated (from `projects` computed
279
+ // earlier), so `resolved.projects` is never nullish and this final `[]`
280
+ // only guards a hypothetical future Playwright `defineConfig` behavior —
281
+ // unreachable through this factory's own construction.
282
+ /* v8 ignore next */
283
+ const configuredProjects = overrides.projects ?? resolved.projects ?? [];
284
+ const mutedProjects = configuredProjects.map((project) => ({
285
+ ...project,
286
+ use: {
287
+ ...project.use,
288
+ launchOptions: mergeMutedLaunchOptions(mergedUse.launchOptions, project.use?.launchOptions),
289
+ },
290
+ }));
291
+ return {
292
+ ...resolved,
293
+ ...overrides,
294
+ // `use` and `webServer` are merged one level deep — a caller supplying
295
+ // `overrides.use` almost always wants to ADD a field (e.g. `channel` or
296
+ // an extra header), not replace baseURL/headless/timeouts wholesale.
297
+ // Every other top-level field (projects, testMatch, etc.) still fully
298
+ // replaces on override, matching a plain object spread.
299
+ use: mergedUse,
300
+ webServer: mergedWebServer,
301
+ projects: mutedProjects,
302
+ };
303
+ }