From b7f4871fd2d30790c3cde6157c64b409209b7a57 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 2 Dec 2025 12:29:55 +0100 Subject: [PATCH 1/5] feat: support expo-dev-client --- packages/bundler-metro/package.json | 1 + .../bundler-metro/src/entry-point-utils.ts | 49 +++++++++++++++ packages/bundler-metro/src/factory.ts | 20 ++---- .../src/middlewares/expo-middleware.ts | 62 +++++++++++++++++++ .../src/middlewares/status-middleware.ts | 10 +++ packages/bundler-metro/src/types.ts | 2 + packages/bundler-metro/tsconfig.json | 5 +- packages/bundler-metro/tsconfig.lib.json | 5 +- packages/jest/src/harness.ts | 2 +- pnpm-lock.yaml | 3 + 10 files changed, 142 insertions(+), 17 deletions(-) create mode 100644 packages/bundler-metro/src/entry-point-utils.ts create mode 100644 packages/bundler-metro/src/middlewares/expo-middleware.ts create mode 100644 packages/bundler-metro/src/middlewares/status-middleware.ts diff --git a/packages/bundler-metro/package.json b/packages/bundler-metro/package.json index 8622a2b..1173ae4 100644 --- a/packages/bundler-metro/package.json +++ b/packages/bundler-metro/package.json @@ -18,6 +18,7 @@ "dependencies": { "@react-native-harness/metro": "workspace:*", "@react-native-harness/tools": "workspace:*", + "@react-native-harness/config": "workspace:*", "connect": "^3.7.0", "nocache": "^4.0.0", "tslib": "^2.3.0" diff --git a/packages/bundler-metro/src/entry-point-utils.ts b/packages/bundler-metro/src/entry-point-utils.ts new file mode 100644 index 0000000..bc72bb6 --- /dev/null +++ b/packages/bundler-metro/src/entry-point-utils.ts @@ -0,0 +1,49 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); + +const CODE_EXTENSIONS = ['', '.ts', '.tsx', '.js', '.jsx']; + +const resolveWithAbsolutePath = (projectRoot: string, entryPoint: string) => { + for (const extension of CODE_EXTENSIONS) { + try { + return require.resolve(entryPoint + extension, { + paths: [projectRoot], + }); + } catch { + continue; + } + } + + return null; +}; + +const relativePathWithoutExtension = ( + projectRoot: string, + pathWithExtension: string +): string => { + const pathWithoutExtension = + path.dirname(pathWithExtension) + + '/' + + path.basename(pathWithExtension, path.extname(pathWithExtension)); + return path.relative(projectRoot, pathWithoutExtension); +}; + +export const getResolvedEntryPointWithoutExtension = ( + projectRoot: string, + entryPoint: string +) => { + const absolutePathToEntryPoint = resolveWithAbsolutePath( + projectRoot, + entryPoint + ); + + if (!absolutePathToEntryPoint) { + throw new Error( + `Could not resolve entry point: ${entryPoint} in ${projectRoot}` + ); + } + + return relativePathWithoutExtension(projectRoot, absolutePathToEntryPoint); +}; diff --git a/packages/bundler-metro/src/factory.ts b/packages/bundler-metro/src/factory.ts index c49e10a..ed1d9d2 100644 --- a/packages/bundler-metro/src/factory.ts +++ b/packages/bundler-metro/src/factory.ts @@ -1,10 +1,6 @@ import { withRnHarness } from '@react-native-harness/metro'; import { logger } from '@react-native-harness/tools'; -import type { - IncomingMessage, - ServerResponse, - Server as HttpServer, -} from 'node:http'; +import type { Server as HttpServer } from 'node:http'; import type { Server as HttpsServer } from 'node:https'; import connect from 'connect'; import nocache from 'nocache'; @@ -17,6 +13,8 @@ import { withReporter, type ReportableEvent, } from './reporter.js'; +import { getExpoMiddleware } from './middlewares/expo-middleware.js'; +import { getStatusMiddleware } from './middlewares/status-middleware.js'; const waitForBundler = async ( reporter: Reporter, @@ -42,7 +40,7 @@ export const getMetroInstance = async ( options: MetroOptions, abortSignal: AbortSignal ): Promise => { - const { projectRoot } = options; + const { projectRoot, harnessConfig } = options; const isDefaultPortAvailable = await isPortAvailable(METRO_PORT); if (!isDefaultPortAvailable) { @@ -62,16 +60,10 @@ export const getMetroInstance = async ( abortSignal.throwIfAborted(); - const statusPageMiddleware = (_: IncomingMessage, res: ServerResponse) => { - res.setHeader( - 'X-React-Native-Project-Root', - new URL(`file:///${projectRoot}`).pathname.slice(1) - ); - res.end('packager-status:running'); - }; const middleware = connect() .use(nocache()) - .use('/status', statusPageMiddleware); + .use('/', getExpoMiddleware(projectRoot, harnessConfig.entryPoint)) + .use('/status', getStatusMiddleware(projectRoot)); const ready = waitForBundler(reporter, abortSignal); const maybeServer = await Metro.runServer(config, { diff --git a/packages/bundler-metro/src/middlewares/expo-middleware.ts b/packages/bundler-metro/src/middlewares/expo-middleware.ts new file mode 100644 index 0000000..946253d --- /dev/null +++ b/packages/bundler-metro/src/middlewares/expo-middleware.ts @@ -0,0 +1,62 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { NextFunction } from 'connect'; +import crypto from 'node:crypto'; +import { getResolvedEntryPointWithoutExtension } from '../entry-point-utils.js'; + +export const getExpoMiddleware = + (projectRoot: string, entryPoint: string) => + (req: IncomingMessage, res: ServerResponse, next: NextFunction) => { + if (req.url !== '/') { + next(); + return; + } + + const platform = req.headers['expo-platform'] as string; + const resolvedEntryPoint = getResolvedEntryPointWithoutExtension( + projectRoot, + entryPoint + ); + + const manifestJson = JSON.stringify({ + id: crypto.randomUUID(), + createdAt: new Date().toISOString(), + launchAsset: { + key: 'bundle', + contentType: 'application/javascript', + url: `http://localhost:8081/${resolvedEntryPoint}.bundle?platform=${platform}&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.bytecode=1&transform.routerRoot=app&transform.reactCompiler=true&unstable_transformProfile=hermes-stable`, + }, + assets: [], + metadata: {}, + extra: { + expoClient: { + name: 'react-native-harness', + slug: 'react-native-harness', + version: '1.0.0', + }, + expoGo: { + debuggerHost: 'localhost:8081', + developer: { + tool: 'expo-cli', + projectRoot, + }, + packagerOpts: { dev: true }, + mainModuleName: resolvedEntryPoint, + }, + }, + }); + + res.statusCode = 200; + res.setHeader('expo-protocol-version', '0'); + res.setHeader('expo-sfv-version', '0'); + res.setHeader('cache-control', 'private, max-age=0'); + res.setHeader('content-type', 'application/expo+json'); + res.setHeader('Exponent-Server', 'lorem'); + res.setHeader('content-length', Buffer.byteLength(manifestJson, 'utf8')); + + if (req.method === 'HEAD') { + res.end(); + return; + } + + res.end(manifestJson); + }; diff --git a/packages/bundler-metro/src/middlewares/status-middleware.ts b/packages/bundler-metro/src/middlewares/status-middleware.ts new file mode 100644 index 0000000..1779526 --- /dev/null +++ b/packages/bundler-metro/src/middlewares/status-middleware.ts @@ -0,0 +1,10 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +export const getStatusMiddleware = + (projectRoot: string) => (_: IncomingMessage, res: ServerResponse) => { + res.setHeader( + 'X-React-Native-Project-Root', + new URL(`file:///${projectRoot}`).pathname.slice(1) + ); + res.end('packager-status:running'); + }; diff --git a/packages/bundler-metro/src/types.ts b/packages/bundler-metro/src/types.ts index 165d4e0..3662b2b 100644 --- a/packages/bundler-metro/src/types.ts +++ b/packages/bundler-metro/src/types.ts @@ -1,7 +1,9 @@ import type { Reporter } from './reporter.js'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; export type MetroOptions = { projectRoot: string; + harnessConfig: HarnessConfig; }; export type MetroInstance = { diff --git a/packages/bundler-metro/tsconfig.json b/packages/bundler-metro/tsconfig.json index 5e964c6..4d3bd48 100644 --- a/packages/bundler-metro/tsconfig.json +++ b/packages/bundler-metro/tsconfig.json @@ -4,11 +4,14 @@ "include": [], "references": [ { - "path": "../metro" + "path": "../config" }, { "path": "../tools" }, + { + "path": "../metro" + }, { "path": "./tsconfig.lib.json" } diff --git a/packages/bundler-metro/tsconfig.lib.json b/packages/bundler-metro/tsconfig.lib.json index 65012f2..d9db878 100644 --- a/packages/bundler-metro/tsconfig.lib.json +++ b/packages/bundler-metro/tsconfig.lib.json @@ -13,10 +13,13 @@ "include": ["src/**/*.ts"], "references": [ { - "path": "../metro/tsconfig.lib.json" + "path": "../config/tsconfig.lib.json" }, { "path": "../tools/tsconfig.lib.json" + }, + { + "path": "../metro/tsconfig.lib.json" } ] } diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index d7796c7..235751e 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -21,7 +21,7 @@ const getHarnessInternal = async ( signal: AbortSignal ): Promise => { const [metroInstance, platformInstance, serverBridge] = await Promise.all([ - getMetroInstance({ projectRoot }, signal), + getMetroInstance({ projectRoot, harnessConfig: config }, signal), import(platform.runner).then((module) => module.default(platform.config)), getBridgeServer({ port: 3001, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffe5384..ff38a02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ importers: packages/bundler-metro: dependencies: + '@react-native-harness/config': + specifier: workspace:* + version: link:../config '@react-native-harness/metro': specifier: workspace:* version: link:../metro From 2b1777e2da684f1dbafd60baad65f25a187d06bd Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 2 Dec 2025 12:50:08 +0100 Subject: [PATCH 2/5] feat: favor expo's metro config package --- packages/metro/src/moduleSystem.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/metro/src/moduleSystem.ts b/packages/metro/src/moduleSystem.ts index 95d846b..4daf4c5 100644 --- a/packages/metro/src/moduleSystem.ts +++ b/packages/metro/src/moduleSystem.ts @@ -8,15 +8,31 @@ const optionalResolve = (path: string, from: string): string | null => { } }; -const getMetroDefaultsPath = (): string => { - const reactNativeMetroConfigPath = require.resolve( +const getMetroConfigPath = (): string => { + const expoConfigPath = optionalResolve('@expo/metro-config', process.cwd()); + + if (expoConfigPath) { + return expoConfigPath; + } + + const reactNativeMetroConfigPath = optionalResolve( '@react-native/metro-config', - { paths: [process.cwd()] } + process.cwd() ); + if (reactNativeMetroConfigPath) { + return reactNativeMetroConfigPath; + } + + throw new CouldNotPatchModuleSystemError(); +}; + +const getMetroDefaultsPath = (): string => { + const metroConfigPath = getMetroConfigPath(); + const preExportsDefaults = optionalResolve( 'metro-config/src/defaults/defaults', - reactNativeMetroConfigPath + metroConfigPath ); if (preExportsDefaults) { @@ -25,7 +41,7 @@ const getMetroDefaultsPath = (): string => { const privateDefaults = optionalResolve( 'metro-config/private/defaults/defaults', - reactNativeMetroConfigPath + metroConfigPath ); if (privateDefaults) { From 46f3825aca05c9d91f2500455946c3ad1fb66f2a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 2 Dec 2025 12:50:27 +0100 Subject: [PATCH 3/5] feat: run android app in a different way --- packages/platform-android/src/adb.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 5b395cc..1a04bcc 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -41,6 +41,10 @@ export const startApp = async ( 'shell', 'am', 'start', + '-a', + 'android.intent.action.MAIN', + '-c', + 'android.intent.category.LAUNCHER', '-n', `${bundleId}/${activityName}`, ]); From d37797b4d2c9bbc72a2ba6d8c8dc475c75ea194a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 2 Dec 2025 13:26:22 +0100 Subject: [PATCH 4/5] feat: add missing runtimeVersion to fake manifest --- packages/bundler-metro/src/middlewares/expo-middleware.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bundler-metro/src/middlewares/expo-middleware.ts b/packages/bundler-metro/src/middlewares/expo-middleware.ts index 946253d..b763f77 100644 --- a/packages/bundler-metro/src/middlewares/expo-middleware.ts +++ b/packages/bundler-metro/src/middlewares/expo-middleware.ts @@ -20,6 +20,7 @@ export const getExpoMiddleware = const manifestJson = JSON.stringify({ id: crypto.randomUUID(), createdAt: new Date().toISOString(), + runtimeVersion: 'react-native-harness', launchAsset: { key: 'bundle', contentType: 'application/javascript', From 59dff20c2633fc5517414304623099786bf2abed Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 2 Dec 2025 16:14:48 +0100 Subject: [PATCH 5/5] docs: update website --- website/src/docs/getting-started/configuration.mdx | 4 ++++ website/src/docs/getting-started/quick-start.mdx | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index d8ca8b3..8292b91 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -70,6 +70,10 @@ AppRegistry.registerComponent('MyApp', () => App); } ``` +:::tip Expo +For Expo projects, the `entryPoint` should be set to the path specified in the `main` property of package.json. The `appRegistryComponentName` is typically set to `main` for Expo apps. +::: + ## All Configuration Options ```typescript diff --git a/website/src/docs/getting-started/quick-start.mdx b/website/src/docs/getting-started/quick-start.mdx index 49e56cf..9ed9310 100644 --- a/website/src/docs/getting-started/quick-start.mdx +++ b/website/src/docs/getting-started/quick-start.mdx @@ -63,6 +63,10 @@ export default config; The `entryPoint` and `appRegistryComponentName` properties tell React Native Harness how to locate and integrate with your React Native app. See the [Configuration](/docs/getting-started/configuration) page for detailed information about these and all other configuration options. ::: +:::tip Expo +For Expo projects, the `entryPoint` should be set to the path specified in the `main` property of package.json. The `appRegistryComponentName` is typically set to `main` for Expo apps. +::: + ### 3. Update Metro Configuration Update your `metro.config.js` so React Native Harness will be able to use it to bundle its tests: