From 5c2b9eda691f44969be8a14a0d2cc6cbf09c8ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Mon, 4 Aug 2025 05:56:51 -0700 Subject: [PATCH] Refactor runner to use a single instance of Metro for each run (#52777) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/52777 Changelog: [internal] This significantly speeds up test execution in Fantom (around 2x in OSS and 6x at Meta) by starting a Metro server before all tests runs and reusing it across all tests to build test bundles, instead of spinning up a new Metro instance every time we run each test. The architecture change (also considering the previous change in buck prebuilds) looks like this: {F1980689532} This is how is impacts execution times (compared to the baseline): * OSS * Before: 62s {F1980564286} * After: 30s (**2x faster**) {F1980564265} Reviewed By: andrewdacenko Differential Revision: D78741903 fbshipit-source-id: b209f88925e49cc2a2067e8df9b7fa9a29b4c8d2 --- .../react-native-fantom/config/jest.config.js | 2 + .../runner/createBundle.js | 117 ++++++++++++++++++ .../runner/global-setup/build.js | 9 +- .../runner/global-setup/globalSetup.js | 33 +++++ .../runner/global-setup/globalTeardown.js | 43 +++++++ .../runner/global-setup/teardown.js | 13 ++ private/react-native-fantom/runner/runner.js | 9 +- scripts/fantom.sh | 9 +- 8 files changed, 221 insertions(+), 14 deletions(-) create mode 100644 private/react-native-fantom/runner/createBundle.js create mode 100644 private/react-native-fantom/runner/global-setup/globalTeardown.js create mode 100644 private/react-native-fantom/runner/global-setup/teardown.js diff --git a/private/react-native-fantom/config/jest.config.js b/private/react-native-fantom/config/jest.config.js index a69fc4c3689..adf10408e5c 100644 --- a/private/react-native-fantom/config/jest.config.js +++ b/private/react-native-fantom/config/jest.config.js @@ -35,4 +35,6 @@ module.exports = { watchPathIgnorePatterns: ['/private/react-native-fantom/build/'], globalSetup: '/private/react-native-fantom/runner/global-setup/setup.js', + globalTeardown: + '/private/react-native-fantom/runner/global-setup/teardown.js', }; diff --git a/private/react-native-fantom/runner/createBundle.js b/private/react-native-fantom/runner/createBundle.js new file mode 100644 index 00000000000..89d9d37637a --- /dev/null +++ b/private/react-native-fantom/runner/createBundle.js @@ -0,0 +1,117 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {RunBuildOptions} from 'metro'; + +import fs from 'fs'; +import path from 'path'; + +type CreateBundleOptions = { + ...RunBuildOptions, + out: $NonMaybeType, + testPath: string, +}; + +const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); + +export default async function createBundle({ + testPath, + entry, + out, + platform, + minify, + dev, + sourceMap, + sourceMapUrl, +}: CreateBundleOptions): Promise { + if (process.env.__FANTOM_METRO_PORT__ == null) { + throw new Error( + 'Could not find Metro server port (process.env.__FANTOM_METRO_PORT__ not set by Fantom)', + ); + } + + const port = Number(process.env.__FANTOM_METRO_PORT__); + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid port for Metro server: ${port}`); + } + + const requestPath = path + .relative(PROJECT_ROOT, entry) + .replace(/\.js$/, '.bundle'); + + const bundleURL = new URL(`http://localhost:${port}/${requestPath}`); + + if (platform != null) { + bundleURL.searchParams.append('platform', platform); + } + + if (minify != null) { + bundleURL.searchParams.append('minify', minify ? 'true' : 'false'); + } + + if (dev != null) { + bundleURL.searchParams.append('dev', dev ? 'true' : 'false'); + } + + if (sourceMap != null) { + bundleURL.searchParams.append('sourceMap', sourceMap ? 'true' : 'false'); + } + + if (sourceMapUrl != null) { + bundleURL.searchParams.append('sourceMapUrl', sourceMapUrl); + } + + let bundleResult; + let bundleError; + + // Retry in case Metro hasn't seen the changes in the filesystem yet. + // TODO(T231910841): Remove this when Metro fixes consistency issues when resolving HTTP requests. + let attemps = 0; + do { + if (attemps > 0) { + await sleep(500); + } + + try { + bundleResult = await fetch(bundleURL); + } catch (e) { + bundleError = e; + } + + attemps++; + } while (attemps < 3 && (bundleError || bundleResult?.status === 404)); + + if (bundleError || bundleResult?.ok !== true) { + throw new Error( + `Failed to request bundle from Metro: ${bundleError?.message ?? (await bundleResult?.text()) ?? ''}`, + ); + } + await fs.promises.writeFile(out, await bundleResult.text(), 'utf8'); + + try { + const sourceMapURL = new URL(bundleURL.toString()); + sourceMapURL.pathname = sourceMapURL.pathname.replace(/\.bundle$/, '.map'); + const sourceMapResult = await fetch(sourceMapURL); + if (!sourceMapResult.ok) { + throw new Error(); + } + await fs.promises.writeFile( + out.replace(/\.js$/, '.map'), + await sourceMapResult.text(), + 'utf8', + ); + } catch (e) { + console.error(`Could not fetch source map from Metro for test ${testPath}`); + } +} + +async function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/private/react-native-fantom/runner/global-setup/build.js b/private/react-native-fantom/runner/global-setup/build.js index 54ed59f302e..99f03f77719 100644 --- a/private/react-native-fantom/runner/global-setup/build.js +++ b/private/react-native-fantom/runner/global-setup/build.js @@ -8,6 +8,7 @@ * @format */ +import createBundle from '../createBundle'; import {isCI} from '../EnvironmentOptions'; import {build as buildHermesCompiler} from '../executables/hermesc'; import {build as buildFantomTester} from '../executables/tester'; @@ -15,7 +16,6 @@ import {getNativeBuildOutputPath} from '../executables/utils'; import {HermesVariant} from '../utils'; // $FlowExpectedError[untyped-import] import fs from 'fs'; -import Metro from 'metro'; import os from 'os'; import path from 'path'; @@ -58,10 +58,6 @@ export default async function build(): Promise { } async function warmUpMetro(isOptimizedMode: boolean): Promise { - const metroConfig = await Metro.loadConfig({ - config: path.resolve(__dirname, '..', '..', 'config', 'metro.config.js'), - }); - const entrypointPath = path.resolve( __dirname, '..', @@ -75,7 +71,8 @@ async function warmUpMetro(isOptimizedMode: boolean): Promise { `fantom-warmup-bundle-${Date.now()}.js`, ); - await Metro.runBuild(metroConfig, { + await createBundle({ + testPath: '(warmup bundle - no test path)', entry: entrypointPath, out: bundlePath, platform: 'android', diff --git a/private/react-native-fantom/runner/global-setup/globalSetup.js b/private/react-native-fantom/runner/global-setup/globalSetup.js index bd6b84bacc4..f87eec9848a 100644 --- a/private/react-native-fantom/runner/global-setup/globalSetup.js +++ b/private/react-native-fantom/runner/global-setup/globalSetup.js @@ -10,6 +10,8 @@ import {isOSS, validateEnvironmentVariables} from '../EnvironmentOptions'; import build from './build'; +import Metro from 'metro'; +import path from 'path'; export default async function globalSetup( globalConfig: {...}, @@ -19,7 +21,38 @@ export default async function globalSetup( validateEnvironmentVariables(); + await startMetroServer(); + if (!isOSS) { await build(); } } + +async function startMetroServer() { + const metroConfig = await Metro.loadConfig({ + config: path.resolve(__dirname, '..', '..', 'config', 'metro.config.js'), + }); + + // We need to reuse the same port across runs because can only set environment + // variables for workers in the first one. + // $FlowExpectedError[cannot-write] + metroConfig.server.port = + process.env.__FANTOM_METRO_PORT__ != null + ? Number(process.env.__FANTOM_METRO_PORT__) + : // Any available port + 0; + + const server = await Metro.runServer(metroConfig, { + waitForBundler: true, + watch: true, + }); + + if (process.env.__FANTOM_METRO_PORT__ == null) { + process.env.__FANTOM_METRO_PORT__ = String( + server.httpServer.address().port, + ); + } + + // $FlowExpectedError[prop-missing] + globalThis.__METRO_SERVER__ = server; +} diff --git a/private/react-native-fantom/runner/global-setup/globalTeardown.js b/private/react-native-fantom/runner/global-setup/globalTeardown.js new file mode 100644 index 00000000000..477bd320238 --- /dev/null +++ b/private/react-native-fantom/runner/global-setup/globalTeardown.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {RunServerResult} from 'metro'; + +type MetroServer = $NonMaybeType; + +declare var __METRO_SERVER__: ?RunServerResult; + +function getMetroServer(): ?MetroServer { + return typeof __METRO_SERVER__ !== 'undefined' && __METRO_SERVER__ != null + ? __METRO_SERVER__.httpServer + : null; +} + +export default async function globalTeardown( + globalConfig: {...}, + projectConfig: {...}, +): Promise { + const metroServer = getMetroServer(); + if (metroServer) { + await stopMetroServer(metroServer); + } +} + +async function stopMetroServer(metroServer: MetroServer): Promise { + return new Promise((resolve, reject) => { + metroServer.close(error => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} diff --git a/private/react-native-fantom/runner/global-setup/teardown.js b/private/react-native-fantom/runner/global-setup/teardown.js new file mode 100644 index 00000000000..6e8913f17cb --- /dev/null +++ b/private/react-native-fantom/runner/global-setup/teardown.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +require('../../../../scripts/shared/babelRegister').registerForMonorepo(); + +module.exports = require('./globalTeardown'); diff --git a/private/react-native-fantom/runner/runner.js b/private/react-native-fantom/runner/runner.js index 36fb632bbc4..18d3b2cacd0 100644 --- a/private/react-native-fantom/runner/runner.js +++ b/private/react-native-fantom/runner/runner.js @@ -20,6 +20,7 @@ import type { HermesVariant, } from './utils'; +import createBundle from './createBundle'; import entrypointTemplate from './entrypoint-template'; import * as EnvironmentOptions from './EnvironmentOptions'; import {run as runHermesCompiler} from './executables/hermesc'; @@ -41,7 +42,6 @@ import fs from 'fs'; // $FlowExpectedError[untyped-import] import {formatResultsErrors} from 'jest-message-util'; import {SnapshotState, buildSnapshotResolver} from 'jest-snapshot'; -import Metro from 'metro'; import nullthrows from 'nullthrows'; import path from 'path'; import readline from 'readline'; @@ -210,10 +210,6 @@ module.exports = async function runTest( const testContents = fs.readFileSync(testPath, 'utf8'); const testConfigs = getFantomTestConfigs(testPath, testContents); - const metroConfig = await Metro.loadConfig({ - config: path.resolve(__dirname, '..', 'config', 'metro.config.js'), - }); - const setupModulePath = path.resolve(__dirname, '../runtime/setup.js'); const featureFlagsModulePath = path.resolve( __dirname, @@ -305,7 +301,8 @@ module.exports = async function runTest( path.basename(testJSBundlePath, '.js') + '.map', ); - await Metro.runBuild(metroConfig, { + await createBundle({ + testPath, entry: entrypointPath, out: testJSBundlePath, platform: 'android', diff --git a/scripts/fantom.sh b/scripts/fantom.sh index e58019f59df..9ae47c1f23b 100755 --- a/scripts/fantom.sh +++ b/scripts/fantom.sh @@ -6,9 +6,14 @@ set -e + if [[ -f "BUCK" && -z "$FANTOM_FORCE_OSS_BUILD" ]]; then - JS_DIR='..' yarn jest --config private/react-native-fantom/config/jest.config.js "$@" + export JS_DIR='..' else yarn workspace @react-native/fantom build - FANTOM_FORCE_OSS_BUILD=1 yarn jest --config private/react-native-fantom/config/jest.config.js "$@" + export FANTOM_FORCE_OSS_BUILD=1 fi + +export NODE_OPTIONS='--max-old-space-size=8192' + +yarn jest --config private/react-native-fantom/config/jest.config.js "$@"