From a5d06a90fd4ee0f0f75e8188388d577007c207ee Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 1 Oct 2026 14:56:57 +0200 Subject: [PATCH 1/2] [build-tools] Start a prebuilt Android emulator device from the worker image when it matches Worker images can ship an AVD that was created and booted once at image build time, together with an eas-prebuilt-avd.json manifest. eas/start_android_emulator now starts that AVD on the first attempt, instead of creating a new one, when the manifest matches the step inputs and environment. Otherwise it creates the AVD as before. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/startAndroidEmulator.test.ts | 69 +++++ .../steps/functions/startAndroidEmulator.ts | 41 ++- .../src/utils/AndroidEmulatorUtils.ts | 284 +++++++++++++----- .../__tests__/AndroidEmulatorUtils.test.ts | 143 +++++++++ 4 files changed, 447 insertions(+), 90 deletions(-) diff --git a/packages/build-tools/src/steps/functions/__tests__/startAndroidEmulator.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAndroidEmulator.test.ts index ab86f75e97..f361229303 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAndroidEmulator.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAndroidEmulator.test.ts @@ -23,6 +23,8 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({ AndroidEmulatorUtils: { defaultSystemImagePackage: 'system-images;android-30;default;x86_64', getAvailableDevicesAsync: jest.fn(), + checkPrebuiltAvdAsync: jest.fn(), + removeLockFilesAsync: jest.fn(), createAsync: jest.fn(), cloneAsync: jest.fn(), startAsync: jest.fn(), @@ -59,6 +61,8 @@ describe(createStartAndroidEmulatorBuildFunction, () => { mockedMkdtemp.mockResolvedValue('/tmp/logcat-directory'); mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any); mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]); + mockedAndroidUtils.checkPrebuiltAvdAsync.mockResolvedValue({ status: 'missing' }); + mockedAndroidUtils.removeLockFilesAsync.mockResolvedValue(undefined); mockedAndroidUtils.createAsync.mockResolvedValue(undefined); mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined); mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-default')); @@ -108,6 +112,71 @@ describe(createStartAndroidEmulatorBuildFunction, () => { ); }); + it('starts the prebuilt device without creating one when it matches the inputs', async () => { + mockedAndroidUtils.checkPrebuiltAvdAsync.mockResolvedValue({ status: 'reusable' }); + const systemImagePackage = 'system-images;android-35;default;x86_64'; + + await createStep({ + device_identifier: 'medium_phone', + system_image_package: systemImagePackage, + lcd_width: 720, + lcd_height: 1600, + lcd_density: 300, + }).executeAsync(); + + expect(mockedAndroidUtils.checkPrebuiltAvdAsync).toHaveBeenCalledWith( + expect.objectContaining({ + deviceName: 'EasAndroidDevice01', + deviceIdentifier: 'medium_phone', + systemImagePackage, + lcdWidth: 720, + lcdHeight: 1600, + lcdDensity: 300, + }) + ); + expect(mockedAndroidUtils.createAsync).not.toHaveBeenCalled(); + expect(mockedAndroidUtils.removeLockFilesAsync).toHaveBeenCalledWith( + expect.objectContaining({ deviceName: 'EasAndroidDevice01' }) + ); + expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(1); + }); + + it('creates a new device when the prebuilt device fails to start', async () => { + mockedAndroidUtils.checkPrebuiltAvdAsync.mockResolvedValue({ status: 'reusable' }); + mockedAndroidUtils.startAsync + .mockResolvedValueOnce(createStartResult('emulator-1111')) + .mockResolvedValueOnce(createStartResult('emulator-2222')); + mockedAndroidUtils.waitForReadyAsync + .mockRejectedValueOnce(new Error('boot has not completed')) + .mockResolvedValueOnce(undefined); + + await createStep().executeAsync(); + + expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith( + expect.objectContaining({ serialId: 'emulator-1111' }) + ); + expect(mockedAndroidUtils.createAsync).toHaveBeenCalledTimes(1); + expect(mockedAndroidUtils.deleteAsync.mock.invocationCallOrder[0]).toBeLessThan( + mockedAndroidUtils.createAsync.mock.invocationCallOrder[0] + ); + }); + + it('creates a new device and logs the reason when the prebuilt device does not match', async () => { + mockedAndroidUtils.checkPrebuiltAvdAsync.mockResolvedValue({ + status: 'mismatch', + reason: 'its config.ini has different settings', + }); + + const step = createStep(); + await step.executeAsync(); + + expect(step.ctx.logger.info).toHaveBeenCalledWith( + 'Not using the prebuilt EasAndroidDevice01, because its config.ini has different settings.' + ); + expect(mockedAndroidUtils.removeLockFilesAsync).not.toHaveBeenCalled(); + expect(mockedAndroidUtils.createAsync).toHaveBeenCalledTimes(1); + }); + it('retries base emulator startup with increasing readiness timeouts', async () => { mockedAndroidUtils.startAsync .mockResolvedValueOnce(createStartResult('emulator-1111')) diff --git a/packages/build-tools/src/steps/functions/startAndroidEmulator.ts b/packages/build-tools/src/steps/functions/startAndroidEmulator.ts index 9d58caa7ce..9d4ec22877 100644 --- a/packages/build-tools/src/steps/functions/startAndroidEmulator.ts +++ b/packages/build-tools/src/steps/functions/startAndroidEmulator.ts @@ -262,6 +262,19 @@ export async function startAndroidEmulatorAsync({ } ); + const prebuiltAvd = await AndroidEmulatorUtils.checkPrebuiltAvdAsync({ + deviceName, + systemImagePackage, + deviceIdentifier: deviceIdentifier ?? null, + lcdWidth: lcdWidth ?? null, + lcdHeight: lcdHeight ?? null, + lcdDensity: lcdDensity ?? null, + env, + }); + if (prebuiltAvd.status === 'mismatch') { + logger.info(`Not using the prebuilt ${deviceName}, because ${prebuiltAvd.reason}.`); + } + let emulatorPromise = null; let serialId = null; await retryAsync( @@ -273,17 +286,23 @@ export async function startAndroidEmulatorAsync({ let attemptSerialId = null; try { - logger.info(`Creating emulator device${attemptSuffix}.`); - await AndroidEmulatorUtils.createAsync({ - deviceName, - systemImagePackage, - deviceIdentifier: deviceIdentifier ?? null, - lcdWidth: lcdWidth ?? null, - lcdHeight: lcdHeight ?? null, - lcdDensity: lcdDensity ?? null, - env, - logger, - }); + // A failed attempt deletes the device, so later attempts create a new one. + if (attemptCount === 0 && prebuiltAvd.status === 'reusable') { + logger.info(`Using the prebuilt ${deviceName} from the worker image.`); + await AndroidEmulatorUtils.removeLockFilesAsync({ deviceName, env }); + } else { + logger.info(`Creating emulator device${attemptSuffix}.`); + await AndroidEmulatorUtils.createAsync({ + deviceName, + systemImagePackage, + deviceIdentifier: deviceIdentifier ?? null, + lcdWidth: lcdWidth ?? null, + lcdHeight: lcdHeight ?? null, + lcdDensity: lcdDensity ?? null, + env, + logger, + }); + } logger.info(`Starting emulator device${attemptSuffix}.`); const startResult = await AndroidEmulatorUtils.startAsync({ diff --git a/packages/build-tools/src/utils/AndroidEmulatorUtils.ts b/packages/build-tools/src/utils/AndroidEmulatorUtils.ts index a8449b3a15..9795625b56 100644 --- a/packages/build-tools/src/utils/AndroidEmulatorUtils.ts +++ b/packages/build-tools/src/utils/AndroidEmulatorUtils.ts @@ -141,85 +141,221 @@ export namespace AndroidEmulatorUtils { // Add extra config to the device's ini file. const configIniFile = `${env.HOME}/.android/avd/${deviceName}.avd/config.ini`; try { - let configIniFileContent = await fs.promises.readFile(configIniFile, 'utf-8'); - - logger.info('Setting hw.ramSize to 2048.'); - configIniFileContent = `${configIniFileContent}\nhw.ramSize=2048\n`; - - const shouldResizeScreen = - env.ANDROID_EMULATOR_ADJUST_SCREEN === 'true' || env.ANDROID_EMULATOR_ADJUST_SCREEN === '1'; - if (shouldResizeScreen) { - const currentDensityString = configIniFileContent.match(/hw.lcd.density=(\d+)/)?.[1]; - const currentDensity = currentDensityString - ? parseInt(currentDensityString, 10) - : undefined; - const currentHeightString = configIniFileContent.match(/hw.lcd.height=(\d+)/)?.[1]; - const currentHeight = currentHeightString ? parseInt(currentHeightString, 10) : undefined; - const currentWidthString = configIniFileContent.match(/hw.lcd.width=(\d+)/)?.[1]; - const currentWidth = currentWidthString ? parseInt(currentWidthString, 10) : undefined; - - if (currentDensity && currentDensity > 220) { - logger.info( - `Current density is ${currentDensity}, which we believe may impact performance.` + const avdManagerConfigIni = await fs.promises.readFile(configIniFile, 'utf-8'); + const configIniFileContent = getConfigIniContent({ + avdManagerConfigIni, + lcdWidth, + lcdHeight, + lcdDensity, + env, + logger, + }); + await fs.promises.writeFile(configIniFile, configIniFileContent); + } catch (err) { + logger.warn({ err }, `Failed to add extra config to ${configIniFile}.`); + } + } + + /** + * Returns the config.ini content `createAsync` writes, given the content `avdmanager` wrote. + */ + export function getConfigIniContent({ + avdManagerConfigIni, + lcdWidth, + lcdHeight, + lcdDensity, + env, + logger, + }: { + avdManagerConfigIni: string; + lcdWidth: number | null; + lcdHeight: number | null; + lcdDensity: number | null; + env: NodeJS.ProcessEnv; + logger?: bunyan; + }): string { + let configIniFileContent = avdManagerConfigIni; + + logger?.info('Setting hw.ramSize to 2048.'); + configIniFileContent = `${configIniFileContent}\nhw.ramSize=2048\n`; + + const shouldResizeScreen = + env.ANDROID_EMULATOR_ADJUST_SCREEN === 'true' || env.ANDROID_EMULATOR_ADJUST_SCREEN === '1'; + if (shouldResizeScreen) { + const currentDensityString = configIniFileContent.match(/hw.lcd.density=(\d+)/)?.[1]; + const currentDensity = currentDensityString ? parseInt(currentDensityString, 10) : undefined; + const currentHeightString = configIniFileContent.match(/hw.lcd.height=(\d+)/)?.[1]; + const currentHeight = currentHeightString ? parseInt(currentHeightString, 10) : undefined; + const currentWidthString = configIniFileContent.match(/hw.lcd.width=(\d+)/)?.[1]; + const currentWidth = currentWidthString ? parseInt(currentWidthString, 10) : undefined; + + if (currentDensity && currentDensity > 220) { + logger?.info( + `Current density is ${currentDensity}, which we believe may impact performance.` + ); + if (currentHeight && currentWidth) { + const newDensity = 220; + logger?.info(`Setting hw.lcd.density to ${newDensity}.`); + configIniFileContent = `${configIniFileContent}\nhw.lcd.density=${newDensity}\n`; + + const newHeight = Math.round((currentHeight * newDensity) / currentDensity); + const newWidth = Math.round((currentWidth * newDensity) / currentDensity); + logger?.info( + `Setting scaled screen resolution: hw.lcd.height to ${newHeight} and hw.lcd.width to ${newWidth}.` ); - if (currentHeight && currentWidth) { - const newDensity = 220; - logger.info(`Setting hw.lcd.density to ${newDensity}.`); - configIniFileContent = `${configIniFileContent}\nhw.lcd.density=${newDensity}\n`; - - const newHeight = Math.round((currentHeight * newDensity) / currentDensity); - const newWidth = Math.round((currentWidth * newDensity) / currentDensity); - logger.info( - `Setting scaled screen resolution: hw.lcd.height to ${newHeight} and hw.lcd.width to ${newWidth}.` - ); - configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${newHeight}\nhw.lcd.width=${newWidth}\n`; - } else { - logger.info( - 'Could not find current screen resolution, setting to 1170x540 and 220 ppi.' - ); - configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${1170}\nhw.lcd.width=${540}\nhw.lcd.density=220\n`; - } + configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${newHeight}\nhw.lcd.width=${newWidth}\n`; + } else { + logger?.info( + 'Could not find current screen resolution, setting to 1170x540 and 220 ppi.' + ); + configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${1170}\nhw.lcd.width=${540}\nhw.lcd.density=220\n`; } } + } - if (lcdWidth !== null && lcdHeight !== null && lcdDensity !== null) { - logger.info( - `Setting screen resolution to ${lcdWidth}x${lcdHeight} and density to ${lcdDensity} ppi.` - ); - configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${lcdHeight}\nhw.lcd.width=${lcdWidth}\nhw.lcd.density=${lcdDensity}\n`; - } + if (lcdWidth !== null && lcdHeight !== null && lcdDensity !== null) { + logger?.info( + `Setting screen resolution to ${lcdWidth}x${lcdHeight} and density to ${lcdDensity} ppi.` + ); + configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${lcdHeight}\nhw.lcd.width=${lcdWidth}\nhw.lcd.density=${lcdDensity}\n`; + } - const shouldAdjustHeapSize = - env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== 'false' && - env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== '0'; - if (shouldAdjustHeapSize) { - const heapSizeString = configIniFileContent.match(/vm.heapSize=(\d\w+)/)?.[1]; - if (!heapSizeString) { - logger.info('Setting vm.heapSize to 768 MB.'); + const shouldAdjustHeapSize = + env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== 'false' && + env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== '0'; + if (shouldAdjustHeapSize) { + const heapSizeString = configIniFileContent.match(/vm.heapSize=(\d\w+)/)?.[1]; + if (!heapSizeString) { + logger?.info('Setting vm.heapSize to 768 MB.'); + configIniFileContent = `${configIniFileContent}\nvm.heapSize=768\n`; + } else if (heapSizeString) { + const heapSize = parseInt(heapSizeString, 10); + const lowerCaseHeapSizeString = heapSizeString.toLocaleLowerCase(); + if (lowerCaseHeapSizeString.includes('g')) { + logger?.info('vm.heapSize is in GB, skipping adjustment.'); + } else if (heapSize < 768) { + logger?.info('Bumping vm.heapSize to 768 MB.'); configIniFileContent = `${configIniFileContent}\nvm.heapSize=768\n`; - } else if (heapSizeString) { - const heapSize = parseInt(heapSizeString, 10); - const lowerCaseHeapSizeString = heapSizeString.toLocaleLowerCase(); - if (lowerCaseHeapSizeString.includes('g')) { - logger.info('vm.heapSize is in GB, skipping adjustment.'); - } else if (heapSize < 768) { - logger.info('Bumping vm.heapSize to 768 MB.'); - configIniFileContent = `${configIniFileContent}\nvm.heapSize=768\n`; - } } } + } - if (env.ANDROID_EMULATOR_EXTRA_CONFIG) { - logger.info( - `Adding extra config from $ANDROID_EMULATOR_EXTRA_CONFIG:\n${env.ANDROID_EMULATOR_EXTRA_CONFIG}` - ); - configIniFileContent = `${configIniFileContent}\n${env.ANDROID_EMULATOR_EXTRA_CONFIG}\n`; - } + if (env.ANDROID_EMULATOR_EXTRA_CONFIG) { + logger?.info( + `Adding extra config from $ANDROID_EMULATOR_EXTRA_CONFIG:\n${env.ANDROID_EMULATOR_EXTRA_CONFIG}` + ); + configIniFileContent = `${configIniFileContent}\n${env.ANDROID_EMULATOR_EXTRA_CONFIG}\n`; + } - await fs.promises.writeFile(configIniFile, configIniFileContent); + return configIniFileContent; + } + + /** + * Worker images can ship an AVD that was created and booted once at image build time, + * so its first-boot setup is already done. The image build writes this manifest into + * the AVD directory to record how the AVD was created. + */ + export const PREBUILT_AVD_MANIFEST_FILE_NAME = 'eas-prebuilt-avd.json'; + + const PrebuiltAvdManifestSchema = z.object({ + deviceIdentifier: z.string().nullable(), + systemImagePackage: z.string(), + /** config.ini as `avdmanager create avd` wrote it. */ + avdManagerConfigIni: z.string(), + /** config.ini after the image build added the `createAsync` settings, before the first boot. */ + configIni: z.string(), + }); + + export type PrebuiltAvdCheckResult = + | { status: 'missing' } + | { status: 'mismatch'; reason: string } + | { status: 'reusable' }; + + /** + * Checks whether the image ships an AVD named `deviceName` that `createAsync` would + * create with the same arguments. Only then is it safe to start it instead of creating one. + */ + export async function checkPrebuiltAvdAsync({ + deviceName, + systemImagePackage, + deviceIdentifier, + lcdWidth, + lcdHeight, + lcdDensity, + env, + }: { + deviceName: AndroidVirtualDeviceName; + systemImagePackage: string; + deviceIdentifier: AndroidDeviceName | null; + lcdWidth: number | null; + lcdHeight: number | null; + lcdDensity: number | null; + env: NodeJS.ProcessEnv; + }): Promise { + const avdDirectory = `${env.HOME}/.android/avd/${deviceName}.avd`; + + let manifestContent: string; + try { + manifestContent = await fs.promises.readFile( + path.join(avdDirectory, PREBUILT_AVD_MANIFEST_FILE_NAME), + 'utf-8' + ); + } catch { + return { status: 'missing' }; + } + + let manifest: z.infer; + try { + manifest = PrebuiltAvdManifestSchema.parse(JSON.parse(manifestContent)); } catch (err) { - logger.warn({ err }, `Failed to add extra config to ${configIniFile}.`); + return { status: 'mismatch', reason: `the manifest is not valid (${err})` }; + } + + if (manifest.systemImagePackage !== systemImagePackage) { + return { + status: 'mismatch', + reason: `it uses system image "${manifest.systemImagePackage}"`, + }; } + if (manifest.deviceIdentifier !== deviceIdentifier) { + return { + status: 'mismatch', + reason: `it uses device "${manifest.deviceIdentifier ?? 'default'}"`, + }; + } + const expectedConfigIni = getConfigIniContent({ + avdManagerConfigIni: manifest.avdManagerConfigIni, + lcdWidth, + lcdHeight, + lcdDensity, + env, + }); + if (manifest.configIni !== expectedConfigIni) { + return { status: 'mismatch', reason: 'its config.ini has different settings' }; + } + + try { + await fs.promises.access(`${env.HOME}/.android/avd/${deviceName}.ini`); + await fs.promises.access(path.join(avdDirectory, 'config.ini')); + } catch { + return { status: 'mismatch', reason: 'its AVD files are missing' }; + } + + return { status: 'reusable' }; + } + + export async function removeLockFilesAsync({ + deviceName, + env, + }: { + deviceName: AndroidVirtualDeviceName; + env: NodeJS.ProcessEnv; + }): Promise { + const lockfiles = await FastGlob('./**/*.lock', { + cwd: `${env.HOME}/.android/avd/${deviceName}.avd`, + absolute: true, + }); + await Promise.all(lockfiles.map(lockfile => fs.promises.rm(lockfile, { force: true }))); } export async function cloneAsync({ @@ -247,12 +383,7 @@ export namespace AndroidEmulatorUtils { } try { - // Remove lockfiles from source device - const sourceLockfiles = await FastGlob('./**/*.lock', { - cwd: `${env.HOME}/.android/avd/${sourceDeviceName}.avd`, - absolute: true, - }); - await Promise.all(sourceLockfiles.map(lockfile => fs.promises.rm(lockfile, { force: true }))); + await removeLockFilesAsync({ deviceName: sourceDeviceName, env }); } catch (err) { logger.warn({ err }, `Failed to remove lockfiles from source device ${sourceDeviceName}.`); } @@ -269,13 +400,8 @@ export namespace AndroidEmulatorUtils { force: true, }); - // Remove lockfiles from destination device try { - const lockfiles = await FastGlob('./**/*.lock', { - cwd: `${env.HOME}/.android/avd/${destinationDeviceName}.avd`, - absolute: true, - }); - await Promise.all(lockfiles.map(lockfile => fs.promises.rm(lockfile, { force: true }))); + await removeLockFilesAsync({ deviceName: destinationDeviceName, env }); } catch (err) { logger.warn( { err }, diff --git a/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts b/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts index 477306b797..33b2ce591f 100644 --- a/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts +++ b/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts @@ -166,6 +166,149 @@ describe('AndroidEmulatorUtils', () => { }); }); + describe(AndroidEmulatorUtils.checkPrebuiltAvdAsync, () => { + const deviceName = 'EasAndroidDevice01' as AndroidVirtualDeviceName; + const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`; + const systemImagePackage = 'system-images;android-35-ext15;google_apis_playstore;x86_64'; + const avdManagerConfigIni = ['hw.device.name=medium_phone', 'hw.lcd.density=420'].join('\n'); + const env = { HOME: '/home/expo' }; + const inputs = { + deviceName, + systemImagePackage, + deviceIdentifier: 'medium_phone' as AndroidDeviceName, + lcdWidth: 720, + lcdHeight: 1600, + lcdDensity: 300, + env, + }; + + async function writePrebuiltAvdAsync(manifest: unknown): Promise { + await fs.promises.mkdir(avdDirectory, { recursive: true }); + await fs.promises.writeFile(`/home/expo/.android/avd/${deviceName}.ini`, ''); + await fs.promises.writeFile(`${avdDirectory}/config.ini`, ''); + await fs.promises.writeFile( + `${avdDirectory}/${AndroidEmulatorUtils.PREBUILT_AVD_MANIFEST_FILE_NAME}`, + typeof manifest === 'string' ? manifest : JSON.stringify(manifest) + ); + } + + function createManifest(overrides: Record = {}) { + return { + deviceIdentifier: 'medium_phone', + systemImagePackage, + avdManagerConfigIni, + configIni: AndroidEmulatorUtils.getConfigIniContent({ + avdManagerConfigIni, + lcdWidth: 720, + lcdHeight: 1600, + lcdDensity: 300, + env, + }), + ...overrides, + }; + } + + afterEach(async () => { + await fs.promises.rm('/home/expo/.android', { recursive: true, force: true }); + }); + + it('reports a missing prebuilt device when there is no manifest', async () => { + await expect(AndroidEmulatorUtils.checkPrebuiltAvdAsync(inputs)).resolves.toEqual({ + status: 'missing', + }); + }); + + it('reports a reusable device when the manifest matches the inputs', async () => { + await writePrebuiltAvdAsync(createManifest()); + + await expect(AndroidEmulatorUtils.checkPrebuiltAvdAsync(inputs)).resolves.toEqual({ + status: 'reusable', + }); + }); + + it('reports a mismatch when the system image is different', async () => { + await writePrebuiltAvdAsync( + createManifest({ systemImagePackage: 'system-images;android-35;default;x86_64' }) + ); + + await expect(AndroidEmulatorUtils.checkPrebuiltAvdAsync(inputs)).resolves.toEqual({ + status: 'mismatch', + reason: 'it uses system image "system-images;android-35;default;x86_64"', + }); + }); + + it('reports a mismatch when the device profile is different', async () => { + await writePrebuiltAvdAsync(createManifest({ deviceIdentifier: 'pixel_9' })); + + await expect(AndroidEmulatorUtils.checkPrebuiltAvdAsync(inputs)).resolves.toEqual({ + status: 'mismatch', + reason: 'it uses device "pixel_9"', + }); + }); + + it('reports a mismatch when the requested LCD settings are different', async () => { + await writePrebuiltAvdAsync(createManifest()); + + await expect( + AndroidEmulatorUtils.checkPrebuiltAvdAsync({ ...inputs, lcdDensity: 262 }) + ).resolves.toEqual({ + status: 'mismatch', + reason: 'its config.ini has different settings', + }); + }); + + it('reports a mismatch when the environment changes the config.ini settings', async () => { + await writePrebuiltAvdAsync(createManifest()); + + await expect( + AndroidEmulatorUtils.checkPrebuiltAvdAsync({ + ...inputs, + env: { ...env, ANDROID_EMULATOR_EXTRA_CONFIG: 'hw.keyboard=yes' }, + }) + ).resolves.toEqual({ + status: 'mismatch', + reason: 'its config.ini has different settings', + }); + }); + + it('reports a mismatch when the manifest is not valid', async () => { + await writePrebuiltAvdAsync('{"deviceIdentifier":'); + + await expect(AndroidEmulatorUtils.checkPrebuiltAvdAsync(inputs)).resolves.toMatchObject({ + status: 'mismatch', + reason: expect.stringContaining('the manifest is not valid'), + }); + }); + + it('reports a mismatch when the AVD ini file is missing', async () => { + await writePrebuiltAvdAsync(createManifest()); + await fs.promises.rm(`/home/expo/.android/avd/${deviceName}.ini`); + + await expect(AndroidEmulatorUtils.checkPrebuiltAvdAsync(inputs)).resolves.toEqual({ + status: 'mismatch', + reason: 'its AVD files are missing', + }); + }); + }); + + describe(AndroidEmulatorUtils.removeLockFilesAsync, () => { + it('removes lock files from the AVD directory', async () => { + const deviceName = 'EasAndroidDevice01' as AndroidVirtualDeviceName; + const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`; + await fs.promises.mkdir(`${avdDirectory}/snapshots/default_boot`, { recursive: true }); + await fs.promises.writeFile(`${avdDirectory}/hardware-qemu.ini.lock`, ''); + await fs.promises.writeFile(`${avdDirectory}/snapshots/default_boot/ram.img.lock`, ''); + await fs.promises.writeFile(`${avdDirectory}/config.ini`, ''); + + await AndroidEmulatorUtils.removeLockFilesAsync({ deviceName, env: { HOME: '/home/expo' } }); + + await expect(fs.promises.readdir(avdDirectory)).resolves.toEqual(['config.ini', 'snapshots']); + await expect(fs.promises.readdir(`${avdDirectory}/snapshots/default_boot`)).resolves.toEqual( + [] + ); + }); + }); + describe(AndroidEmulatorUtils.startAsync, () => { function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) { // Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync From 7f276efd8c14f7d1098e68c28562bdd1f624dba2 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 1 Oct 2026 14:57:26 +0200 Subject: [PATCH 2/2] Add changelog entry Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46724247e1..e5a6c78cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features +- [build-tools] Start the Android emulator device that a worker image prebuilt, instead of creating one, when it matches the `eas/start_android_emulator` inputs. ([#4510](https://github.com/expo/eas-cli/pull/4510) by [@szdziedzic](https://github.com/szdziedzic)) - [build-tools] Save manual iOS and Android live preview screenshots as session artifacts when supported by the preview server. ([#4481](https://github.com/expo/eas-cli/pull/4481) by [@vonovak](https://github.com/vonovak)) - [eas-cli] Allow workflow submissions to skip loading app config when project and application identifiers are supplied. ([#4471](https://github.com/expo/eas-cli/pull/4471) by [@sjchmiela](https://github.com/sjchmiela)) - [build-tools] Record Android device sessions and upload the screen recording, including the partial recording a killed Device Hub leaves behind. ([#4418](https://github.com/expo/eas-cli/pull/4418) by [@vonovak](https://github.com/vonovak))