Compare commits

...
22 changed files with 1337 additions and 256 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.
Compress diagnostic artifacts as complete gzip files after retaining at most the last 100 MiB of each raw log. Artifact metadata reports the original, retained, and compressed sizes and any truncation. Remove raw log files after successful upload.
+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
@@ -61,6 +61,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';
@@ -124,6 +125,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';
@@ -116,13 +117,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 },
@@ -130,7 +138,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: {},
@@ -5,6 +5,7 @@ import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi';
import { registerAndroidEmulatorLogDirectory } from '../../utils/androidEmulatorLogs';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
import {
@@ -32,6 +33,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,6 +110,7 @@ function runStep(
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',
@@ -126,7 +129,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!(
{
@@ -406,6 +417,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(registerAndroidEmulatorLogDirectory).not.toHaveBeenCalled();
expect(sessionDevice()).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv: undefined,
@@ -413,6 +425,27 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
});
it.each([
{ sources: [] },
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('selects $sources before Android boot', async ({ sources }) => {
await runStep(BuildRuntimePlatform.LINUX, { android_log_sources: sources });
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ logSources: sources })
);
});
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',
@@ -424,6 +457,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',
@@ -499,6 +536,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
// 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('stops a stalled download and does not install when the daemon fails', async () => {
@@ -612,6 +653,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,7 @@ import {
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
import { registerAndroidEmulatorLogDirectory } from '../../utils/androidEmulatorLogs';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
@@ -24,6 +25,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 +93,25 @@ 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'));
await createStep({ android_log_sources: sources, count: 2 }).executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(4);
for (const [options] of mockedAndroidUtils.startAsync.mock.calls) {
expect(options.logSources).toEqual(sources);
}
});
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 +133,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 +249,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenNthCalledWith(
@@ -233,6 +257,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
@@ -307,18 +332,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 +376,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 +392,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 { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
import {
AndroidEmulatorLogSourcesSchema,
LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
} from '../../utils/androidEmulatorLogSources';
import { registerAndroidEmulatorLogDirectory } from '../utils/androidEmulatorLogs';
import { withLocalEgressSession } from '../utils/localEgressSession';
import {
createCorsOriginsInputProvider,
@@ -66,6 +71,12 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
name: 'Start agent-device session',
__metricsId: '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',
@@ -204,9 +215,13 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
const booted = iosSimulatorUdid
? undefined
: 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-')
);
registerAndroidEmulatorLogDirectory(global, logcatDirectory);
tasks.signal.throwIfAborted();
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
@@ -216,6 +231,7 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
logSources,
env,
logger: taskLogger,
});
@@ -22,6 +22,12 @@ import {
} from '../../utils/AndroidEmulatorUtils';
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 +39,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 +90,12 @@ 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-')
);
registerAndroidEmulatorLogDirectory(global, logcatDirectory);
outputs.logcat_directory.set(logcatDirectory);
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
@@ -95,6 +109,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
logSources,
env,
logger,
});
@@ -133,6 +148,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
deviceName: cloneIdentifier,
env,
logcatDirectory,
logSources,
});
cloneSerialId = startResult.serialId;
@@ -202,6 +218,7 @@ export async function startAndroidEmulatorAsync({
lcdHeight,
lcdDensity,
logcatDirectory,
logSources,
env,
logger,
}: {
@@ -212,6 +229,7 @@ export async function startAndroidEmulatorAsync({
lcdHeight?: number;
lcdDensity?: number;
logcatDirectory: string;
logSources?: readonly AndroidEmulatorLogSource[];
env: BuildStepEnv;
logger: bunyan;
}): Promise<{
@@ -293,6 +311,7 @@ export async function startAndroidEmulatorAsync({
deviceName,
env,
logcatDirectory,
logSources,
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
});
attemptSerialId = startResult.serialId;
@@ -0,0 +1,406 @@
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 zlib, { gunzipSync } from 'node:zlib';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { type CustomBuildContext } from '../../../customBuildContext';
import {
collectAndroidEmulatorLogsAsync,
registerAndroidEmulatorLogDirectory,
} from '../androidEmulatorLogs';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
jest.mock('../deviceRunSessionArtifacts');
// Keep real compression, with writable exports for injecting a compressor failure.
jest.mock('node:zlib', () => ({ ...jest.requireActual('node:zlib') }));
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, size, metadata, openStream }) => {
const chunks: Buffer[] = [];
for await (const chunk of openStream()) {
chunks.push(Buffer.from(chunk));
}
const compressed = Buffer.concat(chunks);
const retained = gunzipSync(compressed);
expect(compressed.length).toBe(size);
expect(metadata).toEqual(
expect.objectContaining({
compression: 'gzip',
compressedSizeBytes: compressed.length,
retainedSizeBytes: retained.length,
})
);
uploadedContents.set(filename, retained.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)}.gz`]);
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)}.gz`]);
});
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)}.gz`,
`${path.basename(logcatPath)}.gz`,
]);
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.gz',
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.gz',
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 latest bytes and records truncation', async () => {
await writeFile(emulatorPath, 'old output\ncrash\n');
registerAndroidEmulatorLogDirectory(global, directory);
await collect(6);
expect(uploadedContents.get(`${path.basename(emulatorPath)}.gz`)).toBe('crash\n');
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
size: expect.any(Number),
name: expect.stringContaining('truncated, 6 bytes retained'),
metadata: expect.objectContaining({
originalSizeBytes: 17,
retainedSizeBytes: 6,
omittedBytes: 11,
truncated: true,
}),
})
);
await expect(readFile(emulatorPath)).rejects.toMatchObject({ code: 'ENOENT' });
});
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: Buffer[] = [];
upload.mockImplementationOnce(async (_ctx, { size, openStream }) => {
expect(size).toBeGreaterThan(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));
}
const compressed = Buffer.concat(chunks);
expect(compressed.length).toBe(size);
contents.push(compressed);
}
});
try {
await collect();
expect(contents[0]).toEqual(contents[1]);
expect(contents.map(content => gunzipSync(content).toString())).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(await readFile(emulatorPath, 'utf8')).toBe('host output');
await expect(readFile(logcatPath)).rejects.toMatchObject({ code: 'ENOENT' });
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)}.gz`);
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(2);
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)}.gz`)).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)}.gz`)).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)}.gz`)).toBe('Android output');
expect(logger.warn).toHaveBeenCalledWith(
{ err: readError },
expect.stringContaining('other logs will continue')
);
});
it('continues after compression fails without uploading a partial gzip file', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
registerAndroidEmulatorLogDirectory(global, directory);
const createGzip = zlib.createGzip;
const compressionError = new Error('compression failed');
const compress = jest.spyOn(zlib, 'createGzip').mockImplementationOnce(options => {
const gzip = createGzip(options);
gzip.destroy(compressionError);
return gzip;
});
try {
await expect(collect()).resolves.toBeUndefined();
expect(await readFile(emulatorPath, 'utf8')).toBe('host output');
expect(uploadedContents.get(`${path.basename(logcatPath)}.gz`)).toBe('Android output');
expect(upload).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: compressionError },
expect.stringContaining('other logs will continue')
);
} finally {
compress.mockRestore();
}
});
it('does not upload an empty gzip file when the source becomes empty before reading', async () => {
await writeFile(emulatorPath, 'before');
registerAndroidEmulatorLogDirectory(global, directory);
const createReadStream = fs.createReadStream;
const read = jest.spyOn(fs, 'createReadStream').mockImplementationOnce((filePath, options) => {
fs.truncateSync(emulatorPath);
return createReadStream(filePath, options);
});
try {
await expect(collect()).resolves.toBeUndefined();
expect(upload).not.toHaveBeenCalled();
} finally {
read.mockRestore();
}
});
it('keeps successful uploads deduplicated if removing the raw log fails', async () => {
await writeFile(emulatorPath, 'host output');
registerAndroidEmulatorLogDirectory(global, directory);
const remove = fsPromises.rm;
const cleanupError = new Error('raw cleanup failed');
const cleanup = jest.spyOn(fsPromises, 'rm').mockImplementation(async (filePath, options) => {
if (filePath === emulatorPath) {
throw cleanupError;
}
await remove(filePath, options);
});
try {
await expect(collect()).resolves.toBeUndefined();
await collect();
expect(upload).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: cleanupError },
expect.stringContaining('Could not remove uploaded Android log')
);
} finally {
cleanup.mockRestore();
}
});
@@ -0,0 +1,159 @@
import { type BuildStepGlobalContext } from '@expo/steps';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { gunzipSync } from 'node:zlib';
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 complete gzip of the capped raw log after an early storage error', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-upload-test-'));
const maximumBytes = 100 * 1024 * 1024;
// Incompressible input ensures compression can grow beyond the raw cap without
// truncating the gzip stream, and keeps the early HTTP failure meaningful.
const payload = randomBytes(maximumBytes + 6);
await writeFile(path.join(directory, 'device-emulator.log'), payload);
const global = {} as BuildStepGlobalContext;
const logger = createMockLogger();
registerAndroidEmulatorLogDirectory(global, directory);
let requests = 0;
const receivedChunks: Buffer[] = [];
const server = createServer((request, response) => {
requests++;
if (requests === 1) {
response.statusCode = 503;
response.flushHeaders();
return;
}
request.on('data', chunk => {
receivedChunks.push(Buffer.from(chunk));
});
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(async ({ size }: { size: number }) => ({
url: `http://127.0.0.1:${address.port}/log`,
headers: { 'Content-Length': String(size) },
}));
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);
const received = Buffer.concat(receivedChunks);
expect(received.length).toBeGreaterThan(maximumBytes);
expect(gunzipSync(received).equals(payload.subarray(6))).toBe(true);
expect(createArtifactUploadSessionAsync).toHaveBeenCalledTimes(1);
expect(createArtifactUploadSessionAsync).toHaveBeenCalledWith(
expect.objectContaining({
name: expect.stringContaining('truncated, 100 MiB retained'),
filename: 'device-emulator.log.gz',
size: received.length,
metadata: expect.objectContaining({
originalSizeBytes: payload.length,
retainedSizeBytes: maximumBytes,
compressedSizeBytes: received.length,
compression: 'gzip',
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 });
}
}, 30_000);
@@ -0,0 +1,238 @@
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 { createGzip } from 'node:zlib';
import { type CustomBuildContext } from '../../customBuildContext';
import { type AndroidEmulatorLogSource } from '../../utils/androidEmulatorLogSources';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
const MAX_UNCOMPRESSED_BYTES_PER_FILE = 100 * 1024 * 1024;
const UPLOAD_TIMEOUT_MS = 30_000;
type Collection = {
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
): void {
let collection = collections.get(global);
if (!collection) {
collection = { directories: new Set(), uploaded: new Set() };
collections.set(global, collection);
}
collection.directories.add(directory);
}
export async function collectAndroidEmulatorLogsAsync(
ctx: CustomBuildContext,
{
global,
deviceRunSessionId,
logger,
maxBytes = MAX_UNCOMPRESSED_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> {
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,
source,
maxBytes,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
logger,
});
if (uploaded) {
collection.uploaded.add(filePath);
logger.info(`Uploaded Android diagnostic log ${entry.name}.`);
// The uploaded snapshot is final for this capture. Unlink the raw file
// only after success; an open emulator writer releases its storage on close.
await rm(filePath, { force: true }).catch(err => {
logger.warn({ err }, `Could not remove uploaded Android 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,
source,
maxBytes,
signal,
logger,
}: {
deviceRunSessionId: string;
filePath: string;
source: AndroidEmulatorLogSource;
maxBytes: number;
signal: AbortSignal;
logger: bunyan;
}
): Promise<boolean> {
const file = await open(filePath, 'r');
let snapshotDirectory: string | undefined;
try {
const { size: originalSizeBytes } = await file.stat();
if (originalSizeBytes === 0) {
return false;
}
snapshotDirectory = await mkdtemp(path.join(os.tmpdir(), 'eas-android-log-snapshot-'));
const originalFilename = path.basename(filePath);
const filename = `${originalFilename}.gz`;
const snapshotPath = path.join(snapshotDirectory, filename);
const start = Math.max(0, originalSizeBytes - maxBytes);
// The emulator may still append. Fix both the range and upload contents now,
// then gzip the retained raw bytes in full. Never truncate compressed output;
// Content-Length and every retry must describe the same complete gzip file.
const gzip = createGzip({ level: 6 });
await pipeline(
createReadStream(filePath, {
fd: file,
autoClose: false,
start,
end: originalSizeBytes - 1,
}),
gzip,
createWriteStream(snapshotPath),
{ signal }
);
const { size } = await stat(snapshotPath);
const retainedSizeBytes = gzip.bytesWritten;
if (retainedSizeBytes === 0) {
return false;
}
const readController = new AbortController();
const retainedSize =
retainedSizeBytes >= 1024 * 1024
? `${Number((retainedSizeBytes / (1024 * 1024)).toFixed(2))} MiB`
: `${retainedSizeBytes} bytes`;
const truncationNote =
retainedSizeBytes < originalSizeBytes ? `; truncated, ${retainedSize} retained` : '';
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: filename,
name: `${source === 'emulator-output' ? 'Emulator output' : 'Android device logcat'} (${originalFilename.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,
retainedSizeBytes,
compressedSizeBytes: size,
compression: 'gzip',
omittedBytes: originalSizeBytes - retainedSizeBytes,
truncated: retainedSizeBytes < 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,10 @@ 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 { retryAsync } from './retry';
/** Android Virtual Device is the device we run. */
@@ -326,20 +330,22 @@ export namespace AndroidEmulatorUtils {
deviceName,
env,
logcatDirectory,
logSources = LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
launchGate,
}: {
deviceName: AndroidVirtualDeviceName;
env: NodeJS.ProcessEnv;
logcatDirectory: string;
logSources?: readonly AndroidEmulatorLogSource[];
launchGate?: AndroidEmulatorLaunchGate;
}): 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,10 +353,14 @@ 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,
@@ -364,10 +374,7 @@ export namespace AndroidEmulatorUtils {
'-writable-system',
'-noaudio',
'-no-snapshot-save',
'-logcat',
'*:v',
'-logcat-output',
logcatOutputPath,
...(logcatOutputPath ? ['-logcat', '*:v', '-logcat-output', logcatOutputPath] : []),
'-avd',
deviceName,
'-accel',
@@ -381,49 +388,52 @@ 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,
},
});
}
});
// 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();
});
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,7 @@ import {
AndroidVirtualDeviceName,
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
import { AndroidEmulatorLogSourcesSchema } from '../androidEmulatorLogSources';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
@@ -221,8 +222,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 +267,81 @@ 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('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,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").