From 78ab6ec2096231ed503b1014891b52af0681ca67 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 30 Jul 2026 08:01:02 -0700 Subject: [PATCH] fix(platform-web,platform-vega): fix runner init signature mismatch The harness session calls each platform runner's default export as module.default(config, harnessConfig, init), but the web and vega runners only declared (config, init?), so harnessConfig landed in the init slot and init.signal.aborted threw "Cannot read properties of undefined (reading 'aborted')" during session setup. Both runners now declare (config, harnessConfig, init) with init required, matching platform-ios and platform-android. A shared HarnessPlatformRunnerFactory type in @react-native-harness/platforms is applied to all four runners so this class of signature drift fails to typecheck instead of throwing at runtime. --- .../version-plan-1785423581556.md | 5 ++ packages/platform-android/src/runner.ts | 14 ++- packages/platform-ios/src/runner.ts | 14 ++- packages/platform-vega/eslint.config.mjs | 6 +- packages/platform-vega/package.json | 1 + .../src/__tests__/runner.test.ts | 57 ++++++++++++ packages/platform-vega/src/runner.ts | 16 ++-- packages/platform-vega/tsconfig.json | 3 + packages/platform-vega/tsconfig.lib.json | 3 + packages/platform-vega/vite.config.ts | 18 ++++ packages/platform-web/eslint.config.mjs | 6 +- packages/platform-web/package.json | 1 + .../platform-web/src/__tests__/runner.test.ts | 89 +++++++++++++++++++ packages/platform-web/src/runner.ts | 16 ++-- packages/platform-web/tsconfig.json | 3 + packages/platform-web/tsconfig.lib.json | 3 + packages/platform-web/vite.config.ts | 18 ++++ packages/platforms/src/index.ts | 1 + packages/platforms/src/types.ts | 15 ++++ pnpm-lock.yaml | 6 ++ 20 files changed, 259 insertions(+), 36 deletions(-) create mode 100644 .nx/version-plans/version-plan-1785423581556.md create mode 100644 packages/platform-vega/src/__tests__/runner.test.ts create mode 100644 packages/platform-vega/vite.config.ts create mode 100644 packages/platform-web/src/__tests__/runner.test.ts create mode 100644 packages/platform-web/vite.config.ts diff --git a/.nx/version-plans/version-plan-1785423581556.md b/.nx/version-plans/version-plan-1785423581556.md new file mode 100644 index 00000000..b1f2646b --- /dev/null +++ b/.nx/version-plans/version-plan-1785423581556.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +The web and Vega platforms start correctly again. Session setup previously threw `Cannot read properties of undefined (reading 'aborted')` because these runners received the harness config in place of their init options. diff --git a/packages/platform-android/src/runner.ts b/packages/platform-android/src/runner.ts index 0566bea1..a6ef5afe 100644 --- a/packages/platform-android/src/runner.ts +++ b/packages/platform-android/src/runner.ts @@ -1,7 +1,4 @@ -import { - HarnessPlatformRunner, - type HarnessPlatformInitOptions, -} from '@react-native-harness/platforms'; +import { type HarnessPlatformRunnerFactory } from '@react-native-harness/platforms'; import type { Config as HarnessConfig } from '@react-native-harness/config'; import { AndroidPlatformConfigSchema, @@ -17,11 +14,10 @@ import { initializeAndroidProcessEnv, } from './environment.js'; -const getAndroidRunner = async ( - config: AndroidPlatformConfig, - harnessConfig: HarnessConfig, - init: HarnessPlatformInitOptions -): Promise => { +const getAndroidRunner: HarnessPlatformRunnerFactory< + AndroidPlatformConfig, + HarnessConfig +> = async (config, harnessConfig, init) => { const parsedConfig = AndroidPlatformConfigSchema.parse(config); initializeAndroidProcessEnv(); diff --git a/packages/platform-ios/src/runner.ts b/packages/platform-ios/src/runner.ts index 39bf8f3e..818ef995 100644 --- a/packages/platform-ios/src/runner.ts +++ b/packages/platform-ios/src/runner.ts @@ -1,7 +1,4 @@ -import { - HarnessPlatformRunner, - type HarnessPlatformInitOptions, -} from '@react-native-harness/platforms'; +import { type HarnessPlatformRunnerFactory } from '@react-native-harness/platforms'; import type { Config as HarnessConfig } from '@react-native-harness/config'; import { ApplePlatformConfigSchema, @@ -13,11 +10,10 @@ import { getAppleSimulatorPlatformInstance, } from './instance.js'; -const getAppleRunner = async ( - config: ApplePlatformConfig, - harnessConfig: HarnessConfig, - init: HarnessPlatformInitOptions -): Promise => { +const getAppleRunner: HarnessPlatformRunnerFactory< + ApplePlatformConfig, + HarnessConfig +> = async (config, harnessConfig, init) => { const parsedConfig = ApplePlatformConfigSchema.parse(config); if (isAppleDeviceSimulator(parsedConfig.device)) { diff --git a/packages/platform-vega/eslint.config.mjs b/packages/platform-vega/eslint.config.mjs index c334bc0b..8c8d168e 100644 --- a/packages/platform-vega/eslint.config.mjs +++ b/packages/platform-vega/eslint.config.mjs @@ -8,7 +8,11 @@ export default [ '@nx/dependency-checks': [ 'error', { - ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + ignoredDependencies: ['vite', 'vitest'], + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + '{projectRoot}/src/**/__tests__/**', + ], }, ], }, diff --git a/packages/platform-vega/package.json b/packages/platform-vega/package.json index 501a3f92..f38fd0ff 100644 --- a/packages/platform-vega/package.json +++ b/packages/platform-vega/package.json @@ -16,6 +16,7 @@ } }, "dependencies": { + "@react-native-harness/config": "workspace:*", "@react-native-harness/platforms": "workspace:*", "@react-native-harness/tools": "workspace:*", "zod": "^3.25.67", diff --git a/packages/platform-vega/src/__tests__/runner.test.ts b/packages/platform-vega/src/__tests__/runner.test.ts new file mode 100644 index 00000000..ddf09100 --- /dev/null +++ b/packages/platform-vega/src/__tests__/runner.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_METRO_PORT, + type Config as HarnessConfig, +} from '@react-native-harness/config'; +import type { VegaPlatformConfig } from '../config.js'; + +const stopAppMock = vi.fn().mockResolvedValue(undefined); + +vi.mock('../kepler.js', () => ({ + getVegaDeviceStatus: vi.fn().mockResolvedValue('running'), + isAppInstalled: vi.fn().mockResolvedValue(true), + isAppRunning: vi.fn().mockResolvedValue(false), + startApp: vi.fn().mockResolvedValue(undefined), + stopApp: stopAppMock, +})); + +const harnessConfig = { metroPort: DEFAULT_METRO_PORT } as HarnessConfig; + +const config: VegaPlatformConfig = { + name: 'vega', + device: { type: 'emulator', deviceId: 'VegaTV_1' }, + bundleId: 'com.example.app', +}; + +describe('getVegaRunner', () => { + it('is invoked with the (config, harnessConfig, init) shape the harness session uses', async () => { + // Regression test for the bug where the session called + // module.default(config, runtimeConfig, init), but this runner declared + // only (config, init?) — so runtimeConfig landed in the init slot and + // init.signal.aborted threw. Asserting the arity here catches an + // optional-parameter regression, since optional params drop out of + // Function.length. + const { default: getVegaRunner } = await import('../runner.js'); + expect(getVegaRunner.length).toBe(3); + + const init = { signal: new AbortController().signal }; + const runner = await getVegaRunner(config, harnessConfig, init); + expect(runner.createAppSession).toBeTypeOf('function'); + expect(runner.dispose).toBeTypeOf('function'); + }); + + it('stops the app when the init signal aborts after session creation', async () => { + const { default: getVegaRunner } = await import('../runner.js'); + const controller = new AbortController(); + + const runner = await getVegaRunner(config, harnessConfig, { + signal: controller.signal, + }); + await runner.createAppSession(); + stopAppMock.mockClear(); + + controller.abort(); + + expect(stopAppMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/platform-vega/src/runner.ts b/packages/platform-vega/src/runner.ts index 9e005776..74ae680f 100644 --- a/packages/platform-vega/src/runner.ts +++ b/packages/platform-vega/src/runner.ts @@ -4,9 +4,9 @@ import { type AppSessionState, DeviceNotFoundError, AppNotInstalledError, - type HarnessPlatformInitOptions, - HarnessPlatformRunner, + type HarnessPlatformRunnerFactory, } from '@react-native-harness/platforms'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; import { VegaPlatformConfigSchema, type VegaPlatformConfig } from './config.js'; import * as kepler from './kepler.js'; @@ -15,10 +15,10 @@ const APP_EXIT_POLL_INTERVAL_MS = 1000; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -const getVegaRunner = async ( - config: VegaPlatformConfig, - init?: HarnessPlatformInitOptions -): Promise => { +const getVegaRunner: HarnessPlatformRunnerFactory< + VegaPlatformConfig, + HarnessConfig +> = async (config, _harnessConfig, init) => { const parsedConfig = VegaPlatformConfigSchema.parse(config); const deviceId = parsedConfig.device.deviceId; const bundleId = parsedConfig.bundleId; @@ -40,10 +40,10 @@ const getVegaRunner = async ( // poll loop and the app on session teardown, in addition to the normal // dispose() path. const disposeCurrentAppSessionOnAbort = () => void currentAppSession?.dispose(); - if (init?.signal.aborted) { + if (init.signal.aborted) { disposeCurrentAppSessionOnAbort(); } else { - init?.signal.addEventListener('abort', disposeCurrentAppSessionOnAbort, { + init.signal.addEventListener('abort', disposeCurrentAppSessionOnAbort, { once: true, }); } diff --git a/packages/platform-vega/tsconfig.json b/packages/platform-vega/tsconfig.json index 56b5cd95..0faf2791 100644 --- a/packages/platform-vega/tsconfig.json +++ b/packages/platform-vega/tsconfig.json @@ -9,6 +9,9 @@ { "path": "../platforms" }, + { + "path": "../config" + }, { "path": "./tsconfig.lib.json" } diff --git a/packages/platform-vega/tsconfig.lib.json b/packages/platform-vega/tsconfig.lib.json index 1ff1d7b0..1267bfb9 100644 --- a/packages/platform-vega/tsconfig.lib.json +++ b/packages/platform-vega/tsconfig.lib.json @@ -16,6 +16,9 @@ }, { "path": "../platforms/tsconfig.lib.json" + }, + { + "path": "../config/tsconfig.lib.json" } ] } diff --git a/packages/platform-vega/vite.config.ts b/packages/platform-vega/vite.config.ts new file mode 100644 index 00000000..94d9c444 --- /dev/null +++ b/packages/platform-vega/vite.config.ts @@ -0,0 +1,18 @@ +/// +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/platform-vega', + test: { + watch: false, + globals: true, + environment: 'node', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: './test-output/vitest/coverage', + provider: 'v8' as const, + }, + }, +})); diff --git a/packages/platform-web/eslint.config.mjs b/packages/platform-web/eslint.config.mjs index c334bc0b..8c8d168e 100644 --- a/packages/platform-web/eslint.config.mjs +++ b/packages/platform-web/eslint.config.mjs @@ -8,7 +8,11 @@ export default [ '@nx/dependency-checks': [ 'error', { - ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + ignoredDependencies: ['vite', 'vitest'], + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + '{projectRoot}/src/**/__tests__/**', + ], }, ], }, diff --git a/packages/platform-web/package.json b/packages/platform-web/package.json index 8d3457ce..36245700 100644 --- a/packages/platform-web/package.json +++ b/packages/platform-web/package.json @@ -16,6 +16,7 @@ } }, "dependencies": { + "@react-native-harness/config": "workspace:*", "@react-native-harness/platforms": "workspace:*", "playwright": "^1.50.0", "zod": "^3.25.67", diff --git a/packages/platform-web/src/__tests__/runner.test.ts b/packages/platform-web/src/__tests__/runner.test.ts new file mode 100644 index 00000000..8d4657ea --- /dev/null +++ b/packages/platform-web/src/__tests__/runner.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_METRO_PORT, + type Config as HarnessConfig, +} from '@react-native-harness/config'; +import type { WebPlatformConfig } from '../config.js'; + +const closeMock = vi.fn().mockResolvedValue(undefined); +const exposeFunctionMock = vi.fn().mockResolvedValue(undefined); +const gotoMock = vi.fn().mockResolvedValue(undefined); +const pageOnMock = vi.fn(); + +const launchMock = vi.fn().mockResolvedValue({ + close: closeMock, + newContext: vi.fn().mockResolvedValue({ + newPage: vi.fn().mockResolvedValue({ + exposeFunction: exposeFunctionMock, + goto: gotoMock, + on: pageOnMock, + }), + }), +}); + +vi.mock('playwright', () => ({ + chromium: { launch: launchMock }, + firefox: { launch: launchMock }, + webkit: { launch: launchMock }, +})); + +const harnessConfig = { metroPort: DEFAULT_METRO_PORT } as HarnessConfig; + +const config: WebPlatformConfig = { + name: 'web', + browser: { + type: 'chromium', + url: 'http://localhost:8081', + headless: true, + }, +}; + +describe('getWebRunner', () => { + it('is invoked with the (config, harnessConfig, init) shape the harness session uses', async () => { + // Regression test for the bug where the session called + // module.default(config, runtimeConfig, init), but this runner declared + // only (config, init?) — so runtimeConfig landed in the init slot and + // init.signal.aborted threw. Asserting the arity here catches an + // optional-parameter regression, since optional params drop out of + // Function.length. + const { default: getWebRunner } = await import('../runner.js'); + expect(getWebRunner.length).toBe(3); + + const init = { signal: new AbortController().signal }; + const runner = await getWebRunner(config, harnessConfig, init); + expect(runner.createAppSession).toBeTypeOf('function'); + expect(runner.dispose).toBeTypeOf('function'); + }); + + it('does not throw when the init signal is already aborted', async () => { + // This is the exact failure mode from the reported bug: with the wrong + // argument order, `init` ended up holding runtimeConfig, and + // `init.signal.aborted` threw `TypeError: Cannot read properties of + // undefined (reading 'aborted')`. + const { default: getWebRunner } = await import('../runner.js'); + const controller = new AbortController(); + controller.abort(); + + const runner = await getWebRunner(config, harnessConfig, { + signal: controller.signal, + }); + const session = await runner.createAppSession(); + + expect(session.dispose).toBeTypeOf('function'); + }); + + it('closes the browser when the init signal aborts after session creation', async () => { + const { default: getWebRunner } = await import('../runner.js'); + const controller = new AbortController(); + + const runner = await getWebRunner(config, harnessConfig, { + signal: controller.signal, + }); + await runner.createAppSession(); + closeMock.mockClear(); + + controller.abort(); + + expect(closeMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/platform-web/src/runner.ts b/packages/platform-web/src/runner.ts index 314ceeba..c851b697 100644 --- a/packages/platform-web/src/runner.ts +++ b/packages/platform-web/src/runner.ts @@ -2,16 +2,16 @@ import { createAppSessionEmitter, type AppSession, type AppSessionState, - type HarnessPlatformInitOptions, - HarnessPlatformRunner, + type HarnessPlatformRunnerFactory, } from '@react-native-harness/platforms'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; import { chromium, firefox, webkit, type Browser, type Page } from 'playwright'; import { WebPlatformConfigSchema, type WebPlatformConfig } from './config.js'; -const getWebRunner = async ( - config: WebPlatformConfig, - init?: HarnessPlatformInitOptions -): Promise => { +const getWebRunner: HarnessPlatformRunnerFactory< + WebPlatformConfig, + HarnessConfig +> = async (config, _harnessConfig, init) => { const parsedConfig = WebPlatformConfigSchema.parse(config); let browser: Browser | null = null; @@ -24,10 +24,10 @@ const getWebRunner = async ( browser = null; page = null; }; - if (init?.signal.aborted) { + if (init.signal.aborted) { closeBrowserOnAbort(); } else { - init?.signal.addEventListener('abort', closeBrowserOnAbort, { once: true }); + init.signal.addEventListener('abort', closeBrowserOnAbort, { once: true }); } const launchBrowser = async () => { diff --git a/packages/platform-web/tsconfig.json b/packages/platform-web/tsconfig.json index 9f9888e0..f0cc72d1 100644 --- a/packages/platform-web/tsconfig.json +++ b/packages/platform-web/tsconfig.json @@ -6,6 +6,9 @@ { "path": "../platforms" }, + { + "path": "../config" + }, { "path": "./tsconfig.lib.json" } diff --git a/packages/platform-web/tsconfig.lib.json b/packages/platform-web/tsconfig.lib.json index 595d0aa3..167d0366 100644 --- a/packages/platform-web/tsconfig.lib.json +++ b/packages/platform-web/tsconfig.lib.json @@ -14,6 +14,9 @@ "references": [ { "path": "../platforms/tsconfig.lib.json" + }, + { + "path": "../config/tsconfig.lib.json" } ] } diff --git a/packages/platform-web/vite.config.ts b/packages/platform-web/vite.config.ts new file mode 100644 index 00000000..8b86f9c5 --- /dev/null +++ b/packages/platform-web/vite.config.ts @@ -0,0 +1,18 @@ +/// +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/platform-web', + test: { + watch: false, + globals: true, + environment: 'node', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: './test-output/vitest/coverage', + provider: 'v8' as const, + }, + }, +})); diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index f6eef7e1..c5561b7e 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -18,6 +18,7 @@ export type { CrashArtifactWriter, HarnessPlatform, HarnessPlatformInitOptions, + HarnessPlatformRunnerFactory, CollectNativeCoverageOptions, HarnessPlatformRunner, RunTarget, diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index c079fe78..80c30f5c 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -143,6 +143,21 @@ export type HarnessPlatformInitOptions = { diagnostics?: Diagnostics; }; +/** + * Shape every platform runner module's default export must satisfy. The + * harness session always invokes it as + * `module.default(platform.config, runtimeConfig, init)` — annotate each + * runner's default export with this type (parameterized by the platform's + * config type and its harness config type) so a signature drift like a + * missing `harnessConfig` parameter, or an optional `init`, fails to + * typecheck instead of throwing at runtime. + */ +export type HarnessPlatformRunnerFactory = ( + config: TConfig, + harnessConfig: THarnessConfig, + init: HarnessPlatformInitOptions +) => Promise; + export type HarnessCliCommandContext = { cwd: string; projectRoot: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e36eabc5..5ae9b49d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -492,6 +492,9 @@ importers: packages/platform-vega: dependencies: + '@react-native-harness/config': + specifier: workspace:* + version: link:../config '@react-native-harness/platforms': specifier: workspace:* version: link:../platforms @@ -507,6 +510,9 @@ importers: packages/platform-web: dependencies: + '@react-native-harness/config': + specifier: workspace:* + version: link:../config '@react-native-harness/platforms': specifier: workspace:* version: link:../platforms