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 0b877e509f..b62396ae7b 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAndroidEmulator.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAndroidEmulator.test.ts @@ -25,6 +25,8 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({ createAsync: jest.fn(), cloneAsync: jest.fn(), startAsync: jest.fn(), + startLogcatStreamingAsync: jest.fn(), + getLogcatStagingDirectoryPath: jest.fn(), waitForReadyAsync: jest.fn(), disableWindowAndTransitionAnimationsAsync: jest.fn(), deleteAsync: jest.fn(), @@ -59,6 +61,12 @@ describe(createStartAndroidEmulatorBuildFunction, () => { mockedAndroidUtils.createAsync.mockResolvedValue(undefined); mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined); mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-default')); + mockedAndroidUtils.startLogcatStreamingAsync.mockResolvedValue({ + outputPath: '/non/existent/dir/android-emulator-logcat/emulator-default.log', + }); + mockedAndroidUtils.getLogcatStagingDirectoryPath.mockReturnValue( + '/non/existent/dir/android-emulator-logcat' + ); mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined); mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined); mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined); @@ -106,6 +114,23 @@ describe(createStartAndroidEmulatorBuildFunction, () => { serialId: 'emulator-2222', }) ); + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + serialId: 'emulator-1111', + outputDir: '/non/existent/dir/android-emulator-logcat', + }) + ); + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + serialId: 'emulator-2222', + outputDir: '/non/existent/dir/android-emulator-logcat', + }) + ); + expect(mockedAndroidUtils.startLogcatStreamingAsync.mock.invocationCallOrder[0]).toBeLessThan( + mockedAndroidUtils.waitForReadyAsync.mock.invocationCallOrder[0] + ); expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith( expect.objectContaining({ serialId: 'emulator-1111', @@ -174,6 +199,26 @@ describe(createStartAndroidEmulatorBuildFunction, () => { serialId: 'emulator-clone-2-attempt-1', }) ); + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenCalledWith( + expect.objectContaining({ + serialId: 'emulator-base', + }) + ); + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenCalledWith( + expect.objectContaining({ + serialId: 'emulator-clone-1-attempt-1', + }) + ); + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenCalledWith( + expect.objectContaining({ + serialId: 'emulator-clone-1-attempt-2', + }) + ); + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenCalledWith( + expect.objectContaining({ + serialId: 'emulator-clone-2-attempt-1', + }) + ); expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith( expect.objectContaining({ @@ -200,6 +245,24 @@ describe(createStartAndroidEmulatorBuildFunction, () => { }) ); expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3); + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenCalledTimes(3); + }); + + it('continues emulator startup when logcat streaming fails', async () => { + mockedAndroidUtils.startLogcatStreamingAsync.mockResolvedValue(null); + + await createStep().executeAsync(); + + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenCalledWith( + expect.objectContaining({ + serialId: 'emulator-default', + }) + ); + expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith( + expect.objectContaining({ + serialId: 'emulator-default', + }) + ); }); it('skips animation scale adjustments when opt out env var is disabled', async () => { diff --git a/packages/build-tools/src/steps/functions/startAndroidEmulator.ts b/packages/build-tools/src/steps/functions/startAndroidEmulator.ts index b12101fada..50f897dc7d 100644 --- a/packages/build-tools/src/steps/functions/startAndroidEmulator.ts +++ b/packages/build-tools/src/steps/functions/startAndroidEmulator.ts @@ -49,7 +49,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction { allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, }), ], - fn: async ({ logger }, { inputs, env }) => { + fn: async ({ logger, global }, { inputs, env }) => { if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') { await assertAndroidEmulatorHostSupportAsync({ env }); } else { @@ -100,6 +100,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction { let emulatorPromise = null; let serialId = null; + const logcatOutputDir = AndroidEmulatorUtils.getLogcatStagingDirectoryPath({ + buildLogsDirectory: global.buildLogsDirectory, + }); await retryAsync( async attemptCount => { const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount]; @@ -124,6 +127,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction { env, }); attemptSerialId = startResult.serialId; + await AndroidEmulatorUtils.startLogcatStreamingAsync({ + serialId: attemptSerialId, + outputDir: logcatOutputDir, + env, + logger, + }); await AndroidEmulatorUtils.waitForReadyAsync({ env, serialId: attemptSerialId, @@ -213,6 +222,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction { env, }); cloneSerialId = startResult.serialId; + await AndroidEmulatorUtils.startLogcatStreamingAsync({ + serialId: cloneSerialId, + outputDir: logcatOutputDir, + env, + logger, + }); logger.info('Waiting for emulator to become ready'); await AndroidEmulatorUtils.waitForReadyAsync({ diff --git a/packages/build-tools/src/utils/AndroidEmulatorUtils.ts b/packages/build-tools/src/utils/AndroidEmulatorUtils.ts index a2e4171c5d..50a0be6b99 100644 --- a/packages/build-tools/src/utils/AndroidEmulatorUtils.ts +++ b/packages/build-tools/src/utils/AndroidEmulatorUtils.ts @@ -24,6 +24,14 @@ export namespace AndroidEmulatorUtils { process.arch === 'arm64' ? 'arm64-v8a' : 'x86_64' }`; + export function getLogcatStagingDirectoryPath({ + buildLogsDirectory, + }: { + buildLogsDirectory: string; + }): string { + return path.join(buildLogsDirectory, 'android-emulator-logcat'); + } + export async function getAvailableDevicesAsync({ env, }: { @@ -490,6 +498,58 @@ export namespace AndroidEmulatorUtils { return { outputPath }; } + export async function startLogcatStreamingAsync({ + serialId, + outputDir, + env, + logger, + }: { + serialId: AndroidDeviceSerialId; + outputDir: string; + env: NodeJS.ProcessEnv; + logger: bunyan; + }): Promise<{ outputPath: string } | null> { + try { + await fs.promises.mkdir(outputDir, { recursive: true }); + + const logcatPromise = spawn('adb', ['-s', serialId, 'logcat', '-v', 'threadtime'], { + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const { child } = logcatPromise; + + if (!child.stdout) { + throw new Error('"adb logcat" did not start correctly.'); + } + + const safeSerialId = serialId.replace(/[^a-zA-Z0-9_.-]/g, '_'); + const outputPath = path.join(outputDir, `${child.pid}-${safeSerialId}.log`); + const writeStream = fs.createWriteStream(outputPath); + child.stdout.pipe(writeStream); + child.stdout.on('error', err => { + logger.warn({ err }, `Failed to read Android emulator logcat for ${serialId}.`); + }); + writeStream.on('error', err => { + logger.warn({ err }, `Failed to write Android emulator logcat for ${serialId}.`); + }); + child.on('close', () => { + writeStream.end(); + }); + child.unref(); + + void logcatPromise.catch(err => { + logger.warn({ err }, `Android emulator logcat stream for ${serialId} stopped.`); + }); + + logger.info(`Streaming Android emulator logcat for ${serialId} to ${outputPath}.`); + + return { outputPath }; + } catch (err) { + logger.warn({ err }, `Failed to start Android emulator logcat stream for ${serialId}.`); + return null; + } + } + export async function stopAsync({ serialId, env, diff --git a/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts b/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts index d9695ab9f1..de22401db8 100644 --- a/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts +++ b/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts @@ -1,4 +1,9 @@ import spawn from '@expo/turtle-spawn'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; import { createMockLogger } from '../../__tests__/utils/logger'; import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils'; @@ -22,6 +27,79 @@ describe('AndroidEmulatorUtils', () => { mockedRetryAsync.mockImplementation(async fn => await fn(0)); }); + describe(AndroidEmulatorUtils.startLogcatStreamingAsync, () => { + function createSpawnPromise() { + const child = Object.assign(new EventEmitter(), { + pid: 1234, + stdout: new PassThrough(), + stderr: new PassThrough(), + unref: jest.fn(), + }); + const spawnPromise = Promise.resolve({ stdout: '', stderr: '' }) as any; + spawnPromise.child = child; + return { child, spawnPromise }; + } + + it('streams logcat to a staged file', async () => { + const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-')); + const logger = createMockLogger(); + const { child, spawnPromise } = createSpawnPromise(); + mockedSpawn.mockReturnValueOnce(spawnPromise); + + const result = await AndroidEmulatorUtils.startLogcatStreamingAsync({ + serialId: 'emulator-5554' as any, + outputDir, + env: process.env, + logger, + }); + + expect(result).not.toBeNull(); + expect(mockedSpawn).toHaveBeenCalledWith( + 'adb', + ['-s', 'emulator-5554', 'logcat', '-v', 'threadtime'], + { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + expect(child.unref).toHaveBeenCalled(); + + child.stdout.write('log line\n'); + child.emit('close', 0); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(result!.outputPath.startsWith(outputDir)).toBe(true); + await expect(fs.promises.readFile(result!.outputPath, 'utf-8')).resolves.toContain( + 'log line' + ); + expect(path.basename(result!.outputPath)).toBe('1234-emulator-5554.log'); + }); + + it('returns null and warns when logcat cannot start', async () => { + const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-')); + const logger = createMockLogger(); + mockedSpawn.mockImplementationOnce(() => { + throw new Error('spawn failed'); + }); + + await expect( + AndroidEmulatorUtils.startLogcatStreamingAsync({ + serialId: 'emulator-5554' as any, + outputDir, + env: process.env, + logger, + }) + ).resolves.toBeNull(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.objectContaining({ message: 'spawn failed' }), + }), + 'Failed to start Android emulator logcat stream for emulator-5554.' + ); + }); + }); + describe(AndroidEmulatorUtils.waitForReadyAsync, () => { it('checks boot completion and verifies network with netcat to 1.1.1.1:443', async () => { mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {