Compare commits

...
24 changed files with 1661 additions and 257 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 { registerAndroidEmulatorLogDirectory } 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(registerAndroidEmulatorLogDirectory).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(registerAndroidEmulatorLogDirectory).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,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(registerAndroidEmulatorLogDirectory).toHaveBeenCalledWith(
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX }),
jest.mocked(startAndroidEmulatorAsync).mock.calls[0][0].logcatDirectory
);
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceIdentifier: 'pixel_7',
@@ -470,6 +520,24 @@ 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' })).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(registerAndroidEmulatorLogDirectory).toHaveBeenCalledTimes(1);
expect(
jest.mocked(registerAndroidEmulatorLogDirectory).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 { registerAndroidEmulatorLogDirectory } 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(registerAndroidEmulatorLogDirectory).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'],
})
);
@@ -348,6 +383,13 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(3);
expect(registerAndroidEmulatorLogDirectory).toHaveBeenCalledWith(
expect.anything(),
'/tmp/logcat-directory'
);
expect(
jest.mocked(registerAndroidEmulatorLogDirectory).mock.invocationCallOrder[0]
).toBeLessThan(mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]);
});
it('outputs and uses a temporary logcat directory', async () => {
@@ -357,9 +399,14 @@ 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(registerAndroidEmulatorLogDirectory).toHaveBeenCalledWith(
step.ctx.global,
'/tmp/logcat-directory'
);
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 { registerAndroidEmulatorLogDirectory } 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,9 +252,13 @@ 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-')
);
const capture = registerAndroidEmulatorLogDirectory(global, logcatDirectory);
tasks.signal.throwIfAborted();
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
@@ -253,6 +268,8 @@ 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 ? capture : 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 { registerAndroidEmulatorLogDirectory } 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,13 @@ 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 capture = registerAndroidEmulatorLogDirectory(global, logcatDirectory);
const logCapture = env.DEVICE_RUN_SESSION_ID ? capture : undefined;
outputs.logcat_directory.set(logcatDirectory);
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
@@ -95,6 +111,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 +151,8 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
deviceName: cloneIdentifier,
env,
logcatDirectory,
logSources,
logCapture,
});
cloneSerialId = startResult.serialId;
@@ -202,6 +222,8 @@ export async function startAndroidEmulatorAsync({
lcdHeight,
lcdDensity,
logcatDirectory,
logSources,
logCapture,
env,
logger,
}: {
@@ -212,6 +234,8 @@ export async function startAndroidEmulatorAsync({
lcdHeight?: number;
lcdDensity?: number;
logcatDirectory: string;
logSources?: readonly AndroidEmulatorLogSource[];
logCapture?: AndroidEmulatorLogCapture;
env: BuildStepEnv;
logger: bunyan;
}): Promise<{
@@ -293,6 +317,8 @@ export async function startAndroidEmulatorAsync({
deviceName,
env,
logcatDirectory,
logSources,
logCapture,
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
});
attemptSerialId = startResult.serialId;
@@ -0,0 +1,322 @@
import { type AndroidEmulatorLogSource } from '../../../utils/androidEmulatorLogSources';
import { type BuildStepGlobalContext } from '@expo/steps';
import fs from 'node:fs';
import fsPromises, { mkdir, readFile, readdir, symlink, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { type CustomBuildContext } from '../../../customBuildContext';
import {
collectAndroidEmulatorLogsAsync,
registerAndroidEmulatorLogDirectory,
} from '../androidEmulatorLogs';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
jest.mock('../deviceRunSessionArtifacts');
const ctx = {} as CustomBuildContext;
const directory = path.join(os.tmpdir(), 'android-logs');
const emulatorPath = path.join(directory, 'device-attempt-1-emulator.log');
const logcatPath = path.join(directory, 'device-attempt-1-logcat.log');
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
let global: BuildStepGlobalContext;
let logger: ReturnType<typeof createMockLogger>;
let uploadedContents: Map<string, string>;
beforeEach(async () => {
global = {} as BuildStepGlobalContext;
logger = createMockLogger();
uploadedContents = new Map();
await mkdir(directory, { recursive: true });
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());
});
});
function collect(
maxBytes?: number,
sources: readonly AndroidEmulatorLogSource[] = ['emulator-output', 'device-logcat']
): Promise<void> {
return collectAndroidEmulatorLogsAsync(ctx, {
global,
deviceRunSessionId: 'session',
logger,
maxBytes,
sources,
});
}
it('uploads only emulator output when sources are omitted', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'sensitive device output');
registerAndroidEmulatorLogDirectory(global, directory);
const open = jest.spyOn(fsPromises, 'open');
try {
await collectAndroidEmulatorLogsAsync(ctx, { global, deviceRunSessionId: 'session', logger });
expect([...uploadedContents.keys()]).toEqual([path.basename(emulatorPath)]);
expect(open).not.toHaveBeenCalledWith(logcatPath, expect.anything());
} finally {
open.mockRestore();
}
});
it('uploads only explicitly selected logcat and uploads nothing for an empty selection', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'device output');
registerAndroidEmulatorLogDirectory(global, directory);
await collect(undefined, []);
expect(upload).not.toHaveBeenCalled();
await collect(undefined, ['device-logcat']);
expect([...uploadedContents.keys()]).toEqual([path.basename(logcatPath)]);
});
it('honors different source selections during concurrent cleanup without duplicate uploads', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'device output');
registerAndroidEmulatorLogDirectory(global, directory);
await Promise.all([
collect(undefined, ['emulator-output']),
collect(undefined, ['emulator-output', 'device-logcat']),
]);
expect([...uploadedContents.keys()].sort()).toEqual([
path.basename(emulatorPath),
path.basename(logcatPath),
]);
expect(upload).toHaveBeenCalledTimes(2);
});
it('uploads both logs for every attempt from registered directories only', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
await writeFile(path.join(directory, 'device-attempt-2-emulator.log'), 'retry output');
await writeFile(path.join(directory, 'unrelated.txt'), 'not a log');
const elsewhere = path.join(os.tmpdir(), 'unregistered');
await mkdir(elsewhere);
await writeFile(path.join(elsewhere, 'other-emulator.log'), 'another job');
await symlink(
path.join(elsewhere, 'other-emulator.log'),
path.join(directory, 'link-logcat.log')
);
registerAndroidEmulatorLogDirectory(global, directory);
await collect();
expect([...uploadedContents.values()]).toEqual(['host output', 'Android output', 'retry output']);
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
filename: 'device-attempt-1-emulator.log',
kind: 'simulator-log',
metadata: expect.objectContaining({
platform: 'android',
source: 'emulator-output',
scope: 'emulator-process',
truncated: false,
}),
signal: expect.any(AbortSignal),
attempts: 2,
})
);
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
filename: 'device-attempt-1-logcat.log',
metadata: expect.objectContaining({ source: 'device-logcat', scope: 'device' }),
})
);
expect(await readdir(os.tmpdir())).not.toEqual(
expect.arrayContaining([expect.stringMatching(/^eas-android-log-snapshot-/)])
);
});
it('keeps the earliest bytes and records truncation', async () => {
await writeFile(emulatorPath, 'old output\ncrash\n');
registerAndroidEmulatorLogDirectory(global, directory);
await collect(6);
expect(uploadedContents.get(path.basename(emulatorPath))).toBe('old ou');
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
size: 6,
name: expect.stringContaining('truncated, 6 bytes retained'),
metadata: expect.objectContaining({
originalSizeBytes: 17,
omittedBytes: 11,
truncated: true,
}),
})
);
expect(await readFile(emulatorPath, 'utf8')).toBe('old output\ncrash\n');
});
it('fixes the snapshot range before append and keeps upload retries identical', async () => {
await writeFile(emulatorPath, 'before');
registerAndroidEmulatorLogDirectory(global, directory);
const createReadStream = fs.createReadStream;
const read = jest.spyOn(fs, 'createReadStream').mockImplementationOnce((filePath, options) => {
fs.appendFileSync(emulatorPath, ' during snapshot');
return createReadStream(filePath, options);
});
const contents: string[] = [];
upload.mockImplementationOnce(async (_ctx, { size, openStream }) => {
expect(size).toBe(6);
for (let attempt = 0; attempt < 2; attempt++) {
await writeFile(emulatorPath, 'rewritten after snapshot');
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']);
} finally {
read.mockRestore();
}
});
it('continues after a failed upload and retries only unsuccessful files on another collection', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
registerAndroidEmulatorLogDirectory(global, directory);
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 concurrent collection and skips successful uploads on repeated cleanup', async () => {
await writeFile(emulatorPath, 'host output');
registerAndroidEmulatorLogDirectory(global, directory);
registerAndroidEmulatorLogDirectory(global, directory);
await Promise.all([collect(), collect()]);
await collect();
expect(upload).toHaveBeenCalledTimes(1);
});
it('keeps successful uploads deduplicated when snapshot cleanup fails', async () => {
await writeFile(emulatorPath, 'host output');
registerAndroidEmulatorLogDirectory(global, directory);
const open = fsPromises.open;
const openFile = jest.spyOn(fsPromises, 'open').mockImplementationOnce(async (...args) => {
const file = await open(...args);
const close = file.close.bind(file);
file.close = async () => {
await close();
throw new Error('close failed');
};
return file;
});
const remove = jest.spyOn(fsPromises, 'rm').mockRejectedValueOnce(new Error('remove failed'));
try {
await expect(collect()).resolves.toBeUndefined();
await collect();
expect(upload).toHaveBeenCalledTimes(1);
expect(remove).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('Could not close')
);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('Could not remove')
);
} finally {
openFile.mockRestore();
remove.mockRestore();
}
});
it('uses a fresh 30 second budget for the next file after an upload times out', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
registerAndroidEmulatorLogDirectory(global, directory);
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();
});
try {
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);
} finally {
timeout.mockRestore();
}
});
it('does not collect another workflow or empty files and tolerates missing directories', async () => {
registerAndroidEmulatorLogDirectory({} as BuildStepGlobalContext, directory);
await writeFile(emulatorPath, 'other workflow');
await collect();
expect(upload).not.toHaveBeenCalled();
await writeFile(emulatorPath, '');
registerAndroidEmulatorLogDirectory(global, path.join(os.tmpdir(), 'missing'));
registerAndroidEmulatorLogDirectory(global, directory);
await expect(collect()).resolves.toBeUndefined();
expect(upload).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('Could not read')
);
});
it('handles a snapshot read failure and still uploads the other log', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
registerAndroidEmulatorLogDirectory(global, directory);
const createReadStream = fs.createReadStream;
const read = jest.spyOn(fs, 'createReadStream').mockImplementationOnce((filePath, options) => {
const stream = createReadStream(filePath, options);
stream.destroy(new Error('source read failed'));
return stream;
});
try {
await expect(collect()).resolves.toBeUndefined();
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
expect(upload).toHaveBeenCalledTimes(1);
} finally {
read.mockRestore();
}
});
it('aborts an upload on a genuine snapshot file read error and continues with the other log', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
registerAndroidEmulatorLogDirectory(global, directory);
const readError = Object.assign(new Error('snapshot 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,155 @@
import { type BuildStepGlobalContext } from '@expo/steps';
import fs from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { Readable } from 'node:stream';
import os from 'node:os';
import path from 'node:path';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { type CustomBuildContext } from '../../../customBuildContext';
import {
collectAndroidEmulatorLogsAsync,
registerAndroidEmulatorLogDirectory,
} 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('does not close a reused descriptor after a snapshot read failure', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-descriptor-test-'));
const unrelatedPath = path.join(directory, 'unrelated.txt');
const readError = Object.assign(new Error('source read failed'), { code: 'EIO' });
const createReadStream = fs.createReadStream;
let sourceFd: number | undefined;
let reusedFd: number | undefined;
const read = jest.spyOn(fs, 'createReadStream').mockImplementationOnce((filePath, options) => {
if (!options || typeof options === 'string' || options.fd == null) {
throw new Error('Expected an open source file');
}
sourceFd = typeof options.fd === 'number' ? options.fd : options.fd.fd;
const stream = createReadStream(filePath, options);
stream.once('close', () => {
// Reuse the descriptor before the collector's final FileHandle.close().
reusedFd = fs.openSync(unrelatedPath, 'w');
});
stream.destroy(readError);
return stream;
});
try {
await writeFile(path.join(directory, 'device-emulator.log'), 'host output');
const global = createGlobalContextMock();
const logger = createMockLogger();
registerAndroidEmulatorLogDirectory(global, directory);
await collectAndroidEmulatorLogsAsync(createCustomBuildContextMock(), {
global,
deviceRunSessionId: 'session',
logger,
});
expect(reusedFd).toBe(sourceFd);
if (reusedFd === undefined) {
throw new Error('Expected the source descriptor to be reused');
}
expect(fs.writeSync(reusedFd, 'still open')).toBe(10);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: readError },
expect.stringContaining('other logs will continue')
);
expect(requireDeviceRunSessionWorkerApi).not.toHaveBeenCalled();
} finally {
read.mockRestore();
if (reusedFd !== undefined) {
try {
fs.closeSync(reusedFd);
} catch {
// The regression closes this descriptor before the test can clean it up.
}
}
await rm(directory, { recursive: true, force: true });
}
});
it('retries a capped log after an early storage error with real file streams and 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 = registerAndroidEmulatorLogDirectory(global, directory);
const input = Readable.from([payload]);
capture.attach(input, filePath, 'emulator-output');
await once(input, 'end');
let requests = 0;
let receivedBytes = 0;
const server = createServer((request, response) => {
requests++;
if (requests === 1) {
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(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 {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
await rm(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,236 @@
import { type bunyan } from '@expo/logger';
import { type BuildStepGlobalContext } from '@expo/steps';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdtemp, open, readdir, rm, stat } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { type CustomBuildContext } from '../../customBuildContext';
import {
AndroidEmulatorLogCapture,
MAX_ANDROID_LOG_BYTES_PER_SOURCE,
} from '../../utils/androidEmulatorLogCapture';
import { type AndroidEmulatorLogSource } from '../../utils/androidEmulatorLogSources';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
const MAX_BYTES_PER_FILE = MAX_ANDROID_LOG_BYTES_PER_SOURCE;
const UPLOAD_TIMEOUT_MS = 30_000;
type Collection = {
capture: AndroidEmulatorLogCapture;
directories: Set<string>;
uploaded: Set<string>;
collecting?: Promise<void>;
};
// A worker can execute more than one job in its lifetime. Only the workflow that
// created a directory may collect it; never discover logs by scanning /tmp.
const collections = new WeakMap<BuildStepGlobalContext, Collection>();
export function registerAndroidEmulatorLogDirectory(
global: BuildStepGlobalContext,
directory: string
): AndroidEmulatorLogCapture {
let collection = collections.get(global);
if (!collection) {
collection = {
directories: new Set(),
uploaded: new Set(),
capture: new AndroidEmulatorLogCapture(),
};
collections.set(global, collection);
}
collection.directories.add(directory);
return collection.capture;
}
export async function collectAndroidEmulatorLogsAsync(
ctx: CustomBuildContext,
{
global,
deviceRunSessionId,
logger,
maxBytes = MAX_BYTES_PER_FILE,
sources = ['emulator-output'],
}: {
global: BuildStepGlobalContext;
deviceRunSessionId: string;
logger: bunyan;
maxBytes?: number;
sources?: readonly AndroidEmulatorLogSource[];
}
): Promise<void> {
const collection = collections.get(global);
if (!collection) {
logger.info('No Android emulator log directories registered; skipping upload.');
return;
}
while (collection.collecting) {
await collection.collecting;
}
collection.collecting = uploadRegisteredLogsAsync(ctx, {
collection,
deviceRunSessionId,
logger,
maxBytes,
sources,
});
try {
await collection.collecting;
} finally {
collection.collecting = undefined;
}
}
async function uploadRegisteredLogsAsync(
ctx: CustomBuildContext,
{
collection,
deviceRunSessionId,
logger,
maxBytes,
sources,
}: {
collection: Collection;
deviceRunSessionId: string;
logger: bunyan;
maxBytes: number;
sources: readonly AndroidEmulatorLogSource[];
}
): Promise<void> {
await collection.capture.finishAsync();
for (const directory of collection.directories) {
try {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const match = /-(emulator|logcat)\.log$/.exec(entry.name);
if (!entry.isFile() || !match) {
continue;
}
const source = match[1] === 'emulator' ? 'emulator-output' : 'device-logcat';
if (!sources.includes(source)) {
continue;
}
const filePath = path.join(directory, entry.name);
if (collection.uploaded.has(filePath)) {
continue;
}
try {
const uploaded = await uploadLogSnapshotAsync(ctx, {
deviceRunSessionId,
filePath,
bytesSeen: collection.capture.getBytesSeen(filePath),
source,
maxBytes,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
logger,
});
if (uploaded) {
collection.uploaded.add(filePath);
logger.info(`Uploaded Android diagnostic log ${entry.name}.`);
}
} catch (err) {
logger.warn(
{ err },
`Could not upload Android log ${entry.name}; other logs will continue.`
);
}
}
} catch (err) {
logger.warn({ err }, `Could not read Android emulator log directory ${directory}.`);
}
}
}
async function uploadLogSnapshotAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
filePath,
bytesSeen,
source,
maxBytes,
signal,
logger,
}: {
deviceRunSessionId: string;
filePath: string;
bytesSeen?: number;
source: AndroidEmulatorLogSource;
maxBytes: number;
signal: AbortSignal;
logger: bunyan;
}
): Promise<boolean> {
const file = await open(filePath, 'r');
let snapshotDirectory: string | undefined;
try {
const { size: fileSizeBytes } = await file.stat();
const originalSizeBytes = bytesSeen ?? fileSizeBytes;
if (fileSizeBytes === 0) {
return false;
}
snapshotDirectory = await mkdtemp(path.join(os.tmpdir(), 'eas-android-log-snapshot-'));
const filename = path.basename(filePath);
const snapshotPath = path.join(snapshotDirectory, filename);
const end = Math.min(fileSizeBytes, maxBytes) - 1;
// The emulator may still append. Fix both the range and upload contents now,
// so Content-Length and every retry describe the same snapshot.
await pipeline(
createReadStream(filePath, {
fd: file,
autoClose: false,
start: 0,
end,
}),
createWriteStream(snapshotPath),
{ signal }
);
const { size } = await stat(snapshotPath);
if (size === 0) {
return false;
}
const readController = new AbortController();
const retainedSize =
size >= 1024 * 1024 ? `${Number((size / (1024 * 1024)).toFixed(2))} MiB` : `${size} bytes`;
const truncationNote = size < originalSizeBytes ? `; 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,
omittedBytes: originalSizeBytes - size,
truncated: size < originalSizeBytes,
},
size,
openStream: () =>
createReadStream(snapshotPath).on('error', err => {
// The uploader aborts an unfinished body after an HTTP error. That
// cancels one attempt, not the file's remaining upload attempts.
if (err.name !== 'AbortError') {
readController.abort(err);
}
}),
signal: AbortSignal.any([signal, readController.signal]),
attempts: 2,
});
return true;
} finally {
await file.close().catch(err => {
logger.warn({ err }, `Could not close Android log ${filePath}.`);
});
if (snapshotDirectory) {
await rm(snapshotDirectory, { recursive: true, force: true }).catch(err => {
logger.warn({ err }, 'Could not remove the Android log upload snapshot.');
});
}
}
}
@@ -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,145 @@
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');
await capture.finishAsync();
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');
expect(capture.getBytesSeen(retry)).toBe(22);
});
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;
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(capture.getBytesSeen(file)).toBe(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,166 @@
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;
type Capture = {
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 = { 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<void> {
this.finishing = true;
await Promise.all([...this.captures.keys()].map(file => this.finishFileAsync(file)));
}
getBytesSeen(filePath: string): number | undefined {
return this.captures.get(filePath)?.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").