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 43834ed400..0efaae1ccb 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 @@ -40,6 +40,9 @@ jest.mock('../../utils/argentEvents', () => ({ startArgentEventCollectionAsync: jest.fn(), })); jest.mock('../../utils/remoteDeviceRunSession', () => ({ + createServeSimLaunchInputProviders: jest.fn(() => []), + describeServeSimLaunch: jest.fn(() => null), + parseServeSimLaunchInputs: jest.fn(() => ({ launchArgs: [] })), ensureFfmpegInstalledAsync: jest.fn(), getDeviceRunSessionIdOrThrow: jest.fn(), getNgrokAuthtokenOrThrow: jest.fn(), diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts index 5e8a442ac1..6fb0e72200 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, startNgrokTunnelAsync, @@ -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, @@ -67,6 +71,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( // back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain // for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them). const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env); + const launch = parseServeSimLaunchInputs(inputs); const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env); const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env); @@ -114,7 +119,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: ${serveSim.previewUrl}`); } diff --git a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts index 3ca2c7a104..2c39ec386a 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, ensureFfmpegInstalledAsync, getDeviceRunSessionIdOrThrow, getNgrokAuthtokenOrThrow, getNgrokTunnelDomainOrThrow, + parseServeSimLaunchInputs, selectXcodeDeveloperDirectoryAsync, spawnDetached, startNgrokTunnelAsync, @@ -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, @@ -80,6 +84,7 @@ export function createStartArgentRemoteSessionBuildFunction( // back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain // for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them). const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env); + const launch = parseServeSimLaunchInputs(inputs); const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env); const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env); @@ -201,7 +206,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); + } webPreviewUrl = serveSim.previewUrl; logger.info(`Web preview URL: ${webPreviewUrl}`); } diff --git a/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts b/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts index eafe44cee4..d29bd92fac 100644 --- a/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts @@ -2,8 +2,11 @@ import { BuildFunction, BuildRuntimePlatform } from '@expo/steps'; import { CustomBuildContext } from '../../customBuildContext'; import { + createServeSimLaunchInputProviders, + describeServeSimLaunch, getDeviceRunSessionIdOrThrow, getNgrokTunnelDomainOrThrow, + parseServeSimLaunchInputs, selectXcodeDeveloperDirectoryAsync, startServeSimWithTunnelAsync, uploadRemoteSessionConfigAsync, @@ -21,11 +24,17 @@ export function createStartServeSimRemoteSessionBuildFunction( name: 'Start serve-sim remote session', __metricsId: 'eas/start_serve_sim_remote_session', supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN], - fn: async ({ logger }, { env, signal }) => { + inputProviders: createServeSimLaunchInputProviders(), + fn: async ({ logger }, { inputs, env, signal }) => { const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env); const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env); + const launch = parseServeSimLaunchInputs(inputs); logger.info('Starting serve-sim remote session.'); + const launchDescription = describeServeSimLaunch(launch); + if (launchDescription) { + logger.info(launchDescription); + } await selectXcodeDeveloperDirectoryAsync({ env, logger }); @@ -34,6 +43,9 @@ export function createStartServeSimRemoteSessionBuildFunction( env, logger, timeoutMs: STARTUP_TIMEOUT_MS, + launchAppIdentifier: launch.launchAppIdentifier, + launchArgs: launch.launchArgs, + openUrl: launch.openUrl, }); logger.info(`Preview URL: ${serveSim.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 87ffc7092a..99e2f1406e 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -10,9 +10,11 @@ import { turtleFetch } from '../../../utils/turtleFetch'; import { sleepAsync } from '../../../utils/retry'; import { createServeSimArgs, + describeServeSimLaunch, ensureFfmpegInstalledAsync, fetchServeSimTurnArgsAsync, metricsCorsOriginToServeSimArgs, + parseServeSimLaunchInputs, startNgrokTunnelAsync, turnIceServersToServeSimArgs, waitForDeviceRunSessionStoppedAsync, @@ -108,6 +110,69 @@ function createEnvMock(): BuildStepEnv { return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv; } +describe(parseServeSimLaunchInputs, () => { + 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' }, + }) + ).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({})).toEqual({ + launchAppIdentifier: undefined, + launchArgs: [], + openUrl: undefined, + }); + }); + + it('rejects launch arguments that are not a list of strings', () => { + expect(() => + parseServeSimLaunchInputs({ + launch_app_identifier: { value: 'host.exp.Exponent' }, + launch_args: { value: 'oops' }, + }) + ).toThrow('must be an array of strings'); + }); + + it('rejects launch arguments with no application to launch', () => { + expect(() => parseServeSimLaunchInputs({ launch_args: { value: ['-flag'] } })).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' } }) + ).toThrow('Pass "launch_app_identifier"'); + }); +}); + +describe(describeServeSimLaunch, () => { + it('says nothing when there is no application to launch', () => { + expect(describeServeSimLaunch({ launchArgs: [] })).toBeNull(); + }); + + 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( @@ -139,6 +204,31 @@ describe(createServeSimArgs, () => { ]); }); + 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'); + }); + it('appends metrics CORS args after the TURN args when provided', () => { const args = createServeSimArgs({ port: 4321, diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 7505af4af7..2c2c0324bd 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'; @@ -565,14 +571,94 @@ export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] { return args; } +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 serve-sim on iOS 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 }; +}): 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.' + ); + } + 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 = [], + launchAppIdentifier, + launchArgs = [], + openUrl, }: { port: number; turnArgs?: string[]; metricsCorsArgs?: string[]; + launchAppIdentifier?: string; + launchArgs?: string[]; + openUrl?: string; }): string[] { return [ '--yes', @@ -595,6 +681,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] : []), ]; } @@ -669,11 +758,17 @@ export async function startServeSimWithTunnelAsync( env, logger, timeoutMs, + launchAppIdentifier, + launchArgs, + openUrl, }: { baseDomain: string; env: BuildStepEnv; logger: bunyan; timeoutMs: number; + launchAppIdentifier?: string; + launchArgs?: string[]; + openUrl?: string; } ): Promise { const port = await findAvailablePortAsync(); @@ -682,7 +777,14 @@ export async function startServeSimWithTunnelAsync( const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env); const serveSim = spawnDetached({ command: 'npx', - args: createServeSimArgs({ port, turnArgs, metricsCorsArgs }), + args: createServeSimArgs({ + port, + turnArgs, + metricsCorsArgs, + launchAppIdentifier, + launchArgs, + openUrl, + }), env, });