From 4871976bb7fe6bf0d380d36652025699821e0350 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 10 Oct 2026 09:42:19 -0700 Subject: [PATCH 1/4] [build-tools] Add serve-sim Simulator startup helpers Co-Authored-By: GPT-6.1-Sol --- CHANGELOG.md | 2 + .../functions/__tests__/installBuild.test.ts | 22 ++- .../__tests__/startIosSimulator.test.ts | 163 ++++++++++++++++- .../src/steps/functions/installBuild.ts | 75 +++++--- .../src/steps/functions/startIosSimulator.ts | 171 +++++++++++++++--- .../utils/__tests__/localEgressGuard.test.ts | 90 +++++++++ .../utils/__tests__/serveSimActions.test.ts | 146 +++++++++++++++ .../src/steps/utils/localEgressGuard.ts | 124 ++++++++----- .../src/steps/utils/remoteDeviceRunSession.ts | 47 +++++ .../src/steps/utils/serveSimActions.ts | 101 +++++++++++ 10 files changed, 846 insertions(+), 95 deletions(-) create mode 100644 packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts create mode 100644 packages/build-tools/src/steps/utils/serveSimActions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d0361b9213..9d8509ed56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,8 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Install ffmpeg for Appium simulator sessions. ([#4577](https://github.com/expo/eas-cli/pull/4577) by [@gwdp](https://github.com/gwdp)) - [build-tools] Close the file of an artifact upload before reporting that the upload is done. ([#4609](https://github.com/expo/eas-cli/pull/4609) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR)) +- [build-tools] Add shared opt-in serve-sim Simulator startup support. ([#4605](https://github.com/expo/eas-cli/pull/4605) by [@gwdp](https://github.com/gwdp)) + ## [24.12.1](https://github.com/expo/eas-cli/releases/tag/v24.12.1) - 2026-10-08 ### 🐛 Bug fixes diff --git a/packages/build-tools/src/steps/functions/__tests__/installBuild.test.ts b/packages/build-tools/src/steps/functions/__tests__/installBuild.test.ts index 5af166fc3b..21b01e2644 100644 --- a/packages/build-tools/src/steps/functions/__tests__/installBuild.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/installBuild.test.ts @@ -6,7 +6,11 @@ import path from 'node:path'; import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { createMockLogger } from '../../../__tests__/utils/logger'; -import { createInstallBuildFunction, installBuildAsync } from '../installBuild'; +import { + createInstallBuildFunction, + installBuildAsync, + readIosApplicationIdentifierAsync, +} from '../installBuild'; jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, @@ -91,6 +95,22 @@ describe(installBuildAsync, () => { ]); }); + it('reads iOS metadata without installing the application', async () => { + const artifactPath = path.join(await makeTemporaryDirectoryAsync(), 'Example.app'); + await fs.promises.mkdir(artifactPath); + mockedSpawn.mockResolvedValueOnce({ stdout: 'com.example.app\n', stderr: '' } as any); + await expect(readIosApplicationIdentifierAsync({ artifactPath, env: {} })).resolves.toBe( + 'com.example.app' + ); + expect(mockedSpawn.mock.calls).toEqual([ + [ + 'plutil', + ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')], + { stdio: 'pipe', env: {} }, + ], + ]); + }); + it.each([ [BuildRuntimePlatform.DARWIN, 'example.ipa'], [BuildRuntimePlatform.LINUX, 'example.aab'], diff --git a/packages/build-tools/src/steps/functions/__tests__/startIosSimulator.test.ts b/packages/build-tools/src/steps/functions/__tests__/startIosSimulator.test.ts index 200e3a8348..ae517b3648 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startIosSimulator.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startIosSimulator.test.ts @@ -1,15 +1,26 @@ +import { BuildRuntimePlatform } from '@expo/steps'; import spawn from '@expo/turtle-spawn'; import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { createMockLogger } from '../../../__tests__/utils/logger'; -import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils'; +import { IosSimulatorUtils, type IosSimulatorUuid } from '../../../utils/IosSimulatorUtils'; import { configureSimulatorProxyEnvironmentAsync } from '../../utils/localEgress'; import { installLocalEgressGuardAsync, resolveLocalEgressBootEnvironmentAsync, + resolveLocalEgressServeSimBootEnvironmentAsync, + startLocalEgressGuardRelayAsync, verifyLocalEgressGuardAsync, } from '../../utils/localEgressGuard'; -import { createStartIosSimulatorBuildFunction } from '../startIosSimulator'; +import { + createStartIosSimulatorBuildFunction, + disableIosSimulatorPushAsync, + resolveIosSessionStartupAsync, + resolveIosSimulatorUdidAsync, +} from '../startIosSimulator'; + +import { readIosApplicationIdentifierAsync } from '../installBuild'; +jest.mock('../installBuild', () => ({ readIosApplicationIdentifierAsync: jest.fn() })); jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, @@ -37,6 +48,8 @@ jest.mock('../../utils/localEgressGuard', () => ({ installLocalEgressGuardAsync: jest.fn(), resolveLocalEgressBootEnvironmentAsync: jest.fn(), verifyLocalEgressGuardAsync: jest.fn(), + resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(), + startLocalEgressGuardRelayAsync: jest.fn(), })); const mockedSpawn = jest.mocked(spawn); @@ -103,6 +116,33 @@ describe(createStartIosSimulatorBuildFunction, () => { }); }); + it('resolves a requested Simulator without booting or configuring it', async () => { + await expect( + resolveIosSimulatorUdidAsync({ deviceIdentifier: 'iPhone 15' as never, env: {} }) + ).resolves.toBe('BASE'); + + expect(mockedUtils.bootAsync).not.toHaveBeenCalled(); + expect(mockedUtils.startAsync).not.toHaveBeenCalled(); + expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled(); + expect(mockedInstallGuard).not.toHaveBeenCalled(); + }); + + it('selects the same default iPhone without booting', async () => { + mockedUtils.getAvailableDevicesAsync.mockResolvedValue([ + { name: 'iPad', udid: 'ipad' }, + { name: 'iPhone 15 Pro', udid: 'pro' }, + { name: 'iPhone 15', udid: 'base' }, + ] as never); + + await expect(resolveIosSimulatorUdidAsync({ env: {} })).resolves.toBe('BASE'); + expect(mockedUtils.bootAsync).not.toHaveBeenCalled(); + + mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]); + await expect(resolveIosSimulatorUdidAsync({ env: {} })).rejects.toThrow( + 'Could not find an iPhone' + ); + }); + it('enables accessibility settings before starting the main device and every clone when requested', async () => { await createStep({ device_identifier: 'iPhone 15', @@ -234,3 +274,122 @@ describe(createStartIosSimulatorBuildFunction, () => { expect(mockedUtils.waitForReadyAsync).toHaveBeenCalled(); }); }); + +describe('serve-sim-owned boot preparation', () => { + it('configures push without repeating the legacy readiness wait', async () => { + jest.clearAllMocks(); + mockedUtils.disableApsdAsync.mockResolvedValue(undefined); + await disableIosSimulatorPushAsync({ + udid: 'base' as IosSimulatorUuid, + env: {}, + logger: createMockLogger(), + }); + expect(IosSimulatorUtils.disableApsdAsync).toHaveBeenCalledWith({ udid: 'base', env: {} }); + expect(IosSimulatorUtils.waitForReadyAsync).not.toHaveBeenCalled(); + }); +}); + +describe('serve-sim Simulator preparation', () => { + const hostOptions = { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: {}, + logger: createMockLogger(), + }; + beforeEach(() => { + jest.clearAllMocks(); + mockedUtils.getAvailableDevicesAsync.mockResolvedValue([ + { + udid: 'selected-udid', + name: 'iPhone 17', + isAvailable: true, + deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-17', + }, + ] as never); + mockedUtils.resolveUdidAsync.mockResolvedValue('selected-udid' as IosSimulatorUuid); + jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null); + jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app'); + }); + it.each([ + ['argument count', Array(257).fill('a')], + ['argument length', ['a'.repeat(9000)]], + ['NUL argument', ['a\u0000b']], + ['total UTF-8 bytes', Array(8).fill('😃'.repeat(4096))], + ])('rejects invalid launch %s before Simulator preparation', async (_, launchArgs) => { + await expect( + resolveIosSessionStartupAsync({ + ...hostOptions, + bootSimulator: true, + launchAppIdentifier: 'dev.example.app', + launchArgs, + }) + ).rejects.toThrow('iOS launch_args'); + expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled(); + expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled(); + expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled(); + }); + + it('keeps an explicit launch identifier independent of the installed app', async () => { + const startup = await resolveIosSessionStartupAsync({ + ...hostOptions, + installAppPath: '/tmp/App.app', + launchAppIdentifier: 'another.installed.app', + }); + expect(startup.launch.launchAppIdentifier).toBe('another.installed.app'); + expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled(); + }); + + it('does no Simulator work on Android and rejects iOS installation inputs', async () => { + const options = { ...hostOptions, runtimePlatform: BuildRuntimePlatform.LINUX }; + await expect(resolveIosSessionStartupAsync(options)).resolves.toEqual({ + launch: { launchArgs: [], launchAppIdentifier: undefined, openUrl: undefined }, + }); + await expect( + resolveIosSessionStartupAsync({ ...options, installAppPath: '/tmp/App.app' }) + ).rejects.toThrow('only supported for iOS'); + expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled(); + }); + + it('preserves older workflows that already prepared the Simulator and app', async () => { + const startup = await resolveIosSessionStartupAsync({ + ...hostOptions, + launchAppIdentifier: 'previously.installed.app', + }); + expect(startup.iosStartup).toBeUndefined(); + expect(startup.launch.launchAppIdentifier).toBe('previously.installed.app'); + expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled(); + expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('boots a session without an app when the workflow requests owned startup', async () => { + const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true }); + expect(startup.iosStartup?.iosSimulatorUdid).toBe('SELECTED-UDID'); + expect(startup.launch.launchAppIdentifier).toBeUndefined(); + }); + + it.each(['Booted', 'Booting', 'Shutdown'])( + 'prepares guarded startup on a %s device', + async state => { + const bootEnv = { SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' }; + jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv); + mockedUtils.getDeviceAsync.mockResolvedValue({ state } as never); + const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true }); + expect(startup.iosStartup).toEqual({ + iosSimulatorUdid: 'SELECTED-UDID', + bootEnv, + installAppPath: undefined, + }); + if (state === 'Shutdown') { + expect(spawn).not.toHaveBeenCalled(); + } else { + expect(spawn).toHaveBeenCalledWith( + 'xcrun', + ['simctl', 'shutdown', 'SELECTED-UDID'], + expect.anything() + ); + } + expect(startLocalEgressGuardRelayAsync).toHaveBeenCalled(); + expect(mockedUtils.bootAsync).not.toHaveBeenCalled(); + } + ); +}); diff --git a/packages/build-tools/src/steps/functions/installBuild.ts b/packages/build-tools/src/steps/functions/installBuild.ts index 5a51dcd8fd..d07ac88040 100644 --- a/packages/build-tools/src/steps/functions/installBuild.ts +++ b/packages/build-tools/src/steps/functions/installBuild.ts @@ -63,41 +63,18 @@ export async function installBuildAsync({ env: BuildStepEnv; logger: bunyan; }): Promise<{ applicationIdentifier: string; activityName?: string }> { - const artifactStat = await fs.promises.stat(artifactPath).catch(err => { - throw new UserError( - 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', - `Build artifact does not exist at ${artifactPath}.`, - { cause: err } - ); - }); - if (runtimePlatform === BuildRuntimePlatform.DARWIN) { - if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) { - throw new UserError( - 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', - 'iOS Simulator sessions require a .app build artifact.' - ); - } - - const infoPlistPath = path.join(artifactPath, 'Info.plist'); - const { stdout } = await spawn( - 'plutil', - ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath], - { stdio: 'pipe', env } - ); - const applicationIdentifier = stdout.trim(); - if (!applicationIdentifier) { - throw new UserError( - 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER', - `Could not read CFBundleIdentifier from ${infoPlistPath}.` - ); - } + const applicationIdentifier = await readIosApplicationIdentifierAsync({ artifactPath, env }); logger.info(`Installing ${artifactPath} on the iOS Simulator.`); - await spawn('xcrun', ['simctl', 'install', 'booted', artifactPath], { env, logger }); + await spawn('xcrun', ['simctl', 'install', 'booted', artifactPath], { + env, + logger, + }); return { applicationIdentifier }; } + const artifactStat = await statArtifactAsync(artifactPath); if (path.extname(artifactPath) !== '.apk' || !artifactStat.isFile()) { throw new UserError( 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', @@ -122,3 +99,43 @@ export async function installBuildAsync({ await spawn('adb', ['install', '-r', artifactPath], { env, logger }); return { applicationIdentifier, activityName }; } + +export async function readIosApplicationIdentifierAsync({ + artifactPath, + env, +}: { + artifactPath: string; + env: BuildStepEnv; +}): Promise { + const artifactStat = await statArtifactAsync(artifactPath); + if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) { + throw new UserError( + 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', + 'iOS Simulator sessions require a .app build artifact.' + ); + } + const infoPlistPath = path.join(artifactPath, 'Info.plist'); + const { stdout } = await spawn( + 'plutil', + ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath], + { stdio: 'pipe', env } + ); + const applicationIdentifier = stdout.trim(); + if (!applicationIdentifier) { + throw new UserError( + 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER', + `Could not read CFBundleIdentifier from ${infoPlistPath}.` + ); + } + return applicationIdentifier; +} + +async function statArtifactAsync(artifactPath: string): Promise { + return fs.promises.stat(artifactPath).catch(err => { + throw new UserError( + 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', + `Build artifact does not exist at ${artifactPath}.`, + { cause: err } + ); + }); +} diff --git a/packages/build-tools/src/steps/functions/startIosSimulator.ts b/packages/build-tools/src/steps/functions/startIosSimulator.ts index e2db58014a..92143e7f0a 100644 --- a/packages/build-tools/src/steps/functions/startIosSimulator.ts +++ b/packages/build-tools/src/steps/functions/startIosSimulator.ts @@ -1,5 +1,7 @@ +import { UserError } from '@expo/eas-build-job'; import { BuildFunction, + BuildRuntimePlatform, BuildStepEnv, BuildStepInput, BuildStepInputValueTypeName, @@ -12,6 +14,8 @@ import { configureSimulatorProxyEnvironmentAsync } from '../utils/localEgress'; import { installLocalEgressGuardAsync, resolveLocalEgressBootEnvironmentAsync, + resolveLocalEgressServeSimBootEnvironmentAsync, + startLocalEgressGuardRelayAsync, verifyLocalEgressGuardAsync, } from '../utils/localEgressGuard'; @@ -21,6 +25,13 @@ import { IosSimulatorUuid, } from '../../utils/IosSimulatorUtils'; +import { readIosApplicationIdentifierAsync } from './installBuild'; +import { + type ServeSimLaunchOptions, + parseServeSimLaunchInputs, + validateServeSimLaunchOptions, +} from '../utils/remoteDeviceRunSession'; + export function createStartIosSimulatorBuildFunction(): BuildFunction { return new BuildFunction({ namespace: 'eas', @@ -90,16 +101,7 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction { logger, }); - try { - await IosSimulatorUtils.disableApsdAsync({ udid: cloneUdid, env }); - } catch (err) { - logger.warn({ err }, 'Failed to disable apsd in the Simulator.'); - } - - await IosSimulatorUtils.waitForReadyAsync({ - udid: cloneUdid, - env, - }); + await prepareBootedIosSimulatorAsync({ udid: cloneUdid, env, logger }); logger.info(`${cloneDeviceName} is ready.`); logger.info(''); @@ -144,30 +146,81 @@ export async function bootIosSimulatorAsync({ logger.info(''); } - const deviceIdentifier = deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env })); - if (!deviceIdentifier) { - throw new Error('Could not find an iPhone among available simulator devices.'); - } + const deviceIdentifier = await selectIosSimulatorIdentifierAsync({ + deviceIdentifier: deviceIdentifierInput, + env, + }); if (enableAccessibilitySettings) { await IosSimulatorUtils.enableAccessibilitySettingsAsync({ deviceIdentifier, env }); } const udid = await bootWithLocalEgressAsync({ deviceIdentifier, env, logger }); + await prepareBootedIosSimulatorAsync({ udid, env, logger }); + + logger.info(''); + const device = await IosSimulatorUtils.getDeviceAsync({ udid, env }); + const displayName = device?.displayName ?? deviceIdentifier; + logger.info(`${displayName} is ready.`); + return { deviceIdentifier, udid, displayName }; +} + +type BootedIosSimulatorOptions = { + udid: IosSimulatorUuid; + env: BuildStepEnv; + logger: bunyan; + signal?: AbortSignal; +}; + +export async function prepareBootedIosSimulatorAsync( + options: BootedIosSimulatorOptions +): Promise { + await disableIosSimulatorPushAsync(options); + await IosSimulatorUtils.waitForReadyAsync({ udid: options.udid, env: options.env }); +} + +export async function disableIosSimulatorPushAsync({ + udid, + env, + logger, + signal, +}: BootedIosSimulatorOptions): Promise { try { await IosSimulatorUtils.disableApsdAsync({ udid, env }); } catch (err) { + signal?.throwIfAborted(); logger.warn({ err }, 'Failed to disable apsd in the Simulator.'); } + signal?.throwIfAborted(); +} - await IosSimulatorUtils.waitForReadyAsync({ udid, env }); - - logger.info(''); +export async function resolveIosSimulatorUdidAsync({ + deviceIdentifier, + env, +}: { + deviceIdentifier?: IosSimulatorUuid | IosSimulatorName; + env: BuildStepEnv; +}): Promise { + const selectedIdentifier = await selectIosSimulatorIdentifierAsync({ deviceIdentifier, env }); + const udid = await IosSimulatorUtils.resolveUdidAsync({ + deviceIdentifier: selectedIdentifier, + env, + }); + return udid.toUpperCase() as IosSimulatorUuid; +} - const device = await IosSimulatorUtils.getDeviceAsync({ udid, env }); - const displayName = device?.displayName ?? deviceIdentifier; - logger.info(`${displayName} is ready.`); - return { deviceIdentifier, udid, displayName }; +async function selectIosSimulatorIdentifierAsync({ + deviceIdentifier, + env, +}: { + deviceIdentifier?: IosSimulatorUuid | IosSimulatorName; + env: BuildStepEnv; +}): Promise { + const selectedIdentifier = deviceIdentifier ?? (await findMostGenericIphoneUuidAsync({ env })); + if (!selectedIdentifier) { + throw new Error('Could not find an iPhone among available simulator devices.'); + } + return selectedIdentifier; } /** @@ -223,3 +276,79 @@ async function findMostGenericIphoneUuidAsync({ const iphoneWithShortestName = minBy(availableIphones, device => device.name.length); return iphoneWithShortestName?.udid ?? null; } + +export type IosSessionStartup = { + iosSimulatorUdid: string; + installAppPath?: string; + bootEnv?: Record; +}; + +export async function resolveIosSessionStartupAsync({ + runtimePlatform, + bootSimulator, + deviceIdentifier, + installAppPath, + launchAppIdentifier, + launchArgs, + openUrl, + env, + logger, + signal, +}: { + runtimePlatform: BuildRuntimePlatform; + bootSimulator?: boolean; + deviceIdentifier?: string; + installAppPath?: string; + launchAppIdentifier?: unknown; + launchArgs?: unknown; + openUrl?: unknown; + env: BuildStepEnv; + logger: bunyan; + signal?: AbortSignal; +}): Promise<{ launch: ServeSimLaunchOptions; iosStartup?: IosSessionStartup }> { + signal?.throwIfAborted(); + if ((bootSimulator || installAppPath) && runtimePlatform !== BuildRuntimePlatform.DARWIN) { + throw new UserError( + 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', + 'install_app_path is only supported for iOS Simulator sessions.' + ); + } + const applicationIdentifier = + launchAppIdentifier ?? + (installAppPath + ? await readIosApplicationIdentifierAsync({ artifactPath: installAppPath, env }) + : undefined); + const launch = parseServeSimLaunchInputs( + { launchAppIdentifier: applicationIdentifier, launchArgs, openUrl }, + { runtimePlatform } + ); + if ( + runtimePlatform !== BuildRuntimePlatform.DARWIN || + (!bootSimulator && !deviceIdentifier && !installAppPath) + ) { + return { launch }; + } + validateServeSimLaunchOptions(launch); + signal?.throwIfAborted(); + const iosSimulatorUdid = await resolveIosSimulatorUdidAsync({ + deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | undefined, + env, + }); + const bootEnv = await resolveLocalEgressServeSimBootEnvironmentAsync(); + signal?.throwIfAborted(); + if (bootEnv) { + const device = await IosSimulatorUtils.getDeviceAsync({ udid: iosSimulatorUdid, env }); + signal?.throwIfAborted(); + if (device && device.state !== 'Shutdown') { + logger.info(`Shutting down ${iosSimulatorUdid} to apply the local egress guard at boot.`); + await spawn('xcrun', ['simctl', 'shutdown', iosSimulatorUdid], { env, logger, signal }); + } + signal?.throwIfAborted(); + await startLocalEgressGuardRelayAsync({ logger }); + } + signal?.throwIfAborted(); + return { + launch, + iosStartup: { iosSimulatorUdid, installAppPath, bootEnv: bootEnv ?? undefined }, + }; +} diff --git a/packages/build-tools/src/steps/utils/__tests__/localEgressGuard.test.ts b/packages/build-tools/src/steps/utils/__tests__/localEgressGuard.test.ts index 112f5965a8..7ceffd6935 100644 --- a/packages/build-tools/src/steps/utils/__tests__/localEgressGuard.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/localEgressGuard.test.ts @@ -21,6 +21,8 @@ import { rebindLocalEgressGuardRelays, resolveEgressGuardLibraryAsync, resolveLocalEgressBootEnvironmentAsync, + resolveLocalEgressServeSimBootEnvironmentAsync, + startLocalEgressGuardRelayAsync, stopLocalEgressGuardRelaysAsync, verifyLocalEgressGuardAsync, } from '../localEgressGuard'; @@ -469,6 +471,94 @@ describe(resolveLocalEgressBootEnvironmentAsync, () => { }); }); +describe(resolveLocalEgressServeSimBootEnvironmentAsync, () => { + let dir: string; + let handoffPath: string; + beforeEach(async () => { + dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-serve-sim-')); + handoffPath = path.join(dir, 'handoff.json'); + }); + afterEach(async () => { + await fs.promises.rm(dir, { recursive: true, force: true }); + }); + + it('does not require the guard or check for an ordinary session', async () => { + await expect( + resolveLocalEgressServeSimBootEnvironmentAsync({ + handoffPath, + libraryPath: null, + checkPath: null, + }) + ).resolves.toBeNull(); + }); + + it('requires both packaged files before handing startup to serve-sim', async () => { + await writeLocalEgressHandoffAsync(handoff, handoffPath); + await expect( + resolveLocalEgressServeSimBootEnvironmentAsync({ handoffPath, libraryPath: null }) + ).rejects.toThrow('missing bin/egress-guard.dylib'); + await expect( + resolveLocalEgressServeSimBootEnvironmentAsync({ + handoffPath, + libraryPath: '/w/bin/egress-guard.dylib', + checkPath: null, + }) + ).rejects.toThrow('missing bin/egress-guard-check'); + }); + + it('passes the additional dylib and prefixed child variables without host DYLD injection', async () => { + await writeLocalEgressHandoffAsync(handoff, handoffPath); + const bootEnv = await resolveLocalEgressServeSimBootEnvironmentAsync({ + handoffPath, + libraryPath: '/w/bin/egress-guard.dylib', + checkPath: '/w/bin/egress-guard-check', + logPath: '/tmp/guard.log', + }); + expect(bootEnv).toEqual({ + SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib', + SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/guard.log', + SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block', + SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899', + SIMCTL_CHILD_https_proxy: 'http://127.0.0.1:8899', + SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899', + SIMCTL_CHILD_HTTPS_PROXY: 'http://127.0.0.1:8899', + SIMCTL_CHILD_grpc_proxy: 'http://127.0.0.1:8899', + SIMCTL_CHILD_no_proxy: 'localhost,127.0.0.1,::1', + SIMCTL_CHILD_NO_PROXY: 'localhost,127.0.0.1,::1', + }); + }); +}); + +describe(startLocalEgressGuardRelayAsync, () => { + it('relays boot events without setting launchd environment or truncating the log', async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-relay-')); + const logPath = path.join(dir, 'guard.log'); + const logger = createLogger(); + mockedSetEnv.mockClear(); + await fs.promises.writeFile( + logPath, + 'eas-egress-guard\tSpringBoard\t4001\tconnect\tblocked\t1.1.1.1:443\tNetwork\n' + ); + try { + await startLocalEgressGuardRelayAsync({ logger, logPath }); + await fs.promises.appendFile( + logPath, + 'eas-egress-guard\tMyApp\t4002\tconnect\tblocked\t1.1.1.1:443\tNetwork\n' + ); + await stopLocalEgressGuardRelaysAsync(logger); + expect(mockedSetEnv).not.toHaveBeenCalled(); + expect(logger.lines.some(line => /refused connect from SpringBoard/.test(line.msg))).toBe( + true + ); + expect(logger.lines.some(line => /refused connect from MyApp/.test(line.msg))).toBe(true); + expect(logger.lines.at(-1)?.msg).toContain('refused 2 connection attempt(s)'); + } finally { + await stopLocalEgressGuardRelaysAsync(logger); + await fs.promises.rm(dir, { recursive: true, force: true }); + } + }); +}); + describe(parseGuardCoverage, () => { const ps = [ ' 1 0 /sbin/launchd', diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts new file mode 100644 index 0000000000..55d254ef13 --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts @@ -0,0 +1,146 @@ +import { once } from 'node:events'; +import { + access, + lstat, + mkdir, + mkdtemp, + readFile, + readlink, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { WebSocketServer } from 'ws'; + +import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions'; + +jest.unmock('fs'); +jest.unmock('node:fs'); +jest.unmock('fs/promises'); +jest.unmock('node:fs/promises'); + +let server: WebSocketServer; +let port: number; +const token = 'test-session-token'; +const request = () => ({ + port, + token, + action: 'app.install', + params: { udid: 'device', path: '/tmp/App.app' }, + timeoutMs: 2_000, +}); + +beforeEach(async () => { + server = new WebSocketServer({ host: '127.0.0.1', port: 0 }); + await once(server, 'listening'); + port = (server.address() as { port: number }).port; +}); + +afterEach(async () => { + for (const socket of server.clients) { + socket.terminate(); + } + await new Promise(resolve => server.close(() => resolve())); +}); + +it('authenticates the upgrade, waits for ready, and sends the action once', async () => { + const actions: unknown[] = []; + server.on('connection', (socket, upgrade) => { + expect(upgrade.url).toBe('/exec-ws'); + expect(upgrade.headers['sec-websocket-protocol']).toBe(`serve-sim.token.${token}`); + socket.on('message', raw => { + actions.push(JSON.parse(raw.toString())); + socket.send(JSON.stringify({ id: 1, stdout: '', stderr: '', exitCode: 0 })); + }); + socket.send(JSON.stringify({ ready: true })); + socket.send(JSON.stringify({ ready: true })); + }); + await runServeSimActionAsync(request()); + expect(actions).toEqual([{ id: 1, action: 'app.install', params: request().params }]); +}); + +it.each([ + [{ id: 1, exitCode: 1, stderr: 'install refused' }, 'install refused'], + [{ id: 1, error: 'unknown action app.install' }, 'unknown action'], + [{ id: 1, stdout: '' }, 'invalid response'], + [{ id: 1, exitCode: 1, stderr: '' }, 'invalid response'], +])('rejects unsuccessful action replies without retrying', async (reply, message) => { + let attempts = 0; + server.on('connection', socket => { + socket.send(JSON.stringify({ ready: true })); + socket.on('message', () => { + attempts++; + socket.send(JSON.stringify(reply)); + }); + }); + await expect(runServeSimActionAsync(request())).rejects.toThrow(message); + expect(attempts).toBe(1); +}); + +it('rejects a connection that closes before its action finishes', async () => { + server.on('connection', socket => socket.close()); + await expect(runServeSimActionAsync(request())).rejects.toThrow('closed before completion'); +}); + +it('times out and closes a socket that never becomes ready', async () => { + const disconnected = new Promise(resolve => + server.on('connection', socket => socket.on('close', resolve)) + ); + await expect(runServeSimActionAsync({ ...request(), timeoutMs: 30 })).rejects.toThrow( + 'timed out' + ); + await disconnected; +}); + +it('aborts an in-flight action without resending it', async () => { + const controller = new AbortController(); + let attempts = 0; + server.on('connection', socket => { + socket.send(JSON.stringify({ ready: true })); + socket.on('message', () => { + attempts++; + controller.abort(new Error('session stopped')); + }); + }); + await expect(runServeSimActionAsync({ ...request(), signal: controller.signal })).rejects.toThrow( + 'session stopped' + ); + expect(attempts).toBe(1); +}); + +it('does not connect when already aborted', async () => { + const connected = jest.fn(); + server.on('connection', connected); + await expect( + runServeSimActionAsync({ + ...request(), + signal: AbortSignal.abort(new Error('already stopped')), + }) + ).rejects.toThrow('already stopped'); + expect(connected).not.toHaveBeenCalled(); +}); + +it("stages a local app without consuming the caller's original", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-stage-test-')); + try { + const source = path.join(root, 'downloaded', 'App.app'); + await mkdir(source, { recursive: true }); + await writeFile(path.join(source, 'marker'), 'app'); + await symlink('marker', path.join(source, 'current')); + const alias = path.join(root, 'Alias.app'); + await symlink(source, alias); + const staged = await stageServeSimAppAsync(alias, root); + expect(staged.path).toMatch(/serve-sim-uploads\/eas-app-[^/]+\/Alias.app$/); + expect((await lstat(staged.path)).isDirectory()).toBe(true); + await access(path.join(staged.path, 'marker')); + expect(await readlink(path.join(staged.path, 'current'))).toBe('marker'); + expect((await stat(path.join(root, 'serve-sim-uploads'))).mode & 0o777).toBe(0o700); + await rm(staged.directory, { recursive: true, force: true }); + expect(await readFile(path.join(source, 'marker'), 'utf8')).toBe('app'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/build-tools/src/steps/utils/localEgressGuard.ts b/packages/build-tools/src/steps/utils/localEgressGuard.ts index 7990b164c2..0c85b995a5 100644 --- a/packages/build-tools/src/steps/utils/localEgressGuard.ts +++ b/packages/build-tools/src/steps/utils/localEgressGuard.ts @@ -110,6 +110,18 @@ export async function resolveEgressGuardCheckAsync( return await resolvePackagedFileAsync(binDir, EGRESS_GUARD_CHECK_FILE); } +async function requireEgressGuardCheckAsync(checkPath?: string | null): Promise { + const resolvedCheck = checkPath === undefined ? await resolveEgressGuardCheckAsync() : checkPath; + if (!resolvedCheck) { + throw new SystemError( + 'The local egress guard self-check is not available on this device host, so this session cannot ' + + 'verify that the guard is in effect. The device host image is missing bin/egress-guard-check; ' + + 'this is a service problem, please contact support.' + ); + } + return resolvedCheck; +} + /** * Turns guard events into session log lines: one line the first time a * process reaches a destination through a given call, counts after that, and @@ -278,6 +290,14 @@ export class GuardLogTailer { } } +type LocalEgressBootEnvironmentOptions = { + handoffPath?: string; + /** Explicit library path, `null` for "not available"; resolved from the package when omitted. */ + libraryPath?: string | null; + logPath?: string; + mode?: EgressGuardMode; +}; + /** * The environment the simulator's launchd must have from its first process: * the guard and the proxy variables. Pass it to `IosSimulatorUtils.bootAsync`, @@ -291,13 +311,7 @@ export async function resolveLocalEgressBootEnvironmentAsync({ libraryPath, logPath = LOCAL_EGRESS_GUARD_LOG_PATH, mode = 'block', -}: { - handoffPath?: string; - /** Explicit library path, `null` for "not available"; resolved from the package when omitted. */ - libraryPath?: string | null; - logPath?: string; - mode?: EgressGuardMode; -} = {}): Promise | null> { +}: LocalEgressBootEnvironmentOptions = {}): Promise | null> { const handoff = await readLocalEgressHandoffAsync(handoffPath); if (!handoff) { return null; @@ -317,6 +331,27 @@ export async function resolveLocalEgressBootEnvironmentAsync({ }; } +/** Supply the guard at boot and require its self-check before starting serve-sim. */ +export async function resolveLocalEgressServeSimBootEnvironmentAsync({ + checkPath, + ...options +}: LocalEgressBootEnvironmentOptions & { + checkPath?: string | null; +} = {}): Promise | null> { + const environment = await resolveLocalEgressBootEnvironmentAsync(options); + if (!environment) { + return null; + } + await requireEgressGuardCheckAsync(checkPath); + const { DYLD_INSERT_LIBRARIES, ...childEnvironment } = environment; + return { + SERVE_SIM_ADDITIONAL_DYLIBS: DYLD_INSERT_LIBRARIES, + ...Object.fromEntries( + Object.entries(childEnvironment).map(([name, value]) => [`SIMCTL_CHILD_${name}`, value]) + ), + }; +} + type ActiveRelay = { source: { stopAsync: () => Promise }; relay: GuardEventRelay; @@ -325,6 +360,44 @@ type ActiveRelay = { }; const activeRelays = new Map(); +/** Start reporting guard events without changing the Simulator's launchd environment. */ +export async function startLocalEgressGuardRelayAsync({ + logger, + logPath = LOCAL_EGRESS_GUARD_LOG_PATH, + tailIntervalMs, +}: { + logger: bunyan; + logPath?: string; + tailIntervalMs?: number; +}): Promise { + try { + await fs.promises.mkdir(path.dirname(logPath), { recursive: true }); + await fs.promises.appendFile(logPath, ''); + } catch (err) { + logger.warn( + { err }, + `Local egress guard: could not create ${logPath}, so refused connections will not be reported in this log. They are still refused.` + ); + return; + } + if (activeRelays.has(logPath)) { + return; + } + const relay = new GuardEventRelay(logger); + const tailer = new GuardLogTailer({ + path: logPath, + intervalMs: tailIntervalMs, + onLine: line => { + const event = parseGuardLogLine(line); + if (event) { + relay.handle(event); + } + }, + }); + tailer.start(); + activeRelays.set(logPath, { source: tailer, relay }); +} + /** * Report events from another source, such as the Android emulator's packet * fence, through the same session log lines, step rebinding and summary as the @@ -396,17 +469,7 @@ export async function installLocalEgressGuardAsync({ await logAlreadyRunningProcessesAsync({ env, logger }); - let logWritable = true; - try { - await fs.promises.mkdir(path.dirname(logPath), { recursive: true }); - await fs.promises.appendFile(logPath, ''); - } catch (err) { - logWritable = false; - logger.warn( - { err }, - `Local egress guard: could not create ${logPath}, so refused connections will not be reported in this log. They are still refused.` - ); - } + await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs }); try { await IosSimulatorUtils.setLaunchdEnvironmentAsync({ @@ -423,22 +486,6 @@ export async function installLocalEgressGuardAsync({ ); } - if (logWritable && !activeRelays.has(logPath)) { - const relay = new GuardEventRelay(logger); - const tailer = new GuardLogTailer({ - path: logPath, - intervalMs: tailIntervalMs, - onLine: line => { - const event = parseGuardLogLine(line); - if (event) { - relay.handle(event); - } - }, - }); - tailer.start(); - activeRelays.set(logPath, { source: tailer, relay }); - } - logger.info( `Local egress guard installed in the Simulator (mode ${mode}): connections that bypass the system proxy are ${ mode === 'block' ? 'refused' : 'observed' @@ -658,14 +705,7 @@ export async function verifyLocalEgressGuardAsync({ /** Explicit check binary path, `null` for "not available"; resolved from the package when omitted. */ checkPath?: string | null; }): Promise { - const resolvedCheck = checkPath === undefined ? await resolveEgressGuardCheckAsync() : checkPath; - if (!resolvedCheck) { - throw new SystemError( - 'The local egress guard self-check is not available on this device host, so this session cannot ' + - 'verify that the guard is in effect. The device host image is missing bin/egress-guard-check; ' + - 'this is a service problem, please contact support.' - ); - } + const resolvedCheck = await requireEgressGuardCheckAsync(checkPath); let output: string; try { const result = await spawn('xcrun', ['simctl', 'spawn', udid, resolvedCheck, '--mode', mode], { diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index ee7e97129c..f2f8f376c2 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -717,6 +717,53 @@ export function spawnDetached({ }; } +export function createIosSessionStartupInputProviders(): ReturnType< + typeof BuildStepInput.createProvider +>[] { + return [ + BuildStepInput.createProvider({ + id: 'boot_simulator', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }), + ...['device_identifier', 'install_app_path'].map(id => + BuildStepInput.createProvider({ + id, + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }) + ), + ]; +} + +export type ServeSimApplicationOptions = ServeSimLaunchOptions & { + installAppPath: string; + launchAppIdentifier: string; +}; + +export function validateServeSimLaunchOptions({ + launchArgs = [], + openUrl, +}: Pick): void { + // Reject oversized iOS command arguments before starting the host. + if ( + launchArgs.length > 256 || + launchArgs.some(arg => arg.length > 8192 || arg.includes('\0')) || + launchArgs.reduce((bytes, arg) => bytes + Buffer.byteLength(arg) + 1, 0) > 128 * 1024 + ) { + throw new UserError( + 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', + 'iOS launch_args supports up to 256 arguments of 8192 characters each, without NUL, and at most 128 KiB total UTF-8 bytes including terminators.' + ); + } + if (openUrl && (openUrl.length > 8192 || openUrl.includes('\0'))) { + throw new UserError( + 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', + 'iOS open_url supports at most 8192 characters, without NUL.' + ); + } +} + export interface ServeSimLaunchOptions { launchAppIdentifier?: string; launchArgs?: string[]; diff --git a/packages/build-tools/src/steps/utils/serveSimActions.ts b/packages/build-tools/src/steps/utils/serveSimActions.ts new file mode 100644 index 0000000000..562a39415d --- /dev/null +++ b/packages/build-tools/src/steps/utils/serveSimActions.ts @@ -0,0 +1,101 @@ +import { SystemError } from '@expo/eas-build-job'; +import { constants } from 'node:fs'; +import { cp, mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import WebSocket from 'ws'; + +export async function stageServeSimAppAsync( + appPath: string, + tmpdir = os.tmpdir() +): Promise<{ + directory: string; + path: string; +}> { + // Clone into app.install's approved root while preserving the caller's app. + const root = path.join(tmpdir, 'serve-sim-uploads'); + await mkdir(root, { recursive: true, mode: 0o700 }); + const directory = await mkdtemp(path.join(root, 'eas-app-')); + const stagedPath = path.join(directory, path.basename(appPath)); + try { + await cp(await realpath(appPath), stagedPath, { + recursive: true, + mode: constants.COPYFILE_FICLONE, + verbatimSymlinks: true, + }); + return { directory, path: stagedPath }; + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } +} + +export async function runServeSimActionAsync({ + port, + token, + action, + params, + timeoutMs, + signal, +}: { + port: number; + token: string; + action: string; + params: Record; + timeoutMs: number; + signal?: AbortSignal; +}): Promise { + signal?.throwIfAborted(); + await new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://127.0.0.1:${port}/exec-ws`, [`serve-sim.token.${token}`]); + let settled = false; + let sent = false; + const finish = (error?: unknown) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + signal?.removeEventListener('abort', aborted); + socket.terminate(); + if (error) { + reject(error); + } else { + resolve(); + } + }; + const aborted = () => finish(signal?.reason ?? new Error('Serve-sim action aborted.')); + const timer = setTimeout( + () => finish(new SystemError(`serve-sim ${action} timed out.`)), + timeoutMs + ); + signal?.addEventListener('abort', aborted, { once: true }); + socket.on('error', () => finish(new SystemError(`serve-sim ${action} connection failed.`))); + socket.on('close', () => + finish(new SystemError(`serve-sim ${action} connection closed before completion.`)) + ); + socket.on('message', data => { + let reply; + try { + reply = JSON.parse(data.toString()); + } catch { + return finish(new SystemError(`serve-sim ${action} returned invalid JSON.`)); + } + if (reply?.ready === true && !sent) { + sent = true; + socket.send(JSON.stringify({ id: 1, action, params })); + } else if (reply?.id === 1 && sent) { + finish( + reply.exitCode === 0 + ? undefined + : new SystemError( + `serve-sim ${action} failed: ${(typeof reply.error === 'string' && reply.error) || (typeof reply.stderr === 'string' && reply.stderr) || 'invalid response'}` + ) + ); + } + }); + if (signal?.aborted) { + aborted(); + } + }); +} From 17b60419a7a6b3d5e638e8742d91a68f2cf7120e Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 10 Oct 2026 11:15:54 -0700 Subject: [PATCH 2/4] [build-tools] Keep generic boot and install steps independent --- .../functions/__tests__/installBuild.test.ts | 22 +-- .../__tests__/startIosSimulator.test.ts | 163 +--------------- .../src/steps/functions/installBuild.ts | 75 +++----- .../src/steps/functions/startIosSimulator.ts | 171 +++-------------- .../__tests__/iosSimulatorSession.test.ts | 174 ++++++++++++++++++ .../utils/__tests__/serveSimActions.test.ts | 60 +++++- .../src/steps/utils/iosSimulatorSession.ts | 110 +++++++++++ .../src/steps/utils/serveSimActions.ts | 42 ++++- 8 files changed, 436 insertions(+), 381 deletions(-) create mode 100644 packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts create mode 100644 packages/build-tools/src/steps/utils/iosSimulatorSession.ts diff --git a/packages/build-tools/src/steps/functions/__tests__/installBuild.test.ts b/packages/build-tools/src/steps/functions/__tests__/installBuild.test.ts index 21b01e2644..5af166fc3b 100644 --- a/packages/build-tools/src/steps/functions/__tests__/installBuild.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/installBuild.test.ts @@ -6,11 +6,7 @@ import path from 'node:path'; import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { createMockLogger } from '../../../__tests__/utils/logger'; -import { - createInstallBuildFunction, - installBuildAsync, - readIosApplicationIdentifierAsync, -} from '../installBuild'; +import { createInstallBuildFunction, installBuildAsync } from '../installBuild'; jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, @@ -95,22 +91,6 @@ describe(installBuildAsync, () => { ]); }); - it('reads iOS metadata without installing the application', async () => { - const artifactPath = path.join(await makeTemporaryDirectoryAsync(), 'Example.app'); - await fs.promises.mkdir(artifactPath); - mockedSpawn.mockResolvedValueOnce({ stdout: 'com.example.app\n', stderr: '' } as any); - await expect(readIosApplicationIdentifierAsync({ artifactPath, env: {} })).resolves.toBe( - 'com.example.app' - ); - expect(mockedSpawn.mock.calls).toEqual([ - [ - 'plutil', - ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')], - { stdio: 'pipe', env: {} }, - ], - ]); - }); - it.each([ [BuildRuntimePlatform.DARWIN, 'example.ipa'], [BuildRuntimePlatform.LINUX, 'example.aab'], diff --git a/packages/build-tools/src/steps/functions/__tests__/startIosSimulator.test.ts b/packages/build-tools/src/steps/functions/__tests__/startIosSimulator.test.ts index ae517b3648..200e3a8348 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startIosSimulator.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startIosSimulator.test.ts @@ -1,26 +1,15 @@ -import { BuildRuntimePlatform } from '@expo/steps'; import spawn from '@expo/turtle-spawn'; import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { createMockLogger } from '../../../__tests__/utils/logger'; -import { IosSimulatorUtils, type IosSimulatorUuid } from '../../../utils/IosSimulatorUtils'; +import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils'; import { configureSimulatorProxyEnvironmentAsync } from '../../utils/localEgress'; import { installLocalEgressGuardAsync, resolveLocalEgressBootEnvironmentAsync, - resolveLocalEgressServeSimBootEnvironmentAsync, - startLocalEgressGuardRelayAsync, verifyLocalEgressGuardAsync, } from '../../utils/localEgressGuard'; -import { - createStartIosSimulatorBuildFunction, - disableIosSimulatorPushAsync, - resolveIosSessionStartupAsync, - resolveIosSimulatorUdidAsync, -} from '../startIosSimulator'; - -import { readIosApplicationIdentifierAsync } from '../installBuild'; -jest.mock('../installBuild', () => ({ readIosApplicationIdentifierAsync: jest.fn() })); +import { createStartIosSimulatorBuildFunction } from '../startIosSimulator'; jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, @@ -48,8 +37,6 @@ jest.mock('../../utils/localEgressGuard', () => ({ installLocalEgressGuardAsync: jest.fn(), resolveLocalEgressBootEnvironmentAsync: jest.fn(), verifyLocalEgressGuardAsync: jest.fn(), - resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(), - startLocalEgressGuardRelayAsync: jest.fn(), })); const mockedSpawn = jest.mocked(spawn); @@ -116,33 +103,6 @@ describe(createStartIosSimulatorBuildFunction, () => { }); }); - it('resolves a requested Simulator without booting or configuring it', async () => { - await expect( - resolveIosSimulatorUdidAsync({ deviceIdentifier: 'iPhone 15' as never, env: {} }) - ).resolves.toBe('BASE'); - - expect(mockedUtils.bootAsync).not.toHaveBeenCalled(); - expect(mockedUtils.startAsync).not.toHaveBeenCalled(); - expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled(); - expect(mockedInstallGuard).not.toHaveBeenCalled(); - }); - - it('selects the same default iPhone without booting', async () => { - mockedUtils.getAvailableDevicesAsync.mockResolvedValue([ - { name: 'iPad', udid: 'ipad' }, - { name: 'iPhone 15 Pro', udid: 'pro' }, - { name: 'iPhone 15', udid: 'base' }, - ] as never); - - await expect(resolveIosSimulatorUdidAsync({ env: {} })).resolves.toBe('BASE'); - expect(mockedUtils.bootAsync).not.toHaveBeenCalled(); - - mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]); - await expect(resolveIosSimulatorUdidAsync({ env: {} })).rejects.toThrow( - 'Could not find an iPhone' - ); - }); - it('enables accessibility settings before starting the main device and every clone when requested', async () => { await createStep({ device_identifier: 'iPhone 15', @@ -274,122 +234,3 @@ describe(createStartIosSimulatorBuildFunction, () => { expect(mockedUtils.waitForReadyAsync).toHaveBeenCalled(); }); }); - -describe('serve-sim-owned boot preparation', () => { - it('configures push without repeating the legacy readiness wait', async () => { - jest.clearAllMocks(); - mockedUtils.disableApsdAsync.mockResolvedValue(undefined); - await disableIosSimulatorPushAsync({ - udid: 'base' as IosSimulatorUuid, - env: {}, - logger: createMockLogger(), - }); - expect(IosSimulatorUtils.disableApsdAsync).toHaveBeenCalledWith({ udid: 'base', env: {} }); - expect(IosSimulatorUtils.waitForReadyAsync).not.toHaveBeenCalled(); - }); -}); - -describe('serve-sim Simulator preparation', () => { - const hostOptions = { - runtimePlatform: BuildRuntimePlatform.DARWIN, - env: {}, - logger: createMockLogger(), - }; - beforeEach(() => { - jest.clearAllMocks(); - mockedUtils.getAvailableDevicesAsync.mockResolvedValue([ - { - udid: 'selected-udid', - name: 'iPhone 17', - isAvailable: true, - deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-17', - }, - ] as never); - mockedUtils.resolveUdidAsync.mockResolvedValue('selected-udid' as IosSimulatorUuid); - jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null); - jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app'); - }); - it.each([ - ['argument count', Array(257).fill('a')], - ['argument length', ['a'.repeat(9000)]], - ['NUL argument', ['a\u0000b']], - ['total UTF-8 bytes', Array(8).fill('😃'.repeat(4096))], - ])('rejects invalid launch %s before Simulator preparation', async (_, launchArgs) => { - await expect( - resolveIosSessionStartupAsync({ - ...hostOptions, - bootSimulator: true, - launchAppIdentifier: 'dev.example.app', - launchArgs, - }) - ).rejects.toThrow('iOS launch_args'); - expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled(); - expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled(); - expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled(); - }); - - it('keeps an explicit launch identifier independent of the installed app', async () => { - const startup = await resolveIosSessionStartupAsync({ - ...hostOptions, - installAppPath: '/tmp/App.app', - launchAppIdentifier: 'another.installed.app', - }); - expect(startup.launch.launchAppIdentifier).toBe('another.installed.app'); - expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled(); - }); - - it('does no Simulator work on Android and rejects iOS installation inputs', async () => { - const options = { ...hostOptions, runtimePlatform: BuildRuntimePlatform.LINUX }; - await expect(resolveIosSessionStartupAsync(options)).resolves.toEqual({ - launch: { launchArgs: [], launchAppIdentifier: undefined, openUrl: undefined }, - }); - await expect( - resolveIosSessionStartupAsync({ ...options, installAppPath: '/tmp/App.app' }) - ).rejects.toThrow('only supported for iOS'); - expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled(); - }); - - it('preserves older workflows that already prepared the Simulator and app', async () => { - const startup = await resolveIosSessionStartupAsync({ - ...hostOptions, - launchAppIdentifier: 'previously.installed.app', - }); - expect(startup.iosStartup).toBeUndefined(); - expect(startup.launch.launchAppIdentifier).toBe('previously.installed.app'); - expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled(); - expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled(); - expect(spawn).not.toHaveBeenCalled(); - }); - - it('boots a session without an app when the workflow requests owned startup', async () => { - const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true }); - expect(startup.iosStartup?.iosSimulatorUdid).toBe('SELECTED-UDID'); - expect(startup.launch.launchAppIdentifier).toBeUndefined(); - }); - - it.each(['Booted', 'Booting', 'Shutdown'])( - 'prepares guarded startup on a %s device', - async state => { - const bootEnv = { SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' }; - jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv); - mockedUtils.getDeviceAsync.mockResolvedValue({ state } as never); - const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true }); - expect(startup.iosStartup).toEqual({ - iosSimulatorUdid: 'SELECTED-UDID', - bootEnv, - installAppPath: undefined, - }); - if (state === 'Shutdown') { - expect(spawn).not.toHaveBeenCalled(); - } else { - expect(spawn).toHaveBeenCalledWith( - 'xcrun', - ['simctl', 'shutdown', 'SELECTED-UDID'], - expect.anything() - ); - } - expect(startLocalEgressGuardRelayAsync).toHaveBeenCalled(); - expect(mockedUtils.bootAsync).not.toHaveBeenCalled(); - } - ); -}); diff --git a/packages/build-tools/src/steps/functions/installBuild.ts b/packages/build-tools/src/steps/functions/installBuild.ts index d07ac88040..5a51dcd8fd 100644 --- a/packages/build-tools/src/steps/functions/installBuild.ts +++ b/packages/build-tools/src/steps/functions/installBuild.ts @@ -63,18 +63,41 @@ export async function installBuildAsync({ env: BuildStepEnv; logger: bunyan; }): Promise<{ applicationIdentifier: string; activityName?: string }> { + const artifactStat = await fs.promises.stat(artifactPath).catch(err => { + throw new UserError( + 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', + `Build artifact does not exist at ${artifactPath}.`, + { cause: err } + ); + }); + if (runtimePlatform === BuildRuntimePlatform.DARWIN) { - const applicationIdentifier = await readIosApplicationIdentifierAsync({ artifactPath, env }); + if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) { + throw new UserError( + 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', + 'iOS Simulator sessions require a .app build artifact.' + ); + } + + const infoPlistPath = path.join(artifactPath, 'Info.plist'); + const { stdout } = await spawn( + 'plutil', + ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath], + { stdio: 'pipe', env } + ); + const applicationIdentifier = stdout.trim(); + if (!applicationIdentifier) { + throw new UserError( + 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER', + `Could not read CFBundleIdentifier from ${infoPlistPath}.` + ); + } logger.info(`Installing ${artifactPath} on the iOS Simulator.`); - await spawn('xcrun', ['simctl', 'install', 'booted', artifactPath], { - env, - logger, - }); + await spawn('xcrun', ['simctl', 'install', 'booted', artifactPath], { env, logger }); return { applicationIdentifier }; } - const artifactStat = await statArtifactAsync(artifactPath); if (path.extname(artifactPath) !== '.apk' || !artifactStat.isFile()) { throw new UserError( 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', @@ -99,43 +122,3 @@ export async function installBuildAsync({ await spawn('adb', ['install', '-r', artifactPath], { env, logger }); return { applicationIdentifier, activityName }; } - -export async function readIosApplicationIdentifierAsync({ - artifactPath, - env, -}: { - artifactPath: string; - env: BuildStepEnv; -}): Promise { - const artifactStat = await statArtifactAsync(artifactPath); - if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) { - throw new UserError( - 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', - 'iOS Simulator sessions require a .app build artifact.' - ); - } - const infoPlistPath = path.join(artifactPath, 'Info.plist'); - const { stdout } = await spawn( - 'plutil', - ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath], - { stdio: 'pipe', env } - ); - const applicationIdentifier = stdout.trim(); - if (!applicationIdentifier) { - throw new UserError( - 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER', - `Could not read CFBundleIdentifier from ${infoPlistPath}.` - ); - } - return applicationIdentifier; -} - -async function statArtifactAsync(artifactPath: string): Promise { - return fs.promises.stat(artifactPath).catch(err => { - throw new UserError( - 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', - `Build artifact does not exist at ${artifactPath}.`, - { cause: err } - ); - }); -} diff --git a/packages/build-tools/src/steps/functions/startIosSimulator.ts b/packages/build-tools/src/steps/functions/startIosSimulator.ts index 92143e7f0a..e2db58014a 100644 --- a/packages/build-tools/src/steps/functions/startIosSimulator.ts +++ b/packages/build-tools/src/steps/functions/startIosSimulator.ts @@ -1,7 +1,5 @@ -import { UserError } from '@expo/eas-build-job'; import { BuildFunction, - BuildRuntimePlatform, BuildStepEnv, BuildStepInput, BuildStepInputValueTypeName, @@ -14,8 +12,6 @@ import { configureSimulatorProxyEnvironmentAsync } from '../utils/localEgress'; import { installLocalEgressGuardAsync, resolveLocalEgressBootEnvironmentAsync, - resolveLocalEgressServeSimBootEnvironmentAsync, - startLocalEgressGuardRelayAsync, verifyLocalEgressGuardAsync, } from '../utils/localEgressGuard'; @@ -25,13 +21,6 @@ import { IosSimulatorUuid, } from '../../utils/IosSimulatorUtils'; -import { readIosApplicationIdentifierAsync } from './installBuild'; -import { - type ServeSimLaunchOptions, - parseServeSimLaunchInputs, - validateServeSimLaunchOptions, -} from '../utils/remoteDeviceRunSession'; - export function createStartIosSimulatorBuildFunction(): BuildFunction { return new BuildFunction({ namespace: 'eas', @@ -101,7 +90,16 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction { logger, }); - await prepareBootedIosSimulatorAsync({ udid: cloneUdid, env, logger }); + try { + await IosSimulatorUtils.disableApsdAsync({ udid: cloneUdid, env }); + } catch (err) { + logger.warn({ err }, 'Failed to disable apsd in the Simulator.'); + } + + await IosSimulatorUtils.waitForReadyAsync({ + udid: cloneUdid, + env, + }); logger.info(`${cloneDeviceName} is ready.`); logger.info(''); @@ -146,81 +144,30 @@ export async function bootIosSimulatorAsync({ logger.info(''); } - const deviceIdentifier = await selectIosSimulatorIdentifierAsync({ - deviceIdentifier: deviceIdentifierInput, - env, - }); + const deviceIdentifier = deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env })); + if (!deviceIdentifier) { + throw new Error('Could not find an iPhone among available simulator devices.'); + } if (enableAccessibilitySettings) { await IosSimulatorUtils.enableAccessibilitySettingsAsync({ deviceIdentifier, env }); } const udid = await bootWithLocalEgressAsync({ deviceIdentifier, env, logger }); - await prepareBootedIosSimulatorAsync({ udid, env, logger }); - - logger.info(''); - const device = await IosSimulatorUtils.getDeviceAsync({ udid, env }); - const displayName = device?.displayName ?? deviceIdentifier; - logger.info(`${displayName} is ready.`); - return { deviceIdentifier, udid, displayName }; -} - -type BootedIosSimulatorOptions = { - udid: IosSimulatorUuid; - env: BuildStepEnv; - logger: bunyan; - signal?: AbortSignal; -}; - -export async function prepareBootedIosSimulatorAsync( - options: BootedIosSimulatorOptions -): Promise { - await disableIosSimulatorPushAsync(options); - await IosSimulatorUtils.waitForReadyAsync({ udid: options.udid, env: options.env }); -} - -export async function disableIosSimulatorPushAsync({ - udid, - env, - logger, - signal, -}: BootedIosSimulatorOptions): Promise { try { await IosSimulatorUtils.disableApsdAsync({ udid, env }); } catch (err) { - signal?.throwIfAborted(); logger.warn({ err }, 'Failed to disable apsd in the Simulator.'); } - signal?.throwIfAborted(); -} -export async function resolveIosSimulatorUdidAsync({ - deviceIdentifier, - env, -}: { - deviceIdentifier?: IosSimulatorUuid | IosSimulatorName; - env: BuildStepEnv; -}): Promise { - const selectedIdentifier = await selectIosSimulatorIdentifierAsync({ deviceIdentifier, env }); - const udid = await IosSimulatorUtils.resolveUdidAsync({ - deviceIdentifier: selectedIdentifier, - env, - }); - return udid.toUpperCase() as IosSimulatorUuid; -} + await IosSimulatorUtils.waitForReadyAsync({ udid, env }); -async function selectIosSimulatorIdentifierAsync({ - deviceIdentifier, - env, -}: { - deviceIdentifier?: IosSimulatorUuid | IosSimulatorName; - env: BuildStepEnv; -}): Promise { - const selectedIdentifier = deviceIdentifier ?? (await findMostGenericIphoneUuidAsync({ env })); - if (!selectedIdentifier) { - throw new Error('Could not find an iPhone among available simulator devices.'); - } - return selectedIdentifier; + logger.info(''); + + const device = await IosSimulatorUtils.getDeviceAsync({ udid, env }); + const displayName = device?.displayName ?? deviceIdentifier; + logger.info(`${displayName} is ready.`); + return { deviceIdentifier, udid, displayName }; } /** @@ -276,79 +223,3 @@ async function findMostGenericIphoneUuidAsync({ const iphoneWithShortestName = minBy(availableIphones, device => device.name.length); return iphoneWithShortestName?.udid ?? null; } - -export type IosSessionStartup = { - iosSimulatorUdid: string; - installAppPath?: string; - bootEnv?: Record; -}; - -export async function resolveIosSessionStartupAsync({ - runtimePlatform, - bootSimulator, - deviceIdentifier, - installAppPath, - launchAppIdentifier, - launchArgs, - openUrl, - env, - logger, - signal, -}: { - runtimePlatform: BuildRuntimePlatform; - bootSimulator?: boolean; - deviceIdentifier?: string; - installAppPath?: string; - launchAppIdentifier?: unknown; - launchArgs?: unknown; - openUrl?: unknown; - env: BuildStepEnv; - logger: bunyan; - signal?: AbortSignal; -}): Promise<{ launch: ServeSimLaunchOptions; iosStartup?: IosSessionStartup }> { - signal?.throwIfAborted(); - if ((bootSimulator || installAppPath) && runtimePlatform !== BuildRuntimePlatform.DARWIN) { - throw new UserError( - 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', - 'install_app_path is only supported for iOS Simulator sessions.' - ); - } - const applicationIdentifier = - launchAppIdentifier ?? - (installAppPath - ? await readIosApplicationIdentifierAsync({ artifactPath: installAppPath, env }) - : undefined); - const launch = parseServeSimLaunchInputs( - { launchAppIdentifier: applicationIdentifier, launchArgs, openUrl }, - { runtimePlatform } - ); - if ( - runtimePlatform !== BuildRuntimePlatform.DARWIN || - (!bootSimulator && !deviceIdentifier && !installAppPath) - ) { - return { launch }; - } - validateServeSimLaunchOptions(launch); - signal?.throwIfAborted(); - const iosSimulatorUdid = await resolveIosSimulatorUdidAsync({ - deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | undefined, - env, - }); - const bootEnv = await resolveLocalEgressServeSimBootEnvironmentAsync(); - signal?.throwIfAborted(); - if (bootEnv) { - const device = await IosSimulatorUtils.getDeviceAsync({ udid: iosSimulatorUdid, env }); - signal?.throwIfAborted(); - if (device && device.state !== 'Shutdown') { - logger.info(`Shutting down ${iosSimulatorUdid} to apply the local egress guard at boot.`); - await spawn('xcrun', ['simctl', 'shutdown', iosSimulatorUdid], { env, logger, signal }); - } - signal?.throwIfAborted(); - await startLocalEgressGuardRelayAsync({ logger }); - } - signal?.throwIfAborted(); - return { - launch, - iosStartup: { iosSimulatorUdid, installAppPath, bootEnv: bootEnv ?? undefined }, - }; -} diff --git a/packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts new file mode 100644 index 0000000000..25748e65b4 --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts @@ -0,0 +1,174 @@ +import { BuildRuntimePlatform } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; + +import { createMockLogger } from '../../../__tests__/utils/logger'; +import { IosSimulatorUtils, type IosSimulatorUuid } from '../../../utils/IosSimulatorUtils'; +import { resolveIosSessionStartupAsync } from '../iosSimulatorSession'; +import { readIosApplicationIdentifierAsync } from '../serveSimActions'; +import { + resolveLocalEgressServeSimBootEnvironmentAsync, + startLocalEgressGuardRelayAsync, +} from '../localEgressGuard'; + +jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('../../../utils/IosSimulatorUtils'); +jest.mock('../serveSimActions', () => ({ readIosApplicationIdentifierAsync: jest.fn() })); +jest.mock('../localEgressGuard', () => ({ + resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(), + startLocalEgressGuardRelayAsync: jest.fn(), +})); + +const mockedSpawn = jest.mocked(spawn); +const mockedUtils = jest.mocked(IosSimulatorUtils); + +describe('serve-sim Simulator preparation', () => { + const hostOptions = { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: {}, + logger: createMockLogger(), + }; + beforeEach(() => { + jest.clearAllMocks(); + mockedUtils.getAvailableDevicesAsync.mockResolvedValue([ + { + udid: 'selected-udid', + name: 'iPhone 17', + isAvailable: true, + deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-17', + }, + ] as never); + mockedUtils.resolveUdidAsync.mockResolvedValue('selected-udid' as IosSimulatorUuid); + jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null); + mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as never); + jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app'); + }); + it.each([ + ['argument count', Array(257).fill('a')], + ['argument length', ['a'.repeat(9000)]], + ['NUL argument', ['a\u0000b']], + ['total UTF-8 bytes', Array(8).fill('😃'.repeat(4096))], + ])('rejects invalid launch %s before Simulator preparation', async (_, launchArgs) => { + await expect( + resolveIosSessionStartupAsync({ + ...hostOptions, + bootSimulator: true, + launchAppIdentifier: 'dev.example.app', + launchArgs, + }) + ).rejects.toThrow('iOS launch_args'); + expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled(); + expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled(); + expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled(); + }); + + it('keeps an explicit launch identifier independent of the installed app', async () => { + const startup = await resolveIosSessionStartupAsync({ + ...hostOptions, + installAppPath: '/tmp/App.app', + launchAppIdentifier: 'another.installed.app', + }); + expect(startup.launch.launchAppIdentifier).toBe('another.installed.app'); + expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled(); + }); + + it('infers the launch identifier from the downloaded app', async () => { + const startup = await resolveIosSessionStartupAsync({ + ...hostOptions, + installAppPath: '/tmp/App.app', + }); + expect(startup.launch.launchAppIdentifier).toBe('dev.example.app'); + expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({ + artifactPath: '/tmp/App.app', + env: {}, + }); + }); + + it('does no Simulator work on Android and rejects iOS installation inputs', async () => { + const options = { ...hostOptions, runtimePlatform: BuildRuntimePlatform.LINUX }; + await expect(resolveIosSessionStartupAsync(options)).resolves.toEqual({ + launch: { launchArgs: [], launchAppIdentifier: undefined, openUrl: undefined }, + }); + await expect( + resolveIosSessionStartupAsync({ ...options, installAppPath: '/tmp/App.app' }) + ).rejects.toThrow('only supported for iOS'); + expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled(); + }); + + it('preserves older workflows that already prepared the Simulator and app', async () => { + const startup = await resolveIosSessionStartupAsync({ + ...hostOptions, + launchAppIdentifier: 'previously.installed.app', + }); + expect(startup.iosStartup).toBeUndefined(); + expect(startup.launch.launchAppIdentifier).toBe('previously.installed.app'); + expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled(); + expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('prepares a session without an app when the workflow requests owned startup', async () => { + const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true }); + expect(startup.iosStartup?.iosSimulatorUdid).toBe('SELECTED-UDID'); + expect(startup.launch.launchAppIdentifier).toBeUndefined(); + }); + + it.each(['Booted', 'Booting', 'Shutdown'])( + 'prepares guarded startup on a %s device', + async state => { + const bootEnv = { SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' }; + jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv); + mockedUtils.getDeviceAsync.mockResolvedValue({ state } as never); + const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true }); + expect(startup.iosStartup).toEqual({ + iosSimulatorUdid: 'SELECTED-UDID', + bootEnv, + installAppPath: undefined, + }); + if (state === 'Shutdown') { + expect(spawn).not.toHaveBeenCalled(); + } else { + expect(spawn).toHaveBeenCalledWith( + 'xcrun', + ['simctl', 'shutdown', 'SELECTED-UDID'], + expect.anything() + ); + } + expect(startLocalEgressGuardRelayAsync).toHaveBeenCalled(); + expect(mockedUtils.bootAsync).not.toHaveBeenCalled(); + } + ); + + it('resolves an explicit Simulator without booting or configuring it', async () => { + const startup = await resolveIosSessionStartupAsync({ + ...hostOptions, + deviceIdentifier: 'iPhone 17', + }); + expect(startup.iosStartup?.iosSimulatorUdid).toBe('SELECTED-UDID'); + expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({ + deviceIdentifier: 'iPhone 17', + env: {}, + }); + expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled(); + expect(mockedUtils.bootAsync).not.toHaveBeenCalled(); + expect(mockedUtils.startAsync).not.toHaveBeenCalled(); + expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled(); + }); + + it('selects the default iPhone without booting', async () => { + mockedUtils.getAvailableDevicesAsync.mockResolvedValue([ + { name: 'iPad', udid: 'ipad' }, + { name: 'iPhone 17 Pro', udid: 'pro' }, + { name: 'iPhone 17', udid: 'base' }, + ] as never); + await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true }); + expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({ + deviceIdentifier: 'base', + env: {}, + }); + expect(mockedUtils.bootAsync).not.toHaveBeenCalled(); + mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]); + await expect( + resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true }) + ).rejects.toThrow('Could not find an iPhone'); + }); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts index 55d254ef13..7080a77441 100644 --- a/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts @@ -1,3 +1,4 @@ +import spawn from '@expo/turtle-spawn'; import { once } from 'node:events'; import { access, @@ -15,7 +16,13 @@ import os from 'node:os'; import path from 'node:path'; import { WebSocketServer } from 'ws'; -import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions'; +import { + readIosApplicationIdentifierAsync, + runServeSimActionAsync, + stageServeSimAppAsync, +} from '../serveSimActions'; + +jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); jest.unmock('fs'); jest.unmock('node:fs'); @@ -144,3 +151,54 @@ it("stages a local app without consuming the caller's original", async () => { await rm(root, { recursive: true, force: true }); } }); + +describe(readIosApplicationIdentifierAsync, () => { + let directory: string; + let artifactPath: string; + + beforeEach(async () => { + jest.mocked(spawn).mockReset(); + directory = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-app-metadata-')); + artifactPath = path.join(directory, 'Example.app'); + await mkdir(artifactPath); + }); + + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it('reads app metadata without installing or launching', async () => { + jest.mocked(spawn).mockResolvedValue({ stdout: 'com.example.app\n', stderr: '' } as never); + await expect(readIosApplicationIdentifierAsync({ artifactPath, env: {} })).resolves.toBe( + 'com.example.app' + ); + expect(jest.mocked(spawn).mock.calls).toEqual([ + [ + 'plutil', + ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')], + { stdio: 'pipe', env: {} }, + ], + ]); + }); + + it.each(['missing', 'file', 'wrong extension'])('rejects an invalid artifact: %s', async kind => { + await rm(artifactPath, { recursive: true }); + if (kind === 'file') { + await writeFile(artifactPath, 'not an app directory'); + } else if (kind === 'wrong extension') { + artifactPath = path.join(directory, 'Example.ipa'); + await mkdir(artifactPath); + } + await expect( + readIosApplicationIdentifierAsync({ artifactPath, env: {} }) + ).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_INVALID_ARTIFACT' }); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('rejects a missing bundle identifier', async () => { + jest.mocked(spawn).mockResolvedValue({ stdout: ' ', stderr: '' } as never); + await expect( + readIosApplicationIdentifierAsync({ artifactPath, env: {} }) + ).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER' }); + }); +}); diff --git a/packages/build-tools/src/steps/utils/iosSimulatorSession.ts b/packages/build-tools/src/steps/utils/iosSimulatorSession.ts new file mode 100644 index 0000000000..b809fa2cfc --- /dev/null +++ b/packages/build-tools/src/steps/utils/iosSimulatorSession.ts @@ -0,0 +1,110 @@ +import { UserError } from '@expo/eas-build-job'; +import { type bunyan } from '@expo/logger'; +import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import { minBy } from 'lodash'; + +import { + type IosSimulatorName, + IosSimulatorUtils, + type IosSimulatorUuid, +} from '../../utils/IosSimulatorUtils'; +import { + resolveLocalEgressServeSimBootEnvironmentAsync, + startLocalEgressGuardRelayAsync, +} from './localEgressGuard'; +import { readIosApplicationIdentifierAsync } from './serveSimActions'; +import { + type ServeSimLaunchOptions, + parseServeSimLaunchInputs, + validateServeSimLaunchOptions, +} from './remoteDeviceRunSession'; + +export type IosSessionStartup = { + iosSimulatorUdid: string; + installAppPath?: string; + bootEnv?: Record; +}; + +export async function resolveIosSessionStartupAsync({ + runtimePlatform, + bootSimulator, + deviceIdentifier, + installAppPath, + launchAppIdentifier, + launchArgs, + openUrl, + env, + logger, + signal, +}: { + runtimePlatform: BuildRuntimePlatform; + bootSimulator?: boolean; + deviceIdentifier?: string; + installAppPath?: string; + launchAppIdentifier?: unknown; + launchArgs?: unknown; + openUrl?: unknown; + env: BuildStepEnv; + logger: bunyan; + signal?: AbortSignal; +}): Promise<{ launch: ServeSimLaunchOptions; iosStartup?: IosSessionStartup }> { + signal?.throwIfAborted(); + if ((bootSimulator || installAppPath) && runtimePlatform !== BuildRuntimePlatform.DARWIN) { + throw new UserError( + 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', + 'install_app_path is only supported for iOS Simulator sessions.' + ); + } + const applicationIdentifier = + launchAppIdentifier ?? + (installAppPath + ? await readIosApplicationIdentifierAsync({ artifactPath: installAppPath, env }) + : undefined); + const launch = parseServeSimLaunchInputs( + { launchAppIdentifier: applicationIdentifier, launchArgs, openUrl }, + { runtimePlatform } + ); + if ( + runtimePlatform !== BuildRuntimePlatform.DARWIN || + (!bootSimulator && !deviceIdentifier && !installAppPath) + ) { + return { launch }; + } + validateServeSimLaunchOptions(launch); + signal?.throwIfAborted(); + let selectedIdentifier = (deviceIdentifier ?? undefined) as + | IosSimulatorUuid + | IosSimulatorName + | undefined; + if (selectedIdentifier === undefined) { + const devices = await IosSimulatorUtils.getAvailableDevicesAsync({ env, filter: 'available' }); + selectedIdentifier = minBy( + devices.filter(device => device.name.startsWith('iPhone')), + device => device.name.length + )?.udid; + } + if (!selectedIdentifier) { + throw new Error('Could not find an iPhone among available simulator devices.'); + } + const iosSimulatorUdid = ( + await IosSimulatorUtils.resolveUdidAsync({ deviceIdentifier: selectedIdentifier, env }) + ).toUpperCase() as IosSimulatorUuid; + const bootEnv = await resolveLocalEgressServeSimBootEnvironmentAsync(); + signal?.throwIfAborted(); + if (bootEnv) { + const device = await IosSimulatorUtils.getDeviceAsync({ udid: iosSimulatorUdid, env }); + signal?.throwIfAborted(); + if (device && device.state !== 'Shutdown') { + logger.info(`Shutting down ${iosSimulatorUdid} to apply the local egress guard at boot.`); + await spawn('xcrun', ['simctl', 'shutdown', iosSimulatorUdid], { env, logger, signal }); + } + signal?.throwIfAborted(); + await startLocalEgressGuardRelayAsync({ logger }); + } + signal?.throwIfAborted(); + return { + launch, + iosStartup: { iosSimulatorUdid, installAppPath, bootEnv: bootEnv ?? undefined }, + }; +} diff --git a/packages/build-tools/src/steps/utils/serveSimActions.ts b/packages/build-tools/src/steps/utils/serveSimActions.ts index 562a39415d..7aea869a61 100644 --- a/packages/build-tools/src/steps/utils/serveSimActions.ts +++ b/packages/build-tools/src/steps/utils/serveSimActions.ts @@ -1,5 +1,7 @@ -import { SystemError } from '@expo/eas-build-job'; -import { constants } from 'node:fs'; +import { SystemError, UserError } from '@expo/eas-build-job'; +import { type BuildStepEnv } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import fs, { constants } from 'node:fs'; import { cp, mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -99,3 +101,39 @@ export async function runServeSimActionAsync({ } }); } + +export async function readIosApplicationIdentifierAsync({ + artifactPath, + env, +}: { + artifactPath: string; + env: BuildStepEnv; +}): Promise { + const artifactStat = await fs.promises.stat(artifactPath).catch(err => { + throw new UserError( + 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', + `Build artifact does not exist at ${artifactPath}.`, + { cause: err } + ); + }); + if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) { + throw new UserError( + 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', + 'iOS Simulator sessions require a .app build artifact.' + ); + } + const infoPlistPath = path.join(artifactPath, 'Info.plist'); + const { stdout } = await spawn( + 'plutil', + ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath], + { stdio: 'pipe', env } + ); + const applicationIdentifier = stdout.trim(); + if (!applicationIdentifier) { + throw new UserError( + 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER', + `Could not read CFBundleIdentifier from ${infoPlistPath}.` + ); + } + return applicationIdentifier; +} From 1159161bed333028f15f90349e6e99c7a07338e0 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 10 Oct 2026 11:26:14 -0700 Subject: [PATCH 3/4] [build-tools] Keep iOS app metadata independent of serve-sim --- .../utils/__tests__/iosAppArtifact.test.ts | 63 +++++++++++++++++++ .../__tests__/iosSimulatorSession.test.ts | 4 +- .../utils/__tests__/serveSimActions.test.ts | 60 +----------------- .../src/steps/utils/iosAppArtifact.ts | 41 ++++++++++++ .../src/steps/utils/iosSimulatorSession.ts | 2 +- .../src/steps/utils/serveSimActions.ts | 42 +------------ 6 files changed, 110 insertions(+), 102 deletions(-) create mode 100644 packages/build-tools/src/steps/utils/__tests__/iosAppArtifact.test.ts create mode 100644 packages/build-tools/src/steps/utils/iosAppArtifact.ts diff --git a/packages/build-tools/src/steps/utils/__tests__/iosAppArtifact.test.ts b/packages/build-tools/src/steps/utils/__tests__/iosAppArtifact.test.ts new file mode 100644 index 0000000000..5e3e437146 --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/iosAppArtifact.test.ts @@ -0,0 +1,63 @@ +import spawn from '@expo/turtle-spawn'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { readIosApplicationIdentifierAsync } from '../iosAppArtifact'; + +jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); +jest.unmock('fs'); +jest.unmock('node:fs'); +jest.unmock('fs/promises'); +jest.unmock('node:fs/promises'); + +describe(readIosApplicationIdentifierAsync, () => { + let directory: string; + let artifactPath: string; + + beforeEach(async () => { + jest.mocked(spawn).mockReset(); + directory = await mkdtemp(path.join(os.tmpdir(), 'ios-app-artifact-')); + artifactPath = path.join(directory, 'Example.app'); + await mkdir(artifactPath); + }); + + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it('reads app metadata without installing or launching', async () => { + jest.mocked(spawn).mockResolvedValue({ stdout: 'com.example.app\n', stderr: '' } as never); + await expect(readIosApplicationIdentifierAsync({ artifactPath, env: {} })).resolves.toBe( + 'com.example.app' + ); + expect(jest.mocked(spawn).mock.calls).toEqual([ + [ + 'plutil', + ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')], + { stdio: 'pipe', env: {} }, + ], + ]); + }); + + it.each(['missing', 'file', 'wrong extension'])('rejects an invalid artifact: %s', async kind => { + await rm(artifactPath, { recursive: true }); + if (kind === 'file') { + await writeFile(artifactPath, 'not an app directory'); + } else if (kind === 'wrong extension') { + artifactPath = path.join(directory, 'Example.ipa'); + await mkdir(artifactPath); + } + await expect( + readIosApplicationIdentifierAsync({ artifactPath, env: {} }) + ).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_INVALID_ARTIFACT' }); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('rejects a missing bundle identifier', async () => { + jest.mocked(spawn).mockResolvedValue({ stdout: ' ', stderr: '' } as never); + await expect( + readIosApplicationIdentifierAsync({ artifactPath, env: {} }) + ).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER' }); + }); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts index 25748e65b4..d8f8335537 100644 --- a/packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts @@ -4,7 +4,7 @@ import spawn from '@expo/turtle-spawn'; import { createMockLogger } from '../../../__tests__/utils/logger'; import { IosSimulatorUtils, type IosSimulatorUuid } from '../../../utils/IosSimulatorUtils'; import { resolveIosSessionStartupAsync } from '../iosSimulatorSession'; -import { readIosApplicationIdentifierAsync } from '../serveSimActions'; +import { readIosApplicationIdentifierAsync } from '../iosAppArtifact'; import { resolveLocalEgressServeSimBootEnvironmentAsync, startLocalEgressGuardRelayAsync, @@ -12,7 +12,7 @@ import { jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); jest.mock('../../../utils/IosSimulatorUtils'); -jest.mock('../serveSimActions', () => ({ readIosApplicationIdentifierAsync: jest.fn() })); +jest.mock('../iosAppArtifact', () => ({ readIosApplicationIdentifierAsync: jest.fn() })); jest.mock('../localEgressGuard', () => ({ resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(), startLocalEgressGuardRelayAsync: jest.fn(), diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts index 7080a77441..55d254ef13 100644 --- a/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts @@ -1,4 +1,3 @@ -import spawn from '@expo/turtle-spawn'; import { once } from 'node:events'; import { access, @@ -16,13 +15,7 @@ import os from 'node:os'; import path from 'node:path'; import { WebSocketServer } from 'ws'; -import { - readIosApplicationIdentifierAsync, - runServeSimActionAsync, - stageServeSimAppAsync, -} from '../serveSimActions'; - -jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); +import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions'; jest.unmock('fs'); jest.unmock('node:fs'); @@ -151,54 +144,3 @@ it("stages a local app without consuming the caller's original", async () => { await rm(root, { recursive: true, force: true }); } }); - -describe(readIosApplicationIdentifierAsync, () => { - let directory: string; - let artifactPath: string; - - beforeEach(async () => { - jest.mocked(spawn).mockReset(); - directory = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-app-metadata-')); - artifactPath = path.join(directory, 'Example.app'); - await mkdir(artifactPath); - }); - - afterEach(async () => { - await rm(directory, { recursive: true, force: true }); - }); - - it('reads app metadata without installing or launching', async () => { - jest.mocked(spawn).mockResolvedValue({ stdout: 'com.example.app\n', stderr: '' } as never); - await expect(readIosApplicationIdentifierAsync({ artifactPath, env: {} })).resolves.toBe( - 'com.example.app' - ); - expect(jest.mocked(spawn).mock.calls).toEqual([ - [ - 'plutil', - ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')], - { stdio: 'pipe', env: {} }, - ], - ]); - }); - - it.each(['missing', 'file', 'wrong extension'])('rejects an invalid artifact: %s', async kind => { - await rm(artifactPath, { recursive: true }); - if (kind === 'file') { - await writeFile(artifactPath, 'not an app directory'); - } else if (kind === 'wrong extension') { - artifactPath = path.join(directory, 'Example.ipa'); - await mkdir(artifactPath); - } - await expect( - readIosApplicationIdentifierAsync({ artifactPath, env: {} }) - ).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_INVALID_ARTIFACT' }); - expect(spawn).not.toHaveBeenCalled(); - }); - - it('rejects a missing bundle identifier', async () => { - jest.mocked(spawn).mockResolvedValue({ stdout: ' ', stderr: '' } as never); - await expect( - readIosApplicationIdentifierAsync({ artifactPath, env: {} }) - ).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER' }); - }); -}); diff --git a/packages/build-tools/src/steps/utils/iosAppArtifact.ts b/packages/build-tools/src/steps/utils/iosAppArtifact.ts new file mode 100644 index 0000000000..145f0bae0e --- /dev/null +++ b/packages/build-tools/src/steps/utils/iosAppArtifact.ts @@ -0,0 +1,41 @@ +import { UserError } from '@expo/eas-build-job'; +import { type BuildStepEnv } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import fs from 'node:fs'; +import path from 'node:path'; + +export async function readIosApplicationIdentifierAsync({ + artifactPath, + env, +}: { + artifactPath: string; + env: BuildStepEnv; +}): Promise { + const artifactStat = await fs.promises.stat(artifactPath).catch(err => { + throw new UserError( + 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', + `Build artifact does not exist at ${artifactPath}.`, + { cause: err } + ); + }); + if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) { + throw new UserError( + 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', + 'iOS Simulator sessions require a .app build artifact.' + ); + } + const infoPlistPath = path.join(artifactPath, 'Info.plist'); + const { stdout } = await spawn( + 'plutil', + ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath], + { stdio: 'pipe', env } + ); + const applicationIdentifier = stdout.trim(); + if (!applicationIdentifier) { + throw new UserError( + 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER', + `Could not read CFBundleIdentifier from ${infoPlistPath}.` + ); + } + return applicationIdentifier; +} diff --git a/packages/build-tools/src/steps/utils/iosSimulatorSession.ts b/packages/build-tools/src/steps/utils/iosSimulatorSession.ts index b809fa2cfc..8948bece00 100644 --- a/packages/build-tools/src/steps/utils/iosSimulatorSession.ts +++ b/packages/build-tools/src/steps/utils/iosSimulatorSession.ts @@ -13,7 +13,7 @@ import { resolveLocalEgressServeSimBootEnvironmentAsync, startLocalEgressGuardRelayAsync, } from './localEgressGuard'; -import { readIosApplicationIdentifierAsync } from './serveSimActions'; +import { readIosApplicationIdentifierAsync } from './iosAppArtifact'; import { type ServeSimLaunchOptions, parseServeSimLaunchInputs, diff --git a/packages/build-tools/src/steps/utils/serveSimActions.ts b/packages/build-tools/src/steps/utils/serveSimActions.ts index 7aea869a61..562a39415d 100644 --- a/packages/build-tools/src/steps/utils/serveSimActions.ts +++ b/packages/build-tools/src/steps/utils/serveSimActions.ts @@ -1,7 +1,5 @@ -import { SystemError, UserError } from '@expo/eas-build-job'; -import { type BuildStepEnv } from '@expo/steps'; -import spawn from '@expo/turtle-spawn'; -import fs, { constants } from 'node:fs'; +import { SystemError } from '@expo/eas-build-job'; +import { constants } from 'node:fs'; import { cp, mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -101,39 +99,3 @@ export async function runServeSimActionAsync({ } }); } - -export async function readIosApplicationIdentifierAsync({ - artifactPath, - env, -}: { - artifactPath: string; - env: BuildStepEnv; -}): Promise { - const artifactStat = await fs.promises.stat(artifactPath).catch(err => { - throw new UserError( - 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', - `Build artifact does not exist at ${artifactPath}.`, - { cause: err } - ); - }); - if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) { - throw new UserError( - 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', - 'iOS Simulator sessions require a .app build artifact.' - ); - } - const infoPlistPath = path.join(artifactPath, 'Info.plist'); - const { stdout } = await spawn( - 'plutil', - ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath], - { stdio: 'pipe', env } - ); - const applicationIdentifier = stdout.trim(); - if (!applicationIdentifier) { - throw new UserError( - 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER', - `Could not read CFBundleIdentifier from ${infoPlistPath}.` - ); - } - return applicationIdentifier; -} From 6026bec2440d8aac82b641477d8c454d8debcf18 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 10 Oct 2026 13:05:32 -0700 Subject: [PATCH 4/4] [build-tools] Cover serve-sim startup validation and failures --- .changeset/serve-sim-pr-4605.md | 5 ++ CHANGELOG.md | 2 - .../__tests__/iosSimulatorSession.test.ts | 17 +++++++ .../utils/__tests__/localEgressGuard.test.ts | 49 +++++++++++++++++-- .../__tests__/remoteDeviceRunSession.test.ts | 47 ++++++++++++++++++ .../utils/__tests__/serveSimActions.test.ts | 42 +++++++++++++++- 6 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 .changeset/serve-sim-pr-4605.md diff --git a/.changeset/serve-sim-pr-4605.md b/.changeset/serve-sim-pr-4605.md new file mode 100644 index 0000000000..1eea9938a9 --- /dev/null +++ b/.changeset/serve-sim-pr-4605.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': none +--- + +Add shared opt-in serve-sim Simulator startup support. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d8509ed56..d0361b9213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,8 +40,6 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Install ffmpeg for Appium simulator sessions. ([#4577](https://github.com/expo/eas-cli/pull/4577) by [@gwdp](https://github.com/gwdp)) - [build-tools] Close the file of an artifact upload before reporting that the upload is done. ([#4609](https://github.com/expo/eas-cli/pull/4609) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR)) -- [build-tools] Add shared opt-in serve-sim Simulator startup support. ([#4605](https://github.com/expo/eas-cli/pull/4605) by [@gwdp](https://github.com/gwdp)) - ## [24.12.1](https://github.com/expo/eas-cli/releases/tag/v24.12.1) - 2026-10-08 ### 🐛 Bug fixes diff --git a/packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts index d8f8335537..b9e6ad4741 100644 --- a/packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts @@ -61,6 +61,23 @@ describe('serve-sim Simulator preparation', () => { expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled(); }); + it.each([ + ['an oversized URL', `https://example.test/${'a'.repeat(8192)}`], + ['NUL in a URL', 'exp://example.test/a\0b'], + ])('rejects %s before Simulator preparation', async (_, openUrl) => { + await expect( + resolveIosSessionStartupAsync({ + ...hostOptions, + bootSimulator: true, + launchAppIdentifier: 'dev.example.app', + openUrl, + }) + ).rejects.toThrow('iOS open_url'); + expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled(); + expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled(); + expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled(); + }); + it('keeps an explicit launch identifier independent of the installed app', async () => { const startup = await resolveIosSessionStartupAsync({ ...hostOptions, diff --git a/packages/build-tools/src/steps/utils/__tests__/localEgressGuard.test.ts b/packages/build-tools/src/steps/utils/__tests__/localEgressGuard.test.ts index 7ceffd6935..1578208be1 100644 --- a/packages/build-tools/src/steps/utils/__tests__/localEgressGuard.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/localEgressGuard.test.ts @@ -403,6 +403,38 @@ describe(installLocalEgressGuardAsync, () => { expect(sessionLogger.lines.at(-1)?.msg).toContain('refused 1 connection attempt(s)'); }); + it('still installs the guard when its event log cannot be created', async () => { + const handoffPath = path.join(dir, 'handoff.json'); + await writeLocalEgressHandoffAsync(handoff, handoffPath); + const libraryPath = path.join(dir, 'egress-guard.dylib'); + const blockedDirectory = path.join(dir, 'not-a-directory'); + await fs.promises.writeFile(blockedDirectory, 'file'); + await expect( + installLocalEgressGuardAsync({ + udid: 'u' as any, + env: process.env, + logger, + handoffPath, + libraryPath, + logPath: path.join(blockedDirectory, 'guard.log'), + }) + ).resolves.toBe(true); + expect(mockedSetEnv).toHaveBeenCalledWith({ + udid: 'u', + env: process.env, + variables: buildGuardLaunchdEnvironment({ + libraryPath, + logPath: path.join(blockedDirectory, 'guard.log'), + mode: 'block', + }), + }); + expect( + logger.lines.some( + line => line.level === 'warn' && line.msg.includes('They are still refused.') + ) + ).toBe(true); + }); + it('fails the session when launchctl fails', async () => { const handoffPath = path.join(dir, 'handoff.json'); await writeLocalEgressHandoffAsync(handoff, handoffPath); @@ -530,7 +562,7 @@ describe(resolveLocalEgressServeSimBootEnvironmentAsync, () => { }); describe(startLocalEgressGuardRelayAsync, () => { - it('relays boot events without setting launchd environment or truncating the log', async () => { + it('relays boot events once after repeated starts without changing Simulator state', async () => { const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-relay-')); const logPath = path.join(dir, 'guard.log'); const logger = createLogger(); @@ -539,21 +571,28 @@ describe(startLocalEgressGuardRelayAsync, () => { logPath, 'eas-egress-guard\tSpringBoard\t4001\tconnect\tblocked\t1.1.1.1:443\tNetwork\n' ); + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); try { - await startLocalEgressGuardRelayAsync({ logger, logPath }); + await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs: 5 }); + await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs: 5 }); await fs.promises.appendFile( logPath, 'eas-egress-guard\tMyApp\t4002\tconnect\tblocked\t1.1.1.1:443\tNetwork\n' ); + await jest.advanceTimersByTimeAsync(20); await stopLocalEgressGuardRelaysAsync(logger); expect(mockedSetEnv).not.toHaveBeenCalled(); - expect(logger.lines.some(line => /refused connect from SpringBoard/.test(line.msg))).toBe( - true + expect( + logger.lines.filter(line => /refused connect from SpringBoard/.test(line.msg)) + ).toHaveLength(1); + expect(logger.lines.filter(line => /refused connect from MyApp/.test(line.msg))).toHaveLength( + 1 ); - expect(logger.lines.some(line => /refused connect from MyApp/.test(line.msg))).toBe(true); expect(logger.lines.at(-1)?.msg).toContain('refused 2 connection attempt(s)'); } finally { await stopLocalEgressGuardRelaysAsync(logger); + jest.clearAllTimers(); + jest.useRealTimers(); await fs.promises.rm(dir, { recursive: true, force: true }); } }); diff --git a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts index f0d833a5c8..3981fc5a9e 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -27,6 +27,7 @@ import { readServeSimServersAsync } from '../serveSimMetricsRecorder'; import { sleepAsync } from '../../../utils/retry'; import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings'; import { + createIosSessionStartupInputProviders, createServeSimLaunchInputProviders, describeServeSimLaunch, ensureFfmpegInstalledOnceAsync, @@ -35,6 +36,7 @@ import { spawnDetached, startNgrokTunnelAsync, turnIceServersToWebPreviewArgs, + validateServeSimLaunchOptions, waitForDeviceRunSessionStoppedAsync, } from '../remoteDeviceRunSession'; @@ -213,6 +215,51 @@ function createEnvMock(): BuildStepEnv { return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv; } +describe(createIosSessionStartupInputProviders, () => { + it('declares optional inputs so existing workflows do not opt into owned startup', () => { + const inputs = createIosSessionStartupInputProviders().map(provider => + provider(createGlobalContextMock(), 'Test step') + ); + expect( + inputs.map(({ id, required, allowedValueTypeName }) => ({ + id, + required, + allowedValueTypeName, + })) + ).toEqual([ + { + id: 'boot_simulator', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }, + { + id: 'device_identifier', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }, + { + id: 'install_app_path', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }, + ]); + }); +}); + +describe(validateServeSimLaunchOptions, () => { + it('accepts empty options and arguments at the count, character and byte limits', () => { + expect(() => validateServeSimLaunchOptions({})).not.toThrow(); + expect(() => validateServeSimLaunchOptions({ launchArgs: Array(256).fill('a') })).not.toThrow(); + expect(() => validateServeSimLaunchOptions({ launchArgs: ['a'.repeat(8192)] })).not.toThrow(); + expect(() => + validateServeSimLaunchOptions({ launchArgs: Array(16).fill('a'.repeat(8191)) }) + ).not.toThrow(); + expect(() => + validateServeSimLaunchOptions({ openUrl: `https://${'a'.repeat(8184)}` }) + ).not.toThrow(); + }); +}); + describe(createServeSimLaunchInputProviders, () => { it('declares the launch inputs as optional', () => { const globalCtx = createGlobalContextMock(); diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts index 55d254ef13..fe7695a3e9 100644 --- a/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts @@ -5,6 +5,7 @@ import { mkdir, mkdtemp, readFile, + readdir, readlink, rm, stat, @@ -48,9 +49,12 @@ afterEach(async () => { it('authenticates the upgrade, waits for ready, and sends the action once', async () => { const actions: unknown[] = []; + const upgrades: unknown[] = []; server.on('connection', (socket, upgrade) => { - expect(upgrade.url).toBe('/exec-ws'); - expect(upgrade.headers['sec-websocket-protocol']).toBe(`serve-sim.token.${token}`); + upgrades.push({ + url: upgrade.url, + protocol: upgrade.headers['sec-websocket-protocol'], + }); socket.on('message', raw => { actions.push(JSON.parse(raw.toString())); socket.send(JSON.stringify({ id: 1, stdout: '', stderr: '', exitCode: 0 })); @@ -59,6 +63,7 @@ it('authenticates the upgrade, waits for ready, and sends the action once', asyn socket.send(JSON.stringify({ ready: true })); }); await runServeSimActionAsync(request()); + expect(upgrades).toEqual([{ url: '/exec-ws', protocol: `serve-sim.token.${token}` }]); expect(actions).toEqual([{ id: 1, action: 'app.install', params: request().params }]); }); @@ -85,6 +90,27 @@ it('rejects a connection that closes before its action finishes', async () => { await expect(runServeSimActionAsync(request())).rejects.toThrow('closed before completion'); }); +it('rejects an upgrade refused by the server without executing an action', async () => { + const connected = jest.fn(); + server.options.verifyClient = () => false; + server.on('connection', connected); + await expect(runServeSimActionAsync(request())).rejects.toThrow('connection failed'); + expect(connected).not.toHaveBeenCalled(); +}); + +it('rejects malformed replies without resending the action', async () => { + const actions = jest.fn(); + server.on('connection', socket => { + socket.send(JSON.stringify({ ready: true })); + socket.on('message', () => { + actions(); + socket.send('not JSON'); + }); + }); + await expect(runServeSimActionAsync(request())).rejects.toThrow('returned invalid JSON'); + expect(actions).toHaveBeenCalledTimes(1); +}); + it('times out and closes a socket that never becomes ready', async () => { const disconnected = new Promise(resolve => server.on('connection', socket => socket.on('close', resolve)) @@ -144,3 +170,15 @@ it("stages a local app without consuming the caller's original", async () => { await rm(root, { recursive: true, force: true }); } }); + +it('removes its staging directory when the source app cannot be read', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-stage-test-')); + try { + await expect(stageServeSimAppAsync(path.join(root, 'Missing.app'), root)).rejects.toThrow( + 'ENOENT' + ); + expect(await readdir(path.join(root, 'serve-sim-uploads'))).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } +});