diff --git a/CHANGELOG.md b/CHANGELOG.md index ef73608dcb..c53be5232d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes +- [build-tools] Install ffmpeg for Appium simulator sessions. ([#4577](https://github.com/expo/eas-cli/pull/4577) by [@gwdp](https://github.com/gwdp)) + ### ๐Ÿงน Chores ## [24.12.1](https://github.com/expo/eas-cli/releases/tag/v24.12.1) - 2026-10-08 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 56fdefddf8..853e919e09 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 @@ -260,3 +260,76 @@ describe.each([ ); }); }); + +describe('Appium ffmpeg setup', () => { + async function runAppiumAsync(): Promise { + const fn = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext); + await fn.fn!( + { + logger, + global: { runtimePlatform: BuildRuntimePlatform.LINUX }, + } as unknown as BuildStepContext, + { + inputs: new Proxy({} as Record, { + get: (target, id: string) => target[id] ?? { value: undefined }, + }), + outputs: {}, + env: { + DEVICE_RUN_SESSION_ID: 'session-id', + EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test', + NGROK_AUTHTOKEN: 'token', + }, + } as never + ); + } + + function holdFfmpegInstall({ appiumError }: { appiumError?: Error } = {}) { + let finish!: (outcome: 'installed' | 'failed') => void; + const install = new Promise((resolve, reject) => { + finish = outcome => + outcome === 'installed' ? resolve({}) : reject(new Error('apt-get failed')); + }); + jest.mocked(spawn).mockImplementation(((command: string, args: string[]) => { + if (command === 'ffmpeg') { + return Promise.reject(new Error('ffmpeg missing')); + } + if (args.includes('install') && args.includes('ffmpeg')) { + return install; + } + return command === 'npm' && appiumError + ? Promise.reject(appiumError) + : Promise.resolve({ stdout: '{}' }); + }) as never); + return finish; + } + + it.each(['installed', 'failed'] as const)( + 'starts Appium after the ffmpeg install settles (%s)', + async outcome => { + const finishFfmpegInstall = holdFfmpegInstall(); + + const run = runAppiumAsync(); + await new Promise(resolve => setImmediate(resolve)); + expect(spawnDetached).not.toHaveBeenCalled(); + + finishFfmpegInstall(outcome); + await run; + expect(spawnDetached).toHaveBeenCalledTimes(1); + } + ); + + it('waits for the ffmpeg install before failing on an Appium install error', async () => { + const appiumError = new Error('npm failed'); + const finishFfmpegInstall = holdFfmpegInstall({ appiumError }); + + let settled = false; + const run = runAppiumAsync().finally(() => { + settled = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(settled).toBe(false); + + finishFfmpegInstall('installed'); + await expect(run).rejects.toBe(appiumError); + }); +}); diff --git a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts index ccb79e4382..ad780ead68 100644 --- a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts @@ -1,5 +1,6 @@ import { SystemError } from '@expo/eas-build-job'; import { type bunyan } from '@expo/logger'; +import { asyncResult } from '@expo/results'; import { BuildFunction, BuildRuntimePlatform, @@ -37,6 +38,7 @@ import { startAppiumEventCollectionAsync } from '../utils/appiumEvents'; import { createServeSimLaunchInputProviders, describeServeSimLaunch, + ensureFfmpegInstalledOnceAsync, finishRemoteSessionAsync, getDeviceRunSessionIdOrThrow, getNgrokAuthtokenOrThrow, @@ -108,12 +110,17 @@ export function createStartAppiumRemoteSessionBuildFunction( `Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).` ); const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger }); - const { appiumHome, appiumBinPath, appiumEnv } = await installAppiumAsync({ - versionSpec, - driverName: device.driverName, - env, - logger, - }); + // Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the + // simulator stream with it, and UiAutomator2 merges long recordings with it. The + // macOS session image does not ship ffmpeg, so install it concurrently with Appium. + // Both installs settle before the step continues or fails, so no install outlives it. + const [appiumInstall] = await Promise.all([ + asyncResult( + installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger }) + ), + ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }), + ]); + const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue(); const appiumProcess = spawnDetached({ command: appiumBinPath,