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