Skip to content

Commit fdd0146

Browse files
authored
feat: add support for expo-dev-client (#34)
This pull request enables React Native Harness to take over expo-dev-client–based apps and run tests in the same way it does with plain Expo/React Native apps.
1 parent 8a36fe1 commit fdd0146

File tree

14 files changed

+176
-22
lines changed

14 files changed

+176
-22
lines changed

packages/bundler-metro/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"dependencies": {
1919
"@react-native-harness/metro": "workspace:*",
2020
"@react-native-harness/tools": "workspace:*",
21+
"@react-native-harness/config": "workspace:*",
2122
"connect": "^3.7.0",
2223
"nocache": "^4.0.0",
2324
"tslib": "^2.3.0"
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { createRequire } from 'node:module';
2+
import path from 'node:path';
3+
4+
const require = createRequire(import.meta.url);
5+
6+
const CODE_EXTENSIONS = ['', '.ts', '.tsx', '.js', '.jsx'];
7+
8+
const resolveWithAbsolutePath = (projectRoot: string, entryPoint: string) => {
9+
for (const extension of CODE_EXTENSIONS) {
10+
try {
11+
return require.resolve(entryPoint + extension, {
12+
paths: [projectRoot],
13+
});
14+
} catch {
15+
continue;
16+
}
17+
}
18+
19+
return null;
20+
};
21+
22+
const relativePathWithoutExtension = (
23+
projectRoot: string,
24+
pathWithExtension: string
25+
): string => {
26+
const pathWithoutExtension =
27+
path.dirname(pathWithExtension) +
28+
'/' +
29+
path.basename(pathWithExtension, path.extname(pathWithExtension));
30+
return path.relative(projectRoot, pathWithoutExtension);
31+
};
32+
33+
export const getResolvedEntryPointWithoutExtension = (
34+
projectRoot: string,
35+
entryPoint: string
36+
) => {
37+
const absolutePathToEntryPoint = resolveWithAbsolutePath(
38+
projectRoot,
39+
entryPoint
40+
);
41+
42+
if (!absolutePathToEntryPoint) {
43+
throw new Error(
44+
`Could not resolve entry point: ${entryPoint} in ${projectRoot}`
45+
);
46+
}
47+
48+
return relativePathWithoutExtension(projectRoot, absolutePathToEntryPoint);
49+
};

packages/bundler-metro/src/factory.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import { withRnHarness } from '@react-native-harness/metro';
22
import { logger } from '@react-native-harness/tools';
3-
import type {
4-
IncomingMessage,
5-
ServerResponse,
6-
Server as HttpServer,
7-
} from 'node:http';
3+
import type { Server as HttpServer } from 'node:http';
84
import type { Server as HttpsServer } from 'node:https';
95
import connect from 'connect';
106
import nocache from 'nocache';
@@ -17,6 +13,8 @@ import {
1713
withReporter,
1814
type ReportableEvent,
1915
} from './reporter.js';
16+
import { getExpoMiddleware } from './middlewares/expo-middleware.js';
17+
import { getStatusMiddleware } from './middlewares/status-middleware.js';
2018

2119
const waitForBundler = async (
2220
reporter: Reporter,
@@ -42,7 +40,7 @@ export const getMetroInstance = async (
4240
options: MetroOptions,
4341
abortSignal: AbortSignal
4442
): Promise<MetroInstance> => {
45-
const { projectRoot } = options;
43+
const { projectRoot, harnessConfig } = options;
4644
const isDefaultPortAvailable = await isPortAvailable(METRO_PORT);
4745

4846
if (!isDefaultPortAvailable) {
@@ -62,16 +60,10 @@ export const getMetroInstance = async (
6260

6361
abortSignal.throwIfAborted();
6462

65-
const statusPageMiddleware = (_: IncomingMessage, res: ServerResponse) => {
66-
res.setHeader(
67-
'X-React-Native-Project-Root',
68-
new URL(`file:///${projectRoot}`).pathname.slice(1)
69-
);
70-
res.end('packager-status:running');
71-
};
7263
const middleware = connect()
7364
.use(nocache())
74-
.use('/status', statusPageMiddleware);
65+
.use('/', getExpoMiddleware(projectRoot, harnessConfig.entryPoint))
66+
.use('/status', getStatusMiddleware(projectRoot));
7567

7668
const ready = waitForBundler(reporter, abortSignal);
7769
const maybeServer = await Metro.runServer(config, {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { IncomingMessage, ServerResponse } from 'node:http';
2+
import type { NextFunction } from 'connect';
3+
import crypto from 'node:crypto';
4+
import { getResolvedEntryPointWithoutExtension } from '../entry-point-utils.js';
5+
6+
export const getExpoMiddleware =
7+
(projectRoot: string, entryPoint: string) =>
8+
(req: IncomingMessage, res: ServerResponse, next: NextFunction) => {
9+
if (req.url !== '/') {
10+
next();
11+
return;
12+
}
13+
14+
const platform = req.headers['expo-platform'] as string;
15+
const resolvedEntryPoint = getResolvedEntryPointWithoutExtension(
16+
projectRoot,
17+
entryPoint
18+
);
19+
20+
const manifestJson = JSON.stringify({
21+
id: crypto.randomUUID(),
22+
createdAt: new Date().toISOString(),
23+
runtimeVersion: 'react-native-harness',
24+
launchAsset: {
25+
key: 'bundle',
26+
contentType: 'application/javascript',
27+
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`,
28+
},
29+
assets: [],
30+
metadata: {},
31+
extra: {
32+
expoClient: {
33+
name: 'react-native-harness',
34+
slug: 'react-native-harness',
35+
version: '1.0.0',
36+
},
37+
expoGo: {
38+
debuggerHost: 'localhost:8081',
39+
developer: {
40+
tool: 'expo-cli',
41+
projectRoot,
42+
},
43+
packagerOpts: { dev: true },
44+
mainModuleName: resolvedEntryPoint,
45+
},
46+
},
47+
});
48+
49+
res.statusCode = 200;
50+
res.setHeader('expo-protocol-version', '0');
51+
res.setHeader('expo-sfv-version', '0');
52+
res.setHeader('cache-control', 'private, max-age=0');
53+
res.setHeader('content-type', 'application/expo+json');
54+
res.setHeader('Exponent-Server', 'lorem');
55+
res.setHeader('content-length', Buffer.byteLength(manifestJson, 'utf8'));
56+
57+
if (req.method === 'HEAD') {
58+
res.end();
59+
return;
60+
}
61+
62+
res.end(manifestJson);
63+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { IncomingMessage, ServerResponse } from 'node:http';
2+
3+
export const getStatusMiddleware =
4+
(projectRoot: string) => (_: IncomingMessage, res: ServerResponse) => {
5+
res.setHeader(
6+
'X-React-Native-Project-Root',
7+
new URL(`file:///${projectRoot}`).pathname.slice(1)
8+
);
9+
res.end('packager-status:running');
10+
};

packages/bundler-metro/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { Reporter } from './reporter.js';
2+
import type { Config as HarnessConfig } from '@react-native-harness/config';
23

34
export type MetroOptions = {
45
projectRoot: string;
6+
harnessConfig: HarnessConfig;
57
};
68

79
export type MetroInstance = {

packages/bundler-metro/tsconfig.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44
"include": [],
55
"references": [
66
{
7-
"path": "../metro"
7+
"path": "../config"
88
},
99
{
1010
"path": "../tools"
1111
},
12+
{
13+
"path": "../metro"
14+
},
1215
{
1316
"path": "./tsconfig.lib.json"
1417
}

packages/bundler-metro/tsconfig.lib.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@
1313
"include": ["src/**/*.ts"],
1414
"references": [
1515
{
16-
"path": "../metro/tsconfig.lib.json"
16+
"path": "../config/tsconfig.lib.json"
1717
},
1818
{
1919
"path": "../tools/tsconfig.lib.json"
20+
},
21+
{
22+
"path": "../metro/tsconfig.lib.json"
2023
}
2124
]
2225
}

packages/jest/src/harness.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const getHarnessInternal = async (
2121
signal: AbortSignal
2222
): Promise<Harness> => {
2323
const [metroInstance, platformInstance, serverBridge] = await Promise.all([
24-
getMetroInstance({ projectRoot }, signal),
24+
getMetroInstance({ projectRoot, harnessConfig: config }, signal),
2525
import(platform.runner).then((module) => module.default(platform.config)),
2626
getBridgeServer({
2727
port: 3001,

packages/metro/src/moduleSystem.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,31 @@ const optionalResolve = (path: string, from: string): string | null => {
88
}
99
};
1010

11-
const getMetroDefaultsPath = (): string => {
12-
const reactNativeMetroConfigPath = require.resolve(
11+
const getMetroConfigPath = (): string => {
12+
const expoConfigPath = optionalResolve('@expo/metro-config', process.cwd());
13+
14+
if (expoConfigPath) {
15+
return expoConfigPath;
16+
}
17+
18+
const reactNativeMetroConfigPath = optionalResolve(
1319
'@react-native/metro-config',
14-
{ paths: [process.cwd()] }
20+
process.cwd()
1521
);
1622

23+
if (reactNativeMetroConfigPath) {
24+
return reactNativeMetroConfigPath;
25+
}
26+
27+
throw new CouldNotPatchModuleSystemError();
28+
};
29+
30+
const getMetroDefaultsPath = (): string => {
31+
const metroConfigPath = getMetroConfigPath();
32+
1733
const preExportsDefaults = optionalResolve(
1834
'metro-config/src/defaults/defaults',
19-
reactNativeMetroConfigPath
35+
metroConfigPath
2036
);
2137

2238
if (preExportsDefaults) {
@@ -25,7 +41,7 @@ const getMetroDefaultsPath = (): string => {
2541

2642
const privateDefaults = optionalResolve(
2743
'metro-config/private/defaults/defaults',
28-
reactNativeMetroConfigPath
44+
metroConfigPath
2945
);
3046

3147
if (privateDefaults) {

0 commit comments

Comments
 (0)