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
5 changes: 5 additions & 0 deletions .nx/version-plans/cap-metro-workers-for-device-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
__default__: patch
---

Harness test runs keep Metro and the device responsive together, reducing slowdowns on larger development machines while preserving small CI runner behavior.
5 changes: 5 additions & 0 deletions .nx/version-plans/derive-device-core-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
__default__: patch
---

Android Harness runs now use more of the CPU available on larger machines, making local emulator tests faster without changing behavior on small CI runners.
98 changes: 79 additions & 19 deletions actions/shared/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -633,8 +633,8 @@ function getErrorMap() {

// ../../node_modules/zod/dist/esm/v3/helpers/parseUtil.js
var makeIssue = (params) => {
const { data, path: path8, errorMaps, issueData } = params;
const fullPath = [...path8, ...issueData.path || []];
const { data, path: path9, errorMaps, issueData } = params;
const fullPath = [...path9, ...issueData.path || []];
const fullIssue = {
...issueData,
path: fullPath
Expand Down Expand Up @@ -750,11 +750,11 @@ var errorUtil;

// ../../node_modules/zod/dist/esm/v3/types.js
var ParseInputLazyPath = class {
constructor(parent, value, path8, key) {
constructor(parent, value, path9, key) {
this._cachedPath = [];
this.parent = parent;
this.data = value;
this._path = path8;
this._path = path9;
this._key = key;
}
get path() {
Expand Down Expand Up @@ -4357,6 +4357,33 @@ var DEFAULT_ARTIFACT_ROOT = import_node_path5.default.join(process.cwd(), ".harn
var import_node_fs5 = __toESM(require("fs"), 1);
var import_node_path6 = __toESM(require("path"), 1);

// ../tools/dist/diagnostics.js
var noop = () => void 0;
var noopSpan = {
end: noop,
fail: noop
};
var createNoopDiagnostics = () => {
const diagnostics = {
enabled: false,
start: () => noopSpan,
measure: async (_label, fn) => fn(),
mark: noop,
record: noop,
child: () => diagnostics,
flush: () => []
};
return diagnostics;
};
var noopDiagnostics = createNoopDiagnostics();

// ../tools/dist/device-core-budget.js
var MIN_DEVICE_CORE_BUDGET = 2;
var MAX_DEVICE_CORE_BUDGET = 4;
var getDeviceCoreBudget = (hostParallelism) => {
return Math.min(Math.max(Math.floor(hostParallelism / 2), MIN_DEVICE_CORE_BUDGET), MAX_DEVICE_CORE_BUDGET);
};

// ../plugins/dist/utils.js
var isHookTree = (value) => {
if (value == null || typeof value !== "object" || Array.isArray(value)) {
Expand Down Expand Up @@ -4421,6 +4448,7 @@ var ConfigSchema = external_exports.object({
platformReadyTimeout: external_exports.number().min(1e3, "Platform ready timeout must be at least 1 second").default(3e5),
bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(6e4),
maxAppRestarts: external_exports.number().min(0, "Max app restarts must be at least 0").default(2),
eagerPrewarm: external_exports.boolean().optional().default(true).describe("Start building the Metro bundle while the platform (emulator, simulator, or browser) is still booting, so the first bundle is ready sooner. Disable to defer the first bundle build until app startup."),
resetEnvironmentBetweenTestFiles: external_exports.boolean().optional().default(true),
unstable__skipAlreadyIncludedModules: external_exports.boolean().optional().default(false),
unstable__enableMetroCache: external_exports.boolean().optional().default(false),
Expand All @@ -4437,6 +4465,7 @@ var ConfigSchema = external_exports.object({
}).optional().describe("Native code coverage configuration.")
}).optional(),
forwardClientLogs: external_exports.boolean().optional().default(false).describe("Enable forwarding of console.log, console.warn, console.error, and other console method calls from the React Native app during the active test run. When enabled, app console output is attached to the active test result's console output."),
diagnostics: external_exports.boolean().optional().default(false).describe("Enable diagnostics tracing for the harness session. Records spans for session setup, Metro bundling, bridge/client events, and per-file test runs, then writes a Chrome Trace Event JSON file and prints a summary after each run. Can also be enabled via the RN_HARNESS_DIAGNOSTICS environment variable."),
// Deprecated property - used for migration detection
include: external_exports.array(external_exports.string()).optional()
}).refine((config) => {
Expand Down Expand Up @@ -4543,8 +4572,8 @@ var importUp = async (dir, name) => {
} catch (error) {
if (error instanceof ZodError) {
const validationErrors = error.errors.map((err) => {
const path8 = err.path.length > 0 ? ` at "${err.path.join(".")}"` : "";
return `${err.message}${path8}`;
const path9 = err.path.length > 0 ? ` at "${err.path.join(".")}"` : "";
return `${err.message}${path9}`;
});
throw new ConfigValidationError(filePathWithExt, validationErrors);
}
Expand All @@ -4566,11 +4595,22 @@ var getConfig = async (dir) => {
};
};

// src/shared/index.ts
var import_node_path8 = __toESM(require("path"));
var import_node_fs7 = __toESM(require("fs"));
var getHostAndroidSystemImageArch = () => {
switch (process.arch) {
// ../platform-android/dist/avd-config.js
var import_promises3 = require("fs/promises");

// ../platform-android/dist/emulator-startup.js
var import_node_os2 = __toESM(require("os"), 1);

// ../platform-android/dist/environment.js
var import_node_fs7 = require("fs");
var import_promises = require("fs/promises");
var import_node_os = __toESM(require("os"), 1);
var import_node_path8 = __toESM(require("path"), 1);
var import_promises2 = require("stream/promises");
var import_node_https = __toESM(require("https"), 1);
var androidEnvironmentLogger = logger.child("android-environment");
var getHostAndroidSystemImageArch = (architecture = process.arch) => {
switch (architecture) {
case "arm64":
return "arm64-v8a";
case "arm":
Expand All @@ -4580,14 +4620,30 @@ var getHostAndroidSystemImageArch = () => {
return "x86_64";
}
};
var resolveAvdCachingEnabled = ({

// ../platform-android/dist/emulator-startup.js
var emulatorStartupLogger = logger.child("android-emulator-startup");
var getEmulatorCpuCores = () => {
return getDeviceCoreBudget(import_node_os2.default.availableParallelism());
};

// ../platform-android/dist/adb.js
var import_node_child_process = require("child_process");
var import_promises4 = require("fs/promises");
var EMULATOR_OUTPUT_BUFFER_LIMIT = 16 * 1024;
var androidAdbLogger = logger.child("android-adb");

// src/shared/index.ts
var import_node_path9 = __toESM(require("path"));
var import_node_fs8 = __toESM(require("fs"));
var resolveAvdCachingEnabled2 = ({
snapshotEnabled
}) => {
const override = process.env.HARNESS_AVD_CACHING;
const requestedValue = override == null ? snapshotEnabled : override.toLowerCase() === "true";
return requestedValue === true;
};
var getNormalizedAvdCacheConfig = ({
var getNormalizedAvdCacheConfig2 = ({
emulator,
hostArch
}) => {
Expand All @@ -4601,14 +4657,18 @@ var getNormalizedAvdCacheConfig = ({
arch: hostArch,
profile: avd.profile.trim().toLowerCase(),
diskSize: avd.diskSize.trim().toLowerCase(),
heapSize: avd.heapSize.trim().toLowerCase()
heapSize: avd.heapSize.trim().toLowerCase(),
// Roll the AVD cache key whenever the baked-in vCPU count changes, so a
// cached AVD built before this constant existed regenerates once instead
// of failing the compatibility check on every run.
cpuCores: getEmulatorCpuCores()
};
};
var getResolvedRunner = (runner) => {
if (runner.platformId !== "android" || runner.config.device.type !== "emulator") {
return runner;
}
const avdCachingEnabled = resolveAvdCachingEnabled({
const avdCachingEnabled = resolveAvdCachingEnabled2({
snapshotEnabled: runner.config.device.avd?.snapshot?.enabled
});
return {
Expand All @@ -4622,7 +4682,7 @@ var getResolvedRunner = (runner) => {
},
action: {
avdCachingEnabled,
avdCacheConfig: getNormalizedAvdCacheConfig({
avdCacheConfig: getNormalizedAvdCacheConfig2({
emulator: runner.config.device,
hostArch: getHostAndroidSystemImageArch()
})
Expand All @@ -4636,7 +4696,7 @@ var run = async () => {
if (!runnerInput) {
throw new Error("Runner input is required");
}
const projectRoot = projectRootInput ? import_node_path8.default.resolve(projectRootInput) : process.cwd();
const projectRoot = projectRootInput ? import_node_path9.default.resolve(projectRootInput) : process.cwd();
console.info(`Loading React Native Harness config from: ${projectRoot}`);
const { config, projectRoot: resolvedProjectRoot } = await getConfig(
projectRoot
Expand All @@ -4650,13 +4710,13 @@ var run = async () => {
throw new Error("GITHUB_OUTPUT environment variable is not set");
}
const resolvedRunner = getResolvedRunner(runner);
const relativeProjectRoot = import_node_path8.default.relative(process.cwd(), resolvedProjectRoot) || ".";
const relativeProjectRoot = import_node_path9.default.relative(process.cwd(), resolvedProjectRoot) || ".";
const output = `config=${JSON.stringify(
resolvedRunner
)}
projectRoot=${relativeProjectRoot}
`;
import_node_fs7.default.appendFileSync(githubOutput, output);
import_node_fs8.default.appendFileSync(githubOutput, output);
} catch (error) {
if (error instanceof Error) {
console.error(error.message);
Expand Down
6 changes: 5 additions & 1 deletion packages/bundler-metro/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ export default [
'error',
{
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'],
ignoredDependencies: ['@react-native-harness/babel-preset', 'vitest'],
ignoredDependencies: [
'@react-native-harness/babel-preset',
'vite',
'vitest',
],
},
],
},
Expand Down
52 changes: 52 additions & 0 deletions packages/bundler-metro/src/__tests__/metro-workers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { getCappedMaxWorkers } from '../metro-workers.js';

describe('getCappedMaxWorkers', () => {
it('caps to 1 worker when only 2 host cores are available', () => {
expect(
getCappedMaxWorkers({ configuredMaxWorkers: undefined, hostParallelism: 2 })
).toBe(1);
});

it('caps to 1 worker when only 3 host cores are available', () => {
expect(
getCappedMaxWorkers({ configuredMaxWorkers: undefined, hostParallelism: 3 })
).toBe(1);
});

it('caps to 2 workers when 4 host cores are available', () => {
expect(
getCappedMaxWorkers({ configuredMaxWorkers: undefined, hostParallelism: 4 })
).toBe(2);
});

it('reserves the four cores granted to the device on an 8-core host', () => {
expect(
getCappedMaxWorkers({ configuredMaxWorkers: 6, hostParallelism: 8 })
).toBe(4);
});

it('respects a configured value well below the cap on a large host', () => {
expect(
getCappedMaxWorkers({ configuredMaxWorkers: 10, hostParallelism: 16 })
).toBe(10);
});

it('never lowers a configured value of 1', () => {
expect(
getCappedMaxWorkers({ configuredMaxWorkers: 1, hostParallelism: 8 })
).toBe(1);
});

it('falls back to the cap when maxWorkers is undefined', () => {
expect(
getCappedMaxWorkers({ configuredMaxWorkers: undefined, hostParallelism: 8 })
).toBe(4);
});

it('lowers a configured value that exceeds the cap', () => {
expect(
getCappedMaxWorkers({ configuredMaxWorkers: 8, hostParallelism: 4 })
).toBe(2);
});
});
29 changes: 29 additions & 0 deletions packages/bundler-metro/src/__tests__/withRnHarness.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import os from 'node:os';
import { describe, expect, it, vi } from 'vitest';

type MinimalMetroConfig = {
projectRoot: string;
maxWorkers?: number;
serializer?: {
isThirdPartyModule?: (module: { path: string }) => boolean;
};
Expand Down Expand Up @@ -98,4 +100,31 @@ describe('withRnHarness', () => {
collapse: false,
});
});

it('caps maxWorkers to leave host cores free for the device under test', async () => {
const { withRnHarness } = await import('../withRnHarness.js');
const { getCappedMaxWorkers } = await import('../metro-workers.js');

const config = (await withRnHarness(
{
projectRoot: '/tmp/app',
maxWorkers: 64,
serializer: {},
symbolicator: {
async customizeFrame() {
return {};
},
},
},
true,
)()) as unknown as MinimalMetroConfig;

expect(config.maxWorkers).toBe(
getCappedMaxWorkers({
configuredMaxWorkers: 64,
hostParallelism: os.availableParallelism(),
}),
);
expect(config.maxWorkers).toBeLessThan(64);
});
});
23 changes: 23 additions & 0 deletions packages/bundler-metro/src/metro-workers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getDeviceCoreBudget } from '@react-native-harness/tools';

/**
* Caps Metro's resolved `maxWorkers` so it never exceeds the host
* parallelism minus the cores granted to the device under test. This only
* ever lowers the resolved value -- it never raises it -- so a user's
* explicit, lower `maxWorkers` and Metro's own default on many-core
* machines are both respected.
*/
export const getCappedMaxWorkers = ({
configuredMaxWorkers,
hostParallelism,
}: {
configuredMaxWorkers: number | undefined;
hostParallelism: number;
}): number => {
const cap = Math.max(
1,
hostParallelism - getDeviceCoreBudget(hostParallelism)
);

return Math.min(configuredMaxWorkers ?? cap, cap);
};
20 changes: 20 additions & 0 deletions packages/bundler-metro/src/withRnHarness.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { createRequire } from 'node:module';
import os from 'node:os';
import type { MetroConfig } from 'metro-config';
import { getConfig } from '@react-native-harness/config';
import { logger } from '@react-native-harness/tools';
import { getHarnessBabelTransformerPath } from './babel-transformer.js';
import { getHarnessSerializer } from './getHarnessSerializer.js';
import { getHarnessManifest } from './manifest.js';
import { getHarnessCacheStores } from './metro-cache.js';
import { getCappedMaxWorkers } from './metro-workers.js';
import { getHarnessResolver } from './resolvers/resolver.js';
import type { NotReadOnly } from './utils.js';

const require = createRequire(import.meta.url);
const metroWorkersLogger = logger.child('metro-workers');

const INTERNAL_CALLSITES_REGEX =
/(^|[\\/])(node_modules[/\\]@react-native-harness)([\\/]|$)/;
Expand All @@ -30,9 +34,25 @@ export const withRnHarness = <T extends MetroConfig>(
const harnessBabelTransformerPath =
getHarnessBabelTransformerPath(metroConfig);

const hostParallelism = os.availableParallelism();
const cappedMaxWorkers = getCappedMaxWorkers({
configuredMaxWorkers: metroConfig.maxWorkers,
hostParallelism,
});

if (cappedMaxWorkers !== metroConfig.maxWorkers) {
metroWorkersLogger.debug(
'Capping Metro maxWorkers from %s to %s (host parallelism: %s)',
metroConfig.maxWorkers ?? 'default',
cappedMaxWorkers,
hostParallelism
);
}

const patchedConfig: MetroConfig = {
...metroConfig,
cacheVersion: 'react-native-harness',
maxWorkers: cappedMaxWorkers,
server: {
...metroConfig.server,
forwardClientLogs: harnessConfig.forwardClientLogs ?? false,
Expand Down
Loading