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)) 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