mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dbe0c5ca6 | ||
|
|
5fbd4a14d6 | ||
|
|
f7ed53638b | ||
|
|
adc60f0de8 | ||
|
|
83e4abb9fd | ||
|
|
16aebf7b1f | ||
|
|
929637b6bd | ||
|
|
2615c12d58 | ||
|
|
ac00309eec | ||
|
|
ca86f6e361 | ||
|
|
c4920563fb |
No files matched your search
@@ -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.
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
+10
-2
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Generated
+20
@@ -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
@@ -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").
|
||||
|
||||
Reference in new issue
Block a user