From 88f972469356330373ddb2fa5332030e81af438f Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 13 Oct 2026 20:07:08 -0700 Subject: [PATCH 1/6] [build-tools] Respect simulator preview sharing preferences --- CHANGELOG.md | 1 + .../deviceSessionHost-orchestration.test.ts | 19 ++- ...tDeviceRemoteSession-orchestration.test.ts | 24 ++- .../__tests__/startAgentDeviceSession.test.ts | 21 +++ .../startAppiumRemoteSession.test.ts | 10 +- ...tArgentRemoteSession-orchestration.test.ts | 40 ++++- .../startWebPreviewRemoteSession.test.ts | 59 +++++++ .../startAgentDeviceRemoteSession.ts | 10 +- .../functions/startAgentDeviceSession.ts | 6 + .../functions/startAppiumRemoteSession.ts | 6 + .../functions/startArgentRemoteSession.ts | 10 +- .../functions/startWebPreviewRemoteSession.ts | 12 +- .../__tests__/remoteDeviceRunSession.test.ts | 157 ++++++++++++++++++ .../utils/__tests__/serveSimHelpProbe.test.ts | 43 +++++ .../src/steps/utils/deviceSessionHost.ts | 97 ++++++++++- .../src/steps/utils/remoteDeviceRunSession.ts | 22 +++ 16 files changed, 524 insertions(+), 13 deletions(-) create mode 100644 packages/build-tools/src/steps/utils/__tests__/serveSimHelpProbe.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d0361b9213..7b7807f63e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features - [eas-cli] Add `--platform` to `eas workflow:insights:maestro` and list Maestro runs and flows per platform. ([#4613](https://github.com/expo/eas-cli/pull/4613) by [@hSATAC](https://github.com/hSATAC)) +- [build-tools] Respect saved sharing preferences for iOS simulator preview sessions. ### 🐛 Bug fixes diff --git a/packages/build-tools/src/steps/functions/__tests__/deviceSessionHost-orchestration.test.ts b/packages/build-tools/src/steps/functions/__tests__/deviceSessionHost-orchestration.test.ts index 2a6a69dc3e..c0b4f41c76 100644 --- a/packages/build-tools/src/steps/functions/__tests__/deviceSessionHost-orchestration.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/deviceSessionHost-orchestration.test.ts @@ -108,7 +108,7 @@ describe.each([ ['Appium', createStartAppiumRemoteSessionBuildFunction], ['Agent Device', createStartAgentDeviceSessionBuildFunction], ] as const)('%s host ownership', (name, createFunction) => { - async function runAsync() { + async function runAsync(publicPreview?: boolean) { const fn = createFunction({} as CustomBuildContext); await fn.fn!( { @@ -117,9 +117,12 @@ describe.each([ } as unknown as BuildStepContext, { // Inputs a step does not get default to undefined, like in a real step call. - inputs: new Proxy({} as Record, { - get: (target, id: string) => target[id] ?? { value: undefined }, - }), + inputs: new Proxy( + { public_preview: { value: publicPreview } } as Record, + { + get: (target, id: string) => target[id] ?? { value: undefined }, + } + ), outputs: {}, env: { DEVICE_RUN_SESSION_ID: 'session-id', @@ -168,6 +171,14 @@ describe.each([ } }); + it.each([undefined, true, false])('maps public_preview=%s to the host', async publicPreview => { + await runAsync(publicPreview); + expect(startDeviceSessionHostAsync).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ disableShare: publicPreview === false }) + ); + }); + it('stops automation while recording finalization or upload is pending', async () => { let release!: () => void; let stopped!: () => void; 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 index 88c80bbe86..f4baf9f676 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceRemoteSession-orchestration.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceRemoteSession-orchestration.test.ts @@ -74,7 +74,9 @@ const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: async function runAsync( logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock }, runtimePlatform: BuildRuntimePlatform, - capture: CaptureInputs = NO_CAPTURE + capture: CaptureInputs = NO_CAPTURE, + disableShare = false, + privatePreviewUrl?: string ): Promise { await runAgentDeviceRemoteSessionAsync(ctx, { env: {}, @@ -89,6 +91,8 @@ async function runAsync( maxIdleTimeMinutes: undefined, maxDurationSeconds: undefined, capture, + disableShare, + privatePreviewUrl, tasks: createStartupTasks(logger as never), device: { booted: Promise.resolve(), ready: Promise.resolve() }, }); @@ -199,6 +203,24 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { ); }); + it('hands a private preview choice to the session host', async () => { + const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() }; + await runAsync( + logger, + BuildRuntimePlatform.DARWIN, + NO_CAPTURE, + true, + 'https://expo.dev/private-preview' + ); + expect(startDeviceSessionHostAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + disableShare: true, + privatePreviewUrl: 'https://expo.dev/private-preview', + }) + ); + }); + it('starts the session host without waiting for the agent-device daemon', async () => { const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() }; const sessionHost = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!( diff --git a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts index 013d2c09eb..37e5bbbfa6 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts @@ -86,6 +86,8 @@ function runStep( 'open_url', 'network_capture', 'network_capture_fields', + 'public_preview', + 'private_preview_url', 'package_version', 'max_idle_time_minutes', 'max_duration_seconds', @@ -413,12 +415,31 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { 'open_url', 'network_capture', 'network_capture_fields', + 'public_preview', + 'private_preview_url', 'package_version', 'max_idle_time_minutes', 'max_duration_seconds', ]); }); + it.each([undefined, true, false])( + 'passes public_preview=%s to the iOS session host', + async publicPreview => { + await runStep(BuildRuntimePlatform.DARWIN, { + public_preview: publicPreview, + private_preview_url: 'https://expo.dev/private-preview', + }); + expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + disableShare: publicPreview === false, + privatePreviewUrl: 'https://expo.dev/private-preview', + }) + ); + } + ); + it('passes network capture to the session', async () => { await runStep(BuildRuntimePlatform.DARWIN, { network_capture: true, diff --git a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts index 6b0f662ce2..d2b7b9a81d 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts @@ -194,7 +194,15 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => { expect( buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id) - ).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url'])); + ).toEqual( + expect.arrayContaining([ + 'launch_app_identifier', + 'launch_args', + 'open_url', + 'public_preview', + 'private_preview_url', + ]) + ); }); }); 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 25e0532708..4c54842699 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 @@ -381,7 +381,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1); expect(startDeviceSessionHostAsync).toHaveBeenCalledWith( ctx, - expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX }) + expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, disableShare: false }) ); expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith( expect.objectContaining({ @@ -394,6 +394,34 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); }); + it('passes public_preview=false to the session host', async () => { + const ctx = {} as CustomBuildContext; + const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx); + await buildFunction.fn!( + { + logger: { info: jest.fn(), warn: jest.fn() }, + global: { runtimePlatform: BuildRuntimePlatform.LINUX }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + public_preview: { value: false }, + private_preview_url: { value: 'https://expo.dev/private-preview' }, + }, + outputs: {}, + env: {}, + } as never + ); + expect(startDeviceSessionHostAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + disableShare: true, + privatePreviewUrl: 'https://expo.dev/private-preview', + }) + ); + }); + it('continues the session when disabling the video watermark fails', async () => { const error = new Error('bun x failed'); jest @@ -549,7 +577,15 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { expect( buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id) - ).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url'])); + ).toEqual( + expect.arrayContaining([ + 'launch_app_identifier', + 'launch_args', + 'open_url', + 'public_preview', + 'private_preview_url', + ]) + ); }); it('stops automation without waiting for recording upload', async () => { 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 b9b37480b4..c4e2bdb354 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts @@ -113,6 +113,46 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { ); }); + it('logs the authenticated link while publishing the public frame URL', async () => { + const privatePreviewUrl = 'https://expo.dev/private-preview'; + jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({ + openPreviewAsync: jest.fn().mockResolvedValue({ + previewPageUrl: 'https://expo.dev/simulator-preview/preview-id', + sharePageUrl: privatePreviewUrl, + apiUrl: 'https://web-preview.example.test', + closeAsync: jest.fn(), + }), + finishAsync: stopAsync, + }); + + await runAsync(BuildRuntimePlatform.DARWIN, { + public_preview: { value: false }, + private_preview_url: { value: privatePreviewUrl }, + }); + + expect(logger.info).toHaveBeenCalledWith( + `Preview URL: ${privatePreviewUrl} (server: https://web-preview.example.test).` + ); + expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith( + expect.objectContaining({ + remoteConfig: expect.objectContaining({ + webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id', + }), + }) + ); + }); + + it('does not log the public link when a private job lacks its authenticated URL', async () => { + await runAsync(BuildRuntimePlatform.DARWIN, { public_preview: { value: false } }); + + expect(logger.info).toHaveBeenCalledWith('Private preview URL unavailable for this session.'); + expect(jest.mocked(logger.info).mock.calls).not.toEqual( + expect.arrayContaining([ + expect.arrayContaining([expect.stringContaining('/simulator-preview/preview-id')]), + ]) + ); + }); + it.each(['preview', 'config', 'wait'])('finishes the session after %s fails', async phase => { const error = new Error(`${phase} failed`); if (phase === 'preview') { @@ -150,6 +190,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { } : {}), packageVersion: '1.2.3', + disableShare: false, launchAppIdentifier: undefined, launchArgs: [], openUrl: undefined, @@ -196,6 +237,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { 'install_app_path', 'network_capture', 'network_capture_fields', + 'public_preview', + 'private_preview_url', 'package_version', 'max_duration_seconds', ]); @@ -243,6 +286,22 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { }) ); }); + it.each([true, false])( + 'maps public_preview=%s to the native Share option', + async publicPreview => { + await runAsync(BuildRuntimePlatform.DARWIN, { + public_preview: { value: publicPreview }, + private_preview_url: { value: 'https://expo.dev/private-preview' }, + }); + expect(startDeviceSessionHostAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + disableShare: !publicPreview, + privatePreviewUrl: 'https://expo.dev/private-preview', + }) + ); + } + ); it('hands network capture to the session host', async () => { await runAsync(BuildRuntimePlatform.DARWIN, { diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts index 0566e90ee9..981a695af8 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts @@ -96,6 +96,8 @@ export async function runAgentDeviceRemoteSessionAsync( maxIdleTimeMinutes, maxDurationSeconds, capture, + disableShare = false, + privatePreviewUrl, tasks, device, }: { @@ -108,6 +110,8 @@ export async function runAgentDeviceRemoteSessionAsync( maxIdleTimeMinutes: number | undefined; maxDurationSeconds: number | undefined; capture: ReturnType; + disableShare?: boolean; + privatePreviewUrl?: string; tasks: StartupTasks; device: { booted: Promise; ready: Promise }; } @@ -163,6 +167,8 @@ export async function runAgentDeviceRemoteSessionAsync( env, logger: taskLogger, timeoutMs: STARTUP_TIMEOUT_MS, + disableShare, + privatePreviewUrl, networkCapture: capture.networkCapture, networkCaptureFields: capture.networkCaptureFields, }); @@ -172,7 +178,9 @@ export async function runAgentDeviceRemoteSessionAsync( authtoken: ngrokAuthtoken, }); taskLogger.info( - `Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).` + webPreview.sharePageUrl + ? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).` + : 'Private preview URL unavailable for this session.' ); return webPreview; }); diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts index b5b530cec0..6211bfb3bb 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts @@ -20,6 +20,8 @@ import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi import { withLocalEgressSession } from '../utils/localEgressSession'; import { omitNgrokAuthtoken, + createPrivatePreviewUrlInputProvider, + createPublicPreviewInputProvider, selectXcodeDeveloperDirectoryAsync, } from '../utils/remoteDeviceRunSession'; import { @@ -118,6 +120,8 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte }), // Session. ...createNetworkCaptureInputProviders(), + createPublicPreviewInputProvider(), + createPrivatePreviewUrlInputProvider(), BuildStepInput.createProvider({ id: 'package_version', required: false, @@ -265,6 +269,8 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined, maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined, capture, + disableShare: inputs.public_preview?.value === false, + privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, tasks, device: { booted, ready }, }); diff --git a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts index 071a25a335..af63925d17 100644 --- a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts @@ -37,6 +37,8 @@ import { turtleFetch } from '../../utils/turtleFetch'; import { startAppiumEventCollectionAsync } from '../utils/appiumEvents'; import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda'; import { + createPrivatePreviewUrlInputProvider, + createPublicPreviewInputProvider, createServeSimLaunchInputProviders, describeServeSimLaunch, ensureFfmpegInstalledOnceAsync, @@ -73,6 +75,8 @@ export function createStartAppiumRemoteSessionBuildFunction( inputProviders: [ ...createServeSimLaunchInputProviders(), ...createNetworkCaptureInputProviders(), + createPublicPreviewInputProvider(), + createPrivatePreviewUrlInputProvider(), BuildStepInput.createProvider({ id: 'package_version', required: false, @@ -186,6 +190,8 @@ export function createStartAppiumRemoteSessionBuildFunction( env, logger, timeoutMs: APPIUM_STARTUP_TIMEOUT_MS, + disableShare: inputs.public_preview?.value === false, + privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, launchAppIdentifier: launch.launchAppIdentifier, launchArgs: launch.launchArgs, openUrl: launch.openUrl, diff --git a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts index 0dc4965ced..79002cb693 100644 --- a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts @@ -34,6 +34,8 @@ import { sleepAsync } from '../../utils/retry'; import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts'; import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents'; import { + createPrivatePreviewUrlInputProvider, + createPublicPreviewInputProvider, createServeSimLaunchInputProviders, describeServeSimLaunch, ensureFfmpegInstalledOnceAsync, @@ -85,6 +87,8 @@ export function createStartArgentRemoteSessionBuildFunction( inputProviders: [ ...createServeSimLaunchInputProviders(), ...createNetworkCaptureInputProviders(), + createPublicPreviewInputProvider(), + createPrivatePreviewUrlInputProvider(), BuildStepInput.createProvider({ id: 'package_version', required: false, @@ -283,6 +287,8 @@ export function createStartArgentRemoteSessionBuildFunction( env, logger, timeoutMs: STARTUP_TIMEOUT_MS, + disableShare: inputs.public_preview?.value === false, + privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, launchAppIdentifier: launch.launchAppIdentifier, launchArgs: launch.launchArgs, openUrl: launch.openUrl, @@ -294,7 +300,9 @@ export function createStartArgentRemoteSessionBuildFunction( authtoken: ngrokAuthtoken, }); logger.info( - `Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).` + webPreview.sharePageUrl + ? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).` + : 'Private preview URL unavailable for this session.' ); await uploadRemoteSessionConfigWithLocalEgressAsync({ diff --git a/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts b/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts index 66af4ec46d..9e44abf8b6 100644 --- a/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts @@ -18,6 +18,8 @@ import { } from '../utils/localEgressSession'; import { createIosSessionStartupInputProviders, + createPrivatePreviewUrlInputProvider, + createPublicPreviewInputProvider, createServeSimLaunchInputProviders, describeServeSimLaunch, getDeviceRunSessionIdOrThrow, @@ -42,6 +44,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction( ...createServeSimLaunchInputProviders(), ...createIosSessionStartupInputProviders(), ...createNetworkCaptureInputProviders(), + createPublicPreviewInputProvider(), + createPrivatePreviewUrlInputProvider(), BuildStepInput.createProvider({ id: 'package_version', required: false, @@ -98,6 +102,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction( timeoutMs: STARTUP_TIMEOUT_MS, signal, packageVersion, + disableShare: inputs.public_preview?.value === false, + privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, launchAppIdentifier: launch.launchAppIdentifier, launchArgs: launch.launchArgs, openUrl: launch.openUrl, @@ -110,7 +116,11 @@ export function createStartWebPreviewRemoteSessionBuildFunction( baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken, }); - logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`); + logger.info( + webPreview.sharePageUrl + ? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).` + : 'Private preview URL unavailable for this session.' + ); await uploadRemoteSessionConfigWithLocalEgressAsync({ env, signal, 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 aa7cbfcfed..be48654223 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -28,6 +28,8 @@ import { sleepAsync } from '../../../utils/retry'; import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings'; import { createIosSessionStartupInputProviders, + createPrivatePreviewUrlInputProvider, + createPublicPreviewInputProvider, createServeSimLaunchInputProviders, describeServeSimLaunch, ensureFfmpegInstalledOnceAsync, @@ -297,6 +299,28 @@ describe(createServeSimLaunchInputProviders, () => { }); }); +describe(createPublicPreviewInputProvider, () => { + it('preserves the native Share button for legacy jobs without the input', () => { + const input = createPublicPreviewInputProvider()(createGlobalContextMock(), 'Test step'); + expect(input).toMatchObject({ + id: 'public_preview', + required: false, + defaultValue: true, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }); + }); +}); + +describe(createPrivatePreviewUrlInputProvider, () => { + it('accepts an optional authenticated preview URL from the session job', () => { + const provider = createPrivatePreviewUrlInputProvider(); + const input = provider(createGlobalContextMock(), 'step-id'); + expect(input.id).toBe('private_preview_url'); + expect(input.required).toBe(false); + expect(input.allowedValueTypeName).toBe(BuildStepInputValueTypeName.STRING); + }); +}); + describe(parseServeSimLaunchInputs, () => { const darwin = { runtimePlatform: BuildRuntimePlatform.DARWIN }; @@ -490,6 +514,14 @@ describe(createServeSimArgs, () => { ]); }); + it('disables the native Share button only when requested', () => { + expect(createServeSimArgs({ port: 4321, disableShare: true })).toContain('--disable-share'); + expect(createServeSimArgs({ port: 4321, disableShare: false })).not.toContain( + '--disable-share' + ); + expect(createServeSimArgs({ port: 4321 })).not.toContain('--disable-share'); + }); + it('appends the launch flags after the streaming policy', () => { const args = createServeSimArgs({ port: 4321, @@ -983,6 +1015,126 @@ describe(startDeviceSessionHostAsync, () => { await host.finishAsync(); }); + it('disables Share for the default serve-sim without an extra package probe', async () => { + const host = await startDeviceSessionHostAsync(createCtxMock(), { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger: createLoggerMock(), + timeoutMs: 10_000, + disableShare: true, + }); + + const serveSimCalls = jest + .mocked(spawn) + .mock.calls.filter(([, args]) => args.includes('@expo/serve-sim@latest')); + expect(serveSimCalls).toHaveLength(1); + expect(serveSimCalls[0][1]).toContain('--disable-share'); + await host.finishAsync(); + }); + + it.each([true, false])( + 'keeps a pinned serve-sim launch compatible when --disable-share support is %s', + async supportsDisableShare => { + const logger = createLoggerMock(); + const actualSpawnDetached = remoteDeviceRunSession.spawnDetached; + const stopProbe = jest.fn().mockResolvedValue(undefined); + const spawnDetachedSpy = jest + .spyOn(remoteDeviceRunSession, 'spawnDetached') + .mockImplementationOnce(() => ({ + pid: 1234, + getOutput: () => (supportsDisableShare ? '--disable-share' : '--share-url'), + getExitError: () => new Error('Process exited with code 0.'), + stopAsync: stopProbe, + })) + .mockImplementation(actualSpawnDetached); + + const host = await startDeviceSessionHostAsync(createCtxMock(), { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 10_000, + packageVersion: '1.2.3', + disableShare: true, + }); + + expect(spawnDetachedSpy.mock.calls[0][0]).toEqual( + expect.objectContaining({ + command: 'npx', + args: ['--yes', '@expo/serve-sim@1.2.3', '--help'], + env, + stopGracePeriodMs: 0, + }) + ); + expect(stopProbe).toHaveBeenCalledTimes(1); + const launchArgs = jest + .mocked(spawn) + .mock.calls.find(([, args]) => args.includes('@expo/serve-sim@1.2.3'))![1]; + expect(launchArgs).not.toContain('--share-url'); + expect(launchArgs.includes('--disable-share')).toBe(supportsDisableShare); + const compatibilityWarnings = jest + .mocked(logger.warn) + .mock.calls.filter(([message]) => + String(message).includes('does not support --disable-share') + ); + expect(compatibilityWarnings).toHaveLength(supportsDisableShare ? 0 : 1); + await host.finishAsync(); + } + ); + + it('keeps the authenticated link out of serve-sim while retaining the public frame URL', async () => { + jest.mocked(ngrok.forward).mockResolvedValue({ + url: () => 'https://preview.example.test', + close: jest.fn().mockResolvedValue(undefined), + } as never); + const privatePreviewUrl = + 'https://expo.dev/accounts/owner/projects/app/simulator-sessions/session-id/preview'; + const host = await startDeviceSessionHostAsync(createCtxMock(), { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger: createLoggerMock(), + timeoutMs: 10_000, + disableShare: true, + privatePreviewUrl, + }); + const preview = await host.openPreviewAsync({ baseDomain }); + + const serveSimArgs = jest + .mocked(spawn) + .mock.calls.find(([, args]) => args.includes('@expo/serve-sim@latest'))![1]; + expect(serveSimArgs).not.toContain('--share-url'); + expect(serveSimArgs).toContain('--disable-share'); + expect(preview.sharePageUrl).toBe(privatePreviewUrl); + expect(preview.previewPageUrl).toMatch( + /^https:\/\/expo\.dev\/simulator-preview\/[a-f0-9]{32}$/ + ); + await host.finishAsync(); + }); + + it('does not expose a public share URL when a private job omits its authenticated URL', async () => { + jest.mocked(ngrok.forward).mockResolvedValue({ + url: () => 'https://preview.example.test', + close: jest.fn().mockResolvedValue(undefined), + } as never); + const host = await startDeviceSessionHostAsync(createCtxMock(), { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger: createLoggerMock(), + timeoutMs: 10_000, + disableShare: true, + }); + const preview = await host.openPreviewAsync({ baseDomain }); + + const serveSimArgs = jest + .mocked(spawn) + .mock.calls.find(([, args]) => args.includes('@expo/serve-sim@latest'))![1]; + expect(serveSimArgs).not.toContain('--share-url'); + expect(preview.sharePageUrl).toBeUndefined(); + expect(preview.previewPageUrl).toMatch( + /^https:\/\/expo\.dev\/simulator-preview\/[a-f0-9]{32}$/ + ); + await host.finishAsync(); + }); + it('installs ffmpeg before starting expo-device-hub for Linux', async () => { const packageVersion = '1.2.3'; const close = jest.fn().mockResolvedValue(undefined); @@ -1066,6 +1218,11 @@ describe(startDeviceSessionHostAsync, () => { expect(preview.previewToken).toBe('tok-1'); expect(preview.apiUrl).toBe('https://preview.example.test'); + expect(preview.sharePageUrl).toBe(preview.previewPageUrl); + const serveSimArgs = jest + .mocked(spawn) + .mock.calls.find(([, args]) => args.includes('@expo/serve-sim@latest'))![1]; + expect(serveSimArgs[serveSimArgs.indexOf('--share-url') + 1]).toBe(preview.previewPageUrl); }); it.each([ diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimHelpProbe.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimHelpProbe.test.ts new file mode 100644 index 0000000000..1816f4a248 --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimHelpProbe.test.ts @@ -0,0 +1,43 @@ +import type { BuildStepEnv } from '@expo/steps'; + +import { probeServeSimHelpAsync } from '../deviceSessionHost'; + +const env = process.env as BuildStepEnv; + +describe(probeServeSimHelpAsync, () => { + it('returns when the launcher exits even if a descendant retains its output pipes', async () => { + const script = ` + const { spawn } = require('node:child_process'); + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: ['ignore', 'inherit', 'inherit'], + }); + child.unref(); + process.stdout.write('--share-url\\n', () => process.exit(0)); + `; + const startedAt = Date.now(); + expect( + await probeServeSimHelpAsync({ + command: process.execPath, + args: ['-e', script], + env, + timeoutMs: 5_000, + }) + ).toBe(false); + expect(Date.now() - startedAt).toBeLessThan(2_500); + }); + + it('returns after its deadline when the process ignores SIGTERM', async () => { + const script = `process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);`; + const startedAt = Date.now(); + + expect( + await probeServeSimHelpAsync({ + command: process.execPath, + args: ['-e', script], + env, + timeoutMs: 300, + }) + ).toBe(false); + expect(Date.now() - startedAt).toBeLessThan(2_000); + }); +}); diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index 61b6181876..887d4d0a35 100644 --- a/packages/build-tools/src/steps/utils/deviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/deviceSessionHost.ts @@ -72,6 +72,7 @@ const RECORDING_STOP_GRACE_PERIOD_MS = EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS + EXPO_DEVICE_HUB_EXIT_LEEWAY_MS; const HOST_OUTPUT_TAIL_CHARS = 8_000; const WEB_PREVIEW_READY_POLL_INTERVAL_MS = 250; +const SERVE_SIM_HELP_PROBE_TIMEOUT_MS = 30_000; export function websiteOrigin(env: BuildStepEnv): string { return env.EXPO_LOCAL @@ -106,6 +107,76 @@ function createServeSimPackageSpec(packageVersion: string | undefined): string { return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`; } +/** Observe the launcher exit, not pipe close: package-manager descendants can hold pipes open. */ +export async function probeServeSimHelpAsync({ + command, + args, + env, + timeoutMs = SERVE_SIM_HELP_PROBE_TIMEOUT_MS, +}: { + command: string; + args: string[]; + env: BuildStepEnv; + timeoutMs?: number; +}): Promise { + let probe: DetachedProcessHandle | undefined; + try { + const spawned = spawnDetached({ command, args, env, stopGracePeriodMs: 0 }); + probe = spawned; + return await withDeviceRunSessionTimeoutAsync( + { name: 'serve-sim help probe', timeoutMs }, + async signal => { + while (!spawned.getOutput().includes('--disable-share') && !spawned.getExitError()) { + signal.throwIfAborted(); + await sleepAsync(25); + } + // The child exit event may arrive just before its last stdout chunk. + if (!spawned.getOutput().includes('--disable-share')) { + await sleepAsync(25); + } + return spawned.getOutput().includes('--disable-share'); + } + ); + } catch { + return false; + } finally { + // SIGKILL is sent synchronously after SIGTERM (zero grace period). The asynchronous + // reap check is observed but cannot extend the probe's hard deadline. + if (probe) { + void probe.stopAsync().catch(() => {}); + } + } +} + +async function canDisableServeSimShareAsync({ + disableShare, + packageVersion, + packageManager, + env, + logger, +}: { + disableShare: boolean; + packageVersion?: string; + packageManager: PackageManager; + env: BuildStepEnv; + logger: bunyan; +}): Promise { + if (!disableShare || !packageVersion || packageVersion === 'latest') { + return disableShare; + } + const probe = resolvePackageExec(packageManager, [ + createServeSimPackageSpec(packageVersion), + '--help', + ]); + if (await probeServeSimHelpAsync({ ...probe, env })) { + return true; + } + logger.warn( + `The selected serve-sim package (${packageVersion}) does not support --disable-share; its Share button will remain visible.` + ); + return false; +} + function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string { return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`; } @@ -115,6 +186,7 @@ export function createServeSimArgs({ turnArgs = [], websiteArgs = [], shareUrl, + disableShare = false, packageVersion, iosSimulatorUdid, installAppPath, @@ -128,6 +200,7 @@ export function createServeSimArgs({ turnArgs?: string[]; websiteArgs?: string[]; shareUrl?: string; + disableShare?: boolean; packageVersion?: string; iosSimulatorUdid?: string; installAppPath?: string; @@ -157,6 +230,7 @@ export function createServeSimArgs({ ...turnArgs, ...websiteArgs, ...(shareUrl ? ['--share-url', shareUrl] : []), + ...(disableShare ? ['--disable-share'] : []), ...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []), ...(installAppPath ? ['--install-app-path', installAppPath] : []), ...launchArgs.flatMap(argument => ['--launch-arg', argument]), @@ -308,6 +382,8 @@ export async function readServeSimPreviewTokenAsync( export type DeviceWebPreview = { previewPageUrl: string; + /** Human-facing link, absent when a private job has no authenticated URL. */ + sharePageUrl?: string; apiUrl: string; previewToken?: string; /** Closes this tunnel only. The session host and recording remain running. */ @@ -339,6 +415,8 @@ type DeviceSessionHostOptions = { startupTimeoutMs?: number; signal?: AbortSignal; packageVersion?: string; + disableShare?: boolean; + privatePreviewUrl?: string; iosSimulatorUdid?: string; installAppPath?: string; bootEnv?: Record; @@ -387,6 +465,8 @@ async function startDeviceSessionHostInternalAsync( installAppPath, application, onStartupError, + disableShare = false, + privatePreviewUrl, launchAppIdentifier, launchArgs, openUrl, @@ -442,14 +522,25 @@ async function startDeviceSessionHostInternalAsync( const serveSimStateDir = env.SERVE_SIM_STATE_DIR ?? SERVE_SIM_STATE_DIR; const subdomainId = randomBytes(16).toString('hex'); const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId); + const sharePageUrl = disableShare ? privatePreviewUrl : previewPageUrl; const port = await findAvailablePortAsync(); const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim'; const packageSpec = isAndroid ? createExpoDeviceHubPackageSpec(packageVersion) : createServeSimPackageSpec(packageVersion); + const packageManager = resolveConfiguredPackageManager(env, PackageManager.NPM); + const shouldDisableShare = + !isAndroid && + (await canDisableServeSimShareAsync({ + disableShare, + packageVersion, + packageManager, + env, + logger, + })); const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger }); const previewExec = resolvePackageExec( - resolveConfiguredPackageManager(env, PackageManager.NPM), + packageManager, isAndroid ? createExpoDeviceHubArgs({ port, @@ -464,7 +555,8 @@ async function startDeviceSessionHostInternalAsync( packageVersion, iosSimulatorUdid, websiteArgs: websiteOriginArgs(env), - shareUrl: previewPageUrl, + shareUrl: disableShare ? undefined : previewPageUrl, + disableShare: shouldDisableShare, installAppPath: iosSimulatorUdid ? undefined : installAppPath, launchAppIdentifier: iosSimulatorUdid ? undefined : launchAppIdentifier, launchArgs: iosSimulatorUdid ? [] : launchArgs, @@ -557,6 +649,7 @@ async function startDeviceSessionHostInternalAsync( previewFailed = false; return { previewPageUrl, + sharePageUrl, apiUrl: tunnel.url, previewToken, closeAsync() { diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 1aa872b247..8b5c43e22b 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -858,6 +858,28 @@ export function createServeSimLaunchInputProviders(): ReturnType< ]; } +/** Older session jobs omit this input and keep their existing Share button. */ +export function createPublicPreviewInputProvider(): ReturnType< + typeof BuildStepInput.createProvider +> { + return BuildStepInput.createProvider({ + id: 'public_preview', + required: false, + defaultValue: true, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }); +} + +export function createPrivatePreviewUrlInputProvider(): ReturnType< + typeof BuildStepInput.createProvider +> { + return BuildStepInput.createProvider({ + id: 'private_preview_url', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); +} + export function parseServeSimLaunchInputs( { launchAppIdentifier: rawLaunchAppIdentifier, From b0092d2d53793e101f22943687c8d44b6cc3f9f1 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 13 Oct 2026 20:24:42 -0700 Subject: [PATCH 2/6] [build-tools] Add PR attribution to changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b7807f63e..9adaa20b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features - [eas-cli] Add `--platform` to `eas workflow:insights:maestro` and list Maestro runs and flows per platform. ([#4613](https://github.com/expo/eas-cli/pull/4613) by [@hSATAC](https://github.com/hSATAC)) -- [build-tools] Respect saved sharing preferences for iOS simulator preview sessions. +- [build-tools] Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp)) ### 🐛 Bug fixes From a310549cb5eadd5217f376335eccae62298558c5 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 13 Oct 2026 21:26:18 -0700 Subject: [PATCH 3/6] [build-tools] clarify preview Share fallback and verify probe cleanup --- .../__tests__/remoteDeviceRunSession.test.ts | 7 ++- .../utils/__tests__/serveSimHelpProbe.test.ts | 49 +++++++++++++++++++ .../src/steps/utils/deviceSessionHost.ts | 2 +- 3 files changed, 56 insertions(+), 2 deletions(-) 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 be48654223..fb910a9eb8 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -1074,9 +1074,14 @@ describe(startDeviceSessionHostAsync, () => { const compatibilityWarnings = jest .mocked(logger.warn) .mock.calls.filter(([message]) => - String(message).includes('does not support --disable-share') + String(message).includes('Could not confirm --disable-share support') ); expect(compatibilityWarnings).toHaveLength(supportsDisableShare ? 0 : 1); + if (!supportsDisableShare) { + expect(compatibilityWarnings[0][0]).toBe( + 'Could not confirm --disable-share support for serve-sim (1.2.3); Share remains available and copies the direct preview URL.' + ); + } await host.finishAsync(); } ); diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimHelpProbe.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimHelpProbe.test.ts index 1816f4a248..42b3afb733 100644 --- a/packages/build-tools/src/steps/utils/__tests__/serveSimHelpProbe.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimHelpProbe.test.ts @@ -1,10 +1,57 @@ import type { BuildStepEnv } from '@expo/steps'; +import { isProcessGroupRunning } from '../../../utils/processes'; import { probeServeSimHelpAsync } from '../deviceSessionHost'; +import * as remoteDeviceRunSession from '../remoteDeviceRunSession'; const env = process.env as BuildStepEnv; describe(probeServeSimHelpAsync, () => { + let probes: remoteDeviceRunSession.DetachedProcessHandle[]; + + beforeEach(() => { + probes = []; + const spawnDetached = remoteDeviceRunSession.spawnDetached; + jest.spyOn(remoteDeviceRunSession, 'spawnDetached').mockImplementation(options => { + const probe = spawnDetached(options); + probes.push(probe); + return probe; + }); + }); + + afterEach(async () => { + try { + await Promise.all(probes.map(probe => probe.stopAsync())); + } finally { + jest.restoreAllMocks(); + } + }); + + async function expectProbeStoppedAsync(): Promise { + expect(probes).toHaveLength(1); + const pid = probes[0].pid; + if (pid === undefined) { + throw new Error('Probe process did not start.'); + } + const deadline = Date.now() + 2_000; + while (isProcessGroupRunning(pid) && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 20)); + } + expect(isProcessGroupRunning(pid)).toBe(false); + } + + it('detects support from a real help process that prints the flag and exits', async () => { + expect( + await probeServeSimHelpAsync({ + command: process.execPath, + args: ['-e', "process.stdout.write('--disable-share\\n', () => process.exit(0));"], + env, + timeoutMs: 5_000, + }) + ).toBe(true); + await expectProbeStoppedAsync(); + }); + it('returns when the launcher exits even if a descendant retains its output pipes', async () => { const script = ` const { spawn } = require('node:child_process'); @@ -24,6 +71,7 @@ describe(probeServeSimHelpAsync, () => { }) ).toBe(false); expect(Date.now() - startedAt).toBeLessThan(2_500); + await expectProbeStoppedAsync(); }); it('returns after its deadline when the process ignores SIGTERM', async () => { @@ -39,5 +87,6 @@ describe(probeServeSimHelpAsync, () => { }) ).toBe(false); expect(Date.now() - startedAt).toBeLessThan(2_000); + await expectProbeStoppedAsync(); }); }); diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index 887d4d0a35..5749196ade 100644 --- a/packages/build-tools/src/steps/utils/deviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/deviceSessionHost.ts @@ -172,7 +172,7 @@ async function canDisableServeSimShareAsync({ return true; } logger.warn( - `The selected serve-sim package (${packageVersion}) does not support --disable-share; its Share button will remain visible.` + `Could not confirm --disable-share support for serve-sim (${packageVersion}); Share remains available and copies the direct preview URL.` ); return false; } From 1ef60997edf7a5f10b614fc38bb2146e2f069738 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 13 Oct 2026 23:47:22 -0700 Subject: [PATCH 4/6] [build-tools] pass preview sharing flags directly Co-authored-by: Codex --- .../__tests__/remoteDeviceRunSession.test.ts | 70 +++----------- .../utils/__tests__/serveSimHelpProbe.test.ts | 92 ------------------- .../src/steps/utils/deviceSessionHost.ts | 83 +---------------- 3 files changed, 14 insertions(+), 231 deletions(-) delete mode 100644 packages/build-tools/src/steps/utils/__tests__/serveSimHelpProbe.test.ts 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 fb910a9eb8..37c79ba5e5 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -1015,73 +1015,26 @@ describe(startDeviceSessionHostAsync, () => { await host.finishAsync(); }); - it('disables Share for the default serve-sim without an extra package probe', async () => { - const host = await startDeviceSessionHostAsync(createCtxMock(), { - runtimePlatform: BuildRuntimePlatform.DARWIN, - env, - logger: createLoggerMock(), - timeoutMs: 10_000, - disableShare: true, - }); - - const serveSimCalls = jest - .mocked(spawn) - .mock.calls.filter(([, args]) => args.includes('@expo/serve-sim@latest')); - expect(serveSimCalls).toHaveLength(1); - expect(serveSimCalls[0][1]).toContain('--disable-share'); - await host.finishAsync(); - }); - - it.each([true, false])( - 'keeps a pinned serve-sim launch compatible when --disable-share support is %s', - async supportsDisableShare => { - const logger = createLoggerMock(); - const actualSpawnDetached = remoteDeviceRunSession.spawnDetached; - const stopProbe = jest.fn().mockResolvedValue(undefined); - const spawnDetachedSpy = jest - .spyOn(remoteDeviceRunSession, 'spawnDetached') - .mockImplementationOnce(() => ({ - pid: 1234, - getOutput: () => (supportsDisableShare ? '--disable-share' : '--share-url'), - getExitError: () => new Error('Process exited with code 0.'), - stopAsync: stopProbe, - })) - .mockImplementation(actualSpawnDetached); - + it.each([undefined, 'latest', '1.2.3'])( + 'passes private sharing flags directly to serve-sim version %s', + async packageVersion => { const host = await startDeviceSessionHostAsync(createCtxMock(), { runtimePlatform: BuildRuntimePlatform.DARWIN, env, - logger, + logger: createLoggerMock(), timeoutMs: 10_000, - packageVersion: '1.2.3', + packageVersion, disableShare: true, }); - expect(spawnDetachedSpy.mock.calls[0][0]).toEqual( - expect.objectContaining({ - command: 'npx', - args: ['--yes', '@expo/serve-sim@1.2.3', '--help'], - env, - stopGracePeriodMs: 0, - }) - ); - expect(stopProbe).toHaveBeenCalledTimes(1); - const launchArgs = jest + const serveSimCalls = jest .mocked(spawn) - .mock.calls.find(([, args]) => args.includes('@expo/serve-sim@1.2.3'))![1]; - expect(launchArgs).not.toContain('--share-url'); - expect(launchArgs.includes('--disable-share')).toBe(supportsDisableShare); - const compatibilityWarnings = jest - .mocked(logger.warn) - .mock.calls.filter(([message]) => - String(message).includes('Could not confirm --disable-share support') + .mock.calls.filter(([, args]) => + args.includes(`@expo/serve-sim@${packageVersion ?? 'latest'}`) ); - expect(compatibilityWarnings).toHaveLength(supportsDisableShare ? 0 : 1); - if (!supportsDisableShare) { - expect(compatibilityWarnings[0][0]).toBe( - 'Could not confirm --disable-share support for serve-sim (1.2.3); Share remains available and copies the direct preview URL.' - ); - } + expect(serveSimCalls).toHaveLength(1); + expect(serveSimCalls[0][1]).toContain('--disable-share'); + expect(serveSimCalls[0][1]).not.toContain('--share-url'); await host.finishAsync(); } ); @@ -1228,6 +1181,7 @@ describe(startDeviceSessionHostAsync, () => { .mocked(spawn) .mock.calls.find(([, args]) => args.includes('@expo/serve-sim@latest'))![1]; expect(serveSimArgs[serveSimArgs.indexOf('--share-url') + 1]).toBe(preview.previewPageUrl); + expect(serveSimArgs).not.toContain('--disable-share'); }); it.each([ diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimHelpProbe.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimHelpProbe.test.ts deleted file mode 100644 index 42b3afb733..0000000000 --- a/packages/build-tools/src/steps/utils/__tests__/serveSimHelpProbe.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { BuildStepEnv } from '@expo/steps'; - -import { isProcessGroupRunning } from '../../../utils/processes'; -import { probeServeSimHelpAsync } from '../deviceSessionHost'; -import * as remoteDeviceRunSession from '../remoteDeviceRunSession'; - -const env = process.env as BuildStepEnv; - -describe(probeServeSimHelpAsync, () => { - let probes: remoteDeviceRunSession.DetachedProcessHandle[]; - - beforeEach(() => { - probes = []; - const spawnDetached = remoteDeviceRunSession.spawnDetached; - jest.spyOn(remoteDeviceRunSession, 'spawnDetached').mockImplementation(options => { - const probe = spawnDetached(options); - probes.push(probe); - return probe; - }); - }); - - afterEach(async () => { - try { - await Promise.all(probes.map(probe => probe.stopAsync())); - } finally { - jest.restoreAllMocks(); - } - }); - - async function expectProbeStoppedAsync(): Promise { - expect(probes).toHaveLength(1); - const pid = probes[0].pid; - if (pid === undefined) { - throw new Error('Probe process did not start.'); - } - const deadline = Date.now() + 2_000; - while (isProcessGroupRunning(pid) && Date.now() < deadline) { - await new Promise(resolve => setTimeout(resolve, 20)); - } - expect(isProcessGroupRunning(pid)).toBe(false); - } - - it('detects support from a real help process that prints the flag and exits', async () => { - expect( - await probeServeSimHelpAsync({ - command: process.execPath, - args: ['-e', "process.stdout.write('--disable-share\\n', () => process.exit(0));"], - env, - timeoutMs: 5_000, - }) - ).toBe(true); - await expectProbeStoppedAsync(); - }); - - it('returns when the launcher exits even if a descendant retains its output pipes', async () => { - const script = ` - const { spawn } = require('node:child_process'); - const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { - stdio: ['ignore', 'inherit', 'inherit'], - }); - child.unref(); - process.stdout.write('--share-url\\n', () => process.exit(0)); - `; - const startedAt = Date.now(); - expect( - await probeServeSimHelpAsync({ - command: process.execPath, - args: ['-e', script], - env, - timeoutMs: 5_000, - }) - ).toBe(false); - expect(Date.now() - startedAt).toBeLessThan(2_500); - await expectProbeStoppedAsync(); - }); - - it('returns after its deadline when the process ignores SIGTERM', async () => { - const script = `process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);`; - const startedAt = Date.now(); - - expect( - await probeServeSimHelpAsync({ - command: process.execPath, - args: ['-e', script], - env, - timeoutMs: 300, - }) - ).toBe(false); - expect(Date.now() - startedAt).toBeLessThan(2_000); - await expectProbeStoppedAsync(); - }); -}); diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index 5749196ade..7de6b3d360 100644 --- a/packages/build-tools/src/steps/utils/deviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/deviceSessionHost.ts @@ -72,7 +72,6 @@ const RECORDING_STOP_GRACE_PERIOD_MS = EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS + EXPO_DEVICE_HUB_EXIT_LEEWAY_MS; const HOST_OUTPUT_TAIL_CHARS = 8_000; const WEB_PREVIEW_READY_POLL_INTERVAL_MS = 250; -const SERVE_SIM_HELP_PROBE_TIMEOUT_MS = 30_000; export function websiteOrigin(env: BuildStepEnv): string { return env.EXPO_LOCAL @@ -107,76 +106,6 @@ function createServeSimPackageSpec(packageVersion: string | undefined): string { return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`; } -/** Observe the launcher exit, not pipe close: package-manager descendants can hold pipes open. */ -export async function probeServeSimHelpAsync({ - command, - args, - env, - timeoutMs = SERVE_SIM_HELP_PROBE_TIMEOUT_MS, -}: { - command: string; - args: string[]; - env: BuildStepEnv; - timeoutMs?: number; -}): Promise { - let probe: DetachedProcessHandle | undefined; - try { - const spawned = spawnDetached({ command, args, env, stopGracePeriodMs: 0 }); - probe = spawned; - return await withDeviceRunSessionTimeoutAsync( - { name: 'serve-sim help probe', timeoutMs }, - async signal => { - while (!spawned.getOutput().includes('--disable-share') && !spawned.getExitError()) { - signal.throwIfAborted(); - await sleepAsync(25); - } - // The child exit event may arrive just before its last stdout chunk. - if (!spawned.getOutput().includes('--disable-share')) { - await sleepAsync(25); - } - return spawned.getOutput().includes('--disable-share'); - } - ); - } catch { - return false; - } finally { - // SIGKILL is sent synchronously after SIGTERM (zero grace period). The asynchronous - // reap check is observed but cannot extend the probe's hard deadline. - if (probe) { - void probe.stopAsync().catch(() => {}); - } - } -} - -async function canDisableServeSimShareAsync({ - disableShare, - packageVersion, - packageManager, - env, - logger, -}: { - disableShare: boolean; - packageVersion?: string; - packageManager: PackageManager; - env: BuildStepEnv; - logger: bunyan; -}): Promise { - if (!disableShare || !packageVersion || packageVersion === 'latest') { - return disableShare; - } - const probe = resolvePackageExec(packageManager, [ - createServeSimPackageSpec(packageVersion), - '--help', - ]); - if (await probeServeSimHelpAsync({ ...probe, env })) { - return true; - } - logger.warn( - `Could not confirm --disable-share support for serve-sim (${packageVersion}); Share remains available and copies the direct preview URL.` - ); - return false; -} - function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string { return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`; } @@ -529,15 +458,6 @@ async function startDeviceSessionHostInternalAsync( ? createExpoDeviceHubPackageSpec(packageVersion) : createServeSimPackageSpec(packageVersion); const packageManager = resolveConfiguredPackageManager(env, PackageManager.NPM); - const shouldDisableShare = - !isAndroid && - (await canDisableServeSimShareAsync({ - disableShare, - packageVersion, - packageManager, - env, - logger, - })); const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger }); const previewExec = resolvePackageExec( packageManager, @@ -555,8 +475,9 @@ async function startDeviceSessionHostInternalAsync( packageVersion, iosSimulatorUdid, websiteArgs: websiteOriginArgs(env), + // serve-sim adds its bearer token to --share-url; private links are for worker logs only. shareUrl: disableShare ? undefined : previewPageUrl, - disableShare: shouldDisableShare, + disableShare, installAppPath: iosSimulatorUdid ? undefined : installAppPath, launchAppIdentifier: iosSimulatorUdid ? undefined : launchAppIdentifier, launchArgs: iosSimulatorUdid ? [] : launchArgs, From d9f0722cedf1e9f04a0d47b97b168ea15fef01ca Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 13 Oct 2026 23:57:01 -0700 Subject: [PATCH 5/6] [build-tools] clarify public preview logging coverage Co-authored-by: Codex --- .../startWebPreviewRemoteSession.test.ts | 19 +++++++++++++++++++ .../src/steps/utils/deviceSessionHost.ts | 3 +-- 2 files changed, 20 insertions(+), 2 deletions(-) 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 c4e2bdb354..98785f0fd4 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts @@ -77,6 +77,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({ openPreviewAsync: jest.fn().mockResolvedValue({ previewPageUrl: 'https://expo.dev/simulator-preview/preview-id', + sharePageUrl: 'https://expo.dev/simulator-preview/preview-id', apiUrl: 'https://web-preview.example.test', closeAsync: jest.fn(), }), @@ -91,6 +92,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({ openPreviewAsync: jest.fn().mockResolvedValue({ previewPageUrl: 'https://expo.dev/simulator-preview/preview-id', + sharePageUrl: 'https://expo.dev/simulator-preview/preview-id', apiUrl: 'https://web-preview.example.test', previewToken: 'tok-1', closeAsync: jest.fn(), @@ -143,6 +145,14 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { }); it('does not log the public link when a private job lacks its authenticated URL', async () => { + jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({ + openPreviewAsync: jest.fn().mockResolvedValue({ + previewPageUrl: 'https://expo.dev/simulator-preview/preview-id', + apiUrl: 'https://web-preview.example.test', + closeAsync: jest.fn(), + }), + finishAsync: stopAsync, + }); await runAsync(BuildRuntimePlatform.DARWIN, { public_preview: { value: false } }); expect(logger.info).toHaveBeenCalledWith('Private preview URL unavailable for this session.'); @@ -175,6 +185,15 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { ])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => { await runAsync(runtimePlatform, selectsXcode ? { boot_simulator: { value: true } } : {}); + expect( + jest + .mocked(logger.info) + .mock.calls.filter( + ([message]) => + message === + 'Preview URL: https://expo.dev/simulator-preview/preview-id (server: https://web-preview.example.test).' + ) + ).toHaveLength(1); expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0); expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, { runtimePlatform, diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index 7de6b3d360..5b117cd6b3 100644 --- a/packages/build-tools/src/steps/utils/deviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/deviceSessionHost.ts @@ -457,10 +457,9 @@ async function startDeviceSessionHostInternalAsync( const packageSpec = isAndroid ? createExpoDeviceHubPackageSpec(packageVersion) : createServeSimPackageSpec(packageVersion); - const packageManager = resolveConfiguredPackageManager(env, PackageManager.NPM); const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger }); const previewExec = resolvePackageExec( - packageManager, + resolveConfiguredPackageManager(env, PackageManager.NPM), isAndroid ? createExpoDeviceHubArgs({ port, From 8cfbde1017d4a337d324bdf0bacabfd7ac487588 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 14 Oct 2026 19:54:49 -0700 Subject: [PATCH 6/6] [build-tools] redact preview tokens from host diagnostics Edited-by: Codex --- .../private-simulator-preview-sharing.md | 5 +++++ CHANGELOG.md | 1 - .../functions/startAgentDeviceSession.ts | 2 +- .../utils/__tests__/deviceSessionHost.test.ts | 21 +++++++++++++++++++ .../__tests__/remoteDeviceRunSession.test.ts | 4 ++-- .../src/steps/utils/deviceSessionHost.ts | 17 ++++++++++++--- 6 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 .changeset/private-simulator-preview-sharing.md diff --git a/.changeset/private-simulator-preview-sharing.md b/.changeset/private-simulator-preview-sharing.md new file mode 100644 index 0000000000..7a9d6b0be9 --- /dev/null +++ b/.changeset/private-simulator-preview-sharing.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': minor +--- + +Respect saved sharing preferences for iOS simulator preview sessions. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9adaa20b53..d0361b9213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,6 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features - [eas-cli] Add `--platform` to `eas workflow:insights:maestro` and list Maestro runs and flows per platform. ([#4613](https://github.com/expo/eas-cli/pull/4613) by [@hSATAC](https://github.com/hSATAC)) -- [build-tools] Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp)) ### 🐛 Bug fixes diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts index 6211bfb3bb..aa3ce2c53f 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts @@ -19,9 +19,9 @@ import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtil import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi'; import { withLocalEgressSession } from '../utils/localEgressSession'; import { - omitNgrokAuthtoken, createPrivatePreviewUrlInputProvider, createPublicPreviewInputProvider, + omitNgrokAuthtoken, selectXcodeDeveloperDirectoryAsync, } from '../utils/remoteDeviceRunSession'; import { diff --git a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts index 8830e85efc..3c7d61ba26 100644 --- a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts @@ -1766,3 +1766,24 @@ it('cancels an in-flight install while Simulator setup is pending', async () => expect(runServeSimActionAsync).toHaveBeenCalledTimes(1); expect(stopServer).toHaveBeenCalledTimes(1); }); + +it('redacts bearer tokens from preview readiness diagnostics while preserving the failure', async () => { + const output = [ + 'Native addon failed to initialize', + 'Local: http://localhost:1234/?token=private-token&mode=preview', + 'Share: https://expo.dev/simulator-preview/abc#token=fragment-secret', + '{"url":"http://localhost:1234/","token":"quiet-secret"}', + ].join('\n'); + const readiness = waitForWebPreviewReadyAsync({ + previewServer: { pid: undefined, getOutput: () => output }, + serverName: 'serve-sim', + port: 1234, + timeoutMs: 0, + }); + await expect(readiness).rejects.toThrow('Timed out waiting for serve-sim readiness'); + await expect(readiness).rejects.toThrow('Native addon failed to initialize'); + await expect(readiness).rejects.toThrow('?token=[redacted]&mode=preview'); + for (const secret of ['private-token', 'fragment-secret', 'quiet-secret']) { + await expect(readiness).rejects.not.toThrow(secret); + } +}); 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 37c79ba5e5..822363a2f3 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -1054,7 +1054,7 @@ describe(startDeviceSessionHostAsync, () => { disableShare: true, privatePreviewUrl, }); - const preview = await host.openPreviewAsync({ baseDomain }); + const preview = await host.openPreviewAsync({ baseDomain, authtoken }); const serveSimArgs = jest .mocked(spawn) @@ -1080,7 +1080,7 @@ describe(startDeviceSessionHostAsync, () => { timeoutMs: 10_000, disableShare: true, }); - const preview = await host.openPreviewAsync({ baseDomain }); + const preview = await host.openPreviewAsync({ baseDomain, authtoken }); const serveSimArgs = jest .mocked(spawn) diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index 5b117cd6b3..e77737ddde 100644 --- a/packages/build-tools/src/steps/utils/deviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/deviceSessionHost.ts @@ -222,6 +222,13 @@ const WebPreviewReadyResponseSchema = z.object({ device: z.string(), }); +// Startup output includes bearer URLs (or a token field with --quiet). +function redactPreviewTokens(output: string): string { + return output + .replace(/([?&#]token=)[^\s&"'<>]+/g, '$1[redacted]') + .replace(/("token"\s*:\s*")[^"]*/g, '$1[redacted]'); +} + export async function waitForWebPreviewReadyAsync({ previewServer, serverName, @@ -247,7 +254,7 @@ export async function waitForWebPreviewReadyAsync({ if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) { throw new SystemError( `${serverName} exited before becoming ready. Last output:\n${ - previewServer.getOutput() || '' + redactPreviewTokens(previewServer.getOutput()) || '' }` ); } @@ -283,7 +290,7 @@ export async function waitForWebPreviewReadyAsync({ throw new SystemError( `Timed out waiting for ${serverName} readiness at ${readyUrl}${ lastError instanceof Error ? `: ${lastError.message}` : '' - }. Last output:\n${previewServer.getOutput() || ''}` + }. Last output:\n${redactPreviewTokens(previewServer.getOutput()) || ''}` ); } @@ -889,7 +896,11 @@ async function finishDeviceSessionHostAsync( if (recording && captured && (finalization === 'failed' || !uploaded)) { // The Hub reports capture failures on stderr; the stop route answers with only a summary. artifactLogger.warn( - { hostOutput: previewServer.getOutput().slice(-HOST_OUTPUT_TAIL_CHARS) || '' }, + { + hostOutput: + redactPreviewTokens(previewServer.getOutput()).slice(-HOST_OUTPUT_TAIL_CHARS) || + '', + }, 'Session host output around the recording failure.' ); }