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
6 changes: 6 additions & 0 deletions .changeset/appium-stop-during-startup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@expo/build-tools': patch
---

Stop an Appium session that is still starting up when it is stopped.
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import * as timersPromises from 'node:timers/promises';

import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
Expand All @@ -19,6 +20,8 @@ import {
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';

import {
createStartAppiumRemoteSessionBuildFunction,
Expand All @@ -27,6 +30,10 @@ import {
resolveAppiumDeviceAsync,
} from '../startAppiumRemoteSession';

jest.mock('node:timers/promises', () => ({
...jest.requireActual('node:timers/promises'),
setTimeout: jest.fn(jest.requireActual('node:timers/promises').setTimeout),
}));
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
}));
Expand All @@ -45,6 +52,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
startNgrokTunnelAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionStartup', () => ({
watchDeviceRunSessionStartup: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../sentry');
jest.mock('../../../utils/retry', () => ({
Expand Down Expand Up @@ -227,6 +237,10 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
const stopEventCollection = jest.fn();

beforeEach(() => {
jest
.mocked(timersPromises.setTimeout)
.mockImplementation(jest.requireActual('node:timers/promises').setTimeout);
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({});
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({
Expand Down Expand Up @@ -337,6 +351,154 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
}
);

it.each([
[BuildRuntimePlatform.DARWIN, false],
[BuildRuntimePlatform.LINUX, false],
[BuildRuntimePlatform.DARWIN, true],
])(
'handles Stop during %s setup, preserving teardown failure=%s',
async (runtimePlatform, teardownFails) => {
let setupEntered!: () => void;
const entered = new Promise<void>(resolve => {
setupEntered = resolve;
});
jest.mocked(ensureFfmpegInstalledOnceAsync).mockImplementationOnce(({ signal }) => {
setupEntered();
return new Promise<void>(resolve =>
signal!.addEventListener('abort', () => resolve(), { once: true })
);
});
const teardownError = new Error('host teardown failed');
const finishHost = jest.fn().mockImplementation(async () => {
if (teardownFails) {
throw teardownError;
}
});
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn(),
finishAsync: finishHost,
getLastPreviewActivityAt: () => undefined,
});
}
const running = runSessionAsync(
{ info: jest.fn(), warn: jest.fn() },
undefined,
runtimePlatform,
runtimePlatform === BuildRuntimePlatform.DARWIN
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-ios-udid' } }
: {}
);
running.catch(() => {});
await entered;
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
if (teardownFails) {
await expect(running).rejects.toBe(teardownError);
} else {
await expect(running).resolves.toBeUndefined();
}
expect(spawnDetached).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(watch.finishAsync).toHaveBeenCalled();
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
expect(finishHost).toHaveBeenCalledTimes(1);
}
}
);

it.each([
[BuildRuntimePlatform.DARWIN, false],
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.LINUX, false],
])(
'preserves failed installer cleanup after Stop on %s with Simulator startup=%s',
async (runtimePlatform, bootSimulator) => {
const cleanupError = new Error('Appium home removal failed: EACCES');
const remove = fs.promises.rm.bind(fs.promises);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]);
let enteredInstall!: () => void;
const installEntered = new Promise<void>(resolve => {
enteredInstall = resolve;
});
let closeInstaller!: (error: Error) => void;
jest.mocked(spawn).mockImplementationOnce(() => {
enteredInstall();
return Object.assign(
new Promise((_resolve, reject) => {
closeInstaller = reject;
}),
{ child: { pid: 12345 } }
) as never;
});
jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
const rm = jest.spyOn(fs.promises, 'rm').mockRejectedValueOnce(cleanupError);
const running = runSessionAsync(
{ info: jest.fn(), warn: jest.fn() },
undefined,
runtimePlatform,
bootSimulator
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-ios-udid' } }
: {}
);
const observed = running.catch(() => {});
let home: string | undefined;
try {
await installEntered;
home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
closeInstaller(new Error('installer interrupted'));
await expect(running).rejects.toBe(cleanupError);
expect(rm).toHaveBeenCalledWith(home, { recursive: true, force: true });
expect(fs.existsSync(home)).toBe(true);
expect(spawnDetached).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(watch.finishAsync).toHaveBeenCalled();
} finally {
closeInstaller?.(new Error('test cleanup'));
await observed;
if (home) {
await remove(home, { recursive: true, force: true });
}
}
}
);

it('preserves an Android installer failure observed before a later Stop', async () => {
const failed = new Error('Appium install failed first');
let observedFailure!: () => void;
const installFailed = new Promise<void>(resolve => {
observedFailure = resolve;
});
jest.mocked(spawn).mockImplementationOnce((() => {
observedFailure();
return Promise.reject(failed);
}) as never);
jest
.mocked(ensureFfmpegInstalledOnceAsync)
.mockImplementationOnce(
({ signal }) =>
new Promise<void>(resolve =>
signal!.addEventListener('abort', () => resolve(), { once: true })
)
);
const running = runSessionAsync(
{ info: jest.fn(), warn: jest.fn() },
undefined,
BuildRuntimePlatform.LINUX
);
running.catch(() => {});
await installFailed;
await new Promise(resolve => setImmediate(resolve));
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
await expect(running).rejects.toBe(failed);
expect(spawnDetached).not.toHaveBeenCalled();
});

it('selects Xcode only once for an already-prepared iOS session', async () => {
stopAppium.mockResolvedValue(undefined);
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
Expand Down Expand Up @@ -651,9 +813,9 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
it('keeps the readiness error when Appium cannot be stopped', async () => {
let now = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => now);
jest.mocked(sleepAsync).mockImplementation(async ms => {
now += ms;
});
jest.mocked(timersPromises.setTimeout).mockImplementation((async ms => {
now += Number(ms ?? 0);
}) as typeof timersPromises.setTimeout);
jest.mocked(turtleFetch).mockRejectedValue(new Error('connect ECONNREFUSED'));
const logger = { info: jest.fn(), warn: jest.fn() };

Expand Down
Loading
Loading