Compare commits

..
115 changed files with 1206 additions and 8404 deletions

No files matched your search

@@ -1,6 +0,0 @@
---
'@expo/eas-build-job': minor
'@expo/build-tools': patch
---
Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---
Add experimentation client.
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---
Add a Free plan build limit guidance experiment for coding agents to `eas build`.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard.
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---
Add `eas simulator:simctl` to set the appearance of a remote iOS simulator session and restart its SpringBoard.
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/worker': patch
---
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
@@ -1,5 +0,0 @@
---
'@expo/worker': patch
---
Apply the job log's secret masking to uploaded Xcode build logs.
-30
View File
@@ -2,36 +2,6 @@
This is the log of notable changes to EAS CLI and related packages.
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
### 🎉 New features
- [eas-cli] Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
- [build-tools] Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
- [build-tools] Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
- [build-tools] Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### 🐛 Bug fixes
- [build-tools] Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
- [build-tools] Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
## [25.2.0](https://github.com/expo/eas-cli/releases/tag/v25.2.0) - 2026-10-17
### 🎉 New features
- [eas-cli] Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
- [build-tools] Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Prepare authenticated simulator action transport and file transfers. ([#4642](https://github.com/expo/eas-cli/pull/4642) by [@gwdp](https://github.com/gwdp))
## [25.1.1](https://github.com/expo/eas-cli/releases/tag/v25.1.1) - 2026-10-16
### 🐛 Bug fixes
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "25.3.0",
"PRODUCTION": "25.3.0"
"STAGING": "25.1.1",
"PRODUCTION": "25.1.1"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "25.3.0",
"version": "25.1.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
-21
View File
@@ -1,26 +1,5 @@
# @expo/build-tools
## 25.3.0
### Minor Changes
- Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
- Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
- Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
- Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
- Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
- Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
## 25.2.0
### Minor Changes
- Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
## 25.1.1
### Patch Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "25.3.0",
"version": "25.1.1",
"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": "25.3.0",
"@expo/eas-build-job": "25.3.0",
"@expo/downloader": "25.1.1",
"@expo/eas-build-job": "25.1.1",
"@expo/env": "^0.4.0",
"@expo/logger": "25.3.0",
"@expo/logger": "25.1.1",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "25.3.0",
"@expo/template-file": "25.3.0",
"@expo/turtle-spawn": "25.3.0",
"@expo/steps": "25.1.1",
"@expo/template-file": "25.1.1",
"@expo/turtle-spawn": "25.1.1",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -79,11 +79,9 @@ beforeEach(() => {
apiUrl: 'https://preview.example.test',
closeAsync: closePreview,
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
getLastPreviewActivityAt: jest.fn(),
finishAsync: finishHost,
});
jest
.mocked(startDeviceSessionHostAsync)
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
jest.mocked(spawnDetached).mockReturnValue({
pid: undefined,
getOutput: () => '',
@@ -1,6 +1,5 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { spawnSync } from 'node:child_process';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
@@ -202,263 +201,6 @@ describe(launchApplicationAsync, () => {
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('quotes Android launch arguments and URLs for the device shell', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'dev.example.app',
activityName: 'dev.example.app.MainActivity',
launchArgs: ['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"],
openUrl: 'exp://example.test/path?a=1&b=2',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'adb',
[
'shell',
'am',
'start',
'-d',
'euxy://tempo',
'--es',
'probe',
"'hello world'",
'--es',
'quote',
"'it'\\''s'",
'-n',
'dev.example.app/dev.example.app.MainActivity',
],
{ env: {}, logger }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
"'exp://example.test/path?a=1&b=2'",
],
{ env: {}, logger }
);
expect(logger.info).toHaveBeenNthCalledWith(
1,
`Launching dev.example.app with arguments ${JSON.stringify(['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"])}.`
);
});
it('passes iOS launch arguments with spaces unquoted', async () => {
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
launchArgs: ['-name', 'hello world'],
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'launch', 'booted', 'com.example.app', '-name', 'hello world'],
expect.any(Object)
);
});
it('round-trips quoted Android arguments through a POSIX shell', async () => {
const component = 'dev.example.app/dev.example.app.MainActivity$Inner';
const launchArgs = [
'--es',
'text',
'hello world',
'--es',
'json',
'{"a":1,"b":2}',
'--es',
'shell',
'$(printf injected) `printf injected` $HOME ; & | < >',
'--es',
'glob',
'*',
'--es',
'home',
'~',
'--esa',
'list',
'a\\,b,c',
'--es',
'whitespace',
'one\ntwo\tthree',
'--es',
'unicode',
'héllo 🌍',
'--es',
'empty',
'',
];
const openUrl = 'exp://example.test/path?a=1&b=2#fragment';
await launchApplicationAsync({
applicationIdentifier: 'dev.example.app',
activityName: 'dev.example.app.MainActivity$Inner',
launchArgs,
openUrl,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
// adb joins the words after `shell` with spaces and the device shell parses the result.
const parsedCommands = mockedSpawn.mock.calls.map(([, args]) => {
const { stdout } = spawnSync(
'/bin/sh',
['-c', `am() { printf '%s\\0' "$@"; }; ${(args as string[]).slice(1).join(' ')}`],
{ encoding: 'utf8' }
);
return stdout.split('\0').slice(0, -1);
});
expect(parsedCommands).toEqual([
['start', ...launchArgs, '-n', component],
['start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
]);
});
it.each([[['--es', 'key', '']], [['-e', 'key', '']], [['-d', '']]])(
'passes empty Android option values as empty arguments (%j)',
async launchArgs => {
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
...launchArgs.map(argument => (argument === '' ? "''" : argument)),
'-n',
'com.example.app/com.example.app.MainActivity',
],
expect.any(Object)
);
}
);
it('explains an Android launch failure when a launch argument is empty', async () => {
const launchError = new Error('adb shell am start exited with non-zero code: 1');
mockedSpawn.mockRejectedValueOnce(launchError);
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--ez', 'isTest', 'true', ''],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({
errorCode: 'EAS_LAUNCH_APPLICATION_FAILED',
message: expect.stringContaining('empty argument at position 4'),
cause: launchError,
});
});
it('rethrows an Android launch failure unchanged when no launch argument is empty', async () => {
const launchError = new Error('adb shell am start exited with non-zero code: 1');
mockedSpawn.mockRejectedValueOnce(launchError);
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--ez', 'isTest', 'true'],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toBe(launchError);
});
it.each([
{ launchArgs: ['--es', 'key', 'a\0b'] },
{ launchArgs: [], openUrl: 'exp://example.test/a\0b' },
])('rejects NUL characters on Android (%j)', async ({ launchArgs, openUrl }) => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs,
openUrl,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
expect.arrayContaining([expect.stringContaining('\0')]),
expect.any(Object)
);
});
it('accepts an Android launch command of exactly 65,000 bytes', async () => {
const fixedLength = 'am start --es key -n com.example.app/com.example.app.MainActivity'.length;
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--es', 'key', 'a'.repeat(65_000 - fixedLength)],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('rejects an Android URL command of 65,001 bytes', async () => {
const fixedLength = 'am start -a android.intent.action.VIEW -d exp://example.test/'.length;
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
openUrl: `exp://example.test/${'a'.repeat(65_001 - fixedLength)}`,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('rejects an Android launch command that is too long after quoting', async () => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--es', 'quotes', "'".repeat(20_000)],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('launches using application metadata passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
@@ -495,36 +237,6 @@ describe(launchApplicationAsync, () => {
);
});
it('quotes Android launch arguments passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{
callInputs: {
application_identifier: 'com.example.app',
activity_name: 'com.example.app.MainActivity',
launch_args: ['--es', 'probe', 'hello world'],
},
}
);
await buildStep.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
'--es',
'probe',
"'hello world'",
'-n',
'com.example.app/com.example.app.MainActivity',
],
expect.any(Object)
);
});
it.each([{}, ['valid', 42]])('rejects invalid launch arguments (%j)', async launchArgs => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
@@ -162,7 +162,6 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -76,8 +76,7 @@ async function runAsync(
runtimePlatform: BuildRuntimePlatform,
capture: CaptureInputs = NO_CAPTURE,
disableShare = false,
privatePreviewUrl?: string,
maxIdleTimeMinutes?: number
privatePreviewUrl?: string
): Promise<void> {
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
@@ -89,7 +88,7 @@ async function runAsync(
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture,
disableShare,
@@ -134,7 +133,6 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
};
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
@@ -204,21 +202,6 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(encodeURIComponent(token));
});
it('resets the idle timer on preview input from the session host', async () => {
const previewInputAt = new Date('2026-10-19T12:00:00Z');
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(ctx, {
application: Promise.resolve(),
} as never);
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
await runAsync(logger, BuildRuntimePlatform.LINUX, NO_CAPTURE, false, undefined, 5);
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
});
it('hands network capture to serve-sim', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
@@ -687,18 +687,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it.each([
['NUL argument', { launch_args: ['a\u0000b'] }],
['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }],
['NUL URL', { open_url: 'example:\u0000screen' }],
])('rejects Android launch %s before startup work', async (_name, inputs) => {
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs });
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('keeps Android launch inputs on its existing direct path', async () => {
const launchArgs = ['a'.repeat(9000)];
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
@@ -6,18 +6,14 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import * as processes from '../../../utils/processes';
import { sleepAsync } from '../../../utils/retry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import { preparePreinstalledWdaAsync } from '../../utils/preinstalledWda';
import {
ensureFfmpegInstalledOnceAsync,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import {
@@ -31,16 +27,11 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
}));
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
getAvailableDevicesAsync: jest.fn(),
getDeviceAsync: jest.fn(),
resolveUdidAsync: jest.fn().mockResolvedValue('chosen-ios-udid'),
},
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
ensureFfmpegInstalledOnceAsync: jest.fn(),
spawnDetached: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
@@ -57,15 +48,6 @@ jest.mock('../../../utils/turtleFetch', () => ({
}));
jest.mock('../../utils/appiumEvents', () => ({ startAppiumEventCollectionAsync: jest.fn() }));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/preinstalledWda', () => ({ preparePreinstalledWdaAsync: jest.fn() }));
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
}));
jest.mock('../../utils/localEgressGuard', () => ({
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
}));
jest.mock('../../utils/localEgressSession', () => ({
...jest.requireActual('../../utils/localEgressSession'),
uploadRemoteSessionConfigWithLocalEgressAsync: jest.fn(),
@@ -106,6 +88,7 @@ describe(resolveAppiumDeviceAsync, () => {
resolveAppiumDeviceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
})
).resolves.toEqual({
platformName: 'iOS',
@@ -114,6 +97,7 @@ describe(resolveAppiumDeviceAsync, () => {
udid: 'ios-simulator-id',
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
});
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledWith({ env: {}, logger });
expect(IosSimulatorUtils.getAvailableDevicesAsync).toHaveBeenCalledWith({
env: {},
filter: 'booted',
@@ -129,6 +113,7 @@ describe(resolveAppiumDeviceAsync, () => {
resolveAppiumDeviceAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
})
).resolves.toEqual({
platformName: 'Android',
@@ -227,13 +212,6 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
const stopEventCollection = jest.fn();
beforeEach(() => {
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({});
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({
udid: 'CHOSEN-IOS-UDID',
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
state: 'Shutdown',
} as never);
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
stdout: args.includes('--json') ? '{}' : '',
})) as never);
@@ -264,7 +242,6 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: jest.fn(),
});
});
@@ -273,22 +250,16 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
jest.restoreAllMocks();
});
async function runSessionAsync(
logger: { info: jest.Mock; warn: jest.Mock },
maxIdleTimeMinutes?: number,
runtimePlatform = BuildRuntimePlatform.LINUX,
startupInputs: Record<string, { value: unknown }> = {}
): Promise<void> {
async function runSessionAsync(logger: { info: jest.Mock; warn: jest.Mock }): Promise<void> {
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger,
global: { runtimePlatform },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: maxIdleTimeMinutes },
...startupInputs,
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {
@@ -309,345 +280,6 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
);
}
it.each([false, true])(
'preserves preview settings with Simulator startup %s',
async bootSimulator => {
stopAppium.mockResolvedValue(undefined);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]);
await runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
...(bootSimulator
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-device' } }
: {}),
public_preview: { value: false },
private_preview_url: { value: 'https://expo.dev/private-preview' },
frame_ancestors: { value: ['https://preview.example.com'] },
cors_origins: { value: ['https://api.example.com'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
disableShare: true,
privatePreviewUrl: 'https://expo.dev/private-preview',
frameAncestors: ['https://preview.example.com'],
corsOrigins: ['https://api.example.com'],
})
);
}
);
it('selects Xcode only once for an already-prepared iOS session', async () => {
stopAppium.mockResolvedValue(undefined);
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([
{
udid: 'chosen-ios-udid',
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4',
} as never,
]);
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
});
await runSessionAsync(
{ info: jest.fn(), warn: jest.fn() },
undefined,
BuildRuntimePlatform.DARWIN
);
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
expect.objectContaining({ runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4' })
);
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
});
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(1);
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mock.invocationCallOrder[0]
);
});
it('starts serve-sim on the selected iOS device while Appium installation is pending', async () => {
stopAppium.mockResolvedValue(undefined);
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockClear();
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
});
let releaseInstall!: (value: unknown) => void;
let enteredInstall!: () => void;
const installEntered = new Promise<void>(resolve => {
enteredInstall = resolve;
});
jest.mocked(spawn).mockImplementationOnce(() => {
enteredInstall();
return new Promise(resolve => {
releaseInstall = resolve;
}) as never;
});
const running = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
device_identifier: { value: 'chosen-device' },
install_app_path: { value: '/tmp/App.app' },
launch_args: { value: ['--literal'] },
open_url: { value: 'example://home' },
},
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'device-run-session-id',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example.com',
NGROK_AUTHTOKEN: 'ngrok-token',
},
} as never
);
await installEntered;
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
iosSimulatorUdid: 'CHOSEN-IOS-UDID',
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--literal'],
openUrl: 'example://home',
})
);
expect(IosSimulatorUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
const readyHost = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
expect(readyHost.finishAsync).not.toHaveBeenCalled();
expect(jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].signal?.aborted).toBe(false);
releaseInstall({ stdout: '' });
await running;
expect(readyHost.finishAsync).toHaveBeenCalledTimes(1);
expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({
udid: 'CHOSEN-IOS-UDID',
env: expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'device-run-session-id' }),
});
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
expect.objectContaining({
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
appiumHome: jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME,
})
);
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
});
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(1);
expect(stopAppium).toHaveBeenCalledTimes(1);
});
it('drains a failed Simulator startup installer before removing its Appium home', async () => {
const hostError = new Error('Simulator boot failed');
let failHost!: (error: Error) => void;
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
new Promise((_resolve, reject) => {
failHost = reject;
})
);
let enteredInstall!: () => void;
const installEntered = new Promise<void>(resolve => {
enteredInstall = resolve;
});
let closeInstaller!: (error: Error) => void;
const child = { pid: 12345, kill: jest.fn() };
jest.mocked(spawn).mockImplementationOnce(() => {
enteredInstall();
return Object.assign(
new Promise((_resolve, reject) => {
closeInstaller = reject;
}),
{ child }
) as never;
});
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
boot_simulator: { value: true },
device_identifier: { value: 'chosen-device' },
});
const settled = jest.fn();
const observed = running.then(settled, settled);
try {
await installEntered;
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
hostOptions.onStartupError?.(hostError);
failHost(hostError);
await Promise.resolve();
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
expect(settled).not.toHaveBeenCalled();
expect(fs.existsSync(home)).toBe(true);
closeInstaller(new Error('installer interrupted'));
await expect(running).rejects.toBe(hostError);
expect(fs.existsSync(home)).toBe(false);
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
} finally {
closeInstaller?.(new Error('test cleanup'));
await observed;
}
});
it('removes a completed Appium install when Simulator failure interrupts FFmpeg setup', async () => {
const hostError = new Error('Simulator startup failed');
let failHost!: (error: Error) => void;
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
new Promise((_resolve, reject) => {
failHost = reject;
})
);
jest
.mocked(ensureFfmpegInstalledOnceAsync)
.mockImplementationOnce(
({ signal }) =>
new Promise(resolve => signal?.addEventListener('abort', () => resolve(), { once: true }))
);
let installed!: () => void;
const installationFinished = new Promise<void>(resolve => {
installed = resolve;
});
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
if (args[0] === 'driver' && args[1] === 'install') {
installed();
}
return { stdout: args.includes('--json') ? '{}' : '' };
}) as never);
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
boot_simulator: { value: true },
device_identifier: { value: 'chosen-device' },
});
await installationFinished;
await new Promise<void>(resolve => setImmediate(resolve));
jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].onStartupError?.(hostError);
failHost(hostError);
await expect(running).rejects.toBe(hostError);
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
expect(fs.existsSync(home)).toBe(false);
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
});
it('preserves an installer failure before a later host failure during home cleanup', async () => {
const installError = new Error('npm install failed');
const hostError = new Error('later Simulator failure');
let failHost!: (error: Error) => void;
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
new Promise((_resolve, reject) => {
failHost = reject;
})
);
jest.mocked(spawn).mockRejectedValueOnce(installError);
const remove = fs.promises.rm.bind(fs.promises);
let enteredCleanup!: () => void;
const cleanupEntered = new Promise<void>(resolve => {
enteredCleanup = resolve;
});
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>(resolve => {
releaseCleanup = resolve;
});
jest.spyOn(fs.promises, 'rm').mockImplementationOnce(async (directory, options) => {
enteredCleanup();
await cleanupGate;
await remove(directory, options);
});
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
boot_simulator: { value: true },
device_identifier: { value: 'chosen-device' },
});
const observed = running.catch(() => {});
try {
await cleanupEntered;
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
expect(options.signal?.reason).toBe(installError);
options.onStartupError?.(hostError);
failHost(hostError);
releaseCleanup();
await expect(running).rejects.toBe(installError);
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
} finally {
failHost(hostError);
releaseCleanup();
await observed;
}
});
it.each(['WDA download', 'Appium readiness'])(
'keeps the Simulator error when it interrupts %s',
async stage => {
stopAppium.mockResolvedValue(undefined);
const hostError = new Error('Simulator launch failed');
const laterError = new Error(`${stage} interrupted`);
let failHost!: (error: Error) => void;
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
new Promise((_resolve, reject) => {
failHost = reject;
})
);
let enteredStage!: () => void;
const stageEntered = new Promise<void>(resolve => {
enteredStage = resolve;
});
let rejectStage!: (error: Error) => void;
let setupSignal: AbortSignal | undefined;
const waitForStage = (signal?: AbortSignal): Promise<never> => {
setupSignal = signal;
enteredStage();
return new Promise((_resolve, reject) => {
rejectStage = reject;
signal?.addEventListener('abort', () => reject(laterError), { once: true });
});
};
if (stage === 'WDA download') {
jest
.mocked(preparePreinstalledWdaAsync)
.mockImplementationOnce(({ signal }) => waitForStage(signal));
} else {
jest
.mocked(turtleFetch)
.mockImplementationOnce((_url, _method, options) =>
waitForStage(options?.signal ?? undefined)
);
}
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
boot_simulator: { value: true },
device_identifier: { value: 'chosen-device' },
});
const observed = running.catch(() => {});
try {
await stageEntered;
jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].onStartupError?.(hostError);
failHost(hostError);
await Promise.resolve();
expect(setupSignal?.aborted).toBe(true);
await expect(running).rejects.toBe(hostError);
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
expect(fs.existsSync(home)).toBe(false);
expect(startAppiumEventCollectionAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
} finally {
rejectStage?.(laterError);
await observed;
}
}
);
it('keeps the readiness error when Appium cannot be stopped', async () => {
let now = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => now);
@@ -675,32 +307,10 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
});
it('resets the idle timer on preview input from the session host', async () => {
const previewInputAt = new Date('2026-10-19T12:00:00Z');
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
{} as never,
{} as never
);
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
await expect(runSessionAsync({ info: jest.fn(), warn: jest.fn() }, 5)).rejects.toBe(stopError);
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
});
it('fails a finished session when Appium cannot be stopped', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
await expect(runSessionAsync(logger)).rejects.toBe(stopError);
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
expect.objectContaining({ runtimeIdentifier: undefined })
);
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
});
expectAppiumStoppedAndHomeRemoved(logger);
expect(stopEventCollection.mock.invocationCallOrder[0]).toBeLessThan(
stopAppium.mock.invocationCallOrder[0]
@@ -7,9 +7,8 @@ import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import { isProcessDescendantOfAsync, killProcessGroup } from '../../../utils/processes';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
import { createProcessOutput } from '../../utils/processOutput';
@@ -25,7 +24,6 @@ import {
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
// Redirect ~/.argent (where the tool-server writes its state file and event log) to a temp
// home so waitForArgentToolServerStateAsync — which lives in the module under test and cannot
@@ -40,32 +38,13 @@ jest.mock('node:os', () => {
});
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../sentry');
jest.mock('../../../utils/processes', () => ({
isProcessDescendantOfAsync: jest.fn(),
killProcessGroup: jest.fn(),
}));
jest.mock('../../../utils/processes', () => ({ isProcessDescendantOfAsync: jest.fn() }));
jest.mock('../../utils/argentArtifacts', () => ({ pollArgentArtifactsForUploadAsync: jest.fn() }));
jest.mock('../../utils/argentEvents', () => ({
...jest.requireActual('../../utils/argentEvents'),
startArgentEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
}));
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
getAvailableDevicesAsync: jest
.fn()
.mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]),
},
}));
jest.mock('../../utils/localEgressGuard', () => ({
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
ensureFfmpegInstalledOnceAsync: jest.fn(),
@@ -125,7 +104,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -239,7 +217,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
});
} else if (phase === 'config') {
@@ -530,35 +507,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
});
it('resets the idle timer on preview input from the session host', async () => {
const previewInputAt = new Date('2026-10-19T12:00:00Z');
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
{} as never,
{} as never
);
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: 5 },
},
outputs: {},
env: {},
} as never
);
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
});
it('redacts startup credentials and registers the state token for later output', async () => {
const token = 'tool/server "secret"';
await fs.promises.writeFile(
@@ -644,280 +592,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
);
});
it('boots and prepares the downloaded app while Argent installation is pending, and closes the host if installation fails', async () => {
let rejectInstall!: (error: Error) => void;
let enteredInstall!: () => void;
const installEntered = new Promise<void>(resolve => {
enteredInstall = resolve;
});
jest.mocked(spawn).mockImplementationOnce(() => {
enteredInstall();
return new Promise((_resolve, reject) => {
rejectInstall = reject;
}) as never;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
device_identifier: { value: 'chosen-device' },
install_app_path: { value: '/tmp/App.app' },
launch_args: { value: ['--literal'] },
},
outputs: {},
env: {},
} as never
);
await installEntered;
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
iosSimulatorUdid: 'SELECTED-IOS-UDID',
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--literal'],
})
);
const error = new Error('Argent install failed');
rejectInstall(error);
await expect(running).rejects.toBe(error);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it.each(['artifacts-list-endpoint', 'tool-server-event-log', 'video-watermark'])(
'stops and drains the pending %s command when the concurrent host fails',
async flag => {
const hostError = new Error('serve-sim startup failed');
const commandError = new Error('Argent command exited after cancellation');
let failHost!: (error: Error) => void;
let finishHostCleanup!: () => void;
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
async (_ctx, options) =>
await new Promise((_resolve, reject) => {
failHost = error => {
options.onStartupError?.(error);
finishHostCleanup = () => reject(error);
};
})
);
let failCommand!: (error: Error) => void;
let commandStarted!: () => void;
const commandEntered = new Promise<void>(resolve => {
commandStarted = resolve;
});
const child = { pid: 4242, kill: jest.fn() };
jest.mocked(spawn).mockImplementation((_command, args) => {
if (!args.includes(flag)) {
return Promise.resolve(undefined) as never;
}
const pending = new Promise((_resolve, reject) => {
failCommand = reject;
});
commandStarted();
return Object.assign(pending, { child }) as never;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
let settled = false;
void running
.finally(() => {
settled = true;
})
.catch(() => {});
try {
await commandEntered;
failHost(hostError);
await new Promise(resolve => setImmediate(resolve));
expect(killProcessGroup).toHaveBeenCalledWith(child, 'SIGKILL');
expect(settled).toBe(false);
expect(spawnDetached).not.toHaveBeenCalled();
failCommand(commandError);
await new Promise(resolve => setImmediate(resolve));
expect(settled).toBe(false);
finishHostCleanup();
await expect(running).rejects.toBe(hostError);
expect(spawnDetached).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
} finally {
failCommand?.(commandError);
finishHostCleanup?.();
await running.catch(() => {});
}
}
);
it('interrupts tool-server state polling and retains the original host error', async () => {
await fs.promises.rm(path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'));
const hostError = new Error('serve-sim startup failed');
let failHost!: (error: Error) => void;
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
async () =>
await new Promise((_resolve, reject) => {
failHost = reject;
})
);
let pollingStarted!: () => void;
const pollingEntered = new Promise<void>(resolve => {
pollingStarted = resolve;
});
const getExitError = jest.fn<Error | undefined, []>(() => {
pollingStarted();
return undefined;
});
const stopServer = jest.fn().mockResolvedValue(undefined);
jest.mocked(spawnDetached).mockReturnValueOnce({
pid: 4242,
getOutput: () => 'later controller diagnostic',
getExitError,
stopAsync: stopServer,
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
void running.catch(() => {});
try {
await pollingEntered;
failHost(hostError);
await new Promise(resolve => setImmediate(resolve));
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
expect(options.signal?.aborted).toBe(true);
getExitError.mockReturnValue(new Error('later Argent exit'));
await expect(running).rejects.toBe(hostError);
expect(stopServer).toHaveBeenCalledTimes(1);
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
} finally {
getExitError.mockReturnValue(new Error('stop pending state poll'));
await running.catch(() => {});
}
});
it('keeps Argent setup concurrent but waits for the iOS host before opening tools', async () => {
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
{} as CustomBuildContext,
{} as never
);
let releaseHost!: () => void;
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
new Promise(resolve => {
releaseHost = () => resolve(host);
})
);
let stateReady!: () => void;
const stateRead = new Promise<void>(resolve => {
stateReady = resolve;
});
jest.mocked(isProcessDescendantOfAsync).mockImplementationOnce(async () => {
stateReady();
return true;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
try {
await stateRead;
await new Promise(resolve => setImmediate(resolve));
expect(spawnDetached).toHaveBeenCalledTimes(1);
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
} finally {
releaseHost();
await running;
}
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'keeps %s prepared setup commands outside the new cancellation path',
async runtimePlatform => {
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
expect(spawn).toHaveBeenCalledTimes(3);
for (const [, , options] of jest.mocked(spawn).mock.calls) {
expect(options).not.toHaveProperty('detached');
expect(options).not.toHaveProperty('signal');
}
expect(killProcessGroup).not.toHaveBeenCalled();
}
);
it('fails before starting anything when a launch is asked for on Android', async () => {
const ctx = {} as unknown as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() };
@@ -81,7 +81,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -98,7 +97,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
previewToken: 'tok-1',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
@@ -126,7 +124,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
@@ -154,7 +151,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN, { public_preview: { value: false } });
@@ -172,7 +168,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
} else if (phase === 'config') {
@@ -140,12 +140,9 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
await buildStep.executeAsync();
const uploads = jest
const names = jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mock.calls.map(([, options]) => options);
const names = uploads
.filter(options => options.kind === 'screen-recording')
.map(options => options.name);
.mock.calls.map(([, options]) => options.name);
expect(names).toEqual(
expect.arrayContaining([
'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
@@ -154,12 +151,6 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
])
);
expect(new Set(names).size).toBe(3);
const metadata = uploads.filter(options => options.kind === 'screen-recording-metadata');
expect(metadata.map(options => options.name)).toEqual(
expect.arrayContaining(names.map(name => `${name} metadata`))
);
expect(metadata).toHaveLength(3);
expect(uploads).toHaveLength(6);
} finally {
await Promise.all(
recordingDirectories.map(directory => fs.rm(directory, { recursive: true, force: true }))
@@ -1,4 +1,4 @@
import { UserError, quoteAndroidShellCommand } from '@expo/eas-build-job';
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
@@ -106,40 +106,24 @@ export async function launchApplicationAsync({
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
// Android does not support process arguments like iOS. Pass raw `am start` Intent
// arguments instead, such as `--es key value` or `--ez key true`.
try {
await spawnAndroidShellCommandAsync(
['am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
{ env, logger }
);
} catch (err) {
// An empty argument that is not an option value reaches `am start` as an empty package, and
// `am start` then fails to resolve the Intent without saying why.
const emptyArgumentIndex = launchArgs.indexOf('');
if (emptyArgumentIndex === -1 || err instanceof UserError) {
throw err;
await spawn(
'adb',
['shell', 'am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
{
env,
logger,
}
throw new UserError(
'EAS_LAUNCH_APPLICATION_FAILED',
`Launching ${applicationIdentifier} failed. Input "launch_args" has an empty argument at position ${emptyArgumentIndex + 1}, which Android passes to "am start" as an empty argument. Remove it unless it is an option value, such as "--es", "key", "".`,
{ cause: err }
);
}
);
if (openUrl) {
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
await spawnAndroidShellCommandAsync(
['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
await spawn(
'adb',
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
{ env, logger }
);
}
}
async function spawnAndroidShellCommandAsync(
command: string[],
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
): Promise<void> {
await spawn('adb', ['shell', ...quoteAndroidShellCommand(command)], { env, logger });
}
async function preapproveIosUrlSchemeAsync({
applicationIdentifier,
openUrl,
@@ -265,7 +265,6 @@ export async function runAgentDeviceRemoteSessionAsync(
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
@@ -1,4 +1,4 @@
import { UserError, validateAndroidLaunchOptions } from '@expo/eas-build-job';
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -164,8 +164,6 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
}
if (isIos) {
validateServeSimLaunchOptions({ launchArgs, openUrl });
} else {
validateAndroidLaunchOptions({ launchArgs, openUrl });
}
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
const capture = parseNetworkCaptureInputs(
@@ -1,6 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { type Result, asyncResult } from '@expo/results';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -8,11 +8,10 @@ import {
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn, { type SpawnOptions, type SpawnResult } from '@expo/turtle-spawn';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import semver from 'semver';
import { z } from 'zod';
@@ -22,19 +21,17 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageAdd,
} from '../../utils/packageManager';
import { killProcessGroup } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
@@ -42,7 +39,6 @@ import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
@@ -55,6 +51,7 @@ import {
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
@@ -81,7 +78,6 @@ export function createStartAppiumRemoteSessionBuildFunction(
__metricsId: 'eas/start_appium_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
@@ -106,21 +102,14 @@ export function createStartAppiumRemoteSessionBuildFunction(
const packageVersion = inputs.package_version.value as string | undefined;
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const { runtimePlatform } = global;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
signal,
});
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
},
{ runtimePlatform }
);
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
@@ -133,125 +122,54 @@ export function createStartAppiumRemoteSessionBuildFunction(
logger.info(
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
const device: AppiumDevice = iosStartup
? {
platformName: 'iOS',
automationName: 'XCUITest',
driverName: 'xcuitest',
udid: iosStartup.iosSimulatorUdid,
}
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
const startupAbortController = new AbortController();
const startupSignal = signal
? AbortSignal.any([signal, startupAbortController.signal])
: startupAbortController.signal;
const setupSignal = iosStartup ? startupSignal : undefined;
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
let appiumHome: string | undefined;
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
// Both installs settle before the step continues or fails, so no install outlives it.
const [appiumInstall] = await Promise.all([
asyncResult(
installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger })
),
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }),
]);
const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue();
const wdaCapabilities = await preparePreinstalledWdaAsync({
runtimeIdentifier: device.runtimeIdentifier,
appiumHome,
appiumBinPath,
appiumEnv,
logger,
});
const appiumProcess = spawnDetached({
command: appiumBinPath,
args: [
'--address',
APPIUM_HOST,
'--port',
String(APPIUM_PORT),
'--base-path',
'/',
'--log-level',
'error',
// Appium 3 gates session listing (GET /appium/sessions) behind the
// session_discovery insecure feature. We rely on it to poll for
// Appium Event Timings, so enable it for all drivers.
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
],
env: appiumEnv,
logger,
});
let eventCollection: Awaited<ReturnType<typeof startAppiumEventCollectionAsync>> | undefined;
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
const startHost = () =>
startDeviceSessionHostAsync(ctx, {
...iosStartup,
separateLogPhase: true,
runtimePlatform,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
signal: startupSignal,
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
...launch,
networkCapture,
networkCaptureFields,
}).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
throw error;
});
try {
if (iosStartup) {
hostStartup = asyncResult(startHost());
}
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
// Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner.
const [appiumInstall] = await Promise.all([
asyncResult(
installAppiumAsync({
versionSpec,
driverName: device.driverName,
env,
logger,
signal: setupSignal,
onError: iosStartup ? error => startupAbortController.abort(error) : undefined,
}).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
throw error;
})
),
asyncResult(
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal })
),
]);
const installation = appiumInstall.enforceValue();
appiumHome = installation.appiumHome;
setupSignal?.throwIfAborted();
const { appiumBinPath, appiumEnv } = installation;
const wdaCapabilities = await preparePreinstalledWdaAsync({
runtimeIdentifier: iosStartup
? (
await IosSimulatorUtils.getDeviceAsync({
udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid,
env,
})
)?.runtime
: device.runtimeIdentifier,
appiumHome,
appiumBinPath,
appiumEnv,
logger,
signal: setupSignal,
});
setupSignal?.throwIfAborted();
appiumProcess = spawnDetached({
command: appiumBinPath,
args: [
'--address',
APPIUM_HOST,
'--port',
String(APPIUM_PORT),
'--base-path',
'/',
'--log-level',
'error',
// Appium 3 gates session listing (GET /appium/sessions) behind the
// session_discovery insecure feature. We rely on it to poll for
// Appium Event Timings, so enable it for all drivers.
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
],
env: appiumEnv,
logger,
});
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal });
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
setupSignal?.throwIfAborted();
await waitForAppiumReadyAsync({ appiumProcess, logger });
eventCollection = await startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
@@ -272,7 +190,22 @@ export function createStartAppiumRemoteSessionBuildFunction(
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
sessionHost = await startDeviceSessionHostAsync(ctx, {
separateLogPhase: true,
runtimePlatform,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
@@ -307,15 +240,13 @@ export function createStartAppiumRemoteSessionBuildFunction(
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw setupSignal?.aborted ? setupSignal.reason : error;
throw error;
} finally {
startupAbortController.abort(new Error('Appium session ended.'));
await finishRemoteSessionAsync({
logger,
sessionFailed,
@@ -328,16 +259,14 @@ export function createStartAppiumRemoteSessionBuildFunction(
await eventCollection?.stopAsync();
} finally {
try {
await appiumProcess?.stopAsync();
await appiumProcess.stopAsync();
} finally {
if (appiumHome) {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
}
})(),
],
['session host', hostStartup?.then(result => result.value?.finishAsync())],
['session host', sessionHost?.finishAsync()],
],
});
}
@@ -367,12 +296,15 @@ export type AppiumDevice = {
export async function resolveAppiumDeviceAsync({
runtimePlatform,
env,
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<AppiumDevice> {
switch (runtimePlatform) {
case BuildRuntimePlatform.DARWIN: {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
const [bootedDevice] = await IosSimulatorUtils.getAvailableDevicesAsync({
env,
filter: 'booted',
@@ -409,135 +341,65 @@ export async function installAppiumAsync({
driverName,
env,
logger,
signal,
onError,
}: {
versionSpec: string;
driverName: AppiumDevice['driverName'];
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
onError?: (error: unknown) => void;
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
signal?.throwIfAborted();
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
try {
signal?.throwIfAborted();
await fs.promises.writeFile(
path.join(appiumHome, 'package.json'),
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
);
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
const add = resolvePackageAdd(
resolveConfiguredPackageManager(env, PackageManager.NPM),
`appium@${versionSpec}`
);
await fs.promises.writeFile(
path.join(appiumHome, 'package.json'),
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
);
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
const add = resolvePackageAdd(
resolveConfiguredPackageManager(env, PackageManager.NPM),
`appium@${versionSpec}`
);
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
await runAppiumSetupCommandAsync(
add.command,
add.args,
{
cwd: appiumHome,
env: appiumEnv,
logger,
},
signal
);
const { stdout } = await runAppiumSetupCommandAsync(
appiumBinPath,
['driver', 'list', '--installed', '--json'],
{
env: appiumEnv,
stdio: 'pipe',
},
signal
);
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
if (installedDrivers[driverName]?.installed) {
logger.info(`Updating the installed Appium ${driverName} driver.`);
await runAppiumSetupCommandAsync(
appiumBinPath,
['driver', 'update', driverName],
{ env: appiumEnv, logger },
signal
);
} else {
logger.info(`Installing the Appium ${driverName} driver.`);
await runAppiumSetupCommandAsync(
appiumBinPath,
['driver', 'install', driverName],
{ env: appiumEnv, logger },
signal
);
}
return { appiumHome, appiumBinPath, appiumEnv };
} catch (error) {
onError?.(error);
if (signal) {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
signal.throwIfAborted();
}
throw error;
}
}
async function runAppiumSetupCommandAsync(
command: string,
args: string[],
options: SpawnOptions,
signal?: AbortSignal
): Promise<SpawnResult> {
if (!signal) {
return await spawn(command, args, options);
}
signal.throwIfAborted();
const task = spawn(command, args, { ...options, detached: true });
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) {
abort();
}
try {
const result = await task;
signal.throwIfAborted();
return result;
} finally {
signal.removeEventListener('abort', abort);
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
await spawn(add.command, add.args, {
cwd: appiumHome,
env: appiumEnv,
logger,
});
const { stdout } = await spawn(appiumBinPath, ['driver', 'list', '--installed', '--json'], {
env: appiumEnv,
stdio: 'pipe',
});
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
if (installedDrivers[driverName]?.installed) {
logger.info(`Updating the installed Appium ${driverName} driver.`);
await spawn(appiumBinPath, ['driver', 'update', driverName], { env: appiumEnv, logger });
} else {
logger.info(`Installing the Appium ${driverName} driver.`);
await spawn(appiumBinPath, ['driver', 'install', driverName], { env: appiumEnv, logger });
}
return { appiumHome, appiumBinPath, appiumEnv };
}
async function waitForAppiumReadyAsync({
appiumProcess,
logger,
signal,
}: {
appiumProcess: { getOutput: () => string };
logger: bunyan;
signal?: AbortSignal;
}): Promise<void> {
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
signal?.throwIfAborted();
try {
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
timeout: 2_000,
retries: 0,
logger,
signal,
});
signal?.throwIfAborted();
if (response.ok) {
return;
}
} catch {}
signal?.throwIfAborted();
if (signal) {
await setTimeoutAsync(1_000, undefined, { signal });
} else {
await sleepAsync(1_000);
}
await sleepAsync(1_000);
}
const output = appiumProcess.getOutput();
throw new SystemError(
@@ -1,17 +1,15 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { type Result, asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import semver from 'semver';
import { z } from 'zod';
@@ -21,7 +19,6 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
@@ -32,14 +29,13 @@ import {
resolveConfiguredPackageManager,
resolvePackageExec,
} from '../../utils/packageManager';
import { isProcessDescendantOfAsync, killProcessGroup } from '../../utils/processes';
import { isProcessDescendantOfAsync } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
@@ -52,6 +48,7 @@ import {
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
@@ -93,7 +90,6 @@ export function createStartArgentRemoteSessionBuildFunction(
__metricsId: 'eas/start_argent_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
@@ -132,21 +128,14 @@ export function createStartArgentRemoteSessionBuildFunction(
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
signal,
});
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value as string | undefined,
},
{ runtimePlatform }
);
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
@@ -158,6 +147,10 @@ export function createStartArgentRemoteSessionBuildFunction(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
// Start the potentially slow installation while Argent is being prepared.
// On Linux expo-device-hub calls this again and awaits the same in-flight
// setup before launching. On macOS this remains non-blocking, so only a
@@ -169,124 +162,74 @@ export function createStartArgentRemoteSessionBuildFunction(
const argentExec = (args: string[]): { command: string; args: string[] } =>
resolvePackageExec(packageManager, args);
const startupAbortController = new AbortController();
const startupSignal = signal
? AbortSignal.any([signal, startupAbortController.signal])
: startupAbortController.signal;
const setupSignal = iosStartup ? startupSignal : undefined;
logger.info('Enabling the Argent artifacts list endpoint flag.');
const enableArtifacts = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
]);
await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger });
logger.info('Enabling the Argent tool-server event log flag.');
const enableEventLog = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_EVENT_LOG_FLAG,
]);
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
logger.info('Disabling the Argent video watermark flag.');
const disableVideoWatermark = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'disable',
ARGENT_VIDEO_WATERMARK_FLAG,
]);
// The watermark is cosmetic, so a failure here must not fail the session.
try {
await spawn(disableVideoWatermark.command, disableVideoWatermark.args, { env, logger });
} catch (err) {
logger.warn(
{ err },
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
);
}
const startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
]);
logger.info(
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
);
// Keep Argent itself in foreground mode under the detached process. This preserves
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const secrets: string[] = [];
const argentServer = spawnDetached({
command: startServer.command,
args: startServer.args,
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
logger,
secrets,
});
if (argentServer.pid === undefined) {
throw new SystemError(
'Failed to start Argent: could not determine the PID of the launched process.'
);
}
const artifactPollAbortController = new AbortController();
let artifactPollingPromise: Promise<void> | undefined;
let eventCollection: Awaited<ReturnType<typeof startArgentEventCollectionAsync>> | undefined;
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let argentServer: ReturnType<typeof spawnDetached> | undefined;
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
const startHost = () =>
startDeviceSessionHostAsync(ctx, {
...iosStartup,
separateLogPhase: true,
runtimePlatform,
env,
logger,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
timeoutMs: STARTUP_TIMEOUT_MS,
signal: startupSignal,
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
...launch,
networkCapture,
networkCaptureFields,
}).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
throw error;
});
try {
if (iosStartup) {
hostStartup = asyncResult(startHost());
}
logger.info('Enabling the Argent artifacts list endpoint flag.');
const enableArtifacts = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
]);
await runArgentSetupCommandAsync(
enableArtifacts.command,
enableArtifacts.args,
{ env, logger },
setupSignal
);
logger.info('Enabling the Argent tool-server event log flag.');
const enableEventLog = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_EVENT_LOG_FLAG,
]);
await runArgentSetupCommandAsync(
enableEventLog.command,
enableEventLog.args,
{ env, logger },
setupSignal
);
logger.info('Disabling the Argent video watermark flag.');
const disableVideoWatermark = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'disable',
ARGENT_VIDEO_WATERMARK_FLAG,
]);
// The watermark is cosmetic, so a failure here must not fail the session.
try {
await runArgentSetupCommandAsync(
disableVideoWatermark.command,
disableVideoWatermark.args,
{ env, logger },
setupSignal
);
} catch (err) {
setupSignal?.throwIfAborted();
logger.warn(
{ err },
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
);
}
setupSignal?.throwIfAborted();
const startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
]);
logger.info(
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
);
// Keep Argent itself in foreground mode under the detached process. This preserves
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const secrets: string[] = [];
argentServer = spawnDetached({
command: startServer.command,
args: startServer.args,
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
logger,
secrets,
});
if (argentServer.pid === undefined) {
throw new SystemError(
'Failed to start Argent: could not determine the PID of the launched process.'
);
}
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
let toolServerPort: number;
let toolServerToken: string | undefined;
@@ -296,7 +239,6 @@ export function createStartArgentRemoteSessionBuildFunction(
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
signal: setupSignal,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
@@ -311,10 +253,6 @@ export function createStartArgentRemoteSessionBuildFunction(
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
);
}
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
setupSignal?.throwIfAborted();
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const artifactPollSignal = signal
? AbortSignal.any([signal, artifactPollAbortController.signal])
@@ -349,7 +287,22 @@ export function createStartArgentRemoteSessionBuildFunction(
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
sessionHost = await startDeviceSessionHostAsync(ctx, {
separateLogPhase: true,
runtimePlatform,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
@@ -386,15 +339,13 @@ export function createStartArgentRemoteSessionBuildFunction(
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw setupSignal?.aborted ? setupSignal.reason : error;
throw error;
} finally {
startupAbortController.abort(new Error('Argent session ended.'));
await finishRemoteSessionAsync({
logger,
sessionFailed,
@@ -426,11 +377,11 @@ export function createStartArgentRemoteSessionBuildFunction(
);
}
} finally {
await argentServer?.stopAsync();
await argentServer.stopAsync();
}
})(),
],
['session host', hostStartup?.then(result => result.value?.finishAsync())],
['session host', sessionHost?.finishAsync()],
],
});
}
@@ -490,31 +441,6 @@ export function warnIfArgentPackageVersionCannotBeVerified({
}
}
async function runArgentSetupCommandAsync(
command: string,
args: string[],
options: SpawnOptions,
signal?: AbortSignal
): Promise<void> {
if (!signal) {
await spawn(command, args, options);
return;
}
signal.throwIfAborted();
const task = spawn(command, args, { ...options, detached: true });
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) {
abort();
}
try {
await task;
signal.throwIfAborted();
} finally {
signal.removeEventListener('abort', abort);
}
}
function parseArgentToolServerState(raw: string): ArgentToolServerState {
let json: unknown;
try {
@@ -537,16 +463,13 @@ export async function waitForArgentToolServerStateAsync({
timeoutMs,
pollIntervalMs = 1_000,
getExitError,
signal,
}: {
stateDir: string;
ancestorPid: number;
timeoutMs: number;
pollIntervalMs?: number;
getExitError?: () => Error | undefined;
signal?: AbortSignal;
}): Promise<ArgentToolServerState> {
signal?.throwIfAborted();
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
const throwIfExited = (): void => {
@@ -557,7 +480,6 @@ export async function waitForArgentToolServerStateAsync({
};
while (Date.now() < deadline) {
signal?.throwIfAborted();
throwIfExited();
let matchingState: ArgentToolServerState | undefined;
try {
@@ -583,19 +505,13 @@ export async function waitForArgentToolServerStateAsync({
lastError = err;
}
// Keep process failures outside the retryable state-file read errors.
signal?.throwIfAborted();
throwIfExited();
if (matchingState) {
return matchingState;
}
if (signal) {
await setTimeoutAsync(pollIntervalMs, undefined, { signal });
} else {
await sleepAsync(pollIntervalMs);
}
await sleepAsync(pollIntervalMs);
}
signal?.throwIfAborted();
throw new SystemError(
`Timed out waiting for an argent tool-server state file belonging to process ${ancestorPid} in ${stateDir}${
lastError instanceof Error ? `: ${lastError.message}` : ''
@@ -1,7 +1,7 @@
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import { execFileSync } from 'node:child_process';
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -40,14 +40,7 @@ beforeEach(async () => {
creationTokens.push(request.headers['expo-device-run-session-token'] as string | undefined);
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
uploadSession: {
url: `${url}/artifact/${encodeURIComponent(JSON.parse(body).filename)}`,
headers: {},
},
},
})
JSON.stringify({ data: { uploadSession: { url: `${url}/artifact`, headers: {} } } })
);
});
} else {
@@ -163,14 +156,10 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
]);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
const uploaded = new Map<string, Buffer>();
let putBytes = 0;
handle = (request, response) => {
const chunks: Buffer[] = [];
request.on('data', chunk => {
chunks.push(chunk);
});
request.on('data', chunk => (putBytes += chunk.length));
request.on('end', () => {
uploaded.set(request.url!, Buffer.concat(chunks));
response.statusCode = 200;
response.end();
});
@@ -182,18 +171,10 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
recordings,
})
).resolves.toBe(true);
for (const recording of recordings) {
const manifest = await readFile(path.join(recording.directory, 'session.json'));
const recordingId = path.basename(recording.directory);
expect(uploaded.get(`/artifact/${recordingId}.json`)).toEqual(manifest);
expect(uploaded.get(`/artifact/${recordingId}.mp4`)).toEqual(
await readFile(path.join(recording.directory, JSON.parse(manifest.toString()).recording))
);
}
expect(creationTokens).toEqual(Array(6).fill('session-token'));
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
expect(creationTokens).toEqual(['session-token', 'session-token', 'session-token']);
const inputs = creationBodies
.map(body => JSON.parse(body))
.filter(input => input.kind === 'screen-recording')
.sort((a, b) => a.filename.localeCompare(b.filename));
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
@@ -204,16 +185,6 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
width: 128,
height: 96,
});
const manifests = creationBodies
.map(body => JSON.parse(body))
.filter(input => input.kind === 'screen-recording-metadata')
.sort((a, b) => a.filename.localeCompare(b.filename));
expect(manifests.map(input => input.filename)).toEqual(['cut.json', 'done.json', 'failed.json']);
expect(manifests.map(input => input.metadata.recordingFilename)).toEqual([
'cut.mp4',
'done.mp4',
'failed.mp4',
]);
expect(inputs[2].metadata).toMatchObject({
partial: true,
partialReason: 'Recording finalization exceeded 30000 ms.',
@@ -1,141 +0,0 @@
import type { bunyan } from '@expo/logger';
import { vol } from 'memfs';
import { writeFile } from 'node:fs/promises';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
jest.mock('../deviceRunSessionArtifacts');
jest.mock('../../../sentry');
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const ctx = {} as CustomBuildContext;
const recording = {
udid: 'simulator-id',
deviceName: 'iPhone Duo',
runtimeDisplayName: 'iOS 27.1',
directory: '/recordings/recording-id',
};
const manifest = {
firstFrameWallClock: { unixMs: 1_791_072_000_000, iso8601: '2026-10-04T00:00:00.000Z' },
width: 1440,
height: 1200,
recording: 'recording.mp4',
};
beforeEach(() => {
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset().mockResolvedValue(undefined);
});
it.each([
{},
{
deviceStates: [
{
timeMs: 12.5,
state: {
width: 1200,
height: 1440,
orientation: 'landscape-left',
screen: 'inner',
hingeAngle: 90,
physicalOrientation: 'portrait',
tableMode: false,
contentRect: { x: 220, y: 0, width: 1000, height: 1200 },
},
},
],
interruptions: [{ reason: 'simulatorReboot', pausedAtSeconds: 1, resumedAtSeconds: 2 }],
futureField: { label: '서울 🌲' },
},
])('uploads the complete manifest unchanged beside its video (%j)', async extra => {
const contents = Buffer.from(JSON.stringify({ ...manifest, ...extra }, null, 2) + '\n');
vol.fromJSON({
[recording.directory + '/session.json']: contents.toString(),
[recording.directory + '/recording.mp4']: 'video',
});
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
if (input.kind === 'screen-recording') {
await writeFile(recording.directory + '/session.json', '{}');
}
});
await expect(
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
logger,
deviceRunSessionId: 'session-id',
recordings: [recording],
})
).resolves.toBe(true);
const inputs = jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mock.calls.map(([, input]) => input);
expect(inputs.map(input => input.filename)).toEqual(['recording-id.mp4', 'recording-id.json']);
expect(inputs[1]).toMatchObject({
deviceRunSessionId: 'session-id',
artifactId: 'recording-id-metadata',
kind: 'screen-recording-metadata',
metadata: {
__eas_type: 'screen-recording-metadata',
recordingFilename: 'recording-id.mp4',
udid: recording.udid,
},
size: contents.length,
});
for (let attempt = 0; attempt < 2; attempt++) {
const chunks: Buffer[] = [];
for await (const chunk of inputs[1].openStream()) {
chunks.push(Buffer.from(chunk));
}
expect(Buffer.concat(chunks)).toEqual(contents);
}
});
it('reports a metadata upload failure while keeping the video and uploading other recordings', async () => {
const other = { ...recording, directory: '/recordings/other-recording', udid: 'other-device' };
for (const item of [recording, other]) {
vol.fromJSON({
[item.directory + '/session.json']: JSON.stringify(manifest),
[item.directory + '/recording.mp4']: 'video',
});
}
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
if (input.filename === 'recording-id.json') {
throw new Error('Storage unavailable');
}
});
await expect(
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
logger,
deviceRunSessionId: 'session-id',
recordings: [recording, other],
})
).resolves.toBe(false);
expect(
jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls.map(([, input]) => input.filename)
).toEqual(
expect.arrayContaining([
'recording-id.mp4',
'recording-id.json',
'other-recording.mp4',
'other-recording.json',
])
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not upload device screen recording metadata',
expect.any(Error)
);
expect(Sentry.capture).not.toHaveBeenCalledWith(
'Could not upload device screen recording',
expect.any(Error)
);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
`Could not upload screen recording metadata for ${recording.deviceName}.`
);
});
@@ -12,9 +12,7 @@ import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { verifyLocalEgressGuardAsync } from '../localEgressGuard';
import { type ServeSimApplicationOptions } from '../remoteDeviceRunSession';
import { readLocalEgressHandoffAsync } from '../localEgress';
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
import { startServeSimActivityPolling } from '../serveSimActivity';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
@@ -46,11 +44,6 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
}));
jest.mock('../localEgressGuard', () => ({ verifyLocalEgressGuardAsync: jest.fn() }));
jest.mock('../serveSimActions');
jest.mock('../serveSimActivity');
jest.mock('../localEgress', () => ({
...jest.requireActual('../localEgress'),
readLocalEgressHandoffAsync: jest.fn().mockResolvedValue(null),
}));
jest.mock('../deviceRunSessionArtifacts');
jest.mock('../../../sentry');
jest.mock('../serveSimMetricsRecorder', () => ({
@@ -136,7 +129,6 @@ beforeEach(() => {
jest.clearAllMocks();
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReset().mockResolvedValue(undefined);
jest.mocked(verifyLocalEgressGuardAsync).mockReset().mockResolvedValue(undefined);
jest.mocked(readLocalEgressHandoffAsync).mockReset().mockResolvedValue(null);
stopSessionPreview.mockResolvedValue(undefined);
jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview });
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
@@ -460,62 +452,6 @@ it('starts the readiness budget after refusals that exceed the normal startup de
await rejected;
});
it('reports preview activity from serve-sim on iOS and stops polling when finished', async () => {
const observedAt = new Date('2026-10-19T12:00:00Z');
const stopActivity = jest.fn().mockResolvedValue(undefined);
jest.mocked(startServeSimActivityPolling).mockReturnValue({
getLastActivityObservedAt: () => observedAt,
stopAsync: stopActivity,
});
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger,
timeoutMs: 10_000,
});
const port = Number(
jest.mocked(spawnDetached).mock.calls[0][0].args[
jest.mocked(spawnDetached).mock.calls[0][0].args.indexOf('--port') + 1
]
);
expect(startServeSimActivityPolling).toHaveBeenCalledWith({
port,
token: 'preview-token',
logger: expect.anything(),
});
expect(host.getLastPreviewActivityAt()).toBe(observedAt);
await host.finishAsync();
expect(stopActivity).toHaveBeenCalledTimes(1);
});
it('does not start polling preview activity when startup is cancelled', async () => {
const controller = new AbortController();
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockImplementation(async () => {
controller.abort(new Error('cancelled'));
});
await expect(
startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
iosSimulatorUdid: 'emulator-5554',
env,
logger,
timeoutMs: 10_000,
signal: controller.signal,
})
).rejects.toThrow();
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
});
it('reports no preview activity on Android, where expo-device-hub keeps no event log', async () => {
const host = await startHostAsync();
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
expect(host.getLastPreviewActivityAt()).toBeUndefined();
await host.finishAsync();
});
it('stops a host that became ready on a different Simulator', async () => {
await expect(
startDeviceSessionHostAsync(ctx, {
@@ -1383,35 +1319,6 @@ it('uses the existing step logger for startup and shutdown output', async () =>
).toBe(false);
});
it('passes the active local-egress address to serve-sim without tunnel credentials', async () => {
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue({
port: 8899,
url: 'https://tunnel.example',
token: 'tunnel-secret',
fingerprint: 'fingerprint',
});
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger,
timeoutMs: 10_000,
networkCapture: true,
});
const spawned = jest.mocked(spawnDetached).mock.calls[0][0];
expect(spawned.args).toEqual(
expect.arrayContaining(['--network-capture-proxy', 'http://127.0.0.1:8899'])
);
expect(spawned.args).toContain('--network-capture');
expect(JSON.stringify(spawned)).not.toContain('tunnel-secret');
await host.finishAsync();
});
it('does not read the iOS egress handoff for an Android host', async () => {
const host = await startHostAsync();
expect(readLocalEgressHandoffAsync).not.toHaveBeenCalled();
await host.finishAsync();
});
it.each([true, false])(
'waits for setup before launch or boot-only readiness (app: %s)',
async hasApp => {
@@ -3,7 +3,6 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import * as processes from '../../../utils/processes';
import { preparePreinstalledWdaAsync } from '../preinstalledWda';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
@@ -79,37 +78,3 @@ it('does not enable preinstalled startup when the download produces no runner',
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({});
});
it('drains an aborted WDA downloader before removing its partial artifact', async () => {
await fs.promises.mkdir(outdir, { recursive: true });
const controller = new AbortController();
const error = new Error('Simulator startup failed');
const child = { pid: 12345, kill: jest.fn() };
let closeDownloader!: (error: Error) => void;
jest.mocked(spawn).mockReturnValueOnce(
Object.assign(
new Promise((_resolve, reject) => {
closeDownloader = reject;
}),
{ child }
) as never
);
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
const running = preparePreinstalledWdaAsync({ ...options, signal: controller.signal });
const settled = jest.fn();
const observed = running.then(settled, settled);
try {
controller.abort(error);
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
expect(settled).not.toHaveBeenCalled();
expect(fs.existsSync(outdir)).toBe(true);
closeDownloader(new Error('download interrupted'));
await expect(running).rejects.toBe(error);
expect(fs.existsSync(outdir)).toBe(false);
expect(logger.warn).not.toHaveBeenCalled();
} finally {
closeDownloader(error);
await observed;
killGroup.mockRestore();
}
});
@@ -18,7 +18,6 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import * as processes from '../../../utils/processes';
import {
IosSimulatorRecordingUtils,
SERVE_SIM_STOP_GRACE_PERIOD_MS,
@@ -661,14 +660,6 @@ describe(createServeSimArgs, () => {
);
});
it('passes a capture proxy as one argument, including when capture will be enabled later', () => {
const proxy = 'http://user:p%40ss%[email protected]:8899';
const args = createServeSimArgs({ port: 4321, networkCaptureProxy: proxy });
expect(args.slice(-2)).toEqual(['--network-capture-proxy', proxy]);
expect(args).not.toContain('--network-capture');
expect(createServeSimArgs({ port: 4321 })).not.toContain('--network-capture-proxy');
});
it('appends --network-capture when enabled, which also covers an already booted simulator', () => {
const args = createServeSimArgs({ port: 4321, networkCapture: true });
expect(args).toContain('--network-capture');
@@ -1939,47 +1930,6 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
});
it('keeps the session alive while preview input keeps arriving', async () => {
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'stopped' }]);
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
idleTimeout: {
maxIdleTimeMinutes: 1,
getLastEventObservedAt: () => undefined,
getLastPreviewActivityAt: () => new Date(),
},
});
expect(workerApiCallCount('session')).toBe(21);
expect(workerApiCallCount('stop')).toBe(0);
});
it('counts idle time from the later of automation and preview activity', async () => {
const ctx = createStatusCtxMock(manyInProgressStatuses());
const logger = createLoggerMock();
const previewInputAt = new Date(Date.now() + 30_000);
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
idleTimeout: {
maxIdleTimeMinutes: 1,
getLastEventObservedAt: () => undefined,
getLastPreviewActivityAt: () =>
Date.now() >= previewInputAt.getTime() ? previewInputAt : undefined,
},
});
// Idle from 30 s, so the stop comes at 90 s: 18 polls.
expect(workerApiCallCount('session')).toBe(18);
expect(workerApiCallCount('stop')).toBe(1);
});
it('still returns when the session cannot be marked stopped', async () => {
const ctx = createStatusCtxMock(manyInProgressStatuses(), {
stopError: new Error('forbidden'),
@@ -2185,105 +2135,6 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
expect(spawnMock).toHaveBeenCalledTimes(3);
});
it('retries one aborted FFmpeg install for two surviving shared callers after it drains', async () => {
const controller = new AbortController();
const error = new Error('Simulator startup failed');
const child = { pid: 12345, kill: jest.fn() };
let closeInstaller!: (error: Error) => void;
spawnMock.mockReturnValueOnce(
Object.assign(
new Promise((_resolve, reject) => {
closeInstaller = reject;
}),
{ child }
) as never
);
let finishReplacement!: () => void;
spawnMock.mockReturnValueOnce(
new Promise<void>(resolve => {
finishReplacement = resolve;
}) as never
);
spawnMock.mockReturnValue(spawnResolved());
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
const options = {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createPathEnv(),
logger: createLoggerMock(),
};
const owner = ensureFfmpegInstalledOnceAsync({ ...options, signal: controller.signal });
const surviving = [
ensureFfmpegInstalledOnceAsync(options),
ensureFfmpegInstalledOnceAsync({ ...options, signal: new AbortController().signal }),
];
const settled = jest.fn();
const observed = owner.then(settled, settled);
const borrowersSettled = jest.fn();
const observedBorrowers = Promise.all(surviving).then(borrowersSettled, borrowersSettled);
try {
await new Promise<void>(resolve => setImmediate(resolve));
controller.abort(error);
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
expect(settled).not.toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledTimes(1);
closeInstaller(new Error('brew interrupted'));
await expect(owner).rejects.toBe(error);
await new Promise<void>(resolve => setImmediate(resolve));
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(borrowersSettled).not.toHaveBeenCalled();
finishReplacement();
await expect(Promise.all(surviving)).resolves.toEqual([undefined, undefined]);
expect(spawnMock).toHaveBeenCalledTimes(3);
expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall);
expect(options.logger.warn).not.toHaveBeenCalled();
} finally {
closeInstaller?.(error);
finishReplacement();
await Promise.all([observed, observedBorrowers]);
killGroup.mockRestore();
}
});
it('stops an aborted shared waiter without cancelling the installer owner', async () => {
let finishInstall!: () => void;
spawnMock.mockReturnValueOnce(
new Promise<void>(resolve => {
finishInstall = resolve;
}) as never
);
spawnMock.mockReturnValue(spawnResolved());
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
const controller = new AbortController();
const error = new Error('Appium startup failed');
const options = {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createPathEnv(),
logger: createLoggerMock(),
};
const owner = ensureFfmpegInstalledOnceAsync(options);
let ownerFinished = false;
const observedOwner = owner.then(() => {
ownerFinished = true;
});
const waiter = ensureFfmpegInstalledOnceAsync({ ...options, signal: controller.signal });
const observedWaiter = waiter.catch(() => {});
try {
await new Promise<void>(resolve => setImmediate(resolve));
controller.abort(error);
await expect(waiter).rejects.toBe(error);
expect(ownerFinished).toBe(false);
expect(killGroup).not.toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledTimes(1);
finishInstall();
await expect(owner).resolves.toBeUndefined();
expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall);
} finally {
finishInstall();
await Promise.all([observedOwner, observedWaiter]);
killGroup.mockRestore();
}
});
it('still installs on linux when the apt index refresh fails', async () => {
spawnMock
.mockReturnValueOnce(spawnRejected()) // apt-get update
@@ -2321,30 +2172,6 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
});
it('does not retry an ordinary shared FFmpeg install failure', async () => {
let failInstall!: (error: Error) => void;
spawnMock.mockReturnValueOnce(
new Promise((_resolve, reject) => {
failInstall = reject;
}) as never
);
const options = {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createPathEnv(),
logger: createLoggerMock(),
};
const callers = [
ensureFfmpegInstalledOnceAsync(options),
ensureFfmpegInstalledOnceAsync(options),
ensureFfmpegInstalledOnceAsync({ ...options, signal: new AbortController().signal }),
];
await new Promise<void>(resolve => setImmediate(resolve));
failInstall(new Error('Homebrew unavailable'));
await expect(Promise.all(callers)).resolves.toEqual([undefined, undefined, undefined]);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(options.logger.warn).toHaveBeenCalledTimes(1);
});
// The caller runs this with `void` and the worker installs no unhandledRejection
// handler, so a rejection here would crash the process. `spawn` is not async and
// can throw synchronously, which `asyncResult` cannot catch.
@@ -1,196 +0,0 @@
import { type bunyan } from '@expo/logger';
import { once } from 'node:events';
import http from 'node:http';
import { fetchLatestServeSimEventIdAsync, startServeSimActivityPolling } from '../serveSimActivity';
const token = 'test-session-token';
let server: http.Server;
let port: number;
let events: { id: number }[];
let status: number;
let requests: { url?: string; authorization?: string }[];
let rawBody: string | undefined;
function createLoggerMock(): bunyan {
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
}
beforeEach(async () => {
events = [];
status = 200;
requests = [];
rawBody = undefined;
server = http.createServer((req, res) => {
requests.push({ url: req.url, authorization: req.headers.authorization });
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(rawBody ?? JSON.stringify({ events: events.slice(-1) }));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
port = (server.address() as { port: number }).port;
});
afterEach(async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
});
describe(fetchLatestServeSimEventIdAsync, () => {
it('reads the newest event ID with the session token', async () => {
events = [{ id: 3 }, { id: 7 }];
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBe(7);
expect(requests).toEqual([{ url: '/api/event-log?limit=1', authorization: `Bearer ${token}` }]);
});
it('returns undefined when serve-sim has logged no event', async () => {
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBeUndefined();
});
it.each([
['unparseable JSON', 'not json'],
['a body without an events array', '{}'],
['an entry without a numeric ID', '{"events":[{"id":"7"}]}'],
])('rejects %s', async (_, body) => {
rawBody = body;
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow(
'serve-sim event log'
);
});
it('rejects a refused request', async () => {
status = 401;
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow('401');
});
});
describe(startServeSimActivityPolling, () => {
async function waitForRequestsAsync(count: number): Promise<void> {
while (requests.length < count) {
await new Promise(resolve => setTimeout(resolve, 5));
}
}
it('reports activity only when a new event appears', async () => {
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
expect(polling.getLastActivityObservedAt()).toBeUndefined();
events = [{ id: 1 }];
const before = Date.now();
await waitForRequestsAsync(requests.length + 2);
const firstSeen = polling.getLastActivityObservedAt();
expect(firstSeen?.getTime()).toBeGreaterThanOrEqual(before);
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
events = [{ id: 1 }, { id: 2 }];
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
} finally {
await polling.stopAsync();
}
});
it('reports current activity while the event log fails, and warns once', async () => {
status = 500;
const logger = createLoggerMock();
const polling = startServeSimActivityPolling({ port, token, logger, pollIntervalMs: 10 });
try {
await waitForRequestsAsync(3);
expect(logger.warn).toHaveBeenCalledTimes(1);
const before = Date.now();
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
status = 200;
events = [{ id: 4 }];
await waitForRequestsAsync(requests.length + 2);
const observedAt = polling.getLastActivityObservedAt();
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
} finally {
await polling.stopAsync();
}
});
it('keeps the last observation while serve-sim refuses connections', async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
server = http.createServer();
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await new Promise(resolve => setTimeout(resolve, 50));
expect(polling.getLastActivityObservedAt()).toBeUndefined();
} finally {
await polling.stopAsync();
}
});
it('reports current activity when serve-sim drops the connection mid-response', async () => {
server.removeAllListeners('request');
server.on('request', (req, res) => {
requests.push({ url: req.url });
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '100' });
res.write('{"events":');
setImmediate(() => res.socket?.destroy());
});
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(3);
const before = Date.now();
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
} finally {
await polling.stopAsync();
}
});
it('reports current activity while serve-sim answers without an event log', async () => {
status = 404;
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
const before = Date.now();
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
status = 200;
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBeUndefined();
} finally {
await polling.stopAsync();
}
});
it('stops polling after stopAsync', async () => {
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
await waitForRequestsAsync(1);
await polling.stopAsync();
const count = requests.length;
await new Promise(resolve => setTimeout(resolve, 50));
expect(requests.length).toBe(count);
});
});
@@ -196,7 +196,7 @@ export namespace GooglePlayUtils {
signal?.throwIfAborted();
return isUploadStillProcessing(error);
},
{ retries: 13, factor: 2, minTimeout: 2000, maxTimeout: 32_000 },
{ retries: 5, factor: 2, minTimeout: 2000, maxTimeout: 32_000 },
({ attemptNumber, maxAttemptsCount }) => {
logger.warn(
`Google Play is still processing the uploaded bundle. Retrying the same edit commit (attempt ${attemptNumber}/${maxAttemptsCount}).`
@@ -206,7 +206,7 @@ export namespace GooglePlayUtils {
} catch (error) {
if (isUploadStillProcessing(error)) {
throw new SystemError(
`Google Play did not finish processing the uploaded bundle for ${packageName} within five minutes. Wait a few minutes, then check the release at https://play.google.com/console/ before submitting again.`,
`Google Play did not finish processing the uploaded bundle for ${packageName} after six commit attempts. Wait a few minutes, then check the release at https://play.google.com/console/ before submitting again.`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
}
@@ -110,12 +110,12 @@ it.each([false, true])(
}
);
it('stops waiting for bundle processing after 14 attempts', async () => {
it('stops waiting for bundle processing after six attempts', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.times(14)
.times(6)
.reply(400, { error: { message: pendingUploadMessage } });
const error = await commit().catch(error => error);
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toMatchObject({
@@ -5,7 +5,6 @@ import spawn from '@expo/turtle-spawn';
import { createReadStream } from 'node:fs';
import { readFile, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import limitFactory from 'promise-limit';
import { z } from 'zod';
@@ -141,8 +140,9 @@ export async function uploadDeviceRunSessionScreenRecordingsAsync(
recordings.map(recording =>
limit(async () => {
try {
const manifest = await readFile(path.join(recording.directory, 'session.json'));
const metadata = RecordingManifestSchema.parse(JSON.parse(manifest.toString('utf-8')));
const metadata = RecordingManifestSchema.parse(
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
);
if (path.basename(metadata.recording) !== metadata.recording) {
throw new Error('Recording filename must not contain a directory.');
}
@@ -186,31 +186,6 @@ export async function uploadDeviceRunSessionScreenRecordingsAsync(
openStream: () => createReadStream(recordingPath),
attempts: 3,
});
try {
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: `${recordingId}-metadata`,
name: `${displayName} metadata`,
filename: `${recordingId}.json`,
kind: 'screen-recording-metadata',
metadata: {
__eas_type: 'screen-recording-metadata',
recordingFilename: `${recordingId}.mp4`,
udid: recording.udid,
},
size: manifest.length,
openStream: () => Readable.from(manifest),
attempts: 3,
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload device screen recording metadata', error);
logger.warn(
{ err: error },
`Could not upload screen recording metadata for ${recording.deviceName}.`
);
return false;
}
return true;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
@@ -40,7 +40,6 @@ import {
spawnDetached,
startNgrokTunnelAsync,
} from './remoteDeviceRunSession';
import { LOCAL_EGRESS_PROXY_HOST, readLocalEgressHandoffAsync } from './localEgress';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import {
IosSimulatorRecordingUtils,
@@ -49,7 +48,6 @@ import {
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
import { startLogPhase } from '../../utils/logPhase';
import { runServeSimActionAsync, stageServeSimAppAsync } from './serveSimActions';
import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity';
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { createStartupTasks } from './startupTasks';
@@ -138,7 +136,6 @@ export function createServeSimArgs({
openUrl,
networkCapture = false,
networkCaptureFields = [],
networkCaptureProxy,
}: {
port: number;
turnArgs?: string[];
@@ -152,7 +149,6 @@ export function createServeSimArgs({
installAppPath?: string;
networkCapture?: boolean;
networkCaptureFields?: string[];
networkCaptureProxy?: string;
} & ServeSimLaunchOptions): string[] {
return [
createServeSimPackageSpec(packageVersion),
@@ -192,7 +188,6 @@ export function createServeSimArgs({
...networkCaptureFields.flatMap(field => ['--network-capture-field', field]),
]
: []),
...(networkCaptureProxy !== undefined ? ['--network-capture-proxy', networkCaptureProxy] : []),
];
}
@@ -355,8 +350,6 @@ export type DeviceWebPreview = {
export type DeviceSessionHost = {
openPreviewAsync(options: { baseDomain: string; authtoken: string }): Promise<DeviceWebPreview>;
/** Last observed preview input. Always `undefined` on Android, which keeps no event log. */
getLastPreviewActivityAt(): Date | undefined;
/**
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
* and reported, so callers need no error handling around it.
@@ -500,7 +493,6 @@ async function startDeviceSessionHostInternalAsync(
? createExpoDeviceHubPackageSpec(packageVersion)
: createServeSimPackageSpec(packageVersion);
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
const localEgress = !isAndroid ? await readLocalEgressHandoffAsync() : null;
const previewExec = resolvePackageExec(
resolveConfiguredPackageManager(env, PackageManager.NPM),
isAndroid
@@ -530,9 +522,6 @@ async function startDeviceSessionHostInternalAsync(
openUrl: iosSimulatorUdid ? undefined : openUrl,
networkCapture,
networkCaptureFields,
networkCaptureProxy: localEgress
? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}`
: undefined,
})
);
logger.info(
@@ -588,7 +577,6 @@ async function startDeviceSessionHostInternalAsync(
const previewSetupController = new AbortController();
let previewSetupTask: Promise<void> | undefined;
let previewFailed = false;
let previewActivity: ServeSimActivityPolling | null = null;
const host: DeviceSessionHost = {
openPreviewAsync({ baseDomain, authtoken }) {
@@ -646,9 +634,6 @@ async function startDeviceSessionHostInternalAsync(
});
return opening;
},
getLastPreviewActivityAt() {
return previewActivity?.getLastActivityObservedAt();
},
finishAsync() {
readinessController.abort(new Error('Simulator preview ended.'));
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
@@ -657,7 +642,6 @@ async function startDeviceSessionHostInternalAsync(
previewSetupController.abort();
await previewSetupTask;
await sessionPreview?.stopAsync();
await previewActivity?.stopAsync();
},
previewServer,
screenshots,
@@ -785,9 +769,6 @@ async function startDeviceSessionHostInternalAsync(
secrets.push(previewToken);
if (!isAndroid) {
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
if (!finishTask) {
previewActivity = startServeSimActivityPolling({ port, token: previewToken, logger });
}
}
// Android installed FFmpeg before launching the host. The optional thumbnail must not delay
// readiness on macOS, so it starts after its encoder is installed there.
@@ -12,16 +12,13 @@ export async function preparePreinstalledWdaAsync({
appiumBinPath,
appiumEnv,
logger,
signal,
}: {
runtimeIdentifier: string | undefined;
appiumHome: string;
appiumBinPath: string;
appiumEnv: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<Record<string, unknown>> {
signal?.throwIfAborted();
const iosMajor = /^com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)(?:-|$)/.exec(
runtimeIdentifier ?? ''
)?.[1];
@@ -55,17 +52,10 @@ export async function preparePreinstalledWdaAsync({
// Appium's downloader inherits its pipes; killing only Appium leaves spawn's
// close event pending. Kill the owned group, then await close before cleanup.
const timeout = setTimeout(() => killProcessGroup(download.child, 'SIGKILL'), 60_000);
const abort = (): void => killProcessGroup(download.child, 'SIGKILL');
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) {
abort();
}
try {
await download;
signal?.throwIfAborted();
} finally {
clearTimeout(timeout);
signal?.removeEventListener('abort', abort);
}
if (!(await fs.promises.stat(runner)).isDirectory()) {
throw new Error('The downloaded WebDriverAgent runner is not an app directory.');
@@ -77,7 +67,6 @@ export async function preparePreinstalledWdaAsync({
};
} catch (err) {
await fs.promises.rm(outdir, { recursive: true, force: true });
signal?.throwIfAborted();
logger.warn(
{ err },
'Could not prepare preinstalled WebDriverAgent; using the default xcodebuild startup.'
@@ -25,7 +25,7 @@ import {
parseOpenUrlInput,
} from '../functions/launchApplication';
import { Sentry } from '../../sentry';
import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
import { isProcessGroupRunning } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { createProcessOutput } from './processOutput';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
@@ -126,8 +126,6 @@ export type DeviceRunSessionIdleTimeout = {
* begins, so a session nobody ever connects to still times out.
*/
getLastEventObservedAt: () => Date | undefined;
/** Last observed preview input, counted like a session event. */
getLastPreviewActivityAt?: () => Date | undefined;
};
export async function waitForDeviceRunSessionStoppedAsync({
@@ -181,13 +179,9 @@ export async function waitForDeviceRunSessionStoppedAsync({
while (!signal.aborted) {
if (idleTimeout) {
for (const observedAt of [
idleTimeout.getLastEventObservedAt(),
idleTimeout.getLastPreviewActivityAt?.(),
]) {
if (observedAt && observedAt > lastActivityAt) {
lastActivityAt = observedAt;
}
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
lastActivityAt = lastEventObservedAt;
}
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
logger.info(
@@ -317,28 +311,14 @@ function warmUpFfmpeg(env: BuildStepEnv): void {
async function installFfmpegWithHomebrewAsync({
env,
logger,
signal,
}: {
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<void> {
signal?.throwIfAborted();
const install = spawn('brew', ['install', 'ffmpeg'], {
await spawn('brew', ['install', 'ffmpeg'], {
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
logger,
...(signal ? { detached: true } : {}),
});
const abort = (): void => killProcessGroup(install.child, 'SIGKILL');
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) {
abort();
}
try {
await install;
} finally {
signal?.removeEventListener('abort', abort);
}
}
async function installFfmpegWithAptAsync({
@@ -356,7 +336,7 @@ async function installFfmpegWithAptAsync({
await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger });
}
let ffmpegSetupPromise: Promise<'aborted' | void> | undefined;
let ffmpegSetupPromise: Promise<void> | undefined;
/**
* Install ffmpeg when the runtime does not already provide it. Device-session
@@ -376,16 +356,13 @@ async function ensureFfmpegInstalledAsync({
runtimePlatform,
env,
logger,
signal,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<'aborted' | void> {
}): Promise<void> {
try {
const ffmpegPath = await findFfmpegOnPathAsync(env);
signal?.throwIfAborted();
if (ffmpegPath) {
logger.info(`ffmpeg is already installed at ${ffmpegPath}.`);
warmUpFfmpeg(env);
@@ -399,17 +376,13 @@ async function ensureFfmpegInstalledAsync({
} for the device session.`
);
if (isDarwin) {
await installFfmpegWithHomebrewAsync({ env, logger, signal });
await installFfmpegWithHomebrewAsync({ env, logger });
} else {
await installFfmpegWithAptAsync({ env, logger });
}
signal?.throwIfAborted();
logger.info('Installed ffmpeg.');
warmUpFfmpeg(env);
} catch (err) {
if (signal?.aborted) {
return 'aborted';
}
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not install ffmpeg for the device session', error, {
level: 'warning',
@@ -425,46 +398,20 @@ export async function ensureFfmpegInstalledOnceAsync({
runtimePlatform,
env,
logger,
signal,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<void> {
signal?.throwIfAborted();
if (ffmpegSetupPromise) {
const sharedSetup = ffmpegSetupPromise;
let outcome: 'aborted' | void;
if (signal) {
outcome = await new Promise<'aborted' | void>((resolve, reject) => {
const abort = (): void => reject(signal.reason);
signal.addEventListener('abort', abort, { once: true });
void sharedSetup.then(result => {
signal.removeEventListener('abort', abort);
resolve(result);
});
if (signal.aborted) {
abort();
}
});
} else {
outcome = await sharedSetup;
}
if (outcome === 'aborted') {
if (ffmpegSetupPromise === sharedSetup) {
ffmpegSetupPromise = undefined;
}
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal });
}
await ffmpegSetupPromise;
return;
}
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger, signal });
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
ffmpegSetupPromise = setupPromise;
try {
await setupPromise;
signal?.throwIfAborted();
} finally {
if (ffmpegSetupPromise === setupPromise) {
ffmpegSetupPromise = undefined;
@@ -1,131 +0,0 @@
import { type bunyan } from '@expo/logger';
const POLL_INTERVAL_MS = 5_000;
const REQUEST_TIMEOUT_MS = 5_000;
export type ServeSimActivityPolling = {
/**
* When a new event log entry (input or host action, not stream traffic) was first seen. Reports
* now while the log is unreadable but serve-sim is up, so an unobservable session stays active.
*/
getLastActivityObservedAt: () => Date | undefined;
stopAsync: () => Promise<void>;
};
/** Nothing listens on the port. A reset or dropped response can come from a running serve-sim. */
function isServeSimDownError(err: unknown): boolean {
// Not `instanceof`: undici errors can come from another realm.
let error: unknown = err;
for (let depth = 0; depth < 5 && typeof error === 'object' && error !== null; depth++) {
const { code, cause } = error as { code?: unknown; cause?: unknown };
if (code === 'ECONNREFUSED') {
return true;
}
error = cause;
}
return false;
}
/** A changed newest event ID counts as activity, timed by the local clock. */
export function startServeSimActivityPolling({
port,
token,
logger,
pollIntervalMs = POLL_INTERVAL_MS,
}: {
port: number;
token: string;
logger: bunyan;
pollIntervalMs?: number;
}): ServeSimActivityPolling {
const controller = new AbortController();
let lastEventId: number | undefined;
let lastActivityObservedAt: Date | undefined;
let eventLogUnavailable = false;
let didWarn = false;
let timer: NodeJS.Timeout | undefined;
let pending: Promise<void> = Promise.resolve();
const pollAsync = async (): Promise<void> => {
try {
const eventId = await fetchLatestServeSimEventIdAsync({
port,
token,
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
});
eventLogUnavailable = false;
if (eventId !== undefined && eventId !== lastEventId) {
lastEventId = eventId;
lastActivityObservedAt = new Date();
}
} catch (err) {
if (controller.signal.aborted) {
return;
}
// Only a down serve-sim, which receives no input either, keeps the last observation.
eventLogUnavailable = !isServeSimDownError(err);
if (!didWarn) {
didWarn = true;
logger.warn(
{ err },
eventLogUnavailable
? 'Could not read the serve-sim event log. Preview input cannot be observed, so the ' +
'idle timeout does not stop this session.'
: 'Could not read serve-sim preview activity.'
);
}
}
};
const tick = (): void => {
pending = pollAsync().finally(() => {
if (!controller.signal.aborted) {
timer = setTimeout(tick, pollIntervalMs);
timer.unref();
}
});
};
tick();
return {
getLastActivityObservedAt: () => (eventLogUnavailable ? new Date() : lastActivityObservedAt),
stopAsync: async () => {
controller.abort();
clearTimeout(timer);
await pending;
},
};
}
export async function fetchLatestServeSimEventIdAsync({
port,
token,
signal,
}: {
port: number;
token: string;
signal?: AbortSignal;
}): Promise<number | undefined> {
const response = await fetch(`http://127.0.0.1:${port}/api/event-log?limit=1`, {
headers: { Authorization: `Bearer ${token}` },
signal,
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`serve-sim event log request failed: ${response.status}`);
}
const text = await response.text();
let body: { events?: unknown } | undefined;
try {
body = JSON.parse(text);
} catch {}
if (!Array.isArray(body?.events)) {
throw new Error('serve-sim event log response has no events array.');
}
if (body.events.length === 0) {
return undefined;
}
const id = (body.events.at(-1) as { id?: unknown } | null)?.id;
if (typeof id !== 'number') {
throw new Error('serve-sim event log entry has no numeric ID.');
}
return id;
}
@@ -1,13 +1,5 @@
# create-eas-build-function
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "25.3.0",
"version": "25.1.1",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "25.3.0"
"@expo/steps": "25.1.1"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
-8
View File
@@ -1,13 +1,5 @@
# @expo/downloader
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "25.3.0",
"version": "25.1.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,13 +1,5 @@
# eas-build-cache-provider
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "25.3.0",
"version": "25.1.1",
"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",
-8
View File
@@ -1,13 +1,5 @@
# @expo/eas-build-job
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "25.3.0",
"version": "25.1.1",
"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": "25.3.0",
"@expo/logger": "25.1.1",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "25.3.0",
"@expo/turtle-spawn": "25.1.1",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -1,9 +1,4 @@
import {
DeviceRunSessionEvent,
parseDeviceRunSessionEvents,
quoteAndroidShellCommand,
validateAndroidLaunchOptions,
} from '../deviceRunSession';
import { DeviceRunSessionEvent, parseDeviceRunSessionEvents } from '../deviceRunSession';
describe('parseDeviceRunSessionEvents', () => {
it('parses, deduplicates, and sorts events from the dedicated event log', () => {
@@ -38,56 +33,6 @@ describe('parseDeviceRunSessionEvents', () => {
});
});
describe(quoteAndroidShellCommand, () => {
it('quotes only arguments the device shell would split or expand', () => {
expect(
quoteAndroidShellCommand(['am', 'start', '--es', 'text', 'hello world', "it's", '', '-n'])
).toEqual(['am', 'start', '--es', 'text', "'hello world'", "'it'\\''s'", "''", '-n']);
});
it('rejects NUL characters', () => {
expect(() => quoteAndroidShellCommand(['am', 'start', 'a\0b'])).toThrow(
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
);
});
});
describe(validateAndroidLaunchOptions, () => {
it('rejects only commands that are too long without the launch component', () => {
const fixedLength = 'am start --es key -n'.length;
const launchArgs = (valueLength: number) => ['--es', 'key', 'a'.repeat(valueLength)];
expect(() =>
validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength) })
).not.toThrow();
expect(() =>
validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength + 1) })
).toThrow(expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }));
});
it('counts bytes, not characters', () => {
// "é" is two bytes in UTF-8 and is quoted, which adds two more.
const fixedLength = "am start --es key '' -n".length;
const launchArgs = (extraLength: number) => [
'--es',
'key',
'a'.repeat(extraLength) + 'é'.repeat(32_488),
];
expect(fixedLength + 1 + 2 * 32_488).toBe(65_000);
expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(1) })).not.toThrow();
expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(2) })).toThrow(
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
);
});
it('checks the URL to open', () => {
expect(() => validateAndroidLaunchOptions({ openUrl: 'exp://example.test/a\0b' })).toThrow(
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
);
});
});
function createEvent(overrides: Partial<DeviceRunSessionEvent> = {}): string {
return JSON.stringify(createEventValue(overrides));
}
@@ -1,7 +1,5 @@
import { z } from 'zod';
import { UserError } from './errors';
/**
* Canonical record format for the activity events emitted during a remote device run (simulator)
* session. Producers (e.g. the worker and agent-device) write these as NDJSON to the
@@ -48,54 +46,3 @@ export function parseDeviceRunSessionEvents(eventLog: string): DeviceRunSessionE
(left, right) => new Date(left.ts).getTime() - new Date(right.ts).getTime()
);
}
// adb closes the connection for longer shell commands (about 65.5 KB with adb 37 on API 35).
export const ANDROID_SHELL_COMMAND_MAX_BYTES = 65_000;
/**
* Check Android launch inputs before a session starts. The launch component is only known once the
* build is installed, so this rejects only commands that are too long without it; the launch itself
* checks the exact command.
*/
export function validateAndroidLaunchOptions({
launchArgs = [],
openUrl,
}: {
launchArgs?: string[];
openUrl?: string;
}): void {
quoteAndroidShellCommand(['am', 'start', ...launchArgs, '-n']);
if (openUrl) {
quoteAndroidShellCommand(['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl]);
}
}
/**
* `adb shell` joins its arguments with spaces and the device shell parses the result again, so
* each argument is quoted to reach the command as one literal word.
*/
export function quoteAndroidShellCommand(command: string[]): string[] {
if (command.some(argument => argument.includes('\0'))) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'Android "launch_args" and "open_url" cannot contain NUL characters.'
);
}
const quotedCommand = command.map(quoteAndroidShellArgument);
if (
new TextEncoder().encode(quotedCommand.join(' ')).byteLength > ANDROID_SHELL_COMMAND_MAX_BYTES
) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
`The Android launch command is longer than ${ANDROID_SHELL_COMMAND_MAX_BYTES} bytes after shell quoting. Shorten "launch_args" or "open_url".`
);
}
return quotedCommand;
}
function quoteAndroidShellArgument(argument: string): string {
if (/^[\w@%+=:,./-]+$/.test(argument)) {
return argument;
}
return `'${argument.replaceAll("'", `'\\''`)}'`;
}
-15
View File
@@ -1,20 +1,5 @@
# eas-cli
## 25.3.0
### Minor Changes
- Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
- Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
## 25.2.0
### Minor Changes
- Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
- Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
- Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
## 25.1.1
No changes in this release.
+162 -282
View File
File diff suppressed because it is too large. Load diff
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "25.3.0",
"version": "25.1.1",
"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": "25.3.0",
"@expo/eas-json": "25.3.0",
"@expo/eas-build-job": "25.1.1",
"@expo/eas-json": "25.1.1",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "25.3.0",
"@expo/logger": "25.1.1",
"@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": "25.3.0",
"@expo/steps": "25.1.1",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -185,7 +185,6 @@
],
"bin": "eas",
"commands": "./build/commands",
"helpClass": "./build/help",
"plugins": [
"@oclif/plugin-autocomplete",
"@expo/plugin-help",
@@ -1,38 +0,0 @@
import { Config, Help } from '@oclif/core';
import SimulatorControl from '../commands/simulator/control';
import EasHelp from '../help';
describe(EasHelp, () => {
const config = new Config({ root: __dirname });
let run: jest.SpyInstance;
let showHelp: jest.SpyInstance;
beforeEach(() => {
run = jest.spyOn(SimulatorControl, 'run').mockResolvedValue(Promise.resolve());
showHelp = jest.spyOn(Help.prototype, 'showHelp').mockResolvedValue();
});
afterEach(() => jest.restoreAllMocks());
it.each([
['simulator:control', 'rotate', '--help'],
['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'],
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
])('routes action help through the command: %j', async (...argv) => {
await new EasHelp(config).showHelp(argv);
expect(run).toHaveBeenCalledWith(argv.slice(1), config);
expect(showHelp).not.toHaveBeenCalled();
});
it.each([
['simulator:control', '--help'],
['sim:control', '-h'],
['build', '--help'],
['simulator:control', 'clipboard.write', '--', '--help'],
])('preserves regular help and literal values: %j', async (...argv) => {
await new EasHelp(config).showHelp(argv);
expect(showHelp).toHaveBeenCalledWith(argv);
expect(run).not.toHaveBeenCalled();
});
});
@@ -69,7 +69,6 @@ export enum BuildEvent {
BUILD_REQUEST_ATTEMPT = 'build cli build request attempt',
BUILD_REQUEST_SUCCESS = 'build cli build request success',
BUILD_REQUEST_FAIL = 'build cli build request fail',
FREE_TIER_LIMIT_CHECKOUT_PROMPT = 'build cli free tier limit checkout prompt',
BUILD_STATUS_COMMAND = 'build cli build status',
@@ -43,7 +43,7 @@ function hasBillingPermission(permissions: readonly Permission[]): boolean {
* Whether the viewer can manage billing on the account with the credential used for this command.
* The scopes of a scoped credential cap `effectivePermissions`.
*/
export function canManageBilling(account: { viewerUserPermission2: ViewerPermissions }): boolean {
function canManageBilling(account: { viewerUserPermission2: ViewerPermissions }): boolean {
return hasBillingPermission(account.viewerUserPermission2.effectivePermissions);
}
@@ -1,471 +0,0 @@
import type { ExperimentationDefinition } from '@expo/experimentation';
import { Platform } from '@expo/eas-build-job';
import { getAgentTelemetryContext } from '../../analytics/agent';
import { BillingClient } from '../../billing/billingClient';
import { openOrPrintUrlAsync } from '../../billing/openUrl';
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
import {
EMPTY_EXPERIMENTATION_DEFINITION,
ExperimentationClient,
createLoggerConfig,
} from '../../experimentation/ExperimentationClient';
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
import { createMockAnalytics, findUnitsByValue } from '../../experimentation/__tests__/fixtures';
import { getMockViewerUserPermission } from '../../__tests__/viewerUserPermission';
import { Permission, Role } from '../../graphql/generated';
import Log from '../../log';
import { ora } from '../../ora';
import { confirmAsync } from '../../prompts';
import { Actor } from '../../user/User';
import {
FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE,
FreeTierLimitCheckoutExperiment,
getFreeTierLimitCheckoutExperiment,
isEligibleForFreeTierLimitCheckout,
maybeOfferFreeTierLimitCheckoutAsync,
resolveFreeTierLimitCheckoutVariantAsync,
} from '../freeTierLimitCheckout';
jest.mock('../../analytics/agent');
jest.mock('../../billing/billingClient');
jest.mock('../../billing/openUrl');
jest.mock('../../ora');
jest.mock('../../prompts');
const EXPERIMENT_NAME = 'cli-build-free-tier-limit-checkout';
const ACCOUNT_NAME = 'acme';
const AUTHENTICATION_INFO = { accessToken: 'token', sessionSecret: null } as const;
const ANALYTICS_EVENT_PROPERTIES = { tracking_id: 'tracking-id', platform: Platform.ANDROID };
const LIMIT_MESSAGE = 'Error 1\nRun eas billing:subscribe starter --account acme to upgrade to the Starter plan.';
/** Every account is in the experiment: 50% control, 50% treatment. */
const ACCOUNT_DEFINITION: ExperimentationDefinition = {
experiments: [
{
experimentName: EXPERIMENT_NAME,
paramDefinitions: {
variant: { type: 'uniform_choice', choices: ['control', 'treatment'] },
},
exposureLoggerType: 'rudderstack',
},
],
namespaces: [
{
name: FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE,
numSegments: 1000,
segmentExperimentSetupDefinitions: [
{
method: 'ADD',
name: `${EXPERIMENT_NAME} full`,
experimentName: EXPERIMENT_NAME,
numSegments: 1000,
},
],
},
],
};
function createClient(definition: ExperimentationDefinition = ACCOUNT_DEFINITION): {
client: ExperimentationClient;
analytics: ReturnType<typeof createMockAnalytics>;
} {
const analytics = createMockAnalytics();
const client = new ExperimentationClient(
{
user: EMPTY_EXPERIMENTATION_DEFINITION,
account: definition,
device: EMPTY_EXPERIMENTATION_DEFINITION,
},
{ user: null, device: null },
ExperimentOverrides.empty(),
createLoggerConfig(analytics)
);
return { client, analytics };
}
/** Probes a throwaway client, so the exposures it logs do not affect the client under test. */
function findAccountIdsByVariant(): Map<string, string> {
const { client } = createClient();
return findUnitsByValue(
accountId =>
client.getAccountNamespace(FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE, { accountId }),
'variant',
'control',
['control', 'treatment']
);
}
function createOverriddenClient(variant: string): ExperimentationClient {
return ExperimentationClient.createDisabled(
new ExperimentOverrides(
new Map([[FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE, new Map([['variant', variant]])]])
)
);
}
function account(
id: string,
role: Parameters<typeof getMockViewerUserPermission>[0],
{ effectivePermissions }: { effectivePermissions?: Permission[] } = {}
): Actor['accounts'][number] {
const viewerUserPermission2 = getMockViewerUserPermission(role);
return {
id,
name: ACCOUNT_NAME,
viewerUserPermission2: {
...viewerUserPermission2,
effectivePermissions: effectivePermissions ?? viewerUserPermission2.effectivePermissions,
},
} as Actor['accounts'][number];
}
function createExperiment(
accountId: string,
{
client = createClient().client,
accounts = [account(accountId, Role.Admin)],
nonInteractive = false,
runFromCI = false,
analytics = createMockAnalytics(),
}: {
client?: ExperimentationClient;
accounts?: Actor['accounts'];
nonInteractive?: boolean;
runFromCI?: boolean;
analytics?: ReturnType<typeof createMockAnalytics>;
} = {}
): FreeTierLimitCheckoutExperiment {
return {
experimentation: LazyExperimentationClient.fromClient(client),
authenticationInfo: AUTHENTICATION_INFO,
analytics,
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
actor: { id: 'actor-id', accounts } as Actor,
accountId,
accountName: ACCOUNT_NAME,
nonInteractive,
runFromCI,
};
}
const accountIdsByVariant = findAccountIdsByVariant();
const treatmentAccountId = accountIdsByVariant.get('treatment')!;
const controlAccountId = accountIdsByVariant.get('control')!;
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
});
describe(getFreeTierLimitCheckoutExperiment, () => {
const ctx = {
analytics: createMockAnalytics(),
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
user: { id: 'actor-id', accounts: [] } as unknown as Actor,
accountId: 'account-id',
accountName: ACCOUNT_NAME,
nonInteractive: false,
runFromCI: false,
};
it('returns the handle when the command has an experimentation client and auth info', () => {
const experimentation = new LazyExperimentationClient(async () => createClient().client);
expect(
getFreeTierLimitCheckoutExperiment({
...ctx,
experimentation,
authenticationInfo: AUTHENTICATION_INFO,
})
).toEqual({
experimentation,
authenticationInfo: AUTHENTICATION_INFO,
analytics: ctx.analytics,
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
actor: ctx.user,
accountId: 'account-id',
accountName: ACCOUNT_NAME,
nonInteractive: false,
runFromCI: false,
});
});
it('returns undefined when the command has no experimentation client', () => {
expect(
getFreeTierLimitCheckoutExperiment({ ...ctx, authenticationInfo: AUTHENTICATION_INFO })
).toBeUndefined();
});
it('returns undefined when the command has no auth info', () => {
expect(
getFreeTierLimitCheckoutExperiment({
...ctx,
experimentation: new LazyExperimentationClient(async () => createClient().client),
})
).toBeUndefined();
});
});
describe(isEligibleForFreeTierLimitCheckout, () => {
it.each([
['admins', Role.Admin],
['owners', Role.Owner],
] as const)('accepts %s at an interactive terminal', (_label, role) => {
expect(
isEligibleForFreeTierLimitCheckout(
createExperiment('account-id', { accounts: [account('account-id', role)] })
)
).toBe(true);
});
it('rejects admins whose credential scopes do not allow managing billing', () => {
expect(
isEligibleForFreeTierLimitCheckout(
createExperiment('account-id', {
accounts: [
account('account-id', Role.Admin, { effectivePermissions: [Permission.View] }),
],
})
)
).toBe(false);
});
it.each([
['developers', Role.Developer],
['view-only members', Role.ViewOnly],
] as const)('rejects %s', (_label, role) => {
expect(
isEligibleForFreeTierLimitCheckout(
createExperiment('account-id', { accounts: [account('account-id', role)] })
)
).toBe(false);
});
it('rejects actors who are not a member of the account', () => {
expect(
isEligibleForFreeTierLimitCheckout(
createExperiment('account-id', { accounts: [account('other-account-id', Role.Admin)] })
)
).toBe(false);
});
it('rejects non-interactive runs', () => {
expect(
isEligibleForFreeTierLimitCheckout(createExperiment('account-id', { nonInteractive: true }))
).toBe(false);
});
it('rejects runs from CI', () => {
expect(
isEligibleForFreeTierLimitCheckout(createExperiment('account-id', { runFromCI: true }))
).toBe(false);
});
it('rejects runs driven by a coding agent', () => {
jest
.mocked(getAgentTelemetryContext)
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
expect(isEligibleForFreeTierLimitCheckout(createExperiment('account-id'))).toBe(false);
});
});
describe(resolveFreeTierLimitCheckoutVariantAsync, () => {
it('does not load the client for ineligible runs', async () => {
const loadAsync = jest.fn(async () => createClient().client);
expect(
await resolveFreeTierLimitCheckoutVariantAsync({
...createExperiment(treatmentAccountId, { nonInteractive: true }),
experimentation: new LazyExperimentationClient(loadAsync),
})
).toBe('control');
expect(loadAsync).not.toHaveBeenCalled();
});
it('returns the account variant and logs the exposure once', async () => {
const { client, analytics } = createClient();
const experiment = createExperiment(treatmentAccountId, { client });
expect(await resolveFreeTierLimitCheckoutVariantAsync(experiment)).toBe('treatment');
expect(await resolveFreeTierLimitCheckoutVariantAsync(experiment)).toBe('treatment');
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
experimentName: EXPERIMENT_NAME,
variationName: 'treatment',
unit: treatmentAccountId,
});
});
it('logs the exposure for control accounts too', async () => {
const { client, analytics } = createClient();
expect(
await resolveFreeTierLimitCheckoutVariantAsync(createExperiment(controlAccountId, { client }))
).toBe('control');
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
experimentName: EXPERIMENT_NAME,
variationName: 'control',
unit: controlAccountId,
});
});
it('returns control and logs no exposure when the namespace is missing from the config', async () => {
const { client, analytics } = createClient(EMPTY_EXPERIMENTATION_DEFINITION);
expect(
await resolveFreeTierLimitCheckoutVariantAsync(
createExperiment(treatmentAccountId, { client })
)
).toBe('control');
expect(analytics.logEvent).not.toHaveBeenCalled();
});
});
describe(maybeOfferFreeTierLimitCheckoutAsync, () => {
const createCheckoutSessionAsync = jest.fn();
const spinner = { start: jest.fn(), stop: jest.fn(), fail: jest.fn() };
let logErrorSpy: jest.SpyInstance;
let logLogSpy: jest.SpyInstance;
beforeEach(() => {
spinner.start.mockReturnValue(spinner);
jest.mocked(ora).mockReturnValue(spinner as any);
jest
.mocked(BillingClient)
.mockImplementation(() => ({ createCheckoutSessionAsync }) as unknown as BillingClient);
logErrorSpy = jest.spyOn(Log, 'error').mockImplementation(() => {});
logLogSpy = jest.spyOn(Log, 'log').mockImplementation(() => {});
});
function treatmentExperiment(
options: Parameters<typeof createExperiment>[1] = {}
): FreeTierLimitCheckoutExperiment {
return createExperiment('account-id', { client: createOverriddenClient('treatment'), ...options });
}
it('does nothing when the command has no experiment handle', async () => {
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, undefined)).toBe(false);
expect(confirmAsync).not.toHaveBeenCalled();
expect(logErrorSpy).not.toHaveBeenCalled();
});
it('does nothing in the control arm', async () => {
const experiment = createExperiment('account-id', { client: createOverriddenClient('control') });
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(false);
expect(confirmAsync).not.toHaveBeenCalled();
expect(logErrorSpy).not.toHaveBeenCalled();
expect(experiment.analytics.logEvent).not.toHaveBeenCalled();
});
it('does nothing for ineligible runs even in the treatment arm', async () => {
const experiment = treatmentExperiment({ nonInteractive: true });
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(false);
expect(confirmAsync).not.toHaveBeenCalled();
expect(logErrorSpy).not.toHaveBeenCalled();
});
it('prints the error, then asks with No as the default', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, treatmentExperiment())).toBe(
true
);
expect(logErrorSpy).toHaveBeenCalledWith(LIMIT_MESSAGE);
expect(confirmAsync).toHaveBeenCalledWith(
{ message: 'Open the Starter plan checkout in your browser now?', initial: false },
{ onCancel: expect.any(Function) }
);
expect(logErrorSpy.mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(confirmAsync).mock.invocationCallOrder[0]
);
});
it('keeps the process alive on Ctrl+C so the build error still reports', async () => {
jest.mocked(confirmAsync).mockResolvedValue(undefined as unknown as boolean);
const experiment = treatmentExperiment();
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
const [, options] = jest.mocked(confirmAsync).mock.calls[0];
expect(options?.onCancel?.({} as any, {})).toBe(false);
expect(BillingClient).not.toHaveBeenCalled();
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
'build cli free tier limit checkout prompt',
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'cancel', checkout_created: false }
);
});
it('creates nothing when the person declines', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
const experiment = treatmentExperiment();
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
expect(BillingClient).not.toHaveBeenCalled();
expect(openOrPrintUrlAsync).not.toHaveBeenCalled();
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
'build cli free tier limit checkout prompt',
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'no', checkout_created: false }
);
});
it('opens the Starter checkout when the person accepts', async () => {
jest.mocked(confirmAsync).mockResolvedValue(true);
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs_1', url: 'https://checkout.test/cs_1' });
const experiment = treatmentExperiment();
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
expect(BillingClient).toHaveBeenCalledWith(AUTHENTICATION_INFO);
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'STARTER');
expect(spinner.stop).toHaveBeenCalled();
expect(spinner.fail).not.toHaveBeenCalled();
expect(openOrPrintUrlAsync).toHaveBeenCalledWith('https://checkout.test/cs_1', {
label: 'Checkout page',
open: true,
});
expect(logLogSpy).not.toHaveBeenCalled();
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
'build cli free tier limit checkout prompt',
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'yes', checkout_created: true }
);
});
it('prints the billing link when the checkout session request fails', async () => {
jest.mocked(confirmAsync).mockResolvedValue(true);
createCheckoutSessionAsync.mockRejectedValue(new Error('network down'));
const experiment = treatmentExperiment();
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
expect(spinner.fail).toHaveBeenCalledWith('Failed to create a checkout session');
expect(openOrPrintUrlAsync).not.toHaveBeenCalled();
expect(logLogSpy).toHaveBeenCalledWith(
expect.stringContaining(`/accounts/${ACCOUNT_NAME}/settings/billing`)
);
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
'build cli free tier limit checkout prompt',
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'yes', checkout_created: false }
);
});
it('prints the billing link when the checkout session has no URL', async () => {
jest.mocked(confirmAsync).mockResolvedValue(true);
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs_1', url: null });
const experiment = treatmentExperiment();
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
expect(spinner.fail).toHaveBeenCalledWith('Failed to create a checkout session');
expect(openOrPrintUrlAsync).not.toHaveBeenCalled();
expect(logLogSpy).toHaveBeenCalledWith(
expect.stringContaining(`/accounts/${ACCOUNT_NAME}/settings/billing`)
);
});
});
@@ -24,27 +24,16 @@ import {
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from '../errors';
import {
FreeTierLimitCheckoutExperiment,
maybeOfferFreeTierLimitCheckoutAsync,
} from '../freeTierLimitCheckout';
import {
FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
FreeTierLimitAgentGuidanceExperiment,
} from '../freeTierLimitGuidance';
jest.mock('../../analytics/agent');
jest.mock('../freeTierLimitCheckout', () => ({
...jest.requireActual('../freeTierLimitCheckout'),
maybeOfferFreeTierLimitCheckoutAsync: jest.fn(),
}));
const CHECKOUT_EXPERIMENT = { accountId: 'account-id' } as FreeTierLimitCheckoutExperiment;
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(false);
});
/** An experiment handle whose client is forced to the given variant through an override. */
@@ -105,15 +94,11 @@ const getGraphQLError = (message: string, errorCode: string): GraphQLError => {
const assertReThrownError = (
caughtError: CombinedError | Error,
expectedType: new (...args: any[]) => Error,
expectedMessage: string = 'Error 1',
{ messageAlreadyPrinted = false }: { messageAlreadyPrinted?: boolean } = {}
expectedType: typeof EasCommandError,
expectedMessage: string = 'Error 1'
): void => {
expect(caughtError).toBeInstanceOf(expectedType);
expect((caughtError as Error).message).toEqual(expectedMessage);
expect((caughtError as EasCommandError).messageAlreadyPrinted ?? false).toBe(
messageAlreadyPrinted
);
};
describe(Build.name, () => {
@@ -464,153 +449,6 @@ describe(Build.name, () => {
);
});
});
describe('for free tier limit errors in the checkout experiment', () => {
it('offers the checkout with the limit message and marks the message as printed', async () => {
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(true);
const platform = Platform.ANDROID;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const expectedMessage =
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.';
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
async () => {
await handleBuildRequestErrorAsync(
error,
platform,
accountName,
undefined,
CHECKOUT_EXPERIMENT
);
}
);
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
expectedMessage,
CHECKOUT_EXPERIMENT
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierLimitExceededError,
expectedMessage,
{ messageAlreadyPrinted: true }
);
});
it('offers the checkout for the iOS limit error too', async () => {
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(true);
const platform = Platform.IOS;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const expectedMessage =
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.';
const handleBuildRequestErrorThrownError =
await getErrorAsync<EasBuildFreeTierIosLimitExceededError>(async () => {
await handleBuildRequestErrorAsync(
error,
platform,
accountName,
undefined,
CHECKOUT_EXPERIMENT
);
});
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
expectedMessage,
CHECKOUT_EXPERIMENT
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierIosLimitExceededError,
expectedMessage,
{ messageAlreadyPrinted: true }
);
});
it('leaves the message for the command to print when nothing was offered', async () => {
const platform = Platform.ANDROID;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
async () => {
await handleBuildRequestErrorAsync(
error,
platform,
accountName,
undefined,
CHECKOUT_EXPERIMENT
);
}
);
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledTimes(1);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierLimitExceededError,
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
{ messageAlreadyPrinted: false }
);
});
it('passes the agent guidance along when both experiments are present', async () => {
jest
.mocked(getAgentTelemetryContext)
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
const platform = Platform.ANDROID;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
await getErrorAsync(async () => {
await handleBuildRequestErrorAsync(
error,
platform,
accountName,
createExperiment('treatment'),
CHECKOUT_EXPERIMENT
);
});
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
[
'Error 1',
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
'Fix: Human required: Android builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
].join('\n'),
CHECKOUT_EXPERIMENT
);
});
it('does not offer the checkout for other server-side errors', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierDisabledError>(
async () => {
await handleBuildRequestErrorAsync(
error,
platform,
undefined,
undefined,
CHECKOUT_EXPERIMENT
);
}
);
expect(maybeOfferFreeTierLimitCheckoutAsync).not.toHaveBeenCalled();
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierDisabledError,
'Error 1'
);
});
});
describe('for EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
+17 -24
View File
@@ -28,11 +28,6 @@ import {
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from './errors';
import {
FreeTierLimitCheckoutExperiment,
getFreeTierLimitCheckoutExperiment,
maybeOfferFreeTierLimitCheckoutAsync,
} from './freeTierLimitCheckout';
import {
FreeTierLimitAgentGuidanceExperiment,
getFreeTierLimitAgentGuidanceExperiment,
@@ -56,9 +51,9 @@ import {
maybeWarnAboutProjectTarballSize,
reviewAndCommitChangesAsync,
} from './utils/repository';
import { getAccountBillingUrl } from './utils/url';
import { BuildEvent } from '../analytics/AnalyticsManager';
import { withAnalyticsAsync } from '../analytics/common';
import { getExpoWebsiteBaseUrl } from '../api';
import { formatStarterSubscribeCommand } from '../billing/plans';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
@@ -233,8 +228,7 @@ export async function prepareBuildRequestForPlatformAsync<
error,
job.platform,
ctx.accountName,
getFreeTierLimitAgentGuidanceExperiment(ctx),
getFreeTierLimitCheckoutExperiment(ctx)
getFreeTierLimitAgentGuidanceExperiment(ctx)
);
}
} else {
@@ -260,8 +254,7 @@ export async function handleBuildRequestErrorAsync(
error: any,
platform: Platform,
accountName?: string,
freeTierLimitAgentGuidanceExperiment?: FreeTierLimitAgentGuidanceExperiment,
freeTierLimitCheckoutExperiment?: FreeTierLimitCheckoutExperiment
freeTierLimitAgentGuidanceExperiment?: FreeTierLimitAgentGuidanceExperiment
): Promise<never> {
logBuildRequestErrorDebugInfo(error);
@@ -277,19 +270,15 @@ export async function handleBuildRequestErrorAsync(
const isFreeTierLimitError =
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED' ||
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED';
if (!isFreeTierLimitError) {
throw new ErrorClass(message);
}
const limitMessage = await maybeAppendFreeTierLimitAgentGuidanceAsync(
`${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`,
platform,
freeTierLimitAgentGuidanceExperiment
throw new ErrorClass(
isFreeTierLimitError
? await maybeAppendFreeTierLimitAgentGuidanceAsync(
`${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`,
platform,
freeTierLimitAgentGuidanceExperiment
)
: message
);
const messageAlreadyPrinted = await maybeOfferFreeTierLimitCheckoutAsync(
limitMessage,
freeTierLimitCheckoutExperiment
);
throw new ErrorClass(limitMessage, { messageAlreadyPrinted });
} else if (graphQLErrorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE') {
throw new EasBuildDownForMaintenanceError(
`EAS Build is down for maintenance. Try again later. Check ${link(
@@ -706,7 +695,7 @@ async function handleSingleBuildProgressAsync(
} else {
updateSpinnerText(spinner, {
text: `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
getAccountBillingUrl(accountName)
formatAccountBillingUrl(accountName)
)}.`,
});
}
@@ -729,7 +718,7 @@ async function handleSingleBuildProgressAsync(
if (build.priority !== BuildPriority.High) {
Log.newLine();
Log.log('Start builds sooner in the priority queue.');
Log.log(`Sign up for a paid plan at ${link(getAccountBillingUrl(accountName))}`);
Log.log(`Sign up for a paid plan at ${link(formatAccountBillingUrl(accountName))}`);
}
Log.newLine();
@@ -921,6 +910,10 @@ function formatEstimatedWaitTime(estimatedWaitTimeLeftSeconds: number): string {
}
}
function formatAccountBillingUrl(accountName: string): string {
return new URL(`/accounts/${accountName}/settings/billing`, getExpoWebsiteBaseUrl()).toString();
}
async function computeAndMaybeUploadRuntimeAndFingerprintMetadataAsync<T extends Platform>(
ctx: BuildContext<T>
): Promise<{
-2
View File
@@ -12,7 +12,6 @@ import { LazyExperimentationClient } from '../experimentation/LazyExperimentatio
import { Target } from '../credentials/ios/types';
import { BuildResourceClass } from '../graphql/generated';
import { GradleBuildContext } from '../project/android/gradle';
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
import { XcodeBuildContext } from '../project/ios/scheme';
import { Actor } from '../user/User';
@@ -69,5 +68,4 @@ export interface BuildContext<T extends Platform> {
whatToTest?: string;
env: Env;
experimentation?: LazyExperimentationClient;
authenticationInfo?: LoggedInAuthenticationInfo;
}
@@ -20,7 +20,6 @@ import { LazyExperimentationClient } from '../experimentation/LazyExperimentatio
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { resolveWorkflowAsync } from '../project/workflow';
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
import { Actor } from '../user/User';
import { Client } from '../vcs/vcs';
@@ -49,7 +48,6 @@ export async function createBuildContextAsync<T extends Platform>({
whatToTest,
env,
experimentation,
authenticationInfo,
}: {
buildProfileName: string;
buildProfile: BuildProfile<T>;
@@ -75,7 +73,6 @@ export async function createBuildContextAsync<T extends Platform>({
whatToTest?: string;
env: Env;
experimentation?: LazyExperimentationClient;
authenticationInfo?: LoggedInAuthenticationInfo;
}): Promise<BuildContext<T>> {
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
env,
@@ -166,7 +163,6 @@ export async function createBuildContextAsync<T extends Platform>({
env,
whatToTest,
experimentation,
authenticationInfo,
};
if (platform === Platform.ANDROID) {
const common = commonContext as CommonContext<Platform.ANDROID>;
@@ -1,155 +0,0 @@
import { getAccountBillingUrl } from './utils/url';
import { getAgentTelemetryContext } from '../analytics/agent';
import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/AnalyticsManager';
import { BillingClient } from '../billing/billingClient';
import { openOrPrintUrlAsync } from '../billing/openUrl';
import { SUBSCRIBABLE_PLANS } from '../billing/plans';
import { canManageBilling } from '../billing/resolveAccount';
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
import Log, { link } from '../log';
import { ora } from '../ora';
import { confirmAsync } from '../prompts';
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
import { Actor } from '../user/User';
export const FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE = 'cli-build-free-tier-limit-checkout';
export type FreeTierLimitCheckoutVariant = 'control' | 'treatment';
export type FreeTierLimitCheckoutPromptAnswer = 'yes' | 'no' | 'cancel';
export type FreeTierLimitCheckoutExperiment = {
experimentation: LazyExperimentationClient;
authenticationInfo: LoggedInAuthenticationInfo;
analytics: Analytics;
analyticsEventProperties: AnalyticsEventProperties;
actor: Actor;
accountId: string;
accountName: string;
nonInteractive: boolean;
runFromCI: boolean;
};
const STARTER_PLAN = SUBSCRIBABLE_PLANS.starter;
export function getFreeTierLimitCheckoutExperiment(ctx: {
experimentation?: LazyExperimentationClient;
authenticationInfo?: LoggedInAuthenticationInfo;
analytics: Analytics;
analyticsEventProperties: AnalyticsEventProperties;
user: Actor;
accountId: string;
accountName: string;
nonInteractive: boolean;
runFromCI: boolean;
}): FreeTierLimitCheckoutExperiment | undefined {
if (!ctx.experimentation || !ctx.authenticationInfo) {
return undefined;
}
return {
experimentation: ctx.experimentation,
authenticationInfo: ctx.authenticationInfo,
analytics: ctx.analytics,
analyticsEventProperties: ctx.analyticsEventProperties,
actor: ctx.user,
accountId: ctx.accountId,
accountName: ctx.accountName,
nonInteractive: ctx.nonInteractive,
runFromCI: ctx.runFromCI,
};
}
/**
* Only a person at an interactive terminal who can manage billing for the account is eligible.
* Non-interactive runs, CI, coding agents, and credentials that cannot manage billing are not.
*/
export function isEligibleForFreeTierLimitCheckout({
actor,
accountId,
nonInteractive,
runFromCI,
}: FreeTierLimitCheckoutExperiment): boolean {
if (nonInteractive || runFromCI || getAgentTelemetryContext()) {
return false;
}
const account = actor.accounts.find(candidate => candidate.id === accountId);
return account !== undefined && canManageBilling(account);
}
export async function resolveFreeTierLimitCheckoutVariantAsync(
experiment: FreeTierLimitCheckoutExperiment
): Promise<FreeTierLimitCheckoutVariant> {
if (!isEligibleForFreeTierLimitCheckout(experiment)) {
return 'control';
}
const client = await experiment.experimentation.getClientAsync();
return client
.getAccountNamespace(FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE, {
accountId: experiment.accountId,
})
.getParam<FreeTierLimitCheckoutVariant>('variant', 'control');
}
async function createCheckoutUrlAsync({
authenticationInfo,
accountId,
}: FreeTierLimitCheckoutExperiment): Promise<string | null> {
const spinner = ora(`Creating a checkout session for the ${STARTER_PLAN.label} plan`).start();
try {
const session = await new BillingClient(authenticationInfo).createCheckoutSessionAsync(
accountId,
STARTER_PLAN.planType
);
if (!session.url) {
throw new Error('The checkout session did not include a URL.');
}
spinner.stop();
return session.url;
} catch (error) {
spinner.fail('Failed to create a checkout session');
Log.debug(error);
return null;
}
}
/**
* In the treatment arm, prints the limit error and offers to open the Starter plan checkout.
*/
export async function maybeOfferFreeTierLimitCheckoutAsync(
message: string,
experiment: FreeTierLimitCheckoutExperiment | undefined
): Promise<boolean> {
if (!experiment || (await resolveFreeTierLimitCheckoutVariantAsync(experiment)) !== 'treatment') {
return false;
}
Log.error(message);
// Ctrl+C resolves to no answer instead of exiting, so the build error below still reports.
const confirmed: boolean | undefined = await confirmAsync(
{
message: `Open the ${STARTER_PLAN.label} plan checkout in your browser now?`,
initial: false,
},
{ onCancel: () => false }
);
const answer: FreeTierLimitCheckoutPromptAnswer =
confirmed === true ? 'yes' : confirmed === false ? 'no' : 'cancel';
let checkoutCreated = false;
if (answer === 'yes') {
const checkoutUrl = await createCheckoutUrlAsync(experiment);
if (checkoutUrl) {
checkoutCreated = true;
await openOrPrintUrlAsync(checkoutUrl, { label: 'Checkout page', open: true });
} else {
Log.log(`Upgrade your plan at ${link(getAccountBillingUrl(experiment.accountName))}`);
}
}
experiment.analytics.logEvent(BuildEvent.FREE_TIER_LIMIT_CHECKOUT_PROMPT, {
...experiment.analyticsEventProperties,
answer,
checkout_created: checkoutCreated,
});
return true;
}
@@ -31,7 +31,6 @@ import { createAndLinkChannelAsync, doesChannelExistAsync } from '../channel/que
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
import {
AppPlatform,
BuildFragment,
@@ -121,7 +120,6 @@ export async function runBuildAndSubmitAsync({
downloadSimBuildAutoConfirm,
envOverride,
experimentation,
authenticationInfo,
}: {
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
@@ -132,10 +130,8 @@ export async function runBuildAndSubmitAsync({
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
downloadSimBuildAutoConfirm?: boolean;
envOverride?: Env;
/** Enables the Free plan limit experiments on the build request error path. */
/** Enables the Free plan limit guidance experiment for builds driven by a coding agent. */
experimentation?: LazyExperimentationClient;
/** Lets the Free plan limit checkout experiment create a checkout session. */
authenticationInfo?: LoggedInAuthenticationInfo;
}): Promise<{
buildIds: string[];
buildProfiles?: ProfileData<'build'>[];
@@ -249,7 +245,6 @@ export async function runBuildAndSubmitAsync({
env,
easJsonAccessor,
experimentation,
authenticationInfo,
});
if (maybeBuild) {
startedBuilds.push({ build: maybeBuild, buildProfile });
@@ -389,7 +384,6 @@ async function prepareAndStartBuildAsync({
env,
easJsonAccessor,
experimentation,
authenticationInfo,
}: {
projectDir: string;
flags: BuildFlags;
@@ -405,7 +399,6 @@ async function prepareAndStartBuildAsync({
easJsonAccessor: EasJsonAccessor;
env: Env;
experimentation?: LazyExperimentationClient;
authenticationInfo?: LoggedInAuthenticationInfo;
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
const buildCtx = await createBuildContextAsync({
buildProfileName: buildProfile.profileName,
@@ -432,7 +425,6 @@ async function prepareAndStartBuildAsync({
whatToTest: flags.whatToTest,
env,
experimentation,
authenticationInfo,
});
if (!lockfileChecked && !flags.localBuildOptions.localBuildMode) {
-4
View File
@@ -18,10 +18,6 @@ export function getProjectPageUrl(
return new URL(page ? `${projectPath}/${page}` : projectPath, getExpoWebsiteBaseUrl()).toString();
}
export function getAccountBillingUrl(accountName: string): string {
return new URL(`/accounts/${accountName}/settings/billing`, getExpoWebsiteBaseUrl()).toString();
}
export function getBuildLogsUrl(build: BuildFragment, hash?: string): string {
const { app } = build;
const url = `/accounts/${app.ownerAccount.name}/projects/${app.slug}/builds/${build.id}${
@@ -271,9 +271,7 @@ export default abstract class EasCommand extends Command {
const commandId = this.id ?? 'unknown';
let baseMessage = `${commandId} command failed.`;
if (err instanceof EasCommandError) {
if (!err.messageAlreadyPrinted) {
Log.error(err.message);
}
Log.error(err.message);
} else if (err instanceof CombinedError && err?.graphQLErrors) {
const cleanGQLErrorsMessage = err?.graphQLErrors
.map((graphQLError: GraphQLError) => {
@@ -215,30 +215,6 @@ describe('EasCommand', () => {
expect(logDebugSpy).toBeCalledWith(error);
});
it('skips the message when the command already printed it', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const { EasCommandError } = require('../errors');
const Sentry = jest.requireMock('../../sentry').default;
Sentry.withScope.mockImplementation((cb: (scope: any) => void) => {
cb({ setTag: jest.fn(), setExtra: jest.fn() });
});
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const error = new EasCommandError('Already printed', { messageAlreadyPrinted: true });
runAsyncMock.mockImplementation(() => {
throw error;
});
try {
await TestEasCommand.run([], mockConfig);
} catch {}
expect(logErrorSpy).not.toHaveBeenCalled();
expect(logDebugSpy).toBeCalledWith(error);
expect(Sentry.captureException).toHaveBeenCalledWith(error);
});
it('logs the cleaned message if needed', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
+3 -8
View File
@@ -1,12 +1,7 @@
export class EasCommandError extends Error {
/** True when the command already printed the message, so `EasCommand.catch` must not print it again. */
public readonly messageAlreadyPrinted: boolean;
constructor(
message: string,
{ messageAlreadyPrinted = false }: { messageAlreadyPrinted?: boolean } = {}
) {
// constructor is not useless, since the constructor for Error allows for optional `message`
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
constructor(message: string) {
super(message);
this.messageAlreadyPrinted = messageAlreadyPrinted;
}
}
+1 -2
View File
@@ -152,7 +152,7 @@ export default class Build extends EasCommand {
const flags = this.sanitizeFlags(rawFlags);
const {
loggedIn: { actor, graphqlClient, authenticationInfo },
loggedIn: { actor, graphqlClient },
getDynamicPrivateProjectConfigAsync,
projectDir,
analytics,
@@ -185,7 +185,6 @@ export default class Build extends EasCommand {
actor,
getDynamicPrivateProjectConfigAsync,
experimentation,
authenticationInfo,
});
this.maybeSuggestUsingEasBuildDev({
@@ -1,499 +0,0 @@
import { Config } from '@oclif/core';
import * as fs from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
DeviceRunSessionByIdQuery,
DeviceRunSessionStatus,
DeviceRunSessionType,
JobRunStatus,
} from '../../../graphql/generated';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import Log from '../../../log';
import { loadSimulatorEnvAsync } from '../../../simulator/env';
import {
HostActionCatalog,
HostActionResult,
connectHostActionsAsync,
} from '../../../simulator/hostActions';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorControl from '../control';
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../log');
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
loadSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../simulator/hostActions', () => ({
...jest.requireActual('../../../simulator/hostActions'),
connectHostActionsAsync: jest.fn(),
}));
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
fail: jest.fn(),
start: jest.fn(),
succeed: jest.fn(),
};
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
jest.mock('../../../utils/json');
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
const catalog: HostActionCatalog = require('../../../simulator/__tests__/fixtures/hostActionCatalog.json');
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockConnectHostActionsAsync = jest.mocked(connectHostActionsAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeDeviceRunSession(
overrides: Partial<DeviceRunSessionById> = {}
): DeviceRunSessionById & { publicPreview: boolean } {
return {
id: 'session-123',
name: null,
tags: [],
publicPreview: false,
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.WebPreviewOnly,
platform: AppPlatform.Ios,
createdAt: '2025-01-01T00:00:00.000Z',
startedAt: '2025-01-01T00:00:05.000Z',
finishedAt: null,
updatedAt: '2025-01-01T00:01:00.000Z',
app: {
id: 'app-123',
slug: 'testapp',
ownerAccount: { id: 'account-123', name: 'testuser' },
},
artifacts: [],
remoteConfig: {
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
previewUrl: 'https://expo.dev/preview',
previewToken: 'preview-token',
previewApiUrl: 'https://sim.example.test',
},
turtleJobRun: { id: 'job-123', status: JobRunStatus.InProgress },
...overrides,
};
}
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
describe(SimulatorControl, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectDir = '/test/project';
const ok = (stdout: string): HostActionResult => ({ stdout, stderr: '', exitCode: 0 });
let actionResult: HostActionResult;
let served: HostActionCatalog;
let tmpDir: string;
const requestAsync = jest.fn(
async (action: string, params: Record<string, unknown> = {}): Promise<HostActionResult> => {
switch (action) {
case 'actions.describe':
return ok(JSON.stringify(served));
case 'upload.append':
return ok(`/tmp/serve-sim-uploads/${params.uploadId}\n`);
case 'upload.remove':
return ok('');
case 'file.readBase64':
return ok(Buffer.from('png bytes').toString('base64'));
default:
return actionResult;
}
}
);
const close = jest.fn();
beforeEach(async () => {
jest.clearAllMocks();
jest.mocked(loadSimulatorEnvAsync).mockResolvedValue();
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
mockConnectHostActionsAsync.mockResolvedValue({ requestAsync, close });
actionResult = ok('off\n');
served = catalog;
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'simulator-control-'));
});
afterEach(async () => {
await fs.remove(tmpDir);
});
function createCommand(argv: string[]): SimulatorControl {
const command = new SimulatorControl(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
projectDir,
});
return command;
}
function actionRequests(): Parameters<typeof requestAsync>[] {
return requestAsync.mock.calls.filter(([action]) => action !== 'actions.describe');
}
it.each([['--id='], ['--id', '']])('refuses an empty session ID in %j', async (...flags) => {
const env = jest.replaceProperty(process, 'env', {
...process.env,
EAS_SIMULATOR_SESSION_ID: 'env-session',
});
try {
await expect(
createCommand(['hardwareKeyboard.set', 'off', ...flags]).runAsync()
).rejects.toThrow('--id needs a simulator session ID.');
expect(loadSimulatorEnvAsync).not.toHaveBeenCalled();
expect(mockByIdAsync).not.toHaveBeenCalled();
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
} finally {
env.restore();
}
});
it('uses the environment session when --id is absent', async () => {
const env = jest.replaceProperty(process, 'env', {
...process.env,
EAS_SIMULATOR_SESSION_ID: 'env-session',
});
try {
await createCommand(['hardwareKeyboard.set', 'off']).runAsync();
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'env-session');
} finally {
env.restore();
}
});
it('runs an action from the catalog with its values and prints the output', async () => {
await createCommand(['hardwareKeyboard.set', 'off', '--id', 'session-123']).runAsync();
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
expect(mockConnectHostActionsAsync).toHaveBeenCalledWith({
apiUrl: 'https://sim.example.test',
token: 'preview-token',
});
expect(actionRequests()).toEqual([['hardwareKeyboard.set', { value: 'off' }]]);
expect(jest.mocked(Log.log)).toHaveBeenCalledWith('off');
expect(close).toHaveBeenCalled();
});
it.each([false, true])('lists the actions of the session with --list=%s', async list => {
await createCommand([...(list ? ['--list'] : []), '--id', 'session-123']).runAsync();
const lines = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
expect(lines.pop()).toBe('For action usage: eas simulator:control <action> --help');
expect(lines.flatMap(line => line.trim().split(/\s+/)).sort()).toEqual(
Object.keys(catalog).sort()
);
expect(actionRequests()).toEqual([]);
});
it.each(['--help', '-h'])('shows action help with %s without running the action', async help => {
await createCommand(['permissions.set', help, '--id', 'session-123']).runAsync();
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
' Grant, revoke or reset one permission for an app.'
);
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
' eas simulator:control permissions.set <action> <service> <bundleId>'
);
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalledTimes(1);
});
it('shows local file guidance without validating output or uploading files', async () => {
await createCommand([
'screenshot.capture',
'--help',
'--output',
'/missing/folder/screen.png',
'--id',
'session-123',
]).runAsync();
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
' eas simulator:control screenshot.capture [fileName]'
);
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
' --output <path> Local filename or existing directory.'
);
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalledTimes(1);
});
it('prints the host action schema as JSON for help', async () => {
await createCommand(['app.install', '--help', '--json', '--id', 'session-123']).runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
action: 'app.install',
...catalog['app.install'],
});
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalledTimes(1);
});
it('rejects unknown actions in help and closes the connection', async () => {
await expect(
createCommand(['unknown.action', '--help', '--id', 'session-123']).runAsync()
).rejects.toThrow('Unknown simulator action "unknown.action"');
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalledTimes(1);
});
it('prints parsed JSON output', async () => {
actionResult = ok('{"voiceover":"off"}\n');
await createCommand(['permissions.list', '--id', 'session-123', '--json']).runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
id: 'session-123',
action: 'permissions.list',
output: { voiceover: 'off' },
file: null,
});
});
it('uploads a local file for an action that takes one and removes it after', async () => {
const localFile = path.join(tmpDir, 'MyApp.ipa');
await fs.writeFile(localFile, 'app bytes');
actionResult = ok('');
await createCommand(['app.install', localFile, '--id', 'session-123']).runAsync();
const requests = actionRequests();
const uploadId = requests[0][1]?.uploadId;
expect(requests).toEqual([
[
'upload.append',
{ uploadId, data: Buffer.from('app bytes').toString('base64'), first: true },
],
['app.install', { path: `/tmp/serve-sim-uploads/${uploadId}` }],
['upload.remove', { uploadId }],
]);
});
it('sends the upload ID to an action that asks for it', async () => {
const localFile = path.join(tmpDir, 'session.json');
await fs.writeFile(localFile, '{}');
actionResult = ok('');
served = {
...catalog,
'app.file.upload': {
type: 'object',
properties: {
udid: { type: 'string' },
bundleId: { type: 'string' },
relativePath: { type: 'string' },
uploadId: { type: 'string' },
},
upload: 'uploadId',
uploadAs: 'id',
},
};
await createCommand([
'app.file.upload',
'com.example.app',
'Documents/session.json',
localFile,
'--id',
'session-123',
]).runAsync();
const requests = actionRequests();
const uploadId = requests[0][1]?.uploadId;
expect(requests).toEqual([
['upload.append', { uploadId, data: Buffer.from('{}').toString('base64'), first: true }],
[
'app.file.upload',
{ bundleId: 'com.example.app', relativePath: 'Documents/session.json', uploadId },
],
['upload.remove', { uploadId }],
]);
});
it('keeps an upload that the action goes on reading', async () => {
const localFile = path.join(tmpDir, 'feed.mp4');
await fs.writeFile(localFile, 'video bytes');
actionResult = ok('');
await createCommand(['camera.switch', 'file', localFile, '--id', 'session-123']).runAsync();
const requests = actionRequests();
expect(requests.map(([action]) => action)).toEqual(['upload.append', 'camera.switch']);
expect(requests[1][1]).toEqual({
source: 'file',
target: `/tmp/serve-sim-uploads/${requests[0][1]?.uploadId}`,
});
});
it('removes a kept upload when the action fails', async () => {
const localFile = path.join(tmpDir, 'feed.mp4');
await fs.writeFile(localFile, 'video bytes');
actionResult = { stdout: '', stderr: 'No such app.\n', exitCode: 1 };
await expect(
createCommand(['camera.switch', 'file', localFile, '--id', 'session-123']).runAsync()
).rejects.toThrow('The simulator could not run camera.switch: No such app.');
expect(actionRequests().map(([action]) => action)).toEqual([
'upload.append',
'camera.switch',
'upload.remove',
]);
});
it('passes a webcam name through without looking for a local file', async () => {
actionResult = ok('');
await createCommand([
'camera.switch',
'webcam',
'FaceTime HD Camera',
'--id',
'session-123',
]).runAsync();
expect(actionRequests()).toEqual([
['camera.switch', { source: 'webcam', target: 'FaceTime HD Camera' }],
]);
});
it('refuses a relative file that does not exist on this computer', async () => {
await expect(
createCommand(['app.install', './MyAp.ipa', '--id', 'session-123']).runAsync()
).rejects.toThrow('./MyAp.ipa does not exist on this computer.');
expect(actionRequests()).toEqual([]);
});
it('refuses --output for an action that returns no file before it runs', async () => {
await expect(
createCommand([
'hardwareKeyboard.set',
'off',
'--output',
'./out',
'--id',
'session-123',
]).runAsync()
).rejects.toThrow('hardwareKeyboard.set does not return a file, so --output does not apply.');
expect(actionRequests()).toEqual([]);
});
it('passes a path that is not a local file through unchanged', async () => {
actionResult = ok('');
await createCommand([
'app.install',
'/Users/runner/MyApp.ipa',
'--id',
'session-123',
]).runAsync();
expect(actionRequests()).toEqual([['app.install', { path: '/Users/runner/MyApp.ipa' }]]);
});
it('downloads the file an action returns', async () => {
const outputPath = path.join(tmpDir, 'shot.png');
actionResult = ok('/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n');
await createCommand([
'screenshot.capture',
'--output',
outputPath,
'--id',
'session-123',
]).runAsync();
expect(await fs.readFile(outputPath, 'utf8')).toBe('png bytes');
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(`Saved ${outputPath}`);
});
it('refuses an action that the session does not list', async () => {
await expect(createCommand(['server.kill', '--id', 'session-123']).runAsync()).rejects.toThrow(
'Unknown simulator action "server.kill". Run `eas simulator:control` to see the actions.'
);
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalled();
});
it('closes an established connection when catalog setup fails', async () => {
requestAsync.mockImplementationOnce(async () => ok('not a catalog'));
await expect(createCommand(['--list', '--id', 'session-123']).runAsync()).rejects.toThrow(
'did not return its list of actions'
);
expect(close).toHaveBeenCalledTimes(1);
});
it('lists the actions as JSON when no action is provided', async () => {
await createCommand(['--id', 'session-123', '--json']).runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(catalog);
expect(actionRequests()).toEqual([]);
});
it('refuses a session that is not running', async () => {
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped })
);
await expect(
createCommand(['memoryWarning', '--id', 'session-123']).runAsync()
).rejects.toThrow('Simulator session session-123 is stopped');
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
});
it('refuses an Android session', async () => {
mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ platform: AppPlatform.Android }));
await expect(
createCommand(['memoryWarning', '--id', 'session-123']).runAsync()
).rejects.toThrow('available only for iOS sessions');
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
});
it('refuses a session without a preview server API', async () => {
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({
remoteConfig: {
__typename: 'ServeSimRunSessionRemoteConfig',
previewUrl: 'https://sim.example.test',
previewToken: 'preview-token',
},
})
);
await expect(
createCommand(['memoryWarning', '--id', 'session-123']).runAsync()
).rejects.toThrow('does not expose a preview server');
});
it('reports the simulator error when the action fails', async () => {
actionResult = {
stdout: '',
stderr: 'Simulator could not change the hinge angle.\n',
exitCode: 1,
};
await expect(
createCommand(['hinge.set', 'half', '--id', 'session-123']).runAsync()
).rejects.toThrow(
'The simulator could not run hinge.set: Simulator could not change the hinge angle.'
);
});
});
@@ -40,7 +40,6 @@ jest.mock('../../../utils/json');
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockJobRunErrorsByIdAsync = jest.mocked(DeviceRunSessionQuery.jobRunErrorsByIdAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockLoadSimulatorEnvironmentVariablesAsync = jest.mocked(loadSimulatorEnvAsync);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
@@ -109,7 +108,6 @@ describe(SimulatorGet, () => {
beforeEach(() => {
jest.clearAllMocks();
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
mockJobRunErrorsByIdAsync.mockResolvedValue(null);
});
function createCommand(argv: string[]): {
@@ -164,79 +162,6 @@ describe(SimulatorGet, () => {
previewApiUrl: null,
},
artifacts: session.artifacts,
errors: [],
});
expect(mockJobRunErrorsByIdAsync).not.toHaveBeenCalled();
});
describe('errored sessions', () => {
const jobRunErrors = {
id: 'job-123',
errors: [
{
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
message: 'No available iOS Simulator is named "iPhone 16 Pro".',
docsUrl: null,
},
],
logFileUrls: [],
};
it('includes the job run errors in JSON output', async () => {
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null })
);
mockJobRunErrorsByIdAsync.mockResolvedValue(jobRunErrors);
const { command } = createCommand(['--id', 'session-123', '--json']);
await command.runAsync();
expect(mockJobRunErrorsByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123', {
signal: expect.any(AbortSignal),
});
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
errors: [
{
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
message: 'No available iOS Simulator is named "iPhone 16 Pro".',
docsUrl: null,
},
],
})
);
});
it('prints the job run errors when only the job run errored', async () => {
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({
status: DeviceRunSessionStatus.Stopped,
remoteConfig: null,
turtleJobRun: { id: 'job-123', status: JobRunStatus.Errored },
})
);
mockJobRunErrorsByIdAsync.mockResolvedValue(jobRunErrors);
const { command } = createCommand(['--id', 'session-123']);
await command.runAsync();
expect(Log.gray).toHaveBeenCalledWith('Session errors:');
expect(Log.error).toHaveBeenCalledWith(
' No available iOS Simulator is named "iPhone 16 Pro".'
);
});
it('prints no errors section when the job run errors cannot be fetched', async () => {
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null })
);
mockJobRunErrorsByIdAsync.mockRejectedValue(new Error('graphql boom'));
const { command } = createCommand(['--id', 'session-123']);
await command.runAsync();
expect(Log.gray).not.toHaveBeenCalledWith('Session errors:');
expect(Log.error).not.toHaveBeenCalled();
});
});
@@ -6,7 +6,6 @@ import { GraphQLError } from 'graphql';
import { SimulatorEvent, isAnalyticsOptedOutAsync } from '../../../analytics/AnalyticsManager';
import { getAgentTelemetryContext } from '../../../analytics/agent';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import fetch from '../../../fetch';
import {
AppPlatform,
CreateDeviceRunSessionMutation,
@@ -45,7 +44,6 @@ jest.mock('../../../analytics/AnalyticsManager', () => ({
jest.mock('../../../analytics/agent');
jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
jest.mock('../../../graphql/queries/DeviceRunSessionAvailabilityQuery');
jest.mock('../../../fetch');
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../log', () => ({
__esModule: true,
@@ -59,7 +57,6 @@ jest.mock('../../../log', () => ({
withTick: jest.fn(),
},
link: jest.fn((url: string) => url),
learnMore: jest.fn((url: string) => `Learn more: ${url}`),
}));
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
@@ -108,8 +105,6 @@ const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked(
);
const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQuery.byAppIdAsync);
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockJobRunErrorsByIdAsync = jest.mocked(DeviceRunSessionQuery.jobRunErrorsByIdAsync);
const mockFetch = jest.mocked(fetch);
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
const mockLogEvent = jest.fn();
const mockFlushAsync = jest.fn(async () => {});
@@ -196,7 +191,6 @@ describe(Simulator, () => {
jest.restoreAllMocks();
jest.clearAllMocks();
delete process.env[EAS_SIMULATOR_SESSION_ID];
mockJobRunErrorsByIdAsync.mockResolvedValue(null);
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
mockCreateDeviceRunSessionAsync.mockResolvedValue(makeCreatedDeviceRunSession());
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue({
@@ -1130,76 +1124,6 @@ describe(Simulator, () => {
);
});
it('forwards --system-image in the Android create options', async () => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
'--system-image',
' system-images;android-35-ext15;google_apis_playstore;x86_64 ',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
android: {
systemImagePackage: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
},
})
);
});
it('forwards --device and --system-image together in the Android create options', async () => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
'--device',
'pixel_9',
'--system-image',
'system-images;android-35-ext15;google_apis_playstore;x86_64',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
android: {
deviceIdentifier: 'pixel_9',
systemImagePackage: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
},
})
);
});
it('omits a blank --system-image', async () => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
'--system-image',
' ',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1]).not.toHaveProperty('android');
});
it('rejects --system-image for iOS before creating a session', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--system-image',
'system-images;android-35-ext15;google_apis_playstore;x86_64',
]);
await expect(command.runAsync()).rejects.toThrow(
'--system-image is only supported with --platform android.'
);
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('trims --device before sending it', async () => {
const { command } = createCommand([
'--platform',
@@ -1996,292 +1920,6 @@ describe(Simulator, () => {
);
});
describe('job run errors', () => {
const erroredSessionMessage =
'Simulator session session-123 errored before the agent-device session was ready. https://expo.dev/accounts/testuser/projects/testapp/simulator-sessions/session-123';
const logFileUrl = 'https://logs.example.com/job-123';
const unknownError = {
errorCode: 'UNKNOWN_ERROR',
message: 'Unknown error. See logs for more information.',
docsUrl: null,
};
// Shaped like the worker's bunyan output: the failing call logs at error level with `err`,
// then the step runner logs an end-step marker with result "fail".
function failedStepLogLines(
stepId: string,
displayName: string,
messages: string[]
): Record<string, unknown>[] {
return [
{
level: 30,
buildStepId: stepId,
buildStepDisplayName: displayName,
marker: 'start-step',
msg: `Executing build step "${displayName}"`,
},
...messages.map(msg => ({
level: 50,
buildStepId: stepId,
buildStepDisplayName: displayName,
msg,
err: { message: msg },
})),
{
level: 50,
buildStepId: stepId,
buildStepDisplayName: displayName,
marker: 'end-step',
result: 'fail',
msg: `Build step "${displayName}" failed`,
},
];
}
function mockLogFile(logLines: Record<string, unknown>[]): void {
mockFetch.mockResolvedValue({
text: async () => logLines.map(line => JSON.stringify(line)).join('\n'),
} as unknown as Awaited<ReturnType<typeof fetch>>);
}
beforeEach(() => {
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null })
);
});
it('keeps the message unchanged without reading logs when the job run has no errors', async () => {
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [],
logFileUrls: [logFileUrl],
});
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
expect.objectContaining({ message: erroredSessionMessage })
);
expect(mockFetch).not.toHaveBeenCalled();
});
it('lists each job run error on its own line with its docs link', async () => {
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [
{
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
message: 'No available iOS Simulator is named "iPhone 16 Pro".',
docsUrl: null,
},
{
errorCode: 'EAS_SIMULATOR_BUILD_NOT_INSTALLABLE',
message: 'The build cannot be installed on a simulator.',
docsUrl: 'https://docs.expo.dev/simulator',
},
],
logFileUrls: [logFileUrl],
});
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
expect.objectContaining({
message: `${erroredSessionMessage}\nNo available iOS Simulator is named "iPhone 16 Pro".\nThe build cannot be installed on a simulator. Learn more: https://docs.expo.dev/simulator`,
})
);
expect(mockFetch).not.toHaveBeenCalled();
});
it('reads the failed step error from the job run logs for an unknown error', async () => {
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [unknownError],
logFileUrls: [logFileUrl],
});
mockLogFile([
{
level: 50,
buildStepId: 'step-001',
buildStepDisplayName: 'Install application',
msg: 'Retrying install',
err: { message: 'Retrying install' },
},
{
level: 30,
buildStepId: 'step-001',
marker: 'end-step',
result: 'success',
msg: 'Finished',
},
...failedStepLogLines('step-002', 'Launch application', [
'adb shell am start exited with non-zero code: 1',
]),
{
level: 20,
buildStepId: 'step-002',
msg: 'Getting value for output parameter "x" failed.',
err: { message: 'not set' },
},
{
level: 40,
msg: 'Could not stop the emulator during remote session teardown.',
err: { message: 'teardown' },
},
]);
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
expect.objectContaining({
message: `${erroredSessionMessage}\nLaunch application: adb shell am start exited with non-zero code: 1`,
})
);
expect(mockFetch).toHaveBeenCalledWith(
logFileUrl,
expect.objectContaining({ size: 20 * 1024 * 1024, signal: expect.any(AbortSignal) })
);
});
it('prints the masked log message instead of the unmasked err.message', async () => {
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [unknownError],
logFileUrls: [logFileUrl],
});
mockLogFile([
{
level: 50,
buildStepId: 'step-001',
buildStepDisplayName: 'Run script',
msg: 'curl -H "Authorization: ********" exited with non-zero code: 22',
err: { message: 'curl -H "Authorization: secret-token" exited with non-zero code: 22' },
},
{
level: 50,
buildStepId: 'step-001',
marker: 'end-step',
result: 'fail',
msg: 'Build step "Run script" failed',
},
]);
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
expect.objectContaining({
message: `${erroredSessionMessage}\nRun script: curl -H "Authorization: ********" exited with non-zero code: 22`,
})
);
});
it('shows the first failed step, then only the error line of later failed steps', async () => {
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [unknownError],
logFileUrls: [logFileUrl],
});
mockLogFile([
...failedStepLogLines('step-001', 'Boot', ['A', 'B']),
...failedStepLogLines('step-002', 'Finish recordings', ['C', 'D']),
]);
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
expect.objectContaining({
message: `${erroredSessionMessage}\nBoot: B\nFinish recordings: D`,
})
);
});
it('truncates very long log errors', async () => {
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [unknownError],
logFileUrls: [logFileUrl],
});
mockLogFile(failedStepLogLines('step-001', 'Boot', ['x'.repeat(2000)]));
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
expect.objectContaining({
message: `${erroredSessionMessage}\n${`Boot: ${'x'.repeat(2000)}`.slice(0, 500)}…`,
})
);
});
it('keeps the job run error when no step failed, ignoring errors that steps ignored', async () => {
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [
{
errorCode: 'UNKNOWN_ERROR',
message:
'Unknown error. See logs of the Complete job build phase for more information.',
docsUrl: null,
},
],
logFileUrls: [logFileUrl],
});
mockLogFile([
{
level: 50,
buildStepId: 'step-001',
buildStepDisplayName: 'Upload artifact',
msg: 'Failed to upload screen-recording. Ignoring error.',
err: { message: 'upload failed' },
},
{
level: 30,
buildStepId: 'step-001',
marker: 'end-step',
result: 'success',
msg: 'Finished',
},
{ level: 50, phase: 'COMPLETE_JOB', msg: '', err: { message: 'upload outputs failed' } },
]);
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
expect.objectContaining({
message: `${erroredSessionMessage}\nUnknown error. See logs of the Complete job build phase for more information.`,
})
);
});
it('falls back to the unknown error message when the job run logs cannot be read', async () => {
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [unknownError],
logFileUrls: [logFileUrl],
});
mockFetch.mockRejectedValue(new Error('Request failed: 503 (Service Unavailable)'));
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
expect.objectContaining({
message: `${erroredSessionMessage}\nUnknown error. See logs for more information.`,
})
);
});
it('keeps the message unchanged when the job run errors cannot be fetched', async () => {
mockJobRunErrorsByIdAsync.mockRejectedValue(new Error('graphql boom'));
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
expect.objectContaining({ message: erroredSessionMessage })
);
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled();
});
});
it('keeps polling until the remote config appears', async () => {
mockByIdAsync
.mockResolvedValueOnce(makeDeviceRunSession({ remoteConfig: null }))
@@ -2581,31 +2219,6 @@ describe(Simulator, () => {
await expect(command.runAsync()).rejects.toThrow('Simulator session session-123 errored.');
});
it('includes the job run error when the session errors while it is running', async () => {
mockByIdAsync
.mockResolvedValueOnce(makeDeviceRunSession())
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored }));
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [
{
errorCode: 'EAS_SIMULATOR_SESSION_CRASHED',
message: 'The simulator stopped responding.',
docsUrl: null,
},
],
logFileUrls: [],
});
const { command } = createCommand(['--platform', 'ios']);
await expect(command.runAsync()).rejects.toThrow(
expect.objectContaining({
message: 'Simulator session session-123 errored.\nThe simulator stopped responding.',
})
);
});
it('keeps waiting while the session is still in progress', async () => {
mockByIdAsync
.mockResolvedValueOnce(makeDeviceRunSession())
@@ -0,0 +1,154 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
DeviceRunSessionByIdQuery,
DeviceRunSessionStatus,
DeviceRunSessionType,
} from '../../../graphql/generated';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env';
import {
getServeSimDeviceUdidAsync,
runServeSimHostActionAsync,
} from '../../../simulator/serveSimHost';
import SimulatorSimctl, { parseSimctlArgv } from '../simctl';
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../log');
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
loadSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../simulator/serveSimHost', () => ({
...jest.requireActual('../../../simulator/serveSimHost'),
getServeSimDeviceUdidAsync: jest.fn(),
runServeSimHostActionAsync: jest.fn(),
}));
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
const UDID = '5B3A2F1E-0C4D-4E8F-9A7B-1C2D3E4F5A6B';
const SPRINGBOARD_RESTART = [
'spawn',
'launchctl',
'kickstart',
'-k',
'system/com.apple.SpringBoard',
];
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockGetServeSimDeviceUdidAsync = jest.mocked(getServeSimDeviceUdidAsync);
const mockRunServeSimHostActionAsync = jest.mocked(runServeSimHostActionAsync);
function makeSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
return {
id: 'session-123',
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.Argent,
platform: AppPlatform.Ios,
remoteConfig: {
__typename: 'ArgentRunSessionRemoteConfig',
toolsUrl: 'https://argent.example.com',
previewApiUrl: 'https://preview.example.com',
webPreviewToken: 'preview-token',
},
...overrides,
} as DeviceRunSessionById;
}
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({ failures: [], successes: [] });
return config;
}
describe(parseSimctlArgv, () => {
it('reads --id before the simctl arguments', () => {
expect(parseSimctlArgv(['--id', 'session-1', 'ui', 'appearance', 'dark'])).toEqual({
id: 'session-1',
args: ['ui', 'appearance', 'dark'],
});
expect(parseSimctlArgv(['--id=session-1', ...SPRINGBOARD_RESTART])).toEqual({
id: 'session-1',
args: SPRINGBOARD_RESTART,
});
});
it('passes simctl flags through untouched', () => {
expect(parseSimctlArgv(SPRINGBOARD_RESTART)).toEqual({ args: SPRINGBOARD_RESTART });
expect(parseSimctlArgv(['--', '--id', 'x'])).toEqual({ args: ['--id', 'x'] });
});
});
describe(SimulatorSimctl, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
delete process.env[EAS_SIMULATOR_SESSION_ID];
jest.mocked(loadSimulatorEnvAsync).mockResolvedValue();
mockByIdAsync.mockResolvedValue(makeSession());
mockGetServeSimDeviceUdidAsync.mockResolvedValue(UDID);
mockRunServeSimHostActionAsync.mockResolvedValue({ stdout: '', stderr: '' });
});
function createCommand(argv: string[]): SimulatorSimctl {
const command = new SimulatorSimctl(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
projectDir: '/test/project',
});
return command;
}
it('restarts SpringBoard on the session simulator', async () => {
await createCommand(['--id', 'session-123', ...SPRINGBOARD_RESTART]).runAsync();
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
expect(mockRunServeSimHostActionAsync).toHaveBeenCalledWith(
{ apiUrl: 'https://preview.example.com', token: 'preview-token' },
'springboard.restart',
{ udid: UDID }
);
});
it('sets the appearance on the session from the env file', async () => {
process.env[EAS_SIMULATOR_SESSION_ID] = 'session-123';
await createCommand(['ui', 'appearance', 'dark']).runAsync();
expect(mockRunServeSimHostActionAsync).toHaveBeenCalledWith(
expect.anything(),
'appearance.set',
{ value: 'dark', udid: UDID }
);
});
it('rejects an unsupported command before fetching the session', async () => {
await expect(
createCommand(['--id', 'session-123', 'spawn', 'sh', '-c', 'id']).runAsync()
).rejects.toThrow('Unsupported simctl command: spawn sh -c id');
expect(mockByIdAsync).not.toHaveBeenCalled();
});
it('rejects an Android session', async () => {
mockByIdAsync.mockResolvedValue(makeSession({ platform: AppPlatform.Android }));
await expect(
createCommand(['--id', 'session-123', 'ui', 'appearance', 'dark']).runAsync()
).rejects.toThrow('only supports iOS');
expect(mockRunServeSimHostActionAsync).not.toHaveBeenCalled();
});
it('rejects a session that is not running', async () => {
mockByIdAsync.mockResolvedValue(makeSession({ status: DeviceRunSessionStatus.Stopped }));
await expect(
createCommand(['--id', 'session-123', 'ui', 'appearance', 'dark']).runAsync()
).rejects.toThrow('is stopped, not running');
});
});
@@ -1,322 +0,0 @@
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { AppPlatform, DeviceRunSessionStatus } from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log from '../../log';
import { ora } from '../../ora';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../simulator/env';
import {
HostActionCatalog,
HostActionCatalogEntry,
HostActionConnection,
assertHostActionOutputAsync,
buildHostActionParams,
connectHostActionsAsync,
fetchHostActionCatalogAsync,
formatHostActionUsage,
saveHostActionOutputAsync,
uploadFileAsync,
} from '../../simulator/hostActions';
import { PreviewApiAccess, getPreviewApiAccess } from '../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class SimulatorControl extends EasCommand {
static override aliases = ['sim:control'];
static override description =
'[EXPERIMENTAL] run a serve-sim action on a remote iOS simulator session on EAS. Run without an action to list the actions the session supports. To save a screenshot locally, run `eas simulator:control screenshot.capture --output ./screenshot.png`.';
static override strict = false;
static override examples = [
'$ eas simulator:control',
'$ eas simulator:control --id <session-id>',
'$ eas simulator:control screenshot.capture --help',
'$ eas simulator:control clipboard.write -- --some-text',
'$ eas simulator:control hardwareKeyboard.set off',
'$ eas simulator:control permissions.set grant camera com.example.app',
'$ eas simulator:control app.install ./MyApp.ipa',
'$ eas simulator:control screenshot.capture --output ./screenshot.png',
'$ eas simulator:control screenshot.capture --output ./screenshot.png --id <session-id>',
];
static override args = {
action: Args.string({
description:
'Action to run, followed by its values in order or as name=value. Omit to list the actions. Put -- before values that start with --.',
}),
};
static override flags = {
help: Flags.boolean({
char: 'h',
description: 'Show command help, or usage for the selected action from the session.',
}),
id: Flags.string({
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
}),
list: Flags.boolean({
description: 'List the actions the session supports.',
}),
output: Flags.string({
description:
'Local path to save a returned file. For screenshots, --output ./screenshot.png sets the local filename; omit it to save with a generated filename in the current directory.',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectDir,
};
async runAsync(): Promise<void> {
const { args, argv, flags } = await this.parse(SimulatorControl);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
if (flags.id === '') {
throw new Error('--id needs a simulator session ID.');
}
const action = flags.list && !flags.help ? undefined : args.action;
const {
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorControl, {
nonInteractive,
});
await loadSimulatorEnvAsync(projectDir);
const sessionId = flags.id ?? process.env[EAS_SIMULATOR_SESSION_ID];
if (!sessionId) {
throw new Error(
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
);
}
const spinner = ora(`Connecting to simulator session ${sessionId}`).start();
let connection: HostActionConnection | undefined;
try {
let catalog: HostActionCatalog;
try {
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, sessionId);
connection = await connectHostActionsAsync(assertControllableSession(session));
catalog = await fetchHostActionCatalogAsync(connection);
spinner.succeed(`Connected to simulator session ${sessionId}`);
} catch (err) {
spinner.fail(`Failed to connect to simulator session ${sessionId}`);
throw err;
}
if (!action) {
this.printCatalog(catalog, jsonFlag);
return;
}
const entry = Object.hasOwn(catalog, action) ? catalog[action] : undefined;
if (!entry) {
throw new Error(
`Unknown simulator action "${action}". Run \`eas simulator:control\` to see the actions.`
);
}
if (flags.help) {
this.printActionHelp(action, entry, jsonFlag);
return;
}
await this.runActionAsync(connection, entry, {
sessionId,
action,
values: argv.slice(1).map(String),
outputPath: flags.output,
jsonFlag,
});
} finally {
connection?.close();
}
}
private printCatalog(catalog: HostActionCatalog, jsonFlag: boolean): void {
if (jsonFlag) {
printJsonOnlyOutput(catalog);
return;
}
const names = Object.keys(catalog).sort();
const width = Math.max(0, ...names.map(name => name.length)) + 3;
const columns = Math.max(1, Math.min(3, Math.floor((process.stdout.columns || 80) / width)));
const rows = Math.ceil(names.length / columns);
for (let row = 0; row < rows; row++) {
const cells = Array.from({ length: columns }, (_, column) =>
(names[row + column * rows] ?? '').padEnd(width)
);
Log.log(cells.join('').trimEnd());
}
Log.newLine();
Log.log('For action usage: eas simulator:control <action> --help');
}
private printActionHelp(action: string, entry: HostActionCatalogEntry, jsonFlag: boolean): void {
if (jsonFlag) {
printJsonOnlyOutput({ ...entry, action });
return;
}
Log.newLine();
Log.log(` ${chalk.bold(action)}`);
if (entry.description) {
Log.log(` ${entry.description}`);
}
Log.newLine();
Log.log(` ${chalk.bold('USAGE')}`);
Log.log(` eas simulator:control ${formatHostActionUsage(action, entry)}`);
const outputFlag = entry.output === 'base64' ? '--output <path>' : '[--output <path>]';
Log.log(` ${entry.output ? `${outputFlag} ` : ''}[--id <session-id>] [--json]`);
Log.newLine();
Log.log(` ${chalk.bold('OPTIONS')}`);
Log.log(` --id <session-id> Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`);
Log.log(' --json Print the action schema as JSON. Implies --non-interactive.');
if (entry.output) {
Log.log(
entry.output === 'base64'
? ' --output <path> Required local filename for the returned file.'
: ' --output <path> Local filename or existing directory.'
);
if (entry.output === 'path') {
Log.log(' Omit to save with the generated filename.');
}
Log.newLine();
Log.log(
' Action arguments refer to the simulator host. Use --output for the local filename.'
);
}
Log.newLine();
}
private async runActionAsync(
connection: HostActionConnection,
entry: HostActionCatalogEntry,
{
sessionId,
action,
values,
outputPath,
jsonFlag,
}: {
sessionId: string;
action: string;
values: string[];
outputPath: string | undefined;
jsonFlag: boolean;
}
): Promise<void> {
const params = buildHostActionParams(action, entry, values);
await assertHostActionOutputAsync(action, entry, outputPath);
const localFile = await findLocalUploadAsync(entry, params);
const spinner = ora(`Running ${action} on simulator session ${sessionId}`).start();
let uploadId: string | undefined;
let succeeded = false;
let output: string;
let savedFile: string | null;
try {
if (localFile) {
spinner.text = `Uploading ${localFile.path}`;
const upload = await uploadFileAsync(connection, localFile.path);
uploadId = upload.uploadId;
params[localFile.param] = entry.uploadAs === 'id' ? upload.uploadId : upload.path;
spinner.text = `Running ${action} on simulator session ${sessionId}`;
}
const result = await connection.requestAsync(action, params);
if (result.exitCode !== 0) {
const detail = (result.stderr || result.stdout).trim() || `exit code ${result.exitCode}`;
throw new Error(`The simulator could not run ${action}: ${detail}`);
}
succeeded = true;
savedFile = await saveHostActionOutputAsync(connection, entry, result.stdout, outputPath);
output = savedFile ? '' : result.stdout.trim();
spinner.succeed(`Ran ${action} on simulator session ${sessionId}`);
} catch (err) {
spinner.fail(`Failed to run ${action} on simulator session ${sessionId}`);
throw err;
} finally {
if (uploadId && (!entry.keepUpload || !succeeded)) {
await connection.requestAsync('upload.remove', { uploadId }).catch(() => {});
}
}
if (jsonFlag) {
printJsonOnlyOutput({
id: sessionId,
action,
output: parseJsonOutput(output),
file: savedFile,
});
return;
}
if (savedFile) {
Log.log(`Saved ${savedFile}`);
} else if (output) {
Log.log(output);
}
}
}
async function findLocalUploadAsync(
{ upload: param, uploadWhen = {} }: HostActionCatalogEntry,
params: Record<string, unknown>
): Promise<{ param: string; path: string } | null> {
const value = param ? params[param] : undefined;
const applies = Object.entries(uploadWhen).every(([name, expected]) => params[name] === expected);
if (!param || typeof value !== 'string' || !applies) {
return null;
}
const stat = await fs.stat(value).catch(() => null);
if (stat?.isDirectory()) {
throw new Error(`${value} is a folder. Pass the path of a file.`);
}
if (!stat && !path.isAbsolute(value)) {
throw new Error(
`${value} does not exist on this computer. Pass the path of a local file, or an absolute path on the simulator host.`
);
}
return stat ? { param, path: value } : null;
}
function assertControllableSession(
session: Awaited<ReturnType<typeof DeviceRunSessionQuery.byIdAsync>>
): PreviewApiAccess {
if (session.status !== DeviceRunSessionStatus.InProgress) {
throw new Error(
`Simulator session ${session.id} is ${session.status.toLowerCase()}. Simulator actions need a running session. Start one with \`eas simulator:start\`.`
);
}
if (session.platform !== AppPlatform.Ios) {
throw new Error(
`Simulator session ${session.id} runs on ${session.platform.toLowerCase()}. Simulator actions are available only for iOS sessions.`
);
}
const access = session.remoteConfig ? getPreviewApiAccess(session.remoteConfig) : null;
if (!access) {
throw new Error(
`Simulator session ${session.id} does not expose a preview server with a token. Simulator actions use that server. Start a new session with \`eas simulator:start\`.`
);
}
return access;
}
function parseJsonOutput(output: string): unknown {
try {
return JSON.parse(output);
} catch {
return output;
}
}
+1 -24
View File
@@ -6,11 +6,7 @@ import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import {
DeviceRunSessionByIdQuery,
DeviceRunSessionStatus,
JobRunStatus,
} from '../../graphql/generated';
import { DeviceRunSessionByIdQuery, DeviceRunSessionStatus } from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
@@ -19,11 +15,6 @@ import {
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../simulator/env';
import {
SimulatorSessionError,
fetchSimulatorSessionErrorsAsync,
formatSimulatorSessionError,
} from '../../simulator/jobRunErrors';
import {
deviceRunSessionTypeToFlagValue,
formatRemoteSessionInstructions,
@@ -96,11 +87,6 @@ export default class SimulatorGet extends EasCommand {
session.app.slug,
session.id
);
const errors: SimulatorSessionError[] =
session.status === DeviceRunSessionStatus.Errored ||
session.turtleJobRun?.status === JobRunStatus.Errored
? await fetchSimulatorSessionErrorsAsync(graphqlClient, session.id)
: [];
if (jsonFlag) {
printJsonOnlyOutput({
@@ -123,7 +109,6 @@ export default class SimulatorGet extends EasCommand {
)
: session.remoteConfig,
artifacts: session.artifacts,
errors,
});
return;
}
@@ -131,14 +116,6 @@ export default class SimulatorGet extends EasCommand {
Log.newLine();
Log.log(formatSessionFields(session, deviceRunSessionUrl));
if (errors.length > 0) {
Log.newLine();
Log.gray('Session errors:');
for (const error of errors) {
Log.error(` ${formatSimulatorSessionError(error)}`);
}
}
if (session.status === DeviceRunSessionStatus.InProgress) {
Log.newLine();
if (session.remoteConfig) {
@@ -41,10 +41,6 @@ import {
writeSimulatorEnvAsync,
} from '../../simulator/env';
import { resolveExpoGoSdkVersionAsync } from '../../simulator/expoGo';
import {
fetchSimulatorSessionErrorsAsync,
formatSimulatorSessionError,
} from '../../simulator/jobRunErrors';
import {
simulatorRequestFailureReason,
simulatorRequestProperties,
@@ -104,10 +100,6 @@ export default class Simulator extends EasCommand {
description:
'Virtual device to start for the session. On iOS, a Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android, an AVD hardware profile id (e.g. "pixel_7"). Defaults to a device chosen by the runner.',
}),
'system-image': Flags.string({
description:
'Android SDK system image package to use for the emulator (e.g. "system-images;android-35-ext15;google_apis_playstore;x86_64"). Defaults to a system image chosen by the runner. Only supported with --platform android.',
}),
'build-id': Flags.string({
description: 'EAS Build to install and launch before the simulator session is ready.',
exclusive: ['build-fingerprint', 'application-archive-url', 'expo-go'],
@@ -256,7 +248,6 @@ export default class Simulator extends EasCommand {
const name = flags.name?.trim() || undefined;
const tags = flags.tag?.map(tag => tag.trim()).filter(tag => tag.length > 0);
const deviceIdentifier = flags.device?.trim() || undefined;
const systemImagePackage = flags['system-image']?.trim() || undefined;
const buildId = flags['build-id']?.trim() || undefined;
const buildFingerprint = flags['build-fingerprint']?.trim() || undefined;
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
@@ -300,9 +291,6 @@ export default class Simulator extends EasCommand {
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined;
if (systemImagePackage && platform !== AppPlatform.Android) {
throw new EasCommandError('--system-image is only supported with --platform android.');
}
let egressAllow: string[] = [];
try {
egressAllow = parseEgressAllowList(flags['egress-allow'] ?? []);
@@ -378,18 +366,11 @@ export default class Simulator extends EasCommand {
packageVersion: flags['package-version'],
networkCapture: flags['network-capture'],
...(networkCaptureFields.length ? { networkCaptureFields } : {}),
...(platform === AppPlatform.Ios
? deviceIdentifier
...(deviceIdentifier
? platform === AppPlatform.Ios
? { ios: { deviceIdentifier } }
: {}
: deviceIdentifier || systemImagePackage
? {
android: {
...(deviceIdentifier ? { deviceIdentifier } : {}),
...(systemImagePackage ? { systemImagePackage } : {}),
},
}
: {}),
: { android: { deviceIdentifier } }
: {}),
...(buildId ? { buildId } : {}),
...(buildFingerprint ? { buildFingerprint } : {}),
...(applicationArchiveUrlFromFlag
@@ -465,7 +446,7 @@ export default class Simulator extends EasCommand {
session.status === DeviceRunSessionStatus.Stopped
) {
throw new Error(
`Simulator session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId, sessionInterrupt.signal)}`
`Simulator session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}`
);
}
@@ -476,7 +457,7 @@ export default class Simulator extends EasCommand {
jobRunStatus === JobRunStatus.Finished
) {
throw new Error(
`Turtle job run for simulator session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId, sessionInterrupt.signal)}`
`Turtle job run for simulator session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}`
);
}
@@ -638,18 +619,6 @@ export default class Simulator extends EasCommand {
}
}
// Returns the reasons the session's job run failed, one per line, or an empty string when none are available.
async function formatJobRunErrorsAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionId: string,
signal: AbortSignal
): Promise<string> {
const errors = await fetchSimulatorSessionErrorsAsync(graphqlClient, deviceRunSessionId, {
signal,
});
return errors.map(error => `\n${formatSimulatorSessionError(error)}`).join('');
}
async function resolvePlatformAsync(
platform: PlatformFlagValue | undefined,
nonInteractive: boolean
@@ -731,9 +700,7 @@ async function waitForSessionEndOrInterruptAsync({
jobRunStatus === JobRunStatus.Errored
) {
spinner.fail(`Simulator session errored. ${link(deviceRunSessionUrl)}`);
throw new Error(
`Simulator session ${deviceRunSessionId} errored.${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId, signal)}`
);
throw new Error(`Simulator session ${deviceRunSessionId} errored.`);
}
if (
session.status === DeviceRunSessionStatus.Stopped ||
@@ -0,0 +1,108 @@
import EasCommand from '../../commandUtils/EasCommand';
import { AppPlatform, DeviceRunSessionStatus } from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log from '../../log';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../simulator/env';
import {
getServeSimDeviceUdidAsync,
getServeSimHost,
runServeSimHostActionAsync,
} from '../../simulator/serveSimHost';
import { SUPPORTED_SIMCTL_COMMANDS, resolveSimctlHostAction } from '../../simulator/simctl';
export function parseSimctlArgv(rawArgv: string[]): { id?: string; args: string[] } {
let id: string | undefined;
let index = 0;
while (index < rawArgv.length) {
const arg = rawArgv[index];
if (arg === '--') {
index += 1;
break;
}
if (arg === '--id') {
id = rawArgv[index + 1];
if (!id) {
throw new Error('--id needs a simulator session ID.');
}
index += 2;
} else if (arg.startsWith('--id=')) {
id = arg.slice('--id='.length);
index += 1;
} else {
break;
}
}
return { id, args: rawArgv.slice(index) };
}
export default class SimulatorSimctl extends EasCommand {
static override aliases = ['sim:simctl'];
static override description = `[EXPERIMENTAL] run a supported simctl command on a remote iOS simulator session, without the device argument. Supported: ${SUPPORTED_SIMCTL_COMMANDS.map(
command => `\`${command}\``
).join(', ')}`;
static override strict = false;
static override usage = ['simulator:simctl [--id <value>] <simctl arguments...>'];
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectDir,
};
async runAsync(): Promise<void> {
const rawArgv = [...this.argv];
await this.parse(SimulatorSimctl, []);
const { id: flagId, args } = parseSimctlArgv(rawArgv);
const hostAction = resolveSimctlHostAction(args);
if (!hostAction) {
throw new Error(
`Unsupported simctl command${args.length > 0 ? `: ${args.join(' ')}` : ''}. Supported commands, without the device argument:\n${SUPPORTED_SIMCTL_COMMANDS.map(
command => ` ${command}`
).join('\n')}`
);
}
const {
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorSimctl, { nonInteractive: true });
await loadSimulatorEnvAsync(projectDir);
const sessionId = flagId || process.env[EAS_SIMULATOR_SESSION_ID];
if (!sessionId) {
throw new Error(
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
);
}
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, sessionId);
if (session.platform !== AppPlatform.Ios) {
throw new Error(`simulator:simctl only supports iOS simulator sessions.`);
}
if (session.status !== DeviceRunSessionStatus.InProgress) {
throw new Error(
`Simulator session ${session.id} is ${session.status.toLowerCase()}, not running.`
);
}
const host = session.remoteConfig ? getServeSimHost(session.remoteConfig) : null;
if (!host) {
throw new Error(`Simulator session ${session.id} has no simulator host to run simctl on.`);
}
const udid = await getServeSimDeviceUdidAsync(host);
const { stdout, stderr } = await runServeSimHostActionAsync(host, hostAction.action, {
...hostAction.params,
udid,
});
if (stdout) {
Log.log(stdout.trimEnd());
}
if (stderr) {
Log.warn(stderr.trimEnd());
}
}
}
-7
View File
@@ -16018,13 +16018,6 @@ export type DeviceRunSessionEventsByIdQueryVariables = Exact<{
export type DeviceRunSessionEventsByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, downloadUrl: string, metadata?: any | null }> } } };
export type DeviceRunSessionJobRunErrorsByIdQueryVariables = Exact<{
deviceRunSessionId: Scalars['ID']['input'];
}>;
export type DeviceRunSessionJobRunErrorsByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string, docsUrl?: string | null }> } | null } } };
export type DeviceRunSessionByIdQueryVariables = Exact<{
deviceRunSessionId: Scalars['ID']['input'];
}>;
@@ -1,4 +1,3 @@
import { OperationResult, OperationResultSource } from '@urql/core';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
@@ -9,8 +8,6 @@ import {
DeviceRunSessionEventsByIdQuery,
DeviceRunSessionEventsByIdQueryVariables,
DeviceRunSessionFilterInput,
DeviceRunSessionJobRunErrorsByIdQuery,
DeviceRunSessionJobRunErrorsByIdQueryVariables,
DeviceRunSessionsByAppIdQuery,
DeviceRunSessionsByAppIdQueryVariables,
} from '../generated';
@@ -45,52 +42,6 @@ export const DeviceRunSessionQuery = {
);
return data.deviceRunSessions.byId;
},
/**
* Best-effort lookup of why the session's job run failed. It doesn't retry or print errors, and it
* rejects with `signal.reason` and cancels the request when `signal` aborts.
*/
async jobRunErrorsByIdAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionId: string,
{ signal }: { signal: AbortSignal }
): Promise<DeviceRunSessionJobRunErrorsByIdQuery['deviceRunSessions']['byId']['turtleJobRun']> {
const { data, error } = await firstResultUntilAbortedAsync(
graphqlClient.query<
DeviceRunSessionJobRunErrorsByIdQuery,
DeviceRunSessionJobRunErrorsByIdQueryVariables
>(
gql`
query DeviceRunSessionJobRunErrorsByIdQuery($deviceRunSessionId: ID!) {
deviceRunSessions {
byId(deviceRunSessionId: $deviceRunSessionId) {
id
turtleJobRun {
id
errors {
errorCode
message
docsUrl
}
logFileUrls
}
}
}
}
`,
{ deviceRunSessionId },
// Retries would keep a timer alive after the caller gives up.
{ requestPolicy: 'network-only', noRetry: true }
),
signal
);
if (error) {
throw error;
}
if (!data) {
throw new Error('Returned query result data is null!');
}
return data.deviceRunSessions.byId.turtleJobRun;
},
async byIdAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionId: string
@@ -273,41 +224,3 @@ export const DeviceRunSessionQuery = {
return data.app.byId.deviceRunSessionsPaginated;
},
};
// Like `toPromise()`, but unsubscribing on abort makes urql cancel the request.
async function firstResultUntilAbortedAsync<T extends OperationResult>(
source: OperationResultSource<T>,
signal: AbortSignal
): Promise<T> {
signal.throwIfAborted();
return await new Promise<T>((resolve, reject) => {
let subscription: { unsubscribe: () => void } | undefined;
let settled = false;
const settle = (finish: () => void): void => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener('abort', onAbort);
subscription?.unsubscribe();
finish();
};
const onAbort = (): void => {
settle(() => {
reject(signal.reason);
});
};
signal.addEventListener('abort', onAbort, { once: true });
subscription = source.subscribe(result => {
if (!result.stale && !result.hasNext) {
settle(() => {
resolve(result);
});
}
});
// The result can arrive synchronously, before `subscription` is assigned.
if (settled) {
subscription.unsubscribe();
}
});
}
@@ -1,142 +0,0 @@
import http from 'http';
import { AddressInfo } from 'net';
import { createGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import Log from '../../../log';
import { DeviceRunSessionQuery } from '../DeviceRunSessionQuery';
jest.mock('../../../api', () => ({
getExpoApiBaseUrl: () => process.env.DEVICE_RUN_SESSION_QUERY_TEST_URL,
}));
describe('DeviceRunSessionQuery.jobRunErrorsByIdAsync', () => {
let server: http.Server;
let requests: number;
let closedRequests: number;
async function startServerAsync(handler: http.RequestListener): Promise<void> {
requests = 0;
closedRequests = 0;
server = http.createServer((request, response) => {
requests += 1;
request.socket.on('close', () => {
closedRequests += 1;
});
handler(request, response);
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
process.env.DEVICE_RUN_SESSION_QUERY_TEST_URL = `http://127.0.0.1:${port}`;
}
function respondWithJson(body: unknown): http.RequestListener {
return (_request, response) => {
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify(body));
};
}
afterEach(async () => {
jest.restoreAllMocks();
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
});
it('returns the job run errors', async () => {
const turtleJobRun = {
__typename: 'JobRun',
id: 'job-123',
logFileUrls: ['https://logs.example.com/job-123'],
errors: [
{
__typename: 'JobRunError',
errorCode: 'UNKNOWN_ERROR',
message: 'Unknown error.',
docsUrl: null,
},
],
};
await startServerAsync(
respondWithJson({
data: {
deviceRunSessions: {
__typename: 'DeviceRunSessionQuery',
byId: { __typename: 'DeviceRunSession', id: 'session-123', turtleJobRun },
},
},
})
);
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
await expect(
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
signal: new AbortController().signal,
})
).resolves.toEqual(turtleJobRun);
});
it('rejects without printing anything on a transient GraphQL error', async () => {
const logErrorSpy = jest.spyOn(Log, 'error').mockImplementation(() => {});
await startServerAsync(
respondWithJson({
data: null,
errors: [{ message: 'Try again', extensions: { isTransient: true } }],
})
);
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
await expect(
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
signal: new AbortController().signal,
})
).rejects.toThrow('Try again');
expect(logErrorSpy).not.toHaveBeenCalled();
expect(requests).toBe(1);
});
it('does not retry a network error', async () => {
await startServerAsync(request => {
request.socket.destroy();
});
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
await expect(
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
signal: new AbortController().signal,
})
).rejects.toThrow();
expect(requests).toBe(1);
});
it('rejects and cancels the request when the signal aborts', async () => {
const controller = new AbortController();
await startServerAsync(() => {
controller.abort(new Error('interrupted'));
});
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
await expect(
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
signal: controller.signal,
})
).rejects.toThrow('interrupted');
for (let attempt = 0; attempt < 50 && closedRequests === 0; attempt++) {
await new Promise(resolve => setTimeout(resolve, 20));
}
expect(closedRequests).toBe(1);
});
it('rejects without sending a request when the signal has already aborted', async () => {
await startServerAsync(() => {});
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
const controller = new AbortController();
controller.abort(new Error('interrupted'));
await expect(
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
signal: controller.signal,
})
).rejects.toThrow('interrupted');
expect(requests).toBe(0);
});
});
-21
View File
@@ -1,21 +0,0 @@
import { Help, Parser } from '@oclif/core';
export default class EasHelp extends Help {
// eslint-disable-next-line async-protect/async-suffix -- oclif defines the method name.
override async showHelp(argv: string[]): Promise<void> {
const [command, ...args] = argv;
if (command === 'simulator:control' || command === 'sim:control') {
const { default: SimulatorControl } = await import('./commands/simulator/control');
const parsed = await Parser.parse(args, {
args: SimulatorControl.args,
flags: SimulatorControl.flags,
strict: false,
});
if (parsed.args.action && parsed.flags.help) {
await SimulatorControl.run(args, this.config);
return;
}
}
await super.showHelp(argv);
}
}
@@ -1,518 +0,0 @@
{
"hardwareKeyboard.set": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"value": {
"type": "string",
"enum": [
"on",
"off"
]
}
},
"required": [
"udid",
"value"
],
"description": "Connect or disconnect the hardware keyboard."
},
"caDebug.set": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"option": {
"type": "string",
"enum": [
"blended",
"copies",
"misaligned",
"offscreen",
"slow-animations"
]
},
"value": {
"type": "string",
"enum": [
"on",
"off"
]
}
},
"required": [
"udid",
"option",
"value"
],
"description": "Turn a Core Animation debug option on or off."
},
"hinge.set": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"value": {
"anyOf": [
{
"type": "string",
"enum": [
"fold",
"half",
"unfold"
]
},
{
"type": "number"
}
]
}
},
"required": [
"udid",
"value"
],
"description": "Fold or unfold a foldable simulator."
},
"location.set": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"lat": {
"type": "number"
},
"lng": {
"type": "number"
}
},
"required": [
"udid",
"lat",
"lng"
],
"description": "Set the simulated location."
},
"permissions.list": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"bundleId": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
}
},
"required": [
"udid"
],
"description": "List permissions for one app or for every app."
},
"permissions.set": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"action": {
"type": "string",
"enum": [
"grant",
"revoke",
"reset"
]
},
"service": {
"type": "string",
"maxLength": 64,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"bundleId": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
}
},
"required": [
"udid",
"action",
"service",
"bundleId"
],
"description": "Grant, revoke or reset one permission for an app."
},
"app.install": {
"description": "Install an app from an .ipa file.",
"args": [
"path"
],
"upload": "path",
"anyOf": [
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"uploadId": {
"type": "string",
"pattern": "^(?!\\.)[A-Za-z0-9._-]{1,128}$"
}
},
"required": [
"udid",
"uploadId"
]
},
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"path": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\p{C}]+$"
}
},
"required": [
"udid",
"path"
]
}
]
},
"media.add": {
"description": "Add a photo or video to the Photos library.",
"args": [
"path"
],
"upload": "path",
"anyOf": [
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"uploadId": {
"type": "string",
"pattern": "^(?!\\.)[A-Za-z0-9._-]{1,128}$"
}
},
"required": [
"udid",
"uploadId"
]
},
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"path": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\p{C}]+$"
}
},
"required": [
"udid",
"path"
]
}
]
},
"camera.switch": {
"oneOf": [
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"source": {
"type": "string",
"const": "file"
},
"target": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\p{C}]+$"
}
},
"required": [
"udid",
"source",
"target"
]
},
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"source": {
"type": "string",
"const": "webcam"
},
"target": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\p{C}]+$"
}
},
"required": [
"udid",
"source"
]
},
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"source": {
"type": "string",
"const": "placeholder"
}
},
"required": [
"udid",
"source"
]
}
],
"description": "Change the camera feed of an app with a simulated camera.",
"args": [
"source",
"target"
],
"upload": "target",
"uploadWhen": {
"source": "file"
},
"keepUpload": true
},
"camera.inject": {
"oneOf": [
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"bundleId": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"mirror": {
"type": "string",
"enum": [
"on",
"off"
]
},
"source": {
"type": "string",
"const": "file"
},
"target": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\p{C}]+$"
}
},
"required": [
"udid",
"bundleId",
"mirror",
"source",
"target"
]
},
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"bundleId": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"mirror": {
"type": "string",
"enum": [
"on",
"off"
]
},
"source": {
"type": "string",
"const": "webcam"
},
"target": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\p{C}]+$"
}
},
"required": [
"udid",
"bundleId",
"mirror",
"source"
]
},
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"bundleId": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"mirror": {
"type": "string",
"enum": [
"on",
"off"
]
},
"source": {
"type": "string",
"const": "placeholder"
}
},
"required": [
"udid",
"bundleId",
"mirror",
"source"
]
}
],
"description": "Relaunch an app with a simulated camera feed.",
"args": [
"bundleId",
"mirror",
"source",
"target"
],
"upload": "target",
"uploadWhen": {
"source": "file"
},
"keepUpload": true
},
"screenshot.capture": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"fileName": {
"type": "string",
"pattern": "^serve-sim-screenshot-[A-Za-z0-9._-]{1,100}\\.png$"
}
},
"required": [
"udid"
],
"description": "Take a screenshot.",
"output": "path"
},
"file.readBase64": {
"type": "object",
"properties": {
"path": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\p{C}]+$"
}
},
"required": [
"path"
],
"description": "Download a file from the simulator host.",
"output": "base64"
},
"app.container": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"bundleId": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
}
},
"required": [
"udid",
"bundleId"
],
"description": "Print the path of an installed app bundle."
}
}
@@ -1,633 +0,0 @@
import * as fs from 'fs-extra';
import { once } from 'node:events';
import http from 'node:http';
import { AddressInfo } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { Duplex } from 'node:stream';
import { WebSocketServer } from 'ws';
import * as fetchModule from '../../fetch';
import {
HostActionCatalog,
HostActionConnection,
assertHostActionOutputAsync,
buildHostActionParams,
connectHostActionsAsync,
fetchHostActionCatalogAsync,
formatHostActionUsage,
saveHostActionOutputAsync,
uploadFileAsync,
} from '../hostActions';
const UDID = '90858C9D-BC34-4A0A-93D4-E4939A681A72';
const TOKEN = 'preview-token_123';
const catalog: HostActionCatalog = require('./fixtures/hostActionCatalog.json');
function entry(name: string): HostActionCatalog[string] {
return catalog[name];
}
describe(buildHostActionParams, () => {
it('fills parameters in schema order', () => {
expect(
buildHostActionParams('permissions.set', entry('permissions.set'), [
'grant',
'camera',
'com.example.app',
])
).toEqual({ action: 'grant', service: 'camera', bundleId: 'com.example.app' });
});
it('converts values to the types the schema accepts', () => {
expect(
buildHostActionParams('location.set', entry('location.set'), ['37.3349', '-122.009'])
).toEqual({ lat: 37.3349, lng: -122.009 });
expect(buildHostActionParams('hinge.set', entry('hinge.set'), ['90'])).toEqual({ value: 90 });
expect(buildHostActionParams('hinge.set', entry('hinge.set'), ['half'])).toEqual({
value: 'half',
});
expect(
buildHostActionParams('hardwareKeyboard.set', entry('hardwareKeyboard.set'), ['1'])
).toEqual({ value: '1' });
expect(buildHostActionParams('location.set', entry('location.set'), ['1e1', '.5'])).toEqual({
lat: 10,
lng: 0.5,
});
});
it('reads JSON for a parameter that takes a list', () => {
const launch = {
type: 'object',
properties: {
udid: { type: 'string' },
bundleId: { type: 'string' },
launchArgs: { type: 'array', items: { type: 'string' } },
},
};
expect(
buildHostActionParams('app.launch', launch, ['com.example.app', '["-FIRDebugEnabled"]'])
).toEqual({ bundleId: 'com.example.app', launchArgs: ['-FIRDebugEnabled'] });
expect(buildHostActionParams('app.launch', launch, ['com.example.app', '[oops'])).toEqual({
bundleId: 'com.example.app',
launchArgs: '[oops',
});
});
it('fills the values in order around the named ones', () => {
expect(buildHostActionParams('location.set', entry('location.set'), ['lat=1', '2'])).toEqual({
lat: 1,
lng: 2,
});
expect(
buildHostActionParams('permissions.set', entry('permissions.set'), [
'action=grant',
'camera',
'com.example.app',
])
).toEqual({ action: 'grant', service: 'camera', bundleId: 'com.example.app' });
});
it('accepts named values in any order', () => {
expect(
buildHostActionParams('permissions.list', entry('permissions.list'), [
'bundleId=com.example.app',
])
).toEqual({ bundleId: 'com.example.app' });
expect(
buildHostActionParams('camera.switch', entry('camera.switch'), [
'target=./photo.jpg',
'source=file',
])
).toEqual({ source: 'file', target: './photo.jpg' });
});
it('follows the value order the catalog gives for actions with several shapes', () => {
expect(buildHostActionParams('app.install', entry('app.install'), ['./MyApp.ipa'])).toEqual({
path: './MyApp.ipa',
});
expect(
buildHostActionParams('camera.switch', entry('camera.switch'), ['file', './photo.jpg'])
).toEqual({ source: 'file', target: './photo.jpg' });
expect(
buildHostActionParams('camera.inject', entry('camera.inject'), [
'com.example.app',
'off',
'placeholder',
])
).toEqual({ bundleId: 'com.example.app', mirror: 'off', source: 'placeholder' });
});
it('refuses extra values and a value given twice', () => {
expect(() =>
buildHostActionParams('hardwareKeyboard.set', entry('hardwareKeyboard.set'), ['on', 'off'])
).toThrow(
'Too many values for hardwareKeyboard.set. Usage: eas simulator:control hardwareKeyboard.set <value>.'
);
expect(() =>
buildHostActionParams('camera.switch', entry('camera.switch'), ['file', './a.jpg', './b.jpg'])
).toThrow('Too many values for camera.switch.');
expect(() =>
buildHostActionParams('hardwareKeyboard.set', entry('hardwareKeyboard.set'), [
'value=on',
'value=off',
])
).toThrow('value is given twice for hardwareKeyboard.set.');
});
});
describe(formatHostActionUsage, () => {
it.each(['anyOf', 'oneOf'])('shows a %s input group only once', composition => {
expect(
formatHostActionUsage('app.install', {
type: 'object',
properties: {
bundleId: { type: 'string' },
path: { type: 'string' },
uploadId: { type: 'string' },
},
required: ['bundleId'],
args: ['bundleId', 'path', 'uploadId'],
[composition]: [{ required: ['path'] }, { required: ['uploadId'] }],
})
).toBe('app.install <bundleId> <path|uploadId=...>');
});
it('shows the required alternative file sources for an upload action', () => {
expect(formatHostActionUsage('app.install', entry('app.install'))).toBe(
'app.install <uploadId=...|path>'
);
});
it('marks required and optional parameters', () => {
expect(formatHostActionUsage('permissions.set', entry('permissions.set'))).toBe(
'permissions.set <action> <service> <bundleId>'
);
expect(formatHostActionUsage('screenshot.capture', entry('screenshot.capture'))).toBe(
'screenshot.capture [fileName]'
);
expect(formatHostActionUsage('camera.switch', entry('camera.switch'))).toBe(
'camera.switch <source> [target]'
);
});
});
describe('host action transport policy', () => {
afterEach(() => jest.restoreAllMocks());
it.each([
'http://sim.example.test',
'http://127.0.0.1.example.test',
'http://localhost.example.test',
'http://[::ffff:192.0.2.1]',
'ftp://127.0.0.1',
'ws://127.0.0.1',
])('rejects %s before sending the session token', async apiUrl => {
const fetch = jest
.spyOn(fetchModule, 'default')
.mockRejectedValue(new Error('Unexpected fetch'));
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
'Simulator actions require HTTPS'
);
expect(fetch).not.toHaveBeenCalled();
});
it.each([
['https://sim.example.test', 'https://sim.example.test/api'],
['http://localhost:44370', 'http://localhost:44370/api'],
['http://127.0.0.1:44370', 'http://127.0.0.1:44370/api'],
['http://127.255.255.254:44370', 'http://127.255.255.254:44370/api'],
['http://2130706433:44370', 'http://127.0.0.1:44370/api'],
['http://[::1]:44370', 'http://[::1]:44370/api'],
])('accepts %s without following authenticated redirects', async (apiUrl, endpoint) => {
const stopped = new Error('Fetch reached');
const fetch = jest.spyOn(fetchModule, 'default').mockRejectedValue(stopped);
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toBe(stopped);
expect(fetch).toHaveBeenCalledWith(
endpoint,
expect.objectContaining({
headers: { Authorization: `Bearer ${TOKEN}` },
redirect: 'error',
})
);
});
});
describe('host action connection', () => {
let server: http.Server;
let wss: WebSocketServer;
let apiUrl: string;
let received: { id: number; action: string; params: Record<string, unknown> }[];
let requests: (string | undefined)[];
let apiBody: string;
let uploads: Map<string, Buffer>;
let reply: (message: {
id: number;
action: string;
params: Record<string, unknown>;
}) => Record<string, unknown> | null;
let tmpDir: string;
let connection: HostActionConnection | undefined;
let closeBeforeReady: boolean;
function serveSim(message: {
id: number;
action: string;
params: Record<string, unknown>;
}): Record<string, unknown> {
const ok = (stdout: string): Record<string, unknown> => ({
id: message.id,
stdout,
stderr: '',
exitCode: 0,
});
switch (message.action) {
case 'actions.describe':
return ok(JSON.stringify(catalog));
case 'upload.append': {
const uploadId = String(message.params.uploadId);
const chunk = Buffer.from(String(message.params.data), 'base64');
const earlier = message.params.first ? [] : [uploads.get(uploadId) ?? Buffer.alloc(0)];
uploads.set(uploadId, Buffer.concat([...earlier, chunk]));
return ok(`/tmp/serve-sim-uploads/${uploadId}\n`);
}
case 'file.readBase64':
return ok(Buffer.from('png bytes').toString('base64'));
default:
return ok('on\n');
}
}
beforeEach(async () => {
received = [];
requests = [];
uploads = new Map();
apiBody = JSON.stringify({ device: UDID });
reply = serveSim;
closeBeforeReady = false;
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'host-actions-'));
server = http.createServer((req, res) => {
requests.push(req.url);
if (req.url === '/api' && req.headers.authorization === `Bearer ${TOKEN}`) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(apiBody);
return;
}
res.writeHead(401);
res.end();
});
wss = new WebSocketServer({
server,
path: '/exec-ws',
handleProtocols: offered =>
offered.has(`serve-sim.token.${TOKEN}`) ? `serve-sim.token.${TOKEN}` : false,
});
wss.on('connection', ws => {
if (closeBeforeReady) {
ws.close();
return;
}
ws.send(JSON.stringify({ ready: true }));
ws.on('message', data => {
const message = JSON.parse(data.toString());
received.push(message);
const response = reply(message);
if (response) {
ws.send(JSON.stringify(response));
}
});
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
connection?.close();
connection = undefined;
for (const client of wss.clients) {
client.terminate();
}
wss.close();
server.close();
await once(server, 'close');
await fs.remove(tmpDir);
});
async function connectAsync(timeoutMs?: number): Promise<HostActionConnection> {
connection = await connectHostActionsAsync({ apiUrl, token: TOKEN }, { timeoutMs });
return connection;
}
it('sends several requests over one connection with the simulator udid', async () => {
const hostActions = await connectAsync();
const results = await Promise.all([
hostActions.requestAsync('hardwareKeyboard.get'),
hostActions.requestAsync('hardwareKeyboard.set', { value: 'off' }),
]);
expect(received).toEqual([
{ id: 1, action: 'hardwareKeyboard.get', params: { udid: UDID } },
{ id: 2, action: 'hardwareKeyboard.set', params: { value: 'off', udid: UDID } },
]);
expect(results).toEqual([
{ stdout: 'on\n', stderr: '', exitCode: 0 },
{ stdout: 'on\n', stderr: '', exitCode: 0 },
]);
});
it('reads the catalog of actions', async () => {
const hostActions = await connectAsync();
await expect(fetchHostActionCatalogAsync(hostActions)).resolves.toEqual(catalog);
});
it('explains that an old serve-sim does not have the catalog', async () => {
reply = message => ({ id: message.id, error: 'unknown action actions.describe' });
const hostActions = await connectAsync();
await expect(fetchHostActionCatalogAsync(hostActions)).rejects.toThrow(
'does not support this action'
);
});
it('passes a validation error from the simulator through', async () => {
reply = message => ({
id: message.id,
error: 'hardwareKeyboard.set: value must be one of on, off',
});
const hostActions = await connectAsync();
await expect(
hostActions.requestAsync('hardwareKeyboard.set', { value: 'maybe' })
).rejects.toThrow(
'The simulator refused the request: hardwareKeyboard.set: value must be one of on, off'
);
});
it('fails a request at once on a reply it cannot read', async () => {
reply = message => ({ id: message.id, output: 'on' });
const hostActions = await connectAsync();
await expect(hostActions.requestAsync('memoryWarning')).rejects.toThrow(
'sent a reply that this version of eas-cli cannot read'
);
});
it('gives up on a request the simulator does not answer', async () => {
reply = () => null;
const hostActions = await connectAsync(100);
await expect(hostActions.requestAsync('memoryWarning')).rejects.toThrow(
'The simulator did not answer within'
);
});
it('keeps a path on the API URL and drops its query', async () => {
await expect(
connectHostActionsAsync({ apiUrl: `${apiUrl}/preview?token=${TOKEN}`, token: TOKEN })
).rejects.toThrow('refused the session token (HTTP 401)');
expect(requests).toEqual(['/preview/api']);
});
it('refuses a wrong token before it opens the socket', async () => {
await expect(connectHostActionsAsync({ apiUrl, token: 'wrong' })).rejects.toThrow(
'refused the session token (HTTP 401)'
);
expect(wss.clients.size).toBe(0);
});
it('does not send the session token to an API redirect target', async () => {
server.removeAllListeners('request');
server.on('request', (req, res) => {
requests.push(req.url);
if (req.url === '/api') {
res.writeHead(302, { Location: '/redirected-api' });
res.end();
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(apiBody);
}
});
await expect(connectAsync()).rejects.toThrow(/redirect/i);
expect(requests).toEqual(['/api']);
expect(wss.clients.size).toBe(0);
});
it('explains a connection that closes during setup', async () => {
closeBeforeReady = true;
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
/closed the connection during setup.*token.*eas simulator:get/
);
});
it.each([401, 403])(
'closes a refused HTTP %i upgrade without waiting for its body',
async status => {
let rejectedSocket: Duplex | undefined;
let timer: NodeJS.Timeout | undefined;
const closed = new Promise<void>(resolve => {
server.removeAllListeners('upgrade');
server.once('upgrade', (_req, socket) => {
rejectedSocket = socket;
socket.once('close', resolve);
socket.on('end', () => socket.end());
socket.write(
`HTTP/1.1 ${status} Refused\r\nConnection: keep-alive\r\nContent-Length: 1000000\r\n\r\nrefused`
);
});
});
try {
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
`refused the connection (HTTP ${status})`
);
await Promise.race([
closed,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
reject(new Error('Refused upgrade socket stayed open'));
}, 1000);
}),
]);
} finally {
clearTimeout(timer);
rejectedSocket?.destroy();
}
}
);
it('explains a preview server that does not report its simulator', async () => {
apiBody = '<html>starting</html>';
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
'did not report which simulator it streams'
);
});
it('uploads a file in order and returns where the simulator host stored it', async () => {
const localFile = path.join(tmpDir, 'MyApp.ipa');
await fs.writeFile(localFile, 'hello simulator');
const hostActions = await connectAsync();
const upload = await uploadFileAsync(hostActions, localFile, { chunkBytes: 4 });
expect(upload.uploadId).toMatch(/^eas-cli-[0-9a-f-]{36}\.ipa$/);
expect(upload.path).toBe(`/tmp/serve-sim-uploads/${upload.uploadId}`);
expect(uploads.get(upload.uploadId)?.toString()).toBe('hello simulator');
expect(received.map(message => message.params.first)).toEqual([true, false, false, false]);
});
it('removes a partial upload when a chunk fails', async () => {
const localFile = path.join(tmpDir, 'MyApp.ipa');
await fs.writeFile(localFile, 'hello simulator');
reply = message =>
message.action === 'upload.append' && !message.params.first
? { id: message.id, stdout: '', stderr: 'The upload staging area is full.', exitCode: 1 }
: serveSim(message);
const hostActions = await connectAsync();
await expect(uploadFileAsync(hostActions, localFile, { chunkBytes: 4 })).rejects.toThrow(
`Could not upload ${localFile}: The upload staging area is full.`
);
expect(received.at(-1)).toMatchObject({
action: 'upload.remove',
params: { uploadId: received[0].params.uploadId },
});
});
it('refuses to upload an empty file', async () => {
const localFile = path.join(tmpDir, 'empty.png');
await fs.writeFile(localFile, '');
const hostActions = await connectAsync();
await expect(uploadFileAsync(hostActions, localFile)).rejects.toThrow(`${localFile} is empty.`);
});
it('downloads a file the action left on the simulator host', async () => {
const hostActions = await connectAsync();
const outputPath = path.join(tmpDir, 'shot.png');
const saved = await saveHostActionOutputAsync(
hostActions,
entry('screenshot.capture'),
'/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n',
outputPath
);
expect(saved).toBe(outputPath);
expect(received.at(-1)).toMatchObject({
action: 'file.readBase64',
params: { path: '/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png' },
});
expect(await fs.readFile(outputPath, 'utf8')).toBe('png bytes');
});
it('saves into a folder under the name the host gave the file', async () => {
const hostActions = await connectAsync();
const saved = await saveHostActionOutputAsync(
hostActions,
entry('screenshot.capture'),
'/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n',
tmpDir
);
expect(saved).toBe(path.join(tmpDir, 'serve-sim-screenshot-1.png'));
expect(await fs.readFile(path.join(tmpDir, 'serve-sim-screenshot-1.png'), 'utf8')).toBe(
'png bytes'
);
});
it('does not replace a file it named itself', async () => {
const existing = path.join(tmpDir, 'serve-sim-screenshot-1.png');
await fs.writeFile(existing, 'mine');
const hostActions = await connectAsync();
await expect(
saveHostActionOutputAsync(
hostActions,
entry('screenshot.capture'),
'/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n',
tmpDir
)
).rejects.toThrow(`${existing} already exists.`);
expect(await fs.readFile(existing, 'utf8')).toBe('mine');
});
it('saves file contents the action returned', async () => {
const hostActions = await connectAsync();
const outputPath = path.join(tmpDir, 'Info.plist');
await saveHostActionOutputAsync(
hostActions,
entry('file.readBase64'),
Buffer.from('plist').toString('base64'),
outputPath
);
expect(await fs.readFile(outputPath, 'utf8')).toBe('plist');
});
it('saves nothing for an action that returns no file', async () => {
const hostActions = await connectAsync();
await expect(
saveHostActionOutputAsync(hostActions, entry('hardwareKeyboard.set'), 'on', undefined)
).resolves.toBeNull();
});
});
describe(assertHostActionOutputAsync, () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'host-action-output-'));
});
afterEach(async () => {
await fs.remove(tmpDir);
});
it('allows --output only for actions that return a file', async () => {
await expect(
assertHostActionOutputAsync('app.container', entry('app.container'), './out')
).rejects.toThrow('app.container does not return a file, so --output does not apply.');
await expect(
assertHostActionOutputAsync('screenshot.capture', entry('screenshot.capture'), undefined)
).resolves.toBeUndefined();
await expect(
assertHostActionOutputAsync('screenshot.capture', entry('screenshot.capture'), tmpDir)
).resolves.toBeUndefined();
});
it('needs a file path for an action that returns file contents', async () => {
await expect(
assertHostActionOutputAsync('file.readBase64', entry('file.readBase64'), undefined)
).rejects.toThrow('file.readBase64 returns a file. Pass --output with the path to save it to.');
await expect(
assertHostActionOutputAsync('file.readBase64', entry('file.readBase64'), tmpDir)
).rejects.toThrow(`${tmpDir} is a folder. Pass --output with a file path for file.readBase64.`);
});
it('refuses a path in a folder that does not exist', async () => {
const missing = path.join(tmpDir, 'missing', 'shot.png');
await expect(
assertHostActionOutputAsync('screenshot.capture', entry('screenshot.capture'), missing)
).rejects.toThrow(`The folder for ${missing} does not exist.`);
await expect(
assertHostActionOutputAsync(
'screenshot.capture',
entry('screenshot.capture'),
path.join(tmpDir, 'missing') + path.sep
)
).rejects.toThrow('does not exist');
});
});
@@ -1,479 +0,0 @@
import http from 'http';
import { AddressInfo } from 'net';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import type LogType from '../../log';
import type * as JobRunErrors from '../jobRunErrors';
jest.mock('../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../log');
const mockJobRunErrorsByIdAsync = jest.mocked(DeviceRunSessionQuery.jobRunErrorsByIdAsync);
const graphqlClient = {} as ExpoGraphqlClient;
const unknownError = {
errorCode: 'UNKNOWN_ERROR',
message: 'Unknown error. See logs for more information.',
docsUrl: null,
};
// src/fetch.ts reads https_proxy when it loads, so load it only after clearing the proxy.
let jobRunErrors: typeof JobRunErrors;
let Log: typeof LogType;
beforeAll(() => {
delete process.env.https_proxy;
jest.isolateModules(() => {
jobRunErrors = jest.requireActual('../jobRunErrors');
Log = jest.requireMock('../../log').default;
});
});
// Lines 116-121 of a real failed Android session log, trimmed. The step's stderr has the precise
// cause at level 30; the level-50 error line only has the exit code.
const failedLaunchLog = [
{
level: 30,
buildStepId: 'install_build',
marker: 'end-step',
result: 'success',
msg: 'Finished build step "Install build" successfully',
},
{
level: 30,
buildStepId: 'install_build',
source: 'stderr',
msg: 'Error: Unable to find emulator skin, using default',
},
{
level: 30,
buildStepId: 'step-002',
buildStepDisplayName: 'Launch application',
marker: 'start-step',
msg: 'Executing build step "Launch application"',
},
{
level: 30,
buildStepId: 'step-002',
buildStepDisplayName: 'Launch application',
source: 'stdout',
msg: 'Starting: Intent { act=android.intent.action.MAIN pkg=world }',
},
{
level: 30,
buildStepId: 'step-002',
buildStepDisplayName: 'Launch application',
source: 'stderr',
msg: 'Error: Activity not started, unable to resolve Intent { act=android.intent.action.MAIN pkg=world }',
},
{
level: 50,
buildStepId: 'step-002',
buildStepDisplayName: 'Launch application',
msg: 'adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1',
err: {
message: 'adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1',
},
},
{
level: 50,
buildStepId: 'step-002',
buildStepDisplayName: 'Launch application',
marker: 'end-step',
result: 'fail',
msg: 'Build step "Launch application" failed',
},
];
describe('selectFailedStepErrors', () => {
it("shows the failed step's stderr cause before its error line", () => {
expect(jobRunErrors.selectFailedStepErrors(failedLaunchLog)).toEqual([
'Launch application: Error: Activity not started, unable to resolve Intent { act=android.intent.action.MAIN pkg=world }',
'Launch application: adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1',
]);
});
it('ignores stderr from steps that did not fail', () => {
const messages = jobRunErrors.selectFailedStepErrors(failedLaunchLog);
expect(messages.join('\n')).not.toContain('emulator skin');
});
it('never prints the unmasked err.message of a phase failure with an empty message', () => {
const phaseFailureLog = [
{ level: 30, phase: 'PREPARE_PROJECT', msg: 'Start phase: PREPARE_PROJECT' },
{
level: 50,
phase: 'PREPARE_PROJECT',
msg: '',
err: {
message: 'git clone https://[email protected]/acme/app failed',
stack: 'Error: git clone https://[email protected]/acme/app failed',
},
},
];
expect(jobRunErrors.selectFailedStepErrors(phaseFailureLog)).toEqual([]);
});
it('ignores errors outside of failed steps, such as errors a step ignored', () => {
expect(
jobRunErrors.selectFailedStepErrors([
{
level: 50,
buildStepId: 'step-001',
buildStepDisplayName: 'Upload artifact',
msg: 'Failed to upload screen-recording. Ignoring error.',
err: { message: 'upload failed' },
},
{
level: 30,
buildStepId: 'step-001',
marker: 'end-step',
result: 'success',
msg: 'Finished',
},
{
level: 50,
msg: 'simctl boot failed for ********',
err: { message: 'simctl boot failed for secret-udid' },
},
])
).toEqual([]);
});
it('keeps the stderr line that continues the cause', () => {
expect(
jobRunErrors.selectFailedStepErrors([
{
level: 30,
buildStepId: 'step-001',
buildStepDisplayName: 'Boot simulator',
source: 'stderr',
msg: 'An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):',
},
{
level: 30,
buildStepId: 'step-001',
buildStepDisplayName: 'Boot simulator',
source: 'stderr',
msg: 'Invalid device: iPhone 16 Pro',
},
{
level: 50,
buildStepId: 'step-001',
buildStepDisplayName: 'Boot simulator',
msg: 'xcrun simctl boot exited with non-zero code: 149',
err: { message: 'xcrun simctl boot exited with non-zero code: 149' },
},
{
level: 50,
buildStepId: 'step-001',
marker: 'end-step',
result: 'fail',
msg: 'Build step "Boot simulator" failed',
},
])
).toEqual([
'Boot simulator: An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):',
'Boot simulator: Invalid device: iPhone 16 Pro',
'Boot simulator: xcrun simctl boot exited with non-zero code: 149',
]);
});
it('keeps the cause of the first failed step when later steps also fail', () => {
expect(
jobRunErrors.selectFailedStepErrors([
...failedLaunchLog,
{
level: 30,
buildStepId: 'step-003',
buildStepDisplayName: 'Finish recordings',
source: 'stderr',
msg: 'Error: No devices are booted.',
},
{
level: 50,
buildStepId: 'step-003',
buildStepDisplayName: 'Finish recordings',
msg: 'xcrun simctl io recordVideo exited with non-zero code: 1',
err: { message: 'x' },
},
{
level: 50,
buildStepId: 'step-003',
marker: 'end-step',
result: 'fail',
msg: 'Build step "Finish recordings" failed',
},
])
).toEqual([
'Launch application: Error: Activity not started, unable to resolve Intent { act=android.intent.action.MAIN pkg=world }',
'Launch application: adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1',
'Finish recordings: xcrun simctl io recordVideo exited with non-zero code: 1',
]);
});
it('strips escape sequences and control characters from step names and messages', () => {
expect(
jobRunErrors.selectFailedStepErrors([
{
level: 50,
buildStepId: 'step-001',
buildStepDisplayName: 'Boot\u001b[2J simulator',
msg: '\u001b[31mxcrun simctl boot\u001b[0m exited\r\nwith code: 149\u0007',
err: { message: 'x' },
},
{
level: 50,
buildStepId: 'step-001',
marker: 'end-step',
result: 'fail',
msg: 'Build step "Boot simulator" failed',
},
])
).toEqual(['Boot simulator: xcrun simctl boot exited with code: 149']);
});
it('finds a colored stderr cause and skips error lines that are only escape sequences', () => {
expect(
jobRunErrors.selectFailedStepErrors([
{
level: 30,
buildStepId: 'step-001',
buildStepDisplayName: 'Launch application',
source: 'stderr',
msg: '\u001b[31mError\u001b[0m: Activity not started',
},
{
level: 50,
buildStepId: 'step-001',
buildStepDisplayName: 'Launch application',
msg: 'adb shell am start exited with non-zero code: 1',
err: { message: 'x' },
},
{
level: 50,
buildStepId: 'step-001',
buildStepDisplayName: 'Launch application',
msg: '\u001b[0m\u001b[K',
err: { message: 'x' },
},
{
level: 50,
buildStepId: 'step-001',
marker: 'end-step',
result: 'fail',
msg: 'Build step "Launch application" failed',
},
])
).toEqual([
'Launch application: Error: Activity not started',
'Launch application: adb shell am start exited with non-zero code: 1',
]);
});
});
describe('fetchSimulatorSessionErrorsAsync', () => {
let server: http.Server;
let closedRequests: number;
async function startServerAsync(handler: http.RequestListener): Promise<string> {
closedRequests = 0;
server = http.createServer((request, response) => {
request.socket.on('close', () => {
closedRequests += 1;
});
handler(request, response);
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
return `http://127.0.0.1:${port}/logs`;
}
beforeEach(() => {
jest.clearAllMocks();
});
async function waitForCloseAsync(): Promise<void> {
for (let attempt = 0; attempt < 50 && closedRequests === 0; attempt++) {
await new Promise(resolve => setTimeout(resolve, 20));
}
}
afterEach(async () => {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
});
function mockJobRun(logFileUrl: string): void {
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [unknownError],
logFileUrls: [logFileUrl],
});
}
it('returns the failed step errors from the downloaded log', async () => {
mockJobRun(
await startServerAsync((_request, response) => {
response.end(failedLaunchLog.map(line => JSON.stringify(line)).join('\n'));
})
);
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123')
).resolves.toEqual([
{
errorCode: 'UNKNOWN_ERROR',
message: expect.stringContaining('Activity not started'),
docsUrl: null,
},
{
errorCode: 'UNKNOWN_ERROR',
message: expect.stringContaining('exited with non-zero code: 1'),
docsUrl: null,
},
]);
});
it('does not log the signed log URL when the download fails', async () => {
const logFileUrl = await startServerAsync((request, response) => {
request.socket.destroy();
response.end();
});
mockJobRun(`${logFileUrl}?X-Goog-Signature=secret-signature`);
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123')
).resolves.toEqual([unknownError]);
expect(JSON.stringify(jest.mocked(Log.debug).mock.calls)).not.toContain('secret-signature');
});
it('strips escape sequences and control characters from job run error messages', async () => {
server = http.createServer();
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [
{
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
message: 'No available iOS Simulator is named "\u001b[2JiPhone\u001b]0;x\u0007\nFake"',
docsUrl: null,
},
],
logFileUrls: [],
});
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123')
).resolves.toEqual([
{
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
message: 'No available iOS Simulator is named "iPhone Fake"',
docsUrl: null,
},
]);
});
it('falls back to the job run error when the log request fails', async () => {
mockJobRun(
await startServerAsync((_request, response) => {
response.statusCode = 404;
response.end(JSON.stringify({ level: 50, msg: 'not the cause', err: { message: 'x' } }));
})
);
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123')
).resolves.toEqual([unknownError]);
});
it('falls back and closes the connection when the log is too large', async () => {
mockJobRun(
await startServerAsync((_request, response) => {
const chunk = 'x'.repeat(64 * 1024);
const timer = setInterval(() => response.write(chunk), 5);
response.on('close', () => {
clearInterval(timer);
});
})
);
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
maxLogSizeBytes: 256 * 1024,
})
).resolves.toEqual([unknownError]);
await waitForCloseAsync();
expect(closedRequests).toBe(1);
});
it('falls back and closes the connection when the log download stalls', async () => {
mockJobRun(
await startServerAsync((_request, response) => {
response.write('{"level":30,');
})
);
const startedAt = Date.now();
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
logDownloadTimeoutMs: 200,
})
).resolves.toEqual([unknownError]);
expect(Date.now() - startedAt).toBeLessThan(2_000);
await waitForCloseAsync();
expect(closedRequests).toBe(1);
});
it('stops the log download when the signal aborts', async () => {
mockJobRun(
await startServerAsync((_request, response) => {
response.write('{"level":30,');
})
);
const controller = new AbortController();
setTimeout(() => {
controller.abort();
}, 100);
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
signal: controller.signal,
})
).resolves.toEqual([unknownError]);
await waitForCloseAsync();
expect(closedRequests).toBe(1);
});
it('does not look up errors when the signal has already aborted', async () => {
server = http.createServer();
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const controller = new AbortController();
controller.abort();
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
signal: controller.signal,
})
).resolves.toEqual([]);
expect(mockJobRunErrorsByIdAsync).not.toHaveBeenCalled();
});
it('passes an abortable signal with a deadline to the errors query', async () => {
server = http.createServer();
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
mockJobRunErrorsByIdAsync.mockImplementation(
async (_client, _id, { signal }) =>
await new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => {
reject(signal.reason);
});
})
);
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
queryTimeoutMs: 100,
})
).resolves.toEqual([]);
});
});
@@ -0,0 +1,162 @@
import { AddressInfo } from 'net';
import nock from 'nock';
import { WebSocketServer } from 'ws';
import {
UnsupportedServeSimHostActionError,
getServeSimDeviceUdidAsync,
getServeSimHost,
runServeSimHostActionAsync,
} from '../serveSimHost';
import { DeviceRunSessionRemoteConfig } from '../utils';
const TOKEN = 'preview-token';
const UDID = '5B3A2F1E-0C4D-4E8F-9A7B-1C2D3E4F5A6B';
describe(getServeSimHost, () => {
it('reads the web preview host of a controller session', () => {
expect(
getServeSimHost({
__typename: 'ArgentRunSessionRemoteConfig',
toolsUrl: 'https://argent.example.com',
previewApiUrl: 'https://preview.example.com',
webPreviewToken: TOKEN,
} as DeviceRunSessionRemoteConfig)
).toEqual({ apiUrl: 'https://preview.example.com', token: TOKEN });
});
it('reads the preview host of a serve-sim session', () => {
expect(
getServeSimHost({
__typename: 'ServeSimRunSessionRemoteConfig',
previewApiUrl: 'https://preview.example.com',
previewToken: TOKEN,
} as DeviceRunSessionRemoteConfig)
).toEqual({ apiUrl: 'https://preview.example.com', token: TOKEN });
});
it('returns null before the session reports its preview host', () => {
expect(
getServeSimHost({
__typename: 'AgentDeviceRunSessionRemoteConfig',
previewApiUrl: null,
webPreviewToken: TOKEN,
} as DeviceRunSessionRemoteConfig)
).toBeNull();
});
});
describe(getServeSimDeviceUdidAsync, () => {
afterEach(() => {
nock.cleanAll();
});
it('reads the device from the preview state with the session token', async () => {
nock('https://preview.example.com', {
reqheaders: { authorization: `Bearer ${TOKEN}` },
})
.get('/api')
.reply(200, { device: UDID, port: 3200 });
await expect(
getServeSimDeviceUdidAsync({ apiUrl: 'https://preview.example.com', token: TOKEN })
).resolves.toBe(UDID);
});
it('fails when no simulator is running', async () => {
nock('https://preview.example.com').get('/api').reply(200, 'null');
await expect(
getServeSimDeviceUdidAsync({ apiUrl: 'https://preview.example.com', token: TOKEN })
).rejects.toThrow('no running simulator');
});
});
describe(runServeSimHostActionAsync, () => {
let server: WebSocketServer;
let apiUrl: string;
let reply: (request: { id: number; action: string; params: unknown }) => unknown;
let protocols: string[];
let requests: unknown[];
beforeEach(async () => {
protocols = [];
requests = [];
server = new WebSocketServer({
port: 0,
path: '/exec-ws',
handleProtocols: offered => {
protocols = [...offered];
return offered.values().next().value ?? false;
},
});
server.on('connection', socket => {
socket.send(JSON.stringify({ ready: true }));
socket.on('message', data => {
const request = JSON.parse(data.toString());
requests.push(request);
socket.send(JSON.stringify(reply(request)));
});
});
await new Promise<void>(resolve => {
server.once('listening', resolve);
});
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>(resolve => {
server.close(() => {
resolve();
});
});
});
it('runs one action over the token-authenticated exec channel', async () => {
reply = ({ id }) => ({ id, stdout: '', stderr: '', exitCode: 0 });
await expect(
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
udid: UDID,
value: 'dark',
})
).resolves.toEqual({ stdout: '', stderr: '' });
expect(protocols).toEqual([`serve-sim.token.${TOKEN}`]);
expect(requests).toEqual([
{ id: 1, action: 'appearance.set', params: { udid: UDID, value: 'dark' } },
]);
});
it('reports a rejected session token', async () => {
server.removeAllListeners('connection');
server.on('connection', socket => {
socket.close();
});
await expect(
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
udid: UDID,
value: 'dark',
})
).rejects.toThrow('rejected the session token');
});
it('reports an action the session does not have yet', async () => {
reply = ({ id, action }) => ({ id, error: `unknown action ${action}` });
await expect(
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'springboard.restart', { udid: UDID })
).rejects.toThrow(UnsupportedServeSimHostActionError);
});
it('reports a failed command with its stderr', async () => {
reply = ({ id }) => ({ id, stdout: '', stderr: 'Invalid device\n', exitCode: 148 });
await expect(
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
udid: UDID,
value: 'dark',
})
).rejects.toThrow('appearance.set failed: Invalid device');
});
});
@@ -0,0 +1,43 @@
import { resolveSimctlHostAction } from '../simctl';
describe(resolveSimctlHostAction, () => {
it('maps the appearance command', () => {
expect(resolveSimctlHostAction(['ui', 'appearance', 'dark'])).toEqual({
action: 'appearance.set',
params: { value: 'dark' },
});
expect(resolveSimctlHostAction(['ui', 'appearance', 'light'])).toEqual({
action: 'appearance.set',
params: { value: 'light' },
});
});
it('maps the SpringBoard restart', () => {
expect(
resolveSimctlHostAction([
'spawn',
'launchctl',
'kickstart',
'-k',
'system/com.apple.SpringBoard',
])
).toEqual({ action: 'springboard.restart', params: {} });
});
it('rejects every other command', () => {
expect(resolveSimctlHostAction([])).toBeNull();
expect(resolveSimctlHostAction(['ui', 'appearance'])).toBeNull();
expect(resolveSimctlHostAction(['ui', 'appearance', 'sepia'])).toBeNull();
expect(resolveSimctlHostAction(['spawn', 'launchctl', 'list'])).toBeNull();
expect(
resolveSimctlHostAction([
'spawn',
'launchctl',
'kickstart',
'-k',
'system/com.apple.backboardd',
])
).toBeNull();
expect(resolveSimctlHostAction(['openurl', 'myapp://'])).toBeNull();
});
});
@@ -19,7 +19,6 @@ import {
formatSimulatorUnavailableMessage,
getLocalEgressConfig,
getLoopbackForwardPlan,
getPreviewApiAccess,
getRemoteSessionEnvironmentVariables,
sanitizeRemoteConfigForJson,
} from '../utils';
@@ -838,45 +837,6 @@ describe.each(controllerConfigs)('$__typename local egress', remoteConfig => {
});
});
describe(getPreviewApiAccess, () => {
it('reads the preview token for each remote config type', () => {
expect(
getPreviewApiAccess({
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
previewUrl: 'https://expo.dev/preview',
previewToken: 'preview-token',
previewApiUrl: 'https://sim.example.test',
})
).toEqual({ apiUrl: 'https://sim.example.test', token: 'preview-token' });
expect(
getPreviewApiAccess({
__typename: 'AgentDeviceRunSessionRemoteConfig',
agentDeviceRemoteSessionUrl: 'https://agent.example.test',
agentDeviceRemoteSessionToken: 'agent-token',
webPreviewToken: 'preview-token',
previewApiUrl: 'https://sim.example.test',
})
).toEqual({ apiUrl: 'https://sim.example.test', token: 'preview-token' });
});
it('returns null without an API URL or a token', () => {
expect(
getPreviewApiAccess({
__typename: 'ServeSimRunSessionRemoteConfig',
previewUrl: 'https://sim.example.test',
previewToken: 'preview-token',
})
).toBeNull();
expect(
getPreviewApiAccess({
__typename: 'ServeSimRunSessionRemoteConfig',
previewUrl: 'https://sim.example.test',
previewApiUrl: 'https://sim.example.test',
})
).toBeNull();
});
});
describe(formatEmbedPreviewUrl, () => {
it('is null until the worker reports the preview host', () => {
expect(formatEmbedPreviewUrl(agentDeviceConfig)).toBeNull();
@@ -1,520 +0,0 @@
import { randomUUID } from 'crypto';
import * as fs from 'fs-extra';
import path from 'path';
import WebSocket from 'ws';
import { z } from 'zod';
import { PreviewApiAccess } from './utils';
import fetch, { RequestError, httpsProxyAgent } from '../fetch';
export type HostActionResult = {
stdout: string;
stderr: string;
exitCode: number;
};
export type HostActionConnection = {
requestAsync(action: string, params?: Record<string, unknown>): Promise<HostActionResult>;
close(): void;
};
const SchemaNode = z.looseObject({
type: z.union([z.string(), z.array(z.string())]).optional(),
properties: z.record(z.string(), z.unknown()).optional(),
required: z.array(z.string()).optional(),
allOf: z.array(z.unknown()).optional(),
anyOf: z.array(z.unknown()).optional(),
oneOf: z.array(z.unknown()).optional(),
});
const CatalogEntry = SchemaNode.extend({
description: z.string().optional(),
args: z.array(z.string()).optional(),
upload: z.string().optional(),
uploadAs: z.enum(['path', 'id']).optional(),
uploadWhen: z.record(z.string(), z.unknown()).optional(),
keepUpload: z.boolean().optional(),
output: z.enum(['path', 'base64']).optional(),
});
const Catalog = z.record(z.string(), CatalogEntry);
export type HostActionCatalog = z.infer<typeof Catalog>;
export type HostActionCatalogEntry = z.infer<typeof CatalogEntry>;
const PreviewConfig = z.object({ device: z.string().min(1) });
const ReplyId = z.object({ id: z.number() });
const SocketMessage = z.union([
z.object({ ready: z.literal(true) }),
z.object({ id: z.number(), error: z.string() }),
z.object({
id: z.number(),
stdout: z.string(),
stderr: z.string(),
exitCode: z.number(),
}),
]);
const HOST_ACTION_TIMEOUT_MS = 130_000;
const PREVIEW_API_TIMEOUT_MS = 30_000;
const UPLOAD_CHUNK_BYTES = 2 * 1024 * 1024;
const NAMED_VALUE = /^([A-Za-z][A-Za-z0-9]*)=(.*)$/s;
const NUMBER = /^-?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
function previewEndpoint(apiUrl: string, endpoint: string): URL {
const base = new URL(apiUrl);
const loopback =
base.hostname === 'localhost' ||
base.hostname === '[::1]' ||
/^127(?:\.\d{1,3}){3}$/.test(base.hostname);
if (base.protocol !== 'https:' && (base.protocol !== 'http:' || !loopback)) {
throw new Error(
'Simulator actions require HTTPS for remote preview servers. HTTP is supported only on localhost or a loopback address. Start a new session with `eas simulator:start`.'
);
}
base.search = '';
base.hash = '';
if (!base.pathname.endsWith('/')) {
base.pathname += '/';
}
return new URL(endpoint, base);
}
async function fetchSimulatorUdidAsync({ apiUrl, token }: PreviewApiAccess): Promise<string> {
let text: string;
try {
const response = await fetch(previewEndpoint(apiUrl, 'api').toString(), {
headers: { Authorization: `Bearer ${token}` },
redirect: 'error',
signal: AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS),
});
text = await response.text();
} catch (error) {
if (error instanceof RequestError && [401, 403].includes(error.response.status)) {
throw new Error(
`The simulator preview server refused the session token (HTTP ${error.response.status}). Run \`eas simulator:get\` to check that the session still runs, then try again.`
);
}
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(
`The simulator preview server did not answer within ${PREVIEW_API_TIMEOUT_MS / 1000} seconds. Run \`eas simulator:get\` to check that the session still runs, then try again.`
);
}
throw error;
}
let body: unknown = null;
try {
body = JSON.parse(text);
} catch {}
const config = PreviewConfig.safeParse(body);
if (!config.success) {
throw new Error(
'The simulator preview server did not report which simulator it streams. The session may still be starting. Wait a few seconds and run the command again.'
);
}
return config.data.device;
}
function refusedError(error: string): Error {
return error.startsWith('unknown action')
? new Error(
'This simulator session runs a serve-sim version that does not support this action. Start a new session with `eas simulator:start` to get a newer version.'
)
: new Error(`The simulator refused the request: ${error}`);
}
export async function connectHostActionsAsync(
access: PreviewApiAccess,
{ timeoutMs = HOST_ACTION_TIMEOUT_MS }: { timeoutMs?: number } = {}
): Promise<HostActionConnection> {
const udid = await fetchSimulatorUdidAsync(access);
const socketUrl = previewEndpoint(access.apiUrl, 'exec-ws');
socketUrl.protocol = socketUrl.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(
socketUrl.toString(),
[`serve-sim.token.${access.token}`],
httpsProxyAgent ? { agent: httpsProxyAgent } : {}
);
type Pending = {
resolve: (result: HostActionResult) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
};
const pending = new Map<number, Pending>();
let nextId = 1;
let lost: Error | null = null;
let ready = false;
function settle(id: number): Pending | undefined {
const request = pending.get(id);
if (request) {
pending.delete(id);
clearTimeout(request.timer);
}
return request;
}
function loseConnection(error: Error): void {
lost ??= error;
for (const id of [...pending.keys()]) {
settle(id)?.reject(error);
}
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(
new Error(
`The simulator preview server did not accept the connection within ${PREVIEW_API_TIMEOUT_MS / 1000} seconds. Check that the session still runs with \`eas simulator:get\`, then try again.`
)
);
ws.terminate();
}, PREVIEW_API_TIMEOUT_MS);
const fail = (error: Error): void => {
clearTimeout(timer);
reject(error);
loseConnection(error);
if (!ready) {
ws.terminate();
}
};
ws.on('message', data => {
let message: unknown;
try {
message = JSON.parse(data.toString());
} catch {
return;
}
const parsed = SocketMessage.safeParse(message);
if (!parsed.success) {
const id = ReplyId.safeParse(message);
if (id.success) {
settle(id.data.id)?.reject(
new Error(
'The simulator preview server sent a reply that this version of eas-cli cannot read. Update eas-cli and try again.'
)
);
}
return;
}
const reply = parsed.data;
if ('ready' in reply) {
ready = true;
clearTimeout(timer);
resolve();
} else if ('error' in reply) {
settle(reply.id)?.reject(refusedError(reply.error));
} else {
settle(reply.id)?.resolve({
stdout: reply.stdout,
stderr: reply.stderr,
exitCode: reply.exitCode,
});
}
});
ws.on('unexpected-response', (_req, res) => {
res.destroy();
fail(
new Error(
`The simulator preview server refused the connection (HTTP ${res.statusCode}). The session token may be out of date. Run \`eas simulator:get\` to check the session, then try again.`
)
);
});
ws.on('error', error => {
fail(
new Error(
`Could not connect to the simulator preview server: ${error.message}. Check your network connection and that the session still runs.`
)
);
});
ws.on('close', () => {
fail(
new Error(
ready
? 'The simulator preview server closed the connection before it answered. The action may still have run. Check the current state before you try again.'
: 'The simulator preview server closed the connection during setup. The session token may be invalid or expired, or the session may run an older serve-sim version. Check the session with `eas simulator:get` before starting a new session.'
)
);
});
});
return {
async requestAsync(action, params = {}) {
if (lost) {
throw lost;
}
const id = nextId++;
return await new Promise<HostActionResult>((resolve, reject) => {
const timer = setTimeout(() => {
settle(id)?.reject(
new Error(
`The simulator did not answer within ${Math.round(timeoutMs / 1000)} seconds. The action may still have run. Check the session with \`eas simulator:get\` and the current state before you try again.`
)
);
}, timeoutMs);
pending.set(id, { resolve, reject, timer });
ws.send(JSON.stringify({ id, action, params: { ...params, udid } }));
});
},
close() {
ws.close();
},
};
}
export async function fetchHostActionCatalogAsync(
connection: HostActionConnection
): Promise<HostActionCatalog> {
const result = await connection.requestAsync('actions.describe');
let body: unknown = null;
try {
body = JSON.parse(result.stdout);
} catch {}
const catalog = Catalog.safeParse(body);
if (result.exitCode !== 0 || !catalog.success) {
throw new Error(
'The simulator did not return its list of actions. Start a new session with `eas simulator:start` to get a newer serve-sim version.'
);
}
return catalog.data;
}
function collectProperties(node: unknown, into: Map<string, unknown[]>): void {
const parsed = SchemaNode.safeParse(node);
if (!parsed.success) {
return;
}
for (const [name, schema] of Object.entries(parsed.data.properties ?? {})) {
into.set(name, [...(into.get(name) ?? []), schema]);
}
const { allOf = [], anyOf = [], oneOf = [] } = parsed.data;
for (const child of [...allOf, ...anyOf, ...oneOf]) {
collectProperties(child, into);
}
}
function hostActionProperties(entry: HostActionCatalogEntry): Map<string, unknown[]> {
const properties = new Map<string, unknown[]>();
collectProperties(entry, properties);
properties.delete('udid');
return properties;
}
function positionalNames(entry: HostActionCatalogEntry): string[] {
return entry.args ?? Object.keys(entry.properties ?? {}).filter(name => name !== 'udid');
}
function requiredProperties(node: unknown): Set<string> {
const parsed = SchemaNode.safeParse(node);
if (!parsed.success) {
return new Set();
}
const required = new Set(parsed.data.required ?? []);
for (const child of parsed.data.allOf ?? []) {
requiredProperties(child).forEach(name => required.add(name));
}
const branches = [...(parsed.data.anyOf ?? []), ...(parsed.data.oneOf ?? [])].map(
requiredProperties
);
for (const name of branches[0] ?? []) {
if (branches.every(branch => branch.has(name))) {
required.add(name);
}
}
return required;
}
function acceptsType(schema: unknown, types: string[]): boolean {
const node = SchemaNode.safeParse(schema);
if (!node.success) {
return false;
}
const own = [node.data.type ?? []].flat();
return (
own.some(type => types.includes(type)) ||
[...(node.data.anyOf ?? []), ...(node.data.oneOf ?? [])].some(child =>
acceptsType(child, types)
)
);
}
function coerce(value: string, schemas: unknown[]): unknown {
if (/^[[{]/.test(value) && schemas.some(schema => acceptsType(schema, ['array', 'object']))) {
try {
return JSON.parse(value);
} catch {
return value;
}
}
if (NUMBER.test(value) && schemas.some(schema => acceptsType(schema, ['number', 'integer']))) {
return Number(value);
}
if (
(value === 'true' || value === 'false') &&
schemas.some(schema => acceptsType(schema, ['boolean']))
) {
return value === 'true';
}
return value;
}
export function formatHostActionUsage(name: string, entry: HostActionCatalogEntry): string {
const required = requiredProperties(entry);
const alternatives = (entry.anyOf ?? entry.oneOf ?? []).map(branch =>
[...requiredProperties(branch)].filter(
property => property !== 'udid' && !required.has(property)
)
);
const requiredAlternatives =
alternatives.length > 0 && alternatives.every(properties => properties.length === 1)
? alternatives.map(([property]) => property)
: [];
let describedAlternatives = false;
const names = positionalNames(entry).flatMap(property => {
if (requiredAlternatives.includes(property)) {
if (describedAlternatives) {
return [];
}
describedAlternatives = true;
return [
`<${requiredAlternatives.map(name => (name === property ? name : `${name}=...`)).join('|')}>`,
];
}
return [required.has(property) ? `<${property}>` : `[${property}]`];
});
return [name, ...names].join(' ');
}
export function buildHostActionParams(
name: string,
entry: HostActionCatalogEntry,
values: string[]
): Record<string, unknown> {
const properties = hostActionProperties(entry);
const params: Record<string, unknown> = {};
const positional: string[] = [];
for (const value of values) {
const named = NAMED_VALUE.exec(value);
if (!named || !properties.has(named[1])) {
positional.push(value);
} else if (Object.hasOwn(params, named[1])) {
throw new Error(`${named[1]} is given twice for ${name}.`);
} else {
params[named[1]] = coerce(named[2], properties.get(named[1]) ?? []);
}
}
const open = positionalNames(entry).filter(property => !Object.hasOwn(params, property));
if (positional.length > open.length) {
throw new Error(
`Too many values for ${name}. Usage: eas simulator:control ${formatHostActionUsage(name, entry)}. Name a value with name=value.`
);
}
positional.forEach((value, index) => {
params[open[index]] = coerce(value, properties.get(open[index]) ?? []);
});
return params;
}
export async function assertHostActionOutputAsync(
name: string,
entry: HostActionCatalogEntry,
outputPath: string | undefined
): Promise<void> {
if (!outputPath) {
if (entry.output === 'base64') {
throw new Error(`${name} returns a file. Pass --output with the path to save it to.`);
}
return;
}
if (!entry.output) {
throw new Error(`${name} does not return a file, so --output does not apply.`);
}
if ((await fs.stat(outputPath).catch(() => null))?.isDirectory()) {
if (entry.output === 'base64') {
throw new Error(`${outputPath} is a folder. Pass --output with a file path for ${name}.`);
}
return;
}
const folder = await fs.stat(path.dirname(outputPath)).catch(() => null);
if (outputPath.endsWith('/') || outputPath.endsWith(path.sep) || !folder?.isDirectory()) {
throw new Error(
`The folder for ${outputPath} does not exist. Create it, or pass --output with a path in a folder that exists.`
);
}
}
export async function uploadFileAsync(
connection: HostActionConnection,
localPath: string,
{ chunkBytes = UPLOAD_CHUNK_BYTES }: { chunkBytes?: number } = {}
): Promise<{ uploadId: string; path: string }> {
const uploadId = `eas-cli-${randomUUID()}${path.extname(localPath).replace(/[^A-Za-z0-9.]/g, '')}`;
let remotePath = '';
let first = true;
try {
for await (const chunk of fs.createReadStream(localPath, { highWaterMark: chunkBytes })) {
const result = await connection.requestAsync('upload.append', {
uploadId,
data: Buffer.from(chunk).toString('base64'),
first,
});
if (result.exitCode !== 0) {
throw new Error(`Could not upload ${localPath}: ${result.stderr.trim()}`);
}
remotePath = result.stdout.trim();
first = false;
}
} catch (error) {
await connection.requestAsync('upload.remove', { uploadId }).catch(() => {});
throw error;
}
if (first) {
throw new Error(`${localPath} is empty. Pass a file with content.`);
}
return { uploadId, path: remotePath };
}
export async function saveHostActionOutputAsync(
connection: HostActionConnection,
entry: HostActionCatalogEntry,
stdout: string,
outputPath: string | undefined
): Promise<string | null> {
if (!entry.output) {
return null;
}
let base64 = stdout;
let defaultName: string | undefined;
if (entry.output === 'path') {
const remotePath = stdout.trim();
const file = await connection.requestAsync('file.readBase64', { path: remotePath });
if (file.exitCode !== 0) {
throw new Error(`Could not download ${remotePath}: ${file.stderr.trim()}`);
}
base64 = file.stdout;
defaultName = path.basename(remotePath);
}
const outputIsFolder = outputPath
? (await fs.stat(outputPath).catch(() => null))?.isDirectory()
: false;
const generatedName = outputIsFolder || !outputPath ? defaultName : undefined;
const target = generatedName ? path.join(outputPath ?? '.', generatedName) : outputPath;
if (!target) {
throw new Error('Pass --output with a file path to save the file.');
}
try {
await fs.writeFile(target, Buffer.from(base64, 'base64'), {
flag: target === outputPath ? 'w' : 'wx',
});
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') {
throw new Error(`${target} already exists. Pass --output with a new path to save the file.`);
}
throw error;
}
return path.resolve(target);
}
@@ -1,172 +0,0 @@
import { stripVTControlCharacters } from 'util';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { parseLogLines } from '../commandUtils/logs/parseLogs';
import { RawLogLine } from '../commandUtils/logs/types';
import fetch from '../fetch';
import { DeviceRunSessionQuery } from '../graphql/queries/DeviceRunSessionQuery';
import Log, { learnMore } from '../log';
import { sanitizeTerminalText } from '../utils/terminalText';
export type SimulatorSessionError = {
errorCode: string;
message: string;
docsUrl?: string | null;
};
export type FetchSimulatorSessionErrorsOptions = {
/** Aborts the lookup, e.g. on Ctrl+C. */
signal?: AbortSignal;
queryTimeoutMs?: number;
logDownloadTimeoutMs?: number;
maxLogSizeBytes?: number;
};
// ErrorCode.UNKNOWN_ERROR and ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR from @expo/eas-build-job.
const UNKNOWN_ERROR_CODES = new Set(['UNKNOWN_ERROR', 'UNKNOWN_CUSTOM_BUILD_ERROR']);
const DEFAULT_QUERY_TIMEOUT_MS = 10_000;
const DEFAULT_LOG_DOWNLOAD_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_LOG_SIZE_BYTES = 20 * 1024 * 1024;
const MAX_LOGGED_ERRORS = 4;
const MAX_LOGGED_ERROR_LENGTH = 500;
// Bunyan's "error" level.
const ERROR_LOG_LEVEL = 50;
const STDERR_CAUSE_PATTERN = /\b(error|exception|fatal)\b/i;
type JobRunLogLine = RawLogLine & { level?: number; source?: string };
// Returns why the session's job run failed, or an empty list when that is not available.
export async function fetchSimulatorSessionErrorsAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionId: string,
{
signal,
queryTimeoutMs = DEFAULT_QUERY_TIMEOUT_MS,
logDownloadTimeoutMs = DEFAULT_LOG_DOWNLOAD_TIMEOUT_MS,
maxLogSizeBytes = DEFAULT_MAX_LOG_SIZE_BYTES,
}: FetchSimulatorSessionErrorsOptions = {}
): Promise<SimulatorSessionError[]> {
if (signal?.aborted) {
return [];
}
let jobRun;
try {
jobRun = await DeviceRunSessionQuery.jobRunErrorsByIdAsync(graphqlClient, deviceRunSessionId, {
signal: withTimeout(signal, queryTimeoutMs),
});
} catch (err) {
Log.debug(`Failed to fetch simulator session errors: ${err}`);
return [];
}
const errors = jobRun?.errors ?? [];
// Uncategorized failures only say "Unknown error. See logs", so read the real cause from the job run logs.
const logFileUrl = jobRun?.logFileUrls[0];
if (
logFileUrl &&
errors.length > 0 &&
errors.every(error => UNKNOWN_ERROR_CODES.has(error.errorCode))
) {
const logErrors = await fetchJobRunLogErrorsAsync(logFileUrl, {
signal: withTimeout(signal, logDownloadTimeoutMs),
maxLogSizeBytes,
});
if (logErrors.length > 0) {
return logErrors.map(message => ({ errorCode: errors[0].errorCode, message, docsUrl: null }));
}
}
return errors.map(({ errorCode, message, docsUrl }) => ({
errorCode,
message: formatMessage(message),
docsUrl,
}));
}
export function formatSimulatorSessionError(error: SimulatorSessionError): string {
return `${error.message}${error.docsUrl ? ` ${learnMore(error.docsUrl)}` : ''}`;
}
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
}
async function fetchJobRunLogErrorsAsync(
logFileUrl: string,
{ signal, maxLogSizeBytes }: { signal: AbortSignal; maxLogSizeBytes: number }
): Promise<string[]> {
// node-fetch stops reading on its own only when the body ends, so abort to close the connection
// after an error, an oversized body or a timeout.
const controller = new AbortController();
try {
const response = await fetch(logFileUrl, {
size: maxLogSizeBytes,
signal: AbortSignal.any([signal, controller.signal]),
});
const { logLines } = parseLogLines(await response.text());
return selectFailedStepErrors(logLines);
} catch (err) {
// node-fetch's error messages include the signed log URL, so leave the message out.
Log.debug(
`Failed to read simulator session logs: ${err instanceof Error ? err.name : 'unknown error'}`
);
return [];
} finally {
controller.abort();
}
}
// Describes the steps that failed, the first one first: its last error line, preceded by the stderr
// line that names the cause and the line after it. Later failed steps only add their error line.
// Uses the log message rather than err.message because only the message has secrets masked.
export function selectFailedStepErrors(logLines: JobRunLogLine[]): string[] {
const failedStepIds = [
...new Set(
logLines
.filter(line => line.marker === 'end-step' && line.result === 'fail' && line.buildStepId)
.map(line => line.buildStepId as string)
),
];
const messages = failedStepIds.flatMap((stepId, index) => {
const lines = selectStepFailureLines(logLines.filter(line => line.buildStepId === stepId));
return (index === 0 ? lines : lines.slice(-1)).map(formatLogLine);
});
return [...new Set(messages)].slice(0, MAX_LOGGED_ERRORS);
}
function selectStepFailureLines(stepLines: JobRunLogLine[]): JobRunLogLine[] {
let selected: JobRunLogLine[] = [];
let stderrCause: JobRunLogLine[] = [];
for (const [index, line] of stepLines.entries()) {
const text = toPlainText(line.msg ?? '');
if (line.source === 'stderr') {
if (STDERR_CAUSE_PATTERN.test(text)) {
stderrCause = [line];
} else if (stderrCause.length === 1 && stepLines[index - 1] === stderrCause[0]) {
stderrCause.push(line);
}
} else if (line.err && (line.level ?? 0) >= ERROR_LOG_LEVEL && text) {
selected = [...stderrCause, line];
stderrCause = [];
}
}
return selected;
}
function formatLogLine(line: JobRunLogLine): string {
return formatMessage(
line.buildStepDisplayName ? `${line.buildStepDisplayName}: ${line.msg}` : String(line.msg)
);
}
// Log text comes from commands run in the session, so strip escape sequences and control
// characters that could clear the terminal or spoof output.
function toPlainText(message: string): string {
return sanitizeTerminalText(stripVTControlCharacters(message));
}
function formatMessage(message: string): string {
const text = toPlainText(message);
return text.length > MAX_LOGGED_ERROR_LENGTH
? `${text.slice(0, MAX_LOGGED_ERROR_LENGTH)}…`
: text;
}
@@ -0,0 +1,133 @@
import WebSocket from 'ws';
import fetch, { httpsProxyAgent } from '../fetch';
import { DeviceRunSessionRemoteConfig } from './utils';
const SERVE_SIM_TOKEN_SUBPROTOCOL_PREFIX = 'serve-sim.token.';
const DEFAULT_ACTION_TIMEOUT_MS = 60_000;
export type ServeSimHost = {
apiUrl: string;
token: string;
};
export type ServeSimHostActionResult = {
stdout: string;
stderr: string;
};
export class UnsupportedServeSimHostActionError extends Error {}
export function getServeSimHost(remoteConfig: DeviceRunSessionRemoteConfig): ServeSimHost | null {
switch (remoteConfig.__typename) {
case 'ServeSimRunSessionRemoteConfig':
case 'WebPreviewOnlyRunSessionRemoteConfig':
return remoteConfig.previewApiUrl && remoteConfig.previewToken
? { apiUrl: remoteConfig.previewApiUrl, token: remoteConfig.previewToken }
: null;
case 'AgentDeviceRunSessionRemoteConfig':
case 'ArgentRunSessionRemoteConfig':
case 'AppiumRunSessionRemoteConfig':
return remoteConfig.previewApiUrl && remoteConfig.webPreviewToken
? { apiUrl: remoteConfig.previewApiUrl, token: remoteConfig.webPreviewToken }
: null;
}
}
export async function getServeSimDeviceUdidAsync(host: ServeSimHost): Promise<string> {
const response = await fetch(resolveServeSimUrl(host.apiUrl, 'api').toString(), {
headers: { Authorization: `Bearer ${host.token}` },
});
const state = (await response.json()) as { device?: unknown } | null;
if (typeof state?.device !== 'string' || state.device.length === 0) {
throw new Error('The simulator session has no running simulator.');
}
return state.device;
}
export async function runServeSimHostActionAsync(
host: ServeSimHost,
action: string,
params: Record<string, unknown>,
{ timeoutMs = DEFAULT_ACTION_TIMEOUT_MS }: { timeoutMs?: number } = {}
): Promise<ServeSimHostActionResult> {
const url = resolveServeSimUrl(host.apiUrl, 'exec-ws');
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return await new Promise<ServeSimHostActionResult>((resolve, reject) => {
const socket = new WebSocket(
url.toString(),
[SERVE_SIM_TOKEN_SUBPROTOCOL_PREFIX + host.token],
{
...(httpsProxyAgent ? { agent: httpsProxyAgent } : {}),
}
);
let settled = false;
let sent = false;
const finish = (error: Error | null, result?: ServeSimHostActionResult): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
socket.terminate();
if (error) {
reject(error);
} else {
resolve(result!);
}
};
const timer = setTimeout(() => {
finish(new Error(`Timed out waiting for the simulator host to run ${action}.`));
}, timeoutMs);
socket.on('error', error => {
finish(new Error(`Could not connect to the simulator host: ${error.message}`));
});
socket.on('close', () => {
finish(
new Error(
sent
? `The simulator host closed the connection before ${action} finished.`
: 'The simulator host rejected the session token.'
)
);
});
socket.on('message', data => {
let reply: any;
try {
reply = JSON.parse(data.toString());
} catch {
finish(new Error('The simulator host sent an invalid response.'));
return;
}
if (reply?.ready === true && !sent) {
sent = true;
socket.send(JSON.stringify({ id: 1, action, params }));
return;
}
if (reply?.id !== 1 || !sent) {
return;
}
if (typeof reply.error === 'string') {
finish(
reply.error === `unknown action ${action}`
? new UnsupportedServeSimHostActionError(
`This simulator session does not support ${action} yet. Start a new session to use it.`
)
: new Error(`${action} failed: ${reply.error}`)
);
return;
}
const stdout = typeof reply.stdout === 'string' ? reply.stdout : '';
const stderr = typeof reply.stderr === 'string' ? reply.stderr : '';
if (reply.exitCode !== 0) {
finish(new Error(`${action} failed: ${stderr.trim() || `exit code ${reply.exitCode}`}`));
return;
}
finish(null, { stdout, stderr });
});
});
}
function resolveServeSimUrl(apiUrl: string, route: string): URL {
return new URL(route, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
}
+34
View File
@@ -0,0 +1,34 @@
export type SimctlHostAction = {
action: string;
params: Record<string, unknown>;
};
const SPRINGBOARD_RESTART_ARGS = [
'spawn',
'launchctl',
'kickstart',
'-k',
'system/com.apple.SpringBoard',
];
export const SUPPORTED_SIMCTL_COMMANDS = [
'ui appearance <light|dark>',
SPRINGBOARD_RESTART_ARGS.join(' '),
];
export function resolveSimctlHostAction(args: string[]): SimctlHostAction | null {
if (args.length === 3 && args[0] === 'ui' && args[1] === 'appearance') {
const value = args[2];
if (value === 'light' || value === 'dark') {
return { action: 'appearance.set', params: { value } };
}
return null;
}
if (
args.length === SPRINGBOARD_RESTART_ARGS.length &&
args.every((arg, index) => arg === SPRINGBOARD_RESTART_ARGS[index])
) {
return { action: 'springboard.restart', params: {} };
}
return null;
}
-19
View File
@@ -142,25 +142,6 @@ export function getLocalEgressConfig(
};
}
export type PreviewApiAccess = {
apiUrl: string;
token: string;
};
export function getPreviewApiAccess(
remoteConfig: DeviceRunSessionRemoteConfig
): PreviewApiAccess | null {
const token =
remoteConfig.__typename === 'ServeSimRunSessionRemoteConfig' ||
remoteConfig.__typename === 'WebPreviewOnlyRunSessionRemoteConfig'
? remoteConfig.previewToken
: remoteConfig.webPreviewToken;
if (!remoteConfig.previewApiUrl || !token) {
return null;
}
return { apiUrl: remoteConfig.previewApiUrl, token };
}
/** The worker relay that the Android emulator's proxy points at; see build-tools androidLocalEgress.ts. */
export const ANDROID_EMULATOR_EGRESS_RELAY_PORT = 8898;
-8
View File
@@ -1,13 +1,5 @@
# @expo/eas-json
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "25.3.0",
"version": "25.1.1",
"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": "25.3.0",
"@expo/eas-build-job": "25.1.1",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
-8
View File
@@ -1,13 +1,5 @@
# eas-cli-local-build-plugin
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "25.3.0",
"version": "25.1.1",
"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": "25.3.0",
"@expo/build-tools": "25.1.1",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "25.3.0",
"@expo/logger": "25.3.0",
"@expo/eas-build-job": "25.1.1",
"@expo/logger": "25.1.1",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "25.3.0",
"@expo/turtle-spawn": "25.1.1",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
"fs-extra": "^11.2.0",
-8
View File
@@ -1,13 +1,5 @@
# @expo/logger
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/logger",
"version": "25.3.0",
"version": "25.1.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
-8
View File
@@ -1,13 +1,5 @@
# @expo/steps
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "25.3.0",
"version": "25.1.1",
"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": "25.3.0",
"@expo/logger": "25.3.0",
"@expo/eas-build-job": "25.1.1",
"@expo/logger": "25.1.1",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
"fast-glob": "^3.3.2",
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more