Skip to content

Commit 7db4a1e

Browse files
committed
fix: capture Android emulator startup failures
1 parent f269586 commit 7db4a1e

2 files changed

Lines changed: 276 additions & 17 deletions

File tree

packages/platform-android/src/__tests__/adb.test.ts

Lines changed: 124 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
import { describe, expect, it, vi } from 'vitest';
1+
import { EventEmitter } from 'node:events';
2+
import { PassThrough } from 'node:stream';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
24
import {
35
createAvd,
6+
emulatorProcess,
47
getAppUid,
58
getLogcatTimestamp,
69
getStartAppArgs,
710
hasAvd,
811
installApp,
12+
startEmulator,
913
waitForBoot,
1014
waitForEmulator,
1115
} from '../adb.js';
@@ -14,6 +18,24 @@ import * as tools from '@react-native-harness/tools';
1418
const createAbortError = () =>
1519
new DOMException('The operation was aborted', 'AbortError');
1620

21+
const createMockChildProcess = () => {
22+
const process = new EventEmitter() as EventEmitter & {
23+
stdout: PassThrough;
24+
stderr: PassThrough;
25+
unref: ReturnType<typeof vi.fn>;
26+
};
27+
28+
process.stdout = new PassThrough();
29+
process.stderr = new PassThrough();
30+
process.unref = vi.fn();
31+
32+
return process;
33+
};
34+
35+
beforeEach(() => {
36+
vi.restoreAllMocks();
37+
});
38+
1739
describe('getStartAppArgs', () => {
1840
it('maps supported extras to adb am start flags', () => {
1941
expect(
@@ -66,7 +88,7 @@ describe('getStartAppArgs', () => {
6688
10234
6789
);
6890

69-
expect(spawnSpy).toHaveBeenCalledWith('adb', [
91+
expect(spawnSpy).toHaveBeenCalledWith(expect.stringMatching(/adb$/), [
7092
'-s',
7193
'emulator-5554',
7294
'shell',
@@ -86,7 +108,7 @@ describe('getStartAppArgs', () => {
86108
'03-12 11:35:08.000'
87109
);
88110

89-
expect(spawnSpy).toHaveBeenCalledWith('adb', [
111+
expect(spawnSpy).toHaveBeenCalledWith(expect.stringMatching(/adb$/), [
90112
'-s',
91113
'emulator-5554',
92114
'shell',
@@ -111,7 +133,7 @@ describe('getStartAppArgs', () => {
111133

112134
await installApp('emulator-5554', '/tmp/app.apk');
113135

114-
expect(spawnSpy).toHaveBeenCalledWith('adb', [
136+
expect(spawnSpy).toHaveBeenCalledWith(expect.stringMatching(/adb$/), [
115137
'-s',
116138
'emulator-5554',
117139
'install',
@@ -133,19 +155,112 @@ describe('getStartAppArgs', () => {
133155
heapSize: '1G',
134156
});
135157

136-
expect(spawnSpy).toHaveBeenNthCalledWith(1, 'sdkmanager', [
137-
'system-images;android-35;default;x86_64',
138-
]);
158+
expect(spawnSpy).toHaveBeenNthCalledWith(
159+
1,
160+
expect.stringMatching(/sdkmanager$/),
161+
['system-images;android-35;default;x86_64']
162+
);
139163
expect(spawnSpy).toHaveBeenNthCalledWith(2, 'bash', [
140164
'-lc',
141-
`printf 'no\n' | avdmanager create avd --force --name "Pixel_8_API_35" --package "system-images;android-35;default;x86_64" --device "pixel_8"`,
165+
expect.stringContaining(
166+
'create avd --force --name "Pixel_8_API_35" --package "system-images;android-35;default;x86_64" --device "pixel_8"'
167+
),
142168
]);
143169
expect(spawnSpy).toHaveBeenNthCalledWith(3, 'bash', [
144170
'-lc',
145-
`printf '%s\n%s\n' 'disk.dataPartition.size=1G' 'vm.heapSize=1G' >> "$HOME/.android/avd/Pixel_8_API_35.avd/config.ini"`,
171+
expect.stringContaining(
172+
`'disk.dataPartition.size=1G' 'vm.heapSize=1G' >> `
173+
),
146174
]);
147175
});
148176

177+
it('surfaces emulator stdout when startup fails immediately', async () => {
178+
const child = createMockChildProcess();
179+
let launcherReadyResolve: (() => void) | undefined;
180+
const launcherReady = new Promise<void>((resolve) => {
181+
launcherReadyResolve = resolve;
182+
});
183+
184+
vi.spyOn(tools, 'spawn').mockResolvedValue({
185+
stdout: 'List of devices attached\n\n',
186+
} as Awaited<ReturnType<typeof tools.spawn>>);
187+
vi.spyOn(emulatorProcess, 'startDetachedProcess').mockImplementation(() => {
188+
launcherReadyResolve?.();
189+
return child as unknown as ReturnType<
190+
typeof emulatorProcess.startDetachedProcess
191+
>;
192+
});
193+
194+
const startPromise = startEmulator('Pixel_8_API_35');
195+
await launcherReady;
196+
197+
child.stdout.write('Unknown AVD name [Pixel_8_API_35]\n');
198+
child.stdout.end();
199+
child.stderr.end();
200+
child.emit('close', 1, null);
201+
202+
await expect(startPromise).rejects.toThrow(
203+
'Unknown AVD name [Pixel_8_API_35]'
204+
);
205+
});
206+
207+
it('surfaces emulator stderr when startup fails immediately', async () => {
208+
const child = createMockChildProcess();
209+
let launcherReadyResolve: (() => void) | undefined;
210+
const launcherReady = new Promise<void>((resolve) => {
211+
launcherReadyResolve = resolve;
212+
});
213+
214+
vi.spyOn(tools, 'spawn').mockResolvedValue({
215+
stdout: 'List of devices attached\n\n',
216+
} as Awaited<ReturnType<typeof tools.spawn>>);
217+
vi.spyOn(emulatorProcess, 'startDetachedProcess').mockImplementation(() => {
218+
launcherReadyResolve?.();
219+
return child as unknown as ReturnType<
220+
typeof emulatorProcess.startDetachedProcess
221+
>;
222+
});
223+
224+
const startPromise = startEmulator('Pixel_8_API_35');
225+
await launcherReady;
226+
227+
child.stderr.write('emulator: panic: broken config\n');
228+
child.stdout.end();
229+
child.stderr.end();
230+
child.emit('close', 1, null);
231+
232+
await expect(startPromise).rejects.toThrow(
233+
'emulator: panic: broken config'
234+
);
235+
});
236+
237+
it('returns after the emulator appears without waiting for process exit', async () => {
238+
vi.useFakeTimers();
239+
const child = createMockChildProcess();
240+
const spawnSpy = vi.spyOn(tools, 'spawn');
241+
242+
spawnSpy
243+
.mockResolvedValueOnce({
244+
stdout: 'List of devices attached\nemulator-5554\tdevice\n',
245+
} as Awaited<ReturnType<typeof tools.spawn>>)
246+
.mockResolvedValueOnce({
247+
stdout: 'Pixel_8_API_35\n',
248+
} as Awaited<ReturnType<typeof tools.spawn>>);
249+
250+
vi.spyOn(emulatorProcess, 'startDetachedProcess').mockReturnValue(
251+
child as unknown as ReturnType<
252+
typeof emulatorProcess.startDetachedProcess
253+
>
254+
);
255+
256+
const startPromise = startEmulator('Pixel_8_API_35');
257+
258+
await vi.runAllTimersAsync();
259+
260+
await expect(startPromise).resolves.toBeUndefined();
261+
expect(child.unref).toHaveBeenCalled();
262+
});
263+
149264
it('aborts while waiting for an emulator to appear', async () => {
150265
vi.useFakeTimers();
151266
vi.spyOn(tools, 'spawn').mockResolvedValue({

packages/platform-android/src/adb.ts

Lines changed: 152 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms';
22
import { spawn, SubprocessError } from '@react-native-harness/tools';
3+
import { spawn as nodeSpawn } from 'node:child_process';
4+
import type { ChildProcessByStdio } from 'node:child_process';
35
import { access } from 'node:fs/promises';
6+
import type { Readable } from 'node:stream';
47
import {
58
getAdbBinaryPath,
69
getAvdManagerBinaryPath,
@@ -50,6 +53,79 @@ const getAvdConfigPath = (name: string): string =>
5053
process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd`
5154
}/${name}.avd/config.ini`;
5255

56+
const EMULATOR_STARTUP_OBSERVATION_TIMEOUT_MS = 5000;
57+
const EMULATOR_OUTPUT_BUFFER_LIMIT = 16 * 1024;
58+
59+
export const emulatorProcess = {
60+
startDetachedProcess: (
61+
file: string,
62+
args: readonly string[]
63+
): ChildProcessByStdio<null, Readable, Readable> =>
64+
nodeSpawn(file, args, {
65+
detached: true,
66+
stdio: ['ignore', 'pipe', 'pipe'],
67+
}),
68+
};
69+
70+
const appendBoundedOutput = (
71+
output: string,
72+
chunk: string,
73+
limit: number = EMULATOR_OUTPUT_BUFFER_LIMIT
74+
): string => {
75+
const nextOutput = output + chunk;
76+
77+
if (nextOutput.length <= limit) {
78+
return nextOutput;
79+
}
80+
81+
return nextOutput.slice(-limit);
82+
};
83+
84+
const formatEmulatorStartupError = ({
85+
name,
86+
stdout,
87+
stderr,
88+
exitCode,
89+
signal,
90+
error,
91+
}: {
92+
name: string;
93+
stdout: string;
94+
stderr: string;
95+
exitCode?: number | null;
96+
signal?: NodeJS.Signals | null;
97+
error?: unknown;
98+
}): Error => {
99+
const sections = [`Failed to start Android emulator @${name}.`];
100+
101+
if (typeof exitCode === 'number') {
102+
sections.push(`Exit code: ${exitCode}`);
103+
}
104+
105+
if (signal) {
106+
sections.push(`Signal: ${signal}`);
107+
}
108+
109+
if (error instanceof Error) {
110+
sections.push(`Cause: ${error.message}`);
111+
}
112+
113+
const trimmedStdout = stdout.trim();
114+
const trimmedStderr = stderr.trim();
115+
116+
if (trimmedStdout !== '') {
117+
sections.push(`stdout:\n${trimmedStdout}`);
118+
}
119+
120+
if (trimmedStderr !== '') {
121+
sections.push(`stderr:\n${trimmedStderr}`);
122+
}
123+
124+
return new Error(sections.join('\n\n'), {
125+
cause: error instanceof Error ? error : undefined,
126+
});
127+
};
128+
53129
const ensureEmulatorInstalled = async (): Promise<string> => {
54130
const emulatorBinaryPath = getEmulatorBinaryPath();
55131

@@ -264,8 +340,7 @@ export const createAvd = async ({
264340

265341
export const startEmulator = async (name: string): Promise<void> => {
266342
const emulatorBinaryPath = await ensureEmulatorInstalled();
267-
268-
void spawn(
343+
const childProcess = emulatorProcess.startDetachedProcess(
269344
emulatorBinaryPath,
270345
[
271346
`@${name}`,
@@ -277,13 +352,82 @@ export const startEmulator = async (name: string): Promise<void> => {
277352
'-no-boot-anim',
278353
'-camera-back',
279354
'none',
280-
],
281-
{
282-
detached: true,
283-
stdout: 'ignore',
284-
stderr: 'ignore',
285-
}
355+
]
356+
);
357+
358+
let stdout = '';
359+
let stderr = '';
360+
361+
childProcess.stdout?.setEncoding('utf8');
362+
childProcess.stderr?.setEncoding('utf8');
363+
364+
const onStdout = (chunk: string | Buffer) => {
365+
stdout = appendBoundedOutput(stdout, chunk.toString());
366+
};
367+
const onStderr = (chunk: string | Buffer) => {
368+
stderr = appendBoundedOutput(stderr, chunk.toString());
369+
};
370+
371+
childProcess.stdout?.on('data', onStdout);
372+
childProcess.stderr?.on('data', onStderr);
373+
374+
const startupAbortController = new AbortController();
375+
const cleanup = () => {
376+
startupAbortController.abort();
377+
childProcess.stdout?.off('data', onStdout);
378+
childProcess.stderr?.off('data', onStderr);
379+
childProcess.removeAllListeners('error');
380+
childProcess.removeAllListeners('close');
381+
};
382+
383+
const earlyExit = new Promise<never>((_, reject) => {
384+
childProcess.once('error', (error) => {
385+
reject(
386+
formatEmulatorStartupError({
387+
name,
388+
stdout,
389+
stderr,
390+
error,
391+
})
392+
);
393+
});
394+
395+
childProcess.once('close', (exitCode, signal) => {
396+
reject(
397+
formatEmulatorStartupError({
398+
name,
399+
stdout,
400+
stderr,
401+
exitCode,
402+
signal,
403+
})
404+
);
405+
});
406+
});
407+
408+
const observedBoot = waitForEmulator(name, startupAbortController.signal)
409+
.then(() => 'booted' as const)
410+
.catch((error: unknown) => {
411+
if (startupAbortController.signal.aborted) {
412+
return 'aborted' as const;
413+
}
414+
415+
throw error;
416+
});
417+
418+
const observationTimeout = wait(EMULATOR_STARTUP_OBSERVATION_TIMEOUT_MS).then(
419+
() => 'timeout' as const
286420
);
421+
422+
try {
423+
await Promise.race([earlyExit, observedBoot, observationTimeout]);
424+
} finally {
425+
cleanup();
426+
}
427+
428+
childProcess.stdout?.destroy();
429+
childProcess.stderr?.destroy();
430+
childProcess.unref();
287431
};
288432

289433
export const waitForEmulator = async (

0 commit comments

Comments
 (0)