diff --git a/.nx/version-plans/allocate-metro-ports-for-concurrent-runs.md b/.nx/version-plans/allocate-metro-ports-for-concurrent-runs.md new file mode 100644 index 0000000..f4bb074 --- /dev/null +++ b/.nx/version-plans/allocate-metro-ports-for-concurrent-runs.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +Harness now falls back to the next available Metro port when the configured port is already in use, which lets multiple Harness runs start at the same time without colliding on Metro. When this happens, Harness keeps the selected port consistent for the whole run and prints a message showing which port it ended up using. diff --git a/packages/bundler-metro/src/factory.ts b/packages/bundler-metro/src/factory.ts index 08c31ec..661409d 100644 --- a/packages/bundler-metro/src/factory.ts +++ b/packages/bundler-metro/src/factory.ts @@ -92,12 +92,13 @@ export const getMetroInstance = async ( ): Promise => { const { projectRoot, harnessConfig, websocketEndpoints = {} } = options; const metroPort = harnessConfig.metroPort; + const metroBindHost = harnessConfig.host?.trim(); metroLogger.debug( 'creating Metro instance for %s on port %d', projectRoot, metroPort ); - const isMetroPortAvailable = await isPortAvailable(metroPort); + const isMetroPortAvailable = await isPortAvailable(metroPort, metroBindHost); if (!isMetroPortAvailable) { throw new MetroPortUnavailableError(metroPort); @@ -118,12 +119,14 @@ export const getMetroInstance = async ( const middleware = connect() .use(nocache()) - .use('/', getBundleRequestObserverMiddleware(projectRoot, harnessConfig, reporter)) + .use( + '/', + getBundleRequestObserverMiddleware(projectRoot, harnessConfig, reporter) + ) .use('/', getExpoMiddleware(projectRoot, harnessConfig)) .use('/status', getStatusMiddleware(projectRoot)); const ready = waitForBundler(reporter, abortSignal); - const metroBindHost = harnessConfig.host?.trim(); if (metroBindHost) { metroLogger.debug('binding Metro server to host %s', metroBindHost); } diff --git a/packages/bundler-metro/src/index.ts b/packages/bundler-metro/src/index.ts index 29170f6..8ef0652 100644 --- a/packages/bundler-metro/src/index.ts +++ b/packages/bundler-metro/src/index.ts @@ -16,3 +16,4 @@ export { waitForMetroBackedAppReady, type WaitForMetroBackedAppReadyOptions, } from './startup.js'; +export { isPortAvailable } from './utils.js'; diff --git a/packages/bundler-metro/src/utils.ts b/packages/bundler-metro/src/utils.ts index ed12430..9c8c877 100644 --- a/packages/bundler-metro/src/utils.ts +++ b/packages/bundler-metro/src/utils.ts @@ -4,7 +4,10 @@ import { MetroNotInstalledError } from './errors.js'; const require = createRequire(import.meta.url); -export const isPortAvailable = (port: number): Promise => { +export const isPortAvailable = ( + port: number, + host?: string +): Promise => { return new Promise((resolve) => { const server = net.createServer(); server.once('error', () => { @@ -15,7 +18,7 @@ export const isPortAvailable = (port: number): Promise => { server.close(); resolve(true); }); - server.listen(port); + server.listen(port, host); }); }; diff --git a/packages/jest/src/__tests__/harness.test.ts b/packages/jest/src/__tests__/harness.test.ts index 1ff8c7d..c58a3ed 100644 --- a/packages/jest/src/__tests__/harness.test.ts +++ b/packages/jest/src/__tests__/harness.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events'; import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config as HarnessConfig } from '@react-native-harness/config'; +import { MetroPortRangeExhaustedError } from '../errors.js'; import { definePlugin } from '@react-native-harness/plugins'; import type { AppMonitor, @@ -25,10 +26,12 @@ const mocks = vi.hoisted(() => ({ getMetroInstance: vi.fn(), isMetroCacheReusable: vi.fn(() => false), logMetroCacheReused: vi.fn(), + logMetroPortFallback: vi.fn(), logRunnerStarting: vi.fn(), logRunnerStillWaitingInQueue: vi.fn(), logRunnerWaitingInQueue: vi.fn(), waitForMetroBackedAppReady: vi.fn(), + isPortAvailable: vi.fn(async () => true), })); vi.mock('@react-native-harness/bundler-metro', async () => { @@ -39,6 +42,7 @@ vi.mock('@react-native-harness/bundler-metro', async () => { return { ...actual, getMetroInstance: mocks.getMetroInstance, + isPortAvailable: mocks.isPortAvailable, isMetroCacheReusable: mocks.isMetroCacheReusable, waitForMetroBackedAppReady: mocks.waitForMetroBackedAppReady, }; @@ -50,6 +54,7 @@ vi.mock('@react-native-harness/bridge/server', () => ({ vi.mock('../logs.js', () => ({ logMetroCacheReused: mocks.logMetroCacheReused, + logMetroPortFallback: mocks.logMetroPortFallback, logRunnerStarting: mocks.logRunnerStarting, logRunnerStillWaitingInQueue: mocks.logRunnerStillWaitingInQueue, logRunnerWaitingInQueue: mocks.logRunnerWaitingInQueue, @@ -195,6 +200,8 @@ const createHarnessConfig = ( beforeEach(() => { vi.clearAllMocks(); + mocks.isPortAvailable.mockReset(); + mocks.isPortAvailable.mockResolvedValue(true); }); afterEach(() => { @@ -377,7 +384,9 @@ describe('getHarness', () => { expect(runner).toHaveBeenCalledWith( platform.config, - expect.any(Object), + expect.objectContaining({ + metroPort: 8081, + }), expect.objectContaining({ signal: expect.any(AbortSignal), }) @@ -386,6 +395,85 @@ describe('getHarness', () => { await harness.dispose(); }); + it('resolves and exposes a fallback Metro port before platform init', async () => { + const { serverBridge } = createBridgeServer(); + const appMonitor = createAppMonitor(); + const platformInstance = createPlatformRunner({ + createAppMonitor: () => appMonitor.appMonitor, + }); + const metroInstance = createMetroInstance(); + + mocks.getBridgeServer.mockResolvedValue(serverBridge); + mocks.getMetroInstance.mockResolvedValue(metroInstance); + mocks.isPortAvailable + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + const runner = vi.fn(async () => platformInstance); + ( + globalThis as typeof globalThis & { + __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise; + } + ).__HARNESS_PLATFORM_RUNNER__ = runner; + + const platform: HarnessPlatform = { + config: {}, + getResourceLockKey: () => 'android:emulator:Pixel_8_API_35', + name: 'android', + platformId: 'android', + runner: `data:text/javascript,${encodeURIComponent( + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + )}`, + }; + + const harness = await getHarness( + createHarnessConfig(), + platform, + '/tmp/project' + ); + + expect(harness.config.metroPort).toBe(8082); + expect(mocks.getMetroInstance).toHaveBeenCalledWith( + expect.objectContaining({ + harnessConfig: expect.objectContaining({ + metroPort: 8082, + }), + }), + expect.any(AbortSignal) + ); + expect(runner).toHaveBeenCalledWith( + platform.config, + expect.objectContaining({ + metroPort: 8082, + }), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }) + ); + expect(mocks.logMetroPortFallback).toHaveBeenCalledWith(8081, 8082); + + await harness.dispose(); + }); + + it('fails when no Metro port is available in the retry window', async () => { + mocks.isPortAvailable.mockResolvedValue(false); + + const platform: HarnessPlatform = { + config: {}, + getResourceLockKey: () => 'android:emulator:Pixel_8_API_35', + name: 'android', + platformId: 'android', + runner: 'data:text/javascript,export default async () => ({})', + }; + + await expect( + getHarness(createHarnessConfig(), platform, '/tmp/project') + ).rejects.toBeInstanceOf(MetroPortRangeExhaustedError); + + expect(mocks.getBridgeServer).not.toHaveBeenCalled(); + expect(mocks.getMetroInstance).not.toHaveBeenCalled(); + }); + it('falls back to a default resource lock key for platforms without getResourceLockKey', async () => { const { serverBridge } = createBridgeServer(); const appMonitor = createAppMonitor(); diff --git a/packages/jest/src/__tests__/metro-port.test.ts b/packages/jest/src/__tests__/metro-port.test.ts new file mode 100644 index 0000000..bbf7b7f --- /dev/null +++ b/packages/jest/src/__tests__/metro-port.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; +import type { HarnessPlatform } from '@react-native-harness/platforms'; +import { resolveHarnessMetroPort } from '../metro-port.js'; + +const mocks = vi.hoisted(() => ({ + isPortAvailable: vi.fn(async () => true), +})); + +vi.mock('@react-native-harness/bundler-metro', () => ({ + isPortAvailable: mocks.isPortAvailable, +})); + +const createConfig = (overrides: Partial = {}): HarnessConfig => + ({ + appRegistryComponentName: 'App', + bridgeTimeout: 60_000, + bundleStartTimeout: 60_000, + crashDetectionInterval: 500, + defaultRunner: 'ios-device', + detectNativeCrashes: true, + disableViewFlattening: false, + entryPoint: 'index.js', + forwardClientLogs: false, + maxAppRestarts: 2, + metroPort: 8081, + platformReadyTimeout: 300_000, + resetEnvironmentBetweenTestFiles: true, + runners: [], + unstable__enableMetroCache: false, + unstable__skipAlreadyIncludedModules: false, + ...overrides, + } as HarnessConfig); + +describe('resolveHarnessMetroPort', () => { + it('skips fallback allocation for iOS physical device runners', async () => { + const acquire = vi.fn(); + const config = createConfig(); + const platform: HarnessPlatform = { + config: {}, + name: 'ios-device', + platformId: 'ios', + runner: 'unused', + }; + + const result = await resolveHarnessMetroPort({ + config, + platform, + resourceLockManager: { + acquire, + }, + signal: new AbortController().signal, + }); + + expect(result.config).toBe(config); + expect(result.metroPortLease).toBeNull(); + expect(result.didFallback).toBe(false); + expect(acquire).not.toHaveBeenCalled(); + expect(mocks.isPortAvailable).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/jest/src/errors.ts b/packages/jest/src/errors.ts index a7502b8..d9d0dc8 100644 --- a/packages/jest/src/errors.ts +++ b/packages/jest/src/errors.ts @@ -36,6 +36,19 @@ export class PlatformReadyTimeoutError extends HarnessError { } } +export class MetroPortRangeExhaustedError extends HarnessError { + constructor( + public readonly initialPort: number, + public readonly attempts: number + ) { + const finalPort = initialPort + attempts - 1; + super( + `Harness could not find an available Metro port in the range ${initialPort}-${finalPort}.` + ); + this.name = 'MetroPortRangeExhaustedError'; + } +} + export type NativeCrashPhase = 'startup' | 'execution'; export type NativeCrashDetails = AppCrashDetails & { diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index 3dddc41..c065a49 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -49,11 +49,13 @@ import { createClientLogListener } from './client-log-handler.js'; import path from 'node:path'; import { logMetroCacheReused, + logMetroPortFallback, logRunnerStarting, logRunnerStillWaitingInQueue, logRunnerWaitingInQueue, } from './logs.js'; import { createResourceLockManager } from './resource-lock.js'; +import { resolveHarnessMetroPort } from './metro-port.js'; const harnessLogger = logger.child('runtime'); const resourceLockManager = createResourceLockManager(); @@ -73,6 +75,7 @@ export type HarnessRunState = { export type Harness = { context: HarnessContext; + config: HarnessConfig; runTests: ( path: string, options: HarnessRunTestsOptions @@ -285,17 +288,33 @@ const getHarnessInternal = async ( resourceLockKey ); try { - maybeLogMetroCacheReuse(config, platform, projectRoot); + const { + config: runtimeConfig, + metroPortLease, + initialMetroPort, + didFallback, + } = await resolveHarnessMetroPort({ + config, + platform, + resourceLockManager, + signal, + }); + + if (didFallback) { + logMetroPortFallback(initialMetroPort, runtimeConfig.metroPort); + } + + maybeLogMetroCacheReuse(runtimeConfig, platform, projectRoot); const pluginAbortController = new AbortController(); const pluginManager = createHarnessPluginManager< HarnessConfig, HarnessPlatform >({ - plugins: (config.plugins ?? []) as Array< + plugins: (runtimeConfig.plugins ?? []) as Array< HarnessPlugin >, projectRoot, - config, + config: runtimeConfig, runner: platform, abortSignal: pluginAbortController.signal, }); @@ -361,7 +380,7 @@ const getHarnessInternal = async ( const serverBridge = await getBridgeServer({ noServer: true, - timeout: config.bridgeTimeout, + timeout: runtimeConfig.bridgeTimeout, context, }); harnessLogger.debug( @@ -377,7 +396,7 @@ const getHarnessInternal = async ( getMetroInstance( { projectRoot, - harnessConfig: config, + harnessConfig: runtimeConfig, websocketEndpoints: { [HARNESS_BRIDGE_PATH]: serverBridge.ws as unknown as MetroWebSocketEndpoint, @@ -389,12 +408,12 @@ const getHarnessInternal = async ( return instance; }), withPlatformReadyTimeout({ - timeout: config.platformReadyTimeout, + timeout: runtimeConfig.platformReadyTimeout, signal, work: async () => { return await import(platform.runner) .then((module) => - module.default(platform.config, config, { + module.default(platform.config, runtimeConfig, { signal, } satisfies HarnessPlatformInitOptions) ) @@ -408,6 +427,7 @@ const getHarnessInternal = async ( } catch (error) { await Promise.allSettled([ resourceLease.release(), + metroPortLease?.release(), serverBridge.dispose(), ]); throw error; @@ -625,7 +645,7 @@ const getHarnessInternal = async ( appMonitor.addListener(onAppMonitorEvent); harnessLogger.debug('registered runtime, bridge, and Metro listeners'); - if (config.forwardClientLogs) { + if (runtimeConfig.forwardClientLogs) { metroInstance.events.addListener(clientLogListener); harnessLogger.debug('client log forwarding enabled'); } @@ -656,7 +676,7 @@ const getHarnessInternal = async ( hookError = error; } - if (config.forwardClientLogs) { + if (runtimeConfig.forwardClientLogs) { metroInstance.events.removeListener(clientLogListener); } metroInstance.events.removeListener(onMetroEvent); @@ -671,6 +691,7 @@ const getHarnessInternal = async ( serverBridge.dispose(), platformInstance.dispose(), metroInstance.dispose(), + metroPortLease?.release(), ]); } catch (error) { cleanupError = error; @@ -744,9 +765,9 @@ const getHarnessInternal = async ( serverBridge, platformInstance: platformInstance as HarnessPlatformRunner, platformId: platform.platformId, - bundleStartTimeout: config.bundleStartTimeout ?? 60000, - readyTimeout: config.bridgeTimeout, - maxAppRestarts: config.maxAppRestarts ?? 2, + bundleStartTimeout: runtimeConfig.bundleStartTimeout ?? 60000, + readyTimeout: runtimeConfig.bridgeTimeout, + maxAppRestarts: runtimeConfig.maxAppRestarts ?? 2, testFilePath, crashSupervisor, appLaunchOptions, @@ -786,6 +807,7 @@ const getHarnessInternal = async ( return { context, + config: runtimeConfig, runTests: async (path, options) => { await flushPendingHooks(); activeTestFilePath = path; diff --git a/packages/jest/src/logs.ts b/packages/jest/src/logs.ts index 0527252..cdde600 100644 --- a/packages/jest/src/logs.ts +++ b/packages/jest/src/logs.ts @@ -45,6 +45,17 @@ export const logMetroCacheReused = (runner: HarnessPlatform): void => { log(`${TAG} Reusing Metro cache for ${chalk.bold(runner.name)}\n`); }; +export const logMetroPortFallback = ( + initialPort: number, + selectedPort: number +): void => { + log( + `${TAG} Harness could not use Metro port ${chalk.bold( + String(initialPort) + )} and is using ${chalk.bold(String(selectedPort))} instead.\n` + ); +}; + export const getErrorMessage = (error: HarnessError): string => { return `${ERROR_TAG} ${error.message}\n`; }; diff --git a/packages/jest/src/metro-port.ts b/packages/jest/src/metro-port.ts new file mode 100644 index 0000000..25df2fa --- /dev/null +++ b/packages/jest/src/metro-port.ts @@ -0,0 +1,76 @@ +import type { Config as HarnessConfig } from '@react-native-harness/config'; +import { isPortAvailable } from '@react-native-harness/bundler-metro'; +import type { HarnessPlatform } from '@react-native-harness/platforms'; +import { MetroPortRangeExhaustedError } from './errors.js'; +import type { ResourceLease, ResourceLockManager } from './resource-lock.js'; + +export const METRO_PORT_SCAN_ATTEMPTS = 10; + +const getPortLockKey = (host: string | undefined, port: number): string => { + return `metro-port:${host?.trim() || '*'}:${port}`; +}; + +const isIosPhysicalRunner = (platform: HarnessPlatform): boolean => { + return platform.platformId === 'ios' && platform.name.includes('device'); +}; + +export const resolveHarnessMetroPort = async (options: { + config: HarnessConfig; + platform: HarnessPlatform; + resourceLockManager: ResourceLockManager; + signal: AbortSignal; +}): Promise<{ + config: HarnessConfig; + metroPortLease: ResourceLease | null; + initialMetroPort: number; + didFallback: boolean; +}> => { + const { config, platform, resourceLockManager, signal } = options; + const initialMetroPort = config.metroPort; + const host = config.host?.trim(); + + if (isIosPhysicalRunner(platform)) { + return { + config, + metroPortLease: null, + initialMetroPort, + didFallback: false, + }; + } + + for (let attempt = 0; attempt < METRO_PORT_SCAN_ATTEMPTS; attempt += 1) { + const candidatePort = initialMetroPort + attempt; + const metroPortLease = await resourceLockManager.acquire( + getPortLockKey(host, candidatePort), + { signal } + ); + + try { + if (!(await isPortAvailable(candidatePort, host))) { + await metroPortLease.release(); + continue; + } + + return { + config: + candidatePort === initialMetroPort + ? config + : { + ...config, + metroPort: candidatePort, + }, + metroPortLease, + initialMetroPort, + didFallback: candidatePort !== initialMetroPort, + }; + } catch (error) { + await metroPortLease.release(); + throw error; + } + } + + throw new MetroPortRangeExhaustedError( + initialMetroPort, + METRO_PORT_SCAN_ATTEMPTS + ); +}; diff --git a/packages/jest/src/setup.ts b/packages/jest/src/setup.ts index ec20a6a..99e06e4 100644 --- a/packages/jest/src/setup.ts +++ b/packages/jest/src/setup.ts @@ -122,6 +122,6 @@ export const setup = async (globalConfig: JestConfig.GlobalConfig) => { logTestEnvironmentReady(selectedRunner); setupLogger.debug('Harness instance is ready'); - global.HARNESS_CONFIG = harnessConfig; + global.HARNESS_CONFIG = harness.config; global.HARNESS = harness; }; diff --git a/website/src/docs/api/cli.mdx b/website/src/docs/api/cli.mdx index 1cb35d1..dfb338d 100644 --- a/website/src/docs/api/cli.mdx +++ b/website/src/docs/api/cli.mdx @@ -13,6 +13,7 @@ npx react-native-harness init ``` The wizard will help you: + - Detect your project type (Expo or React Native CLI). - Install required platform packages. - Configure Android, iOS, or Web runners. @@ -30,10 +31,11 @@ npx react-native-harness [test-file-pattern] [options] ### Harness Specific Flags -| Flag | Description | -| :--- | :--- | -| `--harnessRunner ` | Specify which runner from your `rn-harness.config.mjs` to use. **Note:** Only one runner can be specified per command execution. | -| `--config, -c ` | Path to your Harness Jest configuration (defaults to `jest.harness.config.mjs`). | +| Flag | Description | +| :----------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--harnessRunner ` | Specify which runner from your `rn-harness.config.mjs` to use. **Note:** Only one runner can be specified per command execution. | +| `--config, -c ` | Path to your Harness Jest configuration (defaults to `jest.harness.config.mjs`). | +| `--metroPort ` | Set the preferred Metro port for this run. If the selected runner supports it and the port is busy, Harness will try the next available ports automatically. | ### Running on Multiple Platforms @@ -66,6 +68,7 @@ To ensure stability and compatibility with real native devices, React Native Har Harness executes tests **serially** (one at a time) on the target device to prevent native resource conflicts (like multiple tests trying to access the camera or filesystem simultaneously). The following Jest flags are **ignored or disabled**: + - `--maxWorkers` - `--runInBand` (Implicitly always true) - `--maxConcurrency` @@ -76,6 +79,7 @@ The following Jest flags are **ignored or disabled**: Harness manages the test environment, transformation, and module resolution internally to ensure tests can be bundled by Metro and run on-device. The following Jest configuration flags are **disabled**: + - `--runner` / `--testRunner` - `--testEnvironment` - `--preset` diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index 4a45f4c..95d51cd 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -86,28 +86,34 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` ## All Configuration Options -| Option | Description | -| :--------------------------------- | :--------------------------------------------------------------------------------------------------------- | -| `entryPoint` | **Required.** Path to your React Native app's entry point file. | -| `appRegistryComponentName` | **Required.** Name of the component registered with AppRegistry. | -| `runners` | **Required.** Array of test runners (at least one required). | -| `defaultRunner` | Default runner to use when none specified. | -| `host` | Hostname or IP address to bind the Metro server to (default: Metro default). | -| `metroPort` | Port used by Metro and Harness bridge traffic (default: `8081`). | -| `platformReadyTimeout` | Platform-ready timeout in milliseconds (default: `300000`). | -| `bridgeTimeout` | Bridge timeout in milliseconds (default: `60000`). | -| `bundleStartTimeout` | Bundle start timeout in milliseconds (default: `60000`). | -| `resetEnvironmentBetweenTestFiles` | Reset environment between test files (default: `true`). | -| `detectNativeCrashes` | Detect native app crashes during startup and test execution (default: `true`). | -| `crashDetectionInterval` | Interval in milliseconds to check for native crashes (default: `500`). | -| `disableViewFlattening` | Disable view flattening in React Native (default: `false`). | -| `coverage` | Coverage configuration object. | -| `coverage.root` | Root directory for coverage instrumentation (default: `process.cwd()`). | -| `forwardClientLogs` | Forward console logs from the app to the terminal (default: `false`). | -| `unstable__enableMetroCache` | Enable Metro transformation cache under `.harness/metro-cache` and log when reusing it (default: `false`). | +| Option | Description | +| :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `entryPoint` | **Required.** Path to your React Native app's entry point file. | +| `appRegistryComponentName` | **Required.** Name of the component registered with AppRegistry. | +| `runners` | **Required.** Array of test runners (at least one required). | +| `defaultRunner` | Default runner to use when none specified. | +| `host` | Hostname or IP address to bind the Metro server to (default: Metro default). | +| `metroPort` | Preferred port used by Metro and Harness bridge traffic (default: `8081`). Harness falls back to the next available port when this one is already in use. | +| `platformReadyTimeout` | Platform-ready timeout in milliseconds (default: `300000`). | +| `bridgeTimeout` | Bridge timeout in milliseconds (default: `60000`). | +| `bundleStartTimeout` | Bundle start timeout in milliseconds (default: `60000`). | +| `resetEnvironmentBetweenTestFiles` | Reset environment between test files (default: `true`). | +| `detectNativeCrashes` | Detect native app crashes during startup and test execution (default: `true`). | +| `crashDetectionInterval` | Interval in milliseconds to check for native crashes (default: `500`). | +| `disableViewFlattening` | Disable view flattening in React Native (default: `false`). | +| `coverage` | Coverage configuration object. | +| `coverage.root` | Root directory for coverage instrumentation (default: `process.cwd()`). | +| `forwardClientLogs` | Forward console logs from the app to the terminal (default: `false`). | +| `unstable__enableMetroCache` | Enable Metro transformation cache under `.harness/metro-cache` and log when reusing it (default: `false`). | ## Test Runners +:::info Metro Port Fallback +Harness treats `metroPort` as the preferred starting port. If that port is already in use, Harness tries the next available ports automatically and logs the port it selected for the run. + +Physical iOS devices are the exception: they always use the default Metro port and do not support custom or fallback ports. +::: + A test runner defines how tests are executed on a specific platform. React Native Harness uses platform-specific packages to create runners with type-safe configurations. Runner-specific launch options belong inside each runner config via `appLaunchOptions`. diff --git a/website/src/docs/platforms/ios.mdx b/website/src/docs/platforms/ios.mdx index fb91312..c45f66e 100644 --- a/website/src/docs/platforms/ios.mdx +++ b/website/src/docs/platforms/ios.mdx @@ -43,10 +43,11 @@ applePlatform({ FEATURE_X: '1', }, }, -}) +}); ``` #### Finding Simulator Details + You can list all available simulators using `xcrun`: ```bash @@ -72,9 +73,11 @@ applePlatform({ FEATURE_X: '1', }, }, -}) +}); ``` +Physical iOS devices always connect to the default Metro port (`8081`). Custom `metroPort` values and automatic port fallback are supported on simulators, but not on physical iOS devices. + ## Requirements - **Xcode**: Xcode must be installed on your system.