mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d5a2e3332 | ||
|
|
b95b7c930a | ||
|
|
8ed5c16c7b | ||
|
|
fa5dee437f | ||
|
|
65ef172a78 | ||
|
|
b15ea359b6 | ||
|
|
ab86b647cf | ||
|
|
f6669461cd |
No files matched your search
@@ -28,7 +28,9 @@ jobs:
|
||||
contains(github.event.pull_request.labels.*.name, 'no changelog') ||
|
||||
github.event.pull_request.user.login == 'dependabot[bot]'
|
||||
}}
|
||||
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }}
|
||||
# The base branch, not `pull_request.base.sha`: that SHA can be older than the base that the
|
||||
# checked-out merge commit contains, and the diff then includes other people's changes.
|
||||
BASE_REF: origin/${{ github.base_ref || 'main' }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [27.7.0](https://github.com/expo/eas-cli/releases/tag/v27.7.0) - 2026-11-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--git-branch` and `--git-commit-hash` to `eas workflow:run` to record the branch and commit of the uploaded project. ([#4815](https://github.com/expo/eas-cli/pull/4815) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Record Android emulator output and, when selected, device logcat during simulator sessions, and upload the last 20 MiB of each as a session artifact. ([#4822](https://github.com/expo/eas-cli/pull/4822) by [@vonovak](https://github.com/vonovak))
|
||||
- [eas-cli] Stop spinners from flickering when their text wraps onto a second line, such as the `simulator:start` queue message in a narrow terminal. ([#4850](https://github.com/expo/eas-cli/pull/4850) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [27.6.0](https://github.com/expo/eas-cli/releases/tag/v27.6.0) - 2026-11-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "27.5.0",
|
||||
"PRODUCTION": "27.5.0"
|
||||
"STAGING": "27.6.0",
|
||||
"PRODUCTION": "27.6.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 27.7.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Record Android emulator output and, when selected, device logcat during simulator sessions, and upload the last 20 MiB of each as a session artifact. ([#4822](https://github.com/expo/eas-cli/pull/4822) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
## 27.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "27.6.0",
|
||||
"@expo/eas-build-job": "27.6.0",
|
||||
"@expo/downloader": "27.7.0",
|
||||
"@expo/eas-build-job": "27.7.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "27.6.0",
|
||||
"@expo/logger": "27.7.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.6.0",
|
||||
"@expo/template-file": "27.6.0",
|
||||
"@expo/turtle-spawn": "27.6.0",
|
||||
"@expo/steps": "27.7.0",
|
||||
"@expo/template-file": "27.7.0",
|
||||
"@expo/turtle-spawn": "27.7.0",
|
||||
"@expo/xcpretty": "^4.4.7",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -62,6 +62,7 @@ import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/sta
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
|
||||
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
|
||||
import { createCollectAndroidEmulatorLogsBuildFunction } from './functions/collectAndroidEmulatorLogs';
|
||||
import { createStartSandboxBuildFunction } from './functions/startSandbox';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
@@ -129,6 +130,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
createCollectAndroidEmulatorLogsBuildFunction(ctx),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createRunAgentBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import {
|
||||
createCustomBuildContextMock,
|
||||
createGlobalContextMock,
|
||||
} from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { collectAndroidEmulatorLogsAsync } from '../../utils/androidEmulatorLogs';
|
||||
import { createCollectAndroidEmulatorLogsBuildFunction } from '../collectAndroidEmulatorLogs';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../utils/androidEmulatorLogs');
|
||||
|
||||
function createStep(callInputs: Record<string, unknown> = {}) {
|
||||
const logger = createMockLogger();
|
||||
const ctx = createCustomBuildContextMock();
|
||||
const global = createGlobalContextMock({ logger });
|
||||
const fn = createCollectAndroidEmulatorLogsBuildFunction(ctx);
|
||||
const step = fn.createBuildStepFromFunctionCall(global, {
|
||||
callInputs,
|
||||
env: { DEVICE_RUN_SESSION_ID: 'session' },
|
||||
});
|
||||
return { ctx, global, logger, fn, step };
|
||||
}
|
||||
|
||||
it('collects captured logs without a second source selection', async () => {
|
||||
const { ctx, global, fn, step } = createStep();
|
||||
expect(fn.supportedRuntimePlatforms).toEqual([BuildRuntimePlatform.LINUX]);
|
||||
await step.executeAsync();
|
||||
expect(collectAndroidEmulatorLogsAsync).toHaveBeenCalledWith(ctx, {
|
||||
global,
|
||||
deviceRunSessionId: 'session',
|
||||
logger: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports collection failure without changing the session result', async () => {
|
||||
jest
|
||||
.mocked(collectAndroidEmulatorLogsAsync)
|
||||
.mockRejectedValueOnce(new Error('collection failed'));
|
||||
const { step } = createStep();
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not collect Android emulator logs',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
+10
-2
@@ -2,6 +2,7 @@ import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
@@ -139,13 +140,20 @@ describe.each([
|
||||
corsOrigins?: string[]
|
||||
) {
|
||||
const fn = createFunction({} as CustomBuildContext);
|
||||
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
|
||||
const defaults = new Map(
|
||||
fn.inputProviders?.map(provider => {
|
||||
const input = provider(global, 'Test session');
|
||||
return [input.id, input.defaultValue];
|
||||
})
|
||||
);
|
||||
await fn.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
// Inputs a step does not get default to undefined, like in a real step call.
|
||||
// Match step execution: omitted inputs use their declared defaults.
|
||||
inputs: new Proxy(
|
||||
{
|
||||
public_preview: { value: publicPreview },
|
||||
@@ -153,7 +161,7 @@ describe.each([
|
||||
cors_origins: { value: corsOrigins },
|
||||
} as Record<string, { value: unknown }>,
|
||||
{
|
||||
get: (target, id: string) => target[id] ?? { value: undefined },
|
||||
get: (target, id: string) => target[id] ?? { value: defaults.get(id) },
|
||||
}
|
||||
),
|
||||
outputs: {},
|
||||
|
||||
+94
-28
@@ -4,6 +4,8 @@ import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorLogRing } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
|
||||
import {
|
||||
@@ -35,6 +37,7 @@ jest.mock('../../utils/localEgressGuard', () => ({
|
||||
startLocalEgressGuardRelayAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../utils/androidEmulatorLogs');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
@@ -108,43 +111,28 @@ function runStep(
|
||||
inputValues: Record<string, unknown> = {},
|
||||
signal?: AbortSignal,
|
||||
stepCtx: CustomBuildContext = ctx,
|
||||
warm = false
|
||||
warm = false,
|
||||
env: NodeJS.ProcessEnv = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx, { warm });
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const global = createGlobalContextMock({
|
||||
runtimePlatform,
|
||||
staticContextContent: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
});
|
||||
const inputs = Object.fromEntries(
|
||||
[
|
||||
'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: inputValues[id] }])
|
||||
(buildFunction.inputProviders ?? []).map(provider => {
|
||||
const input = provider(global, 'test');
|
||||
input.set(inputValues[input.id]);
|
||||
return [input.id, { value: input.rawValue }];
|
||||
})
|
||||
);
|
||||
return buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: {
|
||||
runtimePlatform,
|
||||
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
},
|
||||
global,
|
||||
} as unknown as BuildStepContext,
|
||||
{ inputs, outputs: {}, env: {}, signal } as never
|
||||
{ inputs, outputs: {}, env, signal } as never
|
||||
) as Promise<void>;
|
||||
}
|
||||
|
||||
@@ -173,6 +161,11 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(getAndroidEmulatorLogCapture)
|
||||
.mockImplementation(
|
||||
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
|
||||
);
|
||||
deviceLaunch = undefined;
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
|
||||
@@ -437,6 +430,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
|
||||
expect(sessionDevice()).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv: undefined,
|
||||
@@ -444,6 +438,61 @@ 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({
|
||||
logCapture: expect.objectContaining({ sources: ['emulator-output'] }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes an explicit empty source list and the workflow capture to Android boot', async () => {
|
||||
const capture = new AndroidEmulatorLogRing({ sources: [] });
|
||||
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'session' };
|
||||
await runStep(BuildRuntimePlatform.LINUX, { log_sources: [] }, undefined, ctx, false, env);
|
||||
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything(), env, []);
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ logCapture: capture })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid Android log sources before boot or downloading', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, {
|
||||
log_sources: ['unknown'],
|
||||
build_id: 'build-id',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects Android log sources on an iOS session before boot or downloading', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, {
|
||||
log_sources: ['device-logcat'],
|
||||
build_id: 'build-id',
|
||||
})
|
||||
).rejects.toThrow('Input "log_sources" selects Android emulator logs');
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('boots an iOS session with an explicit empty source list', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, { log_sources: [] });
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalled();
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('boots the Android Emulator with the device inputs', async () => {
|
||||
await runStep(BuildRuntimePlatform.LINUX, {
|
||||
device_identifier: 'pixel_7',
|
||||
@@ -470,6 +519,22 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the download and waits for it when Android boot fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, ctx, false, {
|
||||
DEVICE_RUN_SESSION_ID: 'session',
|
||||
})
|
||||
).rejects.toThrow('boot failed');
|
||||
|
||||
expect(download.aborted()).toBe(true);
|
||||
// The step returned only after the download stopped.
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start work for an already cancelled step', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('cancelled'));
|
||||
@@ -617,6 +682,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual([
|
||||
'log_sources',
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '../../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import { AndroidEmulatorLogRing } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
|
||||
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -24,6 +26,8 @@ jest.mock('../../utils/androidLocalEgress', () => ({
|
||||
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/androidEmulatorLogs');
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: {
|
||||
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
|
||||
@@ -62,6 +66,11 @@ 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([]);
|
||||
@@ -90,6 +99,30 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedMkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
it('preserves an explicit empty source list and one capture through boot retries and clones', async () => {
|
||||
mockedAndroidUtils.waitForReadyAsync.mockRejectedValueOnce(new Error('retry boot'));
|
||||
const capture = new AndroidEmulatorLogRing({ sources: [] });
|
||||
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
|
||||
await createStep(
|
||||
{ log_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.logCapture).toBe(capture);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid sources before starting the emulator', async () => {
|
||||
await expect(createStep({ log_sources: ['unknown'] }).executeAsync()).rejects.toThrow();
|
||||
expect(mockedAndroidUtils.startAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('local egress', () => {
|
||||
function createLocalEgress() {
|
||||
return {
|
||||
@@ -111,6 +144,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
env: expect.any(Object),
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
logCapture: expect.objectContaining({ sources: ['emulator-output', 'device-logcat'] }),
|
||||
});
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
|
||||
env: expect.any(Object),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { collectAndroidEmulatorLogsAsync } from '../utils/androidEmulatorLogs';
|
||||
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
|
||||
|
||||
export function createCollectAndroidEmulatorLogsBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'collect_android_emulator_logs',
|
||||
name: 'Collect Android emulator logs',
|
||||
__metricsId: 'eas/collect_android_emulator_logs',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
|
||||
fn: async ({ logger, global }, { env }) => {
|
||||
try {
|
||||
await collectAndroidEmulatorLogsAsync(ctx, {
|
||||
global,
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not collect Android emulator logs', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not collect Android logs; the session result is unchanged.'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
|
||||
import { parseLogSourcesInput } from '../utils/logSourcesInput';
|
||||
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
|
||||
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
@@ -71,6 +73,12 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
name: 'Start agent-device session',
|
||||
__metricsId: warm ? 'eas/start_warm_agent_device_session' : 'eas/start_agent_device_session',
|
||||
inputProviders: [
|
||||
// No default: an omitted input must stay distinguishable from an explicit selection.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'log_sources',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'device_identifier',
|
||||
@@ -188,6 +196,10 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
} else {
|
||||
validateAndroidLaunchOptions({ launchArgs, openUrl });
|
||||
}
|
||||
const logSources = parseLogSourcesInput(inputs.log_sources.value, {
|
||||
runtimePlatform,
|
||||
fallback: isIos ? [] : ['emulator-output'],
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
@@ -256,6 +268,7 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
logCapture: getAndroidEmulatorLogCapture(global, env, logSources),
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
|
||||
@@ -20,8 +20,12 @@ import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { type AndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogCapture';
|
||||
import { retryAsync } from '../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
|
||||
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../../utils/androidEmulatorLogSources';
|
||||
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
|
||||
import { parseLogSourcesInput } from '../utils/logSourcesInput';
|
||||
|
||||
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
|
||||
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
|
||||
@@ -33,6 +37,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
name: 'Start Android Emulator',
|
||||
__metricsId: 'eas/start_android_emulator',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'log_sources',
|
||||
required: false,
|
||||
defaultValue: [...ALL_ANDROID_EMULATOR_LOG_SOURCES],
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'device_name',
|
||||
required: false,
|
||||
@@ -78,10 +88,15 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, outputs, env }) => {
|
||||
fn: async ({ logger, global }, { inputs, outputs, env }) => {
|
||||
const logSources = parseLogSourcesInput(inputs.log_sources.value, {
|
||||
runtimePlatform: global.runtimePlatform,
|
||||
fallback: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
});
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
const logCapture = getAndroidEmulatorLogCapture(global, env, logSources);
|
||||
outputs.logcat_directory.set(logcatDirectory);
|
||||
|
||||
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
|
||||
@@ -95,6 +110,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
logCapture,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
@@ -133,6 +149,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
logcatDirectory,
|
||||
logCapture,
|
||||
});
|
||||
cloneSerialId = startResult.serialId;
|
||||
|
||||
@@ -202,6 +219,7 @@ export async function startAndroidEmulatorAsync({
|
||||
lcdHeight,
|
||||
lcdDensity,
|
||||
logcatDirectory,
|
||||
logCapture,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
@@ -212,6 +230,7 @@ export async function startAndroidEmulatorAsync({
|
||||
lcdHeight?: number;
|
||||
lcdDensity?: number;
|
||||
logcatDirectory: string;
|
||||
logCapture: AndroidEmulatorLogCapture;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<{
|
||||
@@ -293,6 +312,7 @@ export async function startAndroidEmulatorAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
logCapture,
|
||||
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
|
||||
});
|
||||
attemptSerialId = startResult.serialId;
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { type BuildStepGlobalContext } from '@expo/steps';
|
||||
import { once } from 'node:events';
|
||||
import fsPromises, {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
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 {
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
type AndroidEmulatorLogSource,
|
||||
} from '../../../utils/androidEmulatorLogSources';
|
||||
import {
|
||||
collectAndroidEmulatorLogsAsync,
|
||||
getAndroidEmulatorLogCapture,
|
||||
} from '../androidEmulatorLogs';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const sessionEnv = { DEVICE_RUN_SESSION_ID: 'session' };
|
||||
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
|
||||
let directory: string;
|
||||
let emulatorPath: string;
|
||||
let logcatPath: string;
|
||||
let emulatorRing: string;
|
||||
let logcatRing: string;
|
||||
let global: BuildStepGlobalContext;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
let uploadedContents: Map<string, string>;
|
||||
|
||||
beforeEach(async () => {
|
||||
global = {} as BuildStepGlobalContext;
|
||||
logger = createMockLogger();
|
||||
uploadedContents = new Map();
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-collection-'));
|
||||
emulatorPath = path.join(directory, 'device-attempt-1-emulator.log');
|
||||
logcatPath = path.join(directory, 'device-attempt-1-logcat.log');
|
||||
emulatorRing = path.join(directory, 'emulator-output.log');
|
||||
logcatRing = path.join(directory, 'device-logcat.log');
|
||||
upload.mockReset().mockImplementation(async (_ctx, { filename, openStream }) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
uploadedContents.set(filename, Buffer.concat(chunks).toString());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** `==== <launch> <ISO time> ====\n` precedes the first output of a launch. */
|
||||
const MARKER = String.raw`==== \S+ \S+ ====\n`;
|
||||
|
||||
async function captureLaunch(
|
||||
filePath: string,
|
||||
source: AndroidEmulatorLogSource,
|
||||
text: string,
|
||||
workflow = global
|
||||
): Promise<void> {
|
||||
const capture = getAndroidEmulatorLogCapture(
|
||||
workflow,
|
||||
sessionEnv,
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES
|
||||
);
|
||||
const input = Readable.from([Buffer.from(text)]);
|
||||
capture.attach(input, filePath, source);
|
||||
await once(input, 'end');
|
||||
await capture.finishFileAsync(filePath);
|
||||
}
|
||||
|
||||
function collect(): Promise<void> {
|
||||
return collectAndroidEmulatorLogsAsync(ctx, {
|
||||
global,
|
||||
deviceRunSessionId: 'session',
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ env: {}, file: 'device-attempt-1-emulator.log', expectedBytes: 20 * 1024 * 1024 + 1 },
|
||||
{ env: sessionEnv, file: 'emulator-output.log', expectedBytes: 20 * 1024 * 1024 },
|
||||
])(
|
||||
'retains $expectedBytes in $file with workflow environment $env',
|
||||
async ({ env, file, expectedBytes }) => {
|
||||
const capture = getAndroidEmulatorLogCapture(global, env, ALL_ANDROID_EMULATOR_LOG_SOURCES);
|
||||
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(path.join(directory, file))).size).toBe(expectedBytes);
|
||||
}
|
||||
);
|
||||
|
||||
it('uploads one artifact per source and ignores files outside the capture inventory', async () => {
|
||||
await captureLaunch(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLaunch(logcatPath, 'device-logcat', 'Android output');
|
||||
await captureLaunch(
|
||||
path.join(directory, 'retry-emulator.log'),
|
||||
'emulator-output',
|
||||
'retry output'
|
||||
);
|
||||
await writeFile(emulatorPath, 'not captured');
|
||||
const elsewhere = path.join(directory, 'elsewhere');
|
||||
await mkdir(elsewhere);
|
||||
await writeFile(path.join(elsewhere, 'other-emulator.log'), 'another job');
|
||||
await symlink(path.join(elsewhere, 'other-emulator.log'), logcatPath);
|
||||
await collect();
|
||||
|
||||
expect([...uploadedContents.keys()]).toEqual(['emulator-output.log', 'device-logcat.log']);
|
||||
expect(uploadedContents.get('emulator-output.log')).toMatch(
|
||||
new RegExp(`^${MARKER}host output\\n${MARKER}retry output$`)
|
||||
);
|
||||
expect(uploadedContents.get('device-logcat.log')).toMatch(
|
||||
new RegExp(`^${MARKER}Android output$`)
|
||||
);
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'emulator-output.log',
|
||||
kind: 'simulator-log',
|
||||
metadata: expect.objectContaining({
|
||||
platform: 'android',
|
||||
source: 'emulator-output',
|
||||
scope: 'emulator-process',
|
||||
originalSizeBytes: uploadedContents.get('emulator-output.log')?.length,
|
||||
omittedBytes: 0,
|
||||
truncated: false,
|
||||
}),
|
||||
signal: expect.any(AbortSignal),
|
||||
attempts: 2,
|
||||
})
|
||||
);
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'device-logcat.log',
|
||||
metadata: expect.objectContaining({ source: 'device-logcat', scope: 'device' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('seals a live producer before upload and keeps retry bodies identical without copying files', async () => {
|
||||
const input = new PassThrough();
|
||||
const capture = getAndroidEmulatorLogCapture(
|
||||
global,
|
||||
sessionEnv,
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES
|
||||
);
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
input.write('before');
|
||||
const contents: string[] = [];
|
||||
upload.mockImplementationOnce(async (_ctx, { size, openStream, metadata }) => {
|
||||
expect(metadata).toEqual(
|
||||
expect.objectContaining({ originalSizeBytes: size, truncated: false })
|
||||
);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
input.write(' ignored after finalization');
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
contents.push(Buffer.concat(chunks).toString());
|
||||
}
|
||||
});
|
||||
try {
|
||||
await collect();
|
||||
expect(contents).toHaveLength(2);
|
||||
expect(contents[0]).toMatch(new RegExp(`^${MARKER}before$`));
|
||||
expect(contents[1]).toBe(contents[0]);
|
||||
expect(await readFile(emulatorRing, 'utf8')).toBe(contents[0]);
|
||||
expect(await readdir(directory)).toEqual(['emulator-output.log']);
|
||||
expect(input.readableFlowing).toBe(true);
|
||||
const late = new PassThrough();
|
||||
capture.attach(late, logcatPath, 'device-logcat');
|
||||
late.end('not captured');
|
||||
expect(late.readableFlowing).toBe(true);
|
||||
await expect(fsPromises.stat(logcatRing)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
input.end();
|
||||
}
|
||||
});
|
||||
|
||||
it('continues with the other log after a failed upload and never repeats the collection', async () => {
|
||||
await captureLaunch(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLaunch(logcatPath, 'device-logcat', 'Android output');
|
||||
upload.mockRejectedValueOnce(new Error('storage unavailable'));
|
||||
await Promise.all([collect(), collect()]);
|
||||
await collect();
|
||||
expect(upload).toHaveBeenCalledTimes(2);
|
||||
expect(uploadedContents.get('device-logcat.log')).toMatch(/Android output$/);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('other logs will continue')
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a fresh 30 second budget for the next file after an upload times out', async () => {
|
||||
await captureLaunch(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLaunch(logcatPath, 'device-logcat', 'Android output');
|
||||
const controllers: AbortController[] = [];
|
||||
const timeout = jest.spyOn(AbortSignal, 'timeout').mockImplementation(() => {
|
||||
const controller = new AbortController();
|
||||
controllers.push(controller);
|
||||
return controller.signal;
|
||||
});
|
||||
upload.mockImplementationOnce(async (_ctx, { signal }) => {
|
||||
controllers[0].abort(new Error('upload timed out'));
|
||||
signal?.throwIfAborted();
|
||||
});
|
||||
await expect(collect()).resolves.toBeUndefined();
|
||||
expect(uploadedContents.get('device-logcat.log')).toMatch(/Android output$/);
|
||||
expect(timeout.mock.calls).toEqual([[30_000], [30_000]]);
|
||||
expect(upload.mock.calls[1][1].signal?.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('does not collect another workflow and warns when a captured file disappears', async () => {
|
||||
const otherWorkflow = {} as BuildStepGlobalContext;
|
||||
await captureLaunch(emulatorPath, 'emulator-output', 'other workflow', otherWorkflow);
|
||||
await collect();
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
await getAndroidEmulatorLogCapture(otherWorkflow, sessionEnv, []).finishAsync();
|
||||
await captureLaunch(path.join(directory, 'missing-emulator.log'), 'emulator-output', 'removed');
|
||||
await rm(emulatorRing);
|
||||
await expect(collect()).resolves.toBeUndefined();
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('emulator-output.log')
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads the tail of a truncated log with the retained size and omission metadata', async () => {
|
||||
const capture = getAndroidEmulatorLogCapture(
|
||||
global,
|
||||
sessionEnv,
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES
|
||||
);
|
||||
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();
|
||||
const uploaded = uploadedContents.get('emulator-output.log');
|
||||
expect(uploaded).toHaveLength(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
|
||||
expect(uploaded).not.toContain('head');
|
||||
expect(uploaded).not.toContain('====');
|
||||
const [, call] = upload.mock.calls[0];
|
||||
const { originalSizeBytes } = call.metadata as { originalSizeBytes: number };
|
||||
const omitted = originalSizeBytes - MAX_ANDROID_LOG_BYTES_PER_SOURCE;
|
||||
expect(omitted).toBeGreaterThanOrEqual(6);
|
||||
expect(call).toEqual(
|
||||
expect.objectContaining({
|
||||
size: MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
name: expect.stringContaining('truncated, last 20.0 MB retained'),
|
||||
metadata: expect.objectContaining({ omittedBytes: omitted, truncated: true }),
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -13,6 +13,7 @@ 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');
|
||||
@@ -123,6 +124,41 @@ 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,29 +22,17 @@ 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('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 () => {
|
||||
it('warns and resolves when the upload fails', async () => {
|
||||
await writeFile(filePath, '{}\n');
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.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;
|
||||
});
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'));
|
||||
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepGlobalContext } from '@expo/steps';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
type AndroidEmulatorLogCapture,
|
||||
AndroidEmulatorLogRing,
|
||||
type FinalizedAndroidEmulatorLog,
|
||||
} from '../../utils/androidEmulatorLogCapture';
|
||||
import { AndroidEmulatorLogFiles } from '../../utils/androidEmulatorLogFiles';
|
||||
import { type AndroidEmulatorLogSource } from '../../utils/androidEmulatorLogSources';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
|
||||
|
||||
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>;
|
||||
};
|
||||
|
||||
// A worker can execute more than one job; each workflow owns its capture and uploads.
|
||||
const collections = new WeakMap<BuildStepGlobalContext, Collection>();
|
||||
|
||||
/** The first launch of a workflow fixes its source selection; later calls reuse the capture. */
|
||||
export function getAndroidEmulatorLogCapture(
|
||||
global: BuildStepGlobalContext,
|
||||
env: NodeJS.ProcessEnv,
|
||||
sources: readonly AndroidEmulatorLogSource[]
|
||||
): AndroidEmulatorLogCapture {
|
||||
let collection = collections.get(global);
|
||||
if (!collection) {
|
||||
collection = {
|
||||
// Sessions upload a bounded ring per source; builds keep plain launch files.
|
||||
capture: env.DEVICE_RUN_SESSION_ID
|
||||
? new AndroidEmulatorLogRing({ sources })
|
||||
: new AndroidEmulatorLogFiles({ sources }),
|
||||
};
|
||||
collections.set(global, collection);
|
||||
}
|
||||
return collection.capture;
|
||||
}
|
||||
|
||||
export async function collectAndroidEmulatorLogsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
global,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
}: {
|
||||
global: BuildStepGlobalContext;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
const collection = collections.get(global);
|
||||
if (!collection) {
|
||||
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, {
|
||||
collection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
await collection.upload;
|
||||
}
|
||||
|
||||
async function uploadCapturedLogsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
collection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
}: {
|
||||
collection: Collection;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
const logs = await collection.capture.finishAsync();
|
||||
for (const log of logs) {
|
||||
const filename = path.basename(log.filePath);
|
||||
try {
|
||||
const uploaded = await uploadLogAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
log,
|
||||
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (uploaded) {
|
||||
logger.info(`Uploaded Android diagnostic log ${filename}.`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not upload Android log ${filename}; other logs will continue.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLogAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
log: { filePath, source, bytesSeen },
|
||||
signal,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
log: FinalizedAndroidEmulatorLog;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const filename = path.basename(filePath);
|
||||
return await uploadDeviceRunSessionFileAsync(ctx, {
|
||||
filePath,
|
||||
describe: size => {
|
||||
const truncationNote =
|
||||
size < bytesSeen ? `; truncated, last ${formatBytes(size)} 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,
|
||||
},
|
||||
};
|
||||
},
|
||||
signal,
|
||||
attempts: 2,
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
type ArtifactMetadata = {
|
||||
export type ArtifactMetadata = {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import {
|
||||
type AndroidEmulatorLogSource,
|
||||
AndroidEmulatorLogSourcesSchema,
|
||||
} from '../../utils/androidEmulatorLogSources';
|
||||
|
||||
/**
|
||||
* `log_sources` is unprefixed like every other step input, so the platform
|
||||
* rule is checked here at runtime, the way `network_capture` is.
|
||||
*/
|
||||
export function parseLogSourcesInput(
|
||||
value: unknown,
|
||||
{
|
||||
runtimePlatform,
|
||||
fallback,
|
||||
}: { runtimePlatform: BuildRuntimePlatform; fallback: readonly AndroidEmulatorLogSource[] }
|
||||
): readonly AndroidEmulatorLogSource[] {
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
const sources = AndroidEmulatorLogSourcesSchema.parse(value);
|
||||
if (sources.length > 0 && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
throw new UserError(
|
||||
'EAS_LOG_SOURCES_INVALID_INPUT',
|
||||
`Input "log_sources" selects Android emulator logs, and this session runs on iOS. Drop "log_sources", or run the session on Android.`
|
||||
);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
|
||||
|
||||
export async function uploadServeSimLogsFileAsync(
|
||||
ctx: CustomBuildContext,
|
||||
@@ -22,21 +20,22 @@ export async function uploadServeSimLogsFileAsync(
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
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] : [])]),
|
||||
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,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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,6 +13,7 @@ import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
import { type AndroidEmulatorLogCapture } from './androidEmulatorLogCapture';
|
||||
import { retryAsync } from './retry';
|
||||
|
||||
/** Android Virtual Device is the device we run. */
|
||||
@@ -327,19 +328,19 @@ export namespace AndroidEmulatorUtils {
|
||||
env,
|
||||
logcatDirectory,
|
||||
launchGate,
|
||||
logCapture,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logcatDirectory: string;
|
||||
launchGate?: AndroidEmulatorLaunchGate;
|
||||
logCapture: AndroidEmulatorLogCapture;
|
||||
}): Promise<{
|
||||
emulatorPromise: SpawnPromise<SpawnResult>;
|
||||
serialId: AndroidDeviceSerialId;
|
||||
logcatOutputPath: string;
|
||||
emulatorOutputPath: string;
|
||||
}> {
|
||||
let logcatOutputPath: string;
|
||||
let emulatorOutputPath: string;
|
||||
let logcatOutputPath: string | undefined;
|
||||
let emulatorOutputPath: string | undefined;
|
||||
try {
|
||||
await fs.promises.mkdir(logcatDirectory, { recursive: true });
|
||||
const safeDeviceName = deviceName.replace(/[^a-zA-Z0-9_.-]/g, '_');
|
||||
@@ -347,16 +348,21 @@ export namespace AndroidEmulatorUtils {
|
||||
.toString(16)
|
||||
.padStart(8, '0');
|
||||
const outputName = `${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}`;
|
||||
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
|
||||
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
|
||||
await fs.promises.writeFile(logcatOutputPath, '');
|
||||
await fs.promises.writeFile(emulatorOutputPath, '');
|
||||
if (logCapture.sources.includes('device-logcat')) {
|
||||
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
|
||||
}
|
||||
if (logCapture.sources.includes('emulator-output')) {
|
||||
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new SystemError(`Failed to prepare Android emulator output for ${deviceName}.`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
const logcatOutput = logcatOutputPath
|
||||
? await logCapture.prepareLogcatOutputAsync(logcatOutputPath)
|
||||
: undefined;
|
||||
const emulatorCommand = `${process.env.ANDROID_HOME}/emulator/emulator`;
|
||||
const emulatorArgs = [
|
||||
'-no-window',
|
||||
@@ -364,10 +370,7 @@ export namespace AndroidEmulatorUtils {
|
||||
'-writable-system',
|
||||
'-noaudio',
|
||||
'-no-snapshot-save',
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
logcatOutputPath,
|
||||
...(logcatOutput ? ['-logcat', '*:v', '-logcat-output', logcatOutput.path] : []),
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
@@ -381,49 +384,37 @@ export namespace AndroidEmulatorUtils {
|
||||
// We don't need to wait for emulator to exit gracefully.
|
||||
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
|
||||
};
|
||||
const outputStdio = emulatorOutputPath ? 'pipe' : 'ignore';
|
||||
const emulatorPromise = launchGate
|
||||
? spawn(
|
||||
launchGate.wrapperCommand,
|
||||
[...launchGate.wrapperArgs, emulatorCommand, ...emulatorArgs, ...launchGate.emulatorArgs],
|
||||
{
|
||||
detached: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
stdio: ['pipe', outputStdio, outputStdio],
|
||||
ignoreStdio: true,
|
||||
env: emulatorEnv,
|
||||
}
|
||||
)
|
||||
: spawn(emulatorCommand, emulatorArgs, {
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ['ignore', outputStdio, outputStdio],
|
||||
ignoreStdio: true,
|
||||
env: emulatorEnv,
|
||||
});
|
||||
const emulatorOutputStream = fs.createWriteStream(emulatorOutputPath, { flags: 'a' });
|
||||
let reportedEmulatorOutputError = false;
|
||||
emulatorOutputStream.on('error', err => {
|
||||
process.stderr.write(
|
||||
`Failed to write Android emulator output to ${emulatorOutputPath}: ${err}\n`
|
||||
);
|
||||
if (!reportedEmulatorOutputError) {
|
||||
reportedEmulatorOutputError = true;
|
||||
Sentry.capture('Failed to write Android emulator process output', err, {
|
||||
level: 'warning',
|
||||
tags: {
|
||||
errorCode: (err as NodeJS.ErrnoException).code ?? 'unknown',
|
||||
},
|
||||
extras: {
|
||||
deviceName,
|
||||
emulatorOutputPath,
|
||||
},
|
||||
});
|
||||
if (logcatOutput) {
|
||||
emulatorPromise.child.once('close', logcatOutput.close);
|
||||
}
|
||||
if (emulatorOutputPath) {
|
||||
for (const input of [emulatorPromise.child.stdout, emulatorPromise.child.stderr]) {
|
||||
if (input) {
|
||||
logCapture.attach(input, emulatorOutputPath, 'emulator-output');
|
||||
}
|
||||
}
|
||||
});
|
||||
// Only into the log file -- this process' stdout/stderr is not watched or uploaded.
|
||||
emulatorPromise.child.stdout?.pipe(emulatorOutputStream, { end: false });
|
||||
emulatorPromise.child.stderr?.pipe(emulatorOutputStream, { end: false });
|
||||
emulatorPromise.child.once('close', () => {
|
||||
emulatorOutputStream.end();
|
||||
});
|
||||
emulatorPromise.child.once('close', () => {
|
||||
void logCapture.finishFileAsync(emulatorOutputPath);
|
||||
});
|
||||
}
|
||||
// Piped stdio creates socket handles in this process which, unlike inherited
|
||||
// file descriptors, keep the event loop alive for as long as the emulator runs.
|
||||
// We never stop the emulator explicitly, so without unref-ing these the process
|
||||
@@ -473,7 +464,7 @@ export namespace AndroidEmulatorUtils {
|
||||
|
||||
// We don't want to await the SpawnPromise here.
|
||||
// eslint-disable-next-line @typescript-eslint/return-await
|
||||
return { emulatorPromise, serialId, logcatOutputPath, emulatorOutputPath };
|
||||
return { emulatorPromise, serialId };
|
||||
}
|
||||
|
||||
export async function waitForReadyAsync({
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
import { AndroidEmulatorLogFiles } from '../androidEmulatorLogFiles';
|
||||
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../androidEmulatorLogSources';
|
||||
|
||||
// We need to use real fs for cloning devices to work.
|
||||
jest.unmock('fs');
|
||||
@@ -78,6 +80,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
({ serialId } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -105,6 +108,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -135,6 +139,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
const { serialId: serialIdClone, emulatorPromise: emulatorPromiseClone } =
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName: cloneDeviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -172,6 +177,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -226,6 +232,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: envForAttempt,
|
||||
logcatDirectory,
|
||||
@@ -309,6 +316,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger,
|
||||
});
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory: testLogcatDirectory,
|
||||
@@ -319,7 +327,16 @@ describe('AndroidEmulatorUtils', () => {
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({ serialId, env: process.env });
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const contents = await fs.promises.readFile(startResult.emulatorOutputPath, 'utf-8');
|
||||
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'
|
||||
);
|
||||
if (!contents.includes('Android emulator version')) {
|
||||
throw new Error('Did not find the emulator version in emulator process output yet.');
|
||||
}
|
||||
@@ -340,7 +357,16 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const contents = await fs.promises.readFile(startResult.logcatOutputPath, 'utf-8');
|
||||
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'
|
||||
);
|
||||
if (!contents.includes(marker)) {
|
||||
throw new Error(`Did not find marker ${marker} in logcat output yet.`);
|
||||
}
|
||||
@@ -384,6 +410,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: 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, once } from 'node:events';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import { Socket } from 'node:net';
|
||||
import os from 'node:os';
|
||||
@@ -14,6 +14,17 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
import { AndroidEmulatorLogRing } from '../androidEmulatorLogCapture';
|
||||
import { AndroidEmulatorLogFiles } from '../androidEmulatorLogFiles';
|
||||
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');
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
@@ -35,9 +46,11 @@ const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
|
||||
describe('AndroidEmulatorUtils', () => {
|
||||
let temporaryDirectories: string[] = [];
|
||||
let homeDirectory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
temporaryDirectories = [];
|
||||
beforeEach(async () => {
|
||||
homeDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-utils-home-'));
|
||||
temporaryDirectories = [homeDirectory];
|
||||
jest.mocked(Sentry.capture).mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedRetryAsync.mockImplementation(async fn => await fn(0));
|
||||
@@ -55,7 +68,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
describe(AndroidEmulatorUtils.createAsync, () => {
|
||||
it('applies an explicit LCD configuration after screen scaling', async () => {
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
const avdDirectory = `${homeDirectory}/.android/avd/${deviceName}.avd`;
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
`${avdDirectory}/config.ini`,
|
||||
@@ -83,7 +96,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
HOME: homeDirectory,
|
||||
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
@@ -141,7 +154,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 = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
const avdDirectory = `${homeDirectory}/.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);
|
||||
@@ -157,7 +170,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
HOME: homeDirectory,
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
DEVICE_RUN_SESSION_ID: sessionId,
|
||||
},
|
||||
@@ -172,6 +185,24 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.startAsync, () => {
|
||||
let buildCapture: AndroidEmulatorLogFiles;
|
||||
beforeEach(() => {
|
||||
buildCapture = new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES });
|
||||
});
|
||||
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.
|
||||
@@ -202,35 +233,31 @@ describe('AndroidEmulatorUtils', () => {
|
||||
return { child };
|
||||
}
|
||||
|
||||
it('captures logcat and emulator process output', async () => {
|
||||
it('names log files after the device and detaches the emulator', 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');
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
logcatDirectory: outputDir,
|
||||
});
|
||||
child.emit('close');
|
||||
await buildCapture.finishAsync();
|
||||
|
||||
expect(result.logcatOutputPath).toMatch(
|
||||
expect(await logFile(outputDir, 'logcat')).toMatch(
|
||||
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
|
||||
);
|
||||
expect(result.emulatorOutputPath).toMatch(
|
||||
expect(await logFile(outputDir, 'emulator')).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.arrayContaining(['-logcat', '*:v', '-logcat-output', result.logcatOutputPath]),
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
})
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ detached: true, ignoreStdio: true })
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
@@ -242,28 +269,91 @@ 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,
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
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 stdoutPipe = jest.spyOn(child.stdout, 'pipe');
|
||||
const stderrPipe = jest.spyOn(child.stderr, 'pipe');
|
||||
const capture = new AndroidEmulatorLogFiles({
|
||||
sources: AndroidEmulatorLogSourcesSchema.parse(sources),
|
||||
});
|
||||
const prepare = jest.spyOn(capture, 'prepareLogcatOutputAsync');
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: capture,
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
});
|
||||
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 capture.finishAsync();
|
||||
if (capturesOutput) {
|
||||
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 () => {
|
||||
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 AndroidEmulatorLogRing({ sources: ['device-logcat'] });
|
||||
const close = jest.fn();
|
||||
const createPipe = jest.spyOn(capture, 'prepareLogcatOutputAsync').mockResolvedValue({
|
||||
path: '/logcat.pipe',
|
||||
close,
|
||||
closed: Promise.resolve(),
|
||||
});
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
logCapture: capture,
|
||||
});
|
||||
expect(createPipe).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
new RegExp(`^${outputDir}/bounded-emulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
|
||||
)
|
||||
);
|
||||
const emulatorOutputStreamClosed = once(emulatorOutputStream, 'close');
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['-logcat-output', '/logcat.pipe'])
|
||||
);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
child.emit('close');
|
||||
await emulatorOutputStreamClosed;
|
||||
await capture.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(await fs.promises.readdir(outputDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('spawns the emulator directly with the same arguments when there is no launch gate', async () => {
|
||||
@@ -273,7 +363,8 @@ describe('AndroidEmulatorUtils', () => {
|
||||
mockSuccessfulStart(deviceName);
|
||||
const env = { ANDROID_EMULATOR_EXTRA_ARGS: '-gpu swiftshader_indirect' };
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory: outputDir,
|
||||
@@ -294,7 +385,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
result.logcatOutputPath,
|
||||
await logFile(outputDir, 'logcat'),
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
@@ -331,7 +422,8 @@ describe('AndroidEmulatorUtils', () => {
|
||||
: gatedSpawn(command, args, options as any)) as any);
|
||||
const admitAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
@@ -358,7 +450,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
result.logcatOutputPath,
|
||||
await logFile(outputDir, 'logcat'),
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
@@ -395,6 +487,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
await expect(
|
||||
AndroidEmulatorUtils.startAsync({
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
@@ -419,6 +512,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
try {
|
||||
await expect(
|
||||
AndroidEmulatorUtils.startAsync({
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
logcatDirectory: outputDir,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import fs, { type FileHandle } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
AndroidEmulatorLogRing,
|
||||
MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
} from '../androidEmulatorLogCapture';
|
||||
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../androidEmulatorLogSources';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.mock('../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
|
||||
|
||||
let directory: string;
|
||||
beforeEach(async () => {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'android-capped-logs-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createCapture(maxBytes?: number): AndroidEmulatorLogRing {
|
||||
return new AndroidEmulatorLogRing({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES, maxBytes });
|
||||
}
|
||||
|
||||
/** `==== <name> <ISO time> ====\n` */
|
||||
function markerLength(name: string): number {
|
||||
return name.length + 36;
|
||||
}
|
||||
|
||||
async function record(
|
||||
capture: AndroidEmulatorLogRing,
|
||||
file: string,
|
||||
text: string,
|
||||
source: 'emulator-output' | 'device-logcat' = 'emulator-output'
|
||||
): Promise<void> {
|
||||
const input = Readable.from([Buffer.from(text)]);
|
||||
capture.attach(input, file, source);
|
||||
await once(input, 'end');
|
||||
await capture.finishFileAsync(file);
|
||||
}
|
||||
|
||||
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 one ring per source across launches and names each launch that wrote', async () => {
|
||||
const capture = createCapture(120);
|
||||
const emulatorRing = path.join(directory, 'emulator-output.log');
|
||||
const logcatRing = path.join(directory, 'device-logcat.log');
|
||||
await record(capture, path.join(directory, 'first-emulator.log'), 'first!');
|
||||
await record(capture, path.join(directory, 'retry-emulator.log'), 'second!!');
|
||||
await record(capture, path.join(directory, 'one-logcat.log'), 'device log', 'device-logcat');
|
||||
const logs = await capture.finishAsync();
|
||||
const seen = markerLength('first-emulator') + 6 + 1 + markerLength('retry-emulator') + 8;
|
||||
expect(logs).toEqual([
|
||||
{ filePath: emulatorRing, source: 'emulator-output', bytesSeen: seen },
|
||||
{ filePath: logcatRing, source: 'device-logcat', bytesSeen: markerLength('one-logcat') + 10 },
|
||||
]);
|
||||
expect(await fs.readFile(emulatorRing, 'utf8')).toMatch(
|
||||
/^==== first-emulator \S+ ====\nfirst!\n==== retry-emulator \S+ ====\nsecond!!$/
|
||||
);
|
||||
expect(await fs.readFile(logcatRing, 'utf8')).toMatch(/^==== one-logcat \S+ ====\ndevice log$/);
|
||||
expect((await fs.readdir(directory)).sort()).toEqual([
|
||||
'device-logcat.log',
|
||||
'emulator-output.log',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the tail of the final launch when the ring overflows', async () => {
|
||||
const capture = createCapture(120);
|
||||
await record(capture, path.join(directory, 'first-emulator.log'), 'first!');
|
||||
await record(capture, path.join(directory, 'retry-emulator.log'), 'y'.repeat(100));
|
||||
const [log] = await capture.finishAsync();
|
||||
const result = await fs.readFile(log.filePath, 'utf8');
|
||||
expect(result).toHaveLength(120);
|
||||
expect(result.endsWith('y'.repeat(100))).toBe(true);
|
||||
expect(result).not.toContain('first!');
|
||||
expect(log.bytesSeen).toBeGreaterThan(120);
|
||||
});
|
||||
|
||||
it('keeps the tail of a wrapped ring in order', async () => {
|
||||
const capture = createCapture(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);
|
||||
}
|
||||
const [log] = await capture.finishAsync();
|
||||
expect(await fs.readFile(log.filePath, 'utf8')).toBe('6789abcdef');
|
||||
});
|
||||
|
||||
it('lets overlapping launches of one source share the ring', async () => {
|
||||
const capture = createCapture(400);
|
||||
const a = new PassThrough();
|
||||
const b = new PassThrough();
|
||||
capture.attach(a, path.join(directory, 'clone-1.log'), 'emulator-output');
|
||||
capture.attach(b, path.join(directory, 'clone-2.log'), 'emulator-output');
|
||||
a.write('from clone 1 first\n');
|
||||
b.write('from clone 2\n');
|
||||
a.write('from clone 1 second\n');
|
||||
const [log] = await capture.finishAsync();
|
||||
const result = await fs.readFile(log.filePath, 'utf8');
|
||||
expect(result).toMatch(/==== clone-1 \S+ ====\n/);
|
||||
expect(result).toMatch(/==== clone-2 \S+ ====\n/);
|
||||
expect(result).toContain('from clone 2\n');
|
||||
expect(result.indexOf('from clone 1 first')).toBeLessThan(result.indexOf('from clone 1 second'));
|
||||
expect(result).toHaveLength(log.bytesSeen);
|
||||
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 () => {
|
||||
const file = path.join(directory, 'logcat.log');
|
||||
const capture = createCapture();
|
||||
const pipe = await capture.prepareLogcatOutputAsync(file);
|
||||
expect((await fs.stat(pipe.path)).isFIFO()).toBe(true);
|
||||
const writer = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`
|
||||
const fs = require('node:fs');
|
||||
const fd = fs.openSync(process.argv[1], 'w');
|
||||
fs.writeSync(fd, 'BEGIN\\n');
|
||||
const chunk = Buffer.alloc(64 * 1024, 'x');
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
let offset = 0;
|
||||
while (offset < chunk.length) offset += fs.writeSync(fd, chunk, offset);
|
||||
}
|
||||
fs.writeSync(fd, '\\nEND');
|
||||
fs.closeSync(fd);
|
||||
`,
|
||||
pipe.path,
|
||||
],
|
||||
{ stdio: 'ignore' }
|
||||
);
|
||||
try {
|
||||
expect(await once(writer, 'close')).toEqual([0, null]);
|
||||
pipe.close();
|
||||
pipe.close();
|
||||
await pipe.closed;
|
||||
const logs = await capture.finishAsync();
|
||||
const ring = path.join(directory, 'device-logcat.log');
|
||||
const result = await fs.readFile(ring);
|
||||
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(logs).toEqual([
|
||||
{
|
||||
filePath: ring,
|
||||
source: 'device-logcat',
|
||||
bytesSeen: 64 * 1024 * 1024 + 10 + markerLength('logcat'),
|
||||
},
|
||||
]);
|
||||
await expect(fs.stat(pipe.path)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
writer.kill();
|
||||
pipe.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('closes an unused logcat pipe when the emulator fails to launch', async () => {
|
||||
const file = path.join(directory, 'empty.log');
|
||||
const capture = createCapture();
|
||||
const pipe = await capture.prepareLogcatOutputAsync(file);
|
||||
pipe.close();
|
||||
await pipe.closed;
|
||||
await expect(fs.stat(pipe.path)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
const ring = path.join(directory, 'device-logcat.log');
|
||||
expect(await capture.finishAsync()).toEqual([
|
||||
{ filePath: ring, source: 'device-logcat', bytesSeen: 0 },
|
||||
]);
|
||||
expect((await fs.stat(ring)).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 = createCapture(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(path.join(directory, 'device-logcat.log'))).size).toBe(0);
|
||||
await expect(fs.stat(output.path)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
writer.kill();
|
||||
output.close();
|
||||
await output.closed;
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it('reports a disk write failure once and continues draining output', async () => {
|
||||
const capture = createCapture(10);
|
||||
const input = new PassThrough();
|
||||
const file = path.join(directory, 'failed.log');
|
||||
const diskError = Object.assign(new Error('full'), { code: 'ENOSPC' });
|
||||
jest.spyOn(fs, 'open').mockRejectedValueOnce(diskError);
|
||||
capture.attach(input, file, 'emulator-output');
|
||||
input.write('first');
|
||||
input.write('second');
|
||||
await capture.finishAsync();
|
||||
input.end('more');
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to write Android diagnostic log',
|
||||
diskError,
|
||||
expect.anything()
|
||||
);
|
||||
expect(input.readableFlowing).toBe(true);
|
||||
});
|
||||
|
||||
it('closes the file and reports once when sealing fails', async () => {
|
||||
const opened = trackOpenedFiles();
|
||||
const capture = createCapture(10);
|
||||
await record(capture, path.join(directory, 'wrapped.log'), '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()
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { AndroidEmulatorLogFiles } from '../androidEmulatorLogFiles';
|
||||
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../androidEmulatorLogSources';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.mock('../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
|
||||
|
||||
let directory: string;
|
||||
beforeEach(async () => {
|
||||
directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-build-logs-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await fs.promises.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function trackWriteStreams(): fs.WriteStream[] {
|
||||
const streams: fs.WriteStream[] = [];
|
||||
const createWriteStream = fs.createWriteStream;
|
||||
jest.spyOn(fs, 'createWriteStream').mockImplementation((file, options) => {
|
||||
const stream = createWriteStream(file, options);
|
||||
streams.push(stream);
|
||||
return stream;
|
||||
});
|
||||
return streams;
|
||||
}
|
||||
|
||||
it('appends one plain file per launch, closes it when the launch ends, and finalizes nothing', async () => {
|
||||
const streams = trackWriteStreams();
|
||||
const capture = new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES });
|
||||
const first = path.join(directory, 'first-emulator.log');
|
||||
const stdout = Readable.from([Buffer.from('out ')]);
|
||||
const stderr = Readable.from([Buffer.from('err')]);
|
||||
capture.attach(stdout, first, 'emulator-output');
|
||||
capture.attach(stderr, first, 'emulator-output');
|
||||
await Promise.all([once(stdout, 'end'), once(stderr, 'end')]);
|
||||
await capture.finishFileAsync(first);
|
||||
expect(streams).toHaveLength(1);
|
||||
expect(streams[0].closed).toBe(true);
|
||||
expect(await fs.promises.readFile(first, 'utf8')).toBe('out err');
|
||||
const retry = path.join(directory, 'retry-emulator.log');
|
||||
const input = Readable.from([Buffer.from('retry')]);
|
||||
capture.attach(input, retry, 'emulator-output');
|
||||
await once(input, 'end');
|
||||
expect(await capture.finishAsync()).toEqual([]);
|
||||
expect(streams[1].closed).toBe(true);
|
||||
expect((await fs.promises.readdir(directory)).sort()).toEqual([
|
||||
'first-emulator.log',
|
||||
'retry-emulator.log',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives the emulator the logcat file itself without creating a FIFO', async () => {
|
||||
const capture = new AndroidEmulatorLogFiles({ sources: ['device-logcat'] });
|
||||
const file = path.join(directory, 'build-logcat.log');
|
||||
const output = await capture.prepareLogcatOutputAsync(file);
|
||||
expect(output.path).toBe(file);
|
||||
expect(await fs.promises.readdir(directory)).toEqual(['build-logcat.log']);
|
||||
output.close();
|
||||
await output.closed;
|
||||
expect(await capture.finishAsync()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a write failure once and keeps draining the emulator', async () => {
|
||||
const capture = new AndroidEmulatorLogFiles({ sources: ['emulator-output'] });
|
||||
const input = new PassThrough();
|
||||
const file = path.join(directory, 'missing-directory', 'failed.log');
|
||||
capture.attach(input, file, 'emulator-output');
|
||||
input.write('first');
|
||||
await capture.finishFileAsync(file);
|
||||
input.write('second');
|
||||
input.end('more');
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to write Android diagnostic log',
|
||||
expect.objectContaining({ code: 'ENOENT' }),
|
||||
expect.anything()
|
||||
);
|
||||
expect(input.readableFlowing).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import fs from 'node:fs';
|
||||
import { type FileHandle } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { type Readable, Writable } from 'node:stream';
|
||||
import { finished } from 'node:stream/promises';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
import { type AndroidEmulatorLogSource } from './androidEmulatorLogSources';
|
||||
import { createAndroidLogcatPipeAsync } from './androidLogcatPipe';
|
||||
|
||||
export const MAX_ANDROID_LOG_BYTES_PER_SOURCE = 20 * 1024 * 1024;
|
||||
|
||||
export type FinalizedAndroidEmulatorLog = {
|
||||
readonly filePath: string;
|
||||
readonly source: AndroidEmulatorLogSource;
|
||||
readonly bytesSeen: number;
|
||||
};
|
||||
|
||||
export type AndroidLogcatOutput = {
|
||||
/** Where the emulator writes its logcat: a FIFO for sessions, the file itself for builds. */
|
||||
path: string;
|
||||
close: () => void;
|
||||
/**
|
||||
* Resolves once the pipe reader exited and the FIFO is removed. Emulator
|
||||
* launch does not wait for it, because the emulator can outlive log
|
||||
* collection; tests use it to observe the cleanup.
|
||||
*/
|
||||
closed: Promise<void>;
|
||||
};
|
||||
|
||||
/** Records emulator output per launch file; `getAndroidEmulatorLogCapture` picks the writer. */
|
||||
export type AndroidEmulatorLogCapture = {
|
||||
readonly sources: readonly AndroidEmulatorLogSource[];
|
||||
attach(input: Readable, filePath: string, source: AndroidEmulatorLogSource): void;
|
||||
finishFileAsync(filePath: string): Promise<void>;
|
||||
finishAsync(): Promise<readonly FinalizedAndroidEmulatorLog[]>;
|
||||
prepareLogcatOutputAsync(filePath: string): Promise<AndroidLogcatOutput>;
|
||||
};
|
||||
|
||||
/** One ring of `maxBytes` bytes: `writePos` wraps and the newest `retained` bytes end at it. */
|
||||
type Ring = {
|
||||
filePath: string;
|
||||
source: AndroidEmulatorLogSource;
|
||||
/** 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;
|
||||
};
|
||||
|
||||
/** One emulator launch. Launches of a source can overlap: clones, or a late exit. */
|
||||
type Launch = {
|
||||
ring: Ring;
|
||||
stream: Writable;
|
||||
inputs: Set<Readable>;
|
||||
done: Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* One budget per session, shared by all emulator launches and retries.
|
||||
*
|
||||
* Each source has one ring file, `<source>.log` beside the launch files, so
|
||||
* the last `maxBytes` bytes of a source survive across launches and the tail
|
||||
* of the final attempt is always kept. A marker line names each launch that
|
||||
* produced output. Overlapping launches share the ring, so their lines can
|
||||
* interleave. Logcat arrives through a named pipe so the budget applies to it.
|
||||
*/
|
||||
export class AndroidEmulatorLogRing implements AndroidEmulatorLogCapture {
|
||||
readonly sources: readonly AndroidEmulatorLogSource[];
|
||||
private readonly maxBytes: number;
|
||||
private readonly rings = new Map<AndroidEmulatorLogSource, Ring>();
|
||||
private readonly launches = new Map<string, Launch>();
|
||||
private finishing = false;
|
||||
|
||||
constructor({
|
||||
sources,
|
||||
maxBytes = MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
}: {
|
||||
sources: readonly AndroidEmulatorLogSource[];
|
||||
maxBytes?: number;
|
||||
}) {
|
||||
this.sources = sources;
|
||||
this.maxBytes = maxBytes;
|
||||
}
|
||||
|
||||
attach(input: Readable, filePath: string, source: AndroidEmulatorLogSource): void {
|
||||
if (this.finishing) {
|
||||
input.resume();
|
||||
return;
|
||||
}
|
||||
let launch = this.launches.get(filePath);
|
||||
if (!launch) {
|
||||
launch = this.startLaunch(filePath, this.ringFor(filePath, source));
|
||||
this.launches.set(filePath, launch);
|
||||
}
|
||||
launch.inputs.add(input);
|
||||
input.pipe(launch.stream, { end: false });
|
||||
}
|
||||
|
||||
async finishFileAsync(filePath: string): Promise<void> {
|
||||
const launch = this.launches.get(filePath);
|
||||
if (!launch) {
|
||||
return;
|
||||
}
|
||||
for (const input of launch.inputs) {
|
||||
input.unpipe(launch.stream);
|
||||
// Keep draining: closing a full log must never block or break the emulator.
|
||||
input.resume();
|
||||
}
|
||||
launch.inputs.clear();
|
||||
launch.stream.end();
|
||||
await launch.done;
|
||||
}
|
||||
|
||||
async finishAsync(): Promise<readonly FinalizedAndroidEmulatorLog[]> {
|
||||
this.finishing = true;
|
||||
await Promise.all([...this.launches.keys()].map(file => this.finishFileAsync(file)));
|
||||
await Promise.all([...this.rings.values()].map(ring => this.sealAsync(ring)));
|
||||
// Every writer is closed, so uploads can reopen these files on each retry.
|
||||
return [...this.rings.values()].map(({ filePath, source, bytesSeen }) => ({
|
||||
filePath,
|
||||
source,
|
||||
bytesSeen,
|
||||
}));
|
||||
}
|
||||
|
||||
async prepareLogcatOutputAsync(filePath: string): Promise<AndroidLogcatOutput> {
|
||||
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 ringFor(filePath: string, source: AndroidEmulatorLogSource): Ring {
|
||||
let ring = this.rings.get(source);
|
||||
if (!ring) {
|
||||
ring = this.open(path.join(path.dirname(filePath), `${source}.log`), source);
|
||||
this.rings.set(source, ring);
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
private open(filePath: string, source: AndroidEmulatorLogSource): Ring {
|
||||
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 },
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
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;
|
||||
};
|
||||
return { filePath, source, run, bytesSeen: 0, writePos: 0, retained: 0 };
|
||||
}
|
||||
|
||||
private startLaunch(filePath: string, ring: Ring): Launch {
|
||||
let marked = false;
|
||||
const stream = new Writable({
|
||||
write: (chunk: Buffer, _encoding, callback) => {
|
||||
if (!marked) {
|
||||
marked = true;
|
||||
// Written before the first chunk only, so a silent launch leaves no trace.
|
||||
const name = path.basename(filePath, '.log');
|
||||
const marker = `==== ${name} ${new Date().toISOString()} ====\n`;
|
||||
void this.write(ring, Buffer.from(ring.bytesSeen > 0 ? `\n${marker}` : marker));
|
||||
}
|
||||
void this.write(ring, chunk).then(() => callback());
|
||||
},
|
||||
});
|
||||
return { ring, stream, inputs: new Set(), done: finished(stream) };
|
||||
}
|
||||
|
||||
/** Account synchronously, then queue the I/O, so overlapping launches share one ring consistently. */
|
||||
private write(ring: Ring, chunk: Buffer): Promise<void> {
|
||||
const max = this.maxBytes;
|
||||
ring.bytesSeen += chunk.length;
|
||||
const data = chunk.length > max ? chunk.subarray(chunk.length - max) : chunk;
|
||||
const position = ring.writePos;
|
||||
const head = Math.min(data.length, max - position);
|
||||
ring.writePos = (position + data.length) % max;
|
||||
ring.retained = Math.min(ring.retained + data.length, max);
|
||||
return ring.run(async handle => {
|
||||
await writeAtAsync(handle, data.subarray(0, head), position);
|
||||
await writeAtAsync(handle, data.subarray(head), 0);
|
||||
});
|
||||
}
|
||||
|
||||
/** Rewrite the ring in log order, drop the overwritten bytes, and close the file. */
|
||||
private sealAsync(ring: Ring): Promise<void> {
|
||||
ring.sealed ??= ring.run(async handle => {
|
||||
try {
|
||||
const { retained } = ring;
|
||||
let start = ring.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 ring.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}`);
|
||||
}
|
||||
offset += bytesRead;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from 'node:fs';
|
||||
import { type Readable } from 'node:stream';
|
||||
import { finished } from 'node:stream/promises';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
import {
|
||||
type AndroidEmulatorLogCapture,
|
||||
type AndroidLogcatOutput,
|
||||
type FinalizedAndroidEmulatorLog,
|
||||
} from './androidEmulatorLogCapture';
|
||||
import { type AndroidEmulatorLogSource } from './androidEmulatorLogSources';
|
||||
|
||||
type LaunchFile = { stream: fs.WriteStream; inputs: Set<Readable> };
|
||||
|
||||
/**
|
||||
* Ordinary builds: one plain file per launch, appended without a budget and
|
||||
* closed when the launch ends. The emulator writes logcat to its file directly.
|
||||
* Nothing is finalized or uploaded.
|
||||
*/
|
||||
export class AndroidEmulatorLogFiles implements AndroidEmulatorLogCapture {
|
||||
readonly sources: readonly AndroidEmulatorLogSource[];
|
||||
private readonly files = new Map<string, LaunchFile>();
|
||||
|
||||
constructor({ sources }: { sources: readonly AndroidEmulatorLogSource[] }) {
|
||||
this.sources = sources;
|
||||
}
|
||||
|
||||
attach(input: Readable, filePath: string, source: AndroidEmulatorLogSource): void {
|
||||
let file = this.files.get(filePath);
|
||||
if (!file) {
|
||||
const inputs = new Set<Readable>();
|
||||
const stream = fs.createWriteStream(filePath, { flags: 'a' });
|
||||
stream.once('error', err => {
|
||||
Sentry.capture('Failed to write Android diagnostic log', err, {
|
||||
level: 'warning',
|
||||
extras: { filePath, source },
|
||||
});
|
||||
// The pipe detaches on error; keep draining so the emulator never blocks.
|
||||
for (const attached of inputs) {
|
||||
attached.resume();
|
||||
}
|
||||
});
|
||||
file = { stream, inputs };
|
||||
this.files.set(filePath, file);
|
||||
}
|
||||
file.inputs.add(input);
|
||||
input.pipe(file.stream, { end: false });
|
||||
}
|
||||
|
||||
async finishFileAsync(filePath: string): Promise<void> {
|
||||
const file = this.files.get(filePath);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
for (const input of file.inputs) {
|
||||
input.unpipe(file.stream);
|
||||
input.resume();
|
||||
}
|
||||
file.inputs.clear();
|
||||
file.stream.end();
|
||||
// A write error was reported when it happened.
|
||||
await finished(file.stream).catch(() => {});
|
||||
}
|
||||
|
||||
async finishAsync(): Promise<readonly FinalizedAndroidEmulatorLog[]> {
|
||||
await Promise.all([...this.files.keys()].map(file => this.finishFileAsync(file)));
|
||||
return [];
|
||||
}
|
||||
|
||||
async prepareLogcatOutputAsync(filePath: string): Promise<AndroidLogcatOutput> {
|
||||
await fs.promises.writeFile(filePath, '');
|
||||
return { path: filePath, close: () => {}, closed: Promise.resolve() };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const AndroidEmulatorLogSourcesSchema = z.array(
|
||||
z.enum(['emulator-output', 'device-logcat'])
|
||||
);
|
||||
export type AndroidEmulatorLogSource = z.infer<typeof AndroidEmulatorLogSourcesSchema>[number];
|
||||
|
||||
// Existing build workflows capture both sources. Device sessions pass their selection explicitly.
|
||||
export const ALL_ANDROID_EMULATOR_LOG_SOURCES: readonly AndroidEmulatorLogSource[] = [
|
||||
'emulator-output',
|
||||
'device-logcat',
|
||||
];
|
||||
@@ -0,0 +1,66 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "27.6.0"
|
||||
"@expo/steps": "27.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "27.6.0",
|
||||
"@expo/logger": "27.7.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "27.6.0",
|
||||
"@expo/turtle-spawn": "27.7.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# eas-cli
|
||||
|
||||
## 27.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add `--git-branch` and `--git-commit-hash` to `eas workflow:run` to record the branch and commit of the uploaded project. ([#4815](https://github.com/expo/eas-cli/pull/4815) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stop spinners from flickering when their text wraps onto a second line, such as the `simulator:start` queue message in a narrow terminal. ([#4850](https://github.com/expo/eas-cli/pull/4850) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## 27.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
+173
-161
File diff suppressed because it is too large.
Load diff
Generated
+250
-19
@@ -1907,6 +1907,18 @@
|
||||
"name": "environmentSecrets",
|
||||
"description": "Environment secrets for an account",
|
||||
"args": [
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "filterNames",
|
||||
"description": null,
|
||||
@@ -1951,7 +1963,20 @@
|
||||
{
|
||||
"name": "environmentVariableEnvironments",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"args": [
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
@@ -2004,6 +2029,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "filterNames",
|
||||
"description": null,
|
||||
@@ -2061,6 +2098,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "filterNames",
|
||||
"description": null,
|
||||
@@ -4876,7 +4925,7 @@
|
||||
},
|
||||
{
|
||||
"name": "setEnvironmentVariableProtectionEnabled",
|
||||
"description": "Set whether environment variable protection is on for this account. When it is on, reading,\nusing, creating, changing, or deleting an environment variable whose environments are\nexclusively production needs the PUBLISH_PROTECTED permission on the account, whatever its\nvisibility, in every project of the account. Requires the ADMIN permission on the account.\nTurning protection off also requires a sudo session.",
|
||||
"description": "Set whether environment variable protection is on for this account. When it is on, reading,\nusing, creating, changing, or deleting an environment variable whose environments are\nexclusively production needs the PUBLISH_PROTECTED permission on the account, whatever its\nvisibility, in every project of the account. Requires the ADMIN permission on the account.",
|
||||
"args": [
|
||||
{
|
||||
"name": "accountID",
|
||||
@@ -11640,6 +11689,18 @@
|
||||
"name": "environmentSecrets",
|
||||
"description": "Environment secrets for an app",
|
||||
"args": [
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "filterNames",
|
||||
"description": null,
|
||||
@@ -11684,7 +11745,20 @@
|
||||
{
|
||||
"name": "environmentVariableEnvironments",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"args": [
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
@@ -11721,6 +11795,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "filterNames",
|
||||
"description": null,
|
||||
@@ -11778,6 +11864,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "filterNames",
|
||||
"description": null,
|
||||
@@ -20634,7 +20732,7 @@
|
||||
"fields": [
|
||||
{
|
||||
"name": "affectedSessions",
|
||||
"description": "Distinct sessions on this platform that hit any exception.",
|
||||
"description": "Distinct sessions on this platform that hit any exception, including installations outside the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20650,7 +20748,7 @@
|
||||
},
|
||||
{
|
||||
"name": "affectedUsers",
|
||||
"description": "Distinct users on this platform that hit any exception.",
|
||||
"description": "Distinct users on this platform that hit any exception, including installations outside the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20666,7 +20764,7 @@
|
||||
},
|
||||
{
|
||||
"name": "crashFreeSessions",
|
||||
"description": "Fraction (0..1) of this platform's active sessions without a fatal error.",
|
||||
"description": "Fraction (0..1) of this platform's active sessions without a fatal error. Counts only installations in the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20682,7 +20780,7 @@
|
||||
},
|
||||
{
|
||||
"name": "crashFreeUsers",
|
||||
"description": "Fraction (0..1) of this platform's active users without a fatal error.",
|
||||
"description": "Fraction (0..1) of this platform's active users without a fatal error. Counts only installations in the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20796,7 +20894,7 @@
|
||||
"fields": [
|
||||
{
|
||||
"name": "affectedSessions",
|
||||
"description": "Distinct sessions that hit any exception.",
|
||||
"description": "Distinct sessions that hit any exception, including installations outside the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20812,7 +20910,7 @@
|
||||
},
|
||||
{
|
||||
"name": "affectedUsers",
|
||||
"description": "Distinct users that hit any exception.",
|
||||
"description": "Distinct users that hit any exception, including installations outside the sampleRate sample that send errors because of errorSampleRate.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20828,7 +20926,7 @@
|
||||
},
|
||||
{
|
||||
"name": "crashFreeSessions",
|
||||
"description": "Fraction (0..1) of active sessions without a fatal error. Denominator spans app_metrics and app_events.",
|
||||
"description": "Fraction (0..1) of active sessions without a fatal error. Counts only installations in the sampleRate sample. Denominator spans app_metrics and app_events.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20844,7 +20942,7 @@
|
||||
},
|
||||
{
|
||||
"name": "crashFreeUsers",
|
||||
"description": "Fraction (0..1) of active users without a fatal error.",
|
||||
"description": "Fraction (0..1) of active users without a fatal error. Counts only installations in the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -26227,7 +26325,7 @@
|
||||
},
|
||||
{
|
||||
"name": "crashFreeSessions",
|
||||
"description": "Fraction (0..1) of active sessions without a fatal error.",
|
||||
"description": "Fraction (0..1) of active sessions without a fatal error. Counts only installations in the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -26243,7 +26341,7 @@
|
||||
},
|
||||
{
|
||||
"name": "crashFreeUsers",
|
||||
"description": "Fraction (0..1) of active users without a fatal error.",
|
||||
"description": "Fraction (0..1) of active users without a fatal error. Counts only installations in the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -26259,7 +26357,7 @@
|
||||
},
|
||||
{
|
||||
"name": "crashedUsers",
|
||||
"description": "Distinct users that hit a fatal error in range.",
|
||||
"description": "Distinct users that hit a fatal error in range, including installations outside the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -27930,6 +28028,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "scopes",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "statusCodes",
|
||||
"description": null,
|
||||
@@ -28380,6 +28498,22 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "scopeName",
|
||||
"description": "The instrumentation scope that produced the span (OTLP scopeSpans.scope.name), such as\nexpo-observe.network. Empty when the span was sent without a scope or stored before scopes were kept.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "sessionId",
|
||||
"description": null,
|
||||
@@ -29074,6 +29208,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "scopes",
|
||||
"description": "Spans produced by any of these instrumentation scopes, such as expo-observe.network.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "sessionId",
|
||||
"description": null,
|
||||
@@ -47370,7 +47524,7 @@
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "deviceIdentifier",
|
||||
"description": "AVD hardware profile id to use for the emulator (e.g. \"pixel_9\").",
|
||||
"description": "AVD hardware profile id to use for the emulator (e.g. \"pixel_9\"). Defaults to \"medium_phone\".",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
@@ -48791,7 +48945,7 @@
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "deviceIdentifier",
|
||||
"description": "Simulator device name or UDID to use for the session (e.g. \"iPhone 16 Pro\").",
|
||||
"description": "Simulator device name or UDID to use for the session (e.g. \"iPhone 16 Pro\"). Defaults to\n\"iPhone 17\".",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
@@ -52297,6 +52451,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "deviceIdentifier",
|
||||
"description": "The simulator or emulator the session starts: the requested simulator device\nname or UDID on iOS, or the AVD hardware profile id on Android, or the platform\ndefault when none is requested. Null on sessions created before it was saved.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "finishedAt",
|
||||
"description": null,
|
||||
@@ -60746,9 +60912,13 @@
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
@@ -102791,12 +102961,67 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "WorkflowRunGitInput",
|
||||
"description": "Git metadata for a run started from an uploaded project. The commit must exist in the\nproject's linked GitHub repository. The run records the branch and commit but is not\nlinked to the repository.",
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "branch",
|
||||
"description": "Branch name such as \"main\". Stored as refs/heads/<branch>. Fully qualified refs are rejected.",
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "commitHash",
|
||||
"description": "Full commit SHA. The commit message is read from GitHub.",
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"interfaces": null,
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "WorkflowRunInput",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "git",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "WorkflowRunGitInput",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "inputs",
|
||||
"description": null,
|
||||
@@ -103446,6 +103671,12 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "OBSERVE_NEW_ERROR",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "REPACK_EXPO_GO",
|
||||
"description": null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,12 +48,12 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "27.6.0",
|
||||
"@expo/eas-json": "27.6.0",
|
||||
"@expo/eas-build-job": "27.7.0",
|
||||
"@expo/eas-json": "27.7.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "27.6.0",
|
||||
"@expo/logger": "27.7.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.6.0",
|
||||
"@expo/steps": "27.7.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import readline from 'readline';
|
||||
import { Writable } from 'stream';
|
||||
|
||||
import type { ora as oraType } from '../ora';
|
||||
|
||||
jest.unmock('ora');
|
||||
|
||||
class FakeTTYStream extends EventEmitter {
|
||||
const CLEAR_LINE = '\x1b[2K';
|
||||
|
||||
// Implements _writev like a TTY stream, so corked writes arrive in one call.
|
||||
class FakeTTYStream extends Writable {
|
||||
isTTY = true;
|
||||
columns = 200;
|
||||
clearLineCalls = 0;
|
||||
writes: string[] = [];
|
||||
|
||||
write(): boolean {
|
||||
return true;
|
||||
override _write(chunk: Buffer, _encoding: BufferEncoding, callback: () => void): void {
|
||||
this.writes.push(chunk.toString());
|
||||
callback();
|
||||
}
|
||||
|
||||
clearLine(): boolean {
|
||||
override _writev(chunks: { chunk: Buffer }[], callback: () => void): void {
|
||||
this.writes.push(chunks.map(({ chunk }) => chunk.toString()).join(''));
|
||||
callback();
|
||||
}
|
||||
|
||||
clearLine(dir: readline.Direction): boolean {
|
||||
this.clearLineCalls++;
|
||||
return true;
|
||||
return readline.clearLine(this, dir);
|
||||
}
|
||||
|
||||
cursorTo(): boolean {
|
||||
return true;
|
||||
cursorTo(x: number): boolean {
|
||||
return readline.cursorTo(this, x);
|
||||
}
|
||||
|
||||
moveCursor(): boolean {
|
||||
return true;
|
||||
moveCursor(dx: number, dy: number): boolean {
|
||||
return readline.moveCursor(this, dx, dy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,4 +79,43 @@ describe('ora', () => {
|
||||
spinner.stop();
|
||||
expect(stream.listenerCount('resize')).toBe(0);
|
||||
});
|
||||
|
||||
it('sends each frame as one write', () => {
|
||||
const text = 'x'.repeat(150);
|
||||
const stream = new FakeTTYStream();
|
||||
stream.columns = 80;
|
||||
const spinner = ora({
|
||||
text,
|
||||
stream: stream as unknown as NodeJS.WritableStream,
|
||||
discardStdin: false,
|
||||
}).start();
|
||||
stream.writes = [];
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
expect(stream.writes.length).toBeGreaterThan(1);
|
||||
for (const frame of stream.writes) {
|
||||
expect(frame.split(CLEAR_LINE)).toHaveLength(3);
|
||||
expect(frame).toContain(text);
|
||||
}
|
||||
|
||||
spinner.stop();
|
||||
});
|
||||
|
||||
it('clears the spinner and writes the final line as one write', () => {
|
||||
const stream = new FakeTTYStream();
|
||||
stream.columns = 80;
|
||||
const spinner = ora({
|
||||
text: 'x'.repeat(150),
|
||||
stream: stream as unknown as NodeJS.WritableStream,
|
||||
discardStdin: false,
|
||||
}).start();
|
||||
stream.writes = [];
|
||||
|
||||
spinner.succeed('done');
|
||||
|
||||
expect(stream.writes).toHaveLength(1);
|
||||
expect(stream.writes[0].split(CLEAR_LINE)).toHaveLength(3);
|
||||
expect(stream.writes[0]).toContain('done');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { fileExistsAsync, maybeReadStdinAsync } from '../../../commandUtils/workflow/utils';
|
||||
import { WorkflowRevisionMutation } from '../../../graphql/mutations/WorkflowRevisionMutation';
|
||||
import { WorkflowRunMutation } from '../../../graphql/mutations/WorkflowRunMutation';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
|
||||
import { uploadAccountScopedFileAsync } from '../../../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../../../project/uploadAccountScopedProjectSourceAsync';
|
||||
import { WorkflowFile } from '../../../utils/workflowFile';
|
||||
import WorkflowRun from '../run';
|
||||
|
||||
jest.mock('../../../graphql/mutations/WorkflowRevisionMutation');
|
||||
jest.mock('../../../graphql/mutations/WorkflowRunMutation');
|
||||
jest.mock('../../../project/projectUtils');
|
||||
jest.mock('../../../project/uploadAccountScopedFileAsync');
|
||||
jest.mock('../../../project/uploadAccountScopedProjectSourceAsync');
|
||||
jest.mock('../../../utils/workflowFile');
|
||||
jest.mock('../../../commandUtils/workflow/utils', () => ({
|
||||
...jest.requireActual('../../../commandUtils/workflow/utils'),
|
||||
fileExistsAsync: jest.fn(),
|
||||
maybeReadStdinAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../log', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
log: jest.fn(),
|
||||
newLine: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
link: jest.fn((url: string) => url),
|
||||
}));
|
||||
|
||||
const mockValidate = jest.mocked(WorkflowRevisionMutation.validateWorkflowYamlConfigAsync);
|
||||
const mockCreateRun = jest.mocked(WorkflowRunMutation.createWorkflowRunAsync);
|
||||
const mockGetOwner = jest.mocked(getOwnerAccountForProjectIdAsync);
|
||||
const mockUploadProject = jest.mocked(uploadAccountScopedProjectSourceAsync);
|
||||
const mockUploadFile = jest.mocked(uploadAccountScopedFileAsync);
|
||||
const mockReadWorkflow = jest.mocked(WorkflowFile.readWorkflowFileContentsAsync);
|
||||
const mockFileExists = jest.mocked(fileExistsAsync);
|
||||
const mockReadStdin = jest.mocked(maybeReadStdinAsync);
|
||||
|
||||
describe('WorkflowRun --git-branch and --git-commit-hash', () => {
|
||||
const commitHash = '1234567890abcdef1234567890abcdef12345678';
|
||||
let mockConfig: Config;
|
||||
let exitSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
mockConfig = new Config({ root: __dirname });
|
||||
mockConfig.runHook = async () => ({ failures: [], successes: [] });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as never);
|
||||
mockReadStdin.mockResolvedValue(null);
|
||||
mockGetOwner.mockResolvedValue({ id: 'acct-1', name: 'testuser' } as never);
|
||||
mockValidate.mockResolvedValue(undefined as never);
|
||||
mockReadWorkflow.mockResolvedValue({
|
||||
filePath: '.eas/workflows/test.yml',
|
||||
yamlConfig: 'jobs:\n a:\n steps: []\n',
|
||||
} as never);
|
||||
mockFileExists.mockResolvedValue(false);
|
||||
mockUploadProject.mockResolvedValue({ projectArchiveBucketKey: 'archive-key' } as never);
|
||||
mockUploadFile.mockResolvedValue({ fileBucketKey: 'file-key' } as never);
|
||||
mockCreateRun.mockResolvedValue({ id: 'run-1' } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createCommand(flags: {
|
||||
'git-branch'?: string;
|
||||
'git-commit-hash'?: string;
|
||||
}): WorkflowRun {
|
||||
const command = new WorkflowRun(['.eas/workflows/test.yml'], mockConfig);
|
||||
// @ts-expect-error getContextAsync/parse are protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
getDynamicPrivateProjectConfigAsync: async () => ({
|
||||
projectId: 'app-1',
|
||||
exp: { slug: 'testapp' },
|
||||
}),
|
||||
loggedIn: { graphqlClient: {} },
|
||||
vcsClient: { getRootPathAsync: async () => '/test/project' },
|
||||
projectDir: '/test/project',
|
||||
});
|
||||
// @ts-expect-error parse is protected on the oclif base
|
||||
jest.spyOn(command, 'parse').mockResolvedValue({
|
||||
flags: {
|
||||
json: false,
|
||||
wait: false,
|
||||
'non-interactive': true,
|
||||
input: undefined,
|
||||
ref: undefined,
|
||||
ssh: false,
|
||||
'ssh-idle-timeout': undefined,
|
||||
'git-branch': flags['git-branch'],
|
||||
'git-commit-hash': flags['git-commit-hash'],
|
||||
},
|
||||
args: { file: '.eas/workflows/test.yml' },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('passes the branch and commit hash into createWorkflowRunAsync', async () => {
|
||||
await expect(
|
||||
createCommand({ 'git-branch': 'feature/foo', 'git-commit-hash': commitHash }).runAsync()
|
||||
).rejects.toThrow('process.exit:0');
|
||||
|
||||
expect(mockCreateRun).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
workflowRunInput: expect.objectContaining({
|
||||
git: { branch: 'feature/foo', commitHash },
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('leaves git undefined when the flags are unset', async () => {
|
||||
await expect(createCommand({}).runAsync()).rejects.toThrow('process.exit:0');
|
||||
|
||||
expect(mockCreateRun).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateRun.mock.calls[0][1].workflowRunInput.git).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
parseJsonInputs,
|
||||
parseWorkflowInputsFromYaml,
|
||||
} from '../../../commandUtils/workflow/inputs';
|
||||
import { resolveWorkflowRunSshInput } from '../run';
|
||||
import { resolveWorkflowRunGitInput, resolveWorkflowRunSshInput } from '../run';
|
||||
|
||||
describe(resolveWorkflowRunSshInput, () => {
|
||||
it('is null when --ssh is not set', () => {
|
||||
@@ -29,6 +29,35 @@ describe(resolveWorkflowRunSshInput, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveWorkflowRunGitInput, () => {
|
||||
const commitHash = '1234567890abcdef1234567890abcdef12345678';
|
||||
|
||||
it('is undefined when neither flag is set', () => {
|
||||
expect(
|
||||
resolveWorkflowRunGitInput({ branch: undefined, commitHash: undefined })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards the branch and commit hash when both flags are set', () => {
|
||||
expect(resolveWorkflowRunGitInput({ branch: 'main', commitHash })).toEqual({
|
||||
branch: 'main',
|
||||
commitHash,
|
||||
});
|
||||
});
|
||||
|
||||
it('lowercases the commit hash', () => {
|
||||
expect(
|
||||
resolveWorkflowRunGitInput({ branch: 'main', commitHash: commitHash.toUpperCase() })
|
||||
).toEqual({ branch: 'main', commitHash });
|
||||
});
|
||||
|
||||
it.each(['1234567', 'main'])('rejects the commit hash %j', badCommitHash => {
|
||||
expect(() => resolveWorkflowRunGitInput({ branch: 'main', commitHash: badCommitHash })).toThrow(
|
||||
'--git-commit-hash must be the full 40-character commit SHA'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseInputs', () => {
|
||||
it('should parse single key=value pair', () => {
|
||||
const inputs = parseInputs(['key=value']);
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
WorkflowProjectSourceType,
|
||||
WorkflowReferencedFileInput,
|
||||
WorkflowRevision,
|
||||
WorkflowRunGitInput,
|
||||
WorkflowRunStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation';
|
||||
@@ -93,6 +94,25 @@ export function resolveWorkflowRunSshInput({
|
||||
return ssh ? { idleTimeoutSeconds } : null;
|
||||
}
|
||||
|
||||
export function resolveWorkflowRunGitInput({
|
||||
branch,
|
||||
commitHash,
|
||||
}: {
|
||||
branch: string | undefined;
|
||||
commitHash: string | undefined;
|
||||
}): WorkflowRunGitInput | undefined {
|
||||
if (branch === undefined || commitHash === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedCommitHash = commitHash.toLowerCase();
|
||||
if (!/^[0-9a-f]{40}$/.test(normalizedCommitHash)) {
|
||||
throw new Error(
|
||||
`--git-commit-hash must be the full 40-character commit SHA, got "${commitHash}".`
|
||||
);
|
||||
}
|
||||
return { branch, commitHash: normalizedCommitHash };
|
||||
}
|
||||
|
||||
export default class WorkflowRun extends EasCommand {
|
||||
static override description =
|
||||
'run an EAS workflow. The entire local project directory will be packaged and uploaded to EAS servers for the workflow run, unless the --ref flag is used';
|
||||
@@ -124,6 +144,21 @@ export default class WorkflowRun extends EasCommand {
|
||||
description:
|
||||
"The git reference must exist in the project's git repository, and the workflow file must exist at that reference. When this flag is used, the local project is not uploaded; instead, the workflow is run from the exact state of the project at the chosen reference.",
|
||||
summary: 'Git reference to run the workflow on',
|
||||
exclusive: ['git-branch', 'git-commit-hash'],
|
||||
}),
|
||||
'git-branch': Flags.string({
|
||||
description:
|
||||
'Name of the branch the uploaded project comes from, for example "main". The run is listed under this branch on the EAS dashboard. Requires --git-commit-hash. Cannot be used with --ref.',
|
||||
summary: 'Git branch of the uploaded project',
|
||||
dependsOn: ['git-commit-hash'],
|
||||
exclusive: ['ref'],
|
||||
}),
|
||||
'git-commit-hash': Flags.string({
|
||||
description:
|
||||
"Full 40-character SHA of the commit the uploaded project comes from. The commit must exist in the project's linked GitHub repository. Requires --git-branch. Cannot be used with --ref.",
|
||||
summary: 'Git commit of the uploaded project',
|
||||
dependsOn: ['git-branch'],
|
||||
exclusive: ['ref'],
|
||||
}),
|
||||
ssh: Flags.boolean({
|
||||
default: false,
|
||||
@@ -158,7 +193,6 @@ export default class WorkflowRun extends EasCommand {
|
||||
ssh: flags.ssh,
|
||||
idleTimeoutSeconds: flags['ssh-idle-timeout'],
|
||||
});
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
@@ -319,6 +353,10 @@ export default class WorkflowRun extends EasCommand {
|
||||
workflowRunId = runResult.id;
|
||||
} else {
|
||||
// Run from local file
|
||||
const gitInput = resolveWorkflowRunGitInput({
|
||||
branch: flags['git-branch'],
|
||||
commitHash: flags['git-commit-hash'],
|
||||
});
|
||||
try {
|
||||
({ projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
|
||||
graphqlClient,
|
||||
@@ -383,6 +421,7 @@ export default class WorkflowRun extends EasCommand {
|
||||
projectRootDirectory,
|
||||
},
|
||||
ssh: sshInput,
|
||||
git: gitInput,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
|
||||
+74
-15
@@ -19,14 +19,22 @@ export type Scalars = {
|
||||
Boolean: { input: boolean; output: boolean; }
|
||||
Int: { input: number; output: number; }
|
||||
Float: { input: number; output: number; }
|
||||
/** A non-negative integer number of bytes. */
|
||||
ByteSize: { input: any; output: any; }
|
||||
/** Date custom scalar type */
|
||||
DateTime: { input: any; output: any; }
|
||||
/** A DevDomainName for a deployed serverless app */
|
||||
DevDomainName: { input: any; output: any; }
|
||||
/** A name of an environment variable environment */
|
||||
EnvironmentVariableEnvironment: { input: any; output: any; }
|
||||
/** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
|
||||
JSON: { input: any; output: any; }
|
||||
/** The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
|
||||
JSONObject: { input: any; output: any; }
|
||||
SubmissionAndroidTrack: { input: any; output: any; }
|
||||
/** A alias name for a serverless deployment */
|
||||
WorkerDeploymentIdentifier: { input: any; output: any; }
|
||||
/** A Worker Deployment Request ID (also known as the CF Ray ID) */
|
||||
WorkerDeploymentRequestID: { input: any; output: any; }
|
||||
};
|
||||
|
||||
@@ -489,16 +497,27 @@ export type Account_DeviceRunSessionsPaginatedArgs = {
|
||||
* data and settings. Actors may own and be members of accounts.
|
||||
*/
|
||||
export type Account_EnvironmentSecretsArgs = {
|
||||
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* An account is a container owning projects, credentials, billing and other organization
|
||||
* data and settings. Actors may own and be members of accounts.
|
||||
*/
|
||||
export type Account_EnvironmentVariableEnvironmentsArgs = {
|
||||
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* An account is a container owning projects, credentials, billing and other organization
|
||||
* data and settings. Actors may own and be members of accounts.
|
||||
*/
|
||||
export type Account_EnvironmentVariablesArgs = {
|
||||
environment?: InputMaybe<Scalars['EnvironmentVariableEnvironment']['input']>;
|
||||
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
};
|
||||
|
||||
@@ -509,6 +528,7 @@ export type Account_EnvironmentVariablesArgs = {
|
||||
*/
|
||||
export type Account_EnvironmentVariablesIncludingSensitiveArgs = {
|
||||
environment?: InputMaybe<Scalars['EnvironmentVariableEnvironment']['input']>;
|
||||
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
};
|
||||
|
||||
@@ -795,7 +815,6 @@ export type AccountMutation = {
|
||||
* using, creating, changing, or deleting an environment variable whose environments are
|
||||
* exclusively production needs the PUBLISH_PROTECTED permission on the account, whatever its
|
||||
* visibility, in every project of the account. Requires the ADMIN permission on the account.
|
||||
* Turning protection off also requires a sudo session.
|
||||
*/
|
||||
setEnvironmentVariableProtectionEnabled: Account;
|
||||
/** Set whether EAS Observe ingestion is paused for this account. */
|
||||
@@ -1940,13 +1959,21 @@ export type App_EmbeddedUpdatesPaginatedArgs = {
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type App_EnvironmentSecretsArgs = {
|
||||
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type App_EnvironmentVariableEnvironmentsArgs = {
|
||||
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type App_EnvironmentVariablesArgs = {
|
||||
environment?: InputMaybe<Scalars['EnvironmentVariableEnvironment']['input']>;
|
||||
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
};
|
||||
|
||||
@@ -1954,6 +1981,7 @@ export type App_EnvironmentVariablesArgs = {
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type App_EnvironmentVariablesIncludingSensitiveArgs = {
|
||||
environment?: InputMaybe<Scalars['EnvironmentVariableEnvironment']['input']>;
|
||||
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
};
|
||||
|
||||
@@ -3068,13 +3096,13 @@ export enum AppObserveErrorOccurrencesOrderByField {
|
||||
/** AppObserveErrorStats for one platform; iOS includes iPadOS and tvOS. */
|
||||
export type AppObserveErrorPlatformStats = {
|
||||
__typename?: 'AppObserveErrorPlatformStats';
|
||||
/** Distinct sessions on this platform that hit any exception. */
|
||||
/** Distinct sessions on this platform that hit any exception, including installations outside the sampleRate sample. */
|
||||
affectedSessions: Scalars['Int']['output'];
|
||||
/** Distinct users on this platform that hit any exception. */
|
||||
/** Distinct users on this platform that hit any exception, including installations outside the sampleRate sample. */
|
||||
affectedUsers: Scalars['Int']['output'];
|
||||
/** Fraction (0..1) of this platform's active sessions without a fatal error. */
|
||||
/** Fraction (0..1) of this platform's active sessions without a fatal error. Counts only installations in the sampleRate sample. */
|
||||
crashFreeSessions: Scalars['Float']['output'];
|
||||
/** Fraction (0..1) of this platform's active users without a fatal error. */
|
||||
/** Fraction (0..1) of this platform's active users without a fatal error. Counts only installations in the sampleRate sample. */
|
||||
crashFreeUsers: Scalars['Float']['output'];
|
||||
fatalCount: Scalars['Int']['output'];
|
||||
nonFatalCount: Scalars['Int']['output'];
|
||||
@@ -3090,13 +3118,13 @@ export enum AppObserveErrorSeverity {
|
||||
|
||||
export type AppObserveErrorStats = {
|
||||
__typename?: 'AppObserveErrorStats';
|
||||
/** Distinct sessions that hit any exception. */
|
||||
/** Distinct sessions that hit any exception, including installations outside the sampleRate sample. */
|
||||
affectedSessions: Scalars['Int']['output'];
|
||||
/** Distinct users that hit any exception. */
|
||||
/** Distinct users that hit any exception, including installations outside the sampleRate sample that send errors because of errorSampleRate. */
|
||||
affectedUsers: Scalars['Int']['output'];
|
||||
/** Fraction (0..1) of active sessions without a fatal error. Denominator spans app_metrics and app_events. */
|
||||
/** Fraction (0..1) of active sessions without a fatal error. Counts only installations in the sampleRate sample. Denominator spans app_metrics and app_events. */
|
||||
crashFreeSessions: Scalars['Float']['output'];
|
||||
/** Fraction (0..1) of active users without a fatal error. */
|
||||
/** Fraction (0..1) of active users without a fatal error. Counts only installations in the sampleRate sample. */
|
||||
crashFreeUsers: Scalars['Float']['output'];
|
||||
fatalCount: Scalars['Int']['output'];
|
||||
nonFatalCount: Scalars['Int']['output'];
|
||||
@@ -3753,11 +3781,11 @@ export type AppObserveOverviewStability = {
|
||||
__typename?: 'AppObserveOverviewStability';
|
||||
/** Fatal exception events in range. */
|
||||
crashCount: Scalars['Int']['output'];
|
||||
/** Fraction (0..1) of active sessions without a fatal error. */
|
||||
/** Fraction (0..1) of active sessions without a fatal error. Counts only installations in the sampleRate sample. */
|
||||
crashFreeSessions: Scalars['Float']['output'];
|
||||
/** Fraction (0..1) of active users without a fatal error. */
|
||||
/** Fraction (0..1) of active users without a fatal error. Counts only installations in the sampleRate sample. */
|
||||
crashFreeUsers: Scalars['Float']['output'];
|
||||
/** Distinct users that hit a fatal error in range. */
|
||||
/** Distinct users that hit a fatal error in range, including installations outside the sampleRate sample. */
|
||||
crashedUsers: Scalars['Int']['output'];
|
||||
/** Fatal exception events in the equal-length window before startTime. Null when the window is longer than 45 days. */
|
||||
previousPeriodCrashCount?: Maybe<Scalars['Int']['output']>;
|
||||
@@ -3963,6 +3991,7 @@ export type AppObserveSessionSpanFilter = {
|
||||
minDurationMs?: InputMaybe<Scalars['Float']['input']>;
|
||||
names?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
rootOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
scopes?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
statusCodes?: InputMaybe<Array<AppObserveSpanStatusCode>>;
|
||||
};
|
||||
|
||||
@@ -4002,6 +4031,11 @@ export type AppObserveSpan = {
|
||||
/** Null for a root span. */
|
||||
parentSpanId?: Maybe<Scalars['String']['output']>;
|
||||
reactNativeVersion?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* The instrumentation scope that produced the span (OTLP scopeSpans.scope.name), such as
|
||||
* expo-observe.network. Empty when the span was sent without a scope or stored before scopes were kept.
|
||||
*/
|
||||
scopeName: Scalars['String']['output'];
|
||||
sessionId?: Maybe<Scalars['String']['output']>;
|
||||
spanId: Scalars['String']['output'];
|
||||
startTimestamp: Scalars['DateTime']['output'];
|
||||
@@ -4084,6 +4118,8 @@ export type AppObserveSpanListFilter = {
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
/** Only spans with no parent. */
|
||||
rootOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
/** Spans produced by any of these instrumentation scopes, such as expo-observe.network. */
|
||||
scopes?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
sessionId?: InputMaybe<Scalars['String']['input']>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
statusCodes?: InputMaybe<Array<AppObserveSpanStatusCode>>;
|
||||
@@ -6620,7 +6656,7 @@ export type CreateAndConfigureRepositoryInput = {
|
||||
};
|
||||
|
||||
export type CreateAndroidDeviceRunSessionInput = {
|
||||
/** AVD hardware profile id to use for the emulator (e.g. "pixel_9"). */
|
||||
/** AVD hardware profile id to use for the emulator (e.g. "pixel_9"). Defaults to "medium_phone". */
|
||||
deviceIdentifier?: InputMaybe<Scalars['String']['input']>;
|
||||
/**
|
||||
* Android SDK system image package to use for the emulator (e.g.
|
||||
@@ -6869,7 +6905,10 @@ export type CreateGitHubRepositorySettingsInput = {
|
||||
};
|
||||
|
||||
export type CreateIosDeviceRunSessionInput = {
|
||||
/** Simulator device name or UDID to use for the session (e.g. "iPhone 16 Pro"). */
|
||||
/**
|
||||
* Simulator device name or UDID to use for the session (e.g. "iPhone 16 Pro"). Defaults to
|
||||
* "iPhone 17".
|
||||
*/
|
||||
deviceIdentifier?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
@@ -7377,6 +7416,12 @@ export type DeviceRunSession = ActivityTimelineProjectActivity & {
|
||||
*/
|
||||
build?: Maybe<Build>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
/**
|
||||
* The simulator or emulator the session starts: the requested simulator device
|
||||
* name or UDID on iOS, or the AVD hardware profile id on Android, or the platform
|
||||
* default when none is requested. Null on sessions created before it was saved.
|
||||
*/
|
||||
deviceIdentifier?: Maybe<Scalars['String']['output']>;
|
||||
finishedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
initiatingActor?: Maybe<Actor>;
|
||||
@@ -8682,7 +8727,7 @@ export type GitHubRepository = {
|
||||
|
||||
export type GitHubRepositoryMetadata = {
|
||||
__typename?: 'GitHubRepositoryMetadata';
|
||||
defaultBranch?: Maybe<Scalars['String']['output']>;
|
||||
defaultBranch: Scalars['String']['output'];
|
||||
githubRepoDescription?: Maybe<Scalars['String']['output']>;
|
||||
githubRepoName: Scalars['String']['output'];
|
||||
githubRepoOwnerName: Scalars['String']['output'];
|
||||
@@ -14556,7 +14601,20 @@ export type WorkflowRunGitBranchFilterInput = {
|
||||
searchTerm?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Git metadata for a run started from an uploaded project. The commit must exist in the
|
||||
* project's linked GitHub repository. The run records the branch and commit but is not
|
||||
* linked to the repository.
|
||||
*/
|
||||
export type WorkflowRunGitInput = {
|
||||
/** Branch name such as "main". Stored as refs/heads/<branch>. Fully qualified refs are rejected. */
|
||||
branch: Scalars['String']['input'];
|
||||
/** Full commit SHA. The commit message is read from GitHub. */
|
||||
commitHash: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type WorkflowRunInput = {
|
||||
git?: InputMaybe<WorkflowRunGitInput>;
|
||||
inputs?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
projectSource: WorkflowProjectSourceInput;
|
||||
ssh?: InputMaybe<WorkflowRunSshInput>;
|
||||
@@ -14663,6 +14721,7 @@ export enum WorkflowRunTriggerEventType {
|
||||
GithubPush = 'GITHUB_PUSH',
|
||||
GithubRefDelete = 'GITHUB_REF_DELETE',
|
||||
Manual = 'MANUAL',
|
||||
ObserveNewError = 'OBSERVE_NEW_ERROR',
|
||||
RepackExpoGo = 'REPACK_EXPO_GO',
|
||||
Schedule = 'SCHEDULE',
|
||||
SentryIssueArchived = 'SENTRY_ISSUE_ARCHIVED',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { boolish } from 'getenv';
|
||||
// eslint-disable-next-line
|
||||
import oraReal, { Options, Ora } from 'ora';
|
||||
import type { Writable } from 'stream';
|
||||
|
||||
import Log from './log';
|
||||
|
||||
@@ -46,12 +47,29 @@ export function ora(options?: Options | string): Ora {
|
||||
spinner.text = spinner.text;
|
||||
};
|
||||
|
||||
// Cork so each frame is one write; separate clear and text writes flicker.
|
||||
const corkable = stream as Partial<Pick<Writable, 'cork' | 'uncork'>>;
|
||||
const corked = <T>(write: () => T): T => {
|
||||
if (!corkable.cork || !corkable.uncork) {
|
||||
return write();
|
||||
}
|
||||
corkable.cork();
|
||||
try {
|
||||
return write();
|
||||
} finally {
|
||||
corkable.uncork();
|
||||
}
|
||||
};
|
||||
|
||||
const oraRender = spinner.render.bind(spinner);
|
||||
spinner.render = (): Ora => corked(oraRender);
|
||||
|
||||
const oraStart = spinner.start.bind(spinner);
|
||||
const oraStop = spinner.stop.bind(spinner);
|
||||
const oraStopAndPersist = spinner.stopAndPersist.bind(spinner);
|
||||
|
||||
const logWrap = (method: any, args: any[]): void => {
|
||||
oraStop();
|
||||
corked(oraStop);
|
||||
method(...args);
|
||||
spinner.start();
|
||||
};
|
||||
@@ -111,14 +129,14 @@ export function ora(options?: Options | string): Ora {
|
||||
};
|
||||
|
||||
spinner.stopAndPersist = (options): Ora => {
|
||||
const result = oraStopAndPersist(options);
|
||||
const result = corked(() => oraStopAndPersist(options));
|
||||
resetNativeLogs();
|
||||
stream.off('resize', onResize);
|
||||
return result;
|
||||
};
|
||||
|
||||
spinner.stop = (): Ora => {
|
||||
const result = oraStop();
|
||||
const result = corked(oraStop);
|
||||
resetNativeLogs();
|
||||
stream.off('resize', onResize);
|
||||
return result;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/eas-build-job": "27.6.0",
|
||||
"@expo/eas-build-job": "27.7.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-cli-local-build-plugin
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,12 +26,12 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "27.6.0",
|
||||
"@expo/build-tools": "27.7.0",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "27.6.0",
|
||||
"@expo/logger": "27.6.0",
|
||||
"@expo/eas-build-job": "27.7.0",
|
||||
"@expo/logger": "27.7.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "27.6.0",
|
||||
"@expo/turtle-spawn": "27.7.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/logger
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/simulator-launcher
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/simulator-launcher",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"description": "Launch and claim Simulator sessions on EAS workers",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/steps
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,8 +27,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "27.6.0",
|
||||
"@expo/logger": "27.6.0",
|
||||
"@expo/eas-build-job": "27.7.0",
|
||||
"@expo/logger": "27.7.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/template-file
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/turtle-spawn
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "27.6.0",
|
||||
"@expo/logger": "27.7.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/vm-tools
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/vm-tools",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"description": "VM startup script loader",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/worker
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "27.6.0",
|
||||
"version": "27.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "27.6.0",
|
||||
"@expo/build-tools": "27.7.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/eas-build-job": "27.6.0",
|
||||
"@expo/logger": "27.6.0",
|
||||
"@expo/eas-build-job": "27.7.0",
|
||||
"@expo/logger": "27.7.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.6.0",
|
||||
"@expo/template-file": "27.6.0",
|
||||
"@expo/turtle-spawn": "27.6.0",
|
||||
"@expo/steps": "27.7.0",
|
||||
"@expo/template-file": "27.7.0",
|
||||
"@expo/turtle-spawn": "27.7.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"check-disk-space": "3.4.0",
|
||||
"fast-glob": "3.3.2",
|
||||
|
||||
@@ -2515,24 +2515,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:27.6.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:27.7.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:27.6.0"
|
||||
"@expo/eas-build-job": "npm:27.6.0"
|
||||
"@expo/downloader": "npm:27.7.0"
|
||||
"@expo/eas-build-job": "npm:27.7.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:27.6.0"
|
||||
"@expo/logger": "npm:27.7.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/plist": "npm:^0.3.5"
|
||||
"@expo/repack-app": "npm:~0.9.0"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.6.0"
|
||||
"@expo/template-file": "npm:27.6.0"
|
||||
"@expo/turtle-spawn": "npm:27.6.0"
|
||||
"@expo/steps": "npm:27.7.0"
|
||||
"@expo/template-file": "npm:27.7.0"
|
||||
"@expo/turtle-spawn": "npm:27.7.0"
|
||||
"@expo/xcpretty": "npm:^4.4.7"
|
||||
"@ngrok/ngrok": "npm:1.7.0"
|
||||
"@sentry/node": "npm:7.77.0"
|
||||
@@ -2768,7 +2768,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/downloader@npm:27.6.0, @expo/downloader@workspace:packages/downloader":
|
||||
"@expo/downloader@npm:27.7.0, @expo/downloader@workspace:packages/downloader":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/downloader@workspace:packages/downloader"
|
||||
dependencies:
|
||||
@@ -2784,13 +2784,13 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:27.6.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:27.7.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:27.6.0"
|
||||
"@expo/logger": "npm:27.7.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/turtle-spawn": "npm:27.6.0"
|
||||
"@expo/turtle-spawn": "npm:27.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
jest: "npm:^29.7.0"
|
||||
@@ -2803,12 +2803,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:27.6.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:27.7.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-json@workspace:packages/eas-json"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.23.5"
|
||||
"@expo/eas-build-job": "npm:27.6.0"
|
||||
"@expo/eas-build-job": "npm:27.7.0"
|
||||
"@tsconfig/node22": "npm:22.0.6"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -2931,7 +2931,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/logger@npm:27.6.0, @expo/logger@workspace:packages/logger":
|
||||
"@expo/logger@npm:27.7.0, @expo/logger@workspace:packages/logger":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/logger@workspace:packages/logger"
|
||||
dependencies:
|
||||
@@ -3149,12 +3149,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:27.6.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:27.7.0, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:27.6.0"
|
||||
"@expo/logger": "npm:27.6.0"
|
||||
"@expo/eas-build-job": "npm:27.7.0"
|
||||
"@expo/logger": "npm:27.7.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
@@ -3180,7 +3180,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/template-file@npm:27.6.0, @expo/template-file@workspace:packages/template-file":
|
||||
"@expo/template-file@npm:27.7.0, @expo/template-file@workspace:packages/template-file":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/template-file@workspace:packages/template-file"
|
||||
dependencies:
|
||||
@@ -3203,11 +3203,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/turtle-spawn@npm:27.6.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
"@expo/turtle-spawn@npm:27.7.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:27.6.0"
|
||||
"@expo/logger": "npm:27.7.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
@@ -3238,17 +3238,17 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:27.6.0"
|
||||
"@expo/build-tools": "npm:27.7.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/eas-build-job": "npm:27.6.0"
|
||||
"@expo/logger": "npm:27.6.0"
|
||||
"@expo/eas-build-job": "npm:27.7.0"
|
||||
"@expo/logger": "npm:27.7.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.6.0"
|
||||
"@expo/template-file": "npm:27.6.0"
|
||||
"@expo/turtle-spawn": "npm:27.6.0"
|
||||
"@expo/steps": "npm:27.7.0"
|
||||
"@expo/template-file": "npm:27.7.0"
|
||||
"@expo/turtle-spawn": "npm:27.7.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
"@types/babel__core": "npm:7.20.5"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -10141,7 +10141,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:27.6.0"
|
||||
"@expo/steps": "npm:27.7.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
@@ -10725,12 +10725,12 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:27.6.0"
|
||||
"@expo/build-tools": "npm:27.7.0"
|
||||
"@expo/bunyan": "npm:^4.0.1"
|
||||
"@expo/eas-build-job": "npm:27.6.0"
|
||||
"@expo/logger": "npm:27.6.0"
|
||||
"@expo/eas-build-job": "npm:27.7.0"
|
||||
"@expo/logger": "npm:27.7.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:27.6.0"
|
||||
"@expo/turtle-spawn": "npm:27.7.0"
|
||||
"@types/fs-extra": "npm:^11.0.4"
|
||||
"@types/hapi__joi": "npm:^17.1.14"
|
||||
"@types/lodash": "npm:^4.17.4"
|
||||
@@ -10784,12 +10784,12 @@ __metadata:
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/eas-build-job": "npm:27.6.0"
|
||||
"@expo/eas-json": "npm:27.6.0"
|
||||
"@expo/eas-build-job": "npm:27.7.0"
|
||||
"@expo/eas-json": "npm:27.7.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/experimentation": "npm:0.0.5"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:27.6.0"
|
||||
"@expo/logger": "npm:27.7.0"
|
||||
"@expo/multipart-body-parser": "npm:2.0.0"
|
||||
"@expo/osascript": "npm:2.1.4"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -10801,7 +10801,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.6.0"
|
||||
"@expo/steps": "npm:27.7.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:6.3.1"
|
||||
"@graphql-codegen/introspection": "npm:5.0.2"
|
||||
|
||||
Reference in new issue
Block a user