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/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..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(), @@ -113,6 +115,54 @@ 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 () => { + 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.'); + 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') { @@ -135,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, @@ -150,6 +209,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { } : {}), packageVersion: '1.2.3', + disableShare: false, launchAppIdentifier: undefined, launchArgs: [], openUrl: undefined, @@ -196,6 +256,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { 'install_app_path', 'network_capture', 'network_capture_fields', + 'public_preview', + 'private_preview_url', 'package_version', 'max_duration_seconds', ]); @@ -243,6 +305,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..aa3ce2c53f 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts @@ -19,6 +19,8 @@ import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtil import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi'; import { withLocalEgressSession } from '../utils/localEgressSession'; import { + createPrivatePreviewUrlInputProvider, + createPublicPreviewInputProvider, omitNgrokAuthtoken, selectXcodeDeveloperDirectoryAsync, } from '../utils/remoteDeviceRunSession'; @@ -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__/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 aa7cbfcfed..822363a2f3 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,84 @@ describe(startDeviceSessionHostAsync, () => { await host.finishAsync(); }); + 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: createLoggerMock(), + timeoutMs: 10_000, + packageVersion, + disableShare: true, + }); + + const serveSimCalls = jest + .mocked(spawn) + .mock.calls.filter(([, args]) => + args.includes(`@expo/serve-sim@${packageVersion ?? 'latest'}`) + ); + expect(serveSimCalls).toHaveLength(1); + expect(serveSimCalls[0][1]).toContain('--disable-share'); + expect(serveSimCalls[0][1]).not.toContain('--share-url'); + 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, authtoken }); + + 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, authtoken }); + + 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 +1176,12 @@ 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); + expect(serveSimArgs).not.toContain('--disable-share'); }); it.each([ diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index 61b6181876..e77737ddde 100644 --- a/packages/build-tools/src/steps/utils/deviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/deviceSessionHost.ts @@ -115,6 +115,7 @@ export function createServeSimArgs({ turnArgs = [], websiteArgs = [], shareUrl, + disableShare = false, packageVersion, iosSimulatorUdid, installAppPath, @@ -128,6 +129,7 @@ export function createServeSimArgs({ turnArgs?: string[]; websiteArgs?: string[]; shareUrl?: string; + disableShare?: boolean; packageVersion?: string; iosSimulatorUdid?: string; installAppPath?: string; @@ -157,6 +159,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]), @@ -219,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, @@ -244,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()) || '' }` ); } @@ -280,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()) || ''}` ); } @@ -308,6 +318,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 +351,8 @@ type DeviceSessionHostOptions = { startupTimeoutMs?: number; signal?: AbortSignal; packageVersion?: string; + disableShare?: boolean; + privatePreviewUrl?: string; iosSimulatorUdid?: string; installAppPath?: string; bootEnv?: Record; @@ -387,6 +401,8 @@ async function startDeviceSessionHostInternalAsync( installAppPath, application, onStartupError, + disableShare = false, + privatePreviewUrl, launchAppIdentifier, launchArgs, openUrl, @@ -442,6 +458,7 @@ 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 @@ -464,7 +481,9 @@ async function startDeviceSessionHostInternalAsync( packageVersion, iosSimulatorUdid, websiteArgs: websiteOriginArgs(env), - shareUrl: previewPageUrl, + // serve-sim adds its bearer token to --share-url; private links are for worker logs only. + shareUrl: disableShare ? undefined : previewPageUrl, + disableShare, installAppPath: iosSimulatorUdid ? undefined : installAppPath, launchAppIdentifier: iosSimulatorUdid ? undefined : launchAppIdentifier, launchArgs: iosSimulatorUdid ? [] : launchArgs, @@ -557,6 +576,7 @@ async function startDeviceSessionHostInternalAsync( previewFailed = false; return { previewPageUrl, + sharePageUrl, apiUrl: tunnel.url, previewToken, closeAsync() { @@ -876,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.' ); } 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,