Compare commits

...
24 changed files with 1481 additions and 258 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
---
'@expo/build-tools': patch
'eas-cli': patch
---
Record and upload Android emulator process output as a simulator session artifact by default. Add explicit source selection and a simulator CLI flag to opt in to recording and uploading device-wide logcat. Unselected sources are not recorded by simulator sessions.
Simulator sessions retain the first 20 MiB of each selected Android log source across launches and retries. The limit is enforced while recording; excess output is drained without saving, and artifacts remain uncompressed.
+26 -195
View File
@@ -1,208 +1,39 @@
name: build-and-deploy-worker
# Assumes main branch always specifies an environment (staging/production).
# If environment is empty: PR testing build (no upload, no deploy)
# If environment is set: Build (unless cached on main), upload, and deploy
name: Build Android worker for manual testing
on:
workflow_dispatch:
inputs:
environment:
description: '"staging", "production" or empty'
type: string
description: Build a Linux artifact for manual staging testing; does not deploy
type: choice
options:
- staging
default: staging
required: true
workflow_call:
inputs:
environment:
description: '"staging", "production" or empty'
type: string
required: true
permissions:
contents: read
jobs:
build-and-deploy:
build-android-staging-test:
if: inputs.environment == 'staging'
runs-on: ubuntu-latest
permissions:
id-token: write
timeout-minutes: 25
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: ./.github/actions/setup-mise
- name: Install eas-cli
run: npm install --global eas-cli@latest
- uses: ./.github/internal-actions/setup-gcloud
- name: Check if worker already exists
id: check_worker_exists
# Check if tarballs for this SHA already exist
# If they do, we can skip building regardless of branch
if: inputs.environment != ''
run: |
for platform in android ios; do
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
if ! gsutil stat "gs://eas-build-worker-tarballs/$GCP_TARBALL"; then
echo "worker_exists=false" >> "$GITHUB_OUTPUT"
exit 0
fi
done
echo "worker_exists=true" >> "$GITHUB_OUTPUT"
- name: Start EAS Workflow to build worker tarballs
id: eas_workflow
# Build if: PR testing (no env), or deploying but tarballs don't exist
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
working-directory: packages/worker
- run: yarn install --immutable
- name: Package Linux worker from this checkout
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
OUTPUT_DIR: ${{ runner.temp }}/android-worker-staging-test
run: |
set -euo pipefail
# Only upload artifacts from EAS if GHA will upload them to GCS
# This avoids unnecessary artifact uploads for PR builds
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
# Run workflow and capture output (includes run ID)
WORKFLOW_OUTPUT=$(eas workflow:run build-worker.yml \
--ref ${{ github.sha }} \
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
--non-interactive \
--no-wait \
--json)
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_OUTPUT" | jq -r '.id')
if [ -z "$WORKFLOW_RUN_ID" ] || [ "$WORKFLOW_RUN_ID" = "null" ]; then
echo "Error: Could not determine workflow run ID"
echo "Workflow output:"
echo "$WORKFLOW_OUTPUT" | jq .
exit 1
fi
echo "workflow_run_id=$WORKFLOW_RUN_ID" >> "$GITHUB_OUTPUT"
- name: Wait for EAS workflow and capture artifacts
id: eas_artifacts
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
if [ -z "$WORKFLOW_RUN_ID" ]; then
echo "Error: Missing workflow run ID"
exit 1
fi
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
while true; do
VIEW_OUTPUT=$(eas workflow:view "$WORKFLOW_RUN_ID" \
--non-interactive \
--json)
STATUS=$(echo "$VIEW_OUTPUT" | jq -r '.status')
case "$STATUS" in
SUCCESS)
break
;;
FAILURE)
echo "EAS workflow failed."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
CANCELED)
echo "EAS workflow canceled."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
ACTION_REQUIRED)
echo "EAS workflow requires action."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
NEW|IN_PROGRESS)
sleep 7
;;
*)
echo "Unexpected workflow status: $STATUS"
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
esac
done
# Skip artifact extraction if we didn't request uploads
if [ "$SHOULD_UPLOAD_ARTIFACT" == "false" ]; then
echo "Skipping artifact download - upload was disabled for this run"
exit 0
fi
# Extract artifact download URLs from workflow output
# Artifacts are in jobs[].artifacts[] for custom jobs
DOWNLOAD_IOS_URL=$(echo "$VIEW_OUTPUT" | jq -r '
.jobs[]
| .artifacts[]?
| select(.name == "worker-ios-tarball")
| .downloadUrl
' | head -1)
DOWNLOAD_ANDROID_URL=$(echo "$VIEW_OUTPUT" | jq -r '
.jobs[]
| .artifacts[]?
| select(.name == "worker-android-tarball")
| .downloadUrl
' | head -1)
if [ -z "$DOWNLOAD_IOS_URL" ] || [ "$DOWNLOAD_IOS_URL" = "null" ]; then
echo "Error: Could not find iOS artifact download URL"
echo "$VIEW_OUTPUT" | jq .
exit 1
fi
if [ -z "$DOWNLOAD_ANDROID_URL" ] || [ "$DOWNLOAD_ANDROID_URL" = "null" ]; then
echo "Error: Could not find Android artifact download URL"
echo "$VIEW_OUTPUT" | jq .
exit 1
fi
# Mask the download URLs
echo "::add-mask::$DOWNLOAD_IOS_URL"
echo "::add-mask::$DOWNLOAD_ANDROID_URL"
echo "download_ios_url=$DOWNLOAD_IOS_URL" >> "$GITHUB_OUTPUT"
echo "download_android_url=$DOWNLOAD_ANDROID_URL" >> "$GITHUB_OUTPUT"
- name: Cancel EAS workflow on GitHub cancellation
if: cancelled()
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
if [ -n "$WORKFLOW_RUN_ID" ]; then
eas workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
fi
- name: Upload worker tarballs to GCP
# Upload when deploying if we built something new
# Don't upload PR builds (no environment) or when using cached tarballs
if: inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false'
run: |
IOS_GCP_TARBALL="worker-ios-sha1-$GITHUB_SHA.tar.gz"
ANDROID_GCP_TARBALL="worker-android-sha1-$GITHUB_SHA.tar.gz"
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - "gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL"
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - "gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL"
- name: Promote worker to ${{ inputs.environment }}
if: inputs.environment != ''
run: |
for platform in android ios; do
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
WORKER_STAGING_TARBALL="worker-$platform-${{ inputs.environment }}.tar.gz"
gsutil cp "gs://eas-build-worker-tarballs/$GCP_TARBALL" "gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL"
done
mkdir -p "$OUTPUT_DIR"
bash packages/worker/package.sh "$OUTPUT_DIR/worker-android.tar.gz" android
tar -xOf "$OUTPUT_DIR/worker-android.tar.gz" ./packages/build-tools/dist/steps/utils/androidEmulatorLogs.js > "$OUTPUT_DIR/androidEmulatorLogs.js"
cd "$OUTPUT_DIR"
sha256sum worker-android.tar.gz androidEmulatorLogs.js > SHA256SUMS
printf '%s\n' "$GITHUB_SHA" > build-revision.txt
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: android-worker-staging-test
path: ${{ runner.temp }}/android-worker-staging-test/
retention-days: 7
if-no-files-found: error
@@ -62,6 +62,7 @@ import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/sta
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
import { createCollectAndroidEmulatorLogsBuildFunction } from './functions/collectAndroidEmulatorLogs';
import { createStartSandboxBuildFunction } from './functions/startSandbox';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
@@ -129,6 +130,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createStartServeSimMetricsBuildFunction(),
createStartServeSimLogsBuildFunction(),
createCollectServeSimLogsBuildFunction(ctx),
createCollectAndroidEmulatorLogsBuildFunction(ctx),
createStartSandboxBuildFunction(ctx),
createRunAgentBuildFunction(ctx),
createCollectServeSimMetricsBuildFunction(ctx),
@@ -0,0 +1,76 @@
import { BuildRuntimePlatform } from '@expo/steps';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Sentry } from '../../../sentry';
import { collectAndroidEmulatorLogsAsync } from '../../utils/androidEmulatorLogs';
import { createCollectAndroidEmulatorLogsBuildFunction } from '../collectAndroidEmulatorLogs';
jest.mock('../../../sentry');
jest.mock('../../utils/androidEmulatorLogs');
function createStep(callInputs: Record<string, unknown> = {}) {
const logger = createMockLogger();
const ctx = createCustomBuildContextMock();
const global = createGlobalContextMock({ logger });
const fn = createCollectAndroidEmulatorLogsBuildFunction(ctx);
const step = fn.createBuildStepFromFunctionCall(global, {
callInputs,
env: { DEVICE_RUN_SESSION_ID: 'session' },
});
return { ctx, global, logger, fn, step };
}
it('defaults an existing workflow without inputs to emulator output only', async () => {
const { ctx, global, fn, step } = createStep();
expect(fn.supportedRuntimePlatforms).toEqual([BuildRuntimePlatform.LINUX]);
await step.executeAsync();
expect(collectAndroidEmulatorLogsAsync).toHaveBeenCalledWith(ctx, {
global,
deviceRunSessionId: 'session',
logger: expect.anything(),
sources: ['emulator-output'],
});
});
it.each([
{ sources: ['emulator-output', 'device-logcat'] },
{ sources: ['device-logcat'] },
{ sources: [] },
])('passes an explicit selection of $sources through the step input', async ({ sources }) => {
const { step } = createStep({ sources });
await step.executeAsync();
expect(collectAndroidEmulatorLogsAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ sources })
);
});
it.each([
{ sources: ['unknown'] },
{ sources: ['emulator-output', 'unknown'] },
{ sources: [1] },
{ sources: { logcat: true } },
])('does not collect any logs for invalid sources %j', async ({ sources }) => {
const { step } = createStep({ sources });
const collect = jest.mocked(collectAndroidEmulatorLogsAsync);
collect.mockClear();
await step.executeAsync();
expect(collect).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalled();
});
it('reports collection failure without changing the session result', async () => {
jest
.mocked(collectAndroidEmulatorLogsAsync)
.mockRejectedValueOnce(new Error('collection failed'));
const { step } = createStep();
await expect(step.executeAsync()).resolves.toBeUndefined();
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not collect Android emulator logs',
expect.any(Error)
);
});
@@ -2,6 +2,7 @@ import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
@@ -139,13 +140,20 @@ describe.each([
corsOrigins?: string[]
) {
const fn = createFunction({} as CustomBuildContext);
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
const defaults = new Map(
fn.inputProviders?.map(provider => {
const input = provider(global, 'Test session');
return [input.id, input.defaultValue];
})
);
await fn.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
// Inputs a step does not get default to undefined, like in a real step call.
// Match step execution: omitted inputs use their declared defaults.
inputs: new Proxy(
{
public_preview: { value: publicPreview },
@@ -153,7 +161,7 @@ describe.each([
cors_origins: { value: corsOrigins },
} as Record<string, { value: unknown }>,
{
get: (target, id: string) => target[id] ?? { value: undefined },
get: (target, id: string) => target[id] ?? { value: defaults.get(id) },
}
),
outputs: {},
@@ -4,6 +4,8 @@ import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { AndroidEmulatorLogCapture } from '../../../utils/androidEmulatorLogCapture';
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
import {
@@ -35,6 +37,7 @@ jest.mock('../../utils/localEgressGuard', () => ({
startLocalEgressGuardRelayAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn');
jest.mock('../../utils/androidEmulatorLogs');
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
@@ -108,12 +111,14 @@ function runStep(
inputValues: Record<string, unknown> = {},
signal?: AbortSignal,
stepCtx: CustomBuildContext = ctx,
warm = false
warm = false,
env: NodeJS.ProcessEnv = {}
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx, { warm });
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const inputs = Object.fromEntries(
[
'android_log_sources',
'device_identifier',
'system_image_package',
'lcd_width',
@@ -134,7 +139,15 @@ function runStep(
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
].map(id => [id, { value: inputValues[id] }])
].map(id => [
id,
{
value:
id === 'android_log_sources' && !(id in inputValues)
? ['emulator-output', 'device-logcat']
: inputValues[id],
},
])
);
return buildFunction.fn!(
{
@@ -144,7 +157,7 @@ function runStep(
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
},
} as unknown as BuildStepContext,
{ inputs, outputs: {}, env: {}, signal } as never
{ inputs, outputs: {}, env, signal } as never
) as Promise<void>;
}
@@ -437,6 +450,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
expect(sessionDevice()).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv: undefined,
@@ -444,6 +458,38 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
});
it.each([
{ sources: [] },
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('selects $sources before Android boot', async ({ sources }) => {
const capture = new AndroidEmulatorLogCapture();
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
await runStep(
BuildRuntimePlatform.LINUX,
{ android_log_sources: sources },
undefined,
ctx,
false,
{
DEVICE_RUN_SESSION_ID: 'session',
}
);
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ logSources: sources, logCapture: capture })
);
});
it('rejects invalid Android log sources before boot', async () => {
await expect(
runStep(BuildRuntimePlatform.LINUX, {
android_log_sources: ['unknown'],
})
).rejects.toThrow();
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
});
it('boots the Android Emulator with the device inputs', async () => {
await runStep(BuildRuntimePlatform.LINUX, {
device_identifier: 'pixel_7',
@@ -455,6 +501,8 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
expect(jest.mocked(startAndroidEmulatorAsync).mock.calls[0][0].logCapture).toBeUndefined();
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceIdentifier: 'pixel_7',
@@ -470,6 +518,26 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
);
});
it('stops the download and waits for it when Android boot fails', async () => {
const download = mockStalledDownload();
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
await expect(
runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, ctx, false, {
DEVICE_RUN_SESSION_ID: 'session',
})
).rejects.toThrow('boot failed');
expect(download.aborted()).toBe(true);
// The step returned only after the download stopped.
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledTimes(1);
expect(jest.mocked(getAndroidEmulatorLogCapture).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(startAndroidEmulatorAsync).mock.invocationCallOrder[0]
);
});
it('does not start work for an already cancelled step', async () => {
const controller = new AbortController();
controller.abort(new Error('cancelled'));
@@ -617,6 +685,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual([
'android_log_sources',
'device_identifier',
'system_image_package',
'lcd_width',
@@ -9,6 +9,8 @@ import {
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
import { AndroidEmulatorLogCapture } from '../../../utils/androidEmulatorLogCapture';
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
@@ -24,6 +26,8 @@ jest.mock('../../utils/androidLocalEgress', () => ({
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/androidEmulatorLogs');
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
@@ -90,6 +94,31 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedMkdtemp.mockRestore();
});
it.each([
{ sources: [] },
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('preserves $sources through boot retries and clones', async ({ sources }) => {
mockedAndroidUtils.waitForReadyAsync.mockRejectedValueOnce(new Error('retry boot'));
const capture = new AndroidEmulatorLogCapture();
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
await createStep(
{ android_log_sources: sources, count: 2 },
{ DEVICE_RUN_SESSION_ID: 'session' }
).executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(4);
for (const [options] of mockedAndroidUtils.startAsync.mock.calls) {
expect(options.logSources).toEqual(sources);
expect(options.logCapture).toBe(capture);
}
});
it('rejects invalid sources before starting the emulator', async () => {
await expect(createStep({ android_log_sources: ['unknown'] }).executeAsync()).rejects.toThrow();
expect(mockedAndroidUtils.startAsync).not.toHaveBeenCalled();
});
describe('local egress', () => {
function createLocalEgress() {
return {
@@ -111,6 +140,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
deviceName: 'EasAndroidDevice01',
env: expect.any(Object),
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
});
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
env: expect.any(Object),
@@ -226,6 +256,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenNthCalledWith(
@@ -233,6 +264,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
@@ -307,18 +339,21 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceName: 'eas-simulator-1',
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceName: 'eas-simulator-2',
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
@@ -337,7 +372,9 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
.mockResolvedValueOnce(createStartResult('emulator-attempt-3'));
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
await expect(createStep().executeAsync()).rejects.toThrow('network unavailable');
await expect(
createStep(undefined, { DEVICE_RUN_SESSION_ID: 'session' }).executeAsync()
).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
@@ -348,6 +385,10 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(3);
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything());
expect(jest.mocked(getAndroidEmulatorLogCapture).mock.invocationCallOrder[0]).toBeLessThan(
mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]
);
});
it('outputs and uses a temporary logcat directory', async () => {
@@ -357,9 +398,12 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
expect(step.getOutputValueByName('logcat_directory')).toBe('/tmp/logcat-directory');
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
expect(mockedAndroidUtils.startAsync.mock.calls[0][0].logCapture).toBeUndefined();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-default',
@@ -0,0 +1,49 @@
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { AndroidEmulatorLogSourcesSchema } from '../../utils/androidEmulatorLogSources';
import { collectAndroidEmulatorLogsAsync } from '../utils/androidEmulatorLogs';
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
export function createCollectAndroidEmulatorLogsBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'collect_android_emulator_logs',
name: 'Collect Android emulator logs',
__metricsId: 'eas/collect_android_emulator_logs',
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
inputProviders: [
BuildStepInput.createProvider({
id: 'sources',
required: false,
defaultValue: ['emulator-output'],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
try {
await collectAndroidEmulatorLogsAsync(ctx, {
global,
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
logger,
sources: AndroidEmulatorLogSourcesSchema.parse(inputs.sources.value),
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not collect Android emulator logs', error);
logger.warn(
{ err: error },
'Could not collect Android logs; the session result is unchanged.'
);
}
},
});
}
@@ -16,6 +16,11 @@ import {
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import {
AndroidEmulatorLogSourcesSchema,
LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
} from '../../utils/androidEmulatorLogSources';
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
@@ -71,6 +76,12 @@ export function createStartAgentDeviceSessionBuildFunction(
name: 'Start agent-device session',
__metricsId: warm ? 'eas/start_warm_agent_device_session' : 'eas/start_agent_device_session',
inputProviders: [
BuildStepInput.createProvider({
id: 'android_log_sources',
required: false,
defaultValue: [...LEGACY_ANDROID_EMULATOR_LOG_SOURCES],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
BuildStepInput.createProvider({
id: 'device_identifier',
@@ -241,6 +252,9 @@ export function createStartAgentDeviceSessionBuildFunction(
booted = iosSimulatorUdid
? Promise.resolve()
: tasks.run('Android Emulator boot', async taskLogger => {
const logSources = AndroidEmulatorLogSourcesSchema.parse(
inputs.android_log_sources.value
);
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
@@ -253,6 +267,10 @@ export function createStartAgentDeviceSessionBuildFunction(
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
logSources,
logCapture: env.DEVICE_RUN_SESSION_ID
? getAndroidEmulatorLogCapture(global)
: undefined,
env,
logger: taskLogger,
});
@@ -20,8 +20,15 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { type AndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogCapture';
import { retryAsync } from '../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
import {
type AndroidEmulatorLogSource,
AndroidEmulatorLogSourcesSchema,
LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
} from '../../utils/androidEmulatorLogSources';
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
@@ -33,6 +40,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
name: 'Start Android Emulator',
__metricsId: 'eas/start_android_emulator',
inputProviders: [
BuildStepInput.createProvider({
id: 'android_log_sources',
required: false,
defaultValue: [...LEGACY_ANDROID_EMULATOR_LOG_SOURCES],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'device_name',
required: false,
@@ -78,10 +91,14 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
required: true,
}),
],
fn: async ({ logger }, { inputs, outputs, env }) => {
fn: async ({ logger, global }, { inputs, outputs, env }) => {
const logSources = AndroidEmulatorLogSourcesSchema.parse(inputs.android_log_sources.value);
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
const logCapture = env.DEVICE_RUN_SESSION_ID
? getAndroidEmulatorLogCapture(global)
: undefined;
outputs.logcat_directory.set(logcatDirectory);
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
@@ -95,6 +112,8 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
logSources,
logCapture,
env,
logger,
});
@@ -133,6 +152,8 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
deviceName: cloneIdentifier,
env,
logcatDirectory,
logSources,
logCapture,
});
cloneSerialId = startResult.serialId;
@@ -202,6 +223,8 @@ export async function startAndroidEmulatorAsync({
lcdHeight,
lcdDensity,
logcatDirectory,
logSources,
logCapture,
env,
logger,
}: {
@@ -212,6 +235,8 @@ export async function startAndroidEmulatorAsync({
lcdHeight?: number;
lcdDensity?: number;
logcatDirectory: string;
logSources?: readonly AndroidEmulatorLogSource[];
logCapture?: AndroidEmulatorLogCapture;
env: BuildStepEnv;
logger: bunyan;
}): Promise<{
@@ -293,6 +318,8 @@ export async function startAndroidEmulatorAsync({
deviceName,
env,
logcatDirectory,
logSources,
logCapture,
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
});
attemptSerialId = startResult.serialId;
@@ -0,0 +1,265 @@
import { type BuildStepGlobalContext } from '@expo/steps';
import { once } from 'node:events';
import fsPromises, {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { PassThrough, Readable } from 'node:stream';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { type CustomBuildContext } from '../../../customBuildContext';
import { type AndroidEmulatorLogSource } from '../../../utils/androidEmulatorLogSources';
import {
collectAndroidEmulatorLogsAsync,
getAndroidEmulatorLogCapture,
} from '../androidEmulatorLogs';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
jest.mock('../deviceRunSessionArtifacts');
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
const ctx = {} as CustomBuildContext;
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
let directory: string;
let emulatorPath: string;
let logcatPath: string;
let global: BuildStepGlobalContext;
let logger: ReturnType<typeof createMockLogger>;
let uploadedContents: Map<string, string>;
beforeEach(async () => {
global = {} as BuildStepGlobalContext;
logger = createMockLogger();
uploadedContents = new Map();
directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-collection-'));
emulatorPath = path.join(directory, 'device-attempt-1-emulator.log');
logcatPath = path.join(directory, 'device-attempt-1-logcat.log');
upload.mockReset().mockImplementation(async (_ctx, { filename, openStream }) => {
const chunks: Buffer[] = [];
for await (const chunk of openStream()) {
chunks.push(Buffer.from(chunk));
}
uploadedContents.set(filename, Buffer.concat(chunks).toString());
});
});
afterEach(async () => {
jest.restoreAllMocks();
await rm(directory, { recursive: true, force: true });
});
async function captureLog(
filePath: string,
source: AndroidEmulatorLogSource,
text: string,
workflow = global
): Promise<void> {
const capture = getAndroidEmulatorLogCapture(workflow);
const input = Readable.from([Buffer.from(text)]);
capture.attach(input, filePath, source);
await once(input, 'end');
await capture.finishFileAsync(filePath);
}
function collect(
sources: readonly AndroidEmulatorLogSource[] = ['emulator-output', 'device-logcat']
): Promise<void> {
return collectAndroidEmulatorLogsAsync(ctx, {
global,
deviceRunSessionId: 'session',
logger,
sources,
});
}
it('uploads only emulator output when sources are omitted', async () => {
await captureLog(emulatorPath, 'emulator-output', 'host output');
await captureLog(logcatPath, 'device-logcat', 'sensitive device output');
const stat = jest.spyOn(fsPromises, 'stat');
await collectAndroidEmulatorLogsAsync(ctx, { global, deviceRunSessionId: 'session', logger });
expect([...uploadedContents.keys()]).toEqual([path.basename(emulatorPath)]);
expect(stat).not.toHaveBeenCalledWith(logcatPath);
});
it('uploads only explicitly selected logcat and uploads nothing for an empty selection', async () => {
await captureLog(emulatorPath, 'emulator-output', 'host output');
await captureLog(logcatPath, 'device-logcat', 'device output');
await collect([]);
expect(upload).not.toHaveBeenCalled();
await collect(['device-logcat']);
expect([...uploadedContents.keys()]).toEqual([path.basename(logcatPath)]);
});
it('honors different source selections during concurrent cleanup without duplicate uploads', async () => {
await captureLog(emulatorPath, 'emulator-output', 'host output');
await captureLog(logcatPath, 'device-logcat', 'device output');
await Promise.all([collect(['emulator-output']), collect(['emulator-output', 'device-logcat'])]);
expect([...uploadedContents.keys()].sort()).toEqual([
path.basename(emulatorPath),
path.basename(logcatPath),
]);
expect(upload).toHaveBeenCalledTimes(2);
});
it('uses captured source records and ignores files outside the capture inventory', async () => {
const first = path.join(directory, 'first.txt');
const second = path.join(directory, 'second.txt');
await captureLog(first, 'emulator-output', 'host output');
await captureLog(second, 'device-logcat', 'Android output');
await captureLog(path.join(directory, 'retry.txt'), 'emulator-output', 'retry output');
await writeFile(emulatorPath, 'not captured');
const elsewhere = path.join(directory, 'elsewhere');
await mkdir(elsewhere);
await writeFile(path.join(elsewhere, 'other-emulator.log'), 'another job');
await symlink(path.join(elsewhere, 'other-emulator.log'), logcatPath);
await collect();
expect([...uploadedContents.values()]).toEqual(['host output', 'Android output', 'retry output']);
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
filename: 'first.txt',
kind: 'simulator-log',
metadata: expect.objectContaining({
platform: 'android',
source: 'emulator-output',
scope: 'emulator-process',
originalSizeBytes: 11,
omittedBytes: 0,
truncated: false,
}),
signal: expect.any(AbortSignal),
attempts: 2,
})
);
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
filename: 'second.txt',
metadata: expect.objectContaining({ source: 'device-logcat', scope: 'device' }),
})
);
});
it('seals a live producer before upload and keeps retry bodies identical without copying files', async () => {
const input = new PassThrough();
const capture = getAndroidEmulatorLogCapture(global);
capture.attach(input, emulatorPath, 'emulator-output');
input.write('before');
const contents: string[] = [];
upload.mockImplementationOnce(async (_ctx, { size, openStream, metadata }) => {
expect(size).toBe(6);
expect(metadata).toEqual(expect.objectContaining({ originalSizeBytes: 6, truncated: false }));
for (let attempt = 0; attempt < 2; attempt++) {
input.write(' ignored after finalization');
const chunks: Buffer[] = [];
for await (const chunk of openStream()) {
chunks.push(Buffer.from(chunk));
}
contents.push(Buffer.concat(chunks).toString());
}
});
try {
await collect();
expect(contents).toEqual(['before', 'before']);
expect(await readFile(emulatorPath, 'utf8')).toBe('before');
expect(await readdir(directory)).toEqual([path.basename(emulatorPath)]);
expect(input.readableFlowing).toBe(true);
const late = new PassThrough();
capture.attach(late, logcatPath, 'device-logcat');
late.end('not captured');
expect(late.readableFlowing).toBe(true);
await expect(fsPromises.stat(logcatPath)).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
input.end();
}
});
it('continues after a failed upload and retries only unsuccessful files on another collection', async () => {
await captureLog(emulatorPath, 'emulator-output', 'host output');
await captureLog(logcatPath, 'device-logcat', 'Android output');
upload.mockRejectedValueOnce(new Error('storage unavailable'));
await expect(collect()).resolves.toBeUndefined();
expect(upload).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('other logs will continue')
);
await collect();
expect(upload).toHaveBeenCalledTimes(3);
expect(upload.mock.calls[2][1].filename).toBe(path.basename(emulatorPath));
expect(upload.mock.calls[0][1].signal).not.toBe(upload.mock.calls[1][1].signal);
});
it('shares workflow capture and skips successful uploads on repeated or concurrent cleanup', async () => {
expect(getAndroidEmulatorLogCapture(global)).toBe(getAndroidEmulatorLogCapture(global));
await captureLog(emulatorPath, 'emulator-output', 'host output');
await Promise.all([collect(), collect()]);
await collect();
expect(upload).toHaveBeenCalledTimes(1);
});
it('uses a fresh 30 second budget for the next file after an upload times out', async () => {
await captureLog(emulatorPath, 'emulator-output', 'host output');
await captureLog(logcatPath, 'device-logcat', 'Android output');
const controllers: AbortController[] = [];
const timeout = jest.spyOn(AbortSignal, 'timeout').mockImplementation(() => {
const controller = new AbortController();
controllers.push(controller);
return controller.signal;
});
upload.mockImplementationOnce(async (_ctx, { signal }) => {
controllers[0].abort(new Error('upload timed out'));
signal?.throwIfAborted();
});
await expect(collect()).resolves.toBeUndefined();
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
expect(timeout.mock.calls).toEqual([[30_000], [30_000]]);
expect(upload.mock.calls[1][1].signal?.aborted).toBe(false);
});
it('does not collect another workflow or empty files and continues after a captured file disappears', async () => {
await captureLog(emulatorPath, 'emulator-output', 'other workflow', {} as BuildStepGlobalContext);
await collect();
expect(upload).not.toHaveBeenCalled();
await captureLog(logcatPath, 'device-logcat', '');
await captureLog(path.join(directory, 'missing.log'), 'emulator-output', 'removed');
await rm(path.join(directory, 'missing.log'));
await expect(collect()).resolves.toBeUndefined();
expect(upload).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('missing.log')
);
});
it('aborts on a genuine log file read error and continues with the other log', async () => {
await captureLog(emulatorPath, 'emulator-output', 'host output');
await captureLog(logcatPath, 'device-logcat', 'Android output');
const readError = Object.assign(new Error('log read failed'), { code: 'EIO' });
upload.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
const stream = openStream();
await new Promise<void>(resolve => {
stream.once('error', () => resolve());
stream.destroy(readError);
});
expect(signal?.reason).toBe(readError);
signal?.throwIfAborted();
});
await expect(collect()).resolves.toBeUndefined();
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
expect(logger.warn).toHaveBeenCalledWith(
{ err: readError },
expect.stringContaining('other logs will continue')
);
});
@@ -0,0 +1,97 @@
import { type BuildStepGlobalContext } from '@expo/steps';
import fs from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { createServer } from 'node:http';
import { PassThrough } from 'node:stream';
import os from 'node:os';
import path from 'node:path';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { type CustomBuildContext } from '../../../customBuildContext';
import {
collectAndroidEmulatorLogsAsync,
getAndroidEmulatorLogCapture,
} from '../androidEmulatorLogs';
import { requireDeviceRunSessionWorkerApi } from '../deviceRunSessionWorkerApi';
jest.mock('../deviceRunSessionWorkerApi');
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.unmock('node-fetch');
it('retries a finalized capped log while its producer keeps writing, with one artifact', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-upload-test-'));
const maximumBytes = 20 * 1024 * 1024;
const payload = Buffer.alloc(maximumBytes + 6, 'a');
const filePath = path.join(directory, 'device-emulator.log');
const global = {} as BuildStepGlobalContext;
const logger = createMockLogger();
const capture = getAndroidEmulatorLogCapture(global);
const input = new PassThrough();
capture.attach(input, filePath, 'emulator-output');
input.write(payload);
let requests = 0;
let receivedBytes = 0;
const server = createServer((request, response) => {
requests++;
if (requests === 1) {
input.write('ignored after finalization');
response.statusCode = 503;
response.flushHeaders();
return;
}
request.on('data', chunk => {
receivedBytes += chunk.length;
});
request.on('end', () => response.end());
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
try {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Missing test server address');
}
const createArtifactUploadSessionAsync = jest.fn().mockResolvedValue({
url: `http://127.0.0.1:${address.port}/log`,
headers: { 'Content-Length': String(maximumBytes) },
});
jest.mocked(requireDeviceRunSessionWorkerApi).mockReturnValue({
createArtifactUploadSessionAsync,
} as unknown as ReturnType<typeof requireDeviceRunSessionWorkerApi>);
const collect = async (): Promise<void> =>
await collectAndroidEmulatorLogsAsync({} as CustomBuildContext, {
global,
deviceRunSessionId: 'session',
logger,
});
await collect();
await collect();
expect(logger.warn).not.toHaveBeenCalled();
expect(requests).toBe(2);
expect(receivedBytes).toBe(maximumBytes);
expect((await fs.promises.stat(filePath)).size).toBe(maximumBytes);
expect(input.readableFlowing).toBe(true);
expect(createArtifactUploadSessionAsync).toHaveBeenCalledTimes(1);
expect(createArtifactUploadSessionAsync).toHaveBeenCalledWith(
expect.objectContaining({
name: expect.stringContaining('truncated, 20 MiB retained'),
size: maximumBytes,
metadata: expect.objectContaining({
originalSizeBytes: payload.length,
omittedBytes: 6,
truncated: true,
}),
}),
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
} finally {
input.end();
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
await rm(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,156 @@
import { type bunyan } from '@expo/logger';
import { type BuildStepGlobalContext } from '@expo/steps';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import {
AndroidEmulatorLogCapture,
type FinalizedAndroidEmulatorLog,
} from '../../utils/androidEmulatorLogCapture';
import { type AndroidEmulatorLogSource } from '../../utils/androidEmulatorLogSources';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
const UPLOAD_TIMEOUT_MS = 30_000;
type Collection = {
capture: AndroidEmulatorLogCapture;
uploaded: Set<string>;
collecting?: Promise<void>;
};
// A worker can execute more than one job; each workflow owns its capture and uploads.
const collections = new WeakMap<BuildStepGlobalContext, Collection>();
export function getAndroidEmulatorLogCapture(
global: BuildStepGlobalContext
): AndroidEmulatorLogCapture {
let collection = collections.get(global);
if (!collection) {
collection = { capture: new AndroidEmulatorLogCapture(), uploaded: new Set() };
collections.set(global, collection);
}
return collection.capture;
}
export async function collectAndroidEmulatorLogsAsync(
ctx: CustomBuildContext,
{
global,
deviceRunSessionId,
logger,
sources = ['emulator-output'],
}: {
global: BuildStepGlobalContext;
deviceRunSessionId: string;
logger: bunyan;
sources?: readonly AndroidEmulatorLogSource[];
}
): Promise<void> {
const collection = collections.get(global);
if (!collection) {
logger.info('No Android emulator logs captured; skipping upload.');
return;
}
while (collection.collecting) {
await collection.collecting;
}
collection.collecting = uploadCapturedLogsAsync(ctx, {
collection,
deviceRunSessionId,
logger,
sources,
});
try {
await collection.collecting;
} finally {
collection.collecting = undefined;
}
}
async function uploadCapturedLogsAsync(
ctx: CustomBuildContext,
{
collection,
deviceRunSessionId,
logger,
sources,
}: {
collection: Collection;
deviceRunSessionId: string;
logger: bunyan;
sources: readonly AndroidEmulatorLogSource[];
}
): Promise<void> {
const logs = await collection.capture.finishAsync();
for (const log of logs) {
if (!sources.includes(log.source) || collection.uploaded.has(log.filePath)) {
continue;
}
const filename = path.basename(log.filePath);
try {
const uploaded = await uploadLogAsync(ctx, {
deviceRunSessionId,
log,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
});
if (uploaded) {
collection.uploaded.add(log.filePath);
logger.info(`Uploaded Android diagnostic log ${filename}.`);
}
} catch (err) {
logger.warn({ err }, `Could not upload Android log ${filename}; other logs will continue.`);
}
}
}
async function uploadLogAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
log: { filePath, source, bytesSeen },
signal,
}: {
deviceRunSessionId: string;
log: FinalizedAndroidEmulatorLog;
signal: AbortSignal;
}
): Promise<boolean> {
const { size } = await stat(filePath);
if (size === 0) {
return false;
}
const filename = path.basename(filePath);
const readController = new AbortController();
const retainedSize =
size >= 1024 * 1024 ? `${Number((size / (1024 * 1024)).toFixed(2))} MiB` : `${size} bytes`;
const truncationNote = size < bytesSeen ? `; truncated, ${retainedSize} retained` : '';
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: filename,
name: `${source === 'emulator-output' ? 'Emulator output' : 'Android device logcat'} (${filename.replace(/-(emulator|logcat)\.log$/, '')}${truncationNote})`,
filename,
kind: 'simulator-log',
metadata: {
__eas_type: 'simulator-log',
platform: 'android',
source,
scope: source === 'emulator-output' ? 'emulator-process' : 'device',
originalSizeBytes: bytesSeen,
omittedBytes: bytesSeen - size,
truncated: size < bytesSeen,
},
size,
openStream: () =>
createReadStream(filePath).on('error', err => {
// An HTTP error aborts one request body, not the remaining upload attempts.
if (err.name !== 'AbortError') {
readController.abort(err);
}
}),
signal: AbortSignal.any([signal, readController.signal]),
attempts: 2,
});
return true;
}
@@ -13,6 +13,11 @@ import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { Sentry } from '../sentry';
import {
type AndroidEmulatorLogSource,
LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
} from './androidEmulatorLogSources';
import { type AndroidEmulatorLogCapture } from './androidEmulatorLogCapture';
import { retryAsync } from './retry';
/** Android Virtual Device is the device we run. */
@@ -326,20 +331,24 @@ export namespace AndroidEmulatorUtils {
deviceName,
env,
logcatDirectory,
logSources = LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
launchGate,
logCapture,
}: {
deviceName: AndroidVirtualDeviceName;
env: NodeJS.ProcessEnv;
logcatDirectory: string;
logSources?: readonly AndroidEmulatorLogSource[];
launchGate?: AndroidEmulatorLaunchGate;
logCapture?: AndroidEmulatorLogCapture;
}): Promise<{
emulatorPromise: SpawnPromise<SpawnResult>;
serialId: AndroidDeviceSerialId;
logcatOutputPath: string;
emulatorOutputPath: string;
logcatOutputPath: string | undefined;
emulatorOutputPath: string | undefined;
}> {
let logcatOutputPath: string;
let emulatorOutputPath: string;
let logcatOutputPath: string | undefined;
let emulatorOutputPath: string | undefined;
try {
await fs.promises.mkdir(logcatDirectory, { recursive: true });
const safeDeviceName = deviceName.replace(/[^a-zA-Z0-9_.-]/g, '_');
@@ -347,16 +356,24 @@ export namespace AndroidEmulatorUtils {
.toString(16)
.padStart(8, '0');
const outputName = `${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}`;
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
await fs.promises.writeFile(logcatOutputPath, '');
await fs.promises.writeFile(emulatorOutputPath, '');
if (logSources.includes('device-logcat')) {
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
await fs.promises.writeFile(logcatOutputPath, '');
}
if (logSources.includes('emulator-output')) {
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
await fs.promises.writeFile(emulatorOutputPath, '');
}
} catch (err) {
throw new SystemError(`Failed to prepare Android emulator output for ${deviceName}.`, {
cause: err,
});
}
const logcatPipe =
logCapture && logcatOutputPath
? await logCapture.createLogcatPipeAsync(logcatOutputPath)
: undefined;
const emulatorCommand = `${process.env.ANDROID_HOME}/emulator/emulator`;
const emulatorArgs = [
'-no-window',
@@ -364,10 +381,9 @@ export namespace AndroidEmulatorUtils {
'-writable-system',
'-noaudio',
'-no-snapshot-save',
'-logcat',
'*:v',
'-logcat-output',
logcatOutputPath,
...(logcatOutputPath
? ['-logcat', '*:v', '-logcat-output', logcatPipe?.path ?? logcatOutputPath]
: []),
'-avd',
deviceName,
'-accel',
@@ -381,49 +397,64 @@ export namespace AndroidEmulatorUtils {
// We don't need to wait for emulator to exit gracefully.
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
};
const outputStdio = emulatorOutputPath ? 'pipe' : 'ignore';
const emulatorPromise = launchGate
? spawn(
launchGate.wrapperCommand,
[...launchGate.wrapperArgs, emulatorCommand, ...emulatorArgs, ...launchGate.emulatorArgs],
{
detached: true,
stdio: ['pipe', 'pipe', 'pipe'],
stdio: ['pipe', outputStdio, outputStdio],
ignoreStdio: true,
env: emulatorEnv,
}
)
: spawn(emulatorCommand, emulatorArgs, {
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['ignore', outputStdio, outputStdio],
ignoreStdio: true,
env: emulatorEnv,
});
const emulatorOutputStream = fs.createWriteStream(emulatorOutputPath, { flags: 'a' });
let reportedEmulatorOutputError = false;
emulatorOutputStream.on('error', err => {
process.stderr.write(
`Failed to write Android emulator output to ${emulatorOutputPath}: ${err}\n`
);
if (!reportedEmulatorOutputError) {
reportedEmulatorOutputError = true;
Sentry.capture('Failed to write Android emulator process output', err, {
level: 'warning',
tags: {
errorCode: (err as NodeJS.ErrnoException).code ?? 'unknown',
},
extras: {
deviceName,
emulatorOutputPath,
},
});
if (logcatPipe) {
emulatorPromise.child.once('close', logcatPipe.close);
}
if (emulatorOutputPath && logCapture) {
for (const input of [emulatorPromise.child.stdout, emulatorPromise.child.stderr]) {
if (input) {
logCapture.attach(input, emulatorOutputPath, 'emulator-output');
}
}
});
// Only into the log file -- this process' stdout/stderr is not watched or uploaded.
emulatorPromise.child.stdout?.pipe(emulatorOutputStream, { end: false });
emulatorPromise.child.stderr?.pipe(emulatorOutputStream, { end: false });
emulatorPromise.child.once('close', () => {
emulatorOutputStream.end();
});
emulatorPromise.child.once('close', () => {
void logCapture.finishFileAsync(emulatorOutputPath);
});
} else if (emulatorOutputPath) {
const emulatorOutputStream = fs.createWriteStream(emulatorOutputPath, { flags: 'a' });
let reportedEmulatorOutputError = false;
emulatorOutputStream.on('error', err => {
process.stderr.write(
`Failed to write Android emulator output to ${emulatorOutputPath}: ${err}\n`
);
if (!reportedEmulatorOutputError) {
reportedEmulatorOutputError = true;
Sentry.capture('Failed to write Android emulator process output', err, {
level: 'warning',
tags: {
errorCode: (err as NodeJS.ErrnoException).code ?? 'unknown',
},
extras: {
deviceName,
emulatorOutputPath,
},
});
}
});
// Keep process output out of the build log. Device sessions upload this file during cleanup.
emulatorPromise.child.stdout?.pipe(emulatorOutputStream, { end: false });
emulatorPromise.child.stderr?.pipe(emulatorOutputStream, { end: false });
emulatorPromise.child.once('close', () => {
emulatorOutputStream.end();
});
}
// Piped stdio creates socket handles in this process which, unlike inherited
// file descriptors, keep the event loop alive for as long as the emulator runs.
// We never stop the emulator explicitly, so without unref-ing these the process
@@ -319,7 +319,7 @@ describe('AndroidEmulatorUtils', () => {
await AndroidEmulatorUtils.waitForReadyAsync({ serialId, env: process.env });
await retryAsync(
async () => {
const contents = await fs.promises.readFile(startResult.emulatorOutputPath, 'utf-8');
const contents = await fs.promises.readFile(startResult.emulatorOutputPath!, 'utf-8');
if (!contents.includes('Android emulator version')) {
throw new Error('Did not find the emulator version in emulator process output yet.');
}
@@ -340,7 +340,7 @@ describe('AndroidEmulatorUtils', () => {
await retryAsync(
async () => {
const contents = await fs.promises.readFile(startResult.logcatOutputPath, 'utf-8');
const contents = await fs.promises.readFile(startResult.logcatOutputPath!, 'utf-8');
if (!contents.includes(marker)) {
throw new Error(`Did not find marker ${marker} in logcat output yet.`);
}
@@ -14,6 +14,8 @@ import {
AndroidVirtualDeviceName,
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
import { AndroidEmulatorLogCapture } from '../androidEmulatorLogCapture';
import { AndroidEmulatorLogSourcesSchema } from '../androidEmulatorLogSources';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
@@ -221,8 +223,8 @@ describe('AndroidEmulatorUtils', () => {
expect(result.emulatorOutputPath).toMatch(
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-emulator\\.log$`)
);
await expect(fs.promises.access(result.logcatOutputPath)).resolves.toBeUndefined();
await expect(fs.promises.access(result.emulatorOutputPath)).resolves.toBeUndefined();
await expect(fs.promises.access(result.logcatOutputPath!)).resolves.toBeUndefined();
await expect(fs.promises.access(result.emulatorOutputPath!)).resolves.toBeUndefined();
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/\/emulator\/emulator$/),
expect.arrayContaining(['-logcat', '*:v', '-logcat-output', result.logcatOutputPath]),
@@ -266,6 +268,119 @@ describe('AndroidEmulatorUtils', () => {
await emulatorOutputStreamClosed;
});
describe.each([false, true])('source selection with launch gate %s', gated => {
it.each([
{ sources: [] },
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('records only $sources', async ({ sources }) => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'selected-logs-'));
temporaryDirectories.push(outputDir);
const deviceName = 'selected-emulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const stdin = { end: jest.fn() };
Object.assign(child, { stdin });
const directSpawn = mockedSpawn.getMockImplementation()!;
mockedSpawn.mockImplementation(((command: string, args: string[], options: object) =>
directSpawn(
command === 'bash' ? '/android/emulator/emulator' : command,
args,
options as any
)) as any);
const createWriteStreamSpy = jest.spyOn(fs, 'createWriteStream');
const stdoutPipe = jest.spyOn(child.stdout, 'pipe');
const stderrPipe = jest.spyOn(child.stderr, 'pipe');
const result = await AndroidEmulatorUtils.startAsync({
deviceName,
env: {},
logcatDirectory: outputDir,
logSources: AndroidEmulatorLogSourcesSchema.parse(sources),
...(gated
? {
launchGate: {
emulatorArgs: [],
wrapperCommand: 'bash',
wrapperArgs: [],
admitAsync: async () => {},
},
}
: {}),
});
const capturesOutput = sources.includes('emulator-output');
const capturesLogcat = sources.includes('device-logcat');
expect(result.logcatOutputPath !== undefined).toBe(capturesLogcat);
expect(result.emulatorOutputPath !== undefined).toBe(capturesOutput);
const [, args, options] = mockedSpawn.mock.calls[0];
expect(args.includes('-logcat')).toBe(capturesLogcat);
expect(args.includes('-logcat-output')).toBe(capturesLogcat);
expect(options?.stdio).toEqual([
gated ? 'pipe' : 'ignore',
capturesOutput ? 'pipe' : 'ignore',
capturesOutput ? 'pipe' : 'ignore',
]);
expect(stdoutPipe).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
expect(stderrPipe).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
expect(createWriteStreamSpy).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
if (capturesOutput) {
const stream = createWriteStreamSpy.mock.results[0].value;
child.stdout.emit('data', Buffer.from('stdout marker\n'));
child.stderr.emit('data', Buffer.from('stderr marker\n'));
const closed = once(stream, 'close');
child.emit('close');
await closed;
await expect(fs.promises.readFile(result.emulatorOutputPath!, 'utf8')).resolves.toBe(
'stdout marker\nstderr marker\n'
);
}
const files = await fs.promises.readdir(outputDir);
expect(files).toHaveLength(sources.length);
expect(files.some(file => file.endsWith('-logcat.log'))).toBe(capturesLogcat);
expect(files.some(file => file.endsWith('-emulator.log'))).toBe(capturesOutput);
if (gated) {
expect(stdin.end).toHaveBeenCalledWith('go\n');
}
});
});
it.each([
{ sources: [] },
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('bounds only selected session sources: $sources', async ({ sources }) => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'bounded-logs-'));
temporaryDirectories.push(outputDir);
const deviceName = 'bounded-emulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const capture = new AndroidEmulatorLogCapture(10);
const close = jest.fn();
const createPipe = jest.spyOn(capture, 'createLogcatPipeAsync').mockResolvedValue({
path: '/logcat.pipe',
close,
closed: Promise.resolve(),
});
const result = await AndroidEmulatorUtils.startAsync({
deviceName,
env: {},
logcatDirectory: outputDir,
logSources: AndroidEmulatorLogSourcesSchema.parse(sources),
logCapture: capture,
});
const hasLogcat = sources.includes('device-logcat');
expect(createPipe).toHaveBeenCalledTimes(hasLogcat ? 1 : 0);
expect(mockedSpawn.mock.calls[0][1].includes('/logcat.pipe')).toBe(hasLogcat);
child.stdout.emit('data', Buffer.from('first stdout'));
child.stderr.emit('data', Buffer.from('later stderr'));
child.emit('close');
await capture.finishAsync();
expect(close).toHaveBeenCalledTimes(hasLogcat ? 1 : 0);
if (sources.includes('emulator-output')) {
expect(await fs.promises.readFile(result.emulatorOutputPath!, 'utf8')).toBe('first stdo');
}
expect(await fs.promises.readdir(outputDir)).toHaveLength(sources.length);
});
it('spawns the emulator directly with the same arguments when there is no launch gate', async () => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
temporaryDirectories.push(outputDir);
@@ -0,0 +1,151 @@
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { PassThrough, Readable } from 'node:stream';
import { Sentry } from '../../sentry';
import {
AndroidEmulatorLogCapture,
MAX_ANDROID_LOG_BYTES_PER_SOURCE,
} from '../androidEmulatorLogCapture';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.mock('../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
let directory: string;
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'android-capped-logs-'));
});
afterEach(async () => {
jest.restoreAllMocks();
await fs.rm(directory, { recursive: true, force: true });
});
async function record(
capture: AndroidEmulatorLogCapture,
file: string,
text: string
): Promise<void> {
const input = Readable.from([Buffer.from(text)]);
capture.attach(input, file, 'emulator-output');
await once(input, 'end');
await capture.finishFileAsync(file);
}
it('shares a source budget across launches, with independent budgets for the two sources', async () => {
const capture = new AndroidEmulatorLogCapture(10);
const first = path.join(directory, 'first.log');
const retry = path.join(directory, 'retry.log');
const device = path.join(directory, 'logcat.log');
await record(capture, first, 'first!');
await record(capture, retry, 'second log is too long');
const logcat = Readable.from([Buffer.from('device logs are separate')]);
capture.attach(logcat, device, 'device-logcat');
await once(logcat, 'end');
const logs = await capture.finishAsync();
expect(logs).toEqual([
{ filePath: first, source: 'emulator-output', bytesSeen: 6 },
{ filePath: retry, source: 'emulator-output', bytesSeen: 22 },
{ filePath: device, source: 'device-logcat', bytesSeen: 24 },
]);
expect(await fs.readFile(first, 'utf8')).toBe('first!');
expect(await fs.readFile(retry, 'utf8')).toBe('seco');
expect(await fs.readFile(device, 'utf8')).toBe('device log');
});
it('reserves bytes across simultaneous captures and drains after finalization', async () => {
const capture = new AndroidEmulatorLogCapture(10);
const a = new PassThrough();
const b = new PassThrough();
const first = path.join(directory, 'first.log');
const second = path.join(directory, 'second.log');
capture.attach(a, first, 'emulator-output');
capture.attach(b, second, 'emulator-output');
a.write('12345678');
b.write('abcdefgh');
await capture.finishAsync();
expect(await fs.readFile(first, 'utf8')).toBe('12345678');
expect(await fs.readFile(second, 'utf8')).toBe('ab');
a.end('ignored after cleanup');
b.end('also ignored');
expect(a.readableFlowing).toBe(true);
expect(b.readableFlowing).toBe(true);
});
it('drains a real FIFO beyond 20 MiB, retains the prefix, and closes after the writer exits', async () => {
const file = path.join(directory, 'logcat.log');
const capture = new AndroidEmulatorLogCapture();
const pipe = await capture.createLogcatPipeAsync(file);
expect((await fs.stat(pipe.path)).isFIFO()).toBe(true);
const writer = spawn(
process.execPath,
[
'-e',
`
const fs = require('node:fs');
const fd = fs.openSync(process.argv[1], 'w');
fs.writeSync(fd, 'BEGIN\\n');
const chunk = Buffer.alloc(64 * 1024, 'x');
for (let i = 0; i < 1024; i++) {
let offset = 0;
while (offset < chunk.length) offset += fs.writeSync(fd, chunk, offset);
}
fs.writeSync(fd, '\\nEND');
fs.closeSync(fd);
`,
pipe.path,
],
{ stdio: 'ignore' }
);
try {
expect(await once(writer, 'close')).toEqual([0, null]);
pipe.close();
pipe.close();
await pipe.closed;
const logs = await capture.finishAsync();
const result = await fs.readFile(file);
expect(result.length).toBe(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
expect(result.subarray(0, 6).toString()).toBe('BEGIN\n');
expect(result.subarray(-4).toString()).toBe('xxxx');
expect(logs).toEqual([
{ filePath: file, source: 'device-logcat', bytesSeen: 64 * 1024 * 1024 + 10 },
]);
await expect(fs.stat(pipe.path)).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
writer.kill();
pipe.close();
}
}, 30_000);
it('closes an unused logcat pipe when the emulator fails to launch', async () => {
const capture = new AndroidEmulatorLogCapture();
const pipe = await capture.createLogcatPipeAsync(path.join(directory, 'empty.log'));
pipe.close();
await pipe.closed;
await capture.finishAsync();
});
it('reports a disk write failure once and continues draining output', async () => {
const capture = new AndroidEmulatorLogCapture(10);
const input = new PassThrough();
const file = path.join(directory, 'failed.log');
const diskError = Object.assign(new Error('full'), { code: 'ENOSPC' });
jest.spyOn(fs, 'open').mockRejectedValueOnce(diskError);
capture.attach(input, file, 'emulator-output');
input.write('first');
input.write('second');
await capture.finishAsync();
input.end('more');
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to write Android diagnostic log',
diskError,
expect.anything()
);
expect(input.readableFlowing).toBe(true);
});
@@ -0,0 +1,175 @@
import { execFile, spawn } from 'node:child_process';
import fs from 'node:fs';
import { Socket } from 'node:net';
import { type Readable, Writable } from 'node:stream';
import { finished } from 'node:stream/promises';
import { promisify } from 'node:util';
import { Sentry } from '../sentry';
import { type AndroidEmulatorLogSource } from './androidEmulatorLogSources';
export const MAX_ANDROID_LOG_BYTES_PER_SOURCE = 20 * 1024 * 1024;
export type FinalizedAndroidEmulatorLog = {
readonly filePath: string;
readonly source: AndroidEmulatorLogSource;
readonly bytesSeen: number;
};
type Capture = {
source: AndroidEmulatorLogSource;
stream: Writable;
inputs: Set<Readable>;
done: Promise<void>;
bytesSeen: number;
};
/** One budget per workflow, shared by all emulator launches and retries. */
export class AndroidEmulatorLogCapture {
private readonly bytes = new Map<AndroidEmulatorLogSource, number>();
private readonly captures = new Map<string, Capture>();
private finishing = false;
constructor(private readonly maxBytes = MAX_ANDROID_LOG_BYTES_PER_SOURCE) {}
attach(input: Readable, filePath: string, source: AndroidEmulatorLogSource): void {
if (this.finishing) {
input.resume();
return;
}
let capture = this.captures.get(filePath);
if (!capture) {
const file = fs.promises.open(filePath, 'a');
// Handle open errors even if the process never produces output.
void file.catch(() => {});
let failed = false;
const report = (err: unknown): void => {
if (!failed) {
failed = true;
Sentry.capture(
'Failed to write Android diagnostic log',
err instanceof Error ? err : new Error(String(err)),
{
level: 'warning',
extras: { filePath, source },
}
);
}
};
const stream = new Writable({
write: (chunk: Buffer, _encoding, callback) => {
capture!.bytesSeen += chunk.length;
const used = this.bytes.get(source) ?? 0;
const length = Math.min(chunk.length, this.maxBytes - used);
if (failed || length <= 0) {
callback();
return;
}
// Reserve before awaiting I/O so concurrent launches share the same cap.
this.bytes.set(source, used + length);
void file
.then(handle => handle.writeFile(chunk.subarray(0, length)))
.catch(report)
.then(() => callback());
},
final: callback => {
void file
.then(handle => handle.close())
.catch(report)
.then(() => callback());
},
});
capture = { source, stream, inputs: new Set(), done: finished(stream), bytesSeen: 0 };
this.captures.set(filePath, capture);
}
capture.inputs.add(input);
input.pipe(capture.stream, { end: false });
}
async finishFileAsync(filePath: string): Promise<void> {
const capture = this.captures.get(filePath);
if (!capture) {
return;
}
for (const input of capture.inputs) {
input.unpipe(capture.stream);
// Keep draining: closing a full log must never block or break the emulator.
input.resume();
}
capture.inputs.clear();
capture.stream.end();
await capture.done;
}
async finishAsync(): Promise<readonly FinalizedAndroidEmulatorLog[]> {
this.finishing = true;
await Promise.all([...this.captures.keys()].map(file => this.finishFileAsync(file)));
// Every writer is closed, so uploads can reopen these files on each retry.
return [...this.captures].map(([filePath, { source, bytesSeen }]) => ({
filePath,
source,
bytesSeen,
}));
}
/** Keep logcat separate from emulator stderr without an unbounded intermediate file. */
async createLogcatPipeAsync(
filePath: string
): Promise<{ path: string; close: () => void; closed: Promise<void> }> {
const pipePath = `${filePath}.pipe`;
await promisify(execFile)('mkfifo', ['-m', '600', pipePath]);
// The sentinel keeps the FIFO open before emulator launch. The reader signals
// after opening it, so even a failed launch can close the pipe without a race.
const sentinel = fs.openSync(pipePath, fs.constants.O_RDWR | fs.constants.O_NONBLOCK);
let sentinelOpen = true;
const close = (): void => {
if (sentinelOpen) {
sentinelOpen = false;
fs.closeSync(sentinel);
}
};
const reader = spawn(
'sh',
['-c', 'exec 3<"$1" || exit 1; printf ready >&2; exec cat <&3', 'android-logcat', pipePath],
{
stdio: ['ignore', 'pipe', 'pipe'],
}
);
const ready = new Promise<void>((resolve, reject) => {
reader.once('error', reject);
reader.once('exit', () =>
reject(new Error('Android logcat reader exited before opening its pipe.'))
);
reader.stderr.once('data', () => resolve());
});
this.attach(reader.stdout, filePath, 'device-logcat');
reader.on('error', err => {
close();
Sentry.capture('Failed to read Android logcat pipe', err, { level: 'warning' });
});
const closed = new Promise<void>(resolve => {
reader.once('close', () => {
close();
void this.finishFileAsync(filePath)
.then(() => fs.promises.rm(pipePath, { force: true }))
.catch(() => {})
.then(resolve);
});
});
try {
await ready;
} catch (err) {
close();
reader.kill();
await closed;
throw err;
}
reader.unref();
for (const stream of [reader.stdout, reader.stderr]) {
if (stream instanceof Socket) {
stream.unref();
}
}
return { path: pipePath, close, closed };
}
}
@@ -0,0 +1,12 @@
import { z } from 'zod';
export const AndroidEmulatorLogSourcesSchema = z.array(
z.enum(['emulator-output', 'device-logcat'])
);
export type AndroidEmulatorLogSource = z.infer<typeof AndroidEmulatorLogSourcesSchema>[number];
// Existing build workflows capture both sources. Device sessions pass their selection explicitly.
export const LEGACY_ANDROID_EMULATOR_LOG_SOURCES: readonly AndroidEmulatorLogSource[] = [
'emulator-output',
'device-logcat',
];
+30 -12
View File
@@ -3058,16 +3058,22 @@ USAGE
$ eas sim:start [-p android|ios] [--name <value>] [--tag <value>...] [--device <value>] [--system-image
<value>] [--build-id <value> | --build-fingerprint <value> | --application-archive-url <value> | --expo-go]
[--sdk-version <value>] [--launch-arg <value>...] [--open-url <value>] [--type
agent-device|appium|argent|web-preview-only] [--package-version <value>] [--network-capture-field <value>...
--network-capture] [--max-duration-minutes <value>] [--max-idle-time-minutes <value>] [--egress-allow <value>...
--egress local] [--cors-origin <value>...] [--frame-ancestor <value>...] [--force] [--out-config-type env|dotenv]
[--public-link] [--json] [--non-interactive]
agent-device|appium|argent|web-preview-only] [--package-version <value>] [--android-log-source
emulator-output|device-logcat...] [--network-capture-field <value>... --network-capture] [--max-duration-minutes
<value>] [--max-idle-time-minutes <value>] [--egress-allow <value>... --egress local] [--cors-origin <value>...]
[--frame-ancestor <value>...] [--force] [--out-config-type env|dotenv] [--public-link] [--json] [--non-interactive]
FLAGS
-p, --platform=<option>
Device platform
<options: android|ios>
--android-log-source=<option>...
Android logs to record during the session and upload afterward. Repeat to select both sources; replaces the default
emulator-output selection. Device-wide device-logcat may contain credentials and personal data. Logs are not
redacted and are retained for 90 days.
<options: emulator-output|device-logcat>
--application-archive-url=<value>
Application archive URL to download, install, and launch before the simulator session is ready.
@@ -3214,16 +3220,22 @@ USAGE
$ eas simulator [-p android|ios] [--name <value>] [--tag <value>...] [--device <value>] [--system-image
<value>] [--build-id <value> | --build-fingerprint <value> | --application-archive-url <value> | --expo-go]
[--sdk-version <value>] [--launch-arg <value>...] [--open-url <value>] [--type
agent-device|appium|argent|web-preview-only] [--package-version <value>] [--network-capture-field <value>...
--network-capture] [--max-duration-minutes <value>] [--max-idle-time-minutes <value>] [--egress-allow <value>...
--egress local] [--cors-origin <value>...] [--frame-ancestor <value>...] [--force] [--out-config-type env|dotenv]
[--public-link] [--json] [--non-interactive]
agent-device|appium|argent|web-preview-only] [--package-version <value>] [--android-log-source
emulator-output|device-logcat...] [--network-capture-field <value>... --network-capture] [--max-duration-minutes
<value>] [--max-idle-time-minutes <value>] [--egress-allow <value>... --egress local] [--cors-origin <value>...]
[--frame-ancestor <value>...] [--force] [--out-config-type env|dotenv] [--public-link] [--json] [--non-interactive]
FLAGS
-p, --platform=<option>
Device platform
<options: android|ios>
--android-log-source=<option>...
Android logs to record during the session and upload afterward. Repeat to select both sources; replaces the default
emulator-output selection. Device-wide device-logcat may contain credentials and personal data. Logs are not
redacted and are retained for 90 days.
<options: emulator-output|device-logcat>
--application-archive-url=<value>
Application archive URL to download, install, and launch before the simulator session is ready.
@@ -3580,16 +3592,22 @@ USAGE
$ eas simulator:start [-p android|ios] [--name <value>] [--tag <value>...] [--device <value>] [--system-image
<value>] [--build-id <value> | --build-fingerprint <value> | --application-archive-url <value> | --expo-go]
[--sdk-version <value>] [--launch-arg <value>...] [--open-url <value>] [--type
agent-device|appium|argent|web-preview-only] [--package-version <value>] [--network-capture-field <value>...
--network-capture] [--max-duration-minutes <value>] [--max-idle-time-minutes <value>] [--egress-allow <value>...
--egress local] [--cors-origin <value>...] [--frame-ancestor <value>...] [--force] [--out-config-type env|dotenv]
[--public-link] [--json] [--non-interactive]
agent-device|appium|argent|web-preview-only] [--package-version <value>] [--android-log-source
emulator-output|device-logcat...] [--network-capture-field <value>... --network-capture] [--max-duration-minutes
<value>] [--max-idle-time-minutes <value>] [--egress-allow <value>... --egress local] [--cors-origin <value>...]
[--frame-ancestor <value>...] [--force] [--out-config-type env|dotenv] [--public-link] [--json] [--non-interactive]
FLAGS
-p, --platform=<option>
Device platform
<options: android|ios>
--android-log-source=<option>...
Android logs to record during the session and upload afterward. Repeat to select both sources; replaces the default
emulator-output selection. Device-wide device-logcat may contain credentials and personal data. Logs are not
redacted and are retained for 90 days.
<options: emulator-output|device-logcat>
--application-archive-url=<value>
Application archive URL to download, install, and launch before the simulator session is ready.
+20
View File
@@ -47380,6 +47380,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "logSources",
"description": "Logs to record during the session and upload afterward: emulator-output and device-logcat. Defaults to\n[\"emulator-output\"]. An empty list disables recording and uploading both sources. Device-wide logcat can contain\ncredentials and personal data; include device-logcat only when you want to retain it.\nLogs are not redacted and use the existing 90-day artifact retention policy.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "systemImagePackage",
"description": "Android SDK system image package to use for the emulator (e.g.\n\"system-images;android-35;google_apis;x86_64\").",
@@ -883,6 +883,39 @@ describe(Simulator, () => {
);
});
it.each([
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('passes explicitly selected Android log sources $sources', async ({ sources }) => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
...sources.flatMap(source => ['--android-log-source', source]),
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ android: { logSources: sources } })
);
});
it('leaves Android log sources omitted unless explicitly requested', async () => {
const { command } = createCommand(['--platform', 'android', '--non-interactive']);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1].android?.logSources).toBeUndefined();
});
it.each([
{ args: ['--platform', 'ios', '--android-log-source', 'device-logcat'] },
{ args: ['--platform', 'android', '--android-log-source', 'unknown'] },
])('rejects invalid Android logging options $args', async ({ args }) => {
const { command } = createCommand([...args, '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('passes --network-capture to the createDeviceRunSession mutation', async () => {
const { command } = createCommand([
'--platform',
@@ -150,6 +150,12 @@ export default class Simulator extends EasCommand {
description:
'Version of the package backing the simulator session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
}),
'android-log-source': Flags.option({
description:
'Android logs to record during the session and upload afterward. Repeat to select both sources; replaces the default emulator-output selection. Device-wide device-logcat may contain credentials and personal data. Logs are not redacted and are retained for 90 days.',
options: ['emulator-output', 'device-logcat'],
multiple: true,
})(),
'network-capture': Flags.boolean({
description:
'Record HTTP(S) traffic from apps on the device (iOS only). HTTPS is decrypted, so recordings contain credentials in cleartext and certificate-pinned apps fail to connect.',
@@ -257,6 +263,7 @@ export default class Simulator extends EasCommand {
const tags = flags.tag?.map(tag => tag.trim()).filter(tag => tag.length > 0);
const deviceIdentifier = flags.device?.trim() || undefined;
const systemImagePackage = flags['system-image']?.trim() || undefined;
const androidLogSources = flags['android-log-source'];
const buildId = flags['build-id']?.trim() || undefined;
const buildFingerprint = flags['build-fingerprint']?.trim() || undefined;
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
@@ -300,6 +307,9 @@ export default class Simulator extends EasCommand {
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined;
if (androidLogSources && platform !== AppPlatform.Android) {
throw new EasCommandError('--android-log-source is only supported with --platform android.');
}
if (systemImagePackage && platform !== AppPlatform.Android) {
throw new EasCommandError('--system-image is only supported with --platform android.');
}
@@ -382,11 +392,12 @@ export default class Simulator extends EasCommand {
? deviceIdentifier
? { ios: { deviceIdentifier } }
: {}
: deviceIdentifier || systemImagePackage
: deviceIdentifier || systemImagePackage || androidLogSources
? {
android: {
...(deviceIdentifier ? { deviceIdentifier } : {}),
...(systemImagePackage ? { systemImagePackage } : {}),
...(androidLogSources ? { logSources: androidLogSources } : {}),
},
}
: {}),
+7
View File
@@ -6622,6 +6622,13 @@ export type CreateAndConfigureRepositoryInput = {
export type CreateAndroidDeviceRunSessionInput = {
/** AVD hardware profile id to use for the emulator (e.g. "pixel_9"). */
deviceIdentifier?: InputMaybe<Scalars['String']['input']>;
/**
* Logs to record during the session and upload afterward: emulator-output and device-logcat. Defaults to
* ["emulator-output"]. An empty list disables recording and uploading both sources. Device-wide logcat can contain
* credentials and personal data; include device-logcat only when you want to retain it.
* Logs are not redacted and use the existing 90-day artifact retention policy.
*/
logSources?: InputMaybe<Array<Scalars['String']['input']>>;
/**
* Android SDK system image package to use for the emulator (e.g.
* "system-images;android-35;google_apis;x86_64").