Compare commits

...
Author SHA1 Message Date
Brent Vatne 72b8a78145 [worker] Apply log masking to uploaded Xcode build logs
Pass the job's log masks to the Xcode build log upload and mask the file
line by line while streaming it, including values written in the escaped
form Xcode uses for build settings.
2026-10-20 16:31:19 -07:00
Brent Vatne 56646b7436 [eas-cli] Show simulator session errors in simulator:get (#4694)
Look up why a failed simulator session failed in both simulator:start and
simulator:get. For unknown errors, read the failed step's cause from the job
run log, using only the masked log message, with the download bounded by size
and time. Strip terminal control characters before printing.
2026-10-20 14:00:29 -07:00
Brent Vatne f775b10549 [eas-cli] Show why a simulator session failed to start (#4693)
* [eas-cli] Show why a simulator session failed to start

simulator:start only printed the session status when a session errored
before it was ready. The reason is already on the session's job run, so
fetch turtleJobRun.errors and print each message (and docs link) on its
own line.

* [eas-cli] Read simulator job run errors only on failure, and from logs for unknown errors

Fetch job run errors with a separate query when a session fails instead of
on every poll of the shared session query. When the job run only reports an
unknown error, print the failed step errors from its logs. Also show the
errors when a session fails after it is ready.

* [eas-cli] Show only structured job run errors in simulator:start

Drop the job run log fallback from this change, which printed unmasked
err.message lines, read logs for stopped sessions and had no timeout or size
limit. The bounded, masked log fallback lands in the simulator:get follow-up.

* [eas-cli] Bound and quiet the simulator job run errors lookup

Give the lookup a 10s deadline and the session's Ctrl+C signal, and cancel
its request by unsubscribing from urql, so it can't hold up stopping the
session. Don't retry it or print the transient-error message, since it's
best-effort. Cap each error message at 500 characters.
2026-10-20 13:51:21 -07:00
Brent Vatne c756c5d0b5 [worker] Mask secrets in every field of uploaded log records (#4698)
* [worker] Mask secrets in logged errors

The upload transform masked only each record's msg, so a serialized err
(message, stack and other fields) was uploaded with secrets intact. That
includes unknown build phase failures, which log { err } with an empty msg.
Mask the strings inside err on a copy, since bunyan shares the record with
other streams.

* [worker] Mask secrets in every field of uploaded log records

Mask all fields except bunyan's metadata, not only err: other fields such as
error, data and the job dump also carried secrets. Round-trip each value
through JSON first so a toJSON method can't bring back the original, mask keys
and numbers, and replace values nested too deeply instead of uploading them
unmasked.
2026-10-20 13:51:17 -07:00
Brent VatneandClaude Opus 5.5 9f674b1d19 [eas-build-job][build-tools] Check Android launch inputs before the session starts (#4696)
* [build-tools] Check Android launch inputs before the emulator boots

Agent-device sessions checked Android launch inputs only when launching,
after the emulator booted and the build installed. Check NUL characters
and the command length at the start of the step, as on iOS, leaving room
for the launch component that is only known after install.

* Add changeset for early Android launch input checks

* [eas-build-job] Share the Android launch input checks

Move the Android shell quoting and the early launch input checks into
@expo/eas-build-job, so www can check launch inputs when a session is
created, for every session type.

* [eas-build-job] Check only the known part of the Android launch command early

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 13:40:44 -07:00
Expo CI 6f33a4d2a4 bump STAGING and PRODUCTION to 25.3.0 in cli-versions.json 2026-10-20 19:22:35 +00:00
Expo CI 44b63e5a7d v25.3.0 2026-10-20 19:17:05 +00:00
Krystof Woldrich 42e4fa8a7c [eas-cli] Add --system-image to eas simulator:start (#4500) 2026-10-20 19:09:09 +02:00
Ash Wu e4bb181802 [build-tools] Wait up to five minutes for Google Play to process an uploaded bundle (#4700) 2026-10-20 17:30:02 +08:00
Brent Vatne 53ac936b11 [build-tools] Quote Android launch arguments for the device shell (#4695)
* [build-tools] Quote Android launch arguments for the device shell

adb shell joins its arguments with spaces and the device shell parses the
result again, so a launch argument such as "hello world" reached am start
as two words and the launch failed. Quote each word, reject commands that
adb cannot carry, and reject empty arguments that are not extra values.

* [build-tools] Pass empty Android launch arguments through and reject NUL

Guessing which am start option an empty argument belongs to rejected
valid input such as -e key "" and -d "", and accepted input that am
start then rejects. Pass empty arguments through, and explain a failed
launch when launch_args has an empty element. Reject NUL characters,
which spawn cannot pass, with a user error.
2026-10-19 21:29:47 -07:00
Gabe DebesandGPT-6.1-Sol c0d3f4f935 [build-tools] Forward network capture through local egress (#4598)
* [build-tools] Forward network capture through local egress

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Remove unused capture proxy override

Co-Authored-By: Codex <[email protected]>

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-19 20:46:19 -07:00
Gabe DebesandGPT-6.1-Sol 5ec3d84fcb [build-tools] Let serve-sim start the iOS Simulator for Appium (#4603)
* [build-tools] Let serve-sim start the iOS Simulator for Appium

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Mock Appium app metadata at its own boundary

* [build-tools] Stop Appium setup when Simulator startup fails

Co-Authored-By: Codex <[email protected]>

* [build-tools] Retry cancelled shared FFmpeg setup for surviving callers

Co-Authored-By: Codex <[email protected]>

* [build-tools] Test Appium preview settings after startup rebase

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-19 19:12:03 -07:00
Gabe DebesandGPT-6.1-Sol d0cb5a2d67 [build-tools] Let serve-sim start the iOS Simulator for Argent (#4602)
* [build-tools] Let serve-sim start the iOS Simulator for Argent

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Use session helpers for Argent startup

* [build-tools] Mock Argent app metadata at its own boundary

* [build-tools] Stop Argent setup when Simulator startup fails

Co-Authored-By: Codex <[email protected]>

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-19 17:12:45 -07:00
Gabe Debes aae7d13ac0 [build-tools] Upload iOS Simulator session screen recording metadata (#4689)
* [build-tools] Upload screen recording metadata

* [build-tools] Name recording artifacts metadata

* [build-tools] Check companion metadata upload names

* [build-tools] Report metadata upload failures separately
2026-10-19 16:22:10 -07:00
Gabe DebesandClaude Opus 5.5 d9b1f866d4 [build-tools] Reset the session idle timer on serve-sim preview input (#4691)
Poll serve-sim's event log and reset the existing idle timeout when a
new preview input event appears.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 15:07:16 -07:00
William Grosset a03dca45eb [eas-cli] Add a checkout prompt experiment to the Free plan build limit error (#4633)
* [eas-cli] Add a checkout prompt experiment to the Free plan build limit error

* [eas-cli] Reuse the account billing URL helper in the checkout experiment
2026-10-19 10:08:05 -07:00
Expo CI f949f3430e bump STAGING and PRODUCTION to 25.2.0 in cli-versions.json 2026-10-17 23:28:11 +00:00
Expo CI e61a563b70 v25.2.0 2026-10-17 23:23:01 +00:00
150cd21174 [eas-cli] Add simulator:control for remote simulator actions (#4641)
* [eas-cli] Add simulator:action command

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Fold simulator actions into one table

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Give the simulator API lookup its own timeout

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Drop event-log from simulator:action

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Rename to simulator:control and add sidebar actions

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Drive simulator:control from the serve-sim action catalog

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Fix value order, upload cleanup and output handling in simulator:control

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Check output paths and local files before simulator:control runs

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Upload only when the catalog condition matches

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Send upload IDs and JSON lists from simulator:control

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Complete the simulator control session fixture

Co-authored-by: Codex <[email protected]>

* [eas-cli] Close simulator control after discovery failure

Co-authored-by: Codex <[email protected]>

* [eas-cli] Keep simulator action descriptions on one line

Co-authored-by: Codex <[email protected]>

* [eas-cli] List simulator actions by default

Co-authored-by: Codex <[email protected]>

* [eas-cli] Reject empty simulator control session IDs

Document the separator for values beginning with --.

Co-authored-by: Codex <[email protected]>

* [eas-cli] Clarify local screenshot output usage

Co-authored-by: Codex <[email protected]>

* [eas-cli] Compact simulator action discovery and add action help

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-17 15:38:09 -07:00
Gabe DebesandCodex 4b9e792e91 [eas-cli] Add remote simulator host action client (#4642)
* [eas-cli] Add remote simulator host action client

* [eas-cli] Secure simulator action setup and close rejected upgrades

Co-authored-by: Codex <[email protected]>

* [eas-cli] Clarify alternative simulator action inputs

Co-authored-by: Codex <[email protected]>

* [eas-cli] Show each alternative action input group once

Co-authored-by: Codex <[email protected]>

* [eas-cli] Explain token failures during simulator connection setup

Co-authored-by: Codex <[email protected]>

---------

Co-authored-by: Codex <[email protected]>
2026-10-17 15:38:09 -07:00
108 changed files with 8404 additions and 567 deletions

No files matched your search

@@ -0,0 +1,6 @@
---
'@expo/eas-build-job': minor
'@expo/build-tools': patch
---
Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': minor
---
Add experimentation client.
@@ -1,5 +0,0 @@
---
'eas-cli': minor
---
Add a Free plan build limit guidance experiment for coding agents to `eas build`.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard.
@@ -0,0 +1,5 @@
---
'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.
@@ -0,0 +1,5 @@
---
'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
@@ -0,0 +1,5 @@
---
'@expo/worker': patch
---
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
@@ -0,0 +1,5 @@
---
'@expo/worker': patch
---
Apply the job log's secret masking to uploaded Xcode build logs.
+30
View File
@@ -2,6 +2,36 @@
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.1.1",
"PRODUCTION": "25.1.1"
"STAGING": "25.3.0",
"PRODUCTION": "25.3.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "25.1.1",
"version": "25.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+21
View File
@@ -1,5 +1,26 @@
# @expo/build-tools
## 25.3.0
### Minor Changes
- Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
- Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
- Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
- Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
- Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
- Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
## 25.2.0
### Minor Changes
- Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
## 25.1.1
### Patch Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "25.1.1",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -42,17 +42,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "25.1.1",
"@expo/eas-build-job": "25.1.1",
"@expo/downloader": "25.3.0",
"@expo/eas-build-job": "25.3.0",
"@expo/env": "^0.4.0",
"@expo/logger": "25.1.1",
"@expo/logger": "25.3.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "25.1.1",
"@expo/template-file": "25.1.1",
"@expo/turtle-spawn": "25.1.1",
"@expo/steps": "25.3.0",
"@expo/template-file": "25.3.0",
"@expo/turtle-spawn": "25.3.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -79,9 +79,11 @@ beforeEach(() => {
apiUrl: 'https://preview.example.test',
closeAsync: closePreview,
});
jest
.mocked(startDeviceSessionHostAsync)
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
getLastPreviewActivityAt: jest.fn(),
finishAsync: finishHost,
});
jest.mocked(spawnDetached).mockReturnValue({
pid: undefined,
getOutput: () => '',
@@ -1,5 +1,6 @@
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';
@@ -201,6 +202,263 @@ 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(
@@ -237,6 +495,36 @@ 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,6 +162,7 @@ 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,7 +76,8 @@ async function runAsync(
runtimePlatform: BuildRuntimePlatform,
capture: CaptureInputs = NO_CAPTURE,
disableShare = false,
privatePreviewUrl?: string
privatePreviewUrl?: string,
maxIdleTimeMinutes?: number
): Promise<void> {
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
@@ -88,7 +89,7 @@ async function runAsync(
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxIdleTimeMinutes,
maxDurationSeconds: undefined,
capture,
disableShare,
@@ -133,6 +134,7 @@ 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) => {
@@ -202,6 +204,21 @@ 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,6 +687,18 @@ 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,14 +6,18 @@ 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 {
@@ -27,11 +31,16 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
}));
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
IosSimulatorUtils: {
getAvailableDevicesAsync: jest.fn(),
getDeviceAsync: jest.fn(),
resolveUdidAsync: jest.fn().mockResolvedValue('chosen-ios-udid'),
},
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
ensureFfmpegInstalledOnceAsync: jest.fn(),
spawnDetached: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
@@ -48,6 +57,15 @@ 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(),
@@ -88,7 +106,6 @@ describe(resolveAppiumDeviceAsync, () => {
resolveAppiumDeviceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
})
).resolves.toEqual({
platformName: 'iOS',
@@ -97,7 +114,6 @@ 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',
@@ -113,7 +129,6 @@ describe(resolveAppiumDeviceAsync, () => {
resolveAppiumDeviceAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
})
).resolves.toEqual({
platformName: 'Android',
@@ -212,6 +227,13 @@ 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);
@@ -242,6 +264,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: jest.fn(),
});
});
@@ -250,16 +273,22 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
jest.restoreAllMocks();
});
async function runSessionAsync(logger: { info: jest.Mock; warn: jest.Mock }): Promise<void> {
async function runSessionAsync(
logger: { info: jest.Mock; warn: jest.Mock },
maxIdleTimeMinutes?: number,
runtimePlatform = BuildRuntimePlatform.LINUX,
startupInputs: Record<string, { value: unknown }> = {}
): Promise<void> {
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
max_idle_time_minutes: { value: maxIdleTimeMinutes },
...startupInputs,
},
outputs: {},
env: {
@@ -280,6 +309,345 @@ 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);
@@ -307,10 +675,32 @@ 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,8 +7,9 @@ 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 } from '../../../utils/processes';
import { isProcessDescendantOfAsync, killProcessGroup } from '../../../utils/processes';
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
import { createProcessOutput } from '../../utils/processOutput';
@@ -24,6 +25,7 @@ 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
@@ -38,13 +40,32 @@ jest.mock('node:os', () => {
});
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../sentry');
jest.mock('../../../utils/processes', () => ({ isProcessDescendantOfAsync: jest.fn() }));
jest.mock('../../../utils/processes', () => ({
isProcessDescendantOfAsync: jest.fn(),
killProcessGroup: 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(),
@@ -104,6 +125,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -217,6 +239,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
});
} else if (phase === 'config') {
@@ -507,6 +530,35 @@ 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(
@@ -592,6 +644,280 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
);
});
it('boots and prepares the downloaded app while Argent installation is pending, and closes the host if installation fails', async () => {
let rejectInstall!: (error: Error) => void;
let enteredInstall!: () => void;
const installEntered = new Promise<void>(resolve => {
enteredInstall = resolve;
});
jest.mocked(spawn).mockImplementationOnce(() => {
enteredInstall();
return new Promise((_resolve, reject) => {
rejectInstall = reject;
}) as never;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
device_identifier: { value: 'chosen-device' },
install_app_path: { value: '/tmp/App.app' },
launch_args: { value: ['--literal'] },
},
outputs: {},
env: {},
} as never
);
await installEntered;
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
iosSimulatorUdid: 'SELECTED-IOS-UDID',
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--literal'],
})
);
const error = new Error('Argent install failed');
rejectInstall(error);
await expect(running).rejects.toBe(error);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it.each(['artifacts-list-endpoint', 'tool-server-event-log', 'video-watermark'])(
'stops and drains the pending %s command when the concurrent host fails',
async flag => {
const hostError = new Error('serve-sim startup failed');
const commandError = new Error('Argent command exited after cancellation');
let failHost!: (error: Error) => void;
let finishHostCleanup!: () => void;
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
async (_ctx, options) =>
await new Promise((_resolve, reject) => {
failHost = error => {
options.onStartupError?.(error);
finishHostCleanup = () => reject(error);
};
})
);
let failCommand!: (error: Error) => void;
let commandStarted!: () => void;
const commandEntered = new Promise<void>(resolve => {
commandStarted = resolve;
});
const child = { pid: 4242, kill: jest.fn() };
jest.mocked(spawn).mockImplementation((_command, args) => {
if (!args.includes(flag)) {
return Promise.resolve(undefined) as never;
}
const pending = new Promise((_resolve, reject) => {
failCommand = reject;
});
commandStarted();
return Object.assign(pending, { child }) as never;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
let settled = false;
void running
.finally(() => {
settled = true;
})
.catch(() => {});
try {
await commandEntered;
failHost(hostError);
await new Promise(resolve => setImmediate(resolve));
expect(killProcessGroup).toHaveBeenCalledWith(child, 'SIGKILL');
expect(settled).toBe(false);
expect(spawnDetached).not.toHaveBeenCalled();
failCommand(commandError);
await new Promise(resolve => setImmediate(resolve));
expect(settled).toBe(false);
finishHostCleanup();
await expect(running).rejects.toBe(hostError);
expect(spawnDetached).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
} finally {
failCommand?.(commandError);
finishHostCleanup?.();
await running.catch(() => {});
}
}
);
it('interrupts tool-server state polling and retains the original host error', async () => {
await fs.promises.rm(path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'));
const hostError = new Error('serve-sim startup failed');
let failHost!: (error: Error) => void;
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
async () =>
await new Promise((_resolve, reject) => {
failHost = reject;
})
);
let pollingStarted!: () => void;
const pollingEntered = new Promise<void>(resolve => {
pollingStarted = resolve;
});
const getExitError = jest.fn<Error | undefined, []>(() => {
pollingStarted();
return undefined;
});
const stopServer = jest.fn().mockResolvedValue(undefined);
jest.mocked(spawnDetached).mockReturnValueOnce({
pid: 4242,
getOutput: () => 'later controller diagnostic',
getExitError,
stopAsync: stopServer,
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
void running.catch(() => {});
try {
await pollingEntered;
failHost(hostError);
await new Promise(resolve => setImmediate(resolve));
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
expect(options.signal?.aborted).toBe(true);
getExitError.mockReturnValue(new Error('later Argent exit'));
await expect(running).rejects.toBe(hostError);
expect(stopServer).toHaveBeenCalledTimes(1);
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
} finally {
getExitError.mockReturnValue(new Error('stop pending state poll'));
await running.catch(() => {});
}
});
it('keeps Argent setup concurrent but waits for the iOS host before opening tools', async () => {
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
{} as CustomBuildContext,
{} as never
);
let releaseHost!: () => void;
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
new Promise(resolve => {
releaseHost = () => resolve(host);
})
);
let stateReady!: () => void;
const stateRead = new Promise<void>(resolve => {
stateReady = resolve;
});
jest.mocked(isProcessDescendantOfAsync).mockImplementationOnce(async () => {
stateReady();
return true;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
try {
await stateRead;
await new Promise(resolve => setImmediate(resolve));
expect(spawnDetached).toHaveBeenCalledTimes(1);
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
} finally {
releaseHost();
await running;
}
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'keeps %s prepared setup commands outside the new cancellation path',
async runtimePlatform => {
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
expect(spawn).toHaveBeenCalledTimes(3);
for (const [, , options] of jest.mocked(spawn).mock.calls) {
expect(options).not.toHaveProperty('detached');
expect(options).not.toHaveProperty('signal');
}
expect(killProcessGroup).not.toHaveBeenCalled();
}
);
it('fails before starting anything when a launch is asked for on Android', async () => {
const ctx = {} as unknown as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() };
@@ -81,6 +81,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -97,6 +98,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
previewToken: 'tok-1',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
@@ -124,6 +126,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
@@ -151,6 +154,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN, { public_preview: { value: false } });
@@ -168,6 +172,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
} else if (phase === 'config') {
@@ -140,9 +140,12 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
await buildStep.executeAsync();
const names = jest
const uploads = jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mock.calls.map(([, options]) => options.name);
.mock.calls.map(([, options]) => options);
const names = uploads
.filter(options => options.kind === 'screen-recording')
.map(options => options.name);
expect(names).toEqual(
expect.arrayContaining([
'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
@@ -151,6 +154,12 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
])
);
expect(new Set(names).size).toBe(3);
const metadata = uploads.filter(options => options.kind === 'screen-recording-metadata');
expect(metadata.map(options => options.name)).toEqual(
expect.arrayContaining(names.map(name => `${name} metadata`))
);
expect(metadata).toHaveLength(3);
expect(uploads).toHaveLength(6);
} finally {
await Promise.all(
recordingDirectories.map(directory => fs.rm(directory, { recursive: true, force: true }))
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { UserError, quoteAndroidShellCommand } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
@@ -106,24 +106,40 @@ 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`.
await spawn(
'adb',
['shell', 'am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
{
env,
logger,
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;
}
);
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 spawn(
'adb',
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
await spawnAndroidShellCommandAsync(
['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,6 +265,7 @@ export async function runAgentDeviceRemoteSessionAsync(
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { UserError, validateAndroidLaunchOptions } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -164,6 +164,8 @@ 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 { asyncResult } from '@expo/results';
import { type Result, asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -8,10 +8,11 @@ import {
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import spawn, { type SpawnOptions, type SpawnResult } 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,17 +22,19 @@ 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 } from '../../utils/IosSimulatorUtils';
import { IosSimulatorUtils, type IosSimulatorUuid } 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';
@@ -39,6 +42,7 @@ import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
@@ -51,7 +55,6 @@ import {
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
@@ -78,6 +81,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
__metricsId: 'eas/start_appium_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
@@ -102,14 +106,21 @@ 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;
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
},
{ runtimePlatform }
);
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 { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
@@ -122,54 +133,125 @@ export function createStartAppiumRemoteSessionBuildFunction(
logger.info(
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
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,
});
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;
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 {
await waitForAppiumReadyAsync({ appiumProcess, logger });
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();
eventCollection = await startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
@@ -190,22 +272,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
if (launchDescription) {
logger.info(launchDescription);
}
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,
});
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
@@ -240,13 +307,15 @@ export function createStartAppiumRemoteSessionBuildFunction(
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
throw setupSignal?.aborted ? setupSignal.reason : error;
} finally {
startupAbortController.abort(new Error('Appium session ended.'));
await finishRemoteSessionAsync({
logger,
sessionFailed,
@@ -259,14 +328,16 @@ export function createStartAppiumRemoteSessionBuildFunction(
await eventCollection?.stopAsync();
} finally {
try {
await appiumProcess.stopAsync();
await appiumProcess?.stopAsync();
} finally {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
if (appiumHome) {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
}
}
})(),
],
['session host', sessionHost?.finishAsync()],
['session host', hostStartup?.then(result => result.value?.finishAsync())],
],
});
}
@@ -296,15 +367,12 @@ 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',
@@ -341,65 +409,135 @@ 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-'));
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}`
);
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}`
);
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 });
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);
}
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 {}
await sleepAsync(1_000);
signal?.throwIfAborted();
if (signal) {
await setTimeoutAsync(1_000, undefined, { signal });
} else {
await sleepAsync(1_000);
}
}
const output = appiumProcess.getOutput();
throw new SystemError(
@@ -1,15 +1,17 @@
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 from '@expo/turtle-spawn';
import spawn, { type SpawnOptions } 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';
@@ -19,6 +21,7 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
@@ -29,13 +32,14 @@ import {
resolveConfiguredPackageManager,
resolvePackageExec,
} from '../../utils/packageManager';
import { isProcessDescendantOfAsync } from '../../utils/processes';
import { isProcessDescendantOfAsync, killProcessGroup } 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,
@@ -48,7 +52,6 @@ import {
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
@@ -90,6 +93,7 @@ export function createStartArgentRemoteSessionBuildFunction(
__metricsId: 'eas/start_argent_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
@@ -128,14 +132,21 @@ export function createStartArgentRemoteSessionBuildFunction(
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
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 }
);
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 { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
@@ -147,10 +158,6 @@ 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
@@ -162,74 +169,124 @@ export function createStartArgentRemoteSessionBuildFunction(
const argentExec = (args: string[]): { command: string; args: string[] } =>
resolvePackageExec(packageManager, args);
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 startupAbortController = new AbortController();
const startupSignal = signal
? AbortSignal.any([signal, startupAbortController.signal])
: startupAbortController.signal;
const setupSignal = iosStartup ? startupSignal : undefined;
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;
@@ -239,6 +296,7 @@ export function createStartArgentRemoteSessionBuildFunction(
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
signal: setupSignal,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
@@ -253,6 +311,10 @@ 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])
@@ -287,22 +349,7 @@ export function createStartArgentRemoteSessionBuildFunction(
if (launchDescription) {
logger.info(launchDescription);
}
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,
});
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
@@ -339,13 +386,15 @@ export function createStartArgentRemoteSessionBuildFunction(
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
throw setupSignal?.aborted ? setupSignal.reason : error;
} finally {
startupAbortController.abort(new Error('Argent session ended.'));
await finishRemoteSessionAsync({
logger,
sessionFailed,
@@ -377,11 +426,11 @@ export function createStartArgentRemoteSessionBuildFunction(
);
}
} finally {
await argentServer.stopAsync();
await argentServer?.stopAsync();
}
})(),
],
['session host', sessionHost?.finishAsync()],
['session host', hostStartup?.then(result => result.value?.finishAsync())],
],
});
}
@@ -441,6 +490,31 @@ 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 {
@@ -463,13 +537,16 @@ 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 => {
@@ -480,6 +557,7 @@ export async function waitForArgentToolServerStateAsync({
};
while (Date.now() < deadline) {
signal?.throwIfAborted();
throwIfExited();
let matchingState: ArgentToolServerState | undefined;
try {
@@ -505,13 +583,19 @@ export async function waitForArgentToolServerStateAsync({
lastError = err;
}
// Keep process failures outside the retryable state-file read errors.
signal?.throwIfAborted();
throwIfExited();
if (matchingState) {
return matchingState;
}
await sleepAsync(pollIntervalMs);
if (signal) {
await setTimeoutAsync(pollIntervalMs, undefined, { signal });
} else {
await sleepAsync(pollIntervalMs);
}
}
signal?.throwIfAborted();
throw new SystemError(
`Timed out waiting for an argent tool-server state file belonging to process ${ancestorPid} in ${stateDir}${
lastError instanceof Error ? `: ${lastError.message}` : ''
@@ -1,7 +1,7 @@
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import { execFileSync } from 'node:child_process';
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { copyFile, mkdir, mkdtemp, readFile, rm, 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,7 +40,14 @@ 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`, headers: {} } } })
JSON.stringify({
data: {
uploadSession: {
url: `${url}/artifact/${encodeURIComponent(JSON.parse(body).filename)}`,
headers: {},
},
},
})
);
});
} else {
@@ -156,10 +163,14 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
]);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
let putBytes = 0;
const uploaded = new Map<string, Buffer>();
handle = (request, response) => {
request.on('data', chunk => (putBytes += chunk.length));
const chunks: Buffer[] = [];
request.on('data', chunk => {
chunks.push(chunk);
});
request.on('end', () => {
uploaded.set(request.url!, Buffer.concat(chunks));
response.statusCode = 200;
response.end();
});
@@ -171,10 +182,18 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
recordings,
})
).resolves.toBe(true);
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
expect(creationTokens).toEqual(['session-token', 'session-token', 'session-token']);
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'));
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]);
@@ -185,6 +204,16 @@ 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.',
@@ -0,0 +1,141 @@
import type { bunyan } from '@expo/logger';
import { vol } from 'memfs';
import { writeFile } from 'node:fs/promises';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
jest.mock('../deviceRunSessionArtifacts');
jest.mock('../../../sentry');
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const ctx = {} as CustomBuildContext;
const recording = {
udid: 'simulator-id',
deviceName: 'iPhone Duo',
runtimeDisplayName: 'iOS 27.1',
directory: '/recordings/recording-id',
};
const manifest = {
firstFrameWallClock: { unixMs: 1_791_072_000_000, iso8601: '2026-10-04T00:00:00.000Z' },
width: 1440,
height: 1200,
recording: 'recording.mp4',
};
beforeEach(() => {
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset().mockResolvedValue(undefined);
});
it.each([
{},
{
deviceStates: [
{
timeMs: 12.5,
state: {
width: 1200,
height: 1440,
orientation: 'landscape-left',
screen: 'inner',
hingeAngle: 90,
physicalOrientation: 'portrait',
tableMode: false,
contentRect: { x: 220, y: 0, width: 1000, height: 1200 },
},
},
],
interruptions: [{ reason: 'simulatorReboot', pausedAtSeconds: 1, resumedAtSeconds: 2 }],
futureField: { label: '서울 🌲' },
},
])('uploads the complete manifest unchanged beside its video (%j)', async extra => {
const contents = Buffer.from(JSON.stringify({ ...manifest, ...extra }, null, 2) + '\n');
vol.fromJSON({
[recording.directory + '/session.json']: contents.toString(),
[recording.directory + '/recording.mp4']: 'video',
});
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
if (input.kind === 'screen-recording') {
await writeFile(recording.directory + '/session.json', '{}');
}
});
await expect(
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
logger,
deviceRunSessionId: 'session-id',
recordings: [recording],
})
).resolves.toBe(true);
const inputs = jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mock.calls.map(([, input]) => input);
expect(inputs.map(input => input.filename)).toEqual(['recording-id.mp4', 'recording-id.json']);
expect(inputs[1]).toMatchObject({
deviceRunSessionId: 'session-id',
artifactId: 'recording-id-metadata',
kind: 'screen-recording-metadata',
metadata: {
__eas_type: 'screen-recording-metadata',
recordingFilename: 'recording-id.mp4',
udid: recording.udid,
},
size: contents.length,
});
for (let attempt = 0; attempt < 2; attempt++) {
const chunks: Buffer[] = [];
for await (const chunk of inputs[1].openStream()) {
chunks.push(Buffer.from(chunk));
}
expect(Buffer.concat(chunks)).toEqual(contents);
}
});
it('reports a metadata upload failure while keeping the video and uploading other recordings', async () => {
const other = { ...recording, directory: '/recordings/other-recording', udid: 'other-device' };
for (const item of [recording, other]) {
vol.fromJSON({
[item.directory + '/session.json']: JSON.stringify(manifest),
[item.directory + '/recording.mp4']: 'video',
});
}
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
if (input.filename === 'recording-id.json') {
throw new Error('Storage unavailable');
}
});
await expect(
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
logger,
deviceRunSessionId: 'session-id',
recordings: [recording, other],
})
).resolves.toBe(false);
expect(
jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls.map(([, input]) => input.filename)
).toEqual(
expect.arrayContaining([
'recording-id.mp4',
'recording-id.json',
'other-recording.mp4',
'other-recording.json',
])
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not upload device screen recording metadata',
expect.any(Error)
);
expect(Sentry.capture).not.toHaveBeenCalledWith(
'Could not upload device screen recording',
expect.any(Error)
);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
`Could not upload screen recording metadata for ${recording.deviceName}.`
);
});
@@ -12,7 +12,9 @@ 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';
@@ -44,6 +46,11 @@ 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', () => ({
@@ -129,6 +136,7 @@ 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);
@@ -452,6 +460,62 @@ 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, {
@@ -1319,6 +1383,35 @@ 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,6 +3,7 @@ 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() }));
@@ -78,3 +79,37 @@ 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,6 +18,7 @@ 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,
@@ -660,6 +661,14 @@ 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');
@@ -1930,6 +1939,47 @@ 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'),
@@ -2135,6 +2185,105 @@ 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
@@ -2172,6 +2321,30 @@ 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.
@@ -0,0 +1,196 @@
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: 5, factor: 2, minTimeout: 2000, maxTimeout: 32_000 },
{ retries: 13, 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} after six commit attempts. 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} within five minutes. 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 six attempts', async () => {
it('stops waiting for bundle processing after 14 attempts', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.times(6)
.times(14)
.reply(400, { error: { message: pendingUploadMessage } });
const error = await commit().catch(error => error);
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toMatchObject({
@@ -5,6 +5,7 @@ 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';
@@ -140,9 +141,8 @@ export async function uploadDeviceRunSessionScreenRecordingsAsync(
recordings.map(recording =>
limit(async () => {
try {
const metadata = RecordingManifestSchema.parse(
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
);
const manifest = await readFile(path.join(recording.directory, 'session.json'));
const metadata = RecordingManifestSchema.parse(JSON.parse(manifest.toString('utf-8')));
if (path.basename(metadata.recording) !== metadata.recording) {
throw new Error('Recording filename must not contain a directory.');
}
@@ -186,6 +186,31 @@ export async function uploadDeviceRunSessionScreenRecordingsAsync(
openStream: () => createReadStream(recordingPath),
attempts: 3,
});
try {
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: `${recordingId}-metadata`,
name: `${displayName} metadata`,
filename: `${recordingId}.json`,
kind: 'screen-recording-metadata',
metadata: {
__eas_type: 'screen-recording-metadata',
recordingFilename: `${recordingId}.mp4`,
udid: recording.udid,
},
size: manifest.length,
openStream: () => Readable.from(manifest),
attempts: 3,
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload device screen recording metadata', error);
logger.warn(
{ err: error },
`Could not upload screen recording metadata for ${recording.deviceName}.`
);
return false;
}
return true;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
@@ -40,6 +40,7 @@ import {
spawnDetached,
startNgrokTunnelAsync,
} from './remoteDeviceRunSession';
import { LOCAL_EGRESS_PROXY_HOST, readLocalEgressHandoffAsync } from './localEgress';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import {
IosSimulatorRecordingUtils,
@@ -48,6 +49,7 @@ 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';
@@ -136,6 +138,7 @@ export function createServeSimArgs({
openUrl,
networkCapture = false,
networkCaptureFields = [],
networkCaptureProxy,
}: {
port: number;
turnArgs?: string[];
@@ -149,6 +152,7 @@ export function createServeSimArgs({
installAppPath?: string;
networkCapture?: boolean;
networkCaptureFields?: string[];
networkCaptureProxy?: string;
} & ServeSimLaunchOptions): string[] {
return [
createServeSimPackageSpec(packageVersion),
@@ -188,6 +192,7 @@ export function createServeSimArgs({
...networkCaptureFields.flatMap(field => ['--network-capture-field', field]),
]
: []),
...(networkCaptureProxy !== undefined ? ['--network-capture-proxy', networkCaptureProxy] : []),
];
}
@@ -350,6 +355,8 @@ 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.
@@ -493,6 +500,7 @@ 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
@@ -522,6 +530,9 @@ async function startDeviceSessionHostInternalAsync(
openUrl: iosSimulatorUdid ? undefined : openUrl,
networkCapture,
networkCaptureFields,
networkCaptureProxy: localEgress
? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}`
: undefined,
})
);
logger.info(
@@ -577,6 +588,7 @@ 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 }) {
@@ -634,6 +646,9 @@ async function startDeviceSessionHostInternalAsync(
});
return opening;
},
getLastPreviewActivityAt() {
return previewActivity?.getLastActivityObservedAt();
},
finishAsync() {
readinessController.abort(new Error('Simulator preview ended.'));
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
@@ -642,6 +657,7 @@ async function startDeviceSessionHostInternalAsync(
previewSetupController.abort();
await previewSetupTask;
await sessionPreview?.stopAsync();
await previewActivity?.stopAsync();
},
previewServer,
screenshots,
@@ -769,6 +785,9 @@ 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,13 +12,16 @@ 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];
@@ -52,10 +55,17 @@ 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.');
@@ -67,6 +77,7 @@ 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 } from '../../utils/processes';
import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { createProcessOutput } from './processOutput';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
@@ -126,6 +126,8 @@ 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({
@@ -179,9 +181,13 @@ export async function waitForDeviceRunSessionStoppedAsync({
while (!signal.aborted) {
if (idleTimeout) {
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
lastActivityAt = lastEventObservedAt;
for (const observedAt of [
idleTimeout.getLastEventObservedAt(),
idleTimeout.getLastPreviewActivityAt?.(),
]) {
if (observedAt && observedAt > lastActivityAt) {
lastActivityAt = observedAt;
}
}
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
logger.info(
@@ -311,14 +317,28 @@ function warmUpFfmpeg(env: BuildStepEnv): void {
async function installFfmpegWithHomebrewAsync({
env,
logger,
signal,
}: {
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<void> {
await spawn('brew', ['install', 'ffmpeg'], {
signal?.throwIfAborted();
const install = 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({
@@ -336,7 +356,7 @@ async function installFfmpegWithAptAsync({
await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger });
}
let ffmpegSetupPromise: Promise<void> | undefined;
let ffmpegSetupPromise: Promise<'aborted' | void> | undefined;
/**
* Install ffmpeg when the runtime does not already provide it. Device-session
@@ -356,13 +376,16 @@ async function ensureFfmpegInstalledAsync({
runtimePlatform,
env,
logger,
signal,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
signal?: AbortSignal;
}): Promise<'aborted' | void> {
try {
const ffmpegPath = await findFfmpegOnPathAsync(env);
signal?.throwIfAborted();
if (ffmpegPath) {
logger.info(`ffmpeg is already installed at ${ffmpegPath}.`);
warmUpFfmpeg(env);
@@ -376,13 +399,17 @@ async function ensureFfmpegInstalledAsync({
} for the device session.`
);
if (isDarwin) {
await installFfmpegWithHomebrewAsync({ env, logger });
await installFfmpegWithHomebrewAsync({ env, logger, signal });
} 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',
@@ -398,20 +425,46 @@ export async function ensureFfmpegInstalledOnceAsync({
runtimePlatform,
env,
logger,
signal,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<void> {
signal?.throwIfAborted();
if (ffmpegSetupPromise) {
await 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 });
}
return;
}
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger, signal });
ffmpegSetupPromise = setupPromise;
try {
await setupPromise;
signal?.throwIfAborted();
} finally {
if (ffmpegSetupPromise === setupPromise) {
ffmpegSetupPromise = undefined;
@@ -0,0 +1,131 @@
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,5 +1,13 @@
# 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.1.1",
"version": "25.3.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "25.1.1"
"@expo/steps": "25.3.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+8
View File
@@ -1,5 +1,13 @@
# @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.1.1",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,5 +1,13 @@
# 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.1.1",
"version": "25.3.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+8
View File
@@ -1,5 +1,13 @@
# @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.1.1",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "25.1.1",
"@expo/logger": "25.3.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "25.1.1",
"@expo/turtle-spawn": "25.3.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -1,4 +1,9 @@
import { DeviceRunSessionEvent, parseDeviceRunSessionEvents } from '../deviceRunSession';
import {
DeviceRunSessionEvent,
parseDeviceRunSessionEvents,
quoteAndroidShellCommand,
validateAndroidLaunchOptions,
} from '../deviceRunSession';
describe('parseDeviceRunSessionEvents', () => {
it('parses, deduplicates, and sorts events from the dedicated event log', () => {
@@ -33,6 +38,56 @@ 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,5 +1,7 @@
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
@@ -46,3 +48,54 @@ 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,5 +1,20 @@
# 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.
+282 -162
View File
File diff suppressed because it is too large. Load diff
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "25.1.1",
"version": "25.3.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,12 +48,12 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "25.1.1",
"@expo/eas-json": "25.1.1",
"@expo/eas-build-job": "25.3.0",
"@expo/eas-json": "25.3.0",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "25.1.1",
"@expo/logger": "25.3.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -65,7 +65,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "25.1.1",
"@expo/steps": "25.3.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -185,6 +185,7 @@
],
"bin": "eas",
"commands": "./build/commands",
"helpClass": "./build/help",
"plugins": [
"@oclif/plugin-autocomplete",
"@expo/plugin-help",
@@ -0,0 +1,38 @@
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,6 +69,7 @@ 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`.
*/
function canManageBilling(account: { viewerUserPermission2: ViewerPermissions }): boolean {
export function canManageBilling(account: { viewerUserPermission2: ViewerPermissions }): boolean {
return hasBillingPermission(account.viewerUserPermission2.effectivePermissions);
}
@@ -0,0 +1,471 @@
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,16 +24,27 @@ 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. */
@@ -94,11 +105,15 @@ const getGraphQLError = (message: string, errorCode: string): GraphQLError => {
const assertReThrownError = (
caughtError: CombinedError | Error,
expectedType: typeof EasCommandError,
expectedMessage: string = 'Error 1'
expectedType: new (...args: any[]) => Error,
expectedMessage: string = 'Error 1',
{ messageAlreadyPrinted = false }: { messageAlreadyPrinted?: boolean } = {}
): void => {
expect(caughtError).toBeInstanceOf(expectedType);
expect((caughtError as Error).message).toEqual(expectedMessage);
expect((caughtError as EasCommandError).messageAlreadyPrinted ?? false).toBe(
messageAlreadyPrinted
);
};
describe(Build.name, () => {
@@ -449,6 +464,153 @@ 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;
+24 -17
View File
@@ -28,6 +28,11 @@ import {
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from './errors';
import {
FreeTierLimitCheckoutExperiment,
getFreeTierLimitCheckoutExperiment,
maybeOfferFreeTierLimitCheckoutAsync,
} from './freeTierLimitCheckout';
import {
FreeTierLimitAgentGuidanceExperiment,
getFreeTierLimitAgentGuidanceExperiment,
@@ -51,9 +56,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';
@@ -228,7 +233,8 @@ export async function prepareBuildRequestForPlatformAsync<
error,
job.platform,
ctx.accountName,
getFreeTierLimitAgentGuidanceExperiment(ctx)
getFreeTierLimitAgentGuidanceExperiment(ctx),
getFreeTierLimitCheckoutExperiment(ctx)
);
}
} else {
@@ -254,7 +260,8 @@ export async function handleBuildRequestErrorAsync(
error: any,
platform: Platform,
accountName?: string,
freeTierLimitAgentGuidanceExperiment?: FreeTierLimitAgentGuidanceExperiment
freeTierLimitAgentGuidanceExperiment?: FreeTierLimitAgentGuidanceExperiment,
freeTierLimitCheckoutExperiment?: FreeTierLimitCheckoutExperiment
): Promise<never> {
logBuildRequestErrorDebugInfo(error);
@@ -270,15 +277,19 @@ export async function handleBuildRequestErrorAsync(
const isFreeTierLimitError =
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED' ||
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED';
throw new ErrorClass(
isFreeTierLimitError
? await maybeAppendFreeTierLimitAgentGuidanceAsync(
`${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`,
platform,
freeTierLimitAgentGuidanceExperiment
)
: message
if (!isFreeTierLimitError) {
throw new ErrorClass(message);
}
const limitMessage = await maybeAppendFreeTierLimitAgentGuidanceAsync(
`${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`,
platform,
freeTierLimitAgentGuidanceExperiment
);
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(
@@ -695,7 +706,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(
formatAccountBillingUrl(accountName)
getAccountBillingUrl(accountName)
)}.`,
});
}
@@ -718,7 +729,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(formatAccountBillingUrl(accountName))}`);
Log.log(`Sign up for a paid plan at ${link(getAccountBillingUrl(accountName))}`);
}
Log.newLine();
@@ -910,10 +921,6 @@ 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,6 +12,7 @@ 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';
@@ -68,4 +69,5 @@ export interface BuildContext<T extends Platform> {
whatToTest?: string;
env: Env;
experimentation?: LazyExperimentationClient;
authenticationInfo?: LoggedInAuthenticationInfo;
}
@@ -20,6 +20,7 @@ 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';
@@ -48,6 +49,7 @@ export async function createBuildContextAsync<T extends Platform>({
whatToTest,
env,
experimentation,
authenticationInfo,
}: {
buildProfileName: string;
buildProfile: BuildProfile<T>;
@@ -73,6 +75,7 @@ export async function createBuildContextAsync<T extends Platform>({
whatToTest?: string;
env: Env;
experimentation?: LazyExperimentationClient;
authenticationInfo?: LoggedInAuthenticationInfo;
}): Promise<BuildContext<T>> {
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
env,
@@ -163,6 +166,7 @@ export async function createBuildContextAsync<T extends Platform>({
env,
whatToTest,
experimentation,
authenticationInfo,
};
if (platform === Platform.ANDROID) {
const common = commonContext as CommonContext<Platform.ANDROID>;
@@ -0,0 +1,155 @@
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,6 +31,7 @@ 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,
@@ -120,6 +121,7 @@ export async function runBuildAndSubmitAsync({
downloadSimBuildAutoConfirm,
envOverride,
experimentation,
authenticationInfo,
}: {
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
@@ -130,8 +132,10 @@ export async function runBuildAndSubmitAsync({
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
downloadSimBuildAutoConfirm?: boolean;
envOverride?: Env;
/** Enables the Free plan limit guidance experiment for builds driven by a coding agent. */
/** Enables the Free plan limit experiments on the build request error path. */
experimentation?: LazyExperimentationClient;
/** Lets the Free plan limit checkout experiment create a checkout session. */
authenticationInfo?: LoggedInAuthenticationInfo;
}): Promise<{
buildIds: string[];
buildProfiles?: ProfileData<'build'>[];
@@ -245,6 +249,7 @@ export async function runBuildAndSubmitAsync({
env,
easJsonAccessor,
experimentation,
authenticationInfo,
});
if (maybeBuild) {
startedBuilds.push({ build: maybeBuild, buildProfile });
@@ -384,6 +389,7 @@ async function prepareAndStartBuildAsync({
env,
easJsonAccessor,
experimentation,
authenticationInfo,
}: {
projectDir: string;
flags: BuildFlags;
@@ -399,6 +405,7 @@ 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,
@@ -425,6 +432,7 @@ async function prepareAndStartBuildAsync({
whatToTest: flags.whatToTest,
env,
experimentation,
authenticationInfo,
});
if (!lockfileChecked && !flags.localBuildOptions.localBuildMode) {
+4
View File
@@ -18,6 +18,10 @@ 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,7 +271,9 @@ export default abstract class EasCommand extends Command {
const commandId = this.id ?? 'unknown';
let baseMessage = `${commandId} command failed.`;
if (err instanceof EasCommandError) {
Log.error(err.message);
if (!err.messageAlreadyPrinted) {
Log.error(err.message);
}
} else if (err instanceof CombinedError && err?.graphQLErrors) {
const cleanGQLErrorsMessage = err?.graphQLErrors
.map((graphQLError: GraphQLError) => {
@@ -215,6 +215,30 @@ 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;
+8 -3
View File
@@ -1,7 +1,12 @@
export class EasCommandError extends Error {
// 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) {
/** 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 } = {}
) {
super(message);
this.messageAlreadyPrinted = messageAlreadyPrinted;
}
}
+2 -1
View File
@@ -152,7 +152,7 @@ export default class Build extends EasCommand {
const flags = this.sanitizeFlags(rawFlags);
const {
loggedIn: { actor, graphqlClient },
loggedIn: { actor, graphqlClient, authenticationInfo },
getDynamicPrivateProjectConfigAsync,
projectDir,
analytics,
@@ -185,6 +185,7 @@ export default class Build extends EasCommand {
actor,
getDynamicPrivateProjectConfigAsync,
experimentation,
authenticationInfo,
});
this.maybeSuggestUsingEasBuildDev({
@@ -0,0 +1,499 @@
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,6 +40,7 @@ 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);
@@ -108,6 +109,7 @@ describe(SimulatorGet, () => {
beforeEach(() => {
jest.clearAllMocks();
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
mockJobRunErrorsByIdAsync.mockResolvedValue(null);
});
function createCommand(argv: string[]): {
@@ -162,6 +164,79 @@ 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,6 +6,7 @@ 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,
@@ -44,6 +45,7 @@ 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,
@@ -57,6 +59,7 @@ 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'),
@@ -105,6 +108,8 @@ 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 () => {});
@@ -191,6 +196,7 @@ 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({
@@ -1124,6 +1130,76 @@ 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',
@@ -1920,6 +1996,292 @@ 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 }))
@@ -2219,6 +2581,31 @@ 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,322 @@
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;
}
}
+24 -1
View File
@@ -6,7 +6,11 @@ import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { DeviceRunSessionByIdQuery, DeviceRunSessionStatus } from '../../graphql/generated';
import {
DeviceRunSessionByIdQuery,
DeviceRunSessionStatus,
JobRunStatus,
} from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
@@ -15,6 +19,11 @@ import {
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../simulator/env';
import {
SimulatorSessionError,
fetchSimulatorSessionErrorsAsync,
formatSimulatorSessionError,
} from '../../simulator/jobRunErrors';
import {
deviceRunSessionTypeToFlagValue,
formatRemoteSessionInstructions,
@@ -87,6 +96,11 @@ 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({
@@ -109,6 +123,7 @@ export default class SimulatorGet extends EasCommand {
)
: session.remoteConfig,
artifacts: session.artifacts,
errors,
});
return;
}
@@ -116,6 +131,14 @@ 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,6 +41,10 @@ import {
writeSimulatorEnvAsync,
} from '../../simulator/env';
import { resolveExpoGoSdkVersionAsync } from '../../simulator/expoGo';
import {
fetchSimulatorSessionErrorsAsync,
formatSimulatorSessionError,
} from '../../simulator/jobRunErrors';
import {
simulatorRequestFailureReason,
simulatorRequestProperties,
@@ -100,6 +104,10 @@ 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'],
@@ -248,6 +256,7 @@ 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;
@@ -291,6 +300,9 @@ 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'] ?? []);
@@ -366,11 +378,18 @@ export default class Simulator extends EasCommand {
packageVersion: flags['package-version'],
networkCapture: flags['network-capture'],
...(networkCaptureFields.length ? { networkCaptureFields } : {}),
...(deviceIdentifier
? platform === AppPlatform.Ios
...(platform === AppPlatform.Ios
? deviceIdentifier
? { ios: { deviceIdentifier } }
: { android: { deviceIdentifier } }
: {}),
: {}
: deviceIdentifier || systemImagePackage
? {
android: {
...(deviceIdentifier ? { deviceIdentifier } : {}),
...(systemImagePackage ? { systemImagePackage } : {}),
},
}
: {}),
...(buildId ? { buildId } : {}),
...(buildFingerprint ? { buildFingerprint } : {}),
...(applicationArchiveUrlFromFlag
@@ -446,7 +465,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)}`
`Simulator session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId, sessionInterrupt.signal)}`
);
}
@@ -457,7 +476,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)}`
`Turtle job run for simulator session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId, sessionInterrupt.signal)}`
);
}
@@ -619,6 +638,18 @@ 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
@@ -700,7 +731,9 @@ async function waitForSessionEndOrInterruptAsync({
jobRunStatus === JobRunStatus.Errored
) {
spinner.fail(`Simulator session errored. ${link(deviceRunSessionUrl)}`);
throw new Error(`Simulator session ${deviceRunSessionId} errored.`);
throw new Error(
`Simulator session ${deviceRunSessionId} errored.${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId, signal)}`
);
}
if (
session.status === DeviceRunSessionStatus.Stopped ||
+7
View File
@@ -16018,6 +16018,13 @@ export type DeviceRunSessionEventsByIdQueryVariables = Exact<{
export type DeviceRunSessionEventsByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, downloadUrl: string, metadata?: any | null }> } } };
export type DeviceRunSessionJobRunErrorsByIdQueryVariables = Exact<{
deviceRunSessionId: Scalars['ID']['input'];
}>;
export type DeviceRunSessionJobRunErrorsByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string, docsUrl?: string | null }> } | null } } };
export type DeviceRunSessionByIdQueryVariables = Exact<{
deviceRunSessionId: Scalars['ID']['input'];
}>;
@@ -1,3 +1,4 @@
import { OperationResult, OperationResultSource } from '@urql/core';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
@@ -8,6 +9,8 @@ import {
DeviceRunSessionEventsByIdQuery,
DeviceRunSessionEventsByIdQueryVariables,
DeviceRunSessionFilterInput,
DeviceRunSessionJobRunErrorsByIdQuery,
DeviceRunSessionJobRunErrorsByIdQueryVariables,
DeviceRunSessionsByAppIdQuery,
DeviceRunSessionsByAppIdQueryVariables,
} from '../generated';
@@ -42,6 +45,52 @@ export const DeviceRunSessionQuery = {
);
return data.deviceRunSessions.byId;
},
/**
* Best-effort lookup of why the session's job run failed. It doesn't retry or print errors, and it
* rejects with `signal.reason` and cancels the request when `signal` aborts.
*/
async jobRunErrorsByIdAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionId: string,
{ signal }: { signal: AbortSignal }
): Promise<DeviceRunSessionJobRunErrorsByIdQuery['deviceRunSessions']['byId']['turtleJobRun']> {
const { data, error } = await firstResultUntilAbortedAsync(
graphqlClient.query<
DeviceRunSessionJobRunErrorsByIdQuery,
DeviceRunSessionJobRunErrorsByIdQueryVariables
>(
gql`
query DeviceRunSessionJobRunErrorsByIdQuery($deviceRunSessionId: ID!) {
deviceRunSessions {
byId(deviceRunSessionId: $deviceRunSessionId) {
id
turtleJobRun {
id
errors {
errorCode
message
docsUrl
}
logFileUrls
}
}
}
}
`,
{ deviceRunSessionId },
// Retries would keep a timer alive after the caller gives up.
{ requestPolicy: 'network-only', noRetry: true }
),
signal
);
if (error) {
throw error;
}
if (!data) {
throw new Error('Returned query result data is null!');
}
return data.deviceRunSessions.byId.turtleJobRun;
},
async byIdAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionId: string
@@ -224,3 +273,41 @@ export const DeviceRunSessionQuery = {
return data.app.byId.deviceRunSessionsPaginated;
},
};
// Like `toPromise()`, but unsubscribing on abort makes urql cancel the request.
async function firstResultUntilAbortedAsync<T extends OperationResult>(
source: OperationResultSource<T>,
signal: AbortSignal
): Promise<T> {
signal.throwIfAborted();
return await new Promise<T>((resolve, reject) => {
let subscription: { unsubscribe: () => void } | undefined;
let settled = false;
const settle = (finish: () => void): void => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener('abort', onAbort);
subscription?.unsubscribe();
finish();
};
const onAbort = (): void => {
settle(() => {
reject(signal.reason);
});
};
signal.addEventListener('abort', onAbort, { once: true });
subscription = source.subscribe(result => {
if (!result.stale && !result.hasNext) {
settle(() => {
resolve(result);
});
}
});
// The result can arrive synchronously, before `subscription` is assigned.
if (settled) {
subscription.unsubscribe();
}
});
}
@@ -0,0 +1,142 @@
import http from 'http';
import { AddressInfo } from 'net';
import { createGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import Log from '../../../log';
import { DeviceRunSessionQuery } from '../DeviceRunSessionQuery';
jest.mock('../../../api', () => ({
getExpoApiBaseUrl: () => process.env.DEVICE_RUN_SESSION_QUERY_TEST_URL,
}));
describe('DeviceRunSessionQuery.jobRunErrorsByIdAsync', () => {
let server: http.Server;
let requests: number;
let closedRequests: number;
async function startServerAsync(handler: http.RequestListener): Promise<void> {
requests = 0;
closedRequests = 0;
server = http.createServer((request, response) => {
requests += 1;
request.socket.on('close', () => {
closedRequests += 1;
});
handler(request, response);
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
process.env.DEVICE_RUN_SESSION_QUERY_TEST_URL = `http://127.0.0.1:${port}`;
}
function respondWithJson(body: unknown): http.RequestListener {
return (_request, response) => {
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify(body));
};
}
afterEach(async () => {
jest.restoreAllMocks();
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
});
it('returns the job run errors', async () => {
const turtleJobRun = {
__typename: 'JobRun',
id: 'job-123',
logFileUrls: ['https://logs.example.com/job-123'],
errors: [
{
__typename: 'JobRunError',
errorCode: 'UNKNOWN_ERROR',
message: 'Unknown error.',
docsUrl: null,
},
],
};
await startServerAsync(
respondWithJson({
data: {
deviceRunSessions: {
__typename: 'DeviceRunSessionQuery',
byId: { __typename: 'DeviceRunSession', id: 'session-123', turtleJobRun },
},
},
})
);
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
await expect(
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
signal: new AbortController().signal,
})
).resolves.toEqual(turtleJobRun);
});
it('rejects without printing anything on a transient GraphQL error', async () => {
const logErrorSpy = jest.spyOn(Log, 'error').mockImplementation(() => {});
await startServerAsync(
respondWithJson({
data: null,
errors: [{ message: 'Try again', extensions: { isTransient: true } }],
})
);
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
await expect(
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
signal: new AbortController().signal,
})
).rejects.toThrow('Try again');
expect(logErrorSpy).not.toHaveBeenCalled();
expect(requests).toBe(1);
});
it('does not retry a network error', async () => {
await startServerAsync(request => {
request.socket.destroy();
});
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
await expect(
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
signal: new AbortController().signal,
})
).rejects.toThrow();
expect(requests).toBe(1);
});
it('rejects and cancels the request when the signal aborts', async () => {
const controller = new AbortController();
await startServerAsync(() => {
controller.abort(new Error('interrupted'));
});
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
await expect(
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
signal: controller.signal,
})
).rejects.toThrow('interrupted');
for (let attempt = 0; attempt < 50 && closedRequests === 0; attempt++) {
await new Promise(resolve => setTimeout(resolve, 20));
}
expect(closedRequests).toBe(1);
});
it('rejects without sending a request when the signal has already aborted', async () => {
await startServerAsync(() => {});
const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null });
const controller = new AbortController();
controller.abort(new Error('interrupted'));
await expect(
DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', {
signal: controller.signal,
})
).rejects.toThrow('interrupted');
expect(requests).toBe(0);
});
});
+21
View File
@@ -0,0 +1,21 @@
import { Help, Parser } from '@oclif/core';
export default class EasHelp extends Help {
// eslint-disable-next-line async-protect/async-suffix -- oclif defines the method name.
override async showHelp(argv: string[]): Promise<void> {
const [command, ...args] = argv;
if (command === 'simulator:control' || command === 'sim:control') {
const { default: SimulatorControl } = await import('./commands/simulator/control');
const parsed = await Parser.parse(args, {
args: SimulatorControl.args,
flags: SimulatorControl.flags,
strict: false,
});
if (parsed.args.action && parsed.flags.help) {
await SimulatorControl.run(args, this.config);
return;
}
}
await super.showHelp(argv);
}
}
@@ -0,0 +1,518 @@
{
"hardwareKeyboard.set": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"value": {
"type": "string",
"enum": [
"on",
"off"
]
}
},
"required": [
"udid",
"value"
],
"description": "Connect or disconnect the hardware keyboard."
},
"caDebug.set": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"option": {
"type": "string",
"enum": [
"blended",
"copies",
"misaligned",
"offscreen",
"slow-animations"
]
},
"value": {
"type": "string",
"enum": [
"on",
"off"
]
}
},
"required": [
"udid",
"option",
"value"
],
"description": "Turn a Core Animation debug option on or off."
},
"hinge.set": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"value": {
"anyOf": [
{
"type": "string",
"enum": [
"fold",
"half",
"unfold"
]
},
{
"type": "number"
}
]
}
},
"required": [
"udid",
"value"
],
"description": "Fold or unfold a foldable simulator."
},
"location.set": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"lat": {
"type": "number"
},
"lng": {
"type": "number"
}
},
"required": [
"udid",
"lat",
"lng"
],
"description": "Set the simulated location."
},
"permissions.list": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"bundleId": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
}
},
"required": [
"udid"
],
"description": "List permissions for one app or for every app."
},
"permissions.set": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"action": {
"type": "string",
"enum": [
"grant",
"revoke",
"reset"
]
},
"service": {
"type": "string",
"maxLength": 64,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"bundleId": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
}
},
"required": [
"udid",
"action",
"service",
"bundleId"
],
"description": "Grant, revoke or reset one permission for an app."
},
"app.install": {
"description": "Install an app from an .ipa file.",
"args": [
"path"
],
"upload": "path",
"anyOf": [
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"uploadId": {
"type": "string",
"pattern": "^(?!\\.)[A-Za-z0-9._-]{1,128}$"
}
},
"required": [
"udid",
"uploadId"
]
},
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"path": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\p{C}]+$"
}
},
"required": [
"udid",
"path"
]
}
]
},
"media.add": {
"description": "Add a photo or video to the Photos library.",
"args": [
"path"
],
"upload": "path",
"anyOf": [
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"uploadId": {
"type": "string",
"pattern": "^(?!\\.)[A-Za-z0-9._-]{1,128}$"
}
},
"required": [
"udid",
"uploadId"
]
},
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"path": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\p{C}]+$"
}
},
"required": [
"udid",
"path"
]
}
]
},
"camera.switch": {
"oneOf": [
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"source": {
"type": "string",
"const": "file"
},
"target": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\p{C}]+$"
}
},
"required": [
"udid",
"source",
"target"
]
},
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"source": {
"type": "string",
"const": "webcam"
},
"target": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\p{C}]+$"
}
},
"required": [
"udid",
"source"
]
},
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"source": {
"type": "string",
"const": "placeholder"
}
},
"required": [
"udid",
"source"
]
}
],
"description": "Change the camera feed of an app with a simulated camera.",
"args": [
"source",
"target"
],
"upload": "target",
"uploadWhen": {
"source": "file"
},
"keepUpload": true
},
"camera.inject": {
"oneOf": [
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"bundleId": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"mirror": {
"type": "string",
"enum": [
"on",
"off"
]
},
"source": {
"type": "string",
"const": "file"
},
"target": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\p{C}]+$"
}
},
"required": [
"udid",
"bundleId",
"mirror",
"source",
"target"
]
},
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"bundleId": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"mirror": {
"type": "string",
"enum": [
"on",
"off"
]
},
"source": {
"type": "string",
"const": "webcam"
},
"target": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\p{C}]+$"
}
},
"required": [
"udid",
"bundleId",
"mirror",
"source"
]
},
{
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"bundleId": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"mirror": {
"type": "string",
"enum": [
"on",
"off"
]
},
"source": {
"type": "string",
"const": "placeholder"
}
},
"required": [
"udid",
"bundleId",
"mirror",
"source"
]
}
],
"description": "Relaunch an app with a simulated camera feed.",
"args": [
"bundleId",
"mirror",
"source",
"target"
],
"upload": "target",
"uploadWhen": {
"source": "file"
},
"keepUpload": true
},
"screenshot.capture": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"fileName": {
"type": "string",
"pattern": "^serve-sim-screenshot-[A-Za-z0-9._-]{1,100}\\.png$"
}
},
"required": [
"udid"
],
"description": "Take a screenshot.",
"output": "path"
},
"file.readBase64": {
"type": "object",
"properties": {
"path": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\p{C}]+$"
}
},
"required": [
"path"
],
"description": "Download a file from the simulator host.",
"output": "base64"
},
"app.container": {
"type": "object",
"properties": {
"udid": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 ._()-]*$"
},
"bundleId": {
"type": "string",
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
}
},
"required": [
"udid",
"bundleId"
],
"description": "Print the path of an installed app bundle."
}
}
@@ -0,0 +1,633 @@
import * as fs from 'fs-extra';
import { once } from 'node:events';
import http from 'node:http';
import { AddressInfo } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { Duplex } from 'node:stream';
import { WebSocketServer } from 'ws';
import * as fetchModule from '../../fetch';
import {
HostActionCatalog,
HostActionConnection,
assertHostActionOutputAsync,
buildHostActionParams,
connectHostActionsAsync,
fetchHostActionCatalogAsync,
formatHostActionUsage,
saveHostActionOutputAsync,
uploadFileAsync,
} from '../hostActions';
const UDID = '90858C9D-BC34-4A0A-93D4-E4939A681A72';
const TOKEN = 'preview-token_123';
const catalog: HostActionCatalog = require('./fixtures/hostActionCatalog.json');
function entry(name: string): HostActionCatalog[string] {
return catalog[name];
}
describe(buildHostActionParams, () => {
it('fills parameters in schema order', () => {
expect(
buildHostActionParams('permissions.set', entry('permissions.set'), [
'grant',
'camera',
'com.example.app',
])
).toEqual({ action: 'grant', service: 'camera', bundleId: 'com.example.app' });
});
it('converts values to the types the schema accepts', () => {
expect(
buildHostActionParams('location.set', entry('location.set'), ['37.3349', '-122.009'])
).toEqual({ lat: 37.3349, lng: -122.009 });
expect(buildHostActionParams('hinge.set', entry('hinge.set'), ['90'])).toEqual({ value: 90 });
expect(buildHostActionParams('hinge.set', entry('hinge.set'), ['half'])).toEqual({
value: 'half',
});
expect(
buildHostActionParams('hardwareKeyboard.set', entry('hardwareKeyboard.set'), ['1'])
).toEqual({ value: '1' });
expect(buildHostActionParams('location.set', entry('location.set'), ['1e1', '.5'])).toEqual({
lat: 10,
lng: 0.5,
});
});
it('reads JSON for a parameter that takes a list', () => {
const launch = {
type: 'object',
properties: {
udid: { type: 'string' },
bundleId: { type: 'string' },
launchArgs: { type: 'array', items: { type: 'string' } },
},
};
expect(
buildHostActionParams('app.launch', launch, ['com.example.app', '["-FIRDebugEnabled"]'])
).toEqual({ bundleId: 'com.example.app', launchArgs: ['-FIRDebugEnabled'] });
expect(buildHostActionParams('app.launch', launch, ['com.example.app', '[oops'])).toEqual({
bundleId: 'com.example.app',
launchArgs: '[oops',
});
});
it('fills the values in order around the named ones', () => {
expect(buildHostActionParams('location.set', entry('location.set'), ['lat=1', '2'])).toEqual({
lat: 1,
lng: 2,
});
expect(
buildHostActionParams('permissions.set', entry('permissions.set'), [
'action=grant',
'camera',
'com.example.app',
])
).toEqual({ action: 'grant', service: 'camera', bundleId: 'com.example.app' });
});
it('accepts named values in any order', () => {
expect(
buildHostActionParams('permissions.list', entry('permissions.list'), [
'bundleId=com.example.app',
])
).toEqual({ bundleId: 'com.example.app' });
expect(
buildHostActionParams('camera.switch', entry('camera.switch'), [
'target=./photo.jpg',
'source=file',
])
).toEqual({ source: 'file', target: './photo.jpg' });
});
it('follows the value order the catalog gives for actions with several shapes', () => {
expect(buildHostActionParams('app.install', entry('app.install'), ['./MyApp.ipa'])).toEqual({
path: './MyApp.ipa',
});
expect(
buildHostActionParams('camera.switch', entry('camera.switch'), ['file', './photo.jpg'])
).toEqual({ source: 'file', target: './photo.jpg' });
expect(
buildHostActionParams('camera.inject', entry('camera.inject'), [
'com.example.app',
'off',
'placeholder',
])
).toEqual({ bundleId: 'com.example.app', mirror: 'off', source: 'placeholder' });
});
it('refuses extra values and a value given twice', () => {
expect(() =>
buildHostActionParams('hardwareKeyboard.set', entry('hardwareKeyboard.set'), ['on', 'off'])
).toThrow(
'Too many values for hardwareKeyboard.set. Usage: eas simulator:control hardwareKeyboard.set <value>.'
);
expect(() =>
buildHostActionParams('camera.switch', entry('camera.switch'), ['file', './a.jpg', './b.jpg'])
).toThrow('Too many values for camera.switch.');
expect(() =>
buildHostActionParams('hardwareKeyboard.set', entry('hardwareKeyboard.set'), [
'value=on',
'value=off',
])
).toThrow('value is given twice for hardwareKeyboard.set.');
});
});
describe(formatHostActionUsage, () => {
it.each(['anyOf', 'oneOf'])('shows a %s input group only once', composition => {
expect(
formatHostActionUsage('app.install', {
type: 'object',
properties: {
bundleId: { type: 'string' },
path: { type: 'string' },
uploadId: { type: 'string' },
},
required: ['bundleId'],
args: ['bundleId', 'path', 'uploadId'],
[composition]: [{ required: ['path'] }, { required: ['uploadId'] }],
})
).toBe('app.install <bundleId> <path|uploadId=...>');
});
it('shows the required alternative file sources for an upload action', () => {
expect(formatHostActionUsage('app.install', entry('app.install'))).toBe(
'app.install <uploadId=...|path>'
);
});
it('marks required and optional parameters', () => {
expect(formatHostActionUsage('permissions.set', entry('permissions.set'))).toBe(
'permissions.set <action> <service> <bundleId>'
);
expect(formatHostActionUsage('screenshot.capture', entry('screenshot.capture'))).toBe(
'screenshot.capture [fileName]'
);
expect(formatHostActionUsage('camera.switch', entry('camera.switch'))).toBe(
'camera.switch <source> [target]'
);
});
});
describe('host action transport policy', () => {
afterEach(() => jest.restoreAllMocks());
it.each([
'http://sim.example.test',
'http://127.0.0.1.example.test',
'http://localhost.example.test',
'http://[::ffff:192.0.2.1]',
'ftp://127.0.0.1',
'ws://127.0.0.1',
])('rejects %s before sending the session token', async apiUrl => {
const fetch = jest
.spyOn(fetchModule, 'default')
.mockRejectedValue(new Error('Unexpected fetch'));
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
'Simulator actions require HTTPS'
);
expect(fetch).not.toHaveBeenCalled();
});
it.each([
['https://sim.example.test', 'https://sim.example.test/api'],
['http://localhost:44370', 'http://localhost:44370/api'],
['http://127.0.0.1:44370', 'http://127.0.0.1:44370/api'],
['http://127.255.255.254:44370', 'http://127.255.255.254:44370/api'],
['http://2130706433:44370', 'http://127.0.0.1:44370/api'],
['http://[::1]:44370', 'http://[::1]:44370/api'],
])('accepts %s without following authenticated redirects', async (apiUrl, endpoint) => {
const stopped = new Error('Fetch reached');
const fetch = jest.spyOn(fetchModule, 'default').mockRejectedValue(stopped);
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toBe(stopped);
expect(fetch).toHaveBeenCalledWith(
endpoint,
expect.objectContaining({
headers: { Authorization: `Bearer ${TOKEN}` },
redirect: 'error',
})
);
});
});
describe('host action connection', () => {
let server: http.Server;
let wss: WebSocketServer;
let apiUrl: string;
let received: { id: number; action: string; params: Record<string, unknown> }[];
let requests: (string | undefined)[];
let apiBody: string;
let uploads: Map<string, Buffer>;
let reply: (message: {
id: number;
action: string;
params: Record<string, unknown>;
}) => Record<string, unknown> | null;
let tmpDir: string;
let connection: HostActionConnection | undefined;
let closeBeforeReady: boolean;
function serveSim(message: {
id: number;
action: string;
params: Record<string, unknown>;
}): Record<string, unknown> {
const ok = (stdout: string): Record<string, unknown> => ({
id: message.id,
stdout,
stderr: '',
exitCode: 0,
});
switch (message.action) {
case 'actions.describe':
return ok(JSON.stringify(catalog));
case 'upload.append': {
const uploadId = String(message.params.uploadId);
const chunk = Buffer.from(String(message.params.data), 'base64');
const earlier = message.params.first ? [] : [uploads.get(uploadId) ?? Buffer.alloc(0)];
uploads.set(uploadId, Buffer.concat([...earlier, chunk]));
return ok(`/tmp/serve-sim-uploads/${uploadId}\n`);
}
case 'file.readBase64':
return ok(Buffer.from('png bytes').toString('base64'));
default:
return ok('on\n');
}
}
beforeEach(async () => {
received = [];
requests = [];
uploads = new Map();
apiBody = JSON.stringify({ device: UDID });
reply = serveSim;
closeBeforeReady = false;
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'host-actions-'));
server = http.createServer((req, res) => {
requests.push(req.url);
if (req.url === '/api' && req.headers.authorization === `Bearer ${TOKEN}`) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(apiBody);
return;
}
res.writeHead(401);
res.end();
});
wss = new WebSocketServer({
server,
path: '/exec-ws',
handleProtocols: offered =>
offered.has(`serve-sim.token.${TOKEN}`) ? `serve-sim.token.${TOKEN}` : false,
});
wss.on('connection', ws => {
if (closeBeforeReady) {
ws.close();
return;
}
ws.send(JSON.stringify({ ready: true }));
ws.on('message', data => {
const message = JSON.parse(data.toString());
received.push(message);
const response = reply(message);
if (response) {
ws.send(JSON.stringify(response));
}
});
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
connection?.close();
connection = undefined;
for (const client of wss.clients) {
client.terminate();
}
wss.close();
server.close();
await once(server, 'close');
await fs.remove(tmpDir);
});
async function connectAsync(timeoutMs?: number): Promise<HostActionConnection> {
connection = await connectHostActionsAsync({ apiUrl, token: TOKEN }, { timeoutMs });
return connection;
}
it('sends several requests over one connection with the simulator udid', async () => {
const hostActions = await connectAsync();
const results = await Promise.all([
hostActions.requestAsync('hardwareKeyboard.get'),
hostActions.requestAsync('hardwareKeyboard.set', { value: 'off' }),
]);
expect(received).toEqual([
{ id: 1, action: 'hardwareKeyboard.get', params: { udid: UDID } },
{ id: 2, action: 'hardwareKeyboard.set', params: { value: 'off', udid: UDID } },
]);
expect(results).toEqual([
{ stdout: 'on\n', stderr: '', exitCode: 0 },
{ stdout: 'on\n', stderr: '', exitCode: 0 },
]);
});
it('reads the catalog of actions', async () => {
const hostActions = await connectAsync();
await expect(fetchHostActionCatalogAsync(hostActions)).resolves.toEqual(catalog);
});
it('explains that an old serve-sim does not have the catalog', async () => {
reply = message => ({ id: message.id, error: 'unknown action actions.describe' });
const hostActions = await connectAsync();
await expect(fetchHostActionCatalogAsync(hostActions)).rejects.toThrow(
'does not support this action'
);
});
it('passes a validation error from the simulator through', async () => {
reply = message => ({
id: message.id,
error: 'hardwareKeyboard.set: value must be one of on, off',
});
const hostActions = await connectAsync();
await expect(
hostActions.requestAsync('hardwareKeyboard.set', { value: 'maybe' })
).rejects.toThrow(
'The simulator refused the request: hardwareKeyboard.set: value must be one of on, off'
);
});
it('fails a request at once on a reply it cannot read', async () => {
reply = message => ({ id: message.id, output: 'on' });
const hostActions = await connectAsync();
await expect(hostActions.requestAsync('memoryWarning')).rejects.toThrow(
'sent a reply that this version of eas-cli cannot read'
);
});
it('gives up on a request the simulator does not answer', async () => {
reply = () => null;
const hostActions = await connectAsync(100);
await expect(hostActions.requestAsync('memoryWarning')).rejects.toThrow(
'The simulator did not answer within'
);
});
it('keeps a path on the API URL and drops its query', async () => {
await expect(
connectHostActionsAsync({ apiUrl: `${apiUrl}/preview?token=${TOKEN}`, token: TOKEN })
).rejects.toThrow('refused the session token (HTTP 401)');
expect(requests).toEqual(['/preview/api']);
});
it('refuses a wrong token before it opens the socket', async () => {
await expect(connectHostActionsAsync({ apiUrl, token: 'wrong' })).rejects.toThrow(
'refused the session token (HTTP 401)'
);
expect(wss.clients.size).toBe(0);
});
it('does not send the session token to an API redirect target', async () => {
server.removeAllListeners('request');
server.on('request', (req, res) => {
requests.push(req.url);
if (req.url === '/api') {
res.writeHead(302, { Location: '/redirected-api' });
res.end();
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(apiBody);
}
});
await expect(connectAsync()).rejects.toThrow(/redirect/i);
expect(requests).toEqual(['/api']);
expect(wss.clients.size).toBe(0);
});
it('explains a connection that closes during setup', async () => {
closeBeforeReady = true;
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
/closed the connection during setup.*token.*eas simulator:get/
);
});
it.each([401, 403])(
'closes a refused HTTP %i upgrade without waiting for its body',
async status => {
let rejectedSocket: Duplex | undefined;
let timer: NodeJS.Timeout | undefined;
const closed = new Promise<void>(resolve => {
server.removeAllListeners('upgrade');
server.once('upgrade', (_req, socket) => {
rejectedSocket = socket;
socket.once('close', resolve);
socket.on('end', () => socket.end());
socket.write(
`HTTP/1.1 ${status} Refused\r\nConnection: keep-alive\r\nContent-Length: 1000000\r\n\r\nrefused`
);
});
});
try {
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
`refused the connection (HTTP ${status})`
);
await Promise.race([
closed,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
reject(new Error('Refused upgrade socket stayed open'));
}, 1000);
}),
]);
} finally {
clearTimeout(timer);
rejectedSocket?.destroy();
}
}
);
it('explains a preview server that does not report its simulator', async () => {
apiBody = '<html>starting</html>';
await expect(connectHostActionsAsync({ apiUrl, token: TOKEN })).rejects.toThrow(
'did not report which simulator it streams'
);
});
it('uploads a file in order and returns where the simulator host stored it', async () => {
const localFile = path.join(tmpDir, 'MyApp.ipa');
await fs.writeFile(localFile, 'hello simulator');
const hostActions = await connectAsync();
const upload = await uploadFileAsync(hostActions, localFile, { chunkBytes: 4 });
expect(upload.uploadId).toMatch(/^eas-cli-[0-9a-f-]{36}\.ipa$/);
expect(upload.path).toBe(`/tmp/serve-sim-uploads/${upload.uploadId}`);
expect(uploads.get(upload.uploadId)?.toString()).toBe('hello simulator');
expect(received.map(message => message.params.first)).toEqual([true, false, false, false]);
});
it('removes a partial upload when a chunk fails', async () => {
const localFile = path.join(tmpDir, 'MyApp.ipa');
await fs.writeFile(localFile, 'hello simulator');
reply = message =>
message.action === 'upload.append' && !message.params.first
? { id: message.id, stdout: '', stderr: 'The upload staging area is full.', exitCode: 1 }
: serveSim(message);
const hostActions = await connectAsync();
await expect(uploadFileAsync(hostActions, localFile, { chunkBytes: 4 })).rejects.toThrow(
`Could not upload ${localFile}: The upload staging area is full.`
);
expect(received.at(-1)).toMatchObject({
action: 'upload.remove',
params: { uploadId: received[0].params.uploadId },
});
});
it('refuses to upload an empty file', async () => {
const localFile = path.join(tmpDir, 'empty.png');
await fs.writeFile(localFile, '');
const hostActions = await connectAsync();
await expect(uploadFileAsync(hostActions, localFile)).rejects.toThrow(`${localFile} is empty.`);
});
it('downloads a file the action left on the simulator host', async () => {
const hostActions = await connectAsync();
const outputPath = path.join(tmpDir, 'shot.png');
const saved = await saveHostActionOutputAsync(
hostActions,
entry('screenshot.capture'),
'/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n',
outputPath
);
expect(saved).toBe(outputPath);
expect(received.at(-1)).toMatchObject({
action: 'file.readBase64',
params: { path: '/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png' },
});
expect(await fs.readFile(outputPath, 'utf8')).toBe('png bytes');
});
it('saves into a folder under the name the host gave the file', async () => {
const hostActions = await connectAsync();
const saved = await saveHostActionOutputAsync(
hostActions,
entry('screenshot.capture'),
'/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n',
tmpDir
);
expect(saved).toBe(path.join(tmpDir, 'serve-sim-screenshot-1.png'));
expect(await fs.readFile(path.join(tmpDir, 'serve-sim-screenshot-1.png'), 'utf8')).toBe(
'png bytes'
);
});
it('does not replace a file it named itself', async () => {
const existing = path.join(tmpDir, 'serve-sim-screenshot-1.png');
await fs.writeFile(existing, 'mine');
const hostActions = await connectAsync();
await expect(
saveHostActionOutputAsync(
hostActions,
entry('screenshot.capture'),
'/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n',
tmpDir
)
).rejects.toThrow(`${existing} already exists.`);
expect(await fs.readFile(existing, 'utf8')).toBe('mine');
});
it('saves file contents the action returned', async () => {
const hostActions = await connectAsync();
const outputPath = path.join(tmpDir, 'Info.plist');
await saveHostActionOutputAsync(
hostActions,
entry('file.readBase64'),
Buffer.from('plist').toString('base64'),
outputPath
);
expect(await fs.readFile(outputPath, 'utf8')).toBe('plist');
});
it('saves nothing for an action that returns no file', async () => {
const hostActions = await connectAsync();
await expect(
saveHostActionOutputAsync(hostActions, entry('hardwareKeyboard.set'), 'on', undefined)
).resolves.toBeNull();
});
});
describe(assertHostActionOutputAsync, () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'host-action-output-'));
});
afterEach(async () => {
await fs.remove(tmpDir);
});
it('allows --output only for actions that return a file', async () => {
await expect(
assertHostActionOutputAsync('app.container', entry('app.container'), './out')
).rejects.toThrow('app.container does not return a file, so --output does not apply.');
await expect(
assertHostActionOutputAsync('screenshot.capture', entry('screenshot.capture'), undefined)
).resolves.toBeUndefined();
await expect(
assertHostActionOutputAsync('screenshot.capture', entry('screenshot.capture'), tmpDir)
).resolves.toBeUndefined();
});
it('needs a file path for an action that returns file contents', async () => {
await expect(
assertHostActionOutputAsync('file.readBase64', entry('file.readBase64'), undefined)
).rejects.toThrow('file.readBase64 returns a file. Pass --output with the path to save it to.');
await expect(
assertHostActionOutputAsync('file.readBase64', entry('file.readBase64'), tmpDir)
).rejects.toThrow(`${tmpDir} is a folder. Pass --output with a file path for file.readBase64.`);
});
it('refuses a path in a folder that does not exist', async () => {
const missing = path.join(tmpDir, 'missing', 'shot.png');
await expect(
assertHostActionOutputAsync('screenshot.capture', entry('screenshot.capture'), missing)
).rejects.toThrow(`The folder for ${missing} does not exist.`);
await expect(
assertHostActionOutputAsync(
'screenshot.capture',
entry('screenshot.capture'),
path.join(tmpDir, 'missing') + path.sep
)
).rejects.toThrow('does not exist');
});
});
@@ -0,0 +1,479 @@
import http from 'http';
import { AddressInfo } from 'net';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import type LogType from '../../log';
import type * as JobRunErrors from '../jobRunErrors';
jest.mock('../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../log');
const mockJobRunErrorsByIdAsync = jest.mocked(DeviceRunSessionQuery.jobRunErrorsByIdAsync);
const graphqlClient = {} as ExpoGraphqlClient;
const unknownError = {
errorCode: 'UNKNOWN_ERROR',
message: 'Unknown error. See logs for more information.',
docsUrl: null,
};
// src/fetch.ts reads https_proxy when it loads, so load it only after clearing the proxy.
let jobRunErrors: typeof JobRunErrors;
let Log: typeof LogType;
beforeAll(() => {
delete process.env.https_proxy;
jest.isolateModules(() => {
jobRunErrors = jest.requireActual('../jobRunErrors');
Log = jest.requireMock('../../log').default;
});
});
// Lines 116-121 of a real failed Android session log, trimmed. The step's stderr has the precise
// cause at level 30; the level-50 error line only has the exit code.
const failedLaunchLog = [
{
level: 30,
buildStepId: 'install_build',
marker: 'end-step',
result: 'success',
msg: 'Finished build step "Install build" successfully',
},
{
level: 30,
buildStepId: 'install_build',
source: 'stderr',
msg: 'Error: Unable to find emulator skin, using default',
},
{
level: 30,
buildStepId: 'step-002',
buildStepDisplayName: 'Launch application',
marker: 'start-step',
msg: 'Executing build step "Launch application"',
},
{
level: 30,
buildStepId: 'step-002',
buildStepDisplayName: 'Launch application',
source: 'stdout',
msg: 'Starting: Intent { act=android.intent.action.MAIN pkg=world }',
},
{
level: 30,
buildStepId: 'step-002',
buildStepDisplayName: 'Launch application',
source: 'stderr',
msg: 'Error: Activity not started, unable to resolve Intent { act=android.intent.action.MAIN pkg=world }',
},
{
level: 50,
buildStepId: 'step-002',
buildStepDisplayName: 'Launch application',
msg: 'adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1',
err: {
message: 'adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1',
},
},
{
level: 50,
buildStepId: 'step-002',
buildStepDisplayName: 'Launch application',
marker: 'end-step',
result: 'fail',
msg: 'Build step "Launch application" failed',
},
];
describe('selectFailedStepErrors', () => {
it("shows the failed step's stderr cause before its error line", () => {
expect(jobRunErrors.selectFailedStepErrors(failedLaunchLog)).toEqual([
'Launch application: Error: Activity not started, unable to resolve Intent { act=android.intent.action.MAIN pkg=world }',
'Launch application: adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1',
]);
});
it('ignores stderr from steps that did not fail', () => {
const messages = jobRunErrors.selectFailedStepErrors(failedLaunchLog);
expect(messages.join('\n')).not.toContain('emulator skin');
});
it('never prints the unmasked err.message of a phase failure with an empty message', () => {
const phaseFailureLog = [
{ level: 30, phase: 'PREPARE_PROJECT', msg: 'Start phase: PREPARE_PROJECT' },
{
level: 50,
phase: 'PREPARE_PROJECT',
msg: '',
err: {
message: 'git clone https://[email protected]/acme/app failed',
stack: 'Error: git clone https://[email protected]/acme/app failed',
},
},
];
expect(jobRunErrors.selectFailedStepErrors(phaseFailureLog)).toEqual([]);
});
it('ignores errors outside of failed steps, such as errors a step ignored', () => {
expect(
jobRunErrors.selectFailedStepErrors([
{
level: 50,
buildStepId: 'step-001',
buildStepDisplayName: 'Upload artifact',
msg: 'Failed to upload screen-recording. Ignoring error.',
err: { message: 'upload failed' },
},
{
level: 30,
buildStepId: 'step-001',
marker: 'end-step',
result: 'success',
msg: 'Finished',
},
{
level: 50,
msg: 'simctl boot failed for ********',
err: { message: 'simctl boot failed for secret-udid' },
},
])
).toEqual([]);
});
it('keeps the stderr line that continues the cause', () => {
expect(
jobRunErrors.selectFailedStepErrors([
{
level: 30,
buildStepId: 'step-001',
buildStepDisplayName: 'Boot simulator',
source: 'stderr',
msg: 'An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):',
},
{
level: 30,
buildStepId: 'step-001',
buildStepDisplayName: 'Boot simulator',
source: 'stderr',
msg: 'Invalid device: iPhone 16 Pro',
},
{
level: 50,
buildStepId: 'step-001',
buildStepDisplayName: 'Boot simulator',
msg: 'xcrun simctl boot exited with non-zero code: 149',
err: { message: 'xcrun simctl boot exited with non-zero code: 149' },
},
{
level: 50,
buildStepId: 'step-001',
marker: 'end-step',
result: 'fail',
msg: 'Build step "Boot simulator" failed',
},
])
).toEqual([
'Boot simulator: An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):',
'Boot simulator: Invalid device: iPhone 16 Pro',
'Boot simulator: xcrun simctl boot exited with non-zero code: 149',
]);
});
it('keeps the cause of the first failed step when later steps also fail', () => {
expect(
jobRunErrors.selectFailedStepErrors([
...failedLaunchLog,
{
level: 30,
buildStepId: 'step-003',
buildStepDisplayName: 'Finish recordings',
source: 'stderr',
msg: 'Error: No devices are booted.',
},
{
level: 50,
buildStepId: 'step-003',
buildStepDisplayName: 'Finish recordings',
msg: 'xcrun simctl io recordVideo exited with non-zero code: 1',
err: { message: 'x' },
},
{
level: 50,
buildStepId: 'step-003',
marker: 'end-step',
result: 'fail',
msg: 'Build step "Finish recordings" failed',
},
])
).toEqual([
'Launch application: Error: Activity not started, unable to resolve Intent { act=android.intent.action.MAIN pkg=world }',
'Launch application: adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1',
'Finish recordings: xcrun simctl io recordVideo exited with non-zero code: 1',
]);
});
it('strips escape sequences and control characters from step names and messages', () => {
expect(
jobRunErrors.selectFailedStepErrors([
{
level: 50,
buildStepId: 'step-001',
buildStepDisplayName: 'Boot\u001b[2J simulator',
msg: '\u001b[31mxcrun simctl boot\u001b[0m exited\r\nwith code: 149\u0007',
err: { message: 'x' },
},
{
level: 50,
buildStepId: 'step-001',
marker: 'end-step',
result: 'fail',
msg: 'Build step "Boot simulator" failed',
},
])
).toEqual(['Boot simulator: xcrun simctl boot exited with code: 149']);
});
it('finds a colored stderr cause and skips error lines that are only escape sequences', () => {
expect(
jobRunErrors.selectFailedStepErrors([
{
level: 30,
buildStepId: 'step-001',
buildStepDisplayName: 'Launch application',
source: 'stderr',
msg: '\u001b[31mError\u001b[0m: Activity not started',
},
{
level: 50,
buildStepId: 'step-001',
buildStepDisplayName: 'Launch application',
msg: 'adb shell am start exited with non-zero code: 1',
err: { message: 'x' },
},
{
level: 50,
buildStepId: 'step-001',
buildStepDisplayName: 'Launch application',
msg: '\u001b[0m\u001b[K',
err: { message: 'x' },
},
{
level: 50,
buildStepId: 'step-001',
marker: 'end-step',
result: 'fail',
msg: 'Build step "Launch application" failed',
},
])
).toEqual([
'Launch application: Error: Activity not started',
'Launch application: adb shell am start exited with non-zero code: 1',
]);
});
});
describe('fetchSimulatorSessionErrorsAsync', () => {
let server: http.Server;
let closedRequests: number;
async function startServerAsync(handler: http.RequestListener): Promise<string> {
closedRequests = 0;
server = http.createServer((request, response) => {
request.socket.on('close', () => {
closedRequests += 1;
});
handler(request, response);
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
return `http://127.0.0.1:${port}/logs`;
}
beforeEach(() => {
jest.clearAllMocks();
});
async function waitForCloseAsync(): Promise<void> {
for (let attempt = 0; attempt < 50 && closedRequests === 0; attempt++) {
await new Promise(resolve => setTimeout(resolve, 20));
}
}
afterEach(async () => {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
});
function mockJobRun(logFileUrl: string): void {
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [unknownError],
logFileUrls: [logFileUrl],
});
}
it('returns the failed step errors from the downloaded log', async () => {
mockJobRun(
await startServerAsync((_request, response) => {
response.end(failedLaunchLog.map(line => JSON.stringify(line)).join('\n'));
})
);
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123')
).resolves.toEqual([
{
errorCode: 'UNKNOWN_ERROR',
message: expect.stringContaining('Activity not started'),
docsUrl: null,
},
{
errorCode: 'UNKNOWN_ERROR',
message: expect.stringContaining('exited with non-zero code: 1'),
docsUrl: null,
},
]);
});
it('does not log the signed log URL when the download fails', async () => {
const logFileUrl = await startServerAsync((request, response) => {
request.socket.destroy();
response.end();
});
mockJobRun(`${logFileUrl}?X-Goog-Signature=secret-signature`);
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123')
).resolves.toEqual([unknownError]);
expect(JSON.stringify(jest.mocked(Log.debug).mock.calls)).not.toContain('secret-signature');
});
it('strips escape sequences and control characters from job run error messages', async () => {
server = http.createServer();
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [
{
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
message: 'No available iOS Simulator is named "\u001b[2JiPhone\u001b]0;x\u0007\nFake"',
docsUrl: null,
},
],
logFileUrls: [],
});
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123')
).resolves.toEqual([
{
errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND',
message: 'No available iOS Simulator is named "iPhone Fake"',
docsUrl: null,
},
]);
});
it('falls back to the job run error when the log request fails', async () => {
mockJobRun(
await startServerAsync((_request, response) => {
response.statusCode = 404;
response.end(JSON.stringify({ level: 50, msg: 'not the cause', err: { message: 'x' } }));
})
);
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123')
).resolves.toEqual([unknownError]);
});
it('falls back and closes the connection when the log is too large', async () => {
mockJobRun(
await startServerAsync((_request, response) => {
const chunk = 'x'.repeat(64 * 1024);
const timer = setInterval(() => response.write(chunk), 5);
response.on('close', () => {
clearInterval(timer);
});
})
);
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
maxLogSizeBytes: 256 * 1024,
})
).resolves.toEqual([unknownError]);
await waitForCloseAsync();
expect(closedRequests).toBe(1);
});
it('falls back and closes the connection when the log download stalls', async () => {
mockJobRun(
await startServerAsync((_request, response) => {
response.write('{"level":30,');
})
);
const startedAt = Date.now();
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
logDownloadTimeoutMs: 200,
})
).resolves.toEqual([unknownError]);
expect(Date.now() - startedAt).toBeLessThan(2_000);
await waitForCloseAsync();
expect(closedRequests).toBe(1);
});
it('stops the log download when the signal aborts', async () => {
mockJobRun(
await startServerAsync((_request, response) => {
response.write('{"level":30,');
})
);
const controller = new AbortController();
setTimeout(() => {
controller.abort();
}, 100);
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
signal: controller.signal,
})
).resolves.toEqual([unknownError]);
await waitForCloseAsync();
expect(closedRequests).toBe(1);
});
it('does not look up errors when the signal has already aborted', async () => {
server = http.createServer();
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const controller = new AbortController();
controller.abort();
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
signal: controller.signal,
})
).resolves.toEqual([]);
expect(mockJobRunErrorsByIdAsync).not.toHaveBeenCalled();
});
it('passes an abortable signal with a deadline to the errors query', async () => {
server = http.createServer();
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
mockJobRunErrorsByIdAsync.mockImplementation(
async (_client, _id, { signal }) =>
await new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => {
reject(signal.reason);
});
})
);
await expect(
jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', {
queryTimeoutMs: 100,
})
).resolves.toEqual([]);
});
});
@@ -19,6 +19,7 @@ import {
formatSimulatorUnavailableMessage,
getLocalEgressConfig,
getLoopbackForwardPlan,
getPreviewApiAccess,
getRemoteSessionEnvironmentVariables,
sanitizeRemoteConfigForJson,
} from '../utils';
@@ -837,6 +838,45 @@ describe.each(controllerConfigs)('$__typename local egress', remoteConfig => {
});
});
describe(getPreviewApiAccess, () => {
it('reads the preview token for each remote config type', () => {
expect(
getPreviewApiAccess({
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
previewUrl: 'https://expo.dev/preview',
previewToken: 'preview-token',
previewApiUrl: 'https://sim.example.test',
})
).toEqual({ apiUrl: 'https://sim.example.test', token: 'preview-token' });
expect(
getPreviewApiAccess({
__typename: 'AgentDeviceRunSessionRemoteConfig',
agentDeviceRemoteSessionUrl: 'https://agent.example.test',
agentDeviceRemoteSessionToken: 'agent-token',
webPreviewToken: 'preview-token',
previewApiUrl: 'https://sim.example.test',
})
).toEqual({ apiUrl: 'https://sim.example.test', token: 'preview-token' });
});
it('returns null without an API URL or a token', () => {
expect(
getPreviewApiAccess({
__typename: 'ServeSimRunSessionRemoteConfig',
previewUrl: 'https://sim.example.test',
previewToken: 'preview-token',
})
).toBeNull();
expect(
getPreviewApiAccess({
__typename: 'ServeSimRunSessionRemoteConfig',
previewUrl: 'https://sim.example.test',
previewApiUrl: 'https://sim.example.test',
})
).toBeNull();
});
});
describe(formatEmbedPreviewUrl, () => {
it('is null until the worker reports the preview host', () => {
expect(formatEmbedPreviewUrl(agentDeviceConfig)).toBeNull();
@@ -0,0 +1,520 @@
import { randomUUID } from 'crypto';
import * as fs from 'fs-extra';
import path from 'path';
import WebSocket from 'ws';
import { z } from 'zod';
import { PreviewApiAccess } from './utils';
import fetch, { RequestError, httpsProxyAgent } from '../fetch';
export type HostActionResult = {
stdout: string;
stderr: string;
exitCode: number;
};
export type HostActionConnection = {
requestAsync(action: string, params?: Record<string, unknown>): Promise<HostActionResult>;
close(): void;
};
const SchemaNode = z.looseObject({
type: z.union([z.string(), z.array(z.string())]).optional(),
properties: z.record(z.string(), z.unknown()).optional(),
required: z.array(z.string()).optional(),
allOf: z.array(z.unknown()).optional(),
anyOf: z.array(z.unknown()).optional(),
oneOf: z.array(z.unknown()).optional(),
});
const CatalogEntry = SchemaNode.extend({
description: z.string().optional(),
args: z.array(z.string()).optional(),
upload: z.string().optional(),
uploadAs: z.enum(['path', 'id']).optional(),
uploadWhen: z.record(z.string(), z.unknown()).optional(),
keepUpload: z.boolean().optional(),
output: z.enum(['path', 'base64']).optional(),
});
const Catalog = z.record(z.string(), CatalogEntry);
export type HostActionCatalog = z.infer<typeof Catalog>;
export type HostActionCatalogEntry = z.infer<typeof CatalogEntry>;
const PreviewConfig = z.object({ device: z.string().min(1) });
const ReplyId = z.object({ id: z.number() });
const SocketMessage = z.union([
z.object({ ready: z.literal(true) }),
z.object({ id: z.number(), error: z.string() }),
z.object({
id: z.number(),
stdout: z.string(),
stderr: z.string(),
exitCode: z.number(),
}),
]);
const HOST_ACTION_TIMEOUT_MS = 130_000;
const PREVIEW_API_TIMEOUT_MS = 30_000;
const UPLOAD_CHUNK_BYTES = 2 * 1024 * 1024;
const NAMED_VALUE = /^([A-Za-z][A-Za-z0-9]*)=(.*)$/s;
const NUMBER = /^-?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
function previewEndpoint(apiUrl: string, endpoint: string): URL {
const base = new URL(apiUrl);
const loopback =
base.hostname === 'localhost' ||
base.hostname === '[::1]' ||
/^127(?:\.\d{1,3}){3}$/.test(base.hostname);
if (base.protocol !== 'https:' && (base.protocol !== 'http:' || !loopback)) {
throw new Error(
'Simulator actions require HTTPS for remote preview servers. HTTP is supported only on localhost or a loopback address. Start a new session with `eas simulator:start`.'
);
}
base.search = '';
base.hash = '';
if (!base.pathname.endsWith('/')) {
base.pathname += '/';
}
return new URL(endpoint, base);
}
async function fetchSimulatorUdidAsync({ apiUrl, token }: PreviewApiAccess): Promise<string> {
let text: string;
try {
const response = await fetch(previewEndpoint(apiUrl, 'api').toString(), {
headers: { Authorization: `Bearer ${token}` },
redirect: 'error',
signal: AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS),
});
text = await response.text();
} catch (error) {
if (error instanceof RequestError && [401, 403].includes(error.response.status)) {
throw new Error(
`The simulator preview server refused the session token (HTTP ${error.response.status}). Run \`eas simulator:get\` to check that the session still runs, then try again.`
);
}
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(
`The simulator preview server did not answer within ${PREVIEW_API_TIMEOUT_MS / 1000} seconds. Run \`eas simulator:get\` to check that the session still runs, then try again.`
);
}
throw error;
}
let body: unknown = null;
try {
body = JSON.parse(text);
} catch {}
const config = PreviewConfig.safeParse(body);
if (!config.success) {
throw new Error(
'The simulator preview server did not report which simulator it streams. The session may still be starting. Wait a few seconds and run the command again.'
);
}
return config.data.device;
}
function refusedError(error: string): Error {
return error.startsWith('unknown action')
? new Error(
'This simulator session runs a serve-sim version that does not support this action. Start a new session with `eas simulator:start` to get a newer version.'
)
: new Error(`The simulator refused the request: ${error}`);
}
export async function connectHostActionsAsync(
access: PreviewApiAccess,
{ timeoutMs = HOST_ACTION_TIMEOUT_MS }: { timeoutMs?: number } = {}
): Promise<HostActionConnection> {
const udid = await fetchSimulatorUdidAsync(access);
const socketUrl = previewEndpoint(access.apiUrl, 'exec-ws');
socketUrl.protocol = socketUrl.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(
socketUrl.toString(),
[`serve-sim.token.${access.token}`],
httpsProxyAgent ? { agent: httpsProxyAgent } : {}
);
type Pending = {
resolve: (result: HostActionResult) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
};
const pending = new Map<number, Pending>();
let nextId = 1;
let lost: Error | null = null;
let ready = false;
function settle(id: number): Pending | undefined {
const request = pending.get(id);
if (request) {
pending.delete(id);
clearTimeout(request.timer);
}
return request;
}
function loseConnection(error: Error): void {
lost ??= error;
for (const id of [...pending.keys()]) {
settle(id)?.reject(error);
}
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(
new Error(
`The simulator preview server did not accept the connection within ${PREVIEW_API_TIMEOUT_MS / 1000} seconds. Check that the session still runs with \`eas simulator:get\`, then try again.`
)
);
ws.terminate();
}, PREVIEW_API_TIMEOUT_MS);
const fail = (error: Error): void => {
clearTimeout(timer);
reject(error);
loseConnection(error);
if (!ready) {
ws.terminate();
}
};
ws.on('message', data => {
let message: unknown;
try {
message = JSON.parse(data.toString());
} catch {
return;
}
const parsed = SocketMessage.safeParse(message);
if (!parsed.success) {
const id = ReplyId.safeParse(message);
if (id.success) {
settle(id.data.id)?.reject(
new Error(
'The simulator preview server sent a reply that this version of eas-cli cannot read. Update eas-cli and try again.'
)
);
}
return;
}
const reply = parsed.data;
if ('ready' in reply) {
ready = true;
clearTimeout(timer);
resolve();
} else if ('error' in reply) {
settle(reply.id)?.reject(refusedError(reply.error));
} else {
settle(reply.id)?.resolve({
stdout: reply.stdout,
stderr: reply.stderr,
exitCode: reply.exitCode,
});
}
});
ws.on('unexpected-response', (_req, res) => {
res.destroy();
fail(
new Error(
`The simulator preview server refused the connection (HTTP ${res.statusCode}). The session token may be out of date. Run \`eas simulator:get\` to check the session, then try again.`
)
);
});
ws.on('error', error => {
fail(
new Error(
`Could not connect to the simulator preview server: ${error.message}. Check your network connection and that the session still runs.`
)
);
});
ws.on('close', () => {
fail(
new Error(
ready
? 'The simulator preview server closed the connection before it answered. The action may still have run. Check the current state before you try again.'
: 'The simulator preview server closed the connection during setup. The session token may be invalid or expired, or the session may run an older serve-sim version. Check the session with `eas simulator:get` before starting a new session.'
)
);
});
});
return {
async requestAsync(action, params = {}) {
if (lost) {
throw lost;
}
const id = nextId++;
return await new Promise<HostActionResult>((resolve, reject) => {
const timer = setTimeout(() => {
settle(id)?.reject(
new Error(
`The simulator did not answer within ${Math.round(timeoutMs / 1000)} seconds. The action may still have run. Check the session with \`eas simulator:get\` and the current state before you try again.`
)
);
}, timeoutMs);
pending.set(id, { resolve, reject, timer });
ws.send(JSON.stringify({ id, action, params: { ...params, udid } }));
});
},
close() {
ws.close();
},
};
}
export async function fetchHostActionCatalogAsync(
connection: HostActionConnection
): Promise<HostActionCatalog> {
const result = await connection.requestAsync('actions.describe');
let body: unknown = null;
try {
body = JSON.parse(result.stdout);
} catch {}
const catalog = Catalog.safeParse(body);
if (result.exitCode !== 0 || !catalog.success) {
throw new Error(
'The simulator did not return its list of actions. Start a new session with `eas simulator:start` to get a newer serve-sim version.'
);
}
return catalog.data;
}
function collectProperties(node: unknown, into: Map<string, unknown[]>): void {
const parsed = SchemaNode.safeParse(node);
if (!parsed.success) {
return;
}
for (const [name, schema] of Object.entries(parsed.data.properties ?? {})) {
into.set(name, [...(into.get(name) ?? []), schema]);
}
const { allOf = [], anyOf = [], oneOf = [] } = parsed.data;
for (const child of [...allOf, ...anyOf, ...oneOf]) {
collectProperties(child, into);
}
}
function hostActionProperties(entry: HostActionCatalogEntry): Map<string, unknown[]> {
const properties = new Map<string, unknown[]>();
collectProperties(entry, properties);
properties.delete('udid');
return properties;
}
function positionalNames(entry: HostActionCatalogEntry): string[] {
return entry.args ?? Object.keys(entry.properties ?? {}).filter(name => name !== 'udid');
}
function requiredProperties(node: unknown): Set<string> {
const parsed = SchemaNode.safeParse(node);
if (!parsed.success) {
return new Set();
}
const required = new Set(parsed.data.required ?? []);
for (const child of parsed.data.allOf ?? []) {
requiredProperties(child).forEach(name => required.add(name));
}
const branches = [...(parsed.data.anyOf ?? []), ...(parsed.data.oneOf ?? [])].map(
requiredProperties
);
for (const name of branches[0] ?? []) {
if (branches.every(branch => branch.has(name))) {
required.add(name);
}
}
return required;
}
function acceptsType(schema: unknown, types: string[]): boolean {
const node = SchemaNode.safeParse(schema);
if (!node.success) {
return false;
}
const own = [node.data.type ?? []].flat();
return (
own.some(type => types.includes(type)) ||
[...(node.data.anyOf ?? []), ...(node.data.oneOf ?? [])].some(child =>
acceptsType(child, types)
)
);
}
function coerce(value: string, schemas: unknown[]): unknown {
if (/^[[{]/.test(value) && schemas.some(schema => acceptsType(schema, ['array', 'object']))) {
try {
return JSON.parse(value);
} catch {
return value;
}
}
if (NUMBER.test(value) && schemas.some(schema => acceptsType(schema, ['number', 'integer']))) {
return Number(value);
}
if (
(value === 'true' || value === 'false') &&
schemas.some(schema => acceptsType(schema, ['boolean']))
) {
return value === 'true';
}
return value;
}
export function formatHostActionUsage(name: string, entry: HostActionCatalogEntry): string {
const required = requiredProperties(entry);
const alternatives = (entry.anyOf ?? entry.oneOf ?? []).map(branch =>
[...requiredProperties(branch)].filter(
property => property !== 'udid' && !required.has(property)
)
);
const requiredAlternatives =
alternatives.length > 0 && alternatives.every(properties => properties.length === 1)
? alternatives.map(([property]) => property)
: [];
let describedAlternatives = false;
const names = positionalNames(entry).flatMap(property => {
if (requiredAlternatives.includes(property)) {
if (describedAlternatives) {
return [];
}
describedAlternatives = true;
return [
`<${requiredAlternatives.map(name => (name === property ? name : `${name}=...`)).join('|')}>`,
];
}
return [required.has(property) ? `<${property}>` : `[${property}]`];
});
return [name, ...names].join(' ');
}
export function buildHostActionParams(
name: string,
entry: HostActionCatalogEntry,
values: string[]
): Record<string, unknown> {
const properties = hostActionProperties(entry);
const params: Record<string, unknown> = {};
const positional: string[] = [];
for (const value of values) {
const named = NAMED_VALUE.exec(value);
if (!named || !properties.has(named[1])) {
positional.push(value);
} else if (Object.hasOwn(params, named[1])) {
throw new Error(`${named[1]} is given twice for ${name}.`);
} else {
params[named[1]] = coerce(named[2], properties.get(named[1]) ?? []);
}
}
const open = positionalNames(entry).filter(property => !Object.hasOwn(params, property));
if (positional.length > open.length) {
throw new Error(
`Too many values for ${name}. Usage: eas simulator:control ${formatHostActionUsage(name, entry)}. Name a value with name=value.`
);
}
positional.forEach((value, index) => {
params[open[index]] = coerce(value, properties.get(open[index]) ?? []);
});
return params;
}
export async function assertHostActionOutputAsync(
name: string,
entry: HostActionCatalogEntry,
outputPath: string | undefined
): Promise<void> {
if (!outputPath) {
if (entry.output === 'base64') {
throw new Error(`${name} returns a file. Pass --output with the path to save it to.`);
}
return;
}
if (!entry.output) {
throw new Error(`${name} does not return a file, so --output does not apply.`);
}
if ((await fs.stat(outputPath).catch(() => null))?.isDirectory()) {
if (entry.output === 'base64') {
throw new Error(`${outputPath} is a folder. Pass --output with a file path for ${name}.`);
}
return;
}
const folder = await fs.stat(path.dirname(outputPath)).catch(() => null);
if (outputPath.endsWith('/') || outputPath.endsWith(path.sep) || !folder?.isDirectory()) {
throw new Error(
`The folder for ${outputPath} does not exist. Create it, or pass --output with a path in a folder that exists.`
);
}
}
export async function uploadFileAsync(
connection: HostActionConnection,
localPath: string,
{ chunkBytes = UPLOAD_CHUNK_BYTES }: { chunkBytes?: number } = {}
): Promise<{ uploadId: string; path: string }> {
const uploadId = `eas-cli-${randomUUID()}${path.extname(localPath).replace(/[^A-Za-z0-9.]/g, '')}`;
let remotePath = '';
let first = true;
try {
for await (const chunk of fs.createReadStream(localPath, { highWaterMark: chunkBytes })) {
const result = await connection.requestAsync('upload.append', {
uploadId,
data: Buffer.from(chunk).toString('base64'),
first,
});
if (result.exitCode !== 0) {
throw new Error(`Could not upload ${localPath}: ${result.stderr.trim()}`);
}
remotePath = result.stdout.trim();
first = false;
}
} catch (error) {
await connection.requestAsync('upload.remove', { uploadId }).catch(() => {});
throw error;
}
if (first) {
throw new Error(`${localPath} is empty. Pass a file with content.`);
}
return { uploadId, path: remotePath };
}
export async function saveHostActionOutputAsync(
connection: HostActionConnection,
entry: HostActionCatalogEntry,
stdout: string,
outputPath: string | undefined
): Promise<string | null> {
if (!entry.output) {
return null;
}
let base64 = stdout;
let defaultName: string | undefined;
if (entry.output === 'path') {
const remotePath = stdout.trim();
const file = await connection.requestAsync('file.readBase64', { path: remotePath });
if (file.exitCode !== 0) {
throw new Error(`Could not download ${remotePath}: ${file.stderr.trim()}`);
}
base64 = file.stdout;
defaultName = path.basename(remotePath);
}
const outputIsFolder = outputPath
? (await fs.stat(outputPath).catch(() => null))?.isDirectory()
: false;
const generatedName = outputIsFolder || !outputPath ? defaultName : undefined;
const target = generatedName ? path.join(outputPath ?? '.', generatedName) : outputPath;
if (!target) {
throw new Error('Pass --output with a file path to save the file.');
}
try {
await fs.writeFile(target, Buffer.from(base64, 'base64'), {
flag: target === outputPath ? 'w' : 'wx',
});
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') {
throw new Error(`${target} already exists. Pass --output with a new path to save the file.`);
}
throw error;
}
return path.resolve(target);
}
@@ -0,0 +1,172 @@
import { stripVTControlCharacters } from 'util';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { parseLogLines } from '../commandUtils/logs/parseLogs';
import { RawLogLine } from '../commandUtils/logs/types';
import fetch from '../fetch';
import { DeviceRunSessionQuery } from '../graphql/queries/DeviceRunSessionQuery';
import Log, { learnMore } from '../log';
import { sanitizeTerminalText } from '../utils/terminalText';
export type SimulatorSessionError = {
errorCode: string;
message: string;
docsUrl?: string | null;
};
export type FetchSimulatorSessionErrorsOptions = {
/** Aborts the lookup, e.g. on Ctrl+C. */
signal?: AbortSignal;
queryTimeoutMs?: number;
logDownloadTimeoutMs?: number;
maxLogSizeBytes?: number;
};
// ErrorCode.UNKNOWN_ERROR and ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR from @expo/eas-build-job.
const UNKNOWN_ERROR_CODES = new Set(['UNKNOWN_ERROR', 'UNKNOWN_CUSTOM_BUILD_ERROR']);
const DEFAULT_QUERY_TIMEOUT_MS = 10_000;
const DEFAULT_LOG_DOWNLOAD_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_LOG_SIZE_BYTES = 20 * 1024 * 1024;
const MAX_LOGGED_ERRORS = 4;
const MAX_LOGGED_ERROR_LENGTH = 500;
// Bunyan's "error" level.
const ERROR_LOG_LEVEL = 50;
const STDERR_CAUSE_PATTERN = /\b(error|exception|fatal)\b/i;
type JobRunLogLine = RawLogLine & { level?: number; source?: string };
// Returns why the session's job run failed, or an empty list when that is not available.
export async function fetchSimulatorSessionErrorsAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionId: string,
{
signal,
queryTimeoutMs = DEFAULT_QUERY_TIMEOUT_MS,
logDownloadTimeoutMs = DEFAULT_LOG_DOWNLOAD_TIMEOUT_MS,
maxLogSizeBytes = DEFAULT_MAX_LOG_SIZE_BYTES,
}: FetchSimulatorSessionErrorsOptions = {}
): Promise<SimulatorSessionError[]> {
if (signal?.aborted) {
return [];
}
let jobRun;
try {
jobRun = await DeviceRunSessionQuery.jobRunErrorsByIdAsync(graphqlClient, deviceRunSessionId, {
signal: withTimeout(signal, queryTimeoutMs),
});
} catch (err) {
Log.debug(`Failed to fetch simulator session errors: ${err}`);
return [];
}
const errors = jobRun?.errors ?? [];
// Uncategorized failures only say "Unknown error. See logs", so read the real cause from the job run logs.
const logFileUrl = jobRun?.logFileUrls[0];
if (
logFileUrl &&
errors.length > 0 &&
errors.every(error => UNKNOWN_ERROR_CODES.has(error.errorCode))
) {
const logErrors = await fetchJobRunLogErrorsAsync(logFileUrl, {
signal: withTimeout(signal, logDownloadTimeoutMs),
maxLogSizeBytes,
});
if (logErrors.length > 0) {
return logErrors.map(message => ({ errorCode: errors[0].errorCode, message, docsUrl: null }));
}
}
return errors.map(({ errorCode, message, docsUrl }) => ({
errorCode,
message: formatMessage(message),
docsUrl,
}));
}
export function formatSimulatorSessionError(error: SimulatorSessionError): string {
return `${error.message}${error.docsUrl ? ` ${learnMore(error.docsUrl)}` : ''}`;
}
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
}
async function fetchJobRunLogErrorsAsync(
logFileUrl: string,
{ signal, maxLogSizeBytes }: { signal: AbortSignal; maxLogSizeBytes: number }
): Promise<string[]> {
// node-fetch stops reading on its own only when the body ends, so abort to close the connection
// after an error, an oversized body or a timeout.
const controller = new AbortController();
try {
const response = await fetch(logFileUrl, {
size: maxLogSizeBytes,
signal: AbortSignal.any([signal, controller.signal]),
});
const { logLines } = parseLogLines(await response.text());
return selectFailedStepErrors(logLines);
} catch (err) {
// node-fetch's error messages include the signed log URL, so leave the message out.
Log.debug(
`Failed to read simulator session logs: ${err instanceof Error ? err.name : 'unknown error'}`
);
return [];
} finally {
controller.abort();
}
}
// Describes the steps that failed, the first one first: its last error line, preceded by the stderr
// line that names the cause and the line after it. Later failed steps only add their error line.
// Uses the log message rather than err.message because only the message has secrets masked.
export function selectFailedStepErrors(logLines: JobRunLogLine[]): string[] {
const failedStepIds = [
...new Set(
logLines
.filter(line => line.marker === 'end-step' && line.result === 'fail' && line.buildStepId)
.map(line => line.buildStepId as string)
),
];
const messages = failedStepIds.flatMap((stepId, index) => {
const lines = selectStepFailureLines(logLines.filter(line => line.buildStepId === stepId));
return (index === 0 ? lines : lines.slice(-1)).map(formatLogLine);
});
return [...new Set(messages)].slice(0, MAX_LOGGED_ERRORS);
}
function selectStepFailureLines(stepLines: JobRunLogLine[]): JobRunLogLine[] {
let selected: JobRunLogLine[] = [];
let stderrCause: JobRunLogLine[] = [];
for (const [index, line] of stepLines.entries()) {
const text = toPlainText(line.msg ?? '');
if (line.source === 'stderr') {
if (STDERR_CAUSE_PATTERN.test(text)) {
stderrCause = [line];
} else if (stderrCause.length === 1 && stepLines[index - 1] === stderrCause[0]) {
stderrCause.push(line);
}
} else if (line.err && (line.level ?? 0) >= ERROR_LOG_LEVEL && text) {
selected = [...stderrCause, line];
stderrCause = [];
}
}
return selected;
}
function formatLogLine(line: JobRunLogLine): string {
return formatMessage(
line.buildStepDisplayName ? `${line.buildStepDisplayName}: ${line.msg}` : String(line.msg)
);
}
// Log text comes from commands run in the session, so strip escape sequences and control
// characters that could clear the terminal or spoof output.
function toPlainText(message: string): string {
return sanitizeTerminalText(stripVTControlCharacters(message));
}
function formatMessage(message: string): string {
const text = toPlainText(message);
return text.length > MAX_LOGGED_ERROR_LENGTH
? `${text.slice(0, MAX_LOGGED_ERROR_LENGTH)}…`
: text;
}
+19
View File
@@ -142,6 +142,25 @@ export function getLocalEgressConfig(
};
}
export type PreviewApiAccess = {
apiUrl: string;
token: string;
};
export function getPreviewApiAccess(
remoteConfig: DeviceRunSessionRemoteConfig
): PreviewApiAccess | null {
const token =
remoteConfig.__typename === 'ServeSimRunSessionRemoteConfig' ||
remoteConfig.__typename === 'WebPreviewOnlyRunSessionRemoteConfig'
? remoteConfig.previewToken
: remoteConfig.webPreviewToken;
if (!remoteConfig.previewApiUrl || !token) {
return null;
}
return { apiUrl: remoteConfig.previewApiUrl, token };
}
/** The worker relay that the Android emulator's proxy points at; see build-tools androidLocalEgress.ts. */
export const ANDROID_EMULATOR_EGRESS_RELAY_PORT = 8898;
+8
View File
@@ -1,5 +1,13 @@
# @expo/eas-json
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "25.1.1",
"version": "25.3.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "25.1.1",
"@expo/eas-build-job": "25.3.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+8
View File
@@ -1,5 +1,13 @@
# eas-cli-local-build-plugin
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "25.1.1",
"version": "25.3.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,12 +26,12 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "25.1.1",
"@expo/build-tools": "25.3.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "25.1.1",
"@expo/logger": "25.1.1",
"@expo/eas-build-job": "25.3.0",
"@expo/logger": "25.3.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "25.1.1",
"@expo/turtle-spawn": "25.3.0",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
"fs-extra": "^11.2.0",
+8
View File
@@ -1,5 +1,13 @@
# @expo/logger
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/logger",
"version": "25.1.1",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+8
View File
@@ -1,5 +1,13 @@
# @expo/steps
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "25.1.1",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,8 +27,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "25.1.1",
"@expo/logger": "25.1.1",
"@expo/eas-build-job": "25.3.0",
"@expo/logger": "25.3.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
"fast-glob": "^3.3.2",
+8
View File
@@ -1,5 +1,13 @@
# @expo/template-file
## 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/template-file",
"version": "25.1.1",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+8
View File
@@ -1,5 +1,13 @@
# @expo/turtle-spawn
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/turtle-spawn",
"version": "25.1.1",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -25,7 +25,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "25.1.1",
"@expo/logger": "25.3.0",
"@expo/spawn-async": "^1.7.2"
},
"devDependencies": {
+8
View File
@@ -1,5 +1,13 @@
# @expo/worker
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "25.1.1",
"version": "25.3.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,17 +19,17 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "25.1.1",
"@expo/build-tools": "25.3.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "25.1.1",
"@expo/logger": "25.1.1",
"@expo/eas-build-job": "25.3.0",
"@expo/logger": "25.3.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "25.1.1",
"@expo/template-file": "25.1.1",
"@expo/turtle-spawn": "25.1.1",
"@expo/steps": "25.3.0",
"@expo/template-file": "25.3.0",
"@expo/turtle-spawn": "25.3.0",
"@hapi/boom": "10.0.1",
"check-disk-space": "3.4.0",
"fast-glob": "3.3.2",
@@ -39,6 +39,7 @@ describe(createBuildContext.name, () => {
buildId: 'build-id',
buildLogger,
addBuildLogMask: jest.fn(),
createLogMaskingStream: jest.fn(),
};
beforeEach(() => {
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more