From 6505565afdce2e7011b9f13c3b40428e8bcd399a Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 8 Oct 2026 14:40:54 -0700 Subject: [PATCH] [build-tools] Install ffmpeg before starting Appium sessions Appium's startRecordingScreen runs ffmpeg on the worker. The macOS session image does not ship it, and #4549 removed the only install that iOS sessions ran, so XCUITest recording fails with "'ffmpeg' binary is not found in PATH". Install ffmpeg concurrently with Appium, and wait for both installs before the step continues. --- CHANGELOG.md | 2 + .../deviceSessionHost-orchestration.test.ts | 73 +++++++++++++++++++ .../functions/startAppiumRemoteSession.ts | 19 +++-- 3 files changed, 88 insertions(+), 6 deletions(-) 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,