From 7f5889026a1a0b848703e9d1b8b4bda79d44611a Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 1 Sep 2026 23:23:10 -0700 Subject: [PATCH] [build-tools] hand the iOS application launch to serve-sim --- CHANGELOG.md | 2 + ...tDeviceRemoteSession-orchestration.test.ts | 187 ++++++++++++++++ ...tArgentRemoteSession-orchestration.test.ts | 40 ++++ .../startWebPreviewRemoteSession.test.ts | 65 +++++- .../startAgentDeviceRemoteSession.ts | 12 + .../functions/startArgentRemoteSession.ts | 12 + .../functions/startWebPreviewRemoteSession.ts | 12 + .../__tests__/remoteDeviceRunSession.test.ts | 206 +++++++++++++++++- .../src/steps/utils/remoteDeviceRunSession.ts | 133 ++++++++++- 9 files changed, 659 insertions(+), 10 deletions(-) create mode 100644 packages/build-tools/src/steps/functions/__tests__/startAgentDeviceRemoteSession-orchestration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d46408f8ab..3641646cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features +- [build-tools] Add `launch_app_identifier`, `launch_args`, and `open_url` inputs to iOS remote sessions, so serve-sim launches an installed app before the stream starts. ([#4324](https://github.com/expo/eas-cli/pull/4324) by [@gwdp](https://github.com/gwdp)) + ### 🐛 Bug fixes - [build-tools] Reduce `expo-device-hub` preview resolution from 1280 px to 960 px to match `serve-sim` and lower streaming bandwidth. ([#4326](https://github.com/expo/eas-cli/pull/4326) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent)) diff --git a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceRemoteSession-orchestration.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceRemoteSession-orchestration.test.ts new file mode 100644 index 0000000000..917e43be61 --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceRemoteSession-orchestration.test.ts @@ -0,0 +1,187 @@ +import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { type CustomBuildContext } from '../../../customBuildContext'; +import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts'; +import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents'; +import { + getDeviceRunSessionIdOrThrow, + getNgrokAuthtokenOrThrow, + getNgrokTunnelDomainOrThrow, + selectXcodeDeveloperDirectoryAsync, + spawnDetached, + startDeviceWebPreviewWithTunnelAsync, + startNgrokTunnelAsync, + uploadRemoteSessionConfigAsync, + waitForDeviceRunSessionStoppedAsync, + waitForFileAsync, +} from '../../utils/remoteDeviceRunSession'; +import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession'; + +// The daemon entry path and the state directory are resolved from the home directory when +// the module loads, so point it at a temp home we can populate. +jest.mock('node:os', () => { + const actual = jest.requireActual('node:os'); + const actualPath = jest.requireActual('node:path'); + return { + ...actual, + homedir: () => actualPath.join(actual.tmpdir(), 'eas-agent-device-orchestration-home'), + }; +}); +jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('../../../sentry'); +jest.mock('../../utils/agentDeviceArtifacts', () => ({ + pollAgentDeviceArtifactsForUploadAsync: jest.fn(), +})); +jest.mock('../../utils/agentDeviceEvents', () => ({ + startAgentDeviceEventCollectionAsync: jest.fn(), +})); +jest.mock('../../utils/remoteDeviceRunSession', () => ({ + ...jest.requireActual('../../utils/remoteDeviceRunSession'), + getDeviceRunSessionIdOrThrow: jest.fn(), + getNgrokAuthtokenOrThrow: jest.fn(), + getNgrokTunnelDomainOrThrow: jest.fn(), + selectXcodeDeveloperDirectoryAsync: jest.fn(), + spawnDetached: jest.fn(), + startDeviceWebPreviewWithTunnelAsync: jest.fn(), + startNgrokTunnelAsync: jest.fn(), + uploadRemoteSessionConfigAsync: jest.fn(), + waitForDeviceRunSessionStoppedAsync: jest.fn(), + waitForFileAsync: jest.fn(), +})); + +const TEST_HOME = path.join(os.tmpdir(), 'eas-agent-device-orchestration-home'); +const DAEMON_ENTRY_PATH = path.join( + TEST_HOME, + '.bun/install/global/node_modules/agent-device/dist/src/internal/daemon.js' +); + +const ctx = {} as unknown as CustomBuildContext; +const mockPreviewStopAsync = jest.fn(); +const mockTunnelStopAsync = jest.fn(); +const mockDaemonStopAsync = jest.fn(); +const mockEventCollectionStopAsync = jest.fn(); + +async function runAsync( + logger: { info: jest.Mock; warn: jest.Mock }, + runtimePlatform: BuildRuntimePlatform, + launchInputs: Record = {} +): Promise { + const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx); + await buildFunction.fn!( + { logger, global: { runtimePlatform } } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + max_duration_seconds: { value: undefined }, + ...launchInputs, + }, + outputs: {}, + env: {}, + } as never + ); +} + +describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () => { + beforeEach(async () => { + jest.clearAllMocks(); + + jest.mocked(spawn).mockResolvedValue(undefined as never); + jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue(undefined); + jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue({ + stopAsync: mockEventCollectionStopAsync, + getLastEventObservedAt: () => undefined, + }); + jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id'); + jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com'); + jest.mocked(getNgrokAuthtokenOrThrow).mockReturnValue('ngrok-token'); + jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined); + jest.mocked(spawnDetached).mockReturnValue({ + pid: 4242, + getOutput: () => '', + stopAsync: mockDaemonStopAsync, + }); + jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token: 'daemon-token' }); + jest.mocked(startNgrokTunnelAsync).mockResolvedValue({ + url: 'https://agent-device-abc.tunnel.example.com', + stopAsync: mockTunnelStopAsync, + }); + jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({ + previewUrl: 'https://web-preview.tunnel.example.com', + stopAsync: mockPreviewStopAsync, + }); + jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined); + jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined); + + await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true }); + await fs.promises.writeFile(DAEMON_ENTRY_PATH, ''); + }); + + afterEach(async () => { + await fs.promises.rm(TEST_HOME, { recursive: true, force: true }); + }); + + it('reports the preview URL and tears every resource down', async () => { + const logger = { info: jest.fn(), warn: jest.fn() }; + + await runAsync(logger, BuildRuntimePlatform.LINUX); + + expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled(); + expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX }) + ); + expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith( + expect.objectContaining({ + remoteConfig: expect.objectContaining({ + agentDeviceRemoteSessionUrl: 'https://agent-device-abc.tunnel.example.com', + agentDeviceRemoteSessionToken: 'daemon-token', + webPreviewUrl: 'https://web-preview.tunnel.example.com', + }), + }) + ); + expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); + expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1); + expect(mockEventCollectionStopAsync).toHaveBeenCalledTimes(1); + expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1); + }); + + it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => { + const logger = { info: jest.fn(), warn: jest.fn() }; + + await runAsync(logger, BuildRuntimePlatform.DARWIN, { + launch_app_identifier: { value: 'host.exp.Exponent' }, + launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] }, + open_url: { value: 'exp://127.0.0.1:8081' }, + }); + + expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + runtimePlatform: BuildRuntimePlatform.DARWIN, + launchAppIdentifier: 'host.exp.Exponent', + launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'], + openUrl: 'exp://127.0.0.1:8081', + }) + ); + expect(logger.info).toHaveBeenCalledWith( + 'serve-sim will launch host.exp.Exponent with arguments ' + + '["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.' + ); + }); + + it('fails before starting the daemon when a launch is asked for on Android', async () => { + const logger = { info: jest.fn(), warn: jest.fn() }; + + await expect( + runAsync(logger, BuildRuntimePlatform.LINUX, { + launch_app_identifier: { value: 'host.exp.Exponent' }, + }) + ).rejects.toThrow('runs on linux'); + expect(spawnDetached).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts b/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts index bd298871fd..2b36b23c73 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts @@ -42,6 +42,7 @@ jest.mock('../../utils/argentEvents', () => ({ startArgentEventCollectionAsync: jest.fn(), })); jest.mock('../../utils/remoteDeviceRunSession', () => ({ + ...jest.requireActual('../../utils/remoteDeviceRunSession'), ensureFfmpegInstalledOnceAsync: jest.fn(), getDeviceRunSessionIdOrThrow: jest.fn(), getNgrokAuthtokenOrThrow: jest.fn(), @@ -182,4 +183,43 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { ); expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); }); + + it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => { + const ctx = {} as unknown as CustomBuildContext; + const logger = { info: jest.fn(), warn: jest.fn() }; + const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx); + + await buildFunction.fn!( + { + logger, + global: { runtimePlatform: BuildRuntimePlatform.DARWIN }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + max_duration_seconds: { value: undefined }, + launch_app_identifier: { value: 'host.exp.Exponent' }, + launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] }, + open_url: { value: 'exp://127.0.0.1:8081' }, + }, + outputs: {}, + env: { EXISTING: 'value' }, + } as never + ); + + expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + runtimePlatform: BuildRuntimePlatform.DARWIN, + launchAppIdentifier: 'host.exp.Exponent', + launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'], + openUrl: 'exp://127.0.0.1:8081', + }) + ); + expect(logger.info).toHaveBeenCalledWith( + 'serve-sim will launch host.exp.Exponent with arguments ' + + '["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.' + ); + }); }); diff --git a/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts index 02113966bf..5596826510 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts @@ -1,6 +1,7 @@ import { type bunyan } from '@expo/logger'; import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps'; +import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { type CustomBuildContext } from '../../../customBuildContext'; import { getDeviceRunSessionIdOrThrow, @@ -12,14 +13,25 @@ import { } from '../../utils/remoteDeviceRunSession'; import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession'; -jest.mock('../../utils/remoteDeviceRunSession'); +jest.mock('../../utils/remoteDeviceRunSession', () => ({ + ...jest.requireActual('../../utils/remoteDeviceRunSession'), + getDeviceRunSessionIdOrThrow: jest.fn(), + getNgrokTunnelDomainOrThrow: jest.fn(), + selectXcodeDeveloperDirectoryAsync: jest.fn(), + startDeviceWebPreviewWithTunnelAsync: jest.fn(), + uploadRemoteSessionConfigAsync: jest.fn(), + waitForDeviceRunSessionStoppedAsync: jest.fn(), +})); const ctx = {} as CustomBuildContext; const env = {} as BuildStepEnv; const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan; const stopAsync = jest.fn(); -async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise { +async function runAsync( + runtimePlatform: BuildRuntimePlatform, + launchInputs: Record = {} +): Promise { const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx); await buildFunction.fn!( { @@ -30,6 +42,7 @@ async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise { inputs: { package_version: { value: '1.2.3' }, max_duration_seconds: { value: 120 }, + ...launchInputs, }, outputs: {}, env, @@ -66,6 +79,9 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { logger, timeoutMs: 60_000, packageVersion: '1.2.3', + launchAppIdentifier: undefined, + launchArgs: [], + openUrl: undefined, }); expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({ ctx, @@ -82,4 +98,49 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { }); expect(stopAsync).toHaveBeenCalledTimes(1); }); + + it('declares the launch inputs', () => { + const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx); + const globalCtx = createGlobalContextMock(); + + expect( + buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id) + ).toEqual([ + 'launch_app_identifier', + 'launch_args', + 'open_url', + 'package_version', + 'max_duration_seconds', + ]); + }); + + it('hands the launch inputs to the web preview and announces them', async () => { + await runAsync(BuildRuntimePlatform.DARWIN, { + launch_app_identifier: { value: 'host.exp.Exponent' }, + launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] }, + open_url: { value: 'exp://127.0.0.1:8081' }, + }); + + expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + launchAppIdentifier: 'host.exp.Exponent', + launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'], + openUrl: 'exp://127.0.0.1:8081', + }) + ); + expect(logger.info).toHaveBeenCalledWith( + 'serve-sim will launch host.exp.Exponent with arguments ' + + '["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.' + ); + }); + + it('fails before starting anything when a launch is asked for on Android', async () => { + await expect( + runAsync(BuildRuntimePlatform.LINUX, { + launch_app_identifier: { value: 'host.exp.Exponent' }, + }) + ).rejects.toThrow('runs on linux'); + expect(startDeviceWebPreviewWithTunnelAsync).not.toHaveBeenCalled(); + }); }); diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts index df0c8b6e64..2eebe96363 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts @@ -18,9 +18,12 @@ import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArti import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents'; import { type DetachedProcessHandle, + createServeSimLaunchInputProviders, + describeServeSimLaunch, getDeviceRunSessionIdOrThrow, getNgrokAuthtokenOrThrow, getNgrokTunnelDomainOrThrow, + parseServeSimLaunchInputs, selectXcodeDeveloperDirectoryAsync, spawnDetached, startDeviceWebPreviewWithTunnelAsync, @@ -50,6 +53,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( name: 'Start agent device remote session', __metricsId: 'eas/start_agent_device_remote_session', inputProviders: [ + ...createServeSimLaunchInputProviders(), BuildStepInput.createProvider({ id: 'package_version', required: false, @@ -80,6 +84,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined; const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined; const { runtimePlatform } = global; + const launch = parseServeSimLaunchInputs(inputs, { runtimePlatform }); logger.info( `Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).` ); @@ -118,7 +123,14 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( env, logger, timeoutMs: STARTUP_TIMEOUT_MS, + launchAppIdentifier: launch.launchAppIdentifier, + launchArgs: launch.launchArgs, + openUrl: launch.openUrl, }); + const launchDescription = describeServeSimLaunch(launch); + if (launchDescription) { + logger.info(launchDescription); + } logger.info(`Web preview URL: ${webPreview.previewUrl}`); await uploadRemoteSessionConfigAsync({ diff --git a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts index 136f446a12..faa9cbb423 100644 --- a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts @@ -20,10 +20,13 @@ import { sleepAsync } from '../../utils/retry'; import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts'; import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents'; import { + createServeSimLaunchInputProviders, + describeServeSimLaunch, ensureFfmpegInstalledOnceAsync, getDeviceRunSessionIdOrThrow, getNgrokAuthtokenOrThrow, getNgrokTunnelDomainOrThrow, + parseServeSimLaunchInputs, selectXcodeDeveloperDirectoryAsync, spawnDetached, startDeviceWebPreviewWithTunnelAsync, @@ -63,6 +66,7 @@ export function createStartArgentRemoteSessionBuildFunction( name: 'Start argent remote session', __metricsId: 'eas/start_argent_remote_session', inputProviders: [ + ...createServeSimLaunchInputProviders(), BuildStepInput.createProvider({ id: 'package_version', required: false, @@ -95,6 +99,7 @@ export function createStartArgentRemoteSessionBuildFunction( warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger }); const versionSpec = packageVersion ?? 'latest'; const { runtimePlatform } = global; + const launch = parseServeSimLaunchInputs(inputs, { runtimePlatform }); logger.info( `Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).` ); @@ -206,7 +211,14 @@ export function createStartArgentRemoteSessionBuildFunction( env, logger, timeoutMs: STARTUP_TIMEOUT_MS, + launchAppIdentifier: launch.launchAppIdentifier, + launchArgs: launch.launchArgs, + openUrl: launch.openUrl, }); + const launchDescription = describeServeSimLaunch(launch); + if (launchDescription) { + logger.info(launchDescription); + } logger.info(`Web preview URL: ${webPreview.previewUrl}`); await uploadRemoteSessionConfigAsync({ diff --git a/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts b/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts index e6d54810c4..9df006179a 100644 --- a/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts @@ -7,8 +7,11 @@ import { import { CustomBuildContext } from '../../customBuildContext'; import { + createServeSimLaunchInputProviders, + describeServeSimLaunch, getDeviceRunSessionIdOrThrow, getNgrokTunnelDomainOrThrow, + parseServeSimLaunchInputs, selectXcodeDeveloperDirectoryAsync, startDeviceWebPreviewWithTunnelAsync, uploadRemoteSessionConfigAsync, @@ -26,6 +29,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction( name: 'Start web preview remote session', __metricsId: 'eas/start_serve_sim_remote_session', inputProviders: [ + ...createServeSimLaunchInputProviders(), BuildStepInput.createProvider({ id: 'package_version', required: false, @@ -43,8 +47,13 @@ export function createStartWebPreviewRemoteSessionBuildFunction( const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined; const packageVersion = inputs.package_version?.value as string | undefined; const { runtimePlatform } = global; + const launch = parseServeSimLaunchInputs(inputs, { runtimePlatform }); logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`); + const launchDescription = describeServeSimLaunch(launch); + if (launchDescription) { + logger.info(launchDescription); + } if (runtimePlatform === BuildRuntimePlatform.DARWIN) { await selectXcodeDeveloperDirectoryAsync({ env, logger }); @@ -57,6 +66,9 @@ export function createStartWebPreviewRemoteSessionBuildFunction( logger, timeoutMs: STARTUP_TIMEOUT_MS, packageVersion, + launchAppIdentifier: launch.launchAppIdentifier, + launchArgs: launch.launchArgs, + openUrl: launch.openUrl, }); logger.info(`Preview URL: ${webPreview.previewUrl}`); 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 c3ce207fe3..083756d179 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -1,5 +1,5 @@ import { bunyan } from '@expo/logger'; -import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps'; +import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from '@expo/steps'; import spawn from '@expo/turtle-spawn'; import * as ngrok from '@ngrok/ngrok'; import { @@ -8,6 +8,7 @@ import { } from 'node:timers'; import { setTimeout as setTimeoutAsync } from 'node:timers/promises'; +import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { CustomBuildContext } from '../../../customBuildContext'; import { Sentry } from '../../../sentry'; import { turtleFetch } from '../../../utils/turtleFetch'; @@ -15,9 +16,12 @@ import { sleepAsync } from '../../../utils/retry'; import { createExpoDeviceHubArgs, createServeSimArgs, + createServeSimLaunchInputProviders, + describeServeSimLaunch, ensureFfmpegInstalledOnceAsync, fetchWebPreviewTurnArgsAsync, metricsCorsOriginToServeSimArgs, + parseServeSimLaunchInputs, startDeviceWebPreviewWithTunnelAsync, startExpoDeviceHubWithTunnelAsync, startNgrokTunnelAsync, @@ -116,6 +120,137 @@ function createEnvMock(): BuildStepEnv { return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv; } +describe(createServeSimLaunchInputProviders, () => { + it('declares the launch inputs as optional', () => { + const globalCtx = createGlobalContextMock(); + const inputs = createServeSimLaunchInputProviders().map(provider => + provider(globalCtx, 'Test step') + ); + + expect( + inputs.map(({ id, required, allowedValueTypeName }) => ({ + id, + required, + allowedValueTypeName, + })) + ).toEqual([ + { + id: 'launch_app_identifier', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }, + { + id: 'launch_args', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + }, + { + id: 'open_url', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }, + ]); + }); +}); + +describe(parseServeSimLaunchInputs, () => { + const darwin = { runtimePlatform: BuildRuntimePlatform.DARWIN }; + + it('reads the launch identifier, arguments and URL', () => { + expect( + parseServeSimLaunchInputs( + { + launch_app_identifier: { value: 'host.exp.Exponent' }, + launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] }, + open_url: { value: 'exp://127.0.0.1:8081' }, + }, + darwin + ) + ).toEqual({ + launchAppIdentifier: 'host.exp.Exponent', + launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'], + openUrl: 'exp://127.0.0.1:8081', + }); + }); + + it('defaults to no launch when the step declares nothing', () => { + expect(parseServeSimLaunchInputs({}, { runtimePlatform: BuildRuntimePlatform.LINUX })).toEqual({ + launchAppIdentifier: undefined, + launchArgs: [], + openUrl: undefined, + }); + }); + + it('rejects launch arguments that are not a list', () => { + expect(() => + parseServeSimLaunchInputs( + { + launch_app_identifier: { value: 'host.exp.Exponent' }, + launch_args: { value: 'oops' }, + }, + darwin + ) + ).toThrow('must be an array of strings'); + }); + + it('rejects launch arguments that are not all strings', () => { + expect(() => + parseServeSimLaunchInputs( + { + launch_app_identifier: { value: 'host.exp.Exponent' }, + launch_args: { value: ['-flag', 1] }, + }, + darwin + ) + ).toThrow('must be an array of strings'); + }); + + it('rejects launch arguments with no application to launch', () => { + expect(() => parseServeSimLaunchInputs({ launch_args: { value: ['-flag'] } }, darwin)).toThrow( + 'Pass "launch_app_identifier"' + ); + }); + + it('rejects a URL with no application to open it in', () => { + expect(() => + parseServeSimLaunchInputs({ open_url: { value: 'exp://127.0.0.1:8081' } }, darwin) + ).toThrow('Pass "launch_app_identifier"'); + }); + + it('rejects a launch on a session that does not run an iOS simulator', () => { + expect(() => + parseServeSimLaunchInputs( + { launch_app_identifier: { value: 'host.exp.Exponent' } }, + { runtimePlatform: BuildRuntimePlatform.LINUX } + ) + ).toThrow('runs on linux'); + }); +}); + +describe(describeServeSimLaunch, () => { + it('says nothing when there is no application to launch', () => { + expect(describeServeSimLaunch({ launchArgs: [] })).toBeNull(); + }); + + it('names only the application when there are no arguments or URL', () => { + expect(describeServeSimLaunch({ launchAppIdentifier: 'host.exp.Exponent' })).toBe( + 'serve-sim will launch host.exp.Exponent.' + ); + }); + + it('names the application, its arguments and the URL', () => { + expect( + describeServeSimLaunch({ + launchAppIdentifier: 'host.exp.Exponent', + launchArgs: ['-flag', '1'], + openUrl: 'exp://127.0.0.1:8081', + }) + ).toBe( + 'serve-sim will launch host.exp.Exponent with arguments ["-flag","1"], then open exp://127.0.0.1:8081.' + ); + }); +}); + describe(createServeSimArgs, () => { it('uses the latest Expo package and applies the EAS streaming policy', () => { expect( @@ -174,6 +309,31 @@ describe(createServeSimArgs, () => { '@expo/serve-sim@next', ]); }); + + it('appends the launch flags after the streaming policy', () => { + const args = createServeSimArgs({ + port: 4321, + launchAppIdentifier: 'host.exp.Exponent', + launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'], + openUrl: 'exp://127.0.0.1:8081', + }); + expect(args.slice(-8)).toEqual([ + '--launch-app-identifier', + 'host.exp.Exponent', + '--launch-arg', + '-EXDevMenuIsOnboardingFinished', + '--launch-arg', + '1', + '--open-url', + 'exp://127.0.0.1:8081', + ]); + }); + + it('omits the launch flags when there is no application to launch', () => { + const args = createServeSimArgs({ port: 4321 }); + expect(args.some(argument => argument.startsWith('--launch'))).toBe(false); + expect(args).not.toContain('--open-url'); + }); }); describe(createExpoDeviceHubArgs, () => { @@ -471,6 +631,50 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => { await preview.stopAsync(); expect(close).toHaveBeenCalledTimes(1); }); + + it('hands the launch options to serve-sim on Darwin', async () => { + jest.mocked(ngrok.forward).mockResolvedValue({ + url: () => 'https://ios-preview.example.test', + close: jest.fn().mockResolvedValue(undefined), + } as never); + + await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), { + runtimePlatform: BuildRuntimePlatform.DARWIN, + baseDomain, + env, + logger: createLoggerMock(), + timeoutMs: 10_000, + launchAppIdentifier: 'host.exp.Exponent', + launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'], + openUrl: 'exp://127.0.0.1:8081', + }); + + const [, args] = jest.mocked(spawn).mock.calls[0]; + expect(args.slice(-8)).toEqual([ + '--launch-app-identifier', + 'host.exp.Exponent', + '--launch-arg', + '-EXDevMenuIsOnboardingFinished', + '--launch-arg', + '1', + '--open-url', + 'exp://127.0.0.1:8081', + ]); + }); + + it('refuses to launch an application on Linux, where expo-device-hub cannot', async () => { + await expect( + startDeviceWebPreviewWithTunnelAsync(createCtxMock(), { + runtimePlatform: BuildRuntimePlatform.LINUX, + baseDomain, + env, + logger: createLoggerMock(), + timeoutMs: 10_000, + launchAppIdentifier: 'host.exp.Exponent', + }) + ).rejects.toThrow('Cannot launch host.exp.Exponent'); + expect(spawn).not.toHaveBeenCalled(); + }); }); describe(turnIceServersToWebPreviewArgs, () => { diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 7360ab495e..c797a3726e 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -1,7 +1,13 @@ -import { SystemError } from '@expo/eas-build-job'; +import { SystemError, UserError } from '@expo/eas-build-job'; import { bunyan } from '@expo/logger'; import { asyncResult } from '@expo/results'; -import { BuildRuntimePlatform, BuildStepEnv, spawnAsync } from '@expo/steps'; +import { + BuildRuntimePlatform, + BuildStepEnv, + BuildStepInput, + BuildStepInputValueTypeName, + spawnAsync, +} from '@expo/steps'; import spawn from '@expo/turtle-spawn'; import * as ngrok from '@ngrok/ngrok'; import { graphql } from 'gql.tada'; @@ -634,17 +640,103 @@ function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): str return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`; } +export interface ServeSimLaunchOptions { + launchAppIdentifier?: string; + launchArgs?: string[]; + openUrl?: string; +} + +/** + * serve-sim performs the launch so the application starts under its instrumentation. + * Every session type that starts a web preview accepts the same three inputs. + */ +export function createServeSimLaunchInputProviders(): ReturnType< + typeof BuildStepInput.createProvider +>[] { + return [ + BuildStepInput.createProvider({ + id: 'launch_app_identifier', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'launch_args', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + }), + BuildStepInput.createProvider({ + id: 'open_url', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ]; +} + +export function parseServeSimLaunchInputs( + inputs: { + launch_app_identifier?: { value?: unknown }; + launch_args?: { value?: unknown }; + open_url?: { value?: unknown }; + }, + { runtimePlatform }: { runtimePlatform: BuildRuntimePlatform } +): ServeSimLaunchOptions { + const launchAppIdentifier = inputs.launch_app_identifier?.value as string | undefined; + const openUrl = inputs.open_url?.value as string | undefined; + const rawLaunchArgs = inputs.launch_args?.value; + if ( + rawLaunchArgs !== undefined && + (!Array.isArray(rawLaunchArgs) || + !rawLaunchArgs.every(argument => typeof argument === 'string')) + ) { + throw new UserError( + 'EAS_SERVE_SIM_INVALID_LAUNCH_INPUT', + 'Input "launch_args" must be an array of strings.' + ); + } + const launchArgs = (rawLaunchArgs as string[] | undefined) ?? []; + if (!launchAppIdentifier && (launchArgs.length > 0 || openUrl)) { + throw new UserError( + 'EAS_SERVE_SIM_INVALID_LAUNCH_INPUT', + 'Inputs "launch_args" and "open_url" apply to an application launch. Pass "launch_app_identifier" with the application to launch, or omit them.' + ); + } + if (launchAppIdentifier && runtimePlatform !== BuildRuntimePlatform.DARWIN) { + throw new UserError( + 'EAS_SERVE_SIM_INVALID_LAUNCH_INPUT', + `Input "launch_app_identifier" launches an application on an iOS simulator, and this session runs on ${runtimePlatform}. Run the session on an iOS simulator, or drop the launch inputs.` + ); + } + return { launchAppIdentifier, launchArgs, openUrl }; +} + +export function describeServeSimLaunch({ + launchAppIdentifier, + launchArgs = [], + openUrl, +}: ServeSimLaunchOptions): string | null { + if (!launchAppIdentifier) { + return null; + } + const withArguments = + launchArgs.length > 0 ? ` with arguments ${JSON.stringify(launchArgs)}` : ''; + const thenOpen = openUrl ? `, then open ${openUrl}` : ''; + return `serve-sim will launch ${launchAppIdentifier}${withArguments}${thenOpen}.`; +} + export function createServeSimArgs({ port, turnArgs = [], metricsCorsArgs = [], packageVersion, + launchAppIdentifier, + launchArgs = [], + openUrl, }: { port: number; turnArgs?: string[]; metricsCorsArgs?: string[]; packageVersion?: string; -}): string[] { +} & ServeSimLaunchOptions): string[] { return [ '--yes', createServeSimPackageSpec(packageVersion), @@ -666,6 +758,9 @@ export function createServeSimArgs({ SERVE_SIM_VIDEO_FPS, ...turnArgs, ...metricsCorsArgs, + ...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []), + ...launchArgs.flatMap(argument => ['--launch-arg', argument]), + ...(openUrl ? ['--open-url', openUrl] : []), ]; } @@ -839,13 +934,16 @@ export async function startServeSimWithTunnelAsync( logger, timeoutMs, packageVersion, + launchAppIdentifier, + launchArgs, + openUrl, }: { baseDomain: string; env: BuildStepEnv; logger: bunyan; timeoutMs: number; packageVersion?: string; - } + } & ServeSimLaunchOptions ): Promise { const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env); return await startWebPreviewWithTunnelAsync(ctx, { @@ -856,7 +954,15 @@ export async function startServeSimWithTunnelAsync( serverName: 'serve-sim', packageSpec: createServeSimPackageSpec(packageVersion), createArgs: (port, turnArgs) => - createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }), + createServeSimArgs({ + port, + turnArgs, + metricsCorsArgs, + packageVersion, + launchAppIdentifier, + launchArgs, + openUrl, + }), }); } @@ -896,6 +1002,9 @@ export async function startDeviceWebPreviewWithTunnelAsync( ctx: CustomBuildContext, { runtimePlatform, + launchAppIdentifier, + launchArgs, + openUrl, ...options }: { runtimePlatform: BuildRuntimePlatform; @@ -904,12 +1013,22 @@ export async function startDeviceWebPreviewWithTunnelAsync( logger: bunyan; timeoutMs: number; packageVersion?: string; - } + } & ServeSimLaunchOptions ): Promise { switch (runtimePlatform) { case BuildRuntimePlatform.DARWIN: - return await startServeSimWithTunnelAsync(ctx, options); + return await startServeSimWithTunnelAsync(ctx, { + ...options, + launchAppIdentifier, + launchArgs, + openUrl, + }); case BuildRuntimePlatform.LINUX: + if (launchAppIdentifier) { + throw new SystemError( + `Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.` + ); + } return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform }); } }