diff --git a/.nx/version-plans/capability-aware-ios-startup.md b/.nx/version-plans/capability-aware-ios-startup.md new file mode 100644 index 0000000..d13857b --- /dev/null +++ b/.nx/version-plans/capability-aware-ios-startup.md @@ -0,0 +1,8 @@ +--- +'@react-native-harness/tools': patch +'@react-native-harness/platform-apple': patch +--- + +Harness now adapts iOS permission-agent startup to the host's available memory +and CPU capacity, preserving build-first startup on constrained machines while +overlapping safe preparation work on capable hosts. diff --git a/packages/bundler-metro/src/__tests__/metro-block-list.test.ts b/packages/bundler-metro/src/__tests__/metro-block-list.test.ts index a4cd027..fe64a86 100644 --- a/packages/bundler-metro/src/__tests__/metro-block-list.test.ts +++ b/packages/bundler-metro/src/__tests__/metro-block-list.test.ts @@ -20,28 +20,46 @@ const withBlockList = ( blockList: NonNullable['blockList'] ): MetroConfig => ({ resolver: { blockList } }) as MetroConfig; +const HARNESS_CACHE_ROOT = '/p/.harness/cache'; + +const getBlockList = ( + blockList: NonNullable['blockList'] +) => getHarnessBlockList(withBlockList(blockList), HARNESS_CACHE_ROOT); + describe('getHarnessBlockList', () => { describe('harness-owned exclusions', () => { it('excludes the cache harness creates under .harness', () => { - const { blockList } = getHarnessBlockList(withBlockList(undefined)); + const { blockList } = getBlockList(undefined); expect(blockList.test('/p/.harness/cache/metro/ab/cdef')).toBe(true); expect(blockList.test('/p/.harness/cache/metro-file-map/map.v1')).toBe( true ); + expect(blockList.test('\\p\\.harness\\cache\\metro\\ab\\cdef')).toBe( + true + ); + }); + + it('only excludes the canonical cache root', () => { + const { blockList } = getBlockList(undefined); + + expect(blockList.test('/other/.harness/cache/metro/ab/cdef')).toBe(false); + expect(blockList.test('/p/.harness/cache-backup/metro/ab/cdef')).toBe( + false + ); }); it('keeps the harness manifest crawlable', () => { // The manifest is injected via `serializer.getPolyfills`, so a module // missing from the file map fails with `Failed to get the SHA-1`. - const { blockList } = getHarnessBlockList(withBlockList(undefined)); + const { blockList } = getBlockList(undefined); expect(blockList.test(getHarnessManifestPath('/p'))).toBe(false); expect(blockList.test('/p/.harness/manifest.js')).toBe(false); }); it('adds nothing else of its own', () => { - const { blockList } = getHarnessBlockList(withBlockList(undefined)); + const { blockList } = getBlockList(undefined); for (const path of [ '/p/.nx/cache/a.js', @@ -60,9 +78,7 @@ describe('getHarnessBlockList', () => { describe('inheriting the project blockList', () => { it('honours a plain project pattern', () => { - const { blockList, dropped } = getHarnessBlockList( - withBlockList(/[/\\]fixtures[/\\]/) - ); + const { blockList, dropped } = getBlockList(/[/\\]fixtures[/\\]/); expect(dropped).toEqual([]); expect(blockList.test('/p/src/fixtures/big.json')).toBe(true); @@ -70,9 +86,10 @@ describe('getHarnessBlockList', () => { }); it('honours an array of project patterns', () => { - const { blockList, dropped } = getHarnessBlockList( - withBlockList([/[/\\]fixtures[/\\]/, /\.snap$/]) - ); + const { blockList, dropped } = getBlockList([ + /[/\\]fixtures[/\\]/, + /\.snap$/, + ]); expect(dropped).toEqual([]); expect(blockList.test('/p/src/fixtures/big.json')).toBe(true); @@ -80,27 +97,21 @@ describe('getHarnessBlockList', () => { }); it('honours an anchored project pattern', () => { - const { blockList } = getHarnessBlockList( - withBlockList(/^\/p\/vendor\//) - ); + const { blockList } = getBlockList(/^\/p\/vendor\//); expect(blockList.test('/p/vendor/lib.js')).toBe(true); expect(blockList.test('/other/p/vendor/lib.js')).toBe(false); }); it('preserves group numbering so backreferences still work', () => { - const { blockList } = getHarnessBlockList( - withBlockList(/([/\\])dup\1/) - ); + const { blockList } = getBlockList(/([/\\])dup\1/); expect(blockList.test('/p/dup/dup')).toBe(true); expect(blockList.test('/p/dup\\dup')).toBe(false); }); it('adopts project flags so a case-insensitive pattern keeps working', () => { - const { blockList, dropped } = getHarnessBlockList( - withBlockList(/[/\\]FIXTURES[/\\]/i) - ); + const { blockList, dropped } = getBlockList(/[/\\]FIXTURES[/\\]/i); expect(dropped).toEqual([]); expect(blockList.flags).toBe('i'); @@ -110,9 +121,11 @@ describe('getHarnessBlockList', () => { it('drops minority-flag patterns instead of letting Metro throw', () => { // Metro's array handling throws when combining mismatched flags. const minority = /[/\\]other[/\\]/i; - const { blockList, dropped } = getHarnessBlockList( - withBlockList([/[/\\]a[/\\]/, /[/\\]b[/\\]/, minority]) - ); + const { blockList, dropped } = getBlockList([ + /[/\\]a[/\\]/, + /[/\\]b[/\\]/, + minority, + ]); expect(dropped).toEqual([ { pattern: minority, reason: 'incompatible-flags' }, @@ -123,9 +136,7 @@ describe('getHarnessBlockList', () => { }); it('is stateless across calls when given a global pattern', () => { - const { blockList } = getHarnessBlockList( - withBlockList(/[/\\]fixtures[/\\]/g) - ); + const { blockList } = getBlockList(/[/\\]fixtures[/\\]/g); expect(blockList.test('/p/src/fixtures/a.json')).toBe(true); expect(blockList.test('/p/src/fixtures/a.json')).toBe(true); @@ -134,9 +145,7 @@ describe('getHarnessBlockList', () => { describe('carving __tests__ out of the inherited blockList', () => { it("keeps tests crawlable under Metro's stock blockList", () => { - const { blockList, dropped } = getHarnessBlockList( - withBlockList(exclusionList()) - ); + const { blockList, dropped } = getBlockList(exclusionList()); expect(dropped).toEqual([]); expect(blockList.test('/p/src/__tests__/smoke.harness.ts')).toBe(false); @@ -145,8 +154,8 @@ describe('getHarnessBlockList', () => { it("keeps a project's own exclusions while still crawling tests", () => { // exclusionList fuses the project's patterns and the __tests__ rule into // a single alternation, so the two cannot be separated by inspection. - const { blockList, dropped } = getHarnessBlockList( - withBlockList(exclusionList([/ios\/build\/.*/])) + const { blockList, dropped } = getBlockList( + exclusionList([/ios\/build\/.*/]) ); expect(dropped).toEqual([]); @@ -156,9 +165,7 @@ describe('getHarnessBlockList', () => { }); it('keeps tests crawlable even inside an otherwise excluded directory', () => { - const { blockList } = getHarnessBlockList( - withBlockList(/[/\\]generated[/\\]/) - ); + const { blockList } = getBlockList(/[/\\]generated[/\\]/); expect(blockList.test('/p/generated/a.js')).toBe(true); expect(blockList.test('/p/generated/__tests__/a.harness.ts')).toBe(false); @@ -183,7 +190,7 @@ describe('getHarnessBlockList', () => { ]; for (const pattern of patterns) { - const { blockList } = getHarnessBlockList(withBlockList(pattern)); + const { blockList } = getBlockList(pattern); for (const path of paths) { const expected = diff --git a/packages/bundler-metro/src/metro-block-list.ts b/packages/bundler-metro/src/metro-block-list.ts index aa73787..5363171 100644 --- a/packages/bundler-metro/src/metro-block-list.ts +++ b/packages/bundler-metro/src/metro-block-list.ts @@ -1,23 +1,14 @@ import type { MetroConfig } from 'metro-config'; +import { escapeRegExp } from '@react-native-harness/tools'; type BlockList = NonNullable['blockList']; /** - * The only directory harness excludes on its own behalf: the cache it creates - * and maintains under `.harness`, which Metro never needs to resolve or read. - * - * Scoped to `.harness/cache` rather than all of `.harness` on purpose -- - * `.harness/manifest.js` is served as a polyfill via - * `serializer.getPolyfills`, and a module that harness injects into the graph - * but that is missing from the file map fails the build with - * `Failed to get the SHA-1 for: `. - * - * Nothing else is added here. Excluding third-party directories (build output, - * tool caches, native dependency trees) is the project's call, expressed - * through `resolver.blockList` in its own `metro.config.js`, which harness - * inherits below. + * Converts the canonical cache root into a cross-platform regular-expression + * source without duplicating knowledge of the cache directory layout. */ -const HARNESS_OWNED_EXCLUSIONS = /[/\\]\.harness[/\\]cache(?:[/\\]|$)/; +const getHarnessCacheRootPatternSource = (harnessCacheRoot: string): string => + escapeRegExp(harnessCacheRoot).replace(/\\\\|\//g, '[/\\\\]'); /** * Paths harness must be able to crawl, carved out of whatever the project @@ -84,7 +75,8 @@ export type BlockListDrop = { * conflicts by dropping a pattern instead of failing the run. */ export const getHarnessBlockList = ( - metroConfig: MetroConfig + metroConfig: MetroConfig, + harnessCacheRoot: string ): { blockList: RegExp; dropped: BlockListDrop[] } => { const dropped: BlockListDrop[] = []; const userPatterns = toPatternArray(metroConfig.resolver?.blockList); @@ -100,7 +92,9 @@ export const getHarnessBlockList = ( const flags = [...flagCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? ''; - const sources = [HARNESS_OWNED_EXCLUSIONS.source]; + const sources = [ + `^${getHarnessCacheRootPatternSource(harnessCacheRoot)}(?:[/\\\\]|$)`, + ]; for (const pattern of userPatterns) { if (stripStatefulFlags(pattern.flags) === flags) { sources.push(carveOutProtectedPaths(pattern.source)); diff --git a/packages/bundler-metro/src/withRnHarness.ts b/packages/bundler-metro/src/withRnHarness.ts index 00e444e..71e4dab 100644 --- a/packages/bundler-metro/src/withRnHarness.ts +++ b/packages/bundler-metro/src/withRnHarness.ts @@ -55,7 +55,7 @@ export const withRnHarness = ( getHarnessBabelTransformerPath(metroConfig); const { blockList: harnessBlockList, dropped: droppedBlockListPatterns } = - getHarnessBlockList(metroConfig); + getHarnessBlockList(metroConfig, harnessCache.paths.root); for (const { pattern } of droppedBlockListPatterns) { metroBlockListLogger.warn( diff --git a/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts b/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts index 16ebd95..a3c53b5 100644 --- a/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts +++ b/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts @@ -5,6 +5,7 @@ import { } from '@react-native-harness/config'; import * as simctl from '../xcrun/simctl.js'; import * as devicectl from '../xcrun/devicectl.js'; +import { logger } from '@react-native-harness/tools'; const mocks = vi.hoisted(() => ({ dispose: vi.fn(async () => undefined), @@ -12,10 +13,17 @@ const mocks = vi.hoisted(() => ({ prepare: vi.fn(async () => undefined), createXCTestAgentController: vi.fn(), })); +const startupStrategyMocks = vi.hoisted(() => ({ + shouldOverlapXCTestBuildAndSimulatorBoot: vi.fn(), +})); vi.mock('../xctest-agent.js', () => ({ createXCTestAgentController: mocks.createXCTestAgentController, })); +vi.mock('../startup-strategy.js', () => ({ + shouldOverlapXCTestBuildAndSimulatorBoot: + startupStrategyMocks.shouldOverlapXCTestBuildAndSimulatorBoot, +})); import { getApplePhysicalDevicePlatformInstance, @@ -33,6 +41,10 @@ const harnessConfigWithPermissionsEnabled = { describe('iOS XCTest agent runner integration', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.prepare.mockResolvedValue(undefined); + startupStrategyMocks.shouldOverlapXCTestBuildAndSimulatorBoot.mockReturnValue( + false, + ); mocks.createXCTestAgentController.mockReturnValue({ prepare: mocks.prepare, ensureStarted: mocks.ensureStarted, @@ -85,7 +97,7 @@ describe('iOS XCTest agent runner integration', () => { }, signal: initSignal, }); - expect(mocks.prepare).not.toHaveBeenCalled(); + expect(mocks.prepare).toHaveBeenCalledTimes(1); expect(mocks.ensureStarted).toHaveBeenCalledTimes(1); expect(mocks.dispose).toHaveBeenCalledTimes(1); }); @@ -170,4 +182,198 @@ describe('iOS XCTest agent runner integration', () => { expect(mocks.createXCTestAgentController).not.toHaveBeenCalled(); expect(mocks.ensureStarted).not.toHaveBeenCalled(); }); + + it('builds the XCTest agent before simulator preparation in sequential mode', async () => { + let resolvePreparation: ((value: undefined) => void) | undefined; + mocks.prepare.mockImplementation( + () => + new Promise((resolve) => { + resolvePreparation = resolve; + }), + ); + const getSimulatorStatus = vi + .spyOn(simctl, 'getSimulatorStatus') + .mockResolvedValue('Booted'); + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined, + ); + + const instancePromise = getAppleSimulatorPlatformInstance( + simulatorConfig, + harnessConfigWithPermissionsEnabled, + { signal: new AbortController().signal }, + ); + + await Promise.resolve(); + expect(mocks.prepare).toHaveBeenCalledTimes(1); + expect(getSimulatorStatus).not.toHaveBeenCalled(); + + resolvePreparation?.(undefined); + const instance = await instancePromise; + + expect(mocks.prepare).toHaveBeenCalledBefore(getSimulatorStatus); + expect(mocks.prepare).toHaveBeenCalledBefore(mocks.ensureStarted); + await instance.dispose(); + }); + + it('overlaps simulator preparation with the XCTest build in parallel mode', async () => { + startupStrategyMocks.shouldOverlapXCTestBuildAndSimulatorBoot.mockReturnValue( + true, + ); + let resolvePreparation: ((value: undefined) => void) | undefined; + mocks.prepare.mockImplementation( + () => + new Promise((resolve) => { + resolvePreparation = resolve; + }), + ); + const getSimulatorStatus = vi + .spyOn(simctl, 'getSimulatorStatus') + .mockResolvedValue('Booted'); + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined, + ); + + const instancePromise = getAppleSimulatorPlatformInstance( + simulatorConfig, + harnessConfigWithPermissionsEnabled, + { signal: new AbortController().signal }, + ); + + await Promise.resolve(); + expect(mocks.prepare).toHaveBeenCalledTimes(1); + expect(getSimulatorStatus).toHaveBeenCalledTimes(1); + + resolvePreparation?.(undefined); + const instance = await instancePromise; + + expect(mocks.prepare).toHaveBeenCalledBefore(mocks.ensureStarted); + await instance.dispose(); + }); + + it('logs the selected XCTest startup strategy at debug level', async () => { + const wasVerbose = logger.isVerbose(); + logger.setVerbose(true); + const debug = vi.spyOn(console, 'debug').mockImplementation(() => undefined); + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined, + ); + + try { + const instance = await getAppleSimulatorPlatformInstance( + simulatorConfig, + harnessConfigWithPermissionsEnabled, + { signal: new AbortController().signal }, + ); + + expect(debug).toHaveBeenCalledWith( + expect.stringContaining('selected sequential XCTest build'), + ); + await instance.dispose(); + } finally { + logger.setVerbose(wasVerbose); + debug.mockRestore(); + } + }); + + it('cleans up after a sequential XCTest build failure', async () => { + const buildError = new Error('build failed'); + mocks.prepare.mockRejectedValue(buildError); + const getSimulatorStatus = vi.spyOn(simctl, 'getSimulatorStatus'); + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + + await expect( + getAppleSimulatorPlatformInstance( + simulatorConfig, + harnessConfigWithPermissionsEnabled, + { signal: new AbortController().signal }, + ), + ).rejects.toThrow(buildError); + + expect(getSimulatorStatus).not.toHaveBeenCalled(); + expect(mocks.dispose).toHaveBeenCalledTimes(1); + }); + + it('waits for and cleans up a parallel XCTest build when simulator preparation fails', async () => { + startupStrategyMocks.shouldOverlapXCTestBuildAndSimulatorBoot.mockReturnValue( + true, + ); + let resolvePreparation: ((value: undefined) => void) | undefined; + mocks.prepare.mockImplementation( + () => + new Promise((resolve) => { + resolvePreparation = resolve; + }), + ); + const simulatorError = new Error('simulator failed'); + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Creating'); + vi.spyOn(simctl, 'bootSimulator').mockResolvedValue(undefined); + vi.spyOn(simctl, 'waitForBoot').mockRejectedValue(simulatorError); + const shutdownSimulator = vi + .spyOn(simctl, 'shutdownSimulator') + .mockResolvedValue(undefined); + + const instancePromise = getAppleSimulatorPlatformInstance( + simulatorConfig, + harnessConfigWithPermissionsEnabled, + { signal: new AbortController().signal }, + ); + + await Promise.resolve(); + expect(shutdownSimulator).not.toHaveBeenCalled(); + resolvePreparation?.(undefined); + + await expect(instancePromise).rejects.toThrow(simulatorError); + expect(mocks.dispose).toHaveBeenCalledTimes(1); + expect(shutdownSimulator).toHaveBeenCalledWith('sim-udid'); + }); + + it('propagates a parallel XCTest build failure and cleans up simulator preparation', async () => { + startupStrategyMocks.shouldOverlapXCTestBuildAndSimulatorBoot.mockReturnValue( + true, + ); + const buildError = new Error('build failed'); + mocks.prepare.mockRejectedValue(buildError); + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined, + ); + const clearHarnessJsLocationOverride = vi + .spyOn(simctl, 'clearHarnessJsLocationOverride') + .mockResolvedValue(undefined); + + await expect( + getAppleSimulatorPlatformInstance( + simulatorConfig, + harnessConfigWithPermissionsEnabled, + { signal: new AbortController().signal }, + ), + ).rejects.toThrow(buildError); + + expect(mocks.dispose).toHaveBeenCalledTimes(1); + expect(clearHarnessJsLocationOverride).toHaveBeenCalledWith( + 'sim-udid', + 'com.harnessplayground', + ); + }); }); + +const simulatorConfig = { + name: 'ios', + device: { + type: 'simulator' as const, + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', +}; diff --git a/packages/platform-ios/src/__tests__/startup-strategy.test.ts b/packages/platform-ios/src/__tests__/startup-strategy.test.ts new file mode 100644 index 0000000..5312504 --- /dev/null +++ b/packages/platform-ios/src/__tests__/startup-strategy.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getHostCapabilities: vi.fn(), +})); + +vi.mock('@react-native-harness/tools', async (importOriginal) => ({ + ...(await importOriginal()), + getHostCapabilities: mocks.getHostCapabilities, +})); + +import { shouldOverlapXCTestBuildAndSimulatorBoot } from '../startup-strategy.js'; + +describe('shouldOverlapXCTestBuildAndSimulatorBoot', () => { + it.each([ + [false, 8 * 1024 ** 3, 7], + [false, 9 * 1024 ** 3, 6], + [true, 9 * 1024 ** 3, 7], + ])( + 'returns %s when memory is %i bytes and CPU count is %i', + (expected, totalMemoryBytes, availableCpuCount) => { + mocks.getHostCapabilities.mockReturnValue({ + totalMemoryBytes, + availableCpuCount, + }); + + expect(shouldOverlapXCTestBuildAndSimulatorBoot()).toBe(expected); + }, + ); +}); diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 227ca48..9b51929 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -31,6 +31,7 @@ import { createIosCrashReporter, getIosProcessNames, } from './crash-reporter.js'; +import { shouldOverlapXCTestBuildAndSimulatorBoot } from './startup-strategy.js'; const iosInstanceLogger = logger.child('ios-instance'); @@ -74,83 +75,116 @@ export const getAppleSimulatorPlatformInstance = async ( throw new DeviceNotFoundError(getDeviceName(config.device)); } - const simulatorStatus = await simctl.getSimulatorStatus(udid); let startedByHarness = false; + let harnessJsLocationOverrideApplied = false; - iosInstanceLogger.debug( - 'resolved iOS simulator %s with status %s', - udid, - simulatorStatus - ); + const xctestAgent = permissionsEnabled + ? createXCTestAgentController({ + appBundleId: config.bundleId, + target: { + kind: 'simulator', + id: udid, + }, + capabilities: [createPermissionPromptAutoAcceptCapability()], + signal: init.signal, + }) + : null; + + const prepareSimulator = async () => { + const simulatorStatus = await simctl.getSimulatorStatus(udid); - if ( - !simctl.isBootedSimulatorStatus(simulatorStatus) && - !simctl.isBootingSimulatorStatus(simulatorStatus) - ) { - logger.info('Booting iOS simulator %s...', config.device.name); iosInstanceLogger.debug( - 'booting iOS simulator %s from status %s', + 'resolved iOS simulator %s with status %s', udid, simulatorStatus ); - await simctl.bootSimulator(udid); - startedByHarness = true; - } - if (simctl.isBootedSimulatorStatus(simulatorStatus)) { - logger.info('Using booted iOS simulator %s...', config.device.name); - } else if (simctl.isBootingSimulatorStatus(simulatorStatus)) { - logger.info( - 'Waiting for iOS simulator %s to finish booting...', - config.device.name + if ( + !simctl.isBootedSimulatorStatus(simulatorStatus) && + !simctl.isBootingSimulatorStatus(simulatorStatus) + ) { + logger.info('Booting iOS simulator %s...', config.device.name); + iosInstanceLogger.debug( + 'booting iOS simulator %s from status %s', + udid, + simulatorStatus + ); + await simctl.bootSimulator(udid); + startedByHarness = true; + } + + if (simctl.isBootedSimulatorStatus(simulatorStatus)) { + logger.info('Using booted iOS simulator %s...', config.device.name); + } else if (simctl.isBootingSimulatorStatus(simulatorStatus)) { + logger.info( + 'Waiting for iOS simulator %s to finish booting...', + config.device.name + ); + } + + if (!simctl.isBootedSimulatorStatus(simulatorStatus)) { + iosInstanceLogger.debug( + 'waiting for iOS simulator %s to finish booting', + udid + ); + await simctl.waitForBoot(udid, init.signal); + } + + const isInstalled = await simctl.isAppInstalled(udid, config.bundleId); + + if (!isInstalled) { + const appPath = getHarnessAppPath(); + await simctl.installApp(udid, appPath); + } + + await simctl.applyHarnessJsLocationOverride( + udid, + config.bundleId, + `localhost:${harnessConfig.metroPort}` ); - } + harnessJsLocationOverrideApplied = true; + }; - if (!simctl.isBootedSimulatorStatus(simulatorStatus)) { + if (xctestAgent) { + const overlapStartup = shouldOverlapXCTestBuildAndSimulatorBoot(); iosInstanceLogger.debug( - 'waiting for iOS simulator %s to finish booting', - udid + 'selected %s XCTest build and simulator startup strategy', + overlapStartup ? 'parallel' : 'sequential' ); - await simctl.waitForBoot(udid, init.signal); - } - - const isInstalled = await simctl.isAppInstalled(udid, config.bundleId); - if (!isInstalled) { - const appPath = getHarnessAppPath(); - await simctl.installApp(udid, appPath); - } - - await simctl.applyHarnessJsLocationOverride( - udid, - config.bundleId, - `localhost:${harnessConfig.metroPort}` - ); + const xctestPreparation = overlapStartup + ? xctestAgent.prepare() + : undefined; + // Observe an early build rejection while preparing the simulator; the + // original promise is still awaited below so its error is propagated. + void xctestPreparation?.catch(() => undefined); - const xctestAgent = permissionsEnabled - ? createXCTestAgentController({ - appBundleId: config.bundleId, - target: { - kind: 'simulator', - id: udid, - }, - capabilities: [createPermissionPromptAutoAcceptCapability()], - signal: init.signal, - }) - : null; + let agentStarted = false; + try { + if (!overlapStartup) { + await xctestAgent.prepare(); + } - let agentStarted = false; - try { - await xctestAgent?.ensureStarted(); - agentStarted = true; - } finally { - if (!agentStarted) { - await xctestAgent?.dispose(); - await simctl.clearHarnessJsLocationOverride(udid, config.bundleId); - if (startedByHarness) { - await simctl.shutdownSimulator(udid); + await prepareSimulator(); + await xctestPreparation; + await xctestAgent.ensureStarted(); + agentStarted = true; + } catch (error) { + await xctestPreparation?.catch(() => undefined); + throw error; + } finally { + if (!agentStarted) { + await xctestAgent.dispose(); + if (harnessJsLocationOverrideApplied) { + await simctl.clearHarnessJsLocationOverride(udid, config.bundleId); + } + if (startedByHarness) { + await simctl.shutdownSimulator(udid); + } } } + } else { + await prepareSimulator(); } return { diff --git a/packages/platform-ios/src/startup-strategy.ts b/packages/platform-ios/src/startup-strategy.ts new file mode 100644 index 0000000..680b34a --- /dev/null +++ b/packages/platform-ios/src/startup-strategy.ts @@ -0,0 +1,13 @@ +import { getHostCapabilities } from '@react-native-harness/tools'; + +const MINIMUM_MEMORY_BYTES_FOR_PARALLEL_STARTUP = 8 * 1024 ** 3; +const MINIMUM_CPU_COUNT_FOR_PARALLEL_STARTUP = 6; + +export const shouldOverlapXCTestBuildAndSimulatorBoot = (): boolean => { + const { totalMemoryBytes, availableCpuCount } = getHostCapabilities(); + + return ( + totalMemoryBytes > MINIMUM_MEMORY_BYTES_FOR_PARALLEL_STARTUP && + availableCpuCount > MINIMUM_CPU_COUNT_FOR_PARALLEL_STARTUP + ); +}; diff --git a/packages/tools/src/__tests__/host-capabilities.test.ts b/packages/tools/src/__tests__/host-capabilities.test.ts new file mode 100644 index 0000000..8b24f3e --- /dev/null +++ b/packages/tools/src/__tests__/host-capabilities.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest'; + +const osMocks = vi.hoisted(() => ({ + availableParallelism: vi.fn(), + totalmem: vi.fn(), +})); + +vi.mock('node:os', () => ({ + default: osMocks, +})); + +import { getHostCapabilities } from '../host-capabilities.js'; + +describe('getHostCapabilities', () => { + it('returns total memory and available CPU parallelism from Node', () => { + osMocks.totalmem.mockReturnValue(16 * 1024 ** 3); + osMocks.availableParallelism.mockReturnValue(10); + + expect(getHostCapabilities()).toEqual({ + totalMemoryBytes: 16 * 1024 ** 3, + availableCpuCount: 10, + }); + }); +}); diff --git a/packages/tools/src/host-capabilities.ts b/packages/tools/src/host-capabilities.ts new file mode 100644 index 0000000..7ef9727 --- /dev/null +++ b/packages/tools/src/host-capabilities.ts @@ -0,0 +1,13 @@ +import os from 'node:os'; + +export type HostCapabilities = { + totalMemoryBytes: number; + availableCpuCount: number; +}; + +export const getHostCapabilities = (): HostCapabilities => { + return { + totalMemoryBytes: os.totalmem(), + availableCpuCount: os.availableParallelism(), + }; +}; diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index c73b7e5..b043be0 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -16,3 +16,4 @@ export * from './regex.js'; export * from './isInteractive.js'; export * from './diagnostics.js'; export * from './device-core-budget.js'; +export * from './host-capabilities.js';