diff --git a/.nx/version-plans/version-plan-1785423581556.md b/.nx/version-plans/version-plan-1785423581556.md new file mode 100644 index 0000000..b1f2646 --- /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 0566bea..a6ef5af 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 39bf8f3..818ef99 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 c334bc0..8c8d168 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 501a3f9..f38fd0f 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 0000000..ddf0910 --- /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 9e00577..74ae680 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 56b5cd9..0faf279 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 1ff1d7b..1267bfb 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 0000000..94d9c44 --- /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 c334bc0..8c8d168 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 8d3457c..3624570 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 0000000..8d4657e --- /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 314ceeb..c851b69 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 9f9888e..f0cc72d 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 595d0aa..167d036 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 0000000..8b86f9c --- /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 f6eef7e..c5561b7 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 c079fe7..80c30f5 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 e36eabc..5ae9b49 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