diff --git a/.nx/version-plans/cap-metro-workers-for-device-budget.md b/.nx/version-plans/cap-metro-workers-for-device-budget.md new file mode 100644 index 00000000..6bebc143 --- /dev/null +++ b/.nx/version-plans/cap-metro-workers-for-device-budget.md @@ -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. diff --git a/.nx/version-plans/derive-device-core-budget.md b/.nx/version-plans/derive-device-core-budget.md new file mode 100644 index 00000000..9320591e --- /dev/null +++ b/.nx/version-plans/derive-device-core-budget.md @@ -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. diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index 0264f9d1..e05c4ad4 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -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 @@ -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() { @@ -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)) { @@ -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), @@ -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) => { @@ -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); } @@ -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": @@ -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 }) => { @@ -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 { @@ -4622,7 +4682,7 @@ var getResolvedRunner = (runner) => { }, action: { avdCachingEnabled, - avdCacheConfig: getNormalizedAvdCacheConfig({ + avdCacheConfig: getNormalizedAvdCacheConfig2({ emulator: runner.config.device, hostArch: getHostAndroidSystemImageArch() }) @@ -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 @@ -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); diff --git a/packages/bundler-metro/eslint.config.mjs b/packages/bundler-metro/eslint.config.mjs index 755eba37..fda359eb 100644 --- a/packages/bundler-metro/eslint.config.mjs +++ b/packages/bundler-metro/eslint.config.mjs @@ -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', + ], }, ], }, diff --git a/packages/bundler-metro/src/__tests__/metro-workers.test.ts b/packages/bundler-metro/src/__tests__/metro-workers.test.ts new file mode 100644 index 00000000..0360a2dd --- /dev/null +++ b/packages/bundler-metro/src/__tests__/metro-workers.test.ts @@ -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); + }); +}); diff --git a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts index 25b70557..77f7e3b2 100644 --- a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts +++ b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts @@ -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; }; @@ -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); + }); }); diff --git a/packages/bundler-metro/src/metro-workers.ts b/packages/bundler-metro/src/metro-workers.ts new file mode 100644 index 00000000..5b4ade16 --- /dev/null +++ b/packages/bundler-metro/src/metro-workers.ts @@ -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); +}; diff --git a/packages/bundler-metro/src/withRnHarness.ts b/packages/bundler-metro/src/withRnHarness.ts index d4dfa32e..7ecb4589 100644 --- a/packages/bundler-metro/src/withRnHarness.ts +++ b/packages/bundler-metro/src/withRnHarness.ts @@ -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)([\\/]|$)/; @@ -30,9 +34,25 @@ export const withRnHarness = ( 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, diff --git a/packages/github-action/package.json b/packages/github-action/package.json index fb8c8558..4d2b87c2 100644 --- a/packages/github-action/package.json +++ b/packages/github-action/package.json @@ -11,7 +11,8 @@ "build": "tsup" }, "dependencies": { - "@react-native-harness/config": "workspace:*" + "@react-native-harness/config": "workspace:*", + "@react-native-harness/platform-android": "workspace:*" }, "devDependencies": { "tsup": "^8.5.1" diff --git a/packages/github-action/src/shared/index.ts b/packages/github-action/src/shared/index.ts index 48827834..c1355e70 100644 --- a/packages/github-action/src/shared/index.ts +++ b/packages/github-action/src/shared/index.ts @@ -1,22 +1,11 @@ import { getConfig } from '@react-native-harness/config'; +import { + getEmulatorCpuCores, + getHostAndroidSystemImageArch, +} from '@react-native-harness/platform-android'; import path from 'node:path'; import fs from 'node:fs'; -const getHostAndroidSystemImageArch = (): - | 'x86_64' - | 'arm64-v8a' - | 'armeabi-v7a' => { - switch (process.arch) { - case 'arm64': - return 'arm64-v8a'; - case 'arm': - return 'armeabi-v7a'; - case 'x64': - default: - return 'x86_64'; - } -}; - const resolveAvdCachingEnabled = ({ snapshotEnabled, }: { @@ -57,6 +46,10 @@ const getNormalizedAvdCacheConfig = ({ profile: avd.profile.trim().toLowerCase(), diskSize: avd.diskSize.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(), }; }; diff --git a/packages/github-action/tsconfig.json b/packages/github-action/tsconfig.json index af1b3657..399ec7ae 100644 --- a/packages/github-action/tsconfig.json +++ b/packages/github-action/tsconfig.json @@ -6,6 +6,9 @@ { "path": "../config" }, + { + "path": "../platform-android" + }, { "path": "./tsconfig.lib.json" } diff --git a/packages/github-action/tsconfig.lib.json b/packages/github-action/tsconfig.lib.json index a8b99153..8459915f 100644 --- a/packages/github-action/tsconfig.lib.json +++ b/packages/github-action/tsconfig.lib.json @@ -13,6 +13,9 @@ "references": [ { "path": "../config/tsconfig.lib.json" + }, + { + "path": "../platform-android/tsconfig.lib.json" } ] } diff --git a/packages/platform-android/src/__tests__/avd-config.test.ts b/packages/platform-android/src/__tests__/avd-config.test.ts index 96e3d4cc..49d0cc4a 100644 --- a/packages/platform-android/src/__tests__/avd-config.test.ts +++ b/packages/platform-android/src/__tests__/avd-config.test.ts @@ -6,6 +6,9 @@ import { resolveAvdCachingEnabled, } from '../avd-config.js'; import { AndroidPlatformConfigSchema } from '../config.js'; +import { getEmulatorCpuCores } from '../emulator-startup.js'; + +const emulatorCpuCores = getEmulatorCpuCores(); describe('AVD config helpers', () => { it('parses snapshot config from Android schema', () => { @@ -73,8 +76,11 @@ abi.type=x86_64 hw.device.name=pixel_8 disk.dataPartition.size=1G vm.heapSize=512M +hw.cpu.ncore=${emulatorCpuCores} `); + expect(avdConfig.hwCpuNcore).toBe(String(emulatorCpuCores)); + expect( isAvdCompatible({ emulator: { @@ -93,6 +99,69 @@ vm.heapSize=512M ).toEqual({ compatible: true }); }); + it('rejects AVDs missing hw.cpu.ncore', () => { + const avdConfig = parseAvdConfig(` +image.sysdir.1=system-images/android-35/default/x86_64/ +abi.type=x86_64 +hw.device.name=pixel_8 +disk.dataPartition.size=1G +vm.heapSize=512M +`); + + expect( + isAvdCompatible({ + emulator: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '512M', + }, + }, + avdConfig, + hostArch: 'x86_64', + }) + ).toMatchObject({ + compatible: false, + reason: `CPU core count mismatch: expected ${emulatorCpuCores}, got missing.`, + }); + }); + + it('rejects AVDs with a different hw.cpu.ncore', () => { + const avdConfig = parseAvdConfig(` +image.sysdir.1=system-images/android-35/default/x86_64/ +abi.type=x86_64 +hw.device.name=pixel_8 +disk.dataPartition.size=1G +vm.heapSize=512M +hw.cpu.ncore=${emulatorCpuCores === 4 ? 3 : 4} +`); + + expect( + isAvdCompatible({ + emulator: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '512M', + }, + }, + avdConfig, + hostArch: 'x86_64', + }) + ).toMatchObject({ + compatible: false, + reason: `CPU core count mismatch: expected ${emulatorCpuCores}, got ${ + emulatorCpuCores === 4 ? 3 : 4 + }.`, + }); + }); + it('accepts disk partition sizes rewritten to bytes', () => { const avdConfig = parseAvdConfig(` image.sysdir.1=system-images/android-35/default/x86_64/ @@ -100,6 +169,7 @@ abi.type=x86_64 hw.device.name=pixel_8 disk.dataPartition.size=6442450944 vm.heapSize=512M +hw.cpu.ncore=${emulatorCpuCores} `); expect( diff --git a/packages/platform-android/src/__tests__/emulator-startup.test.ts b/packages/platform-android/src/__tests__/emulator-startup.test.ts index a2a9bcbf..efcb7d12 100644 --- a/packages/platform-android/src/__tests__/emulator-startup.test.ts +++ b/packages/platform-android/src/__tests__/emulator-startup.test.ts @@ -1,5 +1,19 @@ import { describe, expect, it } from 'vitest'; -import { getEmulatorStartupArgs } from '../emulator-startup.js'; +import { + getEmulatorCpuCores, + getEmulatorStartupArgs, + isAccelerationUsable, +} from '../emulator-startup.js'; +import { getDeviceCoreBudget } from '@react-native-harness/tools'; +import os from 'node:os'; + +describe('getEmulatorCpuCores', () => { + it('uses the shared device-core budget for the current host', () => { + expect(getEmulatorCpuCores()).toBe( + getDeviceCoreBudget(os.availableParallelism()) + ); + }); +}); describe('emulator startup modes', () => { it('builds default boot args', () => { @@ -8,17 +22,24 @@ describe('emulator startup modes', () => { '@Pixel_8_API_35', '-no-snapshot-load', '-no-snapshot-save', + '-no-metrics', ]) ); - expect(getEmulatorStartupArgs('Pixel_8_API_35', 'default-boot')).not.toEqual( - expect.arrayContaining(['-camera-back', 'none']) - ); + expect( + getEmulatorStartupArgs('Pixel_8_API_35', 'default-boot') + ).not.toEqual(expect.arrayContaining(['-camera-back', 'none'])); }); it('builds clean snapshot generation args', () => { expect( getEmulatorStartupArgs('Pixel_8_API_35', 'clean-snapshot-generation') - ).toEqual(expect.arrayContaining(['@Pixel_8_API_35', '-no-snapshot-load'])); + ).toEqual( + expect.arrayContaining([ + '@Pixel_8_API_35', + '-no-snapshot-load', + '-no-metrics', + ]) + ); expect( getEmulatorStartupArgs('Pixel_8_API_35', 'clean-snapshot-generation') ).not.toContain('-no-snapshot-save'); @@ -26,10 +47,36 @@ describe('emulator startup modes', () => { it('builds snapshot reuse args', () => { expect(getEmulatorStartupArgs('Pixel_8_API_35', 'snapshot-reuse')).toEqual( - expect.arrayContaining(['@Pixel_8_API_35', '-no-snapshot-save']) + expect.arrayContaining([ + '@Pixel_8_API_35', + '-no-snapshot-save', + '-no-metrics', + ]) ); expect( getEmulatorStartupArgs('Pixel_8_API_35', 'snapshot-reuse') ).not.toContain('-no-snapshot-load'); }); }); + +describe('isAccelerationUsable', () => { + it('returns true when the emulator reports acceleration is usable', () => { + expect( + isAccelerationUsable( + 'accel:\n0\nKVM (version 12) is installed and usable.\naccel\n' + ) + ).toBe(true); + }); + + it('returns false when acceleration is disabled', () => { + expect( + isAccelerationUsable( + 'accel:\n0\nKVM is not installed on this machine.\naccel\n' + ) + ).toBe(false); + }); + + it('returns false for empty output', () => { + expect(isAccelerationUsable('')).toBe(false); + }); +}); diff --git a/packages/platform-android/src/__tests__/instance.test.ts b/packages/platform-android/src/__tests__/instance.test.ts index ac40202b..9ef26089 100644 --- a/packages/platform-android/src/__tests__/instance.test.ts +++ b/packages/platform-android/src/__tests__/instance.test.ts @@ -12,6 +12,8 @@ import * as adb from '../adb.js'; import * as avdConfig from '../avd-config.js'; import * as sharedPrefs from '../shared-prefs.js'; import { HarnessAppPathError, HarnessEmulatorConfigError } from '../errors.js'; +import * as emulatorStartup from '../emulator-startup.js'; +const { getEmulatorCpuCores } = emulatorStartup; const createLogcatProcess = (lines: string[] = []): Subprocess => { const process = { @@ -46,15 +48,19 @@ describe('Android platform instance', () => { vi.useRealTimers(); vi.spyOn( await import('../environment.js'), - 'ensureAndroidEmulatorAvailable', + 'ensureAndroidEmulatorAvailable' ).mockResolvedValue('/tmp/android-sdk'); + vi.spyOn( + emulatorStartup, + 'warnIfEmulatorAccelerationUnavailable' + ).mockResolvedValue(undefined); }); it('reuses a running emulator and does not shut it down on dispose', async () => { const ensureAndroidEmulatorEnvironment = vi .spyOn( await import('../environment.js'), - 'ensureAndroidEmulatorEnvironment', + 'ensureAndroidEmulatorEnvironment' ) .mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); @@ -64,14 +70,12 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); - vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( - '01-01 00:00:00.000', - ); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue('01-01 00:00:00.000'); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( - undefined, + undefined ); vi.spyOn(sharedPrefs, 'clearHarnessDebugHttpHost').mockResolvedValue( - undefined, + undefined ); vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); const stopEmulator = vi.spyOn(adb, 'stopEmulator').mockResolvedValue(); @@ -93,7 +97,7 @@ describe('Android platform instance', () => { activityName: '.MainActivity', }, harnessConfig, - init, + init ); await instance.dispose(); @@ -105,7 +109,7 @@ describe('Android platform instance', () => { it('creates and boots an emulator when missing and shuts it down on dispose', async () => { vi.spyOn( await import('../environment.js'), - 'ensureAndroidEmulatorEnvironment', + 'ensureAndroidEmulatorEnvironment' ).mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); vi.spyOn(adb, 'hasAvd').mockResolvedValue(false); @@ -118,14 +122,12 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); - vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( - '01-01 00:00:00.000', - ); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue('01-01 00:00:00.000'); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( - undefined, + undefined ); vi.spyOn(sharedPrefs, 'clearHarnessDebugHttpHost').mockResolvedValue( - undefined, + undefined ); vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); const stopEmulator = vi.spyOn(adb, 'stopEmulator').mockResolvedValue(); @@ -147,7 +149,7 @@ describe('Android platform instance', () => { activityName: '.MainActivity', }, harnessConfig, - init, + init ); expect(createAvd).toHaveBeenCalledWith({ @@ -168,7 +170,7 @@ describe('Android platform instance', () => { const ensureAndroidEmulatorEnvironment = vi .spyOn( await import('../environment.js'), - 'ensureAndroidEmulatorEnvironment', + 'ensureAndroidEmulatorEnvironment' ) .mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); @@ -182,11 +184,9 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); - vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( - '01-01 00:00:00.000', - ); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue('01-01 00:00:00.000'); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( - undefined, + undefined ); await expect( @@ -207,8 +207,8 @@ describe('Android platform instance', () => { activityName: '.MainActivity', }, harnessConfig, - init, - ), + init + ) ).resolves.toBeDefined(); expect(ensureAndroidEmulatorEnvironment).toHaveBeenCalledWith(35); @@ -220,11 +220,11 @@ describe('Android platform instance', () => { vi.stubEnv('HARNESS_AVD_CACHING', 'true'); vi.spyOn( await import('../environment.js'), - 'getHostAndroidSystemImageArch', + 'getHostAndroidSystemImageArch' ).mockReturnValue('arm64-v8a'); vi.spyOn( await import('../environment.js'), - 'ensureAndroidEmulatorEnvironment', + 'ensureAndroidEmulatorEnvironment' ).mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); vi.spyOn(adb, 'hasAvd').mockResolvedValue(true); @@ -234,6 +234,7 @@ describe('Android platform instance', () => { hwDeviceName: 'pixel_8', diskDataPartitionSize: '1G', vmHeapSize: '1G', + hwCpuNcore: String(getEmulatorCpuCores()), }); vi.spyOn(adb, 'startEmulator').mockResolvedValue(undefined); vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); @@ -241,11 +242,9 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); - vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( - '01-01 00:00:00.000', - ); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue('01-01 00:00:00.000'); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( - undefined, + undefined ); await expect( @@ -267,14 +266,14 @@ describe('Android platform instance', () => { activityName: '.MainActivity', }, harnessConfig, - init, - ), + init + ) ).resolves.toBeDefined(); expect(adb.startEmulator).toHaveBeenCalledTimes(1); expect(adb.startEmulator).toHaveBeenCalledWith( 'Pixel_8_API_35', - 'snapshot-reuse', + 'snapshot-reuse' ); }); @@ -282,7 +281,7 @@ describe('Android platform instance', () => { vi.stubEnv('HARNESS_AVD_CACHING', 'true'); vi.spyOn( await import('../environment.js'), - 'ensureAndroidEmulatorEnvironment', + 'ensureAndroidEmulatorEnvironment' ).mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); vi.spyOn(adb, 'hasAvd').mockResolvedValue(true); @@ -305,11 +304,9 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); - vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( - '01-01 00:00:00.000', - ); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue('01-01 00:00:00.000'); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( - undefined, + undefined ); await expect( @@ -331,8 +328,8 @@ describe('Android platform instance', () => { activityName: '.MainActivity', }, harnessConfig, - init, - ), + init + ) ).resolves.toBeDefined(); expect(deleteAvd).toHaveBeenCalledWith('Pixel_8_API_35'); @@ -340,17 +337,17 @@ describe('Android platform instance', () => { expect(stopEmulator).toHaveBeenCalledWith('emulator-5554'); expect(waitForEmulatorDisconnect).toHaveBeenCalledWith( 'emulator-5554', - init.signal, + init.signal ); expect(adb.startEmulator).toHaveBeenNthCalledWith( 1, 'Pixel_8_API_35', - 'clean-snapshot-generation', + 'clean-snapshot-generation' ); expect(adb.startEmulator).toHaveBeenNthCalledWith( 2, 'Pixel_8_API_35', - 'snapshot-reuse', + 'snapshot-reuse' ); }); @@ -358,7 +355,7 @@ describe('Android platform instance', () => { vi.stubEnv('HARNESS_AVD_CACHING', 'true'); vi.spyOn( await import('../environment.js'), - 'ensureAndroidEmulatorEnvironment', + 'ensureAndroidEmulatorEnvironment' ).mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); vi.spyOn(adb, 'hasAvd').mockResolvedValue(false); @@ -375,11 +372,9 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); - vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( - '01-01 00:00:00.000', - ); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue('01-01 00:00:00.000'); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( - undefined, + undefined ); await expect( @@ -401,24 +396,24 @@ describe('Android platform instance', () => { activityName: '.MainActivity', }, harnessConfig, - init, - ), + init + ) ).resolves.toBeDefined(); expect(startEmulator).toHaveBeenNthCalledWith( 1, 'Pixel_8_API_35', - 'clean-snapshot-generation', + 'clean-snapshot-generation' ); expect(stopEmulator).toHaveBeenCalledWith('emulator-5554'); expect(waitForEmulatorDisconnect).toHaveBeenCalledWith( 'emulator-5554', - init.signal, + init.signal ); expect(startEmulator).toHaveBeenNthCalledWith( 2, 'Pixel_8_API_35', - 'snapshot-reuse', + 'snapshot-reuse' ); }); @@ -446,8 +441,8 @@ describe('Android platform instance', () => { activityName: '.MainActivity', }, harnessConfig, - init, - ), + init + ) ).rejects.toBeInstanceOf(HarnessAppPathError); }); @@ -476,8 +471,8 @@ describe('Android platform instance', () => { activityName: '.MainActivity', }, harnessConfig, - init, - ), + init + ) ).rejects.toBeInstanceOf(HarnessAppPathError); }); @@ -496,8 +491,8 @@ describe('Android platform instance', () => { activityName: '.MainActivity', }, harnessConfig, - init, - ), + init + ) ).rejects.toBeInstanceOf(HarnessEmulatorConfigError); }); @@ -505,7 +500,7 @@ describe('Android platform instance', () => { vi.useFakeTimers(); vi.spyOn( await import('../environment.js'), - 'ensureAndroidEmulatorEnvironment', + 'ensureAndroidEmulatorEnvironment' ).mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); @@ -514,11 +509,9 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); - vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( - '01-01 00:00:00.000', - ); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue('01-01 00:00:00.000'); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( - undefined, + undefined ); const stopApp = vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); const startApp = vi.spyOn(adb, 'startApp').mockResolvedValue(undefined); @@ -544,7 +537,7 @@ describe('Android platform instance', () => { activityName: '.MainActivity', }, harnessConfigWithoutNativeCrashDetection, - init, + init ); const listener = vi.fn(); @@ -563,7 +556,7 @@ describe('Android platform instance', () => { '01-01 00:00:00.000', ]); expect(startLogcat.mock.invocationCallOrder[0]).toBeLessThan( - startApp.mock.invocationCallOrder[0], + startApp.mock.invocationCallOrder[0] ); await expect(appSession.getState()).resolves.toEqual({ status: 'running', @@ -580,7 +573,7 @@ describe('Android platform instance', () => { vi.useFakeTimers(); vi.spyOn( await import('../environment.js'), - 'ensureAndroidEmulatorEnvironment', + 'ensureAndroidEmulatorEnvironment' ).mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); @@ -589,11 +582,9 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); - vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( - '01-01 00:00:00.000', - ); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue('01-01 00:00:00.000'); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( - undefined, + undefined ); vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); vi.spyOn(adb, 'startApp').mockResolvedValue(undefined); @@ -603,7 +594,7 @@ describe('Android platform instance', () => { '--------- beginning of crash', 'Process: com.harnessplayground, PID: 7777', 'FATAL EXCEPTION: main', - ]), + ]) ); const instance = await getAndroidEmulatorPlatformInstance( @@ -623,7 +614,7 @@ describe('Android platform instance', () => { activityName: '.MainActivity', }, harnessConfig, - init, + init ); const listener = vi.fn(); @@ -658,11 +649,9 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); - vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( - '01-01 00:00:00.000', - ); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue('01-01 00:00:00.000'); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( - undefined, + undefined ); vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); vi.spyOn(adb, 'startApp').mockResolvedValue(undefined); @@ -682,7 +671,7 @@ describe('Android platform instance', () => { bundleId: 'com.harnessplayground', activityName: '.MainActivity', }, - harnessConfigWithoutNativeCrashDetection, + harnessConfigWithoutNativeCrashDetection ); const listener = vi.fn(); @@ -712,7 +701,7 @@ describe('Android platform instance', () => { it('grants permissions when permissions are enabled for emulator', async () => { vi.spyOn( await import('../environment.js'), - 'ensureAndroidEmulatorEnvironment', + 'ensureAndroidEmulatorEnvironment' ).mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); @@ -722,7 +711,7 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( - undefined, + undefined ); const grantPermissions = vi .spyOn(adb, 'grantPermissions') @@ -750,19 +739,19 @@ describe('Android platform instance', () => { activityName: '.MainActivity', }, harnessConfigWithPermissions, - init, + init ); expect(grantPermissions).toHaveBeenCalledWith( 'emulator-5554', - 'com.harnessplayground', + 'com.harnessplayground' ); }); it('does not grant permissions when permissions are disabled for emulator', async () => { vi.spyOn( await import('../environment.js'), - 'ensureAndroidEmulatorEnvironment', + 'ensureAndroidEmulatorEnvironment' ).mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); @@ -772,7 +761,7 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( - undefined, + undefined ); const grantPermissions = vi .spyOn(adb, 'grantPermissions') @@ -795,7 +784,7 @@ describe('Android platform instance', () => { activityName: '.MainActivity', }, harnessConfig, - init, + init ); expect(grantPermissions).not.toHaveBeenCalled(); @@ -812,7 +801,7 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( - undefined, + undefined ); const grantPermissions = vi .spyOn(adb, 'grantPermissions') @@ -834,12 +823,12 @@ describe('Android platform instance', () => { bundleId: 'com.harnessplayground', activityName: '.MainActivity', }, - harnessConfigWithPermissions, + harnessConfigWithPermissions ); expect(grantPermissions).toHaveBeenCalledWith( '012345', - 'com.harnessplayground', + 'com.harnessplayground' ); }); }); diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 7c433b38..fde8644e 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -25,6 +25,7 @@ import { getRequiredAndroidSdkPackages, } from './environment.js'; import { + getEmulatorCpuCores, getEmulatorStartupArgs, type EmulatorBootMode, } from './emulator-startup.js'; @@ -50,14 +51,14 @@ const waitForAbort = (signal: AbortSignal): Promise => { () => { reject(signal.reason); }, - { once: true }, + { once: true } ); }); }; const waitWithSignal = async ( ms: number, - signal: AbortSignal, + signal: AbortSignal ): Promise => { if (signal.aborted) { throw signal.reason; @@ -73,7 +74,7 @@ const androidAdbLogger = logger.child('android-adb'); export const emulatorProcess = { startDetachedProcess: ( file: string, - args: readonly string[], + args: readonly string[] ): ChildProcessByStdio => nodeSpawn(file, args, { detached: true, @@ -84,7 +85,7 @@ export const emulatorProcess = { const appendBoundedOutput = ( output: string, chunk: string, - limit: number = EMULATOR_OUTPUT_BUFFER_LIMIT, + limit: number = EMULATOR_OUTPUT_BUFFER_LIMIT ): string => { const nextOutput = output + chunk; @@ -165,7 +166,7 @@ export const getRequiredEmulatorPackages = (apiLevel: number): string[] => { }; export const verifyAndroidEmulatorSdk = async ( - apiLevel: number, + apiLevel: number ): Promise => { await ensureAndroidSdkPackages(getRequiredEmulatorPackages(apiLevel)); }; @@ -192,7 +193,7 @@ const ensureAvdProfileAvailable = async (profile: string): Promise => { : 'None reported by avdmanager.'; throw new Error( - `Android AVD profile "${profile}" is not available on this machine. Available profiles: ${availableProfilesList}`, + `Android AVD profile "${profile}" is not available on this machine. Available profiles: ${availableProfilesList}` ); }; @@ -201,7 +202,7 @@ const ensureAvdConfigExists = async (name: string): Promise => { if ((await readAvdConfig(name)) == null) { throw new Error( - `Android AVD "${name}" was created, but config.ini was not found at ${configPath}.`, + `Android AVD "${name}" was created, but config.ini was not found at ${configPath}.` ); } @@ -235,7 +236,7 @@ const ensureAvdIniExists = async ({ export const getStartAppArgs = ( bundleId: string, activityName: string, - options?: AndroidAppLaunchOptions, + options?: AndroidAppLaunchOptions ): string[] => { const args = [ 'shell', @@ -264,7 +265,7 @@ export const getStartAppArgs = ( if (!Number.isSafeInteger(value)) { throw new Error( - `Android app launch option "${key}" must be a safe integer.`, + `Android app launch option "${key}" must be a safe integer.` ); } @@ -276,7 +277,7 @@ export const getStartAppArgs = ( export const isAppInstalled = async ( adbId: string, - bundleId: string, + bundleId: string ): Promise => { const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', @@ -293,7 +294,7 @@ export const isAppInstalled = async ( export const reversePort = async ( adbId: string, port: number, - hostPort: number = port, + hostPort: number = port ): Promise => { await spawn(getAdbBinaryPath(), [ '-s', @@ -306,7 +307,7 @@ export const reversePort = async ( export const stopApp = async ( adbId: string, - bundleId: string, + bundleId: string ): Promise => { await spawn(getAdbBinaryPath(), [ '-s', @@ -322,7 +323,7 @@ export const startApp = async ( adbId: string, bundleId: string, activityName: string, - options?: AndroidAppLaunchOptions, + options?: AndroidAppLaunchOptions ): Promise => { await spawn(getAdbBinaryPath(), [ '-s', @@ -341,7 +342,7 @@ export const getDeviceIds = async (): Promise => { }; export const getEmulatorName = async ( - adbId: string, + adbId: string ): Promise => { const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', @@ -355,7 +356,7 @@ export const getEmulatorName = async ( export const getShellProperty = async ( adbId: string, - property: string, + property: string ): Promise => { const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', @@ -377,7 +378,7 @@ export type DeviceInfo = { }; export const getDeviceInfo = async ( - adbId: string, + adbId: string ): Promise => { const manufacturer = await getShellProperty(adbId, 'ro.product.manufacturer'); const model = await getShellProperty(adbId, 'ro.product.model'); @@ -386,7 +387,7 @@ export const getDeviceInfo = async ( const getRequestedPermissions = async ( adbId: string, - bundleId: string, + bundleId: string ): Promise => { const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', @@ -476,14 +477,14 @@ export const stopEmulator = async (adbId: string): Promise => { export const installApp = async ( adbId: string, - appPath: string, + appPath: string ): Promise => { await spawn(getAdbBinaryPath(), ['-s', adbId, 'install', '-r', appPath]); }; export const uninstallApp = async ( adbId: string, - bundleId: string, + bundleId: string ): Promise => { await spawn(getAdbBinaryPath(), ['-s', adbId, 'uninstall', bundleId]); }; @@ -502,20 +503,27 @@ export const createAvd = async ({ }: CreateAvdOptions): Promise => { const systemImagePackage = getAndroidSystemImagePackage( apiLevel, - getHostAndroidSystemImageArch(), + getHostAndroidSystemImageArch() ); await verifyAndroidEmulatorSdk(apiLevel); await ensureAvdProfileAvailable(profile); await spawn('bash', [ '-lc', - `printf 'no\n' | "${getAvdManagerBinaryPath()}" create avd --force --name "${name}" --package "${systemImagePackage}" --device "${profile}" -p "${getAvdDirectory(name)}"`, + `printf 'no\n' | "${getAvdManagerBinaryPath()}" create avd --force --name "${name}" --package "${systemImagePackage}" --device "${profile}" -p "${getAvdDirectory( + name + )}"`, ]); await ensureAvdIniExists({ name, apiLevel }); const configPath = await ensureAvdConfigExists(name); + // hw.cpu.ncore is baked into config.ini (rather than passed as a `-cores` + // boot flag) so it's part of the AVD's persisted hardware profile: boot + // snapshots require an identical hardware config to load, so the vCPU + // count must be fixed at AVD-creation time and stay consistent across + // boots. await spawn('bash', [ '-lc', - `printf '%s\n%s\n' 'disk.dataPartition.size=${diskSize}' 'vm.heapSize=${heapSize}' >> "${configPath}"`, + `printf '%s\n%s\n%s\n' 'disk.dataPartition.size=${diskSize}' 'vm.heapSize=${heapSize}' 'hw.cpu.ncore=${getEmulatorCpuCores()}' >> "${configPath}"`, ]); }; @@ -527,7 +535,7 @@ export const deleteAvd = async (name: string): Promise => { { force: true, recursive: true, - }, + } ); await rm( `${ @@ -535,18 +543,18 @@ export const deleteAvd = async (name: string): Promise => { }/${name}.ini`, { force: true, - }, + } ); }; export const startEmulator = async ( name: string, - mode: EmulatorBootMode = 'default-boot', + mode: EmulatorBootMode = 'default-boot' ): Promise => { const emulatorBinaryPath = await ensureEmulatorInstalled(); const childProcess = emulatorProcess.startDetachedProcess( emulatorBinaryPath, - getEmulatorStartupArgs(name, mode), + getEmulatorStartupArgs(name, mode) ); let stdout = ''; @@ -582,7 +590,7 @@ export const startEmulator = async ( stdout, stderr, error, - }), + }) ); }); @@ -594,7 +602,7 @@ export const startEmulator = async ( stderr, exitCode, signal, - }), + }) ); }); }); @@ -610,7 +618,7 @@ export const startEmulator = async ( }); const observationTimeout = wait(EMULATOR_STARTUP_OBSERVATION_TIMEOUT_MS).then( - () => 'timeout' as const, + () => 'timeout' as const ); try { @@ -626,7 +634,7 @@ export const startEmulator = async ( export const waitForEmulator = async ( name: string, - signal: AbortSignal, + signal: AbortSignal ): Promise => { while (!signal.aborted) { const adbIds = await getDeviceIds(); @@ -651,7 +659,7 @@ export const waitForEmulator = async ( export const waitForEmulatorDisconnect = async ( adbId: string, - signal: AbortSignal, + signal: AbortSignal ): Promise => { while (!signal.aborted) { const adbIds = await getDeviceIds(); @@ -668,7 +676,7 @@ export const waitForEmulatorDisconnect = async ( export const waitForBoot = async ( name: string, - signal: AbortSignal, + signal: AbortSignal ): Promise => { while (!signal.aborted) { const adbIds = await getDeviceIds(); @@ -697,14 +705,14 @@ export const waitForBoot = async ( export const isAppRunning = async ( adbId: string, - bundleId: string, + bundleId: string ): Promise => { return (await getAppPid(adbId, bundleId)) != null; }; export const getAppPid = async ( adbId: string, - bundleId: string, + bundleId: string ): Promise => { try { const { stdout } = await spawn(getAdbBinaryPath(), [ @@ -731,7 +739,7 @@ export const getAppPid = async ( export const getAppUid = async ( adbId: string, - bundleId: string, + bundleId: string ): Promise => { const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', @@ -756,7 +764,7 @@ export const getAppUid = async ( export const setHideErrorDialogs = async ( adbId: string, - hide: boolean, + hide: boolean ): Promise => { await spawn(getAdbBinaryPath(), [ '-s', @@ -784,7 +792,7 @@ export const getLogcatTimestamp = async (adbId: string): Promise => { export const startLogcat = ( adbId: string, - args: readonly string[], + args: readonly string[] ): Subprocess => spawn(getAdbBinaryPath(), ['-s', adbId, ...args], { stdout: 'pipe', @@ -798,7 +806,7 @@ export const DROPBOX_CRASH_TAGS = [ export const getDropboxPrint = async ( adbId: string, - tags: readonly string[] = DROPBOX_CRASH_TAGS, + tags: readonly string[] = DROPBOX_CRASH_TAGS ): Promise => { // Android treats multiple args after --print as one search string, so each // tag must be queried separately and merged on the host. @@ -815,7 +823,7 @@ export const getDropboxPrint = async ( ]); return stdout; - }), + }) ); return outputs.join('\n'); @@ -823,7 +831,7 @@ export const getDropboxPrint = async ( export const getActivityExitInfo = async ( adbId: string, - bundleId: string, + bundleId: string ): Promise => { const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', @@ -891,7 +899,7 @@ export const getConnectedDevices = async (): Promise => { export const grantPermissions = async ( adbId: string, - bundleId: string, + bundleId: string ): Promise => { androidAdbLogger.debug('grantPermissions:start %o', { adbId, @@ -908,7 +916,7 @@ export const grantPermissions = async ( getDangerousPermissions(adbId), ]); const permissions = requestedPermissions.filter((permission) => - dangerousPermissions.has(permission), + dangerousPermissions.has(permission) ); androidAdbLogger.debug('grantPermissions:resolved %o', { @@ -944,7 +952,7 @@ export const grantPermissions = async ( }); await Promise.all( - grantCommands.map((args) => spawn(getAdbBinaryPath(), args as string[])), + grantCommands.map((args) => spawn(getAdbBinaryPath(), args as string[])) ); androidAdbLogger.debug('grantPermissions:success %o', { diff --git a/packages/platform-android/src/avd-config.ts b/packages/platform-android/src/avd-config.ts index 97429f91..ac54fa6e 100644 --- a/packages/platform-android/src/avd-config.ts +++ b/packages/platform-android/src/avd-config.ts @@ -1,6 +1,7 @@ import { access, readFile } from 'node:fs/promises'; import type { AndroidSystemImageArch } from './environment.js'; import type { AndroidEmulator, AndroidEmulatorAVDConfig } from './config.js'; +import { getEmulatorCpuCores } from './emulator-startup.js'; export type AvdConfig = { imageSysdir1?: string; @@ -8,6 +9,7 @@ export type AvdConfig = { hwDeviceName?: string; diskDataPartitionSize?: string; vmHeapSize?: string; + hwCpuNcore?: string; }; export type AvdCompatibilityResult = @@ -123,6 +125,9 @@ export const parseAvdConfig = (contents: string): AvdConfig => { case 'vm.heapSize': config.vmHeapSize = value; break; + case 'hw.cpu.ncore': + config.hwCpuNcore = value; + break; default: break; } @@ -235,6 +240,18 @@ export const isAvdCompatible = ({ }; } + if ( + normalizeConfigValue(avdConfig.hwCpuNcore ?? '') !== + normalizeConfigValue(String(getEmulatorCpuCores())) + ) { + return { + compatible: false, + reason: `CPU core count mismatch: expected ${getEmulatorCpuCores()}, got ${ + avdConfig.hwCpuNcore ?? 'missing' + }.`, + }; + } + return { compatible: true }; }; diff --git a/packages/platform-android/src/emulator-startup.ts b/packages/platform-android/src/emulator-startup.ts index 2611084c..d8dd1ddf 100644 --- a/packages/platform-android/src/emulator-startup.ts +++ b/packages/platform-android/src/emulator-startup.ts @@ -1,14 +1,37 @@ +import { + getDeviceCoreBudget, + logger, + spawn, + SubprocessError, +} from '@react-native-harness/tools'; +import os from 'node:os'; +import { getEmulatorBinaryPath } from './environment.js'; + +const emulatorStartupLogger = logger.child('android-emulator-startup'); + export type EmulatorBootMode = | 'default-boot' | 'clean-snapshot-generation' | 'snapshot-reuse'; +/** + * Returns the vCPU count baked into an AVD for this runner. + * + * AVD snapshots require an identical hardware configuration to load, so all + * AVD creation, compatibility, and cache-key callers must use this value on + * the same host. + */ +export const getEmulatorCpuCores = (): number => { + return getDeviceCoreBudget(os.availableParallelism()); +}; + const COMMON_EMULATOR_ARGS = [ '-no-window', '-gpu', 'swiftshader_indirect', '-noaudio', '-no-boot-anim', + '-no-metrics', ] as const; export const getEmulatorStartupArgs = ( @@ -24,3 +47,54 @@ export const getEmulatorStartupArgs = ( return [`@${name}`, ...modeArgs, ...COMMON_EMULATOR_ARGS]; }; + +/** + * Parses the output of `emulator -accel-check` to determine whether + * hardware acceleration (KVM on Linux, HVF on macOS) is usable. The + * emulator prints a message containing "is installed and usable" when + * acceleration is available. + */ +export const isAccelerationUsable = (accelCheckOutput: string): boolean => { + return accelCheckOutput.includes('is installed and usable'); +}; + +/** + * Runs `emulator -accel-check` and logs a non-fatal warning when hardware + * acceleration (KVM/HVF) appears unavailable, in which case the emulator + * falls back to very slow software emulation. This check must never fail or + * block emulator startup - any error running it is swallowed and only + * debug-logged. + */ +export const warnIfEmulatorAccelerationUnavailable = + async (): Promise => { + let combinedOutput: string; + + try { + const emulatorBinaryPath = getEmulatorBinaryPath(); + const { stdout, stderr } = await spawn(emulatorBinaryPath, [ + '-accel-check', + ]); + combinedOutput = `${stdout}\n${stderr}`; + } catch (error) { + if (error instanceof SubprocessError) { + // The check itself ran but exited non-zero - this is how + // `-accel-check` reports that acceleration is unavailable, so treat + // its output as a normal (non-fatal) result rather than swallowing it. + combinedOutput = `${error.stdout}\n${error.stderr}`; + } else { + // Failed to even run the check (e.g. binary missing). This must never + // fail or block emulator startup. + emulatorStartupLogger.debug( + 'Failed to run "emulator -accel-check": %o', + error + ); + return; + } + } + + if (!isAccelerationUsable(combinedOutput)) { + logger.warn( + 'Hardware acceleration (KVM/HVF) appears unavailable for the Android emulator. It will fall back to very slow software emulation.' + ); + } + }; diff --git a/packages/platform-android/src/index.ts b/packages/platform-android/src/index.ts index 1c750eec..b1781d22 100644 --- a/packages/platform-android/src/index.ts +++ b/packages/platform-android/src/index.ts @@ -9,5 +9,6 @@ export { resolveAvdCachingEnabled, } from './avd-config.js'; export { getHostAndroidSystemImageArch } from './environment.js'; +export { getEmulatorCpuCores } from './emulator-startup.js'; export { HarnessAppPathError, HarnessEmulatorConfigError } from './errors.js'; export { getRunTargets } from './targets.js'; diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index 9539a49c..9e2fa362 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -29,6 +29,7 @@ import { ensureAndroidEmulatorEnvironment, getHostAndroidSystemImageArch, } from './environment.js'; +import { warnIfEmulatorAccelerationUnavailable } from './emulator-startup.js'; import { isInteractive } from '@react-native-harness/tools'; import fs from 'node:fs'; import { createAndroidAppSession } from './app-session.js'; @@ -90,6 +91,7 @@ const startAndWaitForBoot = async ({ mode?: Parameters[1]; }): Promise => { await ensureAndroidEmulatorAvailable(); + await warnIfEmulatorAccelerationUnavailable(); await adbModule.startEmulator(emulatorName, mode); return adbModule.waitForBoot(emulatorName, signal); }; diff --git a/packages/tools/src/__tests__/device-core-budget.test.ts b/packages/tools/src/__tests__/device-core-budget.test.ts new file mode 100644 index 00000000..6c83c183 --- /dev/null +++ b/packages/tools/src/__tests__/device-core-budget.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { + getDeviceCoreBudget, + MAX_DEVICE_CORE_BUDGET, + MIN_DEVICE_CORE_BUDGET, +} from '../device-core-budget.js'; + +describe('getDeviceCoreBudget', () => { + it('documents the supported device-core range', () => { + expect(MIN_DEVICE_CORE_BUDGET).toBe(2); + expect(MAX_DEVICE_CORE_BUDGET).toBe(4); + }); + + it.each([ + [1, 2], + [2, 2], + [3, 2], + [4, 2], + [7, 3], + [8, 4], + [32, 4], + ])('allocates %i host cores as a %i-core device budget', (host, expected) => { + expect(getDeviceCoreBudget(host)).toBe(expected); + }); +}); diff --git a/packages/tools/src/device-core-budget.ts b/packages/tools/src/device-core-budget.ts new file mode 100644 index 00000000..5feda512 --- /dev/null +++ b/packages/tools/src/device-core-budget.ts @@ -0,0 +1,20 @@ +/** + * Smallest viable vCPU allocation for a guest device (2 cores). + */ +export const MIN_DEVICE_CORE_BUDGET = 2; + +/** + * The emulator's useful vCPU ceiling (4 cores); allocating more does not + * improve it. + */ +export const MAX_DEVICE_CORE_BUDGET = 4; + +/** + * Derives the vCPU budget for a guest device from host parallelism. + */ +export const getDeviceCoreBudget = (hostParallelism: number): number => { + return Math.min( + Math.max(Math.floor(hostParallelism / 2), MIN_DEVICE_CORE_BUDGET), + MAX_DEVICE_CORE_BUDGET + ); +}; diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 8f2b96f1..41528a22 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -14,3 +14,4 @@ export * from './harness-artifacts.js'; export * from './regex.js'; export * from './isInteractive.js'; export * from './diagnostics.js'; +export * from './device-core-budget.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15851ab8..cacf6e37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,6 +362,9 @@ importers: '@react-native-harness/config': specifier: workspace:* version: link:../config + '@react-native-harness/platform-android': + specifier: workspace:* + version: link:../platform-android devDependencies: tsup: specifier: ^8.5.1