diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index 4a1f983e..6ec60939 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -4388,7 +4388,7 @@ var ConfigSchema = external_exports.object({ defaultRunner: external_exports.string().optional(), host: external_exports.string().min(1, "Host is required").optional(), metroPort: external_exports.number().int("Metro port must be an integer").min(1, "Metro port must be at least 1").max(65535, "Metro port must be at most 65535").optional().default(DEFAULT_METRO_PORT), - webSocketPort: external_exports.number().optional().default(3001), + webSocketPort: external_exports.number().optional().describe("Deprecated. Bridge traffic now uses metroPort and this value is ignored."), bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4), bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(15e3), maxAppRestarts: external_exports.number().min(0, "Max app restarts must be at least 0").default(2), diff --git a/apps/playground/rn-harness.config.mjs b/apps/playground/rn-harness.config.mjs index 0f650f44..2b35e6e1 100644 --- a/apps/playground/rn-harness.config.mjs +++ b/apps/playground/rn-harness.config.mjs @@ -116,7 +116,6 @@ export default { ], defaultRunner: 'android', bridgeTimeout: 120000, - webSocketPort: 3002, resetEnvironmentBetweenTestFiles: true, unstable__enableMetroCache: true, diff --git a/packages/bridge/src/server.ts b/packages/bridge/src/server.ts index e1132c0b..089cd896 100644 --- a/packages/bridge/src/server.ts +++ b/packages/bridge/src/server.ts @@ -2,6 +2,8 @@ import { WebSocketServer, type WebSocket } from 'ws'; import { type BirpcGroup, createBirpcGroup } from 'birpc'; import { logger } from '@react-native-harness/tools'; import { EventEmitter } from 'node:events'; +import type { Server as HttpServer } from 'node:http'; +import type { Server as HttpsServer } from 'node:https'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -24,8 +26,25 @@ import { matchImageSnapshot } from './image-snapshot.js'; export { DeviceNotRespondingError } from './errors.js'; const bridgeLogger = logger.child('bridge'); -export type BridgeServerOptions = { +type BridgeServerStandaloneOptions = { port: number; + host?: string; +}; + +type BridgeServerAttachedOptions = { + server: HttpServer | HttpsServer; + path?: string; +}; + +type BridgeServerNoServerOptions = { + noServer: true; +}; + +export type BridgeServerOptions = ( + | BridgeServerStandaloneOptions + | BridgeServerAttachedOptions + | BridgeServerNoServerOptions +) & { timeout?: number; context: HarnessContext; }; @@ -55,16 +74,43 @@ export type BridgeServer = { }; export const getBridgeServer = async ({ - port, timeout, context, + ...transport }: BridgeServerOptions): Promise => { - const wss = await new Promise((resolve) => { - const server = new WebSocketServer({ port, host: '0.0.0.0' }, () => { - resolve(server); - }); - }); - bridgeLogger.debug('bridge server listening on port %d', port); + const wss = + 'port' in transport + ? await new Promise((resolve) => { + const server = new WebSocketServer( + { + port: transport.port, + host: transport.host ?? '0.0.0.0', + }, + () => { + resolve(server); + } + ); + }) + : new WebSocketServer( + 'server' in transport + ? { + server: transport.server, + path: transport.path, + } + : { + noServer: true, + } + ); + if ('port' in transport) { + bridgeLogger.debug('bridge server listening on port %d', transport.port); + } else if ('server' in transport) { + bridgeLogger.debug( + 'bridge server attached to existing HTTP server at path %s', + transport.path ?? '/' + ); + } else { + bridgeLogger.debug('bridge server created in noServer mode'); + } const emitter = new EventEmitter(); const clients = new Set(); const binaryStore = new BinaryStore(); diff --git a/packages/bridge/src/shared.ts b/packages/bridge/src/shared.ts index beaa200d..118275df 100644 --- a/packages/bridge/src/shared.ts +++ b/packages/bridge/src/shared.ts @@ -6,6 +6,8 @@ import type { TestCollectorEvents } from './shared/test-collector.js'; import type { BundlerEvents } from './shared/bundler.js'; import type { HarnessPlatform } from '@react-native-harness/platforms'; +export const HARNESS_BRIDGE_PATH = '/__harness'; + export type FileReference = { path: string; }; diff --git a/packages/bundler-metro/src/__tests__/startup.test.ts b/packages/bundler-metro/src/__tests__/startup.test.ts index 97262216..656164da 100644 --- a/packages/bundler-metro/src/__tests__/startup.test.ts +++ b/packages/bundler-metro/src/__tests__/startup.test.ts @@ -27,6 +27,8 @@ const createMetroInstance = ( overrides: Partial = {} ): MetroInstance => ({ events: getEmitter(), + httpServer: {} as never, + websocketEndpoints: {}, waitUntilHealthy: vi.fn(async () => 'HTTP 200: packager-status:running'), prewarm: vi.fn(async () => false), dispose: vi.fn(async () => undefined), diff --git a/packages/bundler-metro/src/factory.ts b/packages/bundler-metro/src/factory.ts index 784fd7ae..08c31ec1 100644 --- a/packages/bundler-metro/src/factory.ts +++ b/packages/bundler-metro/src/factory.ts @@ -90,7 +90,7 @@ export const getMetroInstance = async ( options: MetroOptions, abortSignal: AbortSignal ): Promise => { - const { projectRoot, harnessConfig } = options; + const { projectRoot, harnessConfig, websocketEndpoints = {} } = options; const metroPort = harnessConfig.metroPort; metroLogger.debug( 'creating Metro instance for %s on port %d', @@ -131,6 +131,7 @@ export const getMetroInstance = async ( const maybeServer = await Metro.runServer(config, { waitForBundler: true, unstable_extraMiddleware: [middleware], + websocketEndpoints, ...(metroBindHost ? { host: metroBindHost } : {}), watch: process.env.CI ? false : undefined, }); @@ -150,6 +151,8 @@ export const getMetroInstance = async ( return { events: reporter, + httpServer: server, + websocketEndpoints, waitUntilHealthy: async ({ timeoutMs, signal }) => waitForMetroStatus({ port: metroPort, timeoutMs, signal }), prewarm: ({ platform, signal }) => { diff --git a/packages/bundler-metro/src/index.ts b/packages/bundler-metro/src/index.ts index c237d1ea..29170f68 100644 --- a/packages/bundler-metro/src/index.ts +++ b/packages/bundler-metro/src/index.ts @@ -1,5 +1,10 @@ export { getMetroInstance } from './factory.js'; -export type { MetroInstance, MetroFactory, MetroOptions } from './types.js'; +export type { + MetroInstance, + MetroFactory, + MetroOptions, + MetroWebSocketEndpoint, +} from './types.js'; export type { Reporter, ReportableEvent } from './reporter.js'; export { isMetroCacheReusable } from './paths.js'; export { diff --git a/packages/bundler-metro/src/manifest.ts b/packages/bundler-metro/src/manifest.ts index 856ec5ff..4414feaf 100644 --- a/packages/bundler-metro/src/manifest.ts +++ b/packages/bundler-metro/src/manifest.ts @@ -6,7 +6,6 @@ import { getHarnessManifestPath } from './paths.js'; const getManifestContent = (harnessConfig: HarnessConfig): string => { return `global.RN_HARNESS = { appRegistryComponentName: '${harnessConfig.appRegistryComponentName}', - webSocketPort: ${harnessConfig.webSocketPort}, disableViewFlattening: ${harnessConfig.disableViewFlattening}, };`; }; diff --git a/packages/bundler-metro/src/types.ts b/packages/bundler-metro/src/types.ts index c4a3ffb0..fabeb168 100644 --- a/packages/bundler-metro/src/types.ts +++ b/packages/bundler-metro/src/types.ts @@ -1,9 +1,18 @@ +import type { Server as HttpServer } from 'node:http'; +import type { Server as HttpsServer } from 'node:https'; +import type { RunServerOptions } from 'metro'; import type { Reporter } from './reporter.js'; import type { Config as HarnessConfig } from '@react-native-harness/config'; +export type MetroWebSocketEndpoints = NonNullable< + RunServerOptions['websocketEndpoints'] +>; +export type MetroWebSocketEndpoint = MetroWebSocketEndpoints[string]; + export type MetroOptions = { projectRoot: string; harnessConfig: HarnessConfig; + websocketEndpoints?: MetroWebSocketEndpoints; }; export type WaitForMetroHealthOptions = { @@ -18,6 +27,8 @@ export type PrewarmMetroBundleOptions = { export type MetroInstance = { events: Reporter; + httpServer: HttpServer | HttpsServer; + websocketEndpoints: MetroWebSocketEndpoints; waitUntilHealthy: (options: WaitForMetroHealthOptions) => Promise; prewarm: (options: PrewarmMetroBundleOptions) => Promise; dispose: () => Promise; diff --git a/packages/bundler-metro/tsconfig.json b/packages/bundler-metro/tsconfig.json index 403a9dfe..0d5c5349 100644 --- a/packages/bundler-metro/tsconfig.json +++ b/packages/bundler-metro/tsconfig.json @@ -12,6 +12,9 @@ { "path": "../tools" }, + { + "path": "../bridge" + }, { "path": "../babel-preset" }, diff --git a/packages/bundler-metro/tsconfig.lib.json b/packages/bundler-metro/tsconfig.lib.json index 41b56969..fa1ce340 100644 --- a/packages/bundler-metro/tsconfig.lib.json +++ b/packages/bundler-metro/tsconfig.lib.json @@ -13,16 +13,19 @@ "include": ["src/**/*.ts"], "references": [ { - "path": "../babel-preset/tsconfig.lib.json" + "path": "../runtime/tsconfig.lib.json" }, { "path": "../config/tsconfig.lib.json" }, { - "path": "../runtime/tsconfig.lib.json" + "path": "../tools/tsconfig.lib.json" }, { - "path": "../tools/tsconfig.lib.json" + "path": "../bridge/tsconfig.lib.json" + }, + { + "path": "../babel-preset/tsconfig.lib.json" } ] } diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 21e9769e..1c5d8370 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -41,7 +41,12 @@ export const ConfigSchema = z .max(65535, 'Metro port must be at most 65535') .optional() .default(DEFAULT_METRO_PORT), - webSocketPort: z.number().optional().default(3001), + webSocketPort: z + .number() + .optional() + .describe( + 'Deprecated. Bridge traffic now uses metroPort and this value is ignored.' + ), bridgeTimeout: z .number() .min(1000, 'Bridge timeout must be at least 1 second') diff --git a/packages/jest/src/__tests__/harness-cache.test.ts b/packages/jest/src/__tests__/harness-cache.test.ts index fb09219e..b81200e0 100644 --- a/packages/jest/src/__tests__/harness-cache.test.ts +++ b/packages/jest/src/__tests__/harness-cache.test.ts @@ -31,7 +31,6 @@ const createHarnessConfig = ( ): HarnessConfig => ({ appRegistryComponentName: 'App', - webSocketPort: 8081, disableViewFlattening: false, bridgeTimeout: 5000, entryPoint: 'index.js', diff --git a/packages/jest/src/__tests__/harness.test.ts b/packages/jest/src/__tests__/harness.test.ts index 43fd9235..4c545e40 100644 --- a/packages/jest/src/__tests__/harness.test.ts +++ b/packages/jest/src/__tests__/harness.test.ts @@ -1,4 +1,5 @@ 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 { definePlugin } from '@react-native-harness/plugins'; @@ -69,6 +70,7 @@ const createBridgeServer = () => { return { serverBridge: { + ws: {} as BridgeServer['ws'], rpc: { clients: [], }, @@ -117,6 +119,8 @@ const createMetroInstance = ( overrides: Partial = {} ): MetroInstance => ({ events: createReporter(), + httpServer: {} as never, + websocketEndpoints: {}, waitUntilHealthy: vi.fn(async () => 'HTTP 200: packager-status:running'), prewarm: vi.fn(async () => false), dispose: vi.fn(async () => undefined), @@ -406,6 +410,19 @@ describe('getHarness', () => { '/tmp/project' ); + expect(mocks.getBridgeServer).toHaveBeenCalledWith( + expect.objectContaining({ + noServer: true, + }) + ); + expect(mocks.getMetroInstance).toHaveBeenCalledWith( + expect.objectContaining({ + websocketEndpoints: { + [HARNESS_BRIDGE_PATH]: serverBridge.ws, + }, + }), + expect.any(AbortSignal) + ); await harness.restart('/tmp/restart.harness.ts'); expect(stopApp).toHaveBeenCalledTimes(1); diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index c507fed2..bfbfe63b 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -3,6 +3,7 @@ import { BridgeServer, } from '@react-native-harness/bridge/server'; import { + HARNESS_BRIDGE_PATH, HarnessContext, type BridgeEvents, type DeviceDescriptor, @@ -20,6 +21,7 @@ import { isMetroCacheReusable, waitForMetroBackedAppReady, type MetroInstance, + type MetroWebSocketEndpoint, type ReportableEvent, } from '@react-native-harness/bundler-metro'; import { @@ -272,34 +274,47 @@ const getHarnessInternal = async ( trackHook(pluginManager.callHook(name, payload)); }; + const serverBridge = await getBridgeServer({ + noServer: true, + timeout: config.bridgeTimeout, + context, + }); harnessLogger.debug( 'starting Metro, platform runner, and bridge initialization' ); - const [metroInstance, platformInstance, serverBridge] = await Promise.all([ - getMetroInstance({ projectRoot, harnessConfig: config }, signal).then( - (instance) => { - harnessLogger.debug('Metro initialized'); - return instance; - } - ), - import(platform.runner) - .then((module) => module.default(platform.config, config)) - .then((instance) => { - harnessLogger.debug('platform runner initialized'); - return instance; - }), - getBridgeServer({ - port: config.webSocketPort, - timeout: config.bridgeTimeout, - context, - }).then((bridge) => { - harnessLogger.debug( - 'bridge server initialized on port %d', - config.webSocketPort - ); - return bridge; - }), - ]); + harnessLogger.debug( + 'bridge server initialized on Metro websocket path %s', + HARNESS_BRIDGE_PATH + ); + const [metroInstance, platformInstance] = await (async () => { + try { + return await Promise.all([ + getMetroInstance( + { + projectRoot, + harnessConfig: config, + websocketEndpoints: { + [HARNESS_BRIDGE_PATH]: + serverBridge.ws as unknown as MetroWebSocketEndpoint, + }, + }, + signal + ).then((instance) => { + harnessLogger.debug('Metro initialized'); + return instance; + }), + import(platform.runner).then((module) => + module.default(platform.config, config) + ).then((instance) => { + harnessLogger.debug('platform runner initialized'); + return instance; + }), + ]); + } catch (error) { + serverBridge.dispose(); + throw error; + } + })(); const crashArtifactWriter = createCrashArtifactWriter({ runnerName: platform.name, platformId: platform.platformId, diff --git a/packages/jest/src/setup.ts b/packages/jest/src/setup.ts index 888e1028..07d541b3 100644 --- a/packages/jest/src/setup.ts +++ b/packages/jest/src/setup.ts @@ -75,6 +75,15 @@ export const setup = async (globalConfig: JestConfig.GlobalConfig) => { const selectedRunner = getHarnessRunner(harnessConfig, cliArgs); + if ( + harnessConfig.webSocketPort != null && + harnessConfig.webSocketPort !== harnessConfig.metroPort + ) { + logger.warn( + `Config option "webSocketPort" is deprecated and ignored. Harness now uses metroPort (${harnessConfig.metroPort}) for bridge traffic.` + ); + } + if (globalConfig.collectCoverage) { // This is going to be used by @react-native-harness/babel-preset // to enable instrumentation of test files. diff --git a/packages/platform-android/src/runner.ts b/packages/platform-android/src/runner.ts index 8a000d42..a2f220c0 100644 --- a/packages/platform-android/src/runner.ts +++ b/packages/platform-android/src/runner.ts @@ -43,7 +43,6 @@ const getAndroidRunner = async ( await Promise.all([ adb.reversePort(adbId, metroPort), adb.reversePort(adbId, 8080), - adb.reversePort(adbId, harnessConfig.webSocketPort), adb.setHideErrorDialogs(adbId, true), applyHarnessDebugHttpHost(adbId, parsedConfig.bundleId, `localhost:${metroPort}`), ]); diff --git a/packages/runtime/src/client/getWSServer.ts b/packages/runtime/src/client/getWSServer.ts index 8cf0a2fd..f0cc3f23 100644 --- a/packages/runtime/src/client/getWSServer.ts +++ b/packages/runtime/src/client/getWSServer.ts @@ -1,10 +1,9 @@ +import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge'; import { getDevServerUrl } from '../utils/dev-server.js'; -import { WS_SERVER_PORT } from '../constants.js'; export const getWSServer = (): string => { - const devServerUrl = getDevServerUrl(); - const hostname = devServerUrl.split('://')[1].split(':')[0]; - const port = global.RN_HARNESS?.webSocketPort || WS_SERVER_PORT; + const devServerUrl = new URL(getDevServerUrl()); + const protocol = devServerUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - return `ws://${hostname}:${port}`; + return `${protocol}//${devServerUrl.host}${HARNESS_BRIDGE_PATH}`; }; diff --git a/packages/runtime/src/constants.ts b/packages/runtime/src/constants.ts deleted file mode 100644 index aedff8d9..00000000 --- a/packages/runtime/src/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const WS_SERVER_PORT = 3001; diff --git a/packages/runtime/src/globals.ts b/packages/runtime/src/globals.ts index 00e1f98c..d3a63971 100644 --- a/packages/runtime/src/globals.ts +++ b/packages/runtime/src/globals.ts @@ -2,7 +2,6 @@ import type { ImageSnapshotOptions } from '@react-native-harness/bridge'; export type HarnessGlobal = { appRegistryComponentName: string; - webSocketPort?: number; disableViewFlattening?: boolean; }; diff --git a/website/src/docs/getting-started/architecture.md b/website/src/docs/getting-started/architecture.md index 56cbc80d..416cc99d 100644 --- a/website/src/docs/getting-started/architecture.md +++ b/website/src/docs/getting-started/architecture.md @@ -29,7 +29,7 @@ Harness does **not** modify your native code (`.ipa` or `.apk`). Instead, it rel 4. The app loads the bundle, and instead of rendering your `App.tsx`, it starts the Harness Test Runner. ### 3. The Bridge (WebSocket) -Once the Test Runner starts on the device, it establishes a WebSocket connection back to the Harness CLI on your computer (default port `3001`). This bridge is used for: +Once the Test Runner starts on the device, it establishes a WebSocket connection back to the Harness CLI through Metro. This bridge is used for: * **Control**: The CLI tells the device which tests to run. * **Reporting**: The device sends assertions, failures, and logs back to the CLI. * **Lifecycle**: The CLI monitors the device for crashes or timeouts. diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index 5687ce10..d57e97f8 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -93,9 +93,9 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` | `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`). | | `bridgeTimeout` | Bridge timeout in milliseconds (default: `60000`). | | `resetEnvironmentBetweenTestFiles` | Reset environment between test files (default: `true`). | -| `webSocketPort` | Web socket port for bridge communication (default: `3001`). | | `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`). | @@ -177,20 +177,6 @@ Increase this value if you experience timeout errors, especially on: - Slower devices or simulators - Complex test suites with heavy setup -## Web Socket Port - -The port used for the WebSocket bridge communication between the CLI and the device. - -```javascript -{ - webSocketPort: 4000, -} -``` - -**Default:** 3001 - -Change this if port 3001 is already in use on your system. - ## Environment-Specific Configurations You can create different configurations for different environments: