Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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'))
Expand Down
41 changes: 30 additions & 11 deletions packages/build-tools/src/steps/functions/startAndroidEmulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 });
Comment on lines +289 to +292

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Repeated emulator starts retain old state

When a runner starts the emulator again, prebuiltAvd still marks the previously used AVD reusable. The next session inherits its installed apps and device data instead of starting fresh.

Learn more

A matching manifest remains in the AVD directory after a successful boot. checkPrebuiltAvdAsync checks the manifest and file presence but not whether that AVD has already served a session. A later call to the start function therefore skips createAsync, which previously recreated the device with --force. Device changes from the first session persist into the next one.

Example: A workflow starts EasAndroidDevice01, installs an APK, shuts it down, and starts it again. The second start boots the old user-data image with that APK still installed rather than a clean device.

Recommended fix: Mark the prebuilt AVD as consumed before its first runtime boot, or create a per-session copy of the pristine image and boot the copy. Ensure subsequent calls on the same runner recreate or restore clean user data, including after a failed boot.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

} 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({
Expand Down
Loading
Loading