mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
560e59e301 |
No files matched your search
@@ -5,4 +5,4 @@
|
||||
|
||||
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 last 20 MiB of each selected Android log source across launches and retries. Each log file is a fixed-size ring on disk, so the end of the final attempt is always kept; older output is dropped first, and artifacts remain uncompressed.
|
||||
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
|
||||
+29
-1
@@ -24,7 +24,7 @@ function createStep(callInputs: Record<string, unknown> = {}) {
|
||||
return { ctx, global, logger, fn, step };
|
||||
}
|
||||
|
||||
it('collects captured logs without a second source selection', async () => {
|
||||
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();
|
||||
@@ -32,9 +32,37 @@ it('collects captured logs without a second source selection', async () => {
|
||||
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)
|
||||
|
||||
+53
-37
@@ -116,21 +116,46 @@ function runStep(
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx, { warm });
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const global = createGlobalContextMock({
|
||||
runtimePlatform,
|
||||
staticContextContent: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
});
|
||||
const inputs = Object.fromEntries(
|
||||
(buildFunction.inputProviders ?? []).map(provider => {
|
||||
const input = provider(global, 'test');
|
||||
input.set(inputValues[input.id]);
|
||||
return [input.id, { value: input.rawValue }];
|
||||
})
|
||||
[
|
||||
'android_log_sources',
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
'lcd_height',
|
||||
'lcd_density',
|
||||
'install_app_path',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
].map(id => [
|
||||
id,
|
||||
{
|
||||
value:
|
||||
id === 'android_log_sources' && !(id in inputValues)
|
||||
? ['emulator-output', 'device-logcat']
|
||||
: inputValues[id],
|
||||
},
|
||||
])
|
||||
);
|
||||
return buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global,
|
||||
global: {
|
||||
runtimePlatform,
|
||||
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
},
|
||||
} as unknown as BuildStepContext,
|
||||
{ inputs, outputs: {}, env, signal } as never
|
||||
) as Promise<void>;
|
||||
@@ -161,11 +186,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(getAndroidEmulatorLogCapture)
|
||||
.mockImplementation(
|
||||
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
|
||||
);
|
||||
deviceLaunch = undefined;
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
|
||||
@@ -438,46 +458,36 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults an omitted session log input to emulator output through the step boundary', async () => {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
|
||||
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
|
||||
const step = buildFunction.createBuildStepFromFunctionCall(global, {
|
||||
callInputs: {},
|
||||
env: { DEVICE_RUN_SESSION_ID: 'session' },
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ logSources: ['emulator-output'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes an explicit empty source list and the workflow capture to Android boot', async () => {
|
||||
it.each([
|
||||
{ sources: [] },
|
||||
{ sources: ['emulator-output'] },
|
||||
{ sources: ['device-logcat'] },
|
||||
{ sources: ['emulator-output', 'device-logcat'] },
|
||||
])('selects $sources before Android boot', async ({ sources }) => {
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'session' };
|
||||
await runStep(
|
||||
BuildRuntimePlatform.LINUX,
|
||||
{ android_log_sources: [] },
|
||||
{ android_log_sources: sources },
|
||||
undefined,
|
||||
ctx,
|
||||
false,
|
||||
env
|
||||
{
|
||||
DEVICE_RUN_SESSION_ID: 'session',
|
||||
}
|
||||
);
|
||||
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything(), env);
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ logSources: [], logCapture: capture })
|
||||
expect.objectContaining({ logSources: sources, logCapture: capture })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid Android log sources before boot or downloading', async () => {
|
||||
it('rejects invalid Android log sources before boot', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, {
|
||||
android_log_sources: ['unknown'],
|
||||
build_id: 'build-id',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('boots the Android Emulator with the device inputs', async () => {
|
||||
@@ -491,6 +501,8 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(startAndroidEmulatorAsync).mock.calls[0][0].logCapture).toBeUndefined();
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceIdentifier: 'pixel_7',
|
||||
@@ -520,6 +532,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
// The step returned only after the download stopped.
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(getAndroidEmulatorLogCapture).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(startAndroidEmulatorAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('does not start work for an already cancelled step', async () => {
|
||||
|
||||
@@ -66,11 +66,6 @@ function createStartResult(serialId: string) {
|
||||
|
||||
describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(getAndroidEmulatorLogCapture)
|
||||
.mockImplementation(
|
||||
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
|
||||
);
|
||||
mockedMkdtemp.mockResolvedValue('/tmp/logcat-directory');
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
@@ -99,21 +94,22 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedMkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
it('preserves an explicit empty source list and one capture through boot retries and clones', async () => {
|
||||
it.each([
|
||||
{ sources: [] },
|
||||
{ sources: ['emulator-output'] },
|
||||
{ sources: ['device-logcat'] },
|
||||
{ sources: ['emulator-output', 'device-logcat'] },
|
||||
])('preserves $sources through boot retries and clones', async ({ sources }) => {
|
||||
mockedAndroidUtils.waitForReadyAsync.mockRejectedValueOnce(new Error('retry boot'));
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
|
||||
await createStep(
|
||||
{ android_log_sources: [], count: 2 },
|
||||
{ android_log_sources: sources, count: 2 },
|
||||
{ DEVICE_RUN_SESSION_ID: 'session' }
|
||||
).executeAsync();
|
||||
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session' })
|
||||
);
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(4);
|
||||
for (const [options] of mockedAndroidUtils.startAsync.mock.calls) {
|
||||
expect(options.logSources).toEqual([]);
|
||||
expect(options.logSources).toEqual(sources);
|
||||
expect(options.logCapture).toBe(capture);
|
||||
}
|
||||
});
|
||||
@@ -145,7 +141,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
env: expect.any(Object),
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
logSources: ['emulator-output', 'device-logcat'],
|
||||
logCapture: expect.any(AndroidEmulatorLogCapture),
|
||||
});
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
|
||||
env: expect.any(Object),
|
||||
@@ -261,6 +256,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
expect.objectContaining({
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
logSources: ['emulator-output', 'device-logcat'],
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenNthCalledWith(
|
||||
@@ -268,6 +264,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
expect.objectContaining({
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
logSources: ['emulator-output', 'device-logcat'],
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
@@ -342,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'],
|
||||
})
|
||||
);
|
||||
|
||||
@@ -372,7 +372,9 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
.mockResolvedValueOnce(createStartResult('emulator-attempt-3'));
|
||||
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
|
||||
|
||||
await expect(createStep().executeAsync()).rejects.toThrow('network unavailable');
|
||||
await expect(
|
||||
createStep(undefined, { DEVICE_RUN_SESSION_ID: 'session' }).executeAsync()
|
||||
).rejects.toThrow('network unavailable');
|
||||
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
@@ -383,6 +385,10 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(3);
|
||||
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything());
|
||||
expect(jest.mocked(getAndroidEmulatorLogCapture).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('outputs and uses a temporary logcat directory', async () => {
|
||||
@@ -392,9 +398,12 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
logSources: ['emulator-output', 'device-logcat'],
|
||||
})
|
||||
);
|
||||
expect(step.getOutputValueByName('logcat_directory')).toBe('/tmp/logcat-directory');
|
||||
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
|
||||
expect(mockedAndroidUtils.startAsync.mock.calls[0][0].logCapture).toBeUndefined();
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-default',
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
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';
|
||||
|
||||
@@ -14,12 +20,21 @@ export function createCollectAndroidEmulatorLogsBuildFunction(
|
||||
name: 'Collect Android emulator logs',
|
||||
__metricsId: 'eas/collect_android_emulator_logs',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
|
||||
fn: async ({ logger, global }, { env }) => {
|
||||
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));
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import { AndroidEmulatorLogSourcesSchema } from '../../utils/androidEmulatorLogSources';
|
||||
import {
|
||||
AndroidEmulatorLogSourcesSchema,
|
||||
LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
} from '../../utils/androidEmulatorLogSources';
|
||||
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
|
||||
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
|
||||
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
|
||||
@@ -76,7 +79,7 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
BuildStepInput.createProvider({
|
||||
id: 'android_log_sources',
|
||||
required: false,
|
||||
defaultValue: ['emulator-output'],
|
||||
defaultValue: [...LEGACY_ANDROID_EMULATOR_LOG_SOURCES],
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
|
||||
@@ -196,9 +199,6 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
} else {
|
||||
validateAndroidLaunchOptions({ launchArgs, openUrl });
|
||||
}
|
||||
const logSources = isIos
|
||||
? []
|
||||
: AndroidEmulatorLogSourcesSchema.parse(inputs.android_log_sources.value);
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
@@ -252,6 +252,9 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
booted = iosSimulatorUdid
|
||||
? Promise.resolve()
|
||||
: tasks.run('Android Emulator boot', async taskLogger => {
|
||||
const logSources = AndroidEmulatorLogSourcesSchema.parse(
|
||||
inputs.android_log_sources.value
|
||||
);
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
@@ -265,7 +268,9 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture: getAndroidEmulatorLogCapture(global, env),
|
||||
logCapture: env.DEVICE_RUN_SESSION_ID
|
||||
? getAndroidEmulatorLogCapture(global)
|
||||
: undefined,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
|
||||
@@ -24,9 +24,9 @@ import { type AndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogCa
|
||||
import { retryAsync } from '../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
|
||||
import {
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
type AndroidEmulatorLogSource,
|
||||
AndroidEmulatorLogSourcesSchema,
|
||||
LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
} from '../../utils/androidEmulatorLogSources';
|
||||
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
|
||||
|
||||
@@ -43,7 +43,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
BuildStepInput.createProvider({
|
||||
id: 'android_log_sources',
|
||||
required: false,
|
||||
defaultValue: [...ALL_ANDROID_EMULATOR_LOG_SOURCES],
|
||||
defaultValue: [...LEGACY_ANDROID_EMULATOR_LOG_SOURCES],
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
@@ -96,7 +96,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
const logCapture = getAndroidEmulatorLogCapture(global, env);
|
||||
const logCapture = env.DEVICE_RUN_SESSION_ID
|
||||
? getAndroidEmulatorLogCapture(global)
|
||||
: undefined;
|
||||
outputs.logcat_directory.set(logcatDirectory);
|
||||
|
||||
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
|
||||
@@ -233,8 +235,8 @@ export async function startAndroidEmulatorAsync({
|
||||
lcdHeight?: number;
|
||||
lcdDensity?: number;
|
||||
logcatDirectory: string;
|
||||
logSources: readonly AndroidEmulatorLogSource[];
|
||||
logCapture: AndroidEmulatorLogCapture;
|
||||
logSources?: readonly AndroidEmulatorLogSource[];
|
||||
logCapture?: AndroidEmulatorLogCapture;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<{
|
||||
|
||||
@@ -15,7 +15,6 @@ import { PassThrough, Readable } from 'node:stream';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { MAX_ANDROID_LOG_BYTES_PER_SOURCE } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { type AndroidEmulatorLogSource } from '../../../utils/androidEmulatorLogSources';
|
||||
import {
|
||||
collectAndroidEmulatorLogsAsync,
|
||||
@@ -65,31 +64,51 @@ async function captureLog(
|
||||
text: string,
|
||||
workflow = global
|
||||
): Promise<void> {
|
||||
const capture = getAndroidEmulatorLogCapture(workflow, { DEVICE_RUN_SESSION_ID: 'session' });
|
||||
const capture = getAndroidEmulatorLogCapture(workflow);
|
||||
const input = Readable.from([Buffer.from(text)]);
|
||||
capture.attach(input, filePath, source);
|
||||
await once(input, 'end');
|
||||
await capture.finishFileAsync(filePath);
|
||||
}
|
||||
|
||||
function collect(): Promise<void> {
|
||||
function collect(
|
||||
sources: readonly AndroidEmulatorLogSource[] = ['emulator-output', 'device-logcat']
|
||||
): Promise<void> {
|
||||
return collectAndroidEmulatorLogsAsync(ctx, {
|
||||
global,
|
||||
deviceRunSessionId: 'session',
|
||||
logger,
|
||||
sources,
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ env: {}, expectedBytes: 20 * 1024 * 1024 + 1 },
|
||||
{ env: { DEVICE_RUN_SESSION_ID: 'session' }, expectedBytes: 20 * 1024 * 1024 },
|
||||
])('retains $expectedBytes with workflow environment $env', async ({ env, expectedBytes }) => {
|
||||
const capture = getAndroidEmulatorLogCapture(global, env);
|
||||
const input = Readable.from([Buffer.alloc(20 * 1024 * 1024 + 1, 'x')]);
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
await once(input, 'end');
|
||||
await capture.finishAsync();
|
||||
expect((await fsPromises.stat(emulatorPath)).size).toBe(expectedBytes);
|
||||
it('uploads only emulator output when sources are omitted', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLog(logcatPath, 'device-logcat', 'sensitive device output');
|
||||
const stat = jest.spyOn(fsPromises, 'stat');
|
||||
await collectAndroidEmulatorLogsAsync(ctx, { global, deviceRunSessionId: 'session', logger });
|
||||
expect([...uploadedContents.keys()]).toEqual([path.basename(emulatorPath)]);
|
||||
expect(stat).not.toHaveBeenCalledWith(logcatPath);
|
||||
});
|
||||
|
||||
it('uploads only explicitly selected logcat and uploads nothing for an empty selection', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLog(logcatPath, 'device-logcat', 'device output');
|
||||
await collect([]);
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
await collect(['device-logcat']);
|
||||
expect([...uploadedContents.keys()]).toEqual([path.basename(logcatPath)]);
|
||||
});
|
||||
|
||||
it('honors different source selections during concurrent cleanup without duplicate uploads', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLog(logcatPath, 'device-logcat', 'device output');
|
||||
await Promise.all([collect(['emulator-output']), collect(['emulator-output', 'device-logcat'])]);
|
||||
expect([...uploadedContents.keys()].sort()).toEqual([
|
||||
path.basename(emulatorPath),
|
||||
path.basename(logcatPath),
|
||||
]);
|
||||
expect(upload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses captured source records and ignores files outside the capture inventory', async () => {
|
||||
@@ -134,7 +153,7 @@ it('uses captured source records and ignores files outside the capture inventory
|
||||
|
||||
it('seals a live producer before upload and keeps retry bodies identical without copying files', async () => {
|
||||
const input = new PassThrough();
|
||||
const capture = getAndroidEmulatorLogCapture(global, { DEVICE_RUN_SESSION_ID: 'session' });
|
||||
const capture = getAndroidEmulatorLogCapture(global);
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
input.write('before');
|
||||
const contents: string[] = [];
|
||||
@@ -166,19 +185,28 @@ it('seals a live producer before upload and keeps retry bodies identical without
|
||||
}
|
||||
});
|
||||
|
||||
it('continues with the other log after a failed upload and never repeats the collection', async () => {
|
||||
it('continues after a failed upload and retries only unsuccessful files on another collection', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLog(logcatPath, 'device-logcat', 'Android output');
|
||||
upload.mockRejectedValueOnce(new Error('storage unavailable'));
|
||||
await Promise.all([collect(), collect()]);
|
||||
await collect();
|
||||
await expect(collect()).resolves.toBeUndefined();
|
||||
expect(upload).toHaveBeenCalledTimes(2);
|
||||
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('other logs will continue')
|
||||
);
|
||||
await collect();
|
||||
expect(upload).toHaveBeenCalledTimes(3);
|
||||
expect(upload.mock.calls[2][1].filename).toBe(path.basename(emulatorPath));
|
||||
expect(upload.mock.calls[0][1].signal).not.toBe(upload.mock.calls[1][1].signal);
|
||||
});
|
||||
|
||||
it('shares workflow capture and skips successful uploads on repeated or concurrent cleanup', async () => {
|
||||
expect(getAndroidEmulatorLogCapture(global)).toBe(getAndroidEmulatorLogCapture(global));
|
||||
await captureLog(emulatorPath, 'emulator-output', 'host output');
|
||||
await Promise.all([collect(), collect()]);
|
||||
await collect();
|
||||
expect(upload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses a fresh 30 second budget for the next file after an upload times out', async () => {
|
||||
@@ -200,10 +228,11 @@ it('uses a fresh 30 second budget for the next file after an upload times out',
|
||||
expect(upload.mock.calls[1][1].signal?.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('does not collect another workflow and warns when a captured file disappears', async () => {
|
||||
it('does not collect another workflow or empty files and continues after a captured file disappears', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'other workflow', {} as BuildStepGlobalContext);
|
||||
await collect();
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
await captureLog(logcatPath, 'device-logcat', '');
|
||||
await captureLog(path.join(directory, 'missing.log'), 'emulator-output', 'removed');
|
||||
await rm(path.join(directory, 'missing.log'));
|
||||
await expect(collect()).resolves.toBeUndefined();
|
||||
@@ -214,27 +243,23 @@ it('does not collect another workflow and warns when a captured file disappears'
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads the tail of a truncated log with the retained size and omission metadata', async () => {
|
||||
const capture = getAndroidEmulatorLogCapture(global, { DEVICE_RUN_SESSION_ID: 'session' });
|
||||
const input = Readable.from([
|
||||
Buffer.concat([Buffer.from('head: '), Buffer.alloc(MAX_ANDROID_LOG_BYTES_PER_SOURCE, 'b')]),
|
||||
]);
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
await once(input, 'end');
|
||||
await collect();
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
size: MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
name: expect.stringContaining('truncated, last 20 MiB retained'),
|
||||
metadata: expect.objectContaining({
|
||||
originalSizeBytes: MAX_ANDROID_LOG_BYTES_PER_SOURCE + 6,
|
||||
omittedBytes: 6,
|
||||
truncated: true,
|
||||
}),
|
||||
})
|
||||
it('aborts on a genuine log file read error and continues with the other log', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLog(logcatPath, 'device-logcat', 'Android output');
|
||||
const readError = Object.assign(new Error('log read failed'), { code: 'EIO' });
|
||||
upload.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
|
||||
const stream = openStream();
|
||||
await new Promise<void>(resolve => {
|
||||
stream.once('error', () => resolve());
|
||||
stream.destroy(readError);
|
||||
});
|
||||
expect(signal?.reason).toBe(readError);
|
||||
signal?.throwIfAborted();
|
||||
});
|
||||
await expect(collect()).resolves.toBeUndefined();
|
||||
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: readError },
|
||||
expect.stringContaining('other logs will continue')
|
||||
);
|
||||
const uploaded = uploadedContents.get(path.basename(emulatorPath));
|
||||
expect(uploaded).toHaveLength(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
|
||||
expect(uploaded).not.toContain('head');
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { type BuildStepGlobalContext } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import {
|
||||
collectAndroidEmulatorLogsAsync,
|
||||
getAndroidEmulatorLogCapture,
|
||||
} from '../androidEmulatorLogs';
|
||||
import { requireDeviceRunSessionWorkerApi } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('../deviceRunSessionWorkerApi');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
it('retries a finalized capped log while its producer keeps writing, with one artifact', async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-upload-test-'));
|
||||
const maximumBytes = 20 * 1024 * 1024;
|
||||
const payload = Buffer.alloc(maximumBytes + 6, 'a');
|
||||
const filePath = path.join(directory, 'device-emulator.log');
|
||||
const global = {} as BuildStepGlobalContext;
|
||||
const logger = createMockLogger();
|
||||
const capture = getAndroidEmulatorLogCapture(global);
|
||||
const input = new PassThrough();
|
||||
capture.attach(input, filePath, 'emulator-output');
|
||||
input.write(payload);
|
||||
let requests = 0;
|
||||
let receivedBytes = 0;
|
||||
const server = createServer((request, response) => {
|
||||
requests++;
|
||||
if (requests === 1) {
|
||||
input.write('ignored after finalization');
|
||||
response.statusCode = 503;
|
||||
response.flushHeaders();
|
||||
return;
|
||||
}
|
||||
request.on('data', chunk => {
|
||||
receivedBytes += chunk.length;
|
||||
});
|
||||
request.on('end', () => response.end());
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Missing test server address');
|
||||
}
|
||||
const createArtifactUploadSessionAsync = jest.fn().mockResolvedValue({
|
||||
url: `http://127.0.0.1:${address.port}/log`,
|
||||
headers: { 'Content-Length': String(maximumBytes) },
|
||||
});
|
||||
jest.mocked(requireDeviceRunSessionWorkerApi).mockReturnValue({
|
||||
createArtifactUploadSessionAsync,
|
||||
} as unknown as ReturnType<typeof requireDeviceRunSessionWorkerApi>);
|
||||
const collect = async (): Promise<void> =>
|
||||
await collectAndroidEmulatorLogsAsync({} as CustomBuildContext, {
|
||||
global,
|
||||
deviceRunSessionId: 'session',
|
||||
logger,
|
||||
});
|
||||
|
||||
await collect();
|
||||
await collect();
|
||||
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(requests).toBe(2);
|
||||
expect(receivedBytes).toBe(maximumBytes);
|
||||
expect((await fs.promises.stat(filePath)).size).toBe(maximumBytes);
|
||||
expect(input.readableFlowing).toBe(true);
|
||||
expect(createArtifactUploadSessionAsync).toHaveBeenCalledTimes(1);
|
||||
expect(createArtifactUploadSessionAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: expect.stringContaining('truncated, 20 MiB retained'),
|
||||
size: maximumBytes,
|
||||
metadata: expect.objectContaining({
|
||||
originalSizeBytes: payload.length,
|
||||
omittedBytes: 6,
|
||||
truncated: true,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
} finally {
|
||||
input.end();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
import { uploadDeviceRunSessionFileAsync } from '../uploadDeviceRunSessionFile';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('node:fs');
|
||||
@@ -124,41 +123,6 @@ it('replays the full file after a transient PUT failure without recreating the u
|
||||
expect(streams.every(stream => stream.destroyed)).toBe(true);
|
||||
});
|
||||
|
||||
it('retries a PUT that fails before reading the body without aborting the file stream', async () => {
|
||||
let puts = 0;
|
||||
let replayed = 0;
|
||||
handle = (request, response) => {
|
||||
puts++;
|
||||
if (puts === 1) {
|
||||
response.statusCode = 503;
|
||||
response.flushHeaders();
|
||||
return;
|
||||
}
|
||||
request.on('data', chunk => {
|
||||
replayed += chunk.length;
|
||||
});
|
||||
request.on('end', () => response.end());
|
||||
};
|
||||
const file = path.join(directory, 'emulator.log');
|
||||
const payload = Buffer.alloc(8 * 1024 * 1024, '*');
|
||||
await writeFile(file, payload);
|
||||
await expect(
|
||||
uploadDeviceRunSessionFileAsync(ctx, {
|
||||
filePath: file,
|
||||
describe: () => ({
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'log-id',
|
||||
name: 'Emulator log',
|
||||
filename: 'emulator.log',
|
||||
kind: 'simulator-log',
|
||||
}),
|
||||
attempts: 2,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(puts).toBe(2);
|
||||
expect(replayed).toBe(payload.length);
|
||||
});
|
||||
|
||||
it('keeps a slow PUT alive while its body is still flowing', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
let received = 0;
|
||||
|
||||
@@ -22,17 +22,29 @@ it('uploads NDJSON with explicit user-app scope', async () => {
|
||||
kind: 'simulator-log',
|
||||
filename: 'app-logs.ndjson',
|
||||
size: 11,
|
||||
attempts: 1,
|
||||
metadata: expect.objectContaining({ scope: 'user-apps', udid: 'A' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and resolves when the upload fails', async () => {
|
||||
it('skips empty and missing files', async () => {
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
await writeFile(filePath, '');
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts the upload on a file read error without an unhandled error', async () => {
|
||||
await writeFile(filePath, '{}\n');
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'));
|
||||
.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
|
||||
const aborted = new Promise<void>((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
|
||||
});
|
||||
openStream().destroy(new Error('read failure'));
|
||||
await aborted;
|
||||
});
|
||||
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { uploadDeviceRunSessionFileAsync } from '../uploadDeviceRunSessionFile';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
|
||||
const describeFile = (size: number) => ({
|
||||
deviceRunSessionId: 'session',
|
||||
artifactId: 'log',
|
||||
name: `log (${size} bytes)`,
|
||||
filename: 'log.txt',
|
||||
kind: 'simulator-log',
|
||||
});
|
||||
let directory: string;
|
||||
let filePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'device-run-session-file-'));
|
||||
filePath = path.join(directory, 'log.txt');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function uploadFile(options: { signal?: AbortSignal; attempts?: number } = {}): Promise<boolean> {
|
||||
return uploadDeviceRunSessionFileAsync(ctx, { filePath, describe: describeFile, ...options });
|
||||
}
|
||||
|
||||
it('skips an empty file without uploading', async () => {
|
||||
await writeFile(filePath, '');
|
||||
expect(await uploadFile()).toBe(false);
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a missing file before uploading', async () => {
|
||||
await expect(uploadFile()).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('describes the file by its size and forwards attempts and the caller signal', async () => {
|
||||
await writeFile(filePath, 'abc');
|
||||
const controller = new AbortController();
|
||||
expect(await uploadFile({ signal: controller.signal, attempts: 2 })).toBe(true);
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ name: 'log (3 bytes)', size: 3, attempts: 2, filename: 'log.txt' })
|
||||
);
|
||||
const { signal } = upload.mock.calls[0][1];
|
||||
const reason = new Error('stop');
|
||||
controller.abort(reason);
|
||||
expect(signal?.reason).toBe(reason);
|
||||
});
|
||||
|
||||
it('aborts the upload on a file read error and ignores an aborted request body', async () => {
|
||||
await writeFile(filePath, '{}\n');
|
||||
const readError = Object.assign(new Error('read failed'), { code: 'EIO' });
|
||||
upload.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
|
||||
const aborted = openStream();
|
||||
aborted.destroy(Object.assign(new Error('request body aborted'), { name: 'AbortError' }));
|
||||
await new Promise<void>(resolve => aborted.once('close', resolve));
|
||||
expect(signal?.aborted).toBe(false);
|
||||
const failed = openStream();
|
||||
failed.destroy(readError);
|
||||
await new Promise<void>(resolve => failed.once('close', resolve));
|
||||
expect(signal?.reason).toBe(readError);
|
||||
});
|
||||
expect(await uploadFile()).toBe(true);
|
||||
});
|
||||
@@ -1,41 +1,34 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepGlobalContext } from '@expo/steps';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
AndroidEmulatorLogCapture,
|
||||
type FinalizedAndroidEmulatorLog,
|
||||
MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
} from '../../utils/androidEmulatorLogCapture';
|
||||
import { type AndroidEmulatorLogSource } from '../../utils/androidEmulatorLogSources';
|
||||
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 30_000;
|
||||
const LOG_SOURCE_DETAILS = {
|
||||
'emulator-output': { name: 'Emulator output', scope: 'emulator-process' },
|
||||
'device-logcat': { name: 'Android device logcat', scope: 'device' },
|
||||
} satisfies Record<AndroidEmulatorLogSource, { name: string; scope: string }>;
|
||||
|
||||
type Collection = {
|
||||
capture: AndroidEmulatorLogCapture;
|
||||
upload?: Promise<void>;
|
||||
uploaded: Set<string>;
|
||||
collecting?: Promise<void>;
|
||||
};
|
||||
|
||||
// A worker can execute more than one job; each workflow owns its capture and uploads.
|
||||
const collections = new WeakMap<BuildStepGlobalContext, Collection>();
|
||||
|
||||
export function getAndroidEmulatorLogCapture(
|
||||
global: BuildStepGlobalContext,
|
||||
env: NodeJS.ProcessEnv
|
||||
global: BuildStepGlobalContext
|
||||
): AndroidEmulatorLogCapture {
|
||||
let collection = collections.get(global);
|
||||
if (!collection) {
|
||||
collection = {
|
||||
capture: new AndroidEmulatorLogCapture(
|
||||
env.DEVICE_RUN_SESSION_ID ? MAX_ANDROID_LOG_BYTES_PER_SOURCE : Infinity
|
||||
),
|
||||
};
|
||||
collection = { capture: new AndroidEmulatorLogCapture(), uploaded: new Set() };
|
||||
collections.set(global, collection);
|
||||
}
|
||||
return collection.capture;
|
||||
@@ -47,10 +40,12 @@ export async function collectAndroidEmulatorLogsAsync(
|
||||
global,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
sources = ['emulator-output'],
|
||||
}: {
|
||||
global: BuildStepGlobalContext;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
sources?: readonly AndroidEmulatorLogSource[];
|
||||
}
|
||||
): Promise<void> {
|
||||
const collection = collections.get(global);
|
||||
@@ -58,13 +53,20 @@ export async function collectAndroidEmulatorLogsAsync(
|
||||
logger.info('No Android emulator logs captured; skipping upload.');
|
||||
return;
|
||||
}
|
||||
// Generated session workflows collect once. Repeated calls share that finalization/upload.
|
||||
collection.upload ??= uploadCapturedLogsAsync(ctx, {
|
||||
while (collection.collecting) {
|
||||
await collection.collecting;
|
||||
}
|
||||
collection.collecting = uploadCapturedLogsAsync(ctx, {
|
||||
collection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
sources,
|
||||
});
|
||||
await collection.upload;
|
||||
try {
|
||||
await collection.collecting;
|
||||
} finally {
|
||||
collection.collecting = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadCapturedLogsAsync(
|
||||
@@ -73,14 +75,19 @@ async function uploadCapturedLogsAsync(
|
||||
collection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
sources,
|
||||
}: {
|
||||
collection: Collection;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
sources: readonly AndroidEmulatorLogSource[];
|
||||
}
|
||||
): Promise<void> {
|
||||
const logs = await collection.capture.finishAsync();
|
||||
for (const log of logs) {
|
||||
if (!sources.includes(log.source) || collection.uploaded.has(log.filePath)) {
|
||||
continue;
|
||||
}
|
||||
const filename = path.basename(log.filePath);
|
||||
try {
|
||||
const uploaded = await uploadLogAsync(ctx, {
|
||||
@@ -89,6 +96,7 @@ async function uploadCapturedLogsAsync(
|
||||
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (uploaded) {
|
||||
collection.uploaded.add(log.filePath);
|
||||
logger.info(`Uploaded Android diagnostic log ${filename}.`);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -109,32 +117,40 @@ async function uploadLogAsync(
|
||||
signal: AbortSignal;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const { size } = await stat(filePath);
|
||||
if (size === 0) {
|
||||
return false;
|
||||
}
|
||||
const filename = path.basename(filePath);
|
||||
return await uploadDeviceRunSessionFileAsync(ctx, {
|
||||
filePath,
|
||||
describe: size => {
|
||||
const retainedSize =
|
||||
size >= 1024 * 1024 ? `${Number((size / (1024 * 1024)).toFixed(2))} MiB` : `${size} bytes`;
|
||||
const truncationNote = size < bytesSeen ? `; truncated, last ${retainedSize} retained` : '';
|
||||
return {
|
||||
deviceRunSessionId,
|
||||
artifactId: filename,
|
||||
name: `${LOG_SOURCE_DETAILS[source].name} (${filename}${truncationNote})`,
|
||||
filename,
|
||||
kind: 'simulator-log',
|
||||
metadata: {
|
||||
__eas_type: 'simulator-log',
|
||||
platform: 'android',
|
||||
source,
|
||||
scope: LOG_SOURCE_DETAILS[source].scope,
|
||||
originalSizeBytes: bytesSeen,
|
||||
// The capture keeps the newest bytes, so the omitted bytes are the head.
|
||||
omittedBytes: bytesSeen - size,
|
||||
truncated: size < bytesSeen,
|
||||
},
|
||||
};
|
||||
const readController = new AbortController();
|
||||
const retainedSize =
|
||||
size >= 1024 * 1024 ? `${Number((size / (1024 * 1024)).toFixed(2))} MiB` : `${size} bytes`;
|
||||
const truncationNote = size < bytesSeen ? `; truncated, ${retainedSize} retained` : '';
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: filename,
|
||||
name: `${source === 'emulator-output' ? 'Emulator output' : 'Android device logcat'} (${filename.replace(/-(emulator|logcat)\.log$/, '')}${truncationNote})`,
|
||||
filename,
|
||||
kind: 'simulator-log',
|
||||
metadata: {
|
||||
__eas_type: 'simulator-log',
|
||||
platform: 'android',
|
||||
source,
|
||||
scope: source === 'emulator-output' ? 'emulator-process' : 'device',
|
||||
originalSizeBytes: bytesSeen,
|
||||
omittedBytes: bytesSeen - size,
|
||||
truncated: size < bytesSeen,
|
||||
},
|
||||
signal,
|
||||
size,
|
||||
openStream: () =>
|
||||
createReadStream(filePath).on('error', err => {
|
||||
// An HTTP error aborts one request body, not the remaining upload attempts.
|
||||
if (err.name !== 'AbortError') {
|
||||
readController.abort(err);
|
||||
}
|
||||
}),
|
||||
signal: AbortSignal.any([signal, readController.signal]),
|
||||
attempts: 2,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
export type ArtifactMetadata = {
|
||||
type ArtifactMetadata = {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
|
||||
export async function uploadServeSimLogsFileAsync(
|
||||
ctx: CustomBuildContext,
|
||||
@@ -20,22 +22,21 @@ export async function uploadServeSimLogsFileAsync(
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
await uploadDeviceRunSessionFileAsync(ctx, {
|
||||
filePath,
|
||||
describe: () => ({
|
||||
deviceRunSessionId,
|
||||
artifactId: `simulator-log-${udid}`,
|
||||
name: `App logs (${udid.slice(0, 8)})`,
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
metadata: {
|
||||
__eas_type: 'simulator-log',
|
||||
udid,
|
||||
scope: 'user-apps',
|
||||
source: 'serve-sim/logs',
|
||||
},
|
||||
}),
|
||||
signal,
|
||||
const { size } = await stat(filePath);
|
||||
if (size === 0) {
|
||||
return;
|
||||
}
|
||||
const fileController = new AbortController();
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: `simulator-log-${udid}`,
|
||||
name: `App logs (${udid.slice(0, 8)})`,
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
metadata: { __eas_type: 'simulator-log', udid, scope: 'user-apps', source: 'serve-sim/logs' },
|
||||
size,
|
||||
openStream: () => createReadStream(filePath).on('error', err => fileController.abort(err)),
|
||||
signal: AbortSignal.any([fileController.signal, ...(signal ? [signal] : [])]),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
type ArtifactMetadata,
|
||||
uploadDeviceRunSessionArtifactAsync,
|
||||
} from './deviceRunSessionArtifacts';
|
||||
|
||||
/** Upload a finalized file, reopening it for each attempt. */
|
||||
export async function uploadDeviceRunSessionFileAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
filePath,
|
||||
describe,
|
||||
signal,
|
||||
attempts = 1,
|
||||
}: {
|
||||
filePath: string;
|
||||
describe: (size: number) => Omit<ArtifactMetadata, 'size'>;
|
||||
signal?: AbortSignal;
|
||||
attempts?: number;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const { size } = await stat(filePath);
|
||||
if (size === 0) {
|
||||
return false;
|
||||
}
|
||||
const readController = new AbortController();
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...describe(size),
|
||||
size,
|
||||
openStream: () =>
|
||||
createReadStream(filePath).on('error', err => {
|
||||
// An HTTP error aborts one request body, not the remaining upload attempts.
|
||||
if (err.name !== 'AbortError') {
|
||||
readController.abort(err);
|
||||
}
|
||||
}),
|
||||
signal: AbortSignal.any([readController.signal, ...(signal ? [signal] : [])]),
|
||||
attempts,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -13,7 +13,10 @@ import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
import { type AndroidEmulatorLogSource } from './androidEmulatorLogSources';
|
||||
import {
|
||||
type AndroidEmulatorLogSource,
|
||||
LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
} from './androidEmulatorLogSources';
|
||||
import { type AndroidEmulatorLogCapture } from './androidEmulatorLogCapture';
|
||||
import { retryAsync } from './retry';
|
||||
|
||||
@@ -328,19 +331,21 @@ export namespace AndroidEmulatorUtils {
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logSources = LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
launchGate,
|
||||
logCapture,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logcatDirectory: string;
|
||||
logSources: readonly AndroidEmulatorLogSource[];
|
||||
logSources?: readonly AndroidEmulatorLogSource[];
|
||||
launchGate?: AndroidEmulatorLaunchGate;
|
||||
logCapture: AndroidEmulatorLogCapture;
|
||||
logCapture?: AndroidEmulatorLogCapture;
|
||||
}): Promise<{
|
||||
emulatorPromise: SpawnPromise<SpawnResult>;
|
||||
serialId: AndroidDeviceSerialId;
|
||||
logcatOutputPath: string | undefined;
|
||||
emulatorOutputPath: string | undefined;
|
||||
}> {
|
||||
let logcatOutputPath: string | undefined;
|
||||
let emulatorOutputPath: string | undefined;
|
||||
@@ -365,9 +370,10 @@ export namespace AndroidEmulatorUtils {
|
||||
});
|
||||
}
|
||||
|
||||
const logcatOutput = logcatOutputPath
|
||||
? await logCapture.prepareLogcatOutputAsync(logcatOutputPath)
|
||||
: undefined;
|
||||
const logcatPipe =
|
||||
logCapture && logcatOutputPath
|
||||
? await logCapture.createLogcatPipeAsync(logcatOutputPath)
|
||||
: undefined;
|
||||
const emulatorCommand = `${process.env.ANDROID_HOME}/emulator/emulator`;
|
||||
const emulatorArgs = [
|
||||
'-no-window',
|
||||
@@ -375,7 +381,9 @@ export namespace AndroidEmulatorUtils {
|
||||
'-writable-system',
|
||||
'-noaudio',
|
||||
'-no-snapshot-save',
|
||||
...(logcatOutput ? ['-logcat', '*:v', '-logcat-output', logcatOutput.path] : []),
|
||||
...(logcatOutputPath
|
||||
? ['-logcat', '*:v', '-logcat-output', logcatPipe?.path ?? logcatOutputPath]
|
||||
: []),
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
@@ -407,10 +415,10 @@ export namespace AndroidEmulatorUtils {
|
||||
ignoreStdio: true,
|
||||
env: emulatorEnv,
|
||||
});
|
||||
if (logcatOutput) {
|
||||
emulatorPromise.child.once('close', logcatOutput.close);
|
||||
if (logcatPipe) {
|
||||
emulatorPromise.child.once('close', logcatPipe.close);
|
||||
}
|
||||
if (emulatorOutputPath) {
|
||||
if (emulatorOutputPath && logCapture) {
|
||||
for (const input of [emulatorPromise.child.stdout, emulatorPromise.child.stderr]) {
|
||||
if (input) {
|
||||
logCapture.attach(input, emulatorOutputPath, 'emulator-output');
|
||||
@@ -419,6 +427,33 @@ export namespace AndroidEmulatorUtils {
|
||||
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.
|
||||
@@ -469,7 +504,7 @@ export namespace AndroidEmulatorUtils {
|
||||
|
||||
// We don't want to await the SpawnPromise here.
|
||||
// eslint-disable-next-line @typescript-eslint/return-await
|
||||
return { emulatorPromise, serialId };
|
||||
return { emulatorPromise, serialId, logcatOutputPath, emulatorOutputPath };
|
||||
}
|
||||
|
||||
export async function waitForReadyAsync({
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
import { AndroidEmulatorLogCapture } from '../androidEmulatorLogCapture';
|
||||
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../androidEmulatorLogSources';
|
||||
|
||||
// We need to use real fs for cloning devices to work.
|
||||
jest.unmock('fs');
|
||||
@@ -80,8 +78,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
({ serialId } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -109,8 +105,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -141,8 +135,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
const { serialId: serialIdClone, emulatorPromise: emulatorPromiseClone } =
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
deviceName: cloneDeviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -180,8 +172,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -236,8 +226,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
deviceName,
|
||||
env: envForAttempt,
|
||||
logcatDirectory,
|
||||
@@ -321,8 +309,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger,
|
||||
});
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory: testLogcatDirectory,
|
||||
@@ -333,16 +319,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({ serialId, env: process.env });
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const filename = (await fs.promises.readdir(testLogcatDirectory)).find(file =>
|
||||
file.endsWith('-emulator.log')
|
||||
);
|
||||
if (!filename) {
|
||||
throw new Error('Missing emulator output');
|
||||
}
|
||||
const contents = await fs.promises.readFile(
|
||||
path.join(testLogcatDirectory, filename),
|
||||
'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.');
|
||||
}
|
||||
@@ -363,16 +340,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const filename = (await fs.promises.readdir(testLogcatDirectory)).find(file =>
|
||||
file.endsWith('-logcat.log')
|
||||
);
|
||||
if (!filename) {
|
||||
throw new Error('Missing logcat output');
|
||||
}
|
||||
const contents = await fs.promises.readFile(
|
||||
path.join(testLogcatDirectory, filename),
|
||||
'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.`);
|
||||
}
|
||||
@@ -416,8 +384,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import { Socket } from 'node:net';
|
||||
import os from 'node:os';
|
||||
@@ -15,15 +15,7 @@ import {
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
import { AndroidEmulatorLogCapture } from '../androidEmulatorLogCapture';
|
||||
import {
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
AndroidEmulatorLogSourcesSchema,
|
||||
} from '../androidEmulatorLogSources';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
import { AndroidEmulatorLogSourcesSchema } from '../androidEmulatorLogSources';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
@@ -45,11 +37,9 @@ const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
|
||||
describe('AndroidEmulatorUtils', () => {
|
||||
let temporaryDirectories: string[] = [];
|
||||
let homeDirectory: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-utils-home-'));
|
||||
temporaryDirectories = [homeDirectory];
|
||||
beforeEach(() => {
|
||||
temporaryDirectories = [];
|
||||
jest.mocked(Sentry.capture).mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedRetryAsync.mockImplementation(async fn => await fn(0));
|
||||
@@ -67,7 +57,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
describe(AndroidEmulatorUtils.createAsync, () => {
|
||||
it('applies an explicit LCD configuration after screen scaling', async () => {
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `${homeDirectory}/.android/avd/${deviceName}.avd`;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
`${avdDirectory}/config.ini`,
|
||||
@@ -95,7 +85,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
env: {
|
||||
HOME: homeDirectory,
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
@@ -153,7 +143,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
['device-run-session-id', 4096],
|
||||
])('configures memory for session %s without an LCD override', async (sessionId, ramSize) => {
|
||||
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `${homeDirectory}/.android/avd/${deviceName}.avd`;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(`${avdDirectory}/config.ini`, initialConfig);
|
||||
@@ -169,7 +159,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: {
|
||||
HOME: homeDirectory,
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
DEVICE_RUN_SESSION_ID: sessionId,
|
||||
},
|
||||
@@ -184,24 +174,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.startAsync, () => {
|
||||
let buildCapture: AndroidEmulatorLogCapture;
|
||||
beforeEach(() => {
|
||||
buildCapture = new AndroidEmulatorLogCapture(Infinity);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await buildCapture.finishAsync();
|
||||
});
|
||||
|
||||
async function logFile(directory: string, source: 'logcat' | 'emulator'): Promise<string> {
|
||||
const filename = (await fs.promises.readdir(directory)).find(file =>
|
||||
file.endsWith(`-${source}.log`)
|
||||
);
|
||||
if (!filename) {
|
||||
throw new Error(`Missing ${source} log in ${directory}`);
|
||||
}
|
||||
return path.join(directory, filename);
|
||||
}
|
||||
|
||||
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
|
||||
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
|
||||
// narrows on before unref-ing them.
|
||||
@@ -232,30 +204,35 @@ describe('AndroidEmulatorUtils', () => {
|
||||
return { child };
|
||||
}
|
||||
|
||||
it('names log files after the device and detaches the emulator', async () => {
|
||||
it('captures logcat and emulator process output', async () => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const { child } = mockSuccessfulStart(deviceName);
|
||||
const createWriteStreamSpy = jest.spyOn(fs, 'createWriteStream');
|
||||
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: process.env,
|
||||
logcatDirectory: outputDir,
|
||||
});
|
||||
|
||||
expect(await logFile(outputDir, 'logcat')).toMatch(
|
||||
expect(result.logcatOutputPath).toMatch(
|
||||
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
|
||||
);
|
||||
expect(await logFile(outputDir, 'emulator')).toMatch(
|
||||
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();
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/emulator\/emulator$/),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ detached: true, ignoreStdio: true })
|
||||
expect.arrayContaining(['-logcat', '*:v', '-logcat-output', result.logcatOutputPath]),
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
})
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
@@ -267,7 +244,103 @@ describe('AndroidEmulatorUtils', () => {
|
||||
// for as long as the emulator runs.
|
||||
expect(child.stdout.unref).toHaveBeenCalled();
|
||||
expect(child.stderr.unref).toHaveBeenCalled();
|
||||
const emulatorOutputStream = createWriteStreamSpy.mock.results[0].value;
|
||||
const stderrWriteSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const writeError = Object.assign(new Error('disk full'), { code: 'ENOSPC' });
|
||||
emulatorOutputStream.emit('error', writeError);
|
||||
emulatorOutputStream.emit('error', writeError);
|
||||
expect(stderrWriteSpy).toHaveBeenCalledTimes(2);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to write Android emulator process output',
|
||||
writeError,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: { errorCode: 'ENOSPC' },
|
||||
extras: {
|
||||
deviceName,
|
||||
emulatorOutputPath: result.emulatorOutputPath,
|
||||
},
|
||||
}
|
||||
);
|
||||
const emulatorOutputStreamClosed = once(emulatorOutputStream, 'close');
|
||||
child.emit('close');
|
||||
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([
|
||||
@@ -275,77 +348,37 @@ describe('AndroidEmulatorUtils', () => {
|
||||
{ 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 stdoutPipe = jest.spyOn(child.stdout, 'pipe');
|
||||
const stderrPipe = jest.spyOn(child.stderr, 'pipe');
|
||||
const prepare = jest.spyOn(buildCapture, 'prepareLogcatOutputAsync');
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
logSources: AndroidEmulatorLogSourcesSchema.parse(sources),
|
||||
});
|
||||
const capturesOutput = sources.includes('emulator-output');
|
||||
const capturesLogcat = sources.includes('device-logcat');
|
||||
const [, args, options] = mockedSpawn.mock.calls[0];
|
||||
expect(prepare).toHaveBeenCalledTimes(capturesLogcat ? 1 : 0);
|
||||
expect(args.includes('-logcat')).toBe(capturesLogcat);
|
||||
expect(args.includes('-logcat-output')).toBe(capturesLogcat);
|
||||
expect(options?.stdio).toEqual([
|
||||
'ignore',
|
||||
capturesOutput ? 'pipe' : 'ignore',
|
||||
capturesOutput ? 'pipe' : 'ignore',
|
||||
]);
|
||||
expect(stdoutPipe).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
|
||||
expect(stderrPipe).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
|
||||
if (capturesOutput) {
|
||||
child.stdout.emit('data', Buffer.from('stdout marker\n'));
|
||||
child.stderr.emit('data', Buffer.from('stderr marker\n'));
|
||||
child.emit('close');
|
||||
await buildCapture.finishAsync();
|
||||
await expect(
|
||||
fs.promises.readFile(await logFile(outputDir, 'emulator'), '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);
|
||||
});
|
||||
|
||||
it('routes a session logcat through the capture pipe and closes it with the emulator', async () => {
|
||||
])('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();
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const close = jest.fn();
|
||||
const createPipe = jest.spyOn(capture, 'prepareLogcatOutputAsync').mockResolvedValue({
|
||||
const createPipe = jest.spyOn(capture, 'createLogcatPipeAsync').mockResolvedValue({
|
||||
path: '/logcat.pipe',
|
||||
close,
|
||||
closed: Promise.resolve(),
|
||||
});
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
logSources: ['device-logcat'],
|
||||
logSources: AndroidEmulatorLogSourcesSchema.parse(sources),
|
||||
logCapture: capture,
|
||||
});
|
||||
expect(createPipe).toHaveBeenCalledWith(await logFile(outputDir, 'logcat'));
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['-logcat-output', '/logcat.pipe'])
|
||||
);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
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(1);
|
||||
expect(await fs.promises.readdir(outputDir)).toHaveLength(1);
|
||||
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 () => {
|
||||
@@ -355,9 +388,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
mockSuccessfulStart(deviceName);
|
||||
const env = { ANDROID_EMULATOR_EXTRA_ARGS: '-gpu swiftshader_indirect' };
|
||||
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory: outputDir,
|
||||
@@ -378,7 +409,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
await logFile(outputDir, 'logcat'),
|
||||
result.logcatOutputPath,
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
@@ -415,9 +446,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
: gatedSpawn(command, args, options as any)) as any);
|
||||
const admitAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
@@ -444,7 +473,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
await logFile(outputDir, 'logcat'),
|
||||
result.logcatOutputPath,
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
@@ -481,8 +510,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
await expect(
|
||||
AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
@@ -507,8 +534,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
try {
|
||||
await expect(
|
||||
AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
logcatDirectory: outputDir,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import fs, { type FileHandle } from 'node:fs/promises';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
@@ -37,123 +37,25 @@ async function record(
|
||||
await capture.finishFileAsync(file);
|
||||
}
|
||||
|
||||
async function waitForSize(file: string, size: number): Promise<void> {
|
||||
const deadline = Date.now() + 2_000;
|
||||
while ((await fs.stat(file)).size !== size && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
expect((await fs.stat(file)).size).toBe(size);
|
||||
}
|
||||
|
||||
function trackOpenedFiles(): FileHandle[] {
|
||||
const opened: FileHandle[] = [];
|
||||
const open = fs.open;
|
||||
jest.spyOn(fs, 'open').mockImplementation(async (file, flags, mode) => {
|
||||
const handle = await open(file, flags, mode);
|
||||
opened.push(handle);
|
||||
return handle;
|
||||
});
|
||||
return opened;
|
||||
}
|
||||
|
||||
it('keeps the newest bytes of a source across launches, with independent budgets for the two sources', async () => {
|
||||
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!!');
|
||||
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');
|
||||
// A partially evicted file keeps its size on disk until it is sealed.
|
||||
expect((await fs.stat(first)).size).toBe(6);
|
||||
const logs = await capture.finishAsync();
|
||||
expect(logs).toEqual([
|
||||
{ filePath: first, source: 'emulator-output', bytesSeen: 6 },
|
||||
{ filePath: retry, source: 'emulator-output', bytesSeen: 8 },
|
||||
{ filePath: retry, source: 'emulator-output', bytesSeen: 22 },
|
||||
{ filePath: device, source: 'device-logcat', bytesSeen: 24 },
|
||||
]);
|
||||
// The retry evicts the head of the earlier launch; its own tail stays whole.
|
||||
expect(await fs.readFile(first, 'utf8')).toBe('t!');
|
||||
expect(await fs.readFile(retry, 'utf8')).toBe('second!!');
|
||||
expect(await fs.readFile(device, 'utf8')).toBe('e separate');
|
||||
});
|
||||
|
||||
it('frees a finished file on disk once a later launch evicts all of it', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const first = path.join(directory, 'first.log');
|
||||
const retry = path.join(directory, 'retry.log');
|
||||
await record(capture, first, 'first!');
|
||||
const input = new PassThrough();
|
||||
capture.attach(input, retry, 'emulator-output');
|
||||
input.write('second log is too long');
|
||||
await waitForSize(first, 0);
|
||||
await capture.finishAsync();
|
||||
expect(await fs.readFile(first, 'utf8')).toBe('');
|
||||
expect(await fs.readFile(retry, 'utf8')).toBe('s too long');
|
||||
});
|
||||
|
||||
it('frees a fully evicted live capture on disk and records its later output', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const first = path.join(directory, 'first.log');
|
||||
const second = path.join(directory, 'second.log');
|
||||
const a = new PassThrough();
|
||||
const b = new PassThrough();
|
||||
capture.attach(a, first, 'emulator-output');
|
||||
capture.attach(b, second, 'emulator-output');
|
||||
// Wait between writes: two streams deliver their first chunks in either order.
|
||||
a.write('0123456789');
|
||||
await waitForSize(first, 10);
|
||||
b.write('abcdefghij');
|
||||
await waitForSize(first, 0);
|
||||
await waitForSize(second, 10);
|
||||
a.write('xy');
|
||||
await capture.finishAsync();
|
||||
expect(await fs.readFile(first, 'utf8')).toBe('xy');
|
||||
expect(await fs.readFile(second, 'utf8')).toBe('cdefghij');
|
||||
});
|
||||
|
||||
it('closes an uncapped file when its stream finishes', async () => {
|
||||
const opened = trackOpenedFiles();
|
||||
const capture = new AndroidEmulatorLogCapture(Infinity);
|
||||
const file = path.join(directory, 'build.log');
|
||||
await record(capture, file, 'ordinary build output');
|
||||
expect(opened).toHaveLength(1);
|
||||
expect(opened[0].fd).toBe(-1);
|
||||
expect(await fs.readFile(file, 'utf8')).toBe('ordinary build output');
|
||||
expect(await capture.finishAsync()).toEqual([
|
||||
{ filePath: file, source: 'emulator-output', bytesSeen: 21 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('closes the file and reports once when sealing fails', async () => {
|
||||
const opened = trackOpenedFiles();
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const file = path.join(directory, 'wrapped.log');
|
||||
await record(capture, file, '0123456789abcdef');
|
||||
const diskError = Object.assign(new Error('full'), { code: 'ENOSPC' });
|
||||
jest.spyOn(opened[0], 'truncate').mockRejectedValueOnce(diskError);
|
||||
await capture.finishAsync();
|
||||
expect(opened[0].fd).toBe(-1);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to write Android diagnostic log',
|
||||
diskError,
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the tail of a wrapped ring in order', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const file = path.join(directory, 'wrapped.log');
|
||||
const input = new PassThrough();
|
||||
capture.attach(input, file, 'emulator-output');
|
||||
for (const piece of ['0123', '4567', '89ab', 'cdef']) {
|
||||
input.write(piece);
|
||||
}
|
||||
await capture.finishAsync();
|
||||
expect(await fs.readFile(file, 'utf8')).toBe('6789abcdef');
|
||||
expect(await fs.readFile(first, 'utf8')).toBe('first!');
|
||||
expect(await fs.readFile(retry, 'utf8')).toBe('seco');
|
||||
expect(await fs.readFile(device, 'utf8')).toBe('device log');
|
||||
});
|
||||
|
||||
it('reserves bytes across simultaneous captures and drains after finalization', async () => {
|
||||
@@ -165,22 +67,20 @@ it('reserves bytes across simultaneous captures and drains after finalization',
|
||||
capture.attach(a, first, 'emulator-output');
|
||||
capture.attach(b, second, 'emulator-output');
|
||||
a.write('12345678');
|
||||
await waitForSize(first, 8);
|
||||
b.write('abcdefgh');
|
||||
await capture.finishAsync();
|
||||
// The least recently written capture gives up its bytes first.
|
||||
expect(await fs.readFile(first, 'utf8')).toBe('78');
|
||||
expect(await fs.readFile(second, 'utf8')).toBe('abcdefgh');
|
||||
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 tail, and closes after the writer exits', async () => {
|
||||
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.prepareLogcatOutputAsync(file);
|
||||
const pipe = await capture.createLogcatPipeAsync(file);
|
||||
expect((await fs.stat(pipe.path)).isFIFO()).toBe(true);
|
||||
const writer = spawn(
|
||||
process.execPath,
|
||||
@@ -210,8 +110,8 @@ it('drains a real FIFO beyond 20 MiB, retains the tail, and closes after the wri
|
||||
const logs = await capture.finishAsync();
|
||||
const result = await fs.readFile(file);
|
||||
expect(result.length).toBe(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
|
||||
expect(result.subarray(0, 4).toString()).toBe('xxxx');
|
||||
expect(result.subarray(-4).toString()).toBe('\nEND');
|
||||
expect(result.subarray(0, 6).toString()).toBe('BEGIN\n');
|
||||
expect(result.subarray(-4).toString()).toBe('xxxx');
|
||||
expect(logs).toEqual([
|
||||
{ filePath: file, source: 'device-logcat', bytesSeen: 64 * 1024 * 1024 + 10 },
|
||||
]);
|
||||
@@ -222,71 +122,13 @@ it('drains a real FIFO beyond 20 MiB, retains the tail, and closes after the wri
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('uses the original logcat path for ordinary builds without creating a FIFO', async () => {
|
||||
const file = path.join(directory, 'build-logcat.log');
|
||||
const capture = new AndroidEmulatorLogCapture(Infinity);
|
||||
const output = await capture.prepareLogcatOutputAsync(file);
|
||||
expect(output.path).toBe(file);
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
output.close();
|
||||
await output.closed;
|
||||
expect(await capture.finishAsync()).toEqual([]);
|
||||
});
|
||||
|
||||
it('closes an unused logcat pipe when the emulator fails to launch', async () => {
|
||||
const file = path.join(directory, 'empty.log');
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
const pipe = await capture.prepareLogcatOutputAsync(file);
|
||||
const pipe = await capture.createLogcatPipeAsync(path.join(directory, 'empty.log'));
|
||||
pipe.close();
|
||||
await pipe.closed;
|
||||
await expect(fs.stat(pipe.path)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(await capture.finishAsync()).toEqual([
|
||||
{ filePath: file, source: 'device-logcat', bytesSeen: 0 },
|
||||
]);
|
||||
expect((await fs.stat(file)).size).toBe(0);
|
||||
}, 5_000);
|
||||
|
||||
it('keeps draining the FIFO after capture finishes while the emulator is alive', async () => {
|
||||
const file = path.join(directory, 'live-logcat.log');
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const output = await capture.prepareLogcatOutputAsync(file);
|
||||
const writer = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`
|
||||
const fs = require('node:fs');
|
||||
const fd = fs.openSync(process.argv[1], 'w');
|
||||
process.stdout.write('ready');
|
||||
process.stdin.once('data', () => {
|
||||
const chunk = Buffer.alloc(64 * 1024, 'x');
|
||||
for (let i = 0; i < 128; i++) {
|
||||
let offset = 0;
|
||||
while (offset < chunk.length) offset += fs.writeSync(fd, chunk, offset);
|
||||
}
|
||||
fs.closeSync(fd);
|
||||
process.stdin.destroy();
|
||||
});
|
||||
`,
|
||||
output.path,
|
||||
],
|
||||
{ stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
try {
|
||||
await once(writer.stdout, 'data');
|
||||
await capture.finishAsync();
|
||||
writer.stdin.end('go');
|
||||
expect(await once(writer, 'close')).toEqual([0, null]);
|
||||
output.close();
|
||||
await output.closed;
|
||||
expect((await fs.stat(file)).size).toBe(0);
|
||||
await expect(fs.stat(output.path)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
writer.kill();
|
||||
output.close();
|
||||
await output.closed;
|
||||
}
|
||||
}, 10_000);
|
||||
await capture.finishAsync();
|
||||
});
|
||||
|
||||
it('reports a disk write failure once and continues draining output', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { type FileHandle } from 'node:fs/promises';
|
||||
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';
|
||||
import { createAndroidLogcatPipeAsync } from './androidLogcatPipe';
|
||||
|
||||
export const MAX_ANDROID_LOG_BYTES_PER_SOURCE = 20 * 1024 * 1024;
|
||||
|
||||
@@ -15,42 +16,19 @@ export type FinalizedAndroidEmulatorLog = {
|
||||
readonly bytesSeen: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* One file is a ring of `maxBytes` bytes: `writePos` wraps and the newest
|
||||
* `retained` bytes end at it. Sealing rewrites the file in log order.
|
||||
*/
|
||||
type Capture = {
|
||||
source: AndroidEmulatorLogSource;
|
||||
stream: Writable;
|
||||
inputs: Set<Readable>;
|
||||
done: Promise<void>;
|
||||
/** Runs one file operation after every earlier one on the same handle. */
|
||||
run: (operation: (handle: FileHandle) => Promise<void>) => Promise<void>;
|
||||
sealed?: Promise<void>;
|
||||
bytesSeen: number;
|
||||
writePos: number;
|
||||
retained: number;
|
||||
lastWrite: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* One budget per workflow, shared by all emulator launches and retries.
|
||||
*
|
||||
* A source keeps the last `maxBytes` bytes it produced, across files. The
|
||||
* oldest bytes go first, so an earlier launch gives up its head to a later
|
||||
* one and the final attempt always keeps its tail, where failures explain
|
||||
* themselves. The least recently written file gives up bytes first, which
|
||||
* approximates byte age without tracking every chunk.
|
||||
*
|
||||
* Eviction is bookkeeping until a file loses all its bytes, which frees it
|
||||
* on disk at once. A partially evicted file keeps its size until it is
|
||||
* sealed, so one source occupies at most twice its budget while recording.
|
||||
*/
|
||||
/** One budget per workflow, shared by all emulator launches and retries. */
|
||||
export class AndroidEmulatorLogCapture {
|
||||
private readonly sources = new Map<AndroidEmulatorLogSource, Capture[]>();
|
||||
private readonly bytes = new Map<AndroidEmulatorLogSource, number>();
|
||||
private readonly captures = new Map<string, Capture>();
|
||||
private finishing = false;
|
||||
private writes = 0;
|
||||
|
||||
constructor(private readonly maxBytes = MAX_ANDROID_LOG_BYTES_PER_SOURCE) {}
|
||||
|
||||
@@ -61,11 +39,48 @@ export class AndroidEmulatorLogCapture {
|
||||
}
|
||||
let capture = this.captures.get(filePath);
|
||||
if (!capture) {
|
||||
capture = this.open(filePath, source);
|
||||
const file = fs.promises.open(filePath, 'a');
|
||||
// Handle open errors even if the process never produces output.
|
||||
void file.catch(() => {});
|
||||
let failed = false;
|
||||
const report = (err: unknown): void => {
|
||||
if (!failed) {
|
||||
failed = true;
|
||||
Sentry.capture(
|
||||
'Failed to write Android diagnostic log',
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
{
|
||||
level: 'warning',
|
||||
extras: { filePath, source },
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
const stream = new Writable({
|
||||
write: (chunk: Buffer, _encoding, callback) => {
|
||||
capture!.bytesSeen += chunk.length;
|
||||
const used = this.bytes.get(source) ?? 0;
|
||||
const length = Math.min(chunk.length, this.maxBytes - used);
|
||||
if (failed || length <= 0) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
// Reserve before awaiting I/O so concurrent launches share the same cap.
|
||||
this.bytes.set(source, used + length);
|
||||
void file
|
||||
.then(handle => handle.writeFile(chunk.subarray(0, length)))
|
||||
.catch(report)
|
||||
.then(() => callback());
|
||||
},
|
||||
final: callback => {
|
||||
void file
|
||||
.then(handle => handle.close())
|
||||
.catch(report)
|
||||
.then(() => callback());
|
||||
},
|
||||
});
|
||||
capture = { source, stream, inputs: new Set(), done: finished(stream), bytesSeen: 0 };
|
||||
this.captures.set(filePath, capture);
|
||||
const list = this.sources.get(source) ?? [];
|
||||
list.push(capture);
|
||||
this.sources.set(source, list);
|
||||
}
|
||||
capture.inputs.add(input);
|
||||
input.pipe(capture.stream, { end: false });
|
||||
@@ -84,16 +99,11 @@ export class AndroidEmulatorLogCapture {
|
||||
capture.inputs.clear();
|
||||
capture.stream.end();
|
||||
await capture.done;
|
||||
// Nothing can evict an uncapped file later, so release its handle now.
|
||||
if (!Number.isFinite(this.maxBytes)) {
|
||||
await this.sealAsync(capture);
|
||||
}
|
||||
}
|
||||
|
||||
async finishAsync(): Promise<readonly FinalizedAndroidEmulatorLog[]> {
|
||||
this.finishing = true;
|
||||
await Promise.all([...this.captures.keys()].map(file => this.finishFileAsync(file)));
|
||||
await Promise.all([...this.captures.values()].map(capture => this.sealAsync(capture)));
|
||||
// Every writer is closed, so uploads can reopen these files on each retry.
|
||||
return [...this.captures].map(([filePath, { source, bytesSeen }]) => ({
|
||||
filePath,
|
||||
@@ -102,142 +112,64 @@ export class AndroidEmulatorLogCapture {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Select the output transport without exposing the capture budget to emulator launch. */
|
||||
async prepareLogcatOutputAsync(
|
||||
/** Keep logcat separate from emulator stderr without an unbounded intermediate file. */
|
||||
async createLogcatPipeAsync(
|
||||
filePath: string
|
||||
): Promise<{ path: string; close: () => void; closed: Promise<void> }> {
|
||||
// Ordinary builds retain the emulator's direct, uncapped logcat file writer.
|
||||
if (!Number.isFinite(this.maxBytes)) {
|
||||
return { path: filePath, close: () => {}, closed: Promise.resolve() };
|
||||
}
|
||||
const pipe = await createAndroidLogcatPipeAsync(`${filePath}.pipe`);
|
||||
this.attach(pipe.stream, filePath, 'device-logcat');
|
||||
const closed = pipe.closed.then(() => this.finishFileAsync(filePath)).catch(() => {});
|
||||
return { path: pipe.path, close: pipe.close, closed };
|
||||
}
|
||||
|
||||
private open(filePath: string, source: AndroidEmulatorLogSource): Capture {
|
||||
const file = fs.promises.open(filePath, 'w+');
|
||||
// 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 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);
|
||||
}
|
||||
};
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
const run = (operation: (handle: FileHandle) => Promise<void>): Promise<void> => {
|
||||
queue = queue
|
||||
.then(() => file)
|
||||
.then(operation)
|
||||
.catch(report);
|
||||
return queue;
|
||||
};
|
||||
const record = {
|
||||
source,
|
||||
inputs: new Set<Readable>(),
|
||||
run,
|
||||
bytesSeen: 0,
|
||||
writePos: 0,
|
||||
retained: 0,
|
||||
lastWrite: 0,
|
||||
};
|
||||
const stream = new Writable({
|
||||
write: (chunk: Buffer, _encoding, callback) => {
|
||||
record.bytesSeen += chunk.length;
|
||||
if (failed) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
const max = this.maxBytes;
|
||||
const data = chunk.length > max ? chunk.subarray(chunk.length - max) : chunk;
|
||||
const position = record.writePos;
|
||||
const head = Math.min(data.length, max - position);
|
||||
// Account before awaiting I/O so concurrent launches share the same cap.
|
||||
record.writePos = (position + data.length) % max;
|
||||
record.retained = Math.min(record.retained + data.length, max);
|
||||
record.lastWrite = ++this.writes;
|
||||
this.evict(source);
|
||||
void run(async handle => {
|
||||
await writeAtAsync(handle, data.subarray(0, head), position);
|
||||
await writeAtAsync(handle, data.subarray(head), 0);
|
||||
}).then(() => callback());
|
||||
},
|
||||
});
|
||||
return Object.assign(record, { stream, done: finished(stream) });
|
||||
}
|
||||
|
||||
/** Drop the oldest bytes of a source until its files fit the budget together. */
|
||||
private evict(source: AndroidEmulatorLogSource): void {
|
||||
const list = [...(this.sources.get(source) ?? [])].sort((a, b) => a.lastWrite - b.lastWrite);
|
||||
let excess = list.reduce((sum, capture) => sum + capture.retained, 0) - this.maxBytes;
|
||||
for (const capture of list) {
|
||||
if (excess <= 0) {
|
||||
return;
|
||||
const reader = spawn(
|
||||
'sh',
|
||||
['-c', 'exec 3<"$1" || exit 1; printf ready >&2; exec cat <&3', 'android-logcat', pipePath],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}
|
||||
const dropped = Math.min(capture.retained, excess);
|
||||
capture.retained -= dropped;
|
||||
excess -= dropped;
|
||||
if (dropped > 0 && capture.retained === 0) {
|
||||
// Nothing of this file survives: free the disk after its queued writes land.
|
||||
capture.writePos = 0;
|
||||
void capture.run(handle => handle.truncate(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Rewrite the ring in log order, drop evicted bytes, and close the file. */
|
||||
private sealAsync(capture: Capture): Promise<void> {
|
||||
capture.sealed ??= capture.run(async handle => {
|
||||
try {
|
||||
const { retained } = capture;
|
||||
let start = capture.writePos - retained;
|
||||
if (start < 0) {
|
||||
start += this.maxBytes;
|
||||
}
|
||||
if (start > 0) {
|
||||
const tail = Buffer.alloc(retained);
|
||||
const head = Math.min(retained, this.maxBytes - start);
|
||||
await readAtAsync(handle, tail.subarray(0, head), start);
|
||||
await readAtAsync(handle, tail.subarray(head), 0);
|
||||
await writeAtAsync(handle, tail, 0);
|
||||
}
|
||||
await handle.truncate(retained);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
return capture.sealed;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAtAsync(handle: FileHandle, data: Buffer, position: number): Promise<void> {
|
||||
for (let offset = 0; offset < data.length; ) {
|
||||
const { bytesWritten } = await handle.write(
|
||||
data,
|
||||
offset,
|
||||
data.length - offset,
|
||||
position + offset
|
||||
);
|
||||
offset += bytesWritten;
|
||||
}
|
||||
}
|
||||
|
||||
async function readAtAsync(handle: FileHandle, into: Buffer, position: number): Promise<void> {
|
||||
for (let offset = 0; offset < into.length; ) {
|
||||
const { bytesRead } = await handle.read(into, offset, into.length - offset, position + offset);
|
||||
if (bytesRead === 0) {
|
||||
throw new Error(`Android diagnostic log ended before offset ${position + into.length}`);
|
||||
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;
|
||||
}
|
||||
offset += bytesRead;
|
||||
reader.unref();
|
||||
for (const stream of [reader.stdout, reader.stderr]) {
|
||||
if (stream instanceof Socket) {
|
||||
stream.unref();
|
||||
}
|
||||
}
|
||||
return { path: pipePath, close, closed };
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const AndroidEmulatorLogSourcesSchema = z.array(
|
||||
export type AndroidEmulatorLogSource = z.infer<typeof AndroidEmulatorLogSourcesSchema>[number];
|
||||
|
||||
// Existing build workflows capture both sources. Device sessions pass their selection explicitly.
|
||||
export const ALL_ANDROID_EMULATOR_LOG_SOURCES: readonly AndroidEmulatorLogSource[] = [
|
||||
export const LEGACY_ANDROID_EMULATOR_LOG_SOURCES: readonly AndroidEmulatorLogSource[] = [
|
||||
'emulator-output',
|
||||
'device-logcat',
|
||||
];
|
||||
@@ -1,66 +0,0 @@
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { Socket } from 'node:net';
|
||||
import { type Readable } from 'node:stream';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
|
||||
/** Bridge the emulator's path-based logcat output to a readable stream. */
|
||||
export async function createAndroidLogcatPipeAsync(
|
||||
pipePath: string
|
||||
): Promise<{ path: string; stream: Readable; close: () => void; closed: Promise<void> }> {
|
||||
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());
|
||||
});
|
||||
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 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, stream: reader.stdout, close, closed };
|
||||
}
|
||||
@@ -883,20 +883,21 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('accumulates repeated --android-log-source flags in order', async () => {
|
||||
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',
|
||||
'--android-log-source',
|
||||
'emulator-output',
|
||||
'--android-log-source',
|
||||
'device-logcat',
|
||||
...sources.flatMap(source => ['--android-log-source', source]),
|
||||
]);
|
||||
await command.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ android: { logSources: ['emulator-output', 'device-logcat'] } })
|
||||
expect.objectContaining({ android: { logSources: sources } })
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -373,19 +373,6 @@ export default class Simulator extends EasCommand {
|
||||
packageVersion: flags['package-version'],
|
||||
nonInteractive,
|
||||
});
|
||||
const android = {
|
||||
...(deviceIdentifier ? { deviceIdentifier } : {}),
|
||||
...(systemImagePackage ? { systemImagePackage } : {}),
|
||||
...(androidLogSources ? { logSources: androidLogSources } : {}),
|
||||
};
|
||||
const platformInput =
|
||||
platform === AppPlatform.Ios
|
||||
? deviceIdentifier
|
||||
? { ios: { deviceIdentifier } }
|
||||
: {}
|
||||
: Object.keys(android).length
|
||||
? { android }
|
||||
: {};
|
||||
const session = await withSimulatorRequestAnalyticsAsync(
|
||||
analytics,
|
||||
requestProperties,
|
||||
@@ -401,7 +388,19 @@ export default class Simulator extends EasCommand {
|
||||
packageVersion: flags['package-version'],
|
||||
networkCapture: flags['network-capture'],
|
||||
...(networkCaptureFields.length ? { networkCaptureFields } : {}),
|
||||
...platformInput,
|
||||
...(platform === AppPlatform.Ios
|
||||
? deviceIdentifier
|
||||
? { ios: { deviceIdentifier } }
|
||||
: {}
|
||||
: deviceIdentifier || systemImagePackage || androidLogSources
|
||||
? {
|
||||
android: {
|
||||
...(deviceIdentifier ? { deviceIdentifier } : {}),
|
||||
...(systemImagePackage ? { systemImagePackage } : {}),
|
||||
...(androidLogSources ? { logSources: androidLogSources } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(buildId ? { buildId } : {}),
|
||||
...(buildFingerprint ? { buildFingerprint } : {}),
|
||||
...(applicationArchiveUrlFromFlag
|
||||
|
||||
Reference in new issue
Block a user