Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/bundler-metro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
49 changes: 49 additions & 0 deletions packages/bundler-metro/src/entry-point-utils.ts
Original file line number Diff line number Diff line change
@@ -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);
};
20 changes: 6 additions & 14 deletions packages/bundler-metro/src/factory.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -42,7 +40,7 @@ export const getMetroInstance = async (
options: MetroOptions,
abortSignal: AbortSignal
): Promise<MetroInstance> => {
const { projectRoot } = options;
const { projectRoot, harnessConfig } = options;
const isDefaultPortAvailable = await isPortAvailable(METRO_PORT);

if (!isDefaultPortAvailable) {
Expand All @@ -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, {
Expand Down
63 changes: 63 additions & 0 deletions packages/bundler-metro/src/middlewares/expo-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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(),
runtimeVersion: 'react-native-harness',
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);
};
10 changes: 10 additions & 0 deletions packages/bundler-metro/src/middlewares/status-middleware.ts
Original file line number Diff line number Diff line change
@@ -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');
};
2 changes: 2 additions & 0 deletions packages/bundler-metro/src/types.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
5 changes: 4 additions & 1 deletion packages/bundler-metro/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
"include": [],
"references": [
{
"path": "../metro"
"path": "../config"
},
{
"path": "../tools"
},
{
"path": "../metro"
},
{
"path": "./tsconfig.lib.json"
}
Expand Down
5 changes: 4 additions & 1 deletion packages/bundler-metro/tsconfig.lib.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
2 changes: 1 addition & 1 deletion packages/jest/src/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const getHarnessInternal = async (
signal: AbortSignal
): Promise<Harness> => {
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,
Expand Down
26 changes: 21 additions & 5 deletions packages/metro/src/moduleSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -25,7 +41,7 @@ const getMetroDefaultsPath = (): string => {

const privateDefaults = optionalResolve(
'metro-config/private/defaults/defaults',
reactNativeMetroConfigPath
metroConfigPath
);

if (privateDefaults) {
Expand Down
4 changes: 4 additions & 0 deletions packages/platform-android/src/adb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export const startApp = async (
'shell',
'am',
'start',
'-a',
'android.intent.action.MAIN',
'-c',
'android.intent.category.LAUNCHER',
'-n',
`${bundleId}/${activityName}`,
]);
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions website/src/docs/getting-started/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions website/src/docs/getting-started/quick-start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down