Compare commits

..
144 changed files with 1295 additions and 9131 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.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
+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
@@ -1,5 +0,0 @@
---
'eas-cli': none
---
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
-6
View File
@@ -1,6 +0,0 @@
---
'eas-cli': none
'@expo/build-tools': none
---
Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases).
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Stop sandboxes after an optional maximum duration measured from readiness.
+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.
-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"
}
+1 -1
View File
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
actionlint = "1.7.12"
gcloud = "570.0.0"
jq = "1.8.1"
node = "22.23.4"
node = "22.23.3"
-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
+8 -8
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]>",
@@ -26,7 +26,7 @@
"build:egress-guard": "resources/egress-guard/build.sh",
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
"typecheck": "tsc",
"generate-appium-commands": "mise exec [email protected]3.4 -- node scripts/generate-appium-commands.js",
"generate-appium-commands": "mise exec [email protected]2.0 -- node scripts/generate-appium-commands.js",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
@@ -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: () => '',
@@ -11,7 +11,6 @@ import * as tar from 'tar';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Datadog } from '../../../datadog';
import * as files from '../../../utils/files';
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
@@ -31,17 +30,11 @@ const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.
function createMockGraphqlClient({
applicationArchiveUrl,
error,
transientError,
}: {
applicationArchiveUrl?: string | null;
error?: Error;
transientError?: Error;
}): Client {
const toPromise = jest.fn();
if (transientError) {
toPromise.mockResolvedValueOnce({ error: transientError, data: undefined });
}
toPromise.mockResolvedValue(
const toPromise = jest.fn().mockResolvedValue(
error
? { error, data: undefined }
: {
@@ -69,7 +62,7 @@ function createSuccessfulResponse({
url,
headers,
}: {
body: Buffer | Readable;
body: Buffer;
url: string;
headers?: Record<string, string>;
}): Response {
@@ -79,7 +72,7 @@ function createSuccessfulResponse({
return {
ok: true,
body: body instanceof Readable ? body : Readable.from(body),
body: Readable.from(body),
url,
headers: {
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
@@ -87,47 +80,6 @@ function createSuccessfulResponse({
} as unknown as Response;
}
function createUndiciNetworkError(code: string): CombinedError {
return new CombinedError({
networkError: Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error(`read ${code}`), { code }),
}),
});
}
const { FetchError: ActualFetchError } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
function createSocketFetchError(url: string, code: string): Error {
return new ActualFetchError(`request to ${url} failed, reason: connect ${code}`, 'system', {
code,
} as any);
}
function useFakeBackoffTimers(): void {
// Streams and fs callbacks need the real nextTick and setImmediate; only the backoff timers are faked.
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
}
const MAX_IO_TURNS_BEFORE_BACKOFF = 1000;
async function waitForBackoffTimerAsync(): Promise<void> {
for (let turns = 0; jest.getTimerCount() === 0; turns++) {
if (turns === MAX_IO_TURNS_BEFORE_BACKOFF) {
throw new Error('No backoff timer was scheduled.');
}
await new Promise(resolve => setImmediate(resolve));
}
}
async function fireBackoffTimersAsync(count: number): Promise<void> {
for (let i = 0; i < count; i++) {
await waitForBackoffTimerAsync();
await jest.advanceTimersToNextTimerAsync();
}
}
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
@@ -156,11 +108,6 @@ async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
}
}
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
describe('downloadBuild', () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
@@ -518,13 +465,13 @@ describe('downloadBuild', () => {
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError with the cause code after retrying a failing build lookup', async () => {
useFakeBackoffTimers();
it('throws SystemError when GraphQL request fails with a network error', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: createUndiciNetworkError('ECONNRESET'),
error: new CombinedError({
networkError: new Error('Network request failed'),
}),
});
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
@@ -533,81 +480,19 @@ describe('downloadBuild', () => {
robotAccessToken: null,
extensions: ['app'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [Network] fetch failed (ECONNRESET)`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup failed on attempt 6', {
event: 'network_retry',
operation: 'download_build.build_lookup',
outcome: 'failed',
attempts: '6',
code: 'ECONNRESET',
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it.each([
['a network error', createUndiciNetworkError('ECONNRESET'), 'ECONNRESET'],
[
'a transient GraphQL error',
new CombinedError({
graphQLErrors: [
{
message: 'Too many requests',
extensions: { isTransient: true, errorCode: 'TOO_MANY_REQUESTS' },
},
],
response: { status: 200 },
}),
'TOO_MANY_REQUESTS',
],
])('downloads after a build lookup that failed once with %s', async (_, transientError, code) => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
transientError,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(path.basename(artifactPath)).toBe('app.apk');
expect(graphqlClient.query).toHaveBeenCalledTimes(2);
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup recovered on attempt 2', {
event: 'network_retry',
operation: 'download_build.build_lookup',
outcome: 'recovered',
attempts: '2',
code,
});
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
graphQLErrors: [{ message: 'Internal Server Error' }],
error: Object.assign(new Error('Internal Server Error'), {
response: { status: 500 },
}),
});
@@ -619,14 +504,12 @@ describe('downloadBuild', () => {
robotAccessToken: null,
extensions: ['app'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [GraphQL] Internal Server Error`,
message: `Could not fetch build ${buildId}: Internal Server Error`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
@@ -651,193 +534,10 @@ describe('downloadBuild', () => {
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(1);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('downloads after an archive request that failed once with a socket error', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(path.basename(artifactPath)).toBe('app.apk');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
});
it('throws SystemError with the cause code after retrying a failing build archive request', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ETIMEDOUT'));
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not download the application archive: request to ${APPLICATION_ARCHIVE_URL} failed, reason: connect ETIMEDOUT`,
});
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith('download_build.archive_download failed on attempt 6', {
event: 'network_retry',
operation: 'download_build.archive_download',
outcome: 'failed',
attempts: '6',
code: 'ETIMEDOUT',
});
});
function createBrokenBody(code: string): Readable {
return Readable.from(
(async function* () {
yield Buffer.from('he');
throw Object.assign(new Error(`body failed with ${code}`), { code });
})()
);
}
it.each(['ERR_STREAM_PREMATURE_CLOSE', 'ECONNRESET'])(
'downloads again when the archive body breaks off with %s after the headers arrived',
async code => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
const brokenBody = createBrokenBody(code);
jest
.mocked(fetch)
.mockResolvedValueOnce(
createSuccessfulResponse({ body: brokenBody, url: 'https://cdn.test/app.apk' })
)
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
expect(datadogLog).toHaveBeenCalledWith(
'download_build.archive_download recovered on attempt 2',
expect.objectContaining({ outcome: 'recovered', code })
);
}
);
it('throws SystemError when the build archive body keeps breaking off', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockImplementation(async () =>
createSuccessfulResponse({
body: createBrokenBody('ECONNRESET'),
url: 'https://cdn.test/app.apk',
})
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toMatchObject({
message: 'Could not download the application archive: body failed with ECONNRESET',
});
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith(
'download_build.archive_download failed on attempt 6',
expect.objectContaining({ outcome: 'failed', code: 'ECONNRESET' })
);
});
it('does not start another archive attempt after an abort during the backoff', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'));
const controller = new AbortController();
const reason = new Error('step timed out');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
signal: controller.signal,
});
const rejection = expect(promise).rejects.toBe(reason);
await waitForBackoffTimerAsync();
controller.abort(reason);
await jest.advanceTimersToNextTimerAsync();
await rejection;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
});
it('rethrows the raw error of a failing user-supplied archive URL after the retries', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ENOTFOUND'));
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(ActualFetchError);
await fireBackoffTimersAsync(5);
await rejection;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
});
it('should throw an error if no matching files are found', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
@@ -905,21 +605,6 @@ describe('createDownloadBuildFunction', () => {
};
}
function createApplicationResponse(buildId: string): Response {
return new ActualResponse(
JSON.stringify({
data: {
build: {
id: buildId,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
}),
{ status: 200 }
);
}
function createStep(ctx: any, callInputs: Record<string, unknown>) {
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
createGlobalContextMock({
@@ -935,7 +620,18 @@ describe('createDownloadBuildFunction', () => {
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockImplementation(async url => {
if (url === workerApiUrl) {
return createApplicationResponse(buildId);
return new ActualResponse(
JSON.stringify({
data: {
build: {
id: buildId,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
}),
{ status: 200 }
);
}
return {
ok: false,
@@ -965,33 +661,6 @@ describe('createDownloadBuildFunction', () => {
expect(graphqlClient.query).not.toHaveBeenCalled();
});
it('retries the worker API lookup after a 5xx response', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockResolvedValueOnce(new ActualResponse('Service Unavailable', { status: 503 }))
.mockResolvedValueOnce(createApplicationResponse(buildId))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const execution = createStep(createSessionCtx(graphqlClient), {
build_id: buildId,
extensions: ['apk'],
}).executeAsync();
await fireBackoffTimersAsync(1);
await execution;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(3);
expect(jest.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([
workerApiUrl,
workerApiUrl,
APPLICATION_ARCHIVE_URL,
]);
});
it('does not call the worker API for an application_archive_url input', async () => {
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockResolvedValue({
@@ -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() };
@@ -1,4 +1,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { SystemError } from '@expo/eas-build-job';
import { Client, fetchExchange } from '@urql/core';
import * as sandboxDaemon from '../../utils/sandboxDaemon';
@@ -6,90 +6,6 @@ import * as sandboxDaemon from '../../utils/sandboxDaemon';
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
describe('sandbox build functions', () => {
it.each([
{ completion: 'duration', duration: 0.02 },
{ completion: 'cancellation', duration: 60 },
{ completion: 'duration', duration: 0.0001 },
])(
'stops after $completion with $duration seconds only after readiness',
async ({ completion, duration }) => {
const controller = new AbortController();
let ready!: () => void;
const stopAsync = jest.fn(async () => {});
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
ready: new Promise<void>(resolve => {
ready = resolve;
}),
stopAsync,
});
const mutation = jest.fn(() => ({ toPromise: async () => ({ data: {} }) }));
const logger = { info: jest.fn(), child: jest.fn() };
logger.child.mockReturnValue(logger);
const fn = createStartSandboxBuildFunction({
mcpServerUrl: 'ws://localhost:8787',
graphqlClient: { mutation },
} as any);
try {
const pending = fn.fn!({ logger } as any, {
inputs: {
sandbox_id: { value: 'sandbox-id' },
max_duration_seconds: { value: duration },
},
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
signal: controller.signal,
});
await new Promise(resolve => setTimeout(resolve, 40));
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).not.toHaveBeenCalled();
ready();
if (completion === 'cancellation') {
await new Promise(resolve => setImmediate(resolve));
expect(stopAsync).not.toHaveBeenCalled();
controller.abort();
}
await pending;
expect(mutation).toHaveBeenCalledTimes(1);
expect(stopAsync).toHaveBeenCalledTimes(1);
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
if (completion === 'duration') {
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
} else {
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
}
} finally {
start.mockRestore();
}
}
);
it.each([0, -1, Infinity, NaN])(
'rejects invalid maximum duration %s before starting the daemon',
async duration => {
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
const fn = createStartSandboxBuildFunction({
mcpServerUrl: 'ws://localhost:8787',
} as any);
try {
const result = fn.fn!({} as any, {
inputs: {
sandbox_id: { value: 'sandbox-id' },
max_duration_seconds: { value: duration },
},
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
});
await expect(result).rejects.toBeInstanceOf(UserError);
await expect(result).rejects.toThrow(
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
expect(start).not.toHaveBeenCalled();
} finally {
start.mockRestore();
}
}
);
it('aborts an active ready request and keeps its authentication headers', async () => {
const controller = new AbortController();
let requestStarted!: () => void;
@@ -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 }))
@@ -7,7 +7,7 @@ import {
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { Client, CombinedError } from '@urql/core';
import { Client } from '@urql/core';
import contentDisposition from 'content-disposition';
import { glob } from 'fast-glob';
import { graphql } from 'gql.tada';
@@ -26,16 +26,10 @@ import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import {
describeError,
isTransferFailure,
isTransientGraphqlError,
} from '../../utils/networkErrors';
import { retryOnNetworkFailureAsync } from '../../utils/retryOnNetworkFailure';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import { pluralize } from '../../utils/strings';
import {
type DeviceRunSessionWorkerApi,
DeviceRunSessionWorkerApiError,
getDeviceRunSessionWorkerApi,
} from '../utils/deviceRunSessionWorkerApi';
@@ -158,34 +152,21 @@ async function fetchApplicationArchiveUrlAsync({
signal,
});
}
let data;
try {
data = await retryOnNetworkFailureAsync(
async () => {
const queryResult = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
if (queryResult.error) {
throw queryResult.error;
}
return queryResult.data;
},
{
operation: 'download_build.build_lookup',
signal,
isRetryable: error => error instanceof CombinedError && isTransientGraphqlError(error),
}
);
} catch (error) {
signal?.throwIfAborted();
const message = `Could not fetch build ${buildId}: ${describeError(error)}`;
throw error instanceof CombinedError && !isTransientGraphqlError(error)
? new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error })
: new SystemError(message, { cause: error });
}
const result = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
signal?.throwIfAborted();
const applicationArchiveUrl = data?.builds.byId?.artifacts?.applicationArchiveUrl;
if (result.error) {
const { error } = result;
const message = `Could not fetch build ${buildId}: ${error.message}`;
throw error.networkError || result.error.response?.status >= 500
? new SystemError(message, { cause: error })
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
}
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
if (!applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
@@ -214,18 +195,13 @@ async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
}): Promise<string> {
let application;
try {
application = await retryOnNetworkFailureAsync(
() => deviceRunSessionWorkerApi.getApplicationAsync({ signal }),
{
operation: 'download_build.session_build_lookup',
signal,
isRetryable: error => error instanceof DeviceRunSessionWorkerApiError && error.isTransient,
}
);
application = await deviceRunSessionWorkerApi.getApplicationAsync({ signal });
} catch (err) {
signal?.throwIfAborted();
throw new SystemError(
`Could not fetch build ${buildId} for this device run session: ${describeError(err)}`,
`Could not fetch build ${buildId} for this device run session: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
@@ -305,34 +281,18 @@ export async function downloadBuildAsync(
);
signal?.throwIfAborted();
let archivePath: string;
try {
archivePath = await retryOnNetworkFailureAsync(
async () => {
const response = await fetch(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
throw new Error(
`Unexpected response from server (${response.status}): ${textResult.value}`
);
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
const attemptArchivePath = path.join(downloadDestinationDirectory, archiveFilename);
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
await streamPipeline(response.body, fs.createWriteStream(attemptArchivePath));
return attemptArchivePath;
},
{ operation: 'download_build.archive_download', signal, isRetryable: isTransferFailure }
);
} catch (error) {
signal?.throwIfAborted();
if (params.buildId && isTransferFailure(error)) {
throw new SystemError(`Could not download the application archive: ${describeError(error)}`, {
cause: error,
});
}
throw error;
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
await streamPipeline(response.body, fs.createWriteStream(archivePath));
signal?.throwIfAborted();
const { size } = await fs.promises.stat(archivePath);
@@ -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,4 +1,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { SystemError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
@@ -32,11 +32,6 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async (stepCtx, { inputs, signal, env }) => {
// The daemon needs this credential, but shell commands do not.
@@ -49,18 +44,6 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
}
const sandboxId = String(inputs.sandbox_id.value);
const maxDurationSeconds = inputs.max_duration_seconds?.value;
if (
maxDurationSeconds !== undefined &&
(typeof maxDurationSeconds !== 'number' ||
!Number.isFinite(maxDurationSeconds) ||
maxDurationSeconds <= 0)
) {
throw new UserError(
'EAS_SANDBOX_INVALID_DURATION',
`Invalid sandbox maximum duration: ${String(maxDurationSeconds)}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
}
const daemon = await startSandboxDaemonAsync({
credential: sandboxToken,
serverUrl: mcpServerUrl,
@@ -80,21 +63,7 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
stepCtx.logger.info('Sandbox daemon started.');
await withLogPhaseAsync(stepCtx.logger, AWAIT_SANDBOX_STEP_NAME, async () => {
const timeoutSignal =
maxDurationSeconds === undefined
? undefined
: AbortSignal.timeout(Math.ceil(maxDurationSeconds * 1000));
await waitForCompletionAsync(
AbortSignal.any([
...(signal ? [signal] : []),
...(timeoutSignal ? [timeoutSignal] : []),
])
);
if (timeoutSignal?.aborted) {
stepCtx.logger.info(
`Sandbox reached its maximum runtime of ${maxDurationSeconds} seconds. Stopping.`
);
}
await waitForCancellationAsync(signal);
});
} finally {
await daemon.stopAsync();
@@ -120,11 +89,11 @@ export async function markSandboxReadyAsync(
}
}
async function waitForCompletionAsync(signal: AbortSignal): Promise<void> {
if (signal.aborted) {
async function waitForCancellationAsync(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return;
}
await new Promise<void>(resolve => {
signal.addEventListener('abort', () => resolve(), { once: true });
signal?.addEventListener('abort', () => resolve(), { once: true });
});
}
@@ -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}.`
);
});
@@ -330,8 +330,7 @@ describe(DeviceRunSessionWorkerApi, () => {
});
it('reports a request that failed before www responded', async () => {
const reason = new Error('socket hang up');
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
jest.mocked(turtleFetch).mockRejectedValueOnce(new Error('socket hang up'));
const error = await createApi()
.getSessionAsync()
@@ -339,7 +338,6 @@ describe(DeviceRunSessionWorkerApi, () => {
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
expect(error.status).toBeUndefined();
expect(error.cause).toBe(reason);
expect(error.message).toContain('failed before www responded: socket hang up');
});
@@ -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));
@@ -88,16 +88,11 @@ export class DeviceRunSessionWorkerApiError extends Error {
constructor(
message: string,
/** The HTTP status of the response, or undefined when no response arrived. */
readonly status: number | undefined,
options?: ErrorOptions
readonly status: number | undefined
) {
super(message, options);
super(message);
this.name = 'DeviceRunSessionWorkerApiError';
}
get isTransient(): boolean {
return this.status === undefined || this.status >= 500;
}
}
/**
@@ -223,8 +218,7 @@ export class DeviceRunSessionWorkerApi {
`${routeDescription} failed before www responded: ${
reason instanceof Error ? reason.message : String(reason)
}. Check the network connection of the worker.`,
undefined,
{ cause: reason }
undefined
);
}
const response = responseResult.value;
@@ -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,16 +1,4 @@
import { CombinedError } from '@urql/core';
import {
describeError,
findErrorCode,
findGraphqlErrorCode,
isConnectFailure,
isConnectionInterruptedError,
isTransferFailure,
isTransientGraphqlError,
} from '../networkErrors';
const { FetchError } = jest.requireActual('node-fetch') as typeof import('node-fetch');
import { isConnectFailure, isConnectionInterruptedError } from '../networkErrors';
it.each([
'EAI_AGAIN',
@@ -46,77 +34,3 @@ it.each(['ETIMEDOUT', 'CERT_HAS_EXPIRED', undefined])('does not automatically re
expect(isConnectFailure(error)).toBe(false);
expect(isConnectionInterruptedError(error)).toBe(false);
});
it('finds the errno code on the cause of an undici fetch failure', () => {
const error = new CombinedError({
networkError: new TypeError('fetch failed', {
cause: Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }),
}),
});
expect(findErrorCode(error)).toBe('ECONNRESET');
expect(findErrorCode(new Error('Request failed'))).toBeUndefined();
expect(findErrorCode('Request failed')).toBeUndefined();
});
it('finds the www error code on a GraphQL error', () => {
const error = new CombinedError({
graphQLErrors: [
{ message: 'Too many requests', extensions: { errorCode: 'TOO_MANY_REQUESTS' } },
],
});
expect(findGraphqlErrorCode(error)).toBe('TOO_MANY_REQUESTS');
expect(
findGraphqlErrorCode(new CombinedError({ graphQLErrors: ['Build not found'] }))
).toBeUndefined();
expect(findGraphqlErrorCode(new Error('Request failed'))).toBeUndefined();
});
it.each([
['Request failed', 'ECONNRESET', 'Request failed (ECONNRESET)'],
['read ECONNRESET', 'ECONNRESET', 'read ECONNRESET'],
['Request failed', undefined, 'Request failed'],
])('describes %p with code %p as %p', (message, code, expected) => {
expect(describeError(Object.assign(new Error(message), { code }))).toBe(expected);
});
it('treats a missing response, a 5xx response or an isTransient GraphQL error as transient', () => {
expect(
isTransientGraphqlError(new CombinedError({ networkError: new Error('fetch failed') }))
).toBe(true);
expect(
isTransientGraphqlError(
new CombinedError({ graphQLErrors: ['Internal Server Error'], response: { status: 503 } })
)
).toBe(true);
expect(
isTransientGraphqlError(
new CombinedError({
graphQLErrors: [{ message: 'Database unavailable', extensions: { isTransient: true } }],
response: { status: 200 },
})
)
).toBe(true);
expect(
isTransientGraphqlError(
new CombinedError({ graphQLErrors: ['Build not found'], response: { status: 200 } })
)
).toBe(false);
});
it.each([
[
'a node-fetch socket failure',
new FetchError('request failed', 'system', { code: 'ECONNRESET' } as any),
true,
],
['a node-fetch body size failure', new FetchError('too large', 'max-size'), false],
['a body reset', Object.assign(new Error('aborted'), { code: 'ECONNRESET' }), true],
[
'a body closed early',
Object.assign(new Error('closed'), { code: 'ERR_STREAM_PREMATURE_CLOSE' }),
true,
],
['an HTTP error response', new Error('Unexpected response from server (500)'), false],
])('classifies %s as a transfer failure or not', (_label, error, expected) => {
expect(isTransferFailure(error)).toBe(expected);
});
@@ -1,5 +1,3 @@
import { CombinedError } from '@urql/core';
const CONNECT_FAILURE_CODES = new Set([
'EAI_AGAIN',
'ECONNREFUSED',
@@ -32,50 +30,3 @@ export function isConnectionInterruptedError(error: unknown): boolean {
}
return false;
}
/** Errno code of the error or its causes; undici keeps it on cause, urql on networkError. */
export function findErrorCode(error: unknown): string | undefined {
let current: unknown = error;
while (current instanceof Error) {
if ('code' in current && typeof current.code === 'string') {
return current.code;
}
current = current instanceof CombinedError ? current.networkError : current.cause;
}
return undefined;
}
export function findGraphqlErrorCode(error: unknown): string | undefined {
if (!(error instanceof CombinedError)) {
return undefined;
}
const code = error.graphQLErrors.at(0)?.extensions.errorCode;
return typeof code === 'string' ? code : undefined;
}
export function describeError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const code = findErrorCode(error);
return code && !message.includes(code) ? `${message} (${code})` : message;
}
export function isTransientGraphqlError(error: CombinedError): boolean {
return (
Boolean(error.networkError) ||
error.response?.status >= 500 ||
error.graphQLErrors.some(graphqlError => graphqlError.extensions.isTransient === true)
);
}
// node-fetch throws type 'system' for a socket failure before the headers; after them it forwards
// ECONNRESET or ERR_STREAM_PREMATURE_CLOSE to the body. HTTP error responses never throw.
export function isTransferFailure(error: unknown): boolean {
return (
(error instanceof Error &&
error.name === 'FetchError' &&
'type' in error &&
error.type === 'system') ||
isConnectionInterruptedError(error) ||
findErrorCode(error) === 'ERR_STREAM_PREMATURE_CLOSE'
);
}
@@ -1,53 +0,0 @@
import { Datadog } from '../datadog';
import { findErrorCode, findGraphqlErrorCode } from './networkErrors';
import { promiseRetryWithCondition } from './promiseRetryWithCondition';
export async function retryOnNetworkFailureAsync<T>(
attemptAsync: () => Promise<T>,
{
operation,
signal,
isRetryable,
}: {
operation: string;
signal?: AbortSignal;
isRetryable: (error: unknown) => boolean;
}
): Promise<T> {
const shouldRetry = (error: unknown): boolean => !signal?.aborted && isRetryable(error);
let attempts = 1;
let lastError: unknown;
const report = (outcome: 'recovered' | 'failed', error: unknown): void => {
Datadog.log(`${operation} ${outcome} on attempt ${attempts}`, {
event: 'network_retry',
operation,
outcome,
attempts: String(attempts),
code: findErrorCode(error) ?? findGraphqlErrorCode(error) ?? 'unknown',
});
};
try {
const result = await promiseRetryWithCondition(
async () => {
signal?.throwIfAborted();
return await attemptAsync();
},
shouldRetry,
{ retries: 5, minTimeout: 1_000, factor: 2 },
({ attemptNumber, error }) => {
attempts = attemptNumber;
lastError = error;
}
)();
if (attempts > 1) {
report('recovered', lastError);
}
return result;
} catch (error) {
if (attempts > 1 && !signal?.aborted) {
report('failed', error);
}
throw error;
}
}
@@ -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
+6 -7
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",
@@ -252,7 +251,7 @@
},
"update": {
"node": {
"version": "22.23.4"
"version": "22.23.3"
},
"s3": {
"templates": {
@@ -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 -1
View File
@@ -33,7 +33,7 @@ const PROJECT_PAGES: Record<string, string> = {
export default class Browse extends EasCommand {
static override description =
'transition from the terminal to the web browser to view and interact with your project on https://expo.dev';
'Transition from the terminal to the web browser to view and interact with your project on https://expo.dev';
static override args = {
page: Args.string({
+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({
@@ -13,7 +13,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
export default class ChannelDelete extends EasCommand {
static override description = 'delete a channel';
static override description = 'Delete a channel';
static override args = {
name: Args.string({
@@ -50,7 +50,7 @@ type ChannelRolloutArgsAndFlags = {
Partial<CreateRolloutNonInteractiveOptions>;
export default class ChannelRollout extends EasCommand {
static override description = 'roll a new branch out on a channel incrementally';
static override description = 'Roll a new branch out on a channel incrementally.';
static override args = {
channel: Args.string({
@@ -7,7 +7,7 @@ import { SetUpBuildCredentialsCommandAction } from '../../credentials/manager/Se
import { selectPlatformAsync } from '../../platform';
export default class InitializeBuildCredentials extends EasCommand {
static override description = 'set up credentials for building your project';
static override description = 'Set up credentials for building your project.';
static override flags = {
platform: Flags.option({
@@ -22,7 +22,7 @@ interface DeployAliasDeleteFlags {
}
export default class WorkerAliasDelete extends EasCommand {
static override description = 'delete deployment aliases';
static override description = 'Delete deployment aliases.';
static override aliases = ['worker:alias:delete'];
static override state = 'preview';
@@ -39,7 +39,7 @@ interface RawDeployAliasFlags {
}
export default class WorkerAlias extends EasCommand {
static override description = 'assign deployment aliases';
static override description = 'Assign deployment aliases.';
static override aliases = ['worker:alias', 'deploy:promote'];
static override state = 'preview';
@@ -23,7 +23,7 @@ interface RawDeployDeleteFlags {
}
export default class WorkerDelete extends EasCommand {
static override description = 'delete a deployment';
static override description = 'Delete a deployment.';
static override aliases = ['worker:delete'];
static override state = 'preview';
+1 -1
View File
@@ -100,7 +100,7 @@ async function withSuppressedOutputAsync<T>(fn: () => Promise<T>): Promise<T> {
/* eslint-enable no-console */
export default class Go extends EasCommand {
static override description = 'create a custom Expo Go and submit to TestFlight';
static override description = 'Create a custom Expo Go and submit to TestFlight';
static override hidden = true;
static override flags = {
@@ -22,7 +22,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ObserveEvent extends EasCommand {
static override description =
'display a single Observe event (metric, log, or error) by its ID. IDs are included in event data when the `--json` flag is passed to `eas observe:session`, `eas observe:metrics`, or `eas observe:events`';
'display a single Observe event (metric, log, or error) by its ID. IDs are included in event data when the `--json` flag is passed to `eas observe:session`, `eas observe:metrics`, or `eas observe:events`.';
static override args = {
id: Args.string({
@@ -36,7 +36,7 @@ const DEFAULT_EVENTS_LIMIT = 10;
export default class ObserveEvents extends EasCommand {
static override description =
'display individual events emitted by the app via `logEvent`, filtered by the event name in the argument. With no arguments, a list of the available event names and associated event counts is returned';
'display individual events emitted by the app via `logEvent`, filtered by the event name in the argument. With no arguments, a list of the available event names and associated event counts is returned.';
static override args = {
eventName: Args.string({
+1 -1
View File
@@ -134,7 +134,7 @@ export default class New extends EasCommand {
static override aliases = ['new'];
static override description =
'create a new project configured with Expo Application Services (EAS)';
'Create a new project configured with Expo Application Services (EAS)';
static override args = {
path: Args.string({
@@ -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;
}
}
+2 -25
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,
@@ -39,7 +30,7 @@ type DeviceRunSessionArtifact = DeviceRunSessionById['artifacts'][number];
export default class SimulatorGet extends EasCommand {
static override aliases = ['sim:get'];
static override description =
"[EXPERIMENTAL] get info about a remote simulator session on EAS by its simulator session ID. Preview links follow the session's saved sharing preference";
"[EXPERIMENTAL] get info about a remote simulator session on EAS by its simulator session ID. Preview links follow the session's saved sharing preference.";
static override flags = {
id: Flags.string({
@@ -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,
@@ -83,7 +79,7 @@ export default class Simulator extends EasCommand {
static override aliases = ['simulator:start', 'sim:start'];
static override hiddenAliases = ['sim'];
static override description =
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it. The default browser preview link requires sign-in';
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it. The default browser preview link requires sign-in.';
static override flags = {
platform: Flags.option({
@@ -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 ||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more