diff --git a/CHANGELOG.md b/CHANGELOG.md index 84e5dbbf15..7aabe35336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features +- [build-tools] Add serve-emu web previews to Android Agent Device, Argent, and Appium remote sessions. ([#4270](https://github.com/expo/eas-cli/pull/4270) by [@szdziedzic](https://github.com/szdziedzic)) - [build-tools] Add `ios_signing_backend` option to the repack step. ([#4239](https://github.com/expo/eas-cli/pull/4239) by [@gabrieldonadel](https://github.com/gabrieldonadel)) - [build-tools] Support an optional `package_version` input on `eas/start_serve_sim_remote_session`, so a simulator session can pin the `@expo/serve-sim` version instead of always running `latest`. ([#4253](https://github.com/expo/eas-cli/pull/4253) by [@gwdp](https://github.com/gwdp)) 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..e2c1d4f89a 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 @@ -14,6 +14,7 @@ import { getNgrokTunnelDomainOrThrow, selectXcodeDeveloperDirectoryAsync, spawnDetached, + startDeviceWebPreviewWithTunnelAsync, startNgrokTunnelAsync, uploadRemoteSessionConfigAsync, waitForDeviceRunSessionStoppedAsync, @@ -46,8 +47,8 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({ getNgrokTunnelDomainOrThrow: jest.fn(), selectXcodeDeveloperDirectoryAsync: jest.fn(), spawnDetached: jest.fn(), + startDeviceWebPreviewWithTunnelAsync: jest.fn(), startNgrokTunnelAsync: jest.fn(), - startServeSimWithTunnelAsync: jest.fn(), uploadRemoteSessionConfigAsync: jest.fn(), waitForDeviceRunSessionStoppedAsync: jest.fn(), })); @@ -58,6 +59,7 @@ const EXPECTED_EVENT_LOG_PATH = path.join(ARGENT_STATE_DIR, 'tool-server-events. const mockStopAsync = jest.fn(); const mockTunnelStopAsync = jest.fn(); +const mockPreviewStopAsync = jest.fn(); describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { beforeEach(async () => { @@ -68,6 +70,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { jest.mocked(pollArgentArtifactsForUploadAsync).mockResolvedValue(undefined); mockStopAsync.mockResolvedValue(undefined); mockTunnelStopAsync.mockResolvedValue(undefined); + mockPreviewStopAsync.mockResolvedValue(undefined); jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({ stopAsync: mockStopAsync, getLastEventObservedAt: () => undefined, @@ -86,6 +89,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { url: 'https://argent-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); @@ -151,5 +158,17 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0] ); expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1); + expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX }) + ); + expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith( + expect.objectContaining({ + remoteConfig: expect.objectContaining({ + webPreviewUrl: 'https://web-preview.tunnel.example.com', + }), + }) + ); + expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts index dbdd7b960e..df0c8b6e64 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts @@ -23,8 +23,8 @@ import { getNgrokTunnelDomainOrThrow, selectXcodeDeveloperDirectoryAsync, spawnDetached, + startDeviceWebPreviewWithTunnelAsync, startNgrokTunnelAsync, - startServeSimWithTunnelAsync, uploadRemoteSessionConfigAsync, waitForDeviceRunSessionStoppedAsync, waitForFileAsync, @@ -107,22 +107,19 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url; logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`); - let serveSim: Awaited> | undefined; + let webPreview: Awaited> | undefined; let eventCollection: | Awaited> | undefined; try { - // serve-sim is iOS-only — only launch it (and report a webPreviewUrl) - // on Darwin. Android sessions go without a preview URL. - if (runtimePlatform === BuildRuntimePlatform.DARWIN) { - serveSim = await startServeSimWithTunnelAsync(ctx, { - baseDomain: ngrokTunnelDomain, - env, - logger, - timeoutMs: STARTUP_TIMEOUT_MS, - }); - logger.info(`Web preview URL: ${serveSim.previewUrl}`); - } + webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, { + runtimePlatform, + baseDomain: ngrokTunnelDomain, + env, + logger, + timeoutMs: STARTUP_TIMEOUT_MS, + }); + logger.info(`Web preview URL: ${webPreview.previewUrl}`); await uploadRemoteSessionConfigAsync({ ctx, @@ -130,7 +127,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( remoteConfig: { agentDeviceRemoteSessionUrl, agentDeviceRemoteSessionToken: daemonToken, - ...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}), + webPreviewUrl: webPreview.previewUrl, }, logger, }); @@ -163,8 +160,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( : undefined, }); } finally { - if (serveSim) { - await serveSim.stopAsync(); + if (webPreview) { + await webPreview.stopAsync(); } await agentDeviceTunnel.stopAsync(); if (eventCollection) { diff --git a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts index f43ad2b12a..101db28195 100644 --- a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts @@ -26,8 +26,8 @@ import { getNgrokTunnelDomainOrThrow, selectXcodeDeveloperDirectoryAsync, spawnDetached, + startDeviceWebPreviewWithTunnelAsync, startNgrokTunnelAsync, - startServeSimWithTunnelAsync, uploadRemoteSessionConfigAsync, waitForDeviceRunSessionStoppedAsync, } from '../utils/remoteDeviceRunSession'; @@ -118,7 +118,7 @@ export function createStartAppiumRemoteSessionBuildFunction( logger, }); let appiumTunnel: Awaited> | undefined; - let serveSim: Awaited> | undefined; + let webPreview: Awaited> | undefined; try { appiumTunnel = await startNgrokTunnelAsync({ port: APPIUM_PORT, @@ -128,18 +128,14 @@ export function createStartAppiumRemoteSessionBuildFunction( logger, }); - switch (runtimePlatform) { - case BuildRuntimePlatform.DARWIN: - serveSim = await startServeSimWithTunnelAsync(ctx, { - baseDomain: ngrokTunnelDomain, - env, - logger, - timeoutMs: APPIUM_STARTUP_TIMEOUT_MS, - }); - break; - case BuildRuntimePlatform.LINUX: - break; - } + webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, { + runtimePlatform, + baseDomain: ngrokTunnelDomain, + env, + logger, + timeoutMs: APPIUM_STARTUP_TIMEOUT_MS, + serial: runtimePlatform === BuildRuntimePlatform.LINUX ? device.udid : undefined, + }); await uploadRemoteSessionConfigAsync({ ctx, @@ -151,7 +147,7 @@ export function createStartAppiumRemoteSessionBuildFunction( 'appium:automationName': device.automationName, 'appium:udid': device.udid, }, - ...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}), + webPreviewUrl: webPreview.previewUrl, }, logger, }); @@ -170,8 +166,8 @@ export function createStartAppiumRemoteSessionBuildFunction( : undefined, }); } finally { - if (serveSim) { - await serveSim.stopAsync(); + if (webPreview) { + await webPreview.stopAsync(); } if (appiumTunnel) { await appiumTunnel.stopAsync(); diff --git a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts index eced65a17b..fd46ced6f4 100644 --- a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts @@ -26,8 +26,8 @@ import { getNgrokTunnelDomainOrThrow, selectXcodeDeveloperDirectoryAsync, spawnDetached, + startDeviceWebPreviewWithTunnelAsync, startNgrokTunnelAsync, - startServeSimWithTunnelAsync, uploadRemoteSessionConfigAsync, waitForDeviceRunSessionStoppedAsync, } from '../utils/remoteDeviceRunSession'; @@ -186,7 +186,7 @@ export function createStartArgentRemoteSessionBuildFunction( }); let toolsTunnel: Awaited> | undefined; - let serveSim: Awaited> | undefined; + let webPreview: Awaited> | undefined; try { toolsTunnel = await startNgrokTunnelAsync({ port: toolServerPort, @@ -199,18 +199,14 @@ export function createStartArgentRemoteSessionBuildFunction( const publicToolsUrl = toolsTunnel.url; logger.info(`Tunnel is ready at ${publicToolsUrl}.`); - // serve-sim is iOS-only — Android sessions go without a preview URL. - let webPreviewUrl: string | undefined; - if (runtimePlatform === BuildRuntimePlatform.DARWIN) { - serveSim = await startServeSimWithTunnelAsync(ctx, { - baseDomain: ngrokTunnelDomain, - env, - logger, - timeoutMs: STARTUP_TIMEOUT_MS, - }); - webPreviewUrl = serveSim.previewUrl; - logger.info(`Web preview URL: ${webPreviewUrl}`); - } + webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, { + runtimePlatform, + baseDomain: ngrokTunnelDomain, + env, + logger, + timeoutMs: STARTUP_TIMEOUT_MS, + }); + logger.info(`Web preview URL: ${webPreview.previewUrl}`); await uploadRemoteSessionConfigAsync({ ctx, @@ -218,7 +214,7 @@ export function createStartArgentRemoteSessionBuildFunction( remoteConfig: { toolsUrl: publicToolsUrl, ...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}), - ...(webPreviewUrl ? { webPreviewUrl } : {}), + webPreviewUrl: webPreview.previewUrl, }, logger, }); @@ -238,8 +234,8 @@ export function createStartArgentRemoteSessionBuildFunction( : undefined, }); } finally { - if (serveSim) { - await serveSim.stopAsync(); + if (webPreview) { + await webPreview.stopAsync(); } if (toolsTunnel) { await toolsTunnel.stopAsync(); 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 45bb5d5585..8ffdb06e66 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -13,14 +13,15 @@ import { Sentry } from '../../../sentry'; import { turtleFetch } from '../../../utils/turtleFetch'; import { sleepAsync } from '../../../utils/retry'; import { + createServeEmuArgs, createServeSimArgs, ensureFfmpegInstalledAsync, - fetchServeSimTurnArgsAsync, + fetchWebPreviewTurnArgsAsync, metricsCorsOriginToServeSimArgs, startNgrokTunnelAsync, - turnIceServersToServeSimArgs, + turnIceServersToWebPreviewArgs, waitForDeviceRunSessionStoppedAsync, - waitForServeSimReadyAsync, + waitForWebPreviewReadyAsync, } from '../remoteDeviceRunSession'; jest.mock('@ngrok/ngrok'); @@ -173,6 +174,43 @@ describe(createServeSimArgs, () => { }); }); +describe(createServeEmuArgs, () => { + it('uses the latest Expo package and applies the EAS Android streaming policy', () => { + expect( + createServeEmuArgs({ + port: 4321, + turnArgs: ['--turn-url', 'turns:turn.example.test:443'], + }) + ).toEqual([ + '@expo/serve-emu@latest', + '--port', + '4321', + '--host', + '127.0.0.1', + '--transport', + 'webrtc', + '--webrtc-ice-policy', + 'all', + '--max-size', + '1280', + '--bit-rate', + '3000000', + '--max-fps', + '30', + '--key-frame-interval', + '1', + '--turn-url', + 'turns:turn.example.test:443', + ]); + }); + + it('pins the requested package version and Android serial', () => { + expect( + createServeEmuArgs({ port: 4321, packageVersion: '0.1.0', serial: 'emulator-5554' }) + ).toEqual(expect.arrayContaining(['@expo/serve-emu@0.1.0', '--serial', 'emulator-5554'])); + }); +}); + describe(metricsCorsOriginToServeSimArgs, () => { it('returns no args when the origin is unset or empty', () => { expect(metricsCorsOriginToServeSimArgs({} as BuildStepEnv)).toEqual([]); @@ -200,7 +238,7 @@ describe(metricsCorsOriginToServeSimArgs, () => { }); }); -describe(waitForServeSimReadyAsync, () => { +describe(waitForWebPreviewReadyAsync, () => { beforeEach(() => { jest.mocked(turtleFetch).mockReset(); jest.mocked(sleepAsync).mockReset(); @@ -215,8 +253,9 @@ describe(waitForServeSimReadyAsync, () => { json: async () => ({ status: 'ready', device: 'DEVICE-A' }), } as unknown as Awaited>); - await waitForServeSimReadyAsync({ - serveSim: { pid: undefined, getOutput: () => '' }, + await waitForWebPreviewReadyAsync({ + previewServer: { pid: undefined, getOutput: () => '' }, + serverName: 'serve-emu', port: 4321, timeoutMs: 10_000, }); @@ -261,14 +300,14 @@ describe(startNgrokTunnelAsync, () => { }); }); -describe(turnIceServersToServeSimArgs, () => { +describe(turnIceServersToWebPreviewArgs, () => { it('returns no args for an empty ICE server list', () => { - expect(turnIceServersToServeSimArgs([])).toEqual([]); + expect(turnIceServersToWebPreviewArgs([])).toEqual([]); }); it('builds --stun-url and --turn-url flags from Cloudflare ICE servers', () => { expect( - turnIceServersToServeSimArgs([ + turnIceServersToWebPreviewArgs([ { urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.cloudflare.com:53'] }, { urls: [ @@ -293,7 +332,7 @@ describe(turnIceServersToServeSimArgs, () => { it('emits only --turn-url flags when no credential-less (STUN) entry is present', () => { expect( - turnIceServersToServeSimArgs([ + turnIceServersToWebPreviewArgs([ { urls: ['turns:turn.cloudflare.com:443?transport=tcp'], username: 'u', @@ -311,14 +350,14 @@ describe(turnIceServersToServeSimArgs, () => { }); it('emits only --stun-url when there is no credentialed TURN entry', () => { - expect(turnIceServersToServeSimArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([ + expect(turnIceServersToWebPreviewArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([ '--stun-url', 'stun:stun.cloudflare.com:3478', ]); }); }); -describe(fetchServeSimTurnArgsAsync, () => { +describe(fetchWebPreviewTurnArgsAsync, () => { beforeEach(() => { jest.mocked(turtleFetch).mockReset(); }); @@ -339,7 +378,7 @@ describe(fetchServeSimTurnArgsAsync, () => { }), } as unknown as Awaited>); - const args = await fetchServeSimTurnArgsAsync(createCtxMock(), { + const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), { env: createEnvMock(), logger: createLoggerMock(), }); @@ -367,7 +406,7 @@ describe(fetchServeSimTurnArgsAsync, () => { jest.mocked(turtleFetch).mockRejectedValue(new Error('boom')); const logger = createLoggerMock(); - const args = await fetchServeSimTurnArgsAsync(createCtxMock(), { + const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), { env: createEnvMock(), logger, }); diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 8e5fef7383..dc9820998e 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -19,12 +19,17 @@ import { sleepAsync } from '../../utils/retry'; import { turtleFetch } from '../../utils/turtleFetch'; const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; +const WEB_PREVIEW_HOST = '127.0.0.1'; const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim'; -const SERVE_SIM_HOST = '127.0.0.1'; const SERVE_SIM_MAX_DIMENSION = '1280'; const SERVE_SIM_MJPEG_QUALITY = '0.55'; const SERVE_SIM_VIDEO_BITRATE = '3000000'; const SERVE_SIM_VIDEO_FPS = '60'; +const SERVE_EMU_PACKAGE_NAME = '@expo/serve-emu'; +const SERVE_EMU_MAX_SIZE = '1280'; +const SERVE_EMU_BIT_RATE = '3000000'; +const SERVE_EMU_MAX_FPS = '30'; +const SERVE_EMU_KEY_FRAME_INTERVAL = '1'; const START_DEVICE_RUN_SESSION_MUTATION = graphql(` mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) { @@ -390,11 +395,12 @@ const TurnIceServersResponseSchema = z.object({ }); /** - * Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the + * Translate Cloudflare ICE servers into web preview CLI flags: `--stun-url` (the * credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential` - * (the entry carrying the short-lived credentials). + * (the entry carrying the short-lived credentials). serve-sim and serve-emu + * intentionally expose the same ICE flag contract. */ -export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string[] { +export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): string[] { const stunUrls = iceServers .filter(server => !server.username && !server.credential) .flatMap(server => server.urls); @@ -420,14 +426,14 @@ export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string /** * Fetch short-lived Cloudflare TURN ICE servers for this job run from www * (minted on demand, mirroring how the worker fetches project clone URLs) and - * translate them into serve-sim CLI flags. + * translate them into web preview CLI flags. * - * Best-effort: on any failure we log and return [] so serve-sim falls back to - * its built-in P2P/STUN behavior. The credential is passed to serve-sim as a - * process arg and deliberately not logged (turtle-spawn never logs argv and the - * worker is single-tenant). + * Best-effort: on any failure we log and return [] so the preview server falls + * back to its built-in P2P/STUN behavior. The credential is passed as a process + * arg and deliberately not logged (turtle-spawn never logs argv and the worker + * is single-tenant). */ -export async function fetchServeSimTurnArgsAsync( +export async function fetchWebPreviewTurnArgsAsync( ctx: CustomBuildContext, { env, logger }: { env: BuildStepEnv; logger: bunyan } ): Promise { @@ -456,9 +462,9 @@ export async function fetchServeSimTurnArgsAsync( ); const { data } = TurnIceServersResponseSchema.parse(await response.json()); - const args = turnIceServersToServeSimArgs(data.iceServers); + const args = turnIceServersToWebPreviewArgs(data.iceServers); if (args.length > 0) { - logger.info('Configured serve-sim with Cloudflare TURN ICE servers.'); + logger.info('Configured the web preview with Cloudflare TURN ICE servers.'); } return args; } catch (err) { @@ -466,7 +472,7 @@ export async function fetchServeSimTurnArgsAsync( Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' }); logger.warn( { err: error }, - 'Could not fetch Cloudflare TURN ICE servers; serve-sim will fall back to P2P/STUN.' + 'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.' ); return []; } @@ -600,6 +606,10 @@ function createServeSimPackageSpec(packageVersion: string | undefined): string { return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`; } +function createServeEmuPackageSpec(packageVersion: string | undefined): string { + return `${SERVE_EMU_PACKAGE_NAME}@${packageVersion ?? 'latest'}`; +} + export function createServeSimArgs({ port, turnArgs = [], @@ -617,7 +627,7 @@ export function createServeSimArgs({ '--port', String(port), '--host', - SERVE_SIM_HOST, + WEB_PREVIEW_HOST, '--transport', 'webrtc', '--webrtc-codec', @@ -635,44 +645,82 @@ export function createServeSimArgs({ ]; } +export function createServeEmuArgs({ + port, + turnArgs = [], + packageVersion, + serial, +}: { + port: number; + turnArgs?: string[]; + packageVersion?: string; + serial?: string; +}): string[] { + return [ + createServeEmuPackageSpec(packageVersion), + '--port', + String(port), + '--host', + WEB_PREVIEW_HOST, + '--transport', + 'webrtc', + '--webrtc-ice-policy', + 'all', + '--max-size', + SERVE_EMU_MAX_SIZE, + '--bit-rate', + SERVE_EMU_BIT_RATE, + '--max-fps', + SERVE_EMU_MAX_FPS, + '--key-frame-interval', + SERVE_EMU_KEY_FRAME_INTERVAL, + ...(serial ? ['--serial', serial] : []), + ...turnArgs, + ]; +} + async function findAvailablePortAsync(): Promise { const server = createServer(); server.unref(); await new Promise((resolve, reject) => { server.once('error', reject); - server.listen(0, SERVE_SIM_HOST, () => resolve()); + server.listen(0, WEB_PREVIEW_HOST, () => resolve()); }); const address = server.address(); await new Promise((resolve, reject) => { server.close(err => (err ? reject(err) : resolve())); }); if (!address || typeof address === 'string') { - throw new SystemError('Could not allocate a local port for serve-sim.'); + throw new SystemError('Could not allocate a local port for the web preview.'); } return address.port; } -const ServeSimReadyResponseSchema = z.object({ +const WebPreviewReadyResponseSchema = z.object({ status: z.literal('ready'), device: z.string(), }); -export async function waitForServeSimReadyAsync({ - serveSim, +export async function waitForWebPreviewReadyAsync({ + previewServer, + serverName, port, timeoutMs, }: { - serveSim: Pick; + previewServer: Pick; + serverName: string; port: number; timeoutMs: number; }): Promise { - const readyUrl = `http://${SERVE_SIM_HOST}:${port}/readyz`; + const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`; const deadline = Date.now() + timeoutMs; let lastError: unknown; while (Date.now() < deadline) { - if (serveSim.pid !== undefined && !isProcessRunning(serveSim.pid)) { + if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) { throw new SystemError( - `serve-sim exited before becoming ready. Last output:\n${serveSim.getOutput() || ''}` + `${serverName} exited before becoming ready. Last output:\n${ + previewServer.getOutput() || '' + }` ); } try { @@ -680,7 +728,7 @@ export async function waitForServeSimReadyAsync({ retries: 0, timeout: 2_000, }); - ServeSimReadyResponseSchema.parse(await response.json()); + WebPreviewReadyResponseSchema.parse(await response.json()); return; } catch (error) { lastError = error; @@ -688,48 +736,53 @@ export async function waitForServeSimReadyAsync({ await sleepAsync(1_000); } throw new SystemError( - `Timed out waiting for serve-sim readiness at ${readyUrl}${ + `Timed out waiting for ${serverName} readiness at ${readyUrl}${ lastError instanceof Error ? `: ${lastError.message}` : '' - }. Last output:\n${serveSim.getOutput() || ''}` + }. Last output:\n${previewServer.getOutput() || ''}` ); } -export type ServeSimPreviewHandle = { +export type DeviceWebPreviewHandle = { previewUrl: string; stopAsync: () => Promise; }; -export async function startServeSimWithTunnelAsync( +export type ServeSimPreviewHandle = DeviceWebPreviewHandle; + +async function startWebPreviewWithTunnelAsync( ctx: CustomBuildContext, { baseDomain, env, logger, timeoutMs, - packageVersion, + serverName, + packageSpec, + command, + createArgs, }: { baseDomain: string; env: BuildStepEnv; logger: bunyan; timeoutMs: number; - packageVersion?: string; + serverName: string; + packageSpec: string; + command: 'npx' | 'bunx'; + createArgs: (port: number, turnArgs: string[]) => string[]; } -): Promise { +): Promise { const port = await findAvailablePortAsync(); - logger.info( - `Launching ${createServeSimPackageSpec(packageVersion)} on ${SERVE_SIM_HOST}:${port}.` - ); - const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger }); - const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env); - const serveSim = spawnDetached({ - command: 'npx', - args: createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }), + logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`); + const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger }); + const previewServer = spawnDetached({ + command, + args: createArgs(port, turnArgs), env, }); try { - logger.info('Waiting for serve-sim to become ready.'); - await waitForServeSimReadyAsync({ serveSim, port, timeoutMs }); + logger.info(`Waiting for ${serverName} to become ready.`); + await waitForWebPreviewReadyAsync({ previewServer, serverName, port, timeoutMs }); const tunnel = await startNgrokTunnelAsync({ port, subdomainPrefix: 'web-preview', @@ -740,20 +793,103 @@ export async function startServeSimWithTunnelAsync( return { previewUrl: tunnel.url, stopAsync: async () => { - const results = await Promise.allSettled([tunnel.stopAsync(), serveSim.stopAsync()]); + const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]); for (const result of results) { if (result.status === 'rejected') { - logger.warn({ err: result.reason }, 'Could not stop a serve-sim preview resource.'); + logger.warn({ err: result.reason }, `Could not stop a ${serverName} preview resource.`); } } }, }; } catch (error) { - await serveSim.stopAsync(); + await previewServer.stopAsync(); throw error; } } +export async function startServeSimWithTunnelAsync( + ctx: CustomBuildContext, + { + baseDomain, + env, + logger, + timeoutMs, + packageVersion, + }: { + baseDomain: string; + env: BuildStepEnv; + logger: bunyan; + timeoutMs: number; + packageVersion?: string; + } +): Promise { + const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env); + return await startWebPreviewWithTunnelAsync(ctx, { + baseDomain, + env, + logger, + timeoutMs, + serverName: 'serve-sim', + packageSpec: createServeSimPackageSpec(packageVersion), + command: 'npx', + createArgs: (port, turnArgs) => + createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }), + }); +} + +export async function startServeEmuWithTunnelAsync( + ctx: CustomBuildContext, + { + baseDomain, + env, + logger, + timeoutMs, + packageVersion, + serial, + }: { + baseDomain: string; + env: BuildStepEnv; + logger: bunyan; + timeoutMs: number; + packageVersion?: string; + serial?: string; + } +): Promise { + return await startWebPreviewWithTunnelAsync(ctx, { + baseDomain, + env, + logger, + timeoutMs, + serverName: 'serve-emu', + packageSpec: createServeEmuPackageSpec(packageVersion), + command: 'bunx', + createArgs: (port, turnArgs) => createServeEmuArgs({ port, turnArgs, packageVersion, serial }), + }); +} + +export async function startDeviceWebPreviewWithTunnelAsync( + ctx: CustomBuildContext, + { + runtimePlatform, + serial, + ...options + }: { + runtimePlatform: BuildRuntimePlatform; + baseDomain: string; + env: BuildStepEnv; + logger: bunyan; + timeoutMs: number; + serial?: string; + } +): Promise { + switch (runtimePlatform) { + case BuildRuntimePlatform.DARWIN: + return await startServeSimWithTunnelAsync(ctx, options); + case BuildRuntimePlatform.LINUX: + return await startServeEmuWithTunnelAsync(ctx, { ...options, serial }); + } +} + export type NgrokTunnelHandle = { url: string; stopAsync: () => Promise; diff --git a/packages/eas-cli/src/simulator/__tests__/utils.test.ts b/packages/eas-cli/src/simulator/__tests__/utils.test.ts index 7530bfcce1..a41f04a235 100644 --- a/packages/eas-cli/src/simulator/__tests__/utils.test.ts +++ b/packages/eas-cli/src/simulator/__tests__/utils.test.ts @@ -34,6 +34,8 @@ describe('Appium simulator configuration', () => { expect(instructions).toContain('eas simulator:exec [args...]'); expect(instructions).toContain('https://preview.example.test'); + expect(instructions).toContain('Open the simulator preview:'); + expect(instructions).not.toContain('iOS simulator preview'); expect(instructions).not.toContain('https://appium.example.test'); }); }); diff --git a/packages/eas-cli/src/simulator/utils.ts b/packages/eas-cli/src/simulator/utils.ts index 6225cf70fa..1d88e0ad67 100644 --- a/packages/eas-cli/src/simulator/utils.ts +++ b/packages/eas-cli/src/simulator/utils.ts @@ -151,7 +151,7 @@ export function formatRemoteSessionInstructions( ' [args...]', ]; if (remoteConfig.webPreviewUrl) { - lines.push('', 'Open the iOS simulator preview:', '', remoteConfig.webPreviewUrl); + lines.push('', 'Open the simulator preview:', '', remoteConfig.webPreviewUrl); } return lines.join('\n'); }