mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ea392b325 | ||
|
|
ca86f6e361 | ||
|
|
c4920563fb |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Add a cleanup step that uploads Android emulator output and logcat as simulator session artifacts.
|
||||
@@ -1,208 +1,39 @@
|
||||
name: build-and-deploy-worker
|
||||
# Assumes main branch always specifies an environment (staging/production).
|
||||
# If environment is empty: PR testing build (no upload, no deploy)
|
||||
# If environment is set: Build (unless cached on main), upload, and deploy
|
||||
|
||||
name: Build Android worker for manual testing
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: '"staging", "production" or empty'
|
||||
type: string
|
||||
description: Build a Linux artifact for manual staging testing; does not deploy
|
||||
type: choice
|
||||
options:
|
||||
- staging
|
||||
default: staging
|
||||
required: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
environment:
|
||||
description: '"staging", "production" or empty'
|
||||
type: string
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
build-android-staging-test:
|
||||
if: inputs.environment == 'staging'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- uses: ./.github/actions/setup-mise
|
||||
|
||||
- name: Install eas-cli
|
||||
run: npm install --global eas-cli@latest
|
||||
|
||||
- uses: ./.github/internal-actions/setup-gcloud
|
||||
|
||||
- name: Check if worker already exists
|
||||
id: check_worker_exists
|
||||
# Check if tarballs for this SHA already exist
|
||||
# If they do, we can skip building regardless of branch
|
||||
if: inputs.environment != ''
|
||||
run: |
|
||||
for platform in android ios; do
|
||||
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
|
||||
|
||||
if ! gsutil stat "gs://eas-build-worker-tarballs/$GCP_TARBALL"; then
|
||||
echo "worker_exists=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo "worker_exists=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Start EAS Workflow to build worker tarballs
|
||||
id: eas_workflow
|
||||
# Build if: PR testing (no env), or deploying but tarballs don't exist
|
||||
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
|
||||
working-directory: packages/worker
|
||||
- run: yarn install --immutable
|
||||
- name: Package Linux worker from this checkout
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
OUTPUT_DIR: ${{ runner.temp }}/android-worker-staging-test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Only upload artifacts from EAS if GHA will upload them to GCS
|
||||
# This avoids unnecessary artifact uploads for PR builds
|
||||
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
|
||||
|
||||
# Run workflow and capture output (includes run ID)
|
||||
WORKFLOW_OUTPUT=$(eas workflow:run build-worker.yml \
|
||||
--ref ${{ github.sha }} \
|
||||
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
|
||||
--non-interactive \
|
||||
--no-wait \
|
||||
--json)
|
||||
|
||||
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_OUTPUT" | jq -r '.id')
|
||||
if [ -z "$WORKFLOW_RUN_ID" ] || [ "$WORKFLOW_RUN_ID" = "null" ]; then
|
||||
echo "Error: Could not determine workflow run ID"
|
||||
echo "Workflow output:"
|
||||
echo "$WORKFLOW_OUTPUT" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "workflow_run_id=$WORKFLOW_RUN_ID" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Wait for EAS workflow and capture artifacts
|
||||
id: eas_artifacts
|
||||
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
|
||||
working-directory: packages/worker
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
|
||||
if [ -z "$WORKFLOW_RUN_ID" ]; then
|
||||
echo "Error: Missing workflow run ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
|
||||
|
||||
while true; do
|
||||
VIEW_OUTPUT=$(eas workflow:view "$WORKFLOW_RUN_ID" \
|
||||
--non-interactive \
|
||||
--json)
|
||||
STATUS=$(echo "$VIEW_OUTPUT" | jq -r '.status')
|
||||
|
||||
case "$STATUS" in
|
||||
SUCCESS)
|
||||
break
|
||||
;;
|
||||
FAILURE)
|
||||
echo "EAS workflow failed."
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
;;
|
||||
CANCELED)
|
||||
echo "EAS workflow canceled."
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
;;
|
||||
ACTION_REQUIRED)
|
||||
echo "EAS workflow requires action."
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
;;
|
||||
NEW|IN_PROGRESS)
|
||||
sleep 7
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected workflow status: $STATUS"
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Skip artifact extraction if we didn't request uploads
|
||||
if [ "$SHOULD_UPLOAD_ARTIFACT" == "false" ]; then
|
||||
echo "Skipping artifact download - upload was disabled for this run"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract artifact download URLs from workflow output
|
||||
# Artifacts are in jobs[].artifacts[] for custom jobs
|
||||
DOWNLOAD_IOS_URL=$(echo "$VIEW_OUTPUT" | jq -r '
|
||||
.jobs[]
|
||||
| .artifacts[]?
|
||||
| select(.name == "worker-ios-tarball")
|
||||
| .downloadUrl
|
||||
' | head -1)
|
||||
|
||||
DOWNLOAD_ANDROID_URL=$(echo "$VIEW_OUTPUT" | jq -r '
|
||||
.jobs[]
|
||||
| .artifacts[]?
|
||||
| select(.name == "worker-android-tarball")
|
||||
| .downloadUrl
|
||||
' | head -1)
|
||||
|
||||
if [ -z "$DOWNLOAD_IOS_URL" ] || [ "$DOWNLOAD_IOS_URL" = "null" ]; then
|
||||
echo "Error: Could not find iOS artifact download URL"
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$DOWNLOAD_ANDROID_URL" ] || [ "$DOWNLOAD_ANDROID_URL" = "null" ]; then
|
||||
echo "Error: Could not find Android artifact download URL"
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Mask the download URLs
|
||||
echo "::add-mask::$DOWNLOAD_IOS_URL"
|
||||
echo "::add-mask::$DOWNLOAD_ANDROID_URL"
|
||||
echo "download_ios_url=$DOWNLOAD_IOS_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "download_android_url=$DOWNLOAD_ANDROID_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cancel EAS workflow on GitHub cancellation
|
||||
if: cancelled()
|
||||
working-directory: packages/worker
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
|
||||
if [ -n "$WORKFLOW_RUN_ID" ]; then
|
||||
eas workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
|
||||
fi
|
||||
|
||||
- name: Upload worker tarballs to GCP
|
||||
# Upload when deploying if we built something new
|
||||
# Don't upload PR builds (no environment) or when using cached tarballs
|
||||
if: inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false'
|
||||
run: |
|
||||
IOS_GCP_TARBALL="worker-ios-sha1-$GITHUB_SHA.tar.gz"
|
||||
ANDROID_GCP_TARBALL="worker-android-sha1-$GITHUB_SHA.tar.gz"
|
||||
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - "gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL"
|
||||
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - "gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL"
|
||||
|
||||
- name: Promote worker to ${{ inputs.environment }}
|
||||
if: inputs.environment != ''
|
||||
run: |
|
||||
for platform in android ios; do
|
||||
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
|
||||
WORKER_STAGING_TARBALL="worker-$platform-${{ inputs.environment }}.tar.gz"
|
||||
|
||||
gsutil cp "gs://eas-build-worker-tarballs/$GCP_TARBALL" "gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL"
|
||||
done
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
bash packages/worker/package.sh "$OUTPUT_DIR/worker-android.tar.gz" android
|
||||
tar -xOf "$OUTPUT_DIR/worker-android.tar.gz" ./packages/build-tools/dist/steps/utils/androidEmulatorLogs.js > "$OUTPUT_DIR/androidEmulatorLogs.js"
|
||||
cd "$OUTPUT_DIR"
|
||||
sha256sum worker-android.tar.gz androidEmulatorLogs.js > SHA256SUMS
|
||||
printf '%s\n' "$GITHUB_SHA" > build-revision.txt
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: android-worker-staging-test
|
||||
path: ${{ runner.temp }}/android-worker-staging-test/
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
@@ -61,6 +61,7 @@ import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/sta
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
|
||||
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
|
||||
import { createCollectAndroidEmulatorLogsBuildFunction } from './functions/collectAndroidEmulatorLogs';
|
||||
import { createStartSandboxBuildFunction } from './functions/startSandbox';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
@@ -124,6 +125,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
createCollectAndroidEmulatorLogsBuildFunction(ctx),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createRunAgentBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { collectAndroidEmulatorLogsAsync } from '../../utils/androidEmulatorLogs';
|
||||
import { createCollectAndroidEmulatorLogsBuildFunction } from '../collectAndroidEmulatorLogs';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../utils/androidEmulatorLogs');
|
||||
|
||||
const logger = createMockLogger();
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const global = {} as BuildStepContext['global'];
|
||||
const step = { logger, global } as BuildStepContext;
|
||||
const args = { inputs: {}, outputs: {}, env: { DEVICE_RUN_SESSION_ID: 'session' } as BuildStepEnv };
|
||||
|
||||
it('collects logs for its own workflow and session without inputs', async () => {
|
||||
const fn = createCollectAndroidEmulatorLogsBuildFunction(ctx);
|
||||
expect(fn.supportedRuntimePlatforms).toEqual([BuildRuntimePlatform.LINUX]);
|
||||
await fn.fn?.(step, args);
|
||||
expect(collectAndroidEmulatorLogsAsync).toHaveBeenCalledWith(ctx, {
|
||||
global,
|
||||
deviceRunSessionId: 'session',
|
||||
logger,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports collection failure without changing the session result', async () => {
|
||||
jest
|
||||
.mocked(collectAndroidEmulatorLogsAsync)
|
||||
.mockRejectedValueOnce(new Error('collection failed'));
|
||||
await expect(
|
||||
createCollectAndroidEmulatorLogsBuildFunction(ctx).fn?.(step, args)
|
||||
).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not collect Android emulator logs',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import spawn from '@expo/turtle-spawn';
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi';
|
||||
import { registerAndroidEmulatorLogDirectory } from '../../utils/androidEmulatorLogs';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ jest.mock('../../utils/localEgressGuard', () => ({
|
||||
startLocalEgressGuardRelayAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../utils/androidEmulatorLogs');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
@@ -406,6 +408,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(registerAndroidEmulatorLogDirectory).not.toHaveBeenCalled();
|
||||
expect(sessionDevice()).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv: undefined,
|
||||
@@ -424,6 +427,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(registerAndroidEmulatorLogDirectory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
jest.mocked(startAndroidEmulatorAsync).mock.calls[0][0].logcatDirectory
|
||||
);
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceIdentifier: 'pixel_7',
|
||||
@@ -499,6 +506,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
// The step returned only after the download stopped.
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(registerAndroidEmulatorLogDirectory).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
jest.mocked(registerAndroidEmulatorLogDirectory).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(startAndroidEmulatorAsync).mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('stops a stalled download and does not install when the daemon fails', async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import { registerAndroidEmulatorLogDirectory } from '../../utils/androidEmulatorLogs';
|
||||
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -24,6 +25,8 @@ jest.mock('../../utils/androidLocalEgress', () => ({
|
||||
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/androidEmulatorLogs');
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: {
|
||||
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
|
||||
@@ -348,6 +351,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 () => {
|
||||
@@ -360,6 +370,10 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
})
|
||||
);
|
||||
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,34 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
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],
|
||||
fn: async ({ logger, global }, { env }) => {
|
||||
try {
|
||||
await collectAndroidEmulatorLogsAsync(ctx, {
|
||||
global,
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
} 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,7 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
|
||||
import { registerAndroidEmulatorLogDirectory } from '../utils/androidEmulatorLogs';
|
||||
import { withLocalEgressSession } from '../utils/localEgressSession';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
@@ -207,6 +208,7 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
registerAndroidEmulatorLogDirectory(global, logcatDirectory);
|
||||
tasks.signal.throwIfAborted();
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
|
||||
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;
|
||||
@@ -78,10 +79,11 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, outputs, env }) => {
|
||||
fn: async ({ logger, global }, { inputs, outputs, env }) => {
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
registerAndroidEmulatorLogDirectory(global, logcatDirectory);
|
||||
outputs.logcat_directory.set(logcatDirectory);
|
||||
|
||||
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
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): Promise<void> {
|
||||
return collectAndroidEmulatorLogsAsync(ctx, {
|
||||
global,
|
||||
deviceRunSessionId: 'session',
|
||||
logger,
|
||||
maxBytes,
|
||||
});
|
||||
}
|
||||
|
||||
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 latest bytes and records truncation', async () => {
|
||||
await writeFile(emulatorPath, 'old output\ncrash\n');
|
||||
registerAndroidEmulatorLogDirectory(global, directory);
|
||||
await collect(6);
|
||||
expect(uploadedContents.get(path.basename(emulatorPath))).toBe('crash\n');
|
||||
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,149 @@
|
||||
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 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');
|
||||
await writeFile(path.join(directory, 'device-emulator.log'), payload);
|
||||
const global = {} as BuildStepGlobalContext;
|
||||
const logger = createMockLogger();
|
||||
registerAndroidEmulatorLogDirectory(global, directory);
|
||||
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(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,211 @@
|
||||
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 { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
|
||||
const MAX_BYTES_PER_FILE = 20 * 1024 * 1024;
|
||||
const UPLOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
type Collection = {
|
||||
directories: Set<string>;
|
||||
uploaded: Set<string>;
|
||||
collecting?: Promise<void>;
|
||||
};
|
||||
|
||||
// A worker can execute more than one job in its lifetime. Only the workflow that
|
||||
// created a directory may collect it; never discover logs by scanning /tmp.
|
||||
const collections = new WeakMap<BuildStepGlobalContext, Collection>();
|
||||
|
||||
export function registerAndroidEmulatorLogDirectory(
|
||||
global: BuildStepGlobalContext,
|
||||
directory: string
|
||||
): void {
|
||||
let collection = collections.get(global);
|
||||
if (!collection) {
|
||||
collection = { directories: new Set(), uploaded: new Set() };
|
||||
collections.set(global, collection);
|
||||
}
|
||||
collection.directories.add(directory);
|
||||
}
|
||||
|
||||
export async function collectAndroidEmulatorLogsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
global,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxBytes = MAX_BYTES_PER_FILE,
|
||||
}: {
|
||||
global: BuildStepGlobalContext;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
maxBytes?: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
const collection = collections.get(global);
|
||||
if (!collection) {
|
||||
logger.info('No Android emulator log directories registered; skipping upload.');
|
||||
return;
|
||||
}
|
||||
if (collection.collecting) {
|
||||
return await collection.collecting;
|
||||
}
|
||||
collection.collecting = uploadRegisteredLogsAsync(ctx, {
|
||||
collection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxBytes,
|
||||
});
|
||||
try {
|
||||
await collection.collecting;
|
||||
} finally {
|
||||
collection.collecting = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadRegisteredLogsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
collection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxBytes,
|
||||
}: {
|
||||
collection: Collection;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
maxBytes: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
for (const directory of collection.directories) {
|
||||
try {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const match = /-(emulator|logcat)\.log$/.exec(entry.name);
|
||||
if (!entry.isFile() || !match) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(directory, entry.name);
|
||||
if (collection.uploaded.has(filePath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const uploaded = await uploadLogSnapshotAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
filePath,
|
||||
source: match[1] === 'emulator' ? 'emulator-output' : 'device-logcat',
|
||||
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,
|
||||
source,
|
||||
maxBytes,
|
||||
signal,
|
||||
logger,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
filePath: string;
|
||||
source: 'emulator-output' | 'device-logcat';
|
||||
maxBytes: number;
|
||||
signal: AbortSignal;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const file = await open(filePath, 'r');
|
||||
let snapshotDirectory: string | undefined;
|
||||
try {
|
||||
const { size: originalSizeBytes } = await file.stat();
|
||||
if (originalSizeBytes === 0) {
|
||||
return false;
|
||||
}
|
||||
snapshotDirectory = await mkdtemp(path.join(os.tmpdir(), 'eas-android-log-snapshot-'));
|
||||
const filename = path.basename(filePath);
|
||||
const snapshotPath = path.join(snapshotDirectory, filename);
|
||||
const start = Math.max(0, originalSizeBytes - maxBytes);
|
||||
// The emulator may still append. Fix both the range and upload contents now,
|
||||
// so Content-Length and every retry describe the same snapshot.
|
||||
await pipeline(
|
||||
createReadStream(filePath, {
|
||||
fd: file,
|
||||
autoClose: false,
|
||||
start,
|
||||
end: originalSizeBytes - 1,
|
||||
}),
|
||||
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.');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,7 +418,7 @@ export namespace AndroidEmulatorUtils {
|
||||
});
|
||||
}
|
||||
});
|
||||
// Only into the log file -- this process' stdout/stderr is not watched or uploaded.
|
||||
// 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', () => {
|
||||
|
||||
Reference in new issue
Block a user