Compare commits

..
30 Commits
Author SHA1 Message Date
Expo CI 951ccba62c v27.7.2 2026-11-10 17:00:49 +00:00
Tomasz Mazur 8c0e01d64c [eas-cli] Show the ID argument and examples in workflow:ssh help (#4829)
* [eas-cli] Fix workflow SSH help and error text

* [eas-cli] Revert worker wording in workflow:run SSH text

* [eas-cli] Revert workflow:ssh error text
2026-11-10 17:36:59 +01:00
Vojtech NovakandClaude Fable 5.1 539acb8c23 [eas-cli] Add --log-source to eas simulator (#4823)
* [eas-cli] Add --android-log-source to eas simulator

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [eas-cli] Drop the retention period from the --android-log-source help

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [eas-cli] Shorten the --android-log-source changeset

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [eas-cli] Rename --android-log-source to --log-source

The flag is unprefixed like the step input it feeds. Passing it without
--platform android fails at runtime.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-10 17:18:53 +01:00
Expo CI 18d85d571a bump STAGING and PRODUCTION to 27.7.1 in cli-versions.json 2026-11-10 13:36:49 +00:00
Tomasz Mazur f2cf409b21 [build-tools] Start SSH session shells in the build directory (#4843) 2026-11-10 13:40:14 +01:00
Tomasz Mazur 0cf8cd014f [eas-cli] Show the ended message when workflow:ssh is given a finished build ID (#4842) 2026-11-10 13:38:46 +01:00
Expo CI 8df11a04ee v27.7.1 2026-11-10 11:28:19 +00:00
Tomek Czajecki c9d198a894 [eas-cli] update dependencies with security advisories (#4847)
* [eas-cli] update dependencies with security advisories

* [eas-cli] remove unrelated lockfile dedupe
2026-11-10 13:03:43 +02:00
Szymon Świerk 4b70eb14fb [build-tools] Strip the repository access token from the git remote before the sandbox agent starts (#4826)
* [build-tools] Strip the repository access token from the git remote before the sandbox agent starts

* [build-tools] improve the comment explaining why the repository token is stripped before the sandbox starts
2026-11-10 11:25:41 +01:00
Expo CI c224ba58ff bump STAGING and PRODUCTION to 27.7.0 in cli-versions.json 2026-11-10 08:32:43 +00:00
Expo CI 8d5a2e3332 v27.7.0 2026-11-10 08:25:21 +00:00
Vojtech NovakandClaude Fable 5.1 b95b7c930a [build-tools] Record and upload selected Android emulator logs (#4822)
* [build-tools] Record and upload selected Android emulator logs

Select Android log sources at session startup, attach the bounded capture
to every emulator launch, and upload the finalized logs as simulator
session artifacts with truncation metadata.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Upload one Android log artifact per source

Pass the source selection through the capture instead of a second
parameter, let the capture create the launch files, and name artifacts
after the source ring. Finalize the other-workflow capture in the
collection test so no handle closes at garbage collection.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Shorten the Android log changeset

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Choose the Android log writer by session

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Rename android_log_sources to log_sources

The input is unprefixed like every other step input. A non-empty list on an
iOS session fails at runtime, the way network_capture does on Android. The
agent-device step drops its default so an omitted input stays distinguishable
from an explicit selection; the fallback is resolved in code.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Format the retained log size with formatBytes

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-10 09:01:58 +01:00
Vojtech NovakandClaude Fable 5.1 8ed5c16c7b [build-tools] Add bounded Android emulator log capture (#4821)
* [build-tools] Add bounded Android emulator log capture

Capture emulator output and logcat into fixed-size ring files that keep
the last 20 MiB per source across launches, with logcat delivered through
a named pipe. No caller yet; the emulator wiring follows.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Keep one ring file per Android log source

Sessions write every launch of a source into one ring, `<source>.log`,
with a marker line per launch that produced output. This replaces the
per-launch rings and their cross-file eviction, and bounds disk use at the
budget. Ordinary builds keep plain per-launch files closed per launch. The
capture now owns the source selection.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Split the plain Android log writer from the ring

`AndroidEmulatorLogCapture` is now the interface both writers implement.
`AndroidEmulatorLogRing` serves sessions and has no mode checks;
`AndroidEmulatorLogFiles` serves ordinary builds with one appended file
per launch. Document what the logcat output's `closed` promise is for.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-10 09:01:57 +01:00
Vojtech NovakandClaude Fable 5.1 fa5dee437f [build-tools] Share device run session file uploads (#4820)
Move the stat, size-zero skip and per-attempt stream reopening into one
helper, and use it for serve-sim app logs.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-10 09:01:57 +01:00
Ash Wu 65ef172a78 [eas-cli] add --git-branch and --git-commit-hash to workflow:run (#4815)
`eas workflow:run <file>` uploads the local project, so the run carries
no branch or commit and the EAS dashboard cannot list it under a branch.
The two flags record the branch and commit the uploaded
project comes from. They must be given together and cannot be combined
with `--ref`. The server verifies the commit in the project's linked
GitHub repository and reads the commit message from GitHub.

Generated GraphQL types come from production plus the pending www
schema change, so the diff also carries unrelated schema drift.
2026-11-10 15:11:23 +08:00
Brent Vatne b15ea359b6 [eas-cli] Send each spinner frame as one write to stop flicker (#4850) 2026-11-09 22:16:21 -07:00
Brent Vatne ab86b647cf [ci] Compare the changeset check with the base branch, not a stale base SHA (#4851) 2026-11-09 20:06:47 -07:00
Expo CI f6669461cd bump STAGING and PRODUCTION to 27.6.0 in cli-versions.json 2026-11-10 01:22:04 +00:00
Expo CI 5f560f8fc2 v27.6.0 2026-11-10 01:17:13 +00:00
Gabe Debes a3e4617d1c [simulator-launcher] Add Simulator and native tool warmup (#4838)
* [simulator-launcher] Add Simulator and native tool warmup

Edited-by: Codex

* [simulator-launcher] Simplify Expo Go preparation call
2026-11-09 17:44:56 -07:00
Gabe Debes 2e45595250 [build-tools] Reuse prefetched Simulator tools (#4837)
* [build-tools] Reuse prepared Simulator tools

Edited-by: Codex

* [build-tools] Preserve cold Appium setup and validate cached WDA

* [build-tools] Retry cancelled Appium version lookups after adoption fails

* [build-tools] Clarify prepared Appium version matching
2026-11-09 17:44:56 -07:00
Gabe Debes 0c768729a9 [simulator-launcher] Report caller-supplied tool versions (#4845)
* [simulator-launcher] Report caller-supplied tool versions

Edited-by: Codex

* [simulator-launcher] Use semver for version validation

Edited-by: Codex
2026-11-09 17:44:56 -07:00
Gabe Debes f37ad5e137 [simulator-launcher] Prefetch Appium and WebDriverAgent files (#4836)
* [simulator-launcher] Prefetch Appium and WebDriverAgent files

Edited-by: Codex

* [simulator-launcher] Validate the downloaded WDA bundle
2026-11-09 17:44:55 -07:00
Gabe Debes 4d3cba95af [simulator-launcher] Prefetch the selected Expo Go archive (#4834)
* [simulator-launcher] Prefetch the selected Expo Go archive

Edited-by: Codex

* [simulator-launcher] Simplify the changeset

Edited-by: Codex
2026-11-09 17:44:55 -07:00
Gabe Debes d57eadf887 [vm-tools] Use interactive scheduling for Simulator sessions (#4833) 2026-11-09 12:55:32 -07:00
Jakub Tkacz ccba9a0c8d [eas-cli] Add --property filter to observe:events (#4812)
* [eas-cli] Add --property filter to observe:events

Adds a repeatable --property key=value flag that filters events by an exact property match.

Closes ENG-28851

* [eas-cli] Test that observe:events --property forwards an empty value

* [eas-cli] Address review: reject empty, repeated, and more than 5 --property filters
2026-11-09 09:00:31 +01:00
Jakub Tkacz 3fc8dadd63 [eas-cli] Stop suggesting event names when observe:events --session-id finds nothing (#4813)
* [eas-cli] Stop suggesting event names when observe:events --session-id finds nothing

With an event name and --session-id, an empty result does not mean the event name is wrong, so show the plain empty result instead of other event names.

* [eas-cli] Address review: scope comment to --session-id, add --json test

* [eas-cli] Address review: skip suggestions for --app-version, --build-number and --update-id
2026-11-09 09:00:30 +01:00
Tomasz Mazur 50dcb89a0c [build-tools] Require the sandbox worker token for sandbox jobs (#4808) 2026-11-06 15:25:32 +01:00
Ash Wu 84b3bc6354 [build-tools] make iOS Simulator readiness work on the iOS 27.2 runtime (#4816)
On iOS 27.2 `com.apple.datamigrator` stays resident as an idle launchd_sim
service after it reports that no migration is needed, so waiting for it to
exit never ends. The wait is now skipped on iOS 27.2 and later and kept on
earlier runtimes, where the process exits within about a minute of boot and
the wait still keeps the data-migration screen from appearing mid-test
(eas-build #579). The version is the one `simctl list runtimes` reports for
the device's runtime, compared with semver. `simctl list devices` lists UDIDs
in upper case, so a UDID given in lower case is upper-cased first.

The iPhone Duo on iOS 27.2 exposes two integrated screens, and
`simctl io screenshot` without `--display` exits 22, so the readiness
screenshot names the first integrated screen from `simctl io enumerate` when
more than one is listed. Single-screen devices keep the plain command: the
simctl in Xcode 15.3 rejects a numeric `--display`.
2026-11-06 14:46:08 +08:00
Expo CI 2749518a31 bump STAGING and PRODUCTION to 27.5.0 in cli-versions.json 2026-11-05 23:08:41 +00:00
135 changed files with 7595 additions and 1251 deletions

No files matched your search

+3 -1
View File
@@ -28,7 +28,9 @@ jobs:
contains(github.event.pull_request.labels.*.name, 'no changelog') ||
github.event.pull_request.user.login == 'dependabot[bot]'
}}
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }}
# The base branch, not `pull_request.base.sha`: that SHA can be older than the base that the
# checked-out merge commit contains, and the diff then includes other people's changes.
BASE_REF: origin/${{ github.base_ref || 'main' }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
+45
View File
@@ -2,6 +2,51 @@
This is the log of notable changes to EAS CLI and related packages.
## [27.7.2](https://github.com/expo/eas-cli/releases/tag/v27.7.2) - 2026-11-10
### 🐛 Bug fixes
- [eas-cli] Add `--log-source` to `eas simulator` to select which Android logs a session records. ([#4823](https://github.com/expo/eas-cli/pull/4823) by [@vonovak](https://github.com/vonovak))
- [build-tools] Start SSH session shells in the job's build directory instead of the worker's directory. ([#4843](https://github.com/expo/eas-cli/pull/4843) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli] Show that the ssh session has ended, instead of saying SSH was not enabled, when `eas workflow:ssh` is given the ID of a finished build. ([#4842](https://github.com/expo/eas-cli/pull/4842) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli] Show the ID argument and examples in `eas workflow:ssh --help`. ([#4829](https://github.com/expo/eas-cli/pull/4829) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [27.7.1](https://github.com/expo/eas-cli/releases/tag/v27.7.1) - 2026-11-10
### 🐛 Bug fixes
- [eas-cli][eas-json][build-tools][eas-build-job][steps][local-build-plugin][worker][simulator-launcher] Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
- [build-tools] Strip the GitHub repository access token from the git remote before a sandbox agent starts. ([#4826](https://github.com/expo/eas-cli/pull/4826) by [@sswrk](https://github.com/sswrk))
## [27.7.0](https://github.com/expo/eas-cli/releases/tag/v27.7.0) - 2026-11-10
### 🎉 New features
- [eas-cli] Add `--git-branch` and `--git-commit-hash` to `eas workflow:run` to record the branch and commit of the uploaded project. ([#4815](https://github.com/expo/eas-cli/pull/4815) by [@hSATAC](https://github.com/hSATAC))
### 🐛 Bug fixes
- [build-tools] Record Android emulator output and, when selected, device logcat during simulator sessions, and upload the last 20 MiB of each as a session artifact. ([#4822](https://github.com/expo/eas-cli/pull/4822) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Stop spinners from flickering when their text wraps onto a second line, such as the `simulator:start` queue message in a narrow terminal. ([#4850](https://github.com/expo/eas-cli/pull/4850) by [@brentvatne](https://github.com/brentvatne))
## [27.6.0](https://github.com/expo/eas-cli/releases/tag/v27.6.0) - 2026-11-10
### 🎉 New features
- [build-tools] Reuse compatible prefetched Appium and XCUITest files without removing them during cleanup. Resolve explicit package versions, including `latest`, and reuse prepared tools only when they match. Use the launcher's runtime serve-sim version for warm adoption and recordings, including prereleases. ([#4837](https://github.com/expo/eas-cli/pull/4837) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add the `--property key=value` flag to `eas observe:events` to filter events by a property value. ([#4812](https://github.com/expo/eas-cli/pull/4812) by [@Ubax](https://github.com/Ubax))
- [simulator-launcher] Prepare pinned Appium, XCUITest and WebDriverAgent files with `prefetch`. ([#4836](https://github.com/expo/eas-cli/pull/4836) by [@gwdp](https://github.com/gwdp))
- [simulator-launcher] Add `simulator-launcher prefetch` to cache Expo Go downloads. ([#4834](https://github.com/expo/eas-cli/pull/4834) by [@gwdp](https://github.com/gwdp))
- [simulator-launcher] Use caller-supplied serve-sim, agent-device and Argent versions and include exact version inputs in claims. ([#4845](https://github.com/expo/eas-cli/pull/4845) by [@gwdp](https://github.com/gwdp))
- [simulator-launcher] Add `simulator-launcher warmup` so consumers can warm up Simulators, install Expo Go and prepare native tool caches. The command stops its tools and shuts down the selected Simulators before returning. ([#4838](https://github.com/expo/eas-cli/pull/4838) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Stop waiting for `com.apple.datamigrator` to exit before an iOS Simulator on iOS 27.2 or later counts as ready; there it stays resident, so the wait never finished. Earlier runtimes keep the wait. Take the readiness screenshot from the first integrated screen when the device lists several, so the iPhone Duo on iOS 27.2 passes the check. ([#4816](https://github.com/expo/eas-cli/pull/4816) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Stop suggesting other event names in `eas observe:events` when an event name with `--session-id`, `--app-version`, `--build-number`, or `--update-id` returns no events. ([#4813](https://github.com/expo/eas-cli/pull/4813) by [@Ubax](https://github.com/Ubax))
- [build-tools] Require the sandbox worker token for sandbox jobs and remove the sandbox GraphQL fallback. ([#4808](https://github.com/expo/eas-cli/pull/4808) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [vm-tools] Run the macOS VM-tools service with interactive scheduling so Simulator startup and preview processes avoid launchd's default CPU and I/O throttling. ([#4833](https://github.com/expo/eas-cli/pull/4833) by [@gwdp](https://github.com/gwdp))
## [27.5.0](https://github.com/expo/eas-cli/releases/tag/v27.5.0) - 2026-11-05
### 🎉 New features
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "27.4.1",
"PRODUCTION": "27.4.1"
"STAGING": "27.7.1",
"PRODUCTION": "27.7.1"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "27.5.0",
"version": "27.7.2",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+30
View File
@@ -1,5 +1,35 @@
# @expo/build-tools
## 27.7.2
### Patch Changes
- Start SSH session shells in the job's build directory instead of the worker's directory. ([#4843](https://github.com/expo/eas-cli/pull/4843) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## 27.7.1
### Patch Changes
- Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
- Strip the GitHub repository access token from the git remote before a sandbox agent starts. ([#4826](https://github.com/expo/eas-cli/pull/4826) by [@sswrk](https://github.com/sswrk))
## 27.7.0
### Patch Changes
- Record Android emulator output and, when selected, device logcat during simulator sessions, and upload the last 20 MiB of each as a session artifact. ([#4822](https://github.com/expo/eas-cli/pull/4822) by [@vonovak](https://github.com/vonovak))
## 27.6.0
### Minor Changes
- Reuse compatible prefetched Appium and XCUITest files without removing them during cleanup. Resolve explicit package versions, including `latest`, and reuse prepared tools only when they match. Use the launcher's runtime serve-sim version for warm adoption and recordings, including prereleases. ([#4837](https://github.com/expo/eas-cli/pull/4837) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Stop waiting for `com.apple.datamigrator` to exit before an iOS Simulator on iOS 27.2 or later counts as ready; there it stays resident, so the wait never finished. Earlier runtimes keep the wait. Take the readiness screenshot from the first integrated screen when the device lists several, so the iPhone Duo on iOS 27.2 passes the check. ([#4816](https://github.com/expo/eas-cli/pull/4816) by [@hSATAC](https://github.com/hSATAC))
- Require the sandbox worker token for sandbox jobs and remove the sandbox GraphQL fallback. ([#4808](https://github.com/expo/eas-cli/pull/4808) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## 27.5.0
### Patch Changes
+12 -12
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "27.5.0",
"version": "27.7.2",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -42,17 +42,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "27.5.0",
"@expo/eas-build-job": "27.5.0",
"@expo/downloader": "27.7.2",
"@expo/eas-build-job": "27.7.2",
"@expo/env": "^0.4.0",
"@expo/logger": "27.5.0",
"@expo/logger": "27.7.2",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "27.5.0",
"@expo/template-file": "27.5.0",
"@expo/turtle-spawn": "27.5.0",
"@expo/steps": "27.7.2",
"@expo/template-file": "27.7.2",
"@expo/turtle-spawn": "27.7.2",
"@expo/xcpretty": "^4.4.7",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -63,11 +63,11 @@
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
"gql.tada": "^1.11.3",
"joi": "^17.13.1",
"joi": "^17.13.9",
"jose": "5.10.0",
"lodash": "^4.18.1",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.1",
"node-forge": "^1.4.3",
"node-pty": "1.2.0-beta.15",
"node-stream-zip": "1.15.0",
"nullthrows": "^1.1.1",
@@ -77,11 +77,11 @@
"resolve-from": "^5.0.0",
"retry": "^0.13.1",
"semver": "^7.6.2",
"tar": "7.5.19",
"tar": "7.5.22",
"uuid": "9.0.1",
"ws": "8.21.1",
"yaml": "^2.8.1",
"zod": "^4.3.5"
"yaml": "^2.9.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@expo/repack-app": "~0.9.0",
@@ -1,8 +1,8 @@
import { UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { fetchAndCheckoutRefAsync } from '../git';
import { fetchAndCheckoutRefAsync, stripRepositoryCredentialsAsync } from '../git';
jest.mock('@expo/turtle-spawn');
@@ -147,3 +147,64 @@ describe(fetchAndCheckoutRefAsync, () => {
expect(error.message).not.toContain('ghs_secret123');
});
});
describe(stripRepositoryCredentialsAsync, () => {
const token = 'ghs_secret123';
function mockRemoteUrl(url: string): void {
jest.mocked(spawn).mockResolvedValueOnce({ stdout: `${url}\n` } as never);
}
it('strips the token from the origin remote', async () => {
mockRemoteUrl(`https://x-access-token:${token}@github.com/expo/app.git`);
await stripRepositoryCredentialsAsync({ repositoryDirectory });
expect(spawn).toHaveBeenNthCalledWith(1, 'git', ['remote', 'get-url', 'origin'], {
cwd: repositoryDirectory,
});
expect(spawn).toHaveBeenNthCalledWith(
2,
'git',
['remote', 'set-url', 'origin', 'https://github.com/expo/app.git'],
{ cwd: repositoryDirectory }
);
expect(spawn).toHaveBeenCalledTimes(2);
});
it('keeps the host and port of a GitHub Enterprise remote', async () => {
mockRemoteUrl(`https://x-access-token:${token}@ghe.example.com:8443/org/app.git`);
await stripRepositoryCredentialsAsync({ repositoryDirectory });
expect(spawn).toHaveBeenNthCalledWith(
2,
'git',
['remote', 'set-url', 'origin', 'https://ghe.example.com:8443/org/app.git'],
{ cwd: repositoryDirectory }
);
});
it('does nothing when the origin remote carries no credentials', async () => {
mockRemoteUrl('https://github.com/expo/app.git');
await stripRepositoryCredentialsAsync({ repositoryDirectory });
expect(spawn).toHaveBeenCalledTimes(1);
});
it('throws when the remote URL cannot be rewritten', async () => {
mockRemoteUrl(`https://x-access-token:${token}@github.com/expo/app.git`);
const gitError = Object.assign(new Error('git exited with non-zero code: 128'), {
stderr: "fatal: No such remote 'origin'",
});
jest.mocked(spawn).mockRejectedValueOnce(gitError);
const promise = stripRepositoryCredentialsAsync({ repositoryDirectory });
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({ cause: gitError });
const error = await promise.catch(err => err);
expect(error.message).not.toContain(token);
});
});
+40 -1
View File
@@ -1,4 +1,4 @@
import { ArchiveSource, ArchiveSourceType, UserError } from '@expo/eas-build-job';
import { ArchiveSource, ArchiveSourceType, SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
@@ -99,6 +99,45 @@ export async function fetchAndCheckoutRefAsync({
}
}
export async function stripRepositoryCredentialsAsync({
repositoryDirectory,
}: {
repositoryDirectory: string;
}): Promise<void> {
let remoteUrl: URL;
try {
const { stdout } = await spawn('git', ['remote', 'get-url', 'origin'], {
cwd: repositoryDirectory,
});
remoteUrl = new URL(stdout.trim());
} catch (err) {
throw new SystemError('Failed to read the origin remote URL of the project repository.', {
cause: err,
});
}
if (!remoteUrl.username && !remoteUrl.password) {
return;
}
try {
await spawn('git', ['remote', 'set-url', 'origin', stripCredentialsFromGitUrl(remoteUrl)], {
cwd: repositoryDirectory,
});
} catch (err) {
throw new SystemError('Failed to strip credentials from the origin remote URL.', {
cause: err,
});
}
}
function stripCredentialsFromGitUrl(url: URL): string {
const strippedUrl = new URL(url.toString());
strippedUrl.username = '';
strippedUrl.password = '';
return strippedUrl.toString();
}
function getSanitizedGitUrl(maybeGitUrl: string): string | null {
try {
const url = new URL(maybeGitUrl);
@@ -62,6 +62,7 @@ import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/sta
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
import { createCollectAndroidEmulatorLogsBuildFunction } from './functions/collectAndroidEmulatorLogs';
import { createStartSandboxBuildFunction } from './functions/startSandbox';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
@@ -129,6 +130,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createStartServeSimMetricsBuildFunction(),
createStartServeSimLogsBuildFunction(),
createCollectServeSimLogsBuildFunction(ctx),
createCollectAndroidEmulatorLogsBuildFunction(ctx),
createStartSandboxBuildFunction(ctx),
createRunAgentBuildFunction(ctx),
createCollectServeSimMetricsBuildFunction(ctx),
@@ -0,0 +1,48 @@
import { BuildRuntimePlatform } from '@expo/steps';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Sentry } from '../../../sentry';
import { collectAndroidEmulatorLogsAsync } from '../../utils/androidEmulatorLogs';
import { createCollectAndroidEmulatorLogsBuildFunction } from '../collectAndroidEmulatorLogs';
jest.mock('../../../sentry');
jest.mock('../../utils/androidEmulatorLogs');
function createStep(callInputs: Record<string, unknown> = {}) {
const logger = createMockLogger();
const ctx = createCustomBuildContextMock();
const global = createGlobalContextMock({ logger });
const fn = createCollectAndroidEmulatorLogsBuildFunction(ctx);
const step = fn.createBuildStepFromFunctionCall(global, {
callInputs,
env: { DEVICE_RUN_SESSION_ID: 'session' },
});
return { ctx, global, logger, fn, step };
}
it('collects captured logs without a second source selection', async () => {
const { ctx, global, fn, step } = createStep();
expect(fn.supportedRuntimePlatforms).toEqual([BuildRuntimePlatform.LINUX]);
await step.executeAsync();
expect(collectAndroidEmulatorLogsAsync).toHaveBeenCalledWith(ctx, {
global,
deviceRunSessionId: 'session',
logger: expect.anything(),
});
});
it('reports collection failure without changing the session result', async () => {
jest
.mocked(collectAndroidEmulatorLogsAsync)
.mockRejectedValueOnce(new Error('collection failed'));
const { step } = createStep();
await expect(step.executeAsync()).resolves.toBeUndefined();
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not collect Android emulator logs',
expect.any(Error)
);
});
@@ -2,6 +2,7 @@ import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
@@ -139,13 +140,20 @@ describe.each([
corsOrigins?: string[]
) {
const fn = createFunction({} as CustomBuildContext);
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
const defaults = new Map(
fn.inputProviders?.map(provider => {
const input = provider(global, 'Test session');
return [input.id, input.defaultValue];
})
);
await fn.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
// Inputs a step does not get default to undefined, like in a real step call.
// Match step execution: omitted inputs use their declared defaults.
inputs: new Proxy(
{
public_preview: { value: publicPreview },
@@ -153,7 +161,7 @@ describe.each([
cors_origins: { value: corsOrigins },
} as Record<string, { value: unknown }>,
{
get: (target, id: string) => target[id] ?? { value: undefined },
get: (target, id: string) => target[id] ?? { value: defaults.get(id) },
}
),
outputs: {},
@@ -4,6 +4,8 @@ import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { AndroidEmulatorLogRing } from '../../../utils/androidEmulatorLogCapture';
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
import {
@@ -35,6 +37,7 @@ jest.mock('../../utils/localEgressGuard', () => ({
startLocalEgressGuardRelayAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn');
jest.mock('../../utils/androidEmulatorLogs');
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
@@ -108,43 +111,28 @@ function runStep(
inputValues: Record<string, unknown> = {},
signal?: AbortSignal,
stepCtx: CustomBuildContext = ctx,
warm = false
warm = false,
env: NodeJS.ProcessEnv = {}
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx, { warm });
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const global = createGlobalContextMock({
runtimePlatform,
staticContextContent: { job: { secrets: { robotAccessToken: 'robot-token' } } },
});
const inputs = Object.fromEntries(
[
'device_identifier',
'system_image_package',
'lcd_width',
'lcd_height',
'lcd_density',
'install_app_path',
'build_id',
'application_archive_url',
'launch_app_identifier',
'launch_args',
'open_url',
'network_capture',
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
].map(id => [id, { value: inputValues[id] }])
(buildFunction.inputProviders ?? []).map(provider => {
const input = provider(global, 'test');
input.set(inputValues[input.id]);
return [input.id, { value: input.rawValue }];
})
);
return buildFunction.fn!(
{
logger,
global: {
runtimePlatform,
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
},
global,
} as unknown as BuildStepContext,
{ inputs, outputs: {}, env: {}, signal } as never
{ inputs, outputs: {}, env, signal } as never
) as Promise<void>;
}
@@ -173,6 +161,11 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
}
beforeEach(() => {
jest
.mocked(getAndroidEmulatorLogCapture)
.mockImplementation(
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
);
deviceLaunch = undefined;
jest.clearAllMocks();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
@@ -437,6 +430,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
expect(sessionDevice()).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv: undefined,
@@ -444,6 +438,61 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
});
it('defaults an omitted session log input to emulator output through the step boundary', async () => {
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
const step = buildFunction.createBuildStepFromFunctionCall(global, {
callInputs: {},
env: { DEVICE_RUN_SESSION_ID: 'session' },
});
await step.executeAsync();
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({
logCapture: expect.objectContaining({ sources: ['emulator-output'] }),
})
);
});
it('passes an explicit empty source list and the workflow capture to Android boot', async () => {
const capture = new AndroidEmulatorLogRing({ sources: [] });
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
const env = { DEVICE_RUN_SESSION_ID: 'session' };
await runStep(BuildRuntimePlatform.LINUX, { log_sources: [] }, undefined, ctx, false, env);
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything(), env, []);
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ logCapture: capture })
);
});
it('rejects invalid Android log sources before boot or downloading', async () => {
await expect(
runStep(BuildRuntimePlatform.LINUX, {
log_sources: ['unknown'],
build_id: 'build-id',
})
).rejects.toThrow();
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
});
it('rejects Android log sources on an iOS session before boot or downloading', async () => {
await expect(
runStep(BuildRuntimePlatform.DARWIN, {
log_sources: ['device-logcat'],
build_id: 'build-id',
})
).rejects.toThrow('Input "log_sources" selects Android emulator logs');
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
});
it('boots an iOS session with an explicit empty source list', async () => {
await runStep(BuildRuntimePlatform.DARWIN, { log_sources: [] });
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalled();
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
});
it('boots the Android Emulator with the device inputs', async () => {
await runStep(BuildRuntimePlatform.LINUX, {
device_identifier: 'pixel_7',
@@ -470,6 +519,22 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
);
});
it('stops the download and waits for it when Android boot fails', async () => {
const download = mockStalledDownload();
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
await expect(
runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, ctx, false, {
DEVICE_RUN_SESSION_ID: 'session',
})
).rejects.toThrow('boot failed');
expect(download.aborted()).toBe(true);
// The step returned only after the download stopped.
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start work for an already cancelled step', async () => {
const controller = new AbortController();
controller.abort(new Error('cancelled'));
@@ -617,6 +682,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual([
'log_sources',
'device_identifier',
'system_image_package',
'lcd_width',
@@ -821,6 +887,17 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(2);
});
it('passes the resolved version to agent-device after a warm version mismatch', async () => {
jest
.mocked(runOnWarmIosSimulatorAsync)
.mockImplementation((_claim, run) => run(undefined, '999.1.0'));
await runStep(BuildRuntimePlatform.DARWIN, { package_version: 'latest' }, undefined, ctx, true);
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ packageVersion: '999.1.0' })
);
});
it('accepts only one application source', async () => {
await expect(
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app', build_id: 'id' })
@@ -9,6 +9,8 @@ import {
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
import { AndroidEmulatorLogRing } from '../../../utils/androidEmulatorLogCapture';
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
@@ -24,6 +26,8 @@ jest.mock('../../utils/androidLocalEgress', () => ({
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/androidEmulatorLogs');
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
@@ -62,6 +66,11 @@ function createStartResult(serialId: string) {
describe(createStartAndroidEmulatorBuildFunction, () => {
beforeEach(() => {
jest
.mocked(getAndroidEmulatorLogCapture)
.mockImplementation(
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
);
mockedMkdtemp.mockResolvedValue('/tmp/logcat-directory');
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
@@ -90,6 +99,30 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedMkdtemp.mockRestore();
});
it('preserves an explicit empty source list and one capture through boot retries and clones', async () => {
mockedAndroidUtils.waitForReadyAsync.mockRejectedValueOnce(new Error('retry boot'));
const capture = new AndroidEmulatorLogRing({ sources: [] });
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
await createStep(
{ log_sources: [], count: 2 },
{ DEVICE_RUN_SESSION_ID: 'session' }
).executeAsync();
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session' }),
[]
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(4);
for (const [options] of mockedAndroidUtils.startAsync.mock.calls) {
expect(options.logCapture).toBe(capture);
}
});
it('rejects invalid sources before starting the emulator', async () => {
await expect(createStep({ log_sources: ['unknown'] }).executeAsync()).rejects.toThrow();
expect(mockedAndroidUtils.startAsync).not.toHaveBeenCalled();
});
describe('local egress', () => {
function createLocalEgress() {
return {
@@ -111,6 +144,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
deviceName: 'EasAndroidDevice01',
env: expect.any(Object),
logcatDirectory: '/tmp/logcat-directory',
logCapture: expect.objectContaining({ sources: ['emulator-output', 'device-logcat'] }),
});
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
env: expect.any(Object),
@@ -1,7 +1,10 @@
import { type WarmIosSession } from '../../utils/warmIosSession';
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import plist from 'plist';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import * as timersPromises from 'node:timers/promises';
@@ -9,6 +12,7 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { PackageManager, resolveConfiguredPackageManager } from '../../../utils/packageManager';
import * as processes from '../../../utils/processes';
import { sleepAsync } from '../../../utils/retry';
import { turtleFetch } from '../../../utils/turtleFetch';
@@ -98,6 +102,46 @@ jest.mock('../../utils/localEgressSession', () => ({
}));
const logger = { info: jest.fn(), warn: jest.fn() } as never;
const preparedHome = path.join(os.homedir(), 'Library/Caches/expo-simulator-startup/appium');
const preparedManifest = path.join(path.dirname(preparedHome), 'prepared.json');
const preparedBin = path.join(preparedHome, 'node_modules', '.bin', 'appium');
const preparedRunner = path.join(
preparedHome,
'preinstalled-wda',
'WebDriverAgentRunner-Runner.app'
);
const driverList = ['driver', 'list', '--installed', '--json'];
/** Today's `driver list` output: the prepared home lists xcuitest, a new home lists nothing. */
function driverListStdout(command: string): string {
return JSON.stringify(command === preparedBin ? { xcuitest: { installed: true } } : {});
}
/** Writes the Appium home that `simulator-launcher prefetch` prepares for reuse. */
function writePreparedHome(): void {
fs.mkdirSync(path.dirname(preparedBin), { recursive: true });
fs.writeFileSync(preparedBin, '');
writeInstalledVersion('appium', '3.9.0');
writeInstalledVersion('appium-xcuitest-driver', '12.22.8');
fs.mkdirSync(preparedRunner, { recursive: true });
fs.writeFileSync(
path.join(preparedRunner, 'Info.plist'),
plist.build({ CFBundleExecutable: 'Runner' })
);
fs.writeFileSync(path.join(preparedRunner, 'Runner'), 'executable', { mode: 0o755 });
writePreparedManifest({ version: '3.9.0', xcuitest: '12.22.8', home: preparedHome });
}
function writePreparedManifest(appium: unknown): void {
fs.writeFileSync(preparedManifest, `${JSON.stringify({ tools: { appium } })}\n`);
}
function writeInstalledVersion(name: string, version: string): void {
const directory = path.join(preparedHome, 'node_modules', name);
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify({ name, version }));
}
describe(resolveAppium3VersionSpec, () => {
it('uses the worker-supported Appium 3 version by default', () => {
@@ -172,9 +216,9 @@ describe(installAppiumAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
if (args.includes('--json')) {
return { stdout: JSON.stringify({}) };
return { stdout: driverListStdout(command) };
}
return { stdout: '' };
}) as never);
@@ -208,6 +252,255 @@ describe(installAppiumAsync, () => {
}
});
it('uses the xcuitest home prefetched without installing', async () => {
writePreparedHome();
const info = jest.fn();
const result = await installAppiumAsync({
versionSpec: '^3',
driverName: 'xcuitest',
env: { EXISTING: 'value' },
logger: { info } as never,
});
expect(jest.mocked(spawn).mock.calls).toEqual([
[
preparedBin,
driverList,
{ env: { EXISTING: 'value', APPIUM_HOME: preparedHome }, stdio: 'pipe' },
],
]);
expect(info).toHaveBeenCalledWith(
'Using the prefetched Appium home (appium 3.9.0, xcuitest 12.22.8).'
);
expect(result).toEqual({
appiumHome: preparedHome,
appiumBinPath: preparedBin,
appiumEnv: { EXISTING: 'value', APPIUM_HOME: preparedHome },
prepared: true,
});
const env = { EAS_OVERRIDE_PACKAGE_MANAGER: 'nope' };
const expected = (() => {
try {
resolveConfiguredPackageManager(env, PackageManager.NPM);
} catch (error) {
return error;
}
})();
const onError = jest.fn();
await expect(
installAppiumAsync({ versionSpec: '^3', driverName: 'xcuitest', env, logger, onError })
).rejects.toEqual(expected);
expect(onError).toHaveBeenCalledWith(expected);
expect(jest.mocked(spawn).mock.calls.map(([, args]) => args)).toEqual([driverList, driverList]);
});
it('reuses WDA with binary bundle metadata', async () => {
writePreparedHome();
fs.writeFileSync(
path.join(preparedRunner, 'Info.plist'),
Buffer.from(
'YnBsaXN0MDDRAQJfEBJDRkJ1bmRsZUV4ZWN1dGFibGVWUnVubmVyCAsgAAAAAAAAAQEAAAAAAAAAAwAAAAAAAAAAAAAAAAAAACc=',
'base64'
)
);
const result = await installAppiumAsync({
versionSpec: '^3',
driverName: 'xcuitest',
env: {},
logger,
});
expect(result.prepared).toBe(true);
});
it.each([
['a version spec it does not satisfy', { versionSpec: '3.5.0' }, () => {}],
['the uiautomator2 driver', { driverName: 'uiautomator2' as const }, () => {}],
['no prepared.json', {}, () => fs.rmSync(preparedManifest)],
['an invalid prepared.json', {}, () => fs.writeFileSync(preparedManifest, 'not json')],
['a prepared.json without xcuitest', {}, () => writePreparedManifest({ version: '3.9.0' })],
[
'an older per-tool manifest',
{},
() => {
fs.rmSync(preparedManifest);
fs.writeFileSync(
path.join(preparedHome, 'prepared.json'),
JSON.stringify({
appium: '3.9.0',
xcuitest: '12.22.8',
home: preparedHome,
})
);
},
],
[
'a home prepared at another path',
{},
() =>
writePreparedManifest({
version: '3.9.0',
xcuitest: '12.22.8',
home: '/Users/other/appium',
}),
],
['another installed Appium', {}, () => writeInstalledVersion('appium', '2.19.0')],
[
'another installed xcuitest driver',
{},
() => writeInstalledVersion('appium-xcuitest-driver', '12.0.0'),
],
[
'no xcuitest driver',
{},
() =>
fs.rmSync(path.join(preparedHome, 'node_modules', 'appium-xcuitest-driver'), {
recursive: true,
}),
],
['no runner', {}, () => fs.rmSync(preparedRunner, { recursive: true })],
['no runner metadata', {}, () => fs.rmSync(path.join(preparedRunner, 'Info.plist'))],
[
'invalid runner metadata',
{},
() => fs.writeFileSync(path.join(preparedRunner, 'Info.plist'), 'invalid'),
],
['no runner executable', {}, () => fs.rmSync(path.join(preparedRunner, 'Runner'))],
[
'a non-executable runner binary',
{},
() => fs.chmodSync(path.join(preparedRunner, 'Runner'), 0o644),
],
['an empty runner binary', {}, () => fs.writeFileSync(path.join(preparedRunner, 'Runner'), '')],
[
'a directory as runner binary',
{},
() => {
fs.rmSync(path.join(preparedRunner, 'Runner'));
fs.mkdirSync(path.join(preparedRunner, 'Runner'));
},
],
[
'an executable outside the bundle',
{},
() =>
fs.writeFileSync(
path.join(preparedRunner, 'Info.plist'),
plist.build({ CFBundleExecutable: '../outside' })
),
],
['an unreadable runner', {}, () => fs.chmodSync(preparedRunner, 0o000)],
[
'a runner that is a file',
{},
() => {
fs.rmSync(preparedRunner, { recursive: true });
// Readable and executable, so only the directory check rejects it.
fs.writeFileSync(preparedRunner, '', { mode: 0o755 });
},
],
])('installs as before for %s', async (_name, options, breakHome) => {
writePreparedHome();
breakHome();
const before = vol.toJSON(preparedHome);
const versionSpec = 'versionSpec' in options ? options.versionSpec : '^3';
const resolveRequestedVersionAsync = jest.fn(async () => {
throw new Error('registry unavailable');
});
const result = await installAppiumAsync({
versionSpec,
resolveRequestedVersionAsync,
driverName: 'driverName' in options ? options.driverName : 'xcuitest',
env: {},
logger,
});
try {
expect(spawn).toHaveBeenNthCalledWith(
1,
'npm',
['install', '--no-audit', `appium@${versionSpec}`],
expect.objectContaining({ cwd: result.appiumHome })
);
expect(path.basename(result.appiumHome)).toMatch(/^eas-appium-home-/);
expect(result.prepared).toBe(false);
expect(resolveRequestedVersionAsync).not.toHaveBeenCalled();
expect(vol.toJSON(preparedHome)).toEqual(before);
} finally {
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
}
});
it.each([
['a driver list without xcuitest', async () => ({ stdout: '{}' })],
['a failed driver list', async () => Promise.reject(new Error('appium exited with 1.'))],
])('installs as before after %s in the prepared home', async (_name, list) => {
writePreparedHome();
jest.mocked(spawn).mockImplementationOnce(list as never);
const before = vol.toJSON(preparedHome);
const result = await installAppiumAsync({
versionSpec: '^3',
driverName: 'xcuitest',
env: {},
logger,
});
try {
expect(jest.mocked(spawn).mock.calls.slice(0, 2)).toEqual([
[preparedBin, driverList, expect.objectContaining({ stdio: 'pipe' })],
[
'npm',
['install', '--no-audit', 'appium@^3'],
expect.objectContaining({ cwd: result.appiumHome }),
],
]);
expect(result.prepared).toBe(false);
expect(vol.toJSON(preparedHome)).toEqual(before);
} finally {
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
}
});
it('kills the prepared-home driver list on a stop and installs nothing', async () => {
writePreparedHome();
const before = vol.toJSON(preparedHome);
const controller = new AbortController();
const failure = new Error('session stopped');
const child = { pid: 4242, kill: jest.fn() };
let finishList!: (error: Error) => void;
let listStarted!: () => void;
const started = new Promise<void>(resolve => {
listStarted = resolve;
});
jest.mocked(spawn).mockImplementationOnce(() => {
listStarted();
return Object.assign(
new Promise((_resolve, reject) => {
finishList = reject;
}),
{ child }
) as never;
});
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
const running = installAppiumAsync({
versionSpec: '^3',
driverName: 'xcuitest',
env: {},
logger,
signal: controller.signal,
});
running.catch(() => {});
try {
await started;
controller.abort(failure);
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
finishList(new Error('killed'));
await expect(running).rejects.toBe(failure);
expect(spawn).toHaveBeenCalledTimes(1);
expect(vol.toJSON(preparedHome)).toEqual(before);
} finally {
finishList?.(new Error('test cleanup'));
killGroup.mockRestore();
}
});
it('installs Appium with bun add when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
const result = await installAppiumAsync({
versionSpec: '3.5.0',
@@ -302,6 +595,115 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => {
});
});
it.each(
[BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX].flatMap(platform =>
['^3', '3.x'].map(version => [platform, version] as const)
)
)(
'starts cold Appium on %s with cached installer range %s and no registry lookup',
async (runtimePlatform, packageVersion) => {
// Android must ignore prepared XCUITest files too.
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
writePreparedHome();
}
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
if (args[0] === 'view') {
throw new Error('registry unavailable');
}
return { stdout: '{}' };
}) as never);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'simulator' }] as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' }] as never);
const launched = new Error('launch captured');
jest.mocked(spawnDetached).mockImplementation(() => {
throw launched;
});
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
inputs: {
package_version: { value: packageVersion },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
NGROK_AUTHTOKEN: 'token',
EAS_OVERRIDE_PACKAGE_MANAGER: 'bun',
},
} as never
)
).rejects.toBe(launched);
expect(jest.mocked(spawn).mock.calls.some(([, args]) => args[0] === 'view')).toBe(false);
expect(spawn).toHaveBeenCalledWith(
'bun',
['add', `appium@${packageVersion}`],
expect.anything()
);
}
);
it.each(
['latest', '^3', '3.x'].flatMap(requested =>
['3.9.0', '3.10.0'].map(latest => [requested, latest])
)
)('checks %s before using the prepared Appium version (%s)', async (requested, latest) => {
writePreparedHome();
const before = vol.toJSON(preparedHome);
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => ({
stdout:
args[0] === 'view'
? JSON.stringify(latest)
: args.includes('--json')
? driverListStdout(command)
: '',
})) as never);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'simulator' }] as never);
const launched = new Error('launch captured');
jest.mocked(spawnDetached).mockImplementation(() => {
throw launched;
});
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: requested },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
NGROK_AUTHTOKEN: 'token',
},
} as never
)
).rejects.toBe(launched);
const installs = jest.mocked(spawn).mock.calls.filter(([, args]) => args[0] === 'install');
if (latest === '3.9.0') {
expect(installs).toHaveLength(0);
expect(jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME).toBe(preparedHome);
} else {
expect(installs).toHaveLength(1);
expect(installs[0][1]).toContain('[email protected]');
}
expect(vol.toJSON(preparedHome)).toEqual(before);
});
it('claims the warm Simulator for Appium without its package version', async () => {
jest.mocked(runOnWarmIosSimulatorAsync).mockResolvedValue(undefined);
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext, {
@@ -369,8 +771,8 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
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') ? '{}' : '',
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => ({
stdout: args.includes('--json') ? driverListStdout(command) : '',
})) as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
@@ -625,6 +1027,73 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
return stopWarm;
}
it.each(['cancelled lookup', 'AbortError lookup', 'resolved lookup'])(
'recovers from failed warm adoption with a %s',
async stage => {
writePreparedHome();
const stopWarm = mockWarmSession();
stopAppium.mockResolvedValue(undefined);
const adoptionError = new WarmSessionAdoptionError();
let failHost!: (error: Error) => void;
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
new Promise((_resolve, reject) => {
failHost = reject;
})
);
let lookups = 0;
jest.mocked(spawn).mockImplementation((async (command, args, options) => {
if (args[0] !== 'view') {
return { stdout: args.includes('--json') ? driverListStdout(command) : '' };
}
lookups++;
if (lookups === 1 && stage !== 'resolved lookup') {
return await new Promise((_resolve, reject) => {
options!.signal!.addEventListener(
'abort',
() => {
reject(
stage === 'AbortError lookup'
? new DOMException('Aborted', 'AbortError')
: options!.signal!.reason
);
},
{ once: true }
);
failHost(adoptionError);
});
}
return { stdout: JSON.stringify('3.9.0') };
}) as typeof spawn);
if (stage === 'resolved lookup') {
const process = jest.mocked(spawnDetached).getMockImplementation()!;
jest.mocked(spawnDetached).mockImplementationOnce(options => {
failHost(adoptionError);
return process(options);
});
}
await expect(
runSessionAsync(
logger,
undefined,
BuildRuntimePlatform.DARWIN,
{
boot_simulator: { value: true },
device_identifier: { value: 'iPhone 17' },
package_version: { value: '^3' },
},
true
)
).resolves.toBeUndefined();
expect(lookups).toBe(stage === 'resolved lookup' ? 1 : 2);
expect(stopWarm).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
expect(spawnDetached).toHaveBeenCalledTimes(stage === 'resolved lookup' ? 2 : 1);
expect(fs.existsSync(preparedRunner)).toBe(true);
}
);
it.each(['event collection', 'Appium process'])(
'waits for warm %s teardown before finishing the session host',
async stage => {
@@ -1102,6 +1571,31 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
);
});
it('a Darwin session uses the prepared Appium home and keeps it', async () => {
stopAppium.mockResolvedValue(undefined);
writePreparedHome();
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([
{
udid: 'chosen-ios-udid',
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4',
} as never,
]);
await runSessionAsync(
{ info: jest.fn(), warn: jest.fn() },
undefined,
BuildRuntimePlatform.DARWIN
);
expect(jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME).toBe(preparedHome);
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
expect.objectContaining({ appiumHome: preparedHome, prepared: true })
);
expect(jest.mocked(spawn).mock.calls.map(([command, args]) => [command, args])).toEqual([
[preparedBin, driverList],
]);
expect(stopAppium).toHaveBeenCalledTimes(1);
expect(fs.existsSync(preparedManifest)).toBe(true);
});
it('starts serve-sim on the selected iOS device while Appium installation is pending', async () => {
stopAppium.mockResolvedValue(undefined);
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
@@ -322,6 +322,29 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('uses the resolved Argent version after a warm mismatch', async () => {
jest
.mocked(runOnWarmIosSimulatorAsync)
.mockImplementation((_claim, run) => run(undefined, '999.1.0'));
await createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext, { warm: true }).fn!(
{
logger: { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as never,
{
inputs: {
package_version: { value: 'latest' },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
expect(spawnDetached).toHaveBeenCalledWith(
expect.objectContaining({ args: expect.arrayContaining(['@swmansion/[email protected]']) })
);
});
it('keeps watching startup when warm adoption retries cold', async () => {
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
@@ -1,14 +1,30 @@
import { EnvironmentSecretType, SystemError, UserError } from '@expo/eas-build-job';
import {
ArchiveSourceType,
EnvironmentSecretType,
SystemError,
UserError,
} from '@expo/eas-build-job';
import fetch from 'node-fetch';
import { stripRepositoryCredentialsAsync } from '../../../common/git';
import * as sandboxDaemon from '../../utils/sandboxDaemon';
import { SandboxWorkerApi } from '../../utils/sandboxWorkerApi';
import { createStartSandboxBuildFunction } from '../startSandbox';
const { AbortError, Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
jest.mock('../../../common/git');
const checkedOutStepCtx = { logger: {}, global: { wasCheckedOut: () => true } } as any;
const notCheckedOutStepCtx = { logger: {}, global: { wasCheckedOut: () => false } } as any;
const URL_JOB = {
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com/project.tar.gz' },
};
const context = {
job: {
...URL_JOB,
secrets: {
environmentSecrets: [
{
@@ -183,67 +199,102 @@ describe('sandbox build functions', () => {
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it('falls back to the legacy token and keeps the Expo token for commands without a worker token', async () => {
it('strips the repository credentials before starting the daemon for git sources', async () => {
const startupError = new Error('stop after checking options');
const start = jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction({ ...context, job: {} } as any);
await expect(
fn.fn!({ logger: {} } as any, {
inputs,
outputs: {},
env: {
PREPARED_BY_EARLIER_STEP: 'value',
EXPO_TOKEN: 'expo-token',
__EAS_SANDBOX_MCP_TOKEN: 'legacy-token',
const fn = createStartSandboxBuildFunction({
...context,
job: {
...context.job,
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: 'refs/heads/main',
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
},
})
).rejects.toBe(startupError);
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'legacy-token' });
expect(start.mock.calls[0][0].env).toEqual({
PREPARED_BY_EARLIER_STEP: 'value',
EXPO_TOKEN: 'expo-token',
},
projectTargetDirectory: '/workingdir/build',
} as any);
await expect(fn.fn!(checkedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
startupError
);
expect(stripRepositoryCredentialsAsync).toHaveBeenCalledWith({
repositoryDirectory: '/workingdir/build',
});
expect(jest.mocked(stripRepositoryCredentialsAsync).mock.invocationCallOrder[0]).toBeLessThan(
start.mock.invocationCallOrder[0]
);
});
it('strips the repository credentials from the source directory before eas/checkout', async () => {
const startupError = new Error('stop after checking options');
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction({
...context,
job: {
...context.job,
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: null,
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
},
},
projectSourceDirectory: '/workingdir/temporary-custom-build',
projectTargetDirectory: '/workingdir/build',
} as any);
await expect(fn.fn!(notCheckedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
startupError
);
expect(stripRepositoryCredentialsAsync).toHaveBeenCalledWith({
repositoryDirectory: '/workingdir/temporary-custom-build',
});
});
it('marks the sandbox ready over GraphQL without a worker token', async () => {
const stopAsync = jest.fn(async () => {});
jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
const mutation = jest.fn(() => ({
toPromise: async () => ({ data: { sandbox: { markSandboxReady: { id: 'sandbox-id' } } } }),
}));
const logger = { info: jest.fn(), child: jest.fn() };
logger.child.mockReturnValue(logger);
it('does not start the daemon when stripping the repository credentials fails', async () => {
const stripError = new SystemError('Failed to strip credentials from the origin remote URL.');
jest.mocked(stripRepositoryCredentialsAsync).mockRejectedValueOnce(stripError);
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
const fn = createStartSandboxBuildFunction({
...context,
job: {},
graphqlClient: { mutation },
job: {
...context.job,
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: null,
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
},
},
projectTargetDirectory: '/workingdir/build',
} as any);
await fn.fn!({ logger } as any, {
inputs: { ...inputs, max_duration_seconds: { value: 0.0001 } },
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'legacy-token' },
});
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
{ sandboxId: 'sandbox-id' },
undefined
await expect(fn.fn!(checkedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
stripError
);
expect(fetch).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
expect(start).not.toHaveBeenCalled();
});
it('leaves the remote alone for non-git sources', async () => {
const startupError = new Error('stop after checking options');
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction({
...context,
projectTargetDirectory: '/workingdir/build',
} as any);
await expect(fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {} })).rejects.toBe(
startupError
);
expect(stripRepositoryCredentialsAsync).not.toHaveBeenCalled();
});
it.each([
{
missing: 'a sandbox worker token or a legacy token',
missing: 'the sandbox worker token',
overrides: { job: {} },
message: 'This sandbox job has neither EAS_SANDBOX_WORKER_TOKEN nor __EAS_SANDBOX_MCP_TOKEN.',
message: 'This sandbox job has no EAS_SANDBOX_WORKER_TOKEN secret.',
},
{
missing: 'the MCP server URL',
@@ -224,6 +224,20 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
);
});
it('passes the resolved version to cold setup after a warm version mismatch', async () => {
jest
.mocked(runOnWarmIosSimulatorAsync)
.mockImplementation((_claim, run) => run(undefined, '0.14.0'));
await createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{ inputs: { package_version: { value: 'latest' } }, outputs: {}, env } as never
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ packageVersion: '0.14.0' })
);
});
it('uses preview activity to reset the configured idle timeout', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
@@ -0,0 +1,34 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { collectAndroidEmulatorLogsAsync } from '../utils/androidEmulatorLogs';
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
export function createCollectAndroidEmulatorLogsBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'collect_android_emulator_logs',
name: 'Collect Android emulator logs',
__metricsId: 'eas/collect_android_emulator_logs',
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
fn: async ({ logger, global }, { env }) => {
try {
await collectAndroidEmulatorLogsAsync(ctx, {
global,
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
logger,
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not collect Android emulator logs', error);
logger.warn(
{ err: error },
'Could not collect Android logs; the session result is unchanged.'
);
}
},
});
}
@@ -16,6 +16,8 @@ import {
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
import { parseLogSourcesInput } from '../utils/logSourcesInput';
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
@@ -71,6 +73,12 @@ export function createStartAgentDeviceSessionBuildFunction(
name: 'Start agent-device session',
__metricsId: warm ? 'eas/start_warm_agent_device_session' : 'eas/start_agent_device_session',
inputProviders: [
// No default: an omitted input must stay distinguishable from an explicit selection.
BuildStepInput.createProvider({
id: 'log_sources',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
BuildStepInput.createProvider({
id: 'device_identifier',
@@ -188,6 +196,10 @@ export function createStartAgentDeviceSessionBuildFunction(
} else {
validateAndroidLaunchOptions({ launchArgs, openUrl });
}
const logSources = parseLogSourcesInput(inputs.log_sources.value, {
runtimePlatform,
fallback: isIos ? [] : ['emulator-output'],
});
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
const capture = parseNetworkCaptureInputs(
{
@@ -205,7 +217,10 @@ export function createStartAgentDeviceSessionBuildFunction(
logger,
startup: { signal: startupSignal, abort: reason => startup.abort(reason) },
});
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
const startSessionAsync = async (
warmSession?: WarmIosSession,
resolvedPackageVersion = inputs.package_version.value as string | undefined
): Promise<void> => {
const tasks = createStartupTasks(logger, startupSignal);
let booted: Promise<void> | undefined;
let sessionOwnsTeardown = false;
@@ -253,6 +268,7 @@ export function createStartAgentDeviceSessionBuildFunction(
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
logCapture: getAndroidEmulatorLogCapture(global, env, logSources),
env,
logger: taskLogger,
});
@@ -338,7 +354,7 @@ export function createStartAgentDeviceSessionBuildFunction(
signal,
runtimePlatform,
sessionEnv,
packageVersion: inputs.package_version.value as string | undefined,
packageVersion: resolvedPackageVersion,
// A missing or non-positive value disables the idle timeout (opt-in feature).
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
@@ -20,8 +20,12 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { type AndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogCapture';
import { retryAsync } from '../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../../utils/androidEmulatorLogSources';
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
import { parseLogSourcesInput } from '../utils/logSourcesInput';
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
@@ -33,6 +37,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
name: 'Start Android Emulator',
__metricsId: 'eas/start_android_emulator',
inputProviders: [
BuildStepInput.createProvider({
id: 'log_sources',
required: false,
defaultValue: [...ALL_ANDROID_EMULATOR_LOG_SOURCES],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'device_name',
required: false,
@@ -78,10 +88,15 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
required: true,
}),
],
fn: async ({ logger }, { inputs, outputs, env }) => {
fn: async ({ logger, global }, { inputs, outputs, env }) => {
const logSources = parseLogSourcesInput(inputs.log_sources.value, {
runtimePlatform: global.runtimePlatform,
fallback: ALL_ANDROID_EMULATOR_LOG_SOURCES,
});
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
const logCapture = getAndroidEmulatorLogCapture(global, env, logSources);
outputs.logcat_directory.set(logcatDirectory);
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
@@ -95,6 +110,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
logCapture,
env,
logger,
});
@@ -133,6 +149,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
deviceName: cloneIdentifier,
env,
logcatDirectory,
logCapture,
});
cloneSerialId = startResult.serialId;
@@ -202,6 +219,7 @@ export async function startAndroidEmulatorAsync({
lcdHeight,
lcdDensity,
logcatDirectory,
logCapture,
env,
logger,
}: {
@@ -212,6 +230,7 @@ export async function startAndroidEmulatorAsync({
lcdHeight?: number;
lcdDensity?: number;
logcatDirectory: string;
logCapture: AndroidEmulatorLogCapture;
env: BuildStepEnv;
logger: bunyan;
}): Promise<{
@@ -293,6 +312,7 @@ export async function startAndroidEmulatorAsync({
deviceName,
env,
logcatDirectory,
logCapture,
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
});
attemptSerialId = startResult.serialId;
@@ -14,6 +14,8 @@ import os from 'node:os';
import path from 'node:path';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import semver from 'semver';
import bplistParser from 'bplist-parser';
import plist from 'plist';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
@@ -43,6 +45,8 @@ import {
import { killProcessGroup } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { resolveSimulatorPackageVersionAsync } from '../../utils/simulatorPackageVersion';
import { readPreparedSimulatorToolAsync } from '../../utils/warmSimulator';
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
@@ -79,6 +83,12 @@ const AppiumInstalledDriversSchema = z.record(
z.string(),
z.object({ installed: z.boolean().optional() }).passthrough()
);
const PreparedAppiumSchema = z.object({
version: z.string(),
xcuitest: z.string(),
home: z.string(),
});
const PackageVersionSchema = z.object({ version: z.string() });
export function createStartAppiumRemoteSessionBuildFunction(
ctx: CustomBuildContext,
@@ -127,6 +137,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
});
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
let requestedVersion: string | undefined;
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
const startupAbortController = new AbortController();
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
@@ -149,7 +160,21 @@ export function createStartAppiumRemoteSessionBuildFunction(
},
{ runtimePlatform }
);
const versionSpec = resolveAppium3VersionSpec(packageVersion);
const resolveRequestedVersionAsync =
packageVersion === undefined
? undefined
: async (): Promise<string> =>
(requestedVersion ??= await resolveSimulatorPackageVersionAsync(
'appium',
packageVersion,
{ env, signal: startupSignal }
));
// Keep cold ranges on the configured installer's path. Tags need resolution to enforce Appium 3.
const versionSpec = resolveAppium3VersionSpec(
packageVersion !== undefined && !semver.validRange(packageVersion)
? await resolveRequestedVersionAsync!()
: packageVersion
);
logger.info(
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
@@ -211,6 +236,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
asyncResult(
installAppiumAsync({
versionSpec,
resolveRequestedVersionAsync,
driverName: device.driverName,
env,
logger,
@@ -231,7 +257,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
),
]);
const installation = appiumInstall.enforceValue();
appiumHome = installation.appiumHome;
// A prepared home is shared across sessions; remove only a job-owned home after a session.
appiumHome = installation.prepared ? undefined : installation.appiumHome;
startupSignal?.throwIfAborted();
const { appiumBinPath, appiumEnv } = installation;
const wdaCapabilities = await preparePreinstalledWdaAsync({
@@ -243,7 +270,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
})
)?.runtime
: device.runtimeIdentifier,
appiumHome,
appiumHome: installation.appiumHome,
prepared: installation.prepared,
appiumBinPath,
appiumEnv,
logger,
@@ -418,7 +446,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
watchSignal.throwIfAborted();
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
// package_version is the Appium version, which the job installs either way.
// package_version is the Appium version. The job installs it or uses the prepared
// home; the warm claim does not depend on it.
{
engine: 'appium',
requestedDevice: deviceIdentifier,
@@ -503,6 +532,7 @@ export async function resolveAppiumDeviceAsync({
export async function installAppiumAsync({
versionSpec,
resolveRequestedVersionAsync,
driverName,
env,
logger,
@@ -510,13 +540,53 @@ export async function installAppiumAsync({
onError,
}: {
versionSpec: string;
resolveRequestedVersionAsync?: () => Promise<string>;
driverName: AppiumDevice['driverName'];
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
onError?: (error: unknown) => void;
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
}): Promise<{
appiumHome: string;
appiumBinPath: string;
appiumEnv: BuildStepEnv;
prepared: boolean;
}> {
signal?.throwIfAborted();
let prepared =
driverName === 'xcuitest'
? await readPreparedSimulatorToolAsync('appium', PreparedAppiumSchema)
: undefined;
if (
prepared &&
!(await isPreparedAppiumCompatibleAsync(prepared, { versionSpec, env, signal }))
) {
prepared = undefined;
}
if (prepared && resolveRequestedVersionAsync) {
versionSpec = resolveAppium3VersionSpec(await resolveRequestedVersionAsync());
if (!semver.satisfies(prepared.version, versionSpec)) {
prepared = undefined;
}
}
if (prepared) {
try {
// An invalid package manager override fails as it does without a prepared home.
resolveConfiguredPackageManager(env, PackageManager.NPM);
} catch (error) {
onError?.(error);
throw error;
}
logger.info(
`Using the prefetched Appium home (appium ${prepared.version}, xcuitest ${prepared.xcuitest}).`
);
return {
appiumHome: prepared.home,
appiumBinPath: path.join(prepared.home, 'node_modules', '.bin', 'appium'),
appiumEnv: { ...env, APPIUM_HOME: prepared.home },
prepared: true,
};
}
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
try {
signal?.throwIfAborted();
@@ -569,7 +639,7 @@ export async function installAppiumAsync({
signal
);
}
return { appiumHome, appiumBinPath, appiumEnv };
return { appiumHome, appiumBinPath, appiumEnv, prepared: false };
} catch (error) {
onError?.(error);
if (signal) {
@@ -580,6 +650,67 @@ export async function installAppiumAsync({
}
}
/** A metadata hit still needs Appium-specific version, driver and runner checks before reuse. */
async function isPreparedAppiumCompatibleAsync(
prepared: z.infer<typeof PreparedAppiumSchema>,
{ versionSpec, env, signal }: { versionSpec: string; env: BuildStepEnv; signal?: AbortSignal }
): Promise<boolean> {
const { home } = prepared;
if (home !== path.join(os.homedir(), 'Library', 'Caches', 'expo-simulator-startup', 'appium')) {
return false;
}
try {
const installedVersionAsync = async (name: string): Promise<string> =>
PackageVersionSchema.parse(
JSON.parse(
await fs.promises.readFile(path.join(home, 'node_modules', name, 'package.json'), 'utf8')
)
).version;
if (
!semver.satisfies(prepared.version, versionSpec) ||
(await installedVersionAsync('appium')) !== prepared.version ||
(await installedVersionAsync('appium-xcuitest-driver')) !== prepared.xcuitest
) {
return false;
}
const runner = path.join(home, 'preinstalled-wda', 'WebDriverAgentRunner-Runner.app');
await fs.promises.access(runner, fs.constants.R_OK | fs.constants.X_OK);
if (!(await fs.promises.stat(runner)).isDirectory()) {
return false;
}
const metadata = await fs.promises.readFile(path.join(runner, 'Info.plist'));
const info =
metadata.subarray(0, 8).toString('ascii') === 'bplist00'
? bplistParser.parseBuffer(metadata)[0]
: plist.parse(metadata.toString('utf8'));
const executable = z
.object({ CFBundleExecutable: z.string().min(1) })
.parse(info).CFBundleExecutable;
if (path.basename(executable) !== executable || executable === '.' || executable === '..') {
return false;
}
const executablePath = path.join(runner, executable);
await fs.promises.access(executablePath, fs.constants.R_OK | fs.constants.X_OK);
const executableStat = await fs.promises.stat(executablePath);
if (!executableStat.isFile() || executableStat.size === 0) {
return false;
}
// Today's driver list shows that the Appium CLI runs from this home. It does not load the driver.
const { stdout } = await runAppiumSetupCommandAsync(
path.join(home, 'node_modules', '.bin', 'appium'),
['driver', 'list', '--installed', '--json'],
{ env: { ...env, APPIUM_HOME: home }, stdio: 'pipe' },
signal
);
if (!AppiumInstalledDriversSchema.parse(JSON.parse(stdout)).xcuitest?.installed) {
return false;
}
return true;
} catch {
return false;
}
}
async function runAppiumSetupCommandAsync(
command: string,
args: string[],
@@ -137,13 +137,6 @@ export function createStartArgentRemoteSessionBuildFunction(
// A missing or non-positive value disables the idle timeout (opt-in feature).
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
validateControllerVersion({
packageName: ARGENT_PACKAGE_NAME,
minimumVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
packageVersion,
logger,
});
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
const startupStop = new AbortController();
const watchSignal = signal
@@ -156,7 +149,17 @@ export function createStartArgentRemoteSessionBuildFunction(
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
});
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
const startSessionAsync = async (
warmSession?: WarmIosSession,
resolvedPackageVersion = packageVersion
): Promise<void> => {
const versionSpec = resolvedPackageVersion ?? 'latest';
validateControllerVersion({
packageName: ARGENT_PACKAGE_NAME,
minimumVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
packageVersion: resolvedPackageVersion,
logger,
});
const startupAbortController = new AbortController();
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
@@ -1,11 +1,11 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { ArchiveSourceType, SystemError, UserError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { stripRepositoryCredentialsAsync } from '../../common/git';
import { CustomBuildContext } from '../../customBuildContext';
import { withLogPhaseAsync } from '../../utils/logPhase';
import { getLegacySandboxConnection } from '../utils/legacySandboxApi';
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
import { getSandboxWorkerApi } from '../utils/sandboxWorkerApi';
import { requireSandboxWorkerApi } from '../utils/sandboxWorkerApi';
const RECONNECT_DELAY_MS = 1_000;
const AWAIT_SANDBOX_STEP_NAME = 'Await sandbox completion';
@@ -29,18 +29,7 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
}),
],
fn: async (stepCtx, { inputs, signal, env }) => {
const workerApi = getSandboxWorkerApi(ctx);
const { sandboxApi, credential, commandEnv } = workerApi
? {
sandboxApi: workerApi,
credential: workerApi.token,
commandEnv: env,
}
: getLegacySandboxConnection({
graphqlClient: ctx.graphqlClient,
env,
sandboxId: String(inputs.sandbox_id.value),
});
const workerApi = requireSandboxWorkerApi(ctx);
const mcpServerUrl = ctx.mcpServerUrl;
if (!mcpServerUrl) {
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
@@ -57,20 +46,29 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
`Invalid sandbox maximum duration: ${String(maxDurationSeconds)}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
}
if (ctx.job.projectArchive.type === ArchiveSourceType.GIT) {
// Scripts and commands inside the sandbox could read the token from .git/config.
await stripRepositoryCredentialsAsync({
repositoryDirectory: stepCtx.global.wasCheckedOut()
? ctx.projectTargetDirectory
: ctx.projectSourceDirectory,
});
}
const daemon = await startSandboxDaemonAsync({
credential,
credential: workerApi.token,
serverUrl: mcpServerUrl,
reconnectDelayMs: RECONNECT_DELAY_MS,
logger: stepCtx.logger,
signal,
workingDirectory: stepCtx.workingDirectory,
env: commandEnv,
workerApi: sandboxApi,
env,
workerApi,
});
try {
await daemon.ready;
signal?.throwIfAborted();
await sandboxApi.markReadyAsync({ signal }).catch((error: unknown) => {
await workerApi.markReadyAsync({ signal }).catch((error: unknown) => {
signal?.throwIfAborted();
throw new SystemError(
`Failed to mark sandbox ${String(inputs.sandbox_id.value)} as ready.`,
@@ -108,7 +108,10 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
});
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
const startSessionAsync = async (
warmSession?: WarmIosSession,
resolvedPackageVersion = packageVersion
): Promise<void> => {
const startupAbortController = new AbortController();
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
@@ -148,7 +151,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
signal: startupSignal,
onStartupError: error => startupAbortController.abort(error),
packageVersion,
packageVersion: resolvedPackageVersion,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
@@ -0,0 +1,279 @@
import { type BuildStepGlobalContext } from '@expo/steps';
import { once } from 'node:events';
import fsPromises, {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { PassThrough, Readable } from 'node:stream';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { type CustomBuildContext } from '../../../customBuildContext';
import { MAX_ANDROID_LOG_BYTES_PER_SOURCE } from '../../../utils/androidEmulatorLogCapture';
import {
ALL_ANDROID_EMULATOR_LOG_SOURCES,
type AndroidEmulatorLogSource,
} from '../../../utils/androidEmulatorLogSources';
import {
collectAndroidEmulatorLogsAsync,
getAndroidEmulatorLogCapture,
} from '../androidEmulatorLogs';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
jest.mock('../deviceRunSessionArtifacts');
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
const ctx = {} as CustomBuildContext;
const sessionEnv = { DEVICE_RUN_SESSION_ID: 'session' };
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
let directory: string;
let emulatorPath: string;
let logcatPath: string;
let emulatorRing: string;
let logcatRing: string;
let global: BuildStepGlobalContext;
let logger: ReturnType<typeof createMockLogger>;
let uploadedContents: Map<string, string>;
beforeEach(async () => {
global = {} as BuildStepGlobalContext;
logger = createMockLogger();
uploadedContents = new Map();
directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-collection-'));
emulatorPath = path.join(directory, 'device-attempt-1-emulator.log');
logcatPath = path.join(directory, 'device-attempt-1-logcat.log');
emulatorRing = path.join(directory, 'emulator-output.log');
logcatRing = path.join(directory, 'device-logcat.log');
upload.mockReset().mockImplementation(async (_ctx, { filename, openStream }) => {
const chunks: Buffer[] = [];
for await (const chunk of openStream()) {
chunks.push(Buffer.from(chunk));
}
uploadedContents.set(filename, Buffer.concat(chunks).toString());
});
});
afterEach(async () => {
jest.restoreAllMocks();
await rm(directory, { recursive: true, force: true });
});
/** `==== <launch> <ISO time> ====\n` precedes the first output of a launch. */
const MARKER = String.raw`==== \S+ \S+ ====\n`;
async function captureLaunch(
filePath: string,
source: AndroidEmulatorLogSource,
text: string,
workflow = global
): Promise<void> {
const capture = getAndroidEmulatorLogCapture(
workflow,
sessionEnv,
ALL_ANDROID_EMULATOR_LOG_SOURCES
);
const input = Readable.from([Buffer.from(text)]);
capture.attach(input, filePath, source);
await once(input, 'end');
await capture.finishFileAsync(filePath);
}
function collect(): Promise<void> {
return collectAndroidEmulatorLogsAsync(ctx, {
global,
deviceRunSessionId: 'session',
logger,
});
}
it.each([
{ env: {}, file: 'device-attempt-1-emulator.log', expectedBytes: 20 * 1024 * 1024 + 1 },
{ env: sessionEnv, file: 'emulator-output.log', expectedBytes: 20 * 1024 * 1024 },
])(
'retains $expectedBytes in $file with workflow environment $env',
async ({ env, file, expectedBytes }) => {
const capture = getAndroidEmulatorLogCapture(global, env, ALL_ANDROID_EMULATOR_LOG_SOURCES);
const input = Readable.from([Buffer.alloc(20 * 1024 * 1024 + 1, 'x')]);
capture.attach(input, emulatorPath, 'emulator-output');
await once(input, 'end');
await capture.finishAsync();
expect((await fsPromises.stat(path.join(directory, file))).size).toBe(expectedBytes);
}
);
it('uploads one artifact per source and ignores files outside the capture inventory', async () => {
await captureLaunch(emulatorPath, 'emulator-output', 'host output');
await captureLaunch(logcatPath, 'device-logcat', 'Android output');
await captureLaunch(
path.join(directory, 'retry-emulator.log'),
'emulator-output',
'retry output'
);
await writeFile(emulatorPath, 'not captured');
const elsewhere = path.join(directory, 'elsewhere');
await mkdir(elsewhere);
await writeFile(path.join(elsewhere, 'other-emulator.log'), 'another job');
await symlink(path.join(elsewhere, 'other-emulator.log'), logcatPath);
await collect();
expect([...uploadedContents.keys()]).toEqual(['emulator-output.log', 'device-logcat.log']);
expect(uploadedContents.get('emulator-output.log')).toMatch(
new RegExp(`^${MARKER}host output\\n${MARKER}retry output$`)
);
expect(uploadedContents.get('device-logcat.log')).toMatch(
new RegExp(`^${MARKER}Android output$`)
);
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
filename: 'emulator-output.log',
kind: 'simulator-log',
metadata: expect.objectContaining({
platform: 'android',
source: 'emulator-output',
scope: 'emulator-process',
originalSizeBytes: uploadedContents.get('emulator-output.log')?.length,
omittedBytes: 0,
truncated: false,
}),
signal: expect.any(AbortSignal),
attempts: 2,
})
);
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
filename: 'device-logcat.log',
metadata: expect.objectContaining({ source: 'device-logcat', scope: 'device' }),
})
);
});
it('seals a live producer before upload and keeps retry bodies identical without copying files', async () => {
const input = new PassThrough();
const capture = getAndroidEmulatorLogCapture(
global,
sessionEnv,
ALL_ANDROID_EMULATOR_LOG_SOURCES
);
capture.attach(input, emulatorPath, 'emulator-output');
input.write('before');
const contents: string[] = [];
upload.mockImplementationOnce(async (_ctx, { size, openStream, metadata }) => {
expect(metadata).toEqual(
expect.objectContaining({ originalSizeBytes: size, truncated: false })
);
for (let attempt = 0; attempt < 2; attempt++) {
input.write(' ignored after finalization');
const chunks: Buffer[] = [];
for await (const chunk of openStream()) {
chunks.push(Buffer.from(chunk));
}
contents.push(Buffer.concat(chunks).toString());
}
});
try {
await collect();
expect(contents).toHaveLength(2);
expect(contents[0]).toMatch(new RegExp(`^${MARKER}before$`));
expect(contents[1]).toBe(contents[0]);
expect(await readFile(emulatorRing, 'utf8')).toBe(contents[0]);
expect(await readdir(directory)).toEqual(['emulator-output.log']);
expect(input.readableFlowing).toBe(true);
const late = new PassThrough();
capture.attach(late, logcatPath, 'device-logcat');
late.end('not captured');
expect(late.readableFlowing).toBe(true);
await expect(fsPromises.stat(logcatRing)).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
input.end();
}
});
it('continues with the other log after a failed upload and never repeats the collection', async () => {
await captureLaunch(emulatorPath, 'emulator-output', 'host output');
await captureLaunch(logcatPath, 'device-logcat', 'Android output');
upload.mockRejectedValueOnce(new Error('storage unavailable'));
await Promise.all([collect(), collect()]);
await collect();
expect(upload).toHaveBeenCalledTimes(2);
expect(uploadedContents.get('device-logcat.log')).toMatch(/Android output$/);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('other logs will continue')
);
});
it('uses a fresh 30 second budget for the next file after an upload times out', async () => {
await captureLaunch(emulatorPath, 'emulator-output', 'host output');
await captureLaunch(logcatPath, 'device-logcat', 'Android output');
const controllers: AbortController[] = [];
const timeout = jest.spyOn(AbortSignal, 'timeout').mockImplementation(() => {
const controller = new AbortController();
controllers.push(controller);
return controller.signal;
});
upload.mockImplementationOnce(async (_ctx, { signal }) => {
controllers[0].abort(new Error('upload timed out'));
signal?.throwIfAborted();
});
await expect(collect()).resolves.toBeUndefined();
expect(uploadedContents.get('device-logcat.log')).toMatch(/Android output$/);
expect(timeout.mock.calls).toEqual([[30_000], [30_000]]);
expect(upload.mock.calls[1][1].signal?.aborted).toBe(false);
});
it('does not collect another workflow and warns when a captured file disappears', async () => {
const otherWorkflow = {} as BuildStepGlobalContext;
await captureLaunch(emulatorPath, 'emulator-output', 'other workflow', otherWorkflow);
await collect();
expect(upload).not.toHaveBeenCalled();
await getAndroidEmulatorLogCapture(otherWorkflow, sessionEnv, []).finishAsync();
await captureLaunch(path.join(directory, 'missing-emulator.log'), 'emulator-output', 'removed');
await rm(emulatorRing);
await expect(collect()).resolves.toBeUndefined();
expect(upload).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('emulator-output.log')
);
});
it('uploads the tail of a truncated log with the retained size and omission metadata', async () => {
const capture = getAndroidEmulatorLogCapture(
global,
sessionEnv,
ALL_ANDROID_EMULATOR_LOG_SOURCES
);
const input = Readable.from([
Buffer.concat([Buffer.from('head: '), Buffer.alloc(MAX_ANDROID_LOG_BYTES_PER_SOURCE, 'b')]),
]);
capture.attach(input, emulatorPath, 'emulator-output');
await once(input, 'end');
await collect();
const uploaded = uploadedContents.get('emulator-output.log');
expect(uploaded).toHaveLength(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
expect(uploaded).not.toContain('head');
expect(uploaded).not.toContain('====');
const [, call] = upload.mock.calls[0];
const { originalSizeBytes } = call.metadata as { originalSizeBytes: number };
const omitted = originalSizeBytes - MAX_ANDROID_LOG_BYTES_PER_SOURCE;
expect(omitted).toBeGreaterThanOrEqual(6);
expect(call).toEqual(
expect.objectContaining({
size: MAX_ANDROID_LOG_BYTES_PER_SOURCE,
name: expect.stringContaining('truncated, last 20.0 MB retained'),
metadata: expect.objectContaining({ omittedBytes: omitted, truncated: true }),
})
);
});
@@ -13,6 +13,7 @@ import type { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
import { uploadDeviceRunSessionFileAsync } from '../uploadDeviceRunSessionFile';
jest.unmock('node-fetch');
jest.unmock('node:fs');
@@ -123,6 +124,41 @@ it('replays the full file after a transient PUT failure without recreating the u
expect(streams.every(stream => stream.destroyed)).toBe(true);
});
it('retries a PUT that fails before reading the body without aborting the file stream', async () => {
let puts = 0;
let replayed = 0;
handle = (request, response) => {
puts++;
if (puts === 1) {
response.statusCode = 503;
response.flushHeaders();
return;
}
request.on('data', chunk => {
replayed += chunk.length;
});
request.on('end', () => response.end());
};
const file = path.join(directory, 'emulator.log');
const payload = Buffer.alloc(8 * 1024 * 1024, '*');
await writeFile(file, payload);
await expect(
uploadDeviceRunSessionFileAsync(ctx, {
filePath: file,
describe: () => ({
deviceRunSessionId: 'drs-id',
artifactId: 'log-id',
name: 'Emulator log',
filename: 'emulator.log',
kind: 'simulator-log',
}),
attempts: 2,
})
).resolves.toBe(true);
expect(puts).toBe(2);
expect(replayed).toBe(payload.length);
});
it('keeps a slow PUT alive while its body is still flowing', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
let received = 0;
@@ -9,6 +9,7 @@ import path from 'node:path';
import type { CustomBuildContext } from '../../../customBuildContext';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
import { verifyLocalEgressGuardAsync } from '../localEgressGuard';
import { type ServeSimApplicationOptions } from '../remoteDeviceRunSession';
@@ -2193,6 +2194,58 @@ it.each([false, true])(
}
);
it.each([
['0.13.3', '@expo/[email protected]'],
[undefined, '@expo/serve-sim@latest'],
])(
'uses the warm serve-sim version %s for adoption and recordings',
async (serveSimVersion, spec) => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([
{ udid: 'emulator-5554', token: 'preview-token', url: 'http://127.0.0.1:3200' },
]);
const recording = jest.spyOn(IosSimulatorRecordingUtils, 'useServeSimPackage');
const startAsync = jest.fn(async () => ({
pid: undefined,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopServer,
}));
const host = await startDeviceSessionHostAsync(
ctx,
{
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' },
logger,
iosSimulatorUdid: 'emulator-5554',
timeoutMs: 1000,
},
{
port: 3200,
serveSimVersion,
turnArgs: Promise.resolve([]),
requiresPreviewRetirement: true,
startAsync,
stopAsync: stopServer,
}
);
try {
expect(startAsync).toHaveBeenCalledWith(
expect.objectContaining({
command: 'bun',
args: expect.arrayContaining(['x', spec]),
})
);
expect(recording).toHaveBeenCalledWith(spec);
} finally {
await host.finishAsync();
recording.mockRestore();
}
}
);
it('opens the preview while a supplied server waits for TURN and its startup handshake', async () => {
const turn = deferred<string[]>();
const handshake = deferred<Awaited<ReturnType<typeof spawnDetached>>>();
@@ -1,120 +0,0 @@
import { type Client, CombinedError } from '@urql/core';
import { LegacySandboxApi } from '../legacySandboxApi';
import { WorkerApiError } from '../workerApi';
const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
describe(LegacySandboxApi, () => {
let mutation: jest.Mock;
let api: LegacySandboxApi;
beforeEach(() => {
mutation = jest.fn();
api = new LegacySandboxApi({ mutation } as unknown as Client, 'sandbox-id');
});
function mockMutationResults(...results: unknown[]): void {
for (const result of results) {
mutation.mockReturnValueOnce({ toPromise: async () => result });
}
}
it('marks the sandbox ready and rethrows a GraphQL error', async () => {
const error = new CombinedError({ graphQLErrors: ['Sandbox has stopped.'] });
mockMutationResults({ data: {} }, { error });
await api.markReadyAsync();
await expect(api.markReadyAsync()).rejects.toBe(error);
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
{ sandboxId: 'sandbox-id' },
undefined
);
});
it('creates an upload session for the sandbox', async () => {
mockMutationResults({
data: {
sandbox: {
createArtifactUploadSession: {
artifact: { id: ARTIFACT_ID },
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: { A: '1' } },
},
},
},
});
await expect(
api.createArtifactUploadSessionAsync({ name: 'Crash log', filename: 'crash.log', size: 5 })
).resolves.toEqual({
artifact: { id: ARTIFACT_ID },
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: { A: '1' } },
});
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
expect.anything()
);
});
it.each([
{ codes: ['VALIDATION_ERROR'], code: 'VALIDATION_ERROR' },
{ codes: ['VALIDATION_ERROR', 'UNEXPECTED_ERROR'], code: undefined },
])(
'reports upload session errors with codes $codes as a worker API error with code $code',
async ({ codes, code }) => {
mockMutationResults({
error: new CombinedError({
graphQLErrors: codes.map((errorCode, index) => ({
message: `Error ${index}.`,
extensions: { errorCode },
})),
}),
});
const error = await api
.createArtifactUploadSessionAsync({ name: 'Crash log', filename: 'crash.log', size: 5 })
.catch(err => err);
expect(error).toBeInstanceOf(WorkerApiError);
expect(error.code).toBe(code);
expect(error.message).toBe(codes.map((_, index) => `Error ${index}.`).join('\n'));
}
);
it('retries finalize after a server error and throws a failure that persists', async () => {
const persistent = new CombinedError({
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
response: { status: 200 },
});
mockMutationResults(
{
error: new CombinedError({
networkError: new Error('Bad Gateway'),
response: { status: 502 },
}),
},
{ error: persistent }
);
await expect(api.finalizeArtifactAsync(ARTIFACT_ID)).rejects.toBe(persistent);
expect(mutation).toHaveBeenCalledTimes(2);
expect(mutation).toHaveBeenLastCalledWith(
expect.anything(),
{ artifactId: ARTIFACT_ID },
expect.anything()
);
});
it('rejects a finalized artifact without a download URL', async () => {
mockMutationResults({
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: null } } },
});
await expect(api.finalizeArtifactAsync(ARTIFACT_ID)).rejects.toThrow(
`Finalized sandbox artifact ${ARTIFACT_ID} has no download URL.`
);
});
});
@@ -54,6 +54,23 @@ it('downloads the driver-matched Simulator runner and retains its XCTest framewo
).toBe(true);
});
it('uses the runner of a prepared Appium home without a download', async () => {
await fs.promises.mkdir(runner, { recursive: true });
await expect(preparePreinstalledWdaAsync({ ...options, prepared: true })).resolves.toEqual({
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': runner,
});
await expect(
preparePreinstalledWdaAsync({
...options,
prepared: true,
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-16-4',
})
).resolves.toEqual({});
expect(spawn).not.toHaveBeenCalled();
expect(fs.existsSync(runner)).toBe(true);
});
it.each([
undefined,
'com.apple.CoreSimulator.SimRuntime.iOS-16-4',
@@ -23,7 +23,6 @@ import { setTimeout as delay } from 'node:timers/promises';
import { inspect } from 'node:util';
import { SandboxArtifactUploadManager, startSandboxArtifactUploadAsync } from '../sandboxArtifacts';
import { type LegacySandboxApi } from '../legacySandboxApi';
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
import { WorkerApiError } from '../workerApi';
@@ -66,7 +65,7 @@ describe('sandbox artifact uploads', () => {
logger = { info: jest.fn(), error: jest.fn() };
uploadArtifactAsync = async options =>
await startSandboxArtifactUploadAsync({
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
workerApi: workerApi as unknown as SandboxWorkerApi,
logger: logger as unknown as bunyan,
...options,
});
@@ -390,7 +389,7 @@ describe('sandbox artifact uploads', () => {
function createManager(signal: AbortSignal): SandboxArtifactUploadManager {
return new SandboxArtifactUploadManager({
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
workerApi: workerApi as unknown as SandboxWorkerApi,
logger: logger as unknown as bunyan,
signal,
});
@@ -6,7 +6,6 @@ import WebSocket, { WebSocketServer } from 'ws';
import * as sandboxCommandImplementations from '../sandboxCommandImplementations';
import { startSandboxDaemonAsync } from '../sandboxDaemon';
import { type LegacySandboxApi } from '../legacySandboxApi';
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
jest.unmock('@expo/logger');
@@ -17,7 +16,7 @@ jest.unmock('node:fs/promises');
describe(startSandboxDaemonAsync.name, () => {
const logger = Log.child({ buildStepId: 'sandbox-test' });
const workerApi = {} as SandboxWorkerApi | LegacySandboxApi;
const workerApi = {} as SandboxWorkerApi;
it.each(['ftp://localhost', 'not a URL'])('reports invalid server URL %s', async serverUrl => {
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
const daemon = await startSandboxDaemonAsync({
@@ -18,7 +18,6 @@ import {
type SandboxDaemonCommandImplementations,
createSandboxCommandImplementations,
} from '../sandboxCommandImplementations';
import { type LegacySandboxApi } from '../legacySandboxApi';
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
const READ_FILE_LIMITS = { maxTextBytes: 40_000, maxImageBytes: 3_000_000 };
@@ -50,7 +49,7 @@ describe('sandbox daemon commands', () => {
workingDirectory,
env: process.env,
signal: controller.signal,
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
workerApi: workerApi as unknown as SandboxWorkerApi,
logger,
});
commandImplementations = commands.commandImplementations;
@@ -79,7 +78,7 @@ describe('sandbox daemon commands', () => {
workingDirectory,
env: process.env,
signal: controller.signal,
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
workerApi: workerApi as unknown as SandboxWorkerApi,
logger,
});
const command = commands.commandImplementations.execCommand({ cmd: 'printf should-not-run' });
@@ -22,29 +22,17 @@ it('uploads NDJSON with explicit user-app scope', async () => {
kind: 'simulator-log',
filename: 'app-logs.ndjson',
size: 11,
attempts: 1,
metadata: expect.objectContaining({ scope: 'user-apps', udid: 'A' }),
})
);
});
it('skips empty and missing files', async () => {
await uploadServeSimLogsFileAsync(ctx, args);
await writeFile(filePath, '');
await uploadServeSimLogsFileAsync(ctx, args);
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
});
it('aborts the upload on a file read error without an unhandled error', async () => {
it('warns and resolves when the upload fails', async () => {
await writeFile(filePath, '{}\n');
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
const aborted = new Promise<void>((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
});
openStream().destroy(new Error('read failure'));
await aborted;
});
.mockRejectedValueOnce(new Error('storage unavailable'));
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
@@ -0,0 +1,79 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { uploadDeviceRunSessionFileAsync } from '../uploadDeviceRunSessionFile';
jest.mock('../deviceRunSessionArtifacts');
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
const ctx = {} as CustomBuildContext;
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
const describeFile = (size: number) => ({
deviceRunSessionId: 'session',
artifactId: 'log',
name: `log (${size} bytes)`,
filename: 'log.txt',
kind: 'simulator-log',
});
let directory: string;
let filePath: string;
beforeEach(async () => {
directory = await mkdtemp(path.join(os.tmpdir(), 'device-run-session-file-'));
filePath = path.join(directory, 'log.txt');
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
function uploadFile(options: { signal?: AbortSignal; attempts?: number } = {}): Promise<boolean> {
return uploadDeviceRunSessionFileAsync(ctx, { filePath, describe: describeFile, ...options });
}
it('skips an empty file without uploading', async () => {
await writeFile(filePath, '');
expect(await uploadFile()).toBe(false);
expect(upload).not.toHaveBeenCalled();
});
it('rejects a missing file before uploading', async () => {
await expect(uploadFile()).rejects.toMatchObject({ code: 'ENOENT' });
expect(upload).not.toHaveBeenCalled();
});
it('describes the file by its size and forwards attempts and the caller signal', async () => {
await writeFile(filePath, 'abc');
const controller = new AbortController();
expect(await uploadFile({ signal: controller.signal, attempts: 2 })).toBe(true);
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ name: 'log (3 bytes)', size: 3, attempts: 2, filename: 'log.txt' })
);
const { signal } = upload.mock.calls[0][1];
const reason = new Error('stop');
controller.abort(reason);
expect(signal?.reason).toBe(reason);
});
it('aborts the upload on a file read error and ignores an aborted request body', async () => {
await writeFile(filePath, '{}\n');
const readError = Object.assign(new Error('read failed'), { code: 'EIO' });
upload.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
const aborted = openStream();
aborted.destroy(Object.assign(new Error('request body aborted'), { name: 'AbortError' }));
await new Promise<void>(resolve => aborted.once('close', resolve));
expect(signal?.aborted).toBe(false);
const failed = openStream();
failed.destroy(readError);
await new Promise<void>(resolve => failed.once('close', resolve));
expect(signal?.reason).toBe(readError);
});
expect(await uploadFile()).toBe(true);
});
@@ -2,6 +2,8 @@ import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { type CustomBuildContext } from '../../../customBuildContext';
import { resolveSimulatorPackageVersionAsync } from '../../../utils/simulatorPackageVersion';
import { stopWarmSimulatorAsync } from '../../../utils/warmSimulator';
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
import { fetchWebPreviewTurnArgsAsync } from '../remoteDeviceRunSession';
import {
@@ -12,6 +14,8 @@ import {
import { type WarmIosSession, tryClaimWarmIosSessionAsync } from '../warmIosSession';
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/simulatorPackageVersion');
jest.mock('../../../utils/warmSimulator');
jest.mock('../IosSimulatorRecordingUtils');
jest.mock('../warmIosSession');
jest.mock('../remoteDeviceRunSession', () => ({
@@ -63,9 +67,11 @@ beforeEach(() => {
});
it('adopts the warm device on the warm port with its recording, then prepares tools', async () => {
warm.serveSimVersion = '0.13.3';
const server = createWarmDeviceSessionServer(ctx, logger, warm);
expect(server).toMatchObject({
port: 3200,
serveSimVersion: '0.13.3',
requiresPreviewRetirement: true,
preinstalledApp: warm.preinstalledExpoGo,
});
@@ -125,7 +131,7 @@ describe(runOnWarmIosSimulatorAsync, () => {
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(undefined);
const run = jest.fn().mockResolvedValue('cold');
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold');
expect(run).toHaveBeenCalledWith(undefined);
expect(run).toHaveBeenCalledWith(undefined, undefined);
});
it('runs on the claimed warm Simulator', async () => {
@@ -133,7 +139,7 @@ describe(runOnWarmIosSimulatorAsync, () => {
const run = jest.fn().mockResolvedValue('warm');
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('warm');
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith(warm);
expect(run).toHaveBeenCalledWith(warm, undefined);
});
it('stops warmup and runs cold after an adoption failure', async () => {
@@ -144,7 +150,71 @@ describe(runOnWarmIosSimulatorAsync, () => {
.mockResolvedValueOnce('cold');
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold');
expect(warm.process.stopAsync).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenLastCalledWith(undefined);
expect(run).toHaveBeenLastCalledWith(undefined, undefined);
});
it.each([true, false])(
'uses the resolved request for claim and setup (warm: %s)',
async reuse => {
jest.mocked(resolveSimulatorPackageVersionAsync).mockResolvedValue('0.14.0');
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(reuse ? warm : undefined);
const request = { ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' };
const run = jest.fn().mockResolvedValue('done');
await expect(runOnWarmIosSimulatorAsync(request, run)).resolves.toBe('done');
expect(tryClaimWarmIosSessionAsync).toHaveBeenCalledWith({
...request,
packageVersion: '0.14.0',
});
expect(run).toHaveBeenCalledWith(reuse ? warm : undefined, '0.14.0');
}
);
it('keeps the resolved version through an adoption failure', async () => {
jest.mocked(resolveSimulatorPackageVersionAsync).mockResolvedValue('0.14.0');
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
const run = jest
.fn()
.mockRejectedValueOnce(new WarmSessionAdoptionError())
.mockResolvedValue('cold');
await runOnWarmIosSimulatorAsync(
{ ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' },
run
);
expect(resolveSimulatorPackageVersionAsync).toHaveBeenCalledTimes(1);
expect(run.mock.calls).toEqual([
[warm, '0.14.0'],
[undefined, '0.14.0'],
]);
});
it('stops warmup and fails when an explicit request cannot be resolved', async () => {
const error = new Error('lookup failed');
jest.mocked(resolveSimulatorPackageVersionAsync).mockRejectedValue(error);
const run = jest.fn();
await expect(
runOnWarmIosSimulatorAsync(
{ ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' },
run
)
).rejects.toBe(error);
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
expect(tryClaimWarmIosSessionAsync).not.toHaveBeenCalled();
expect(run).not.toHaveBeenCalled();
});
it('preserves lookup and cleanup failures without starting a session', async () => {
const lookup = new Error('lookup failed');
const cleanup = new Error('cleanup failed');
jest.mocked(resolveSimulatorPackageVersionAsync).mockRejectedValue(lookup);
jest.mocked(stopWarmSimulatorAsync).mockRejectedValue(cleanup);
const run = jest.fn();
await expect(
runOnWarmIosSimulatorAsync(
{ ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' },
run
)
).rejects.toMatchObject({ errors: [lookup, cleanup] });
expect(run).not.toHaveBeenCalled();
});
it('does not run cold after a failure during the session', async () => {
@@ -4,6 +4,7 @@ import path from 'node:path';
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../../utils/warmSimulator';
import { readLocalEgressHandoffAsync } from '../localEgress';
import { runOnWarmIosSimulatorAsync } from '../warmDeviceSessionHost';
import { tryClaimWarmIosSessionAsync } from '../warmIosSession';
jest.mock('../../../utils/warmSimulator');
@@ -84,6 +85,24 @@ it('takes the preinstalled Expo Go that prepare-tools reports', async () => {
expect(session!.preinstalledExpoGo).toEqual(expoGo);
});
it('keeps the exact serve-sim version from the launcher claim', async () => {
jest
.mocked(runSimulatorLauncherAsync)
.mockResolvedValueOnce(JSON.stringify({ ...claim, serveSimVersion: '0.13.3' }));
const session = await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger });
expect(session?.serveSimVersion).toBe('0.13.3');
});
it('stops warmup when the claim contains an invalid serve-sim version', async () => {
jest
.mocked(runSimulatorLauncherAsync)
.mockResolvedValueOnce(JSON.stringify({ ...claim, serveSimVersion: 'latest' }));
await expect(
tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })
).resolves.toBeUndefined();
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
});
it('reads the agent-device server after tool preparation', async () => {
jest
.spyOn(fs, 'readFile')
@@ -121,7 +140,6 @@ it.each([
{},
() => jest.mocked(runSimulatorLauncherAsync).mockRejectedValue(new Error('no session')),
],
['a pinned package version', { packageVersion: '1.2.3' }, () => {}],
['a job Simulator setting', { env: { ...env, SIMCTL_CHILD_FOO: '1' } }, () => {}],
[
'a local egress session',
@@ -149,3 +167,69 @@ it('stops the claimed session once', async () => {
await Promise.all([session!.process.stopAsync(), session!.process.stopAsync()]);
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
});
it.each([
['serve-sim', 'serveSimVersion'],
['agent-device', 'agentDeviceVersion'],
['argent', 'argentVersion'],
] as const)(
'reuses %s only when its recorded version matches the resolved request',
async (engine, field) => {
jest
.mocked(runSimulatorLauncherAsync)
.mockResolvedValue(JSON.stringify({ ...claim, [field]: '1.2.3' }));
await expect(
tryClaimWarmIosSessionAsync({ engine, packageVersion: '1.2.3', env, logger })
).resolves.toBeDefined();
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
await expect(
tryClaimWarmIosSessionAsync({ engine, packageVersion: '1.2.4', env, logger })
).resolves.toBeUndefined();
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
}
);
it.each(['serve-sim', 'agent-device', 'argent'] as const)(
'uses normal setup for %s when older claims omit its version',
async engine => {
await expect(
tryClaimWarmIosSessionAsync({ engine, packageVersion: '1.2.3', env, logger })
).resolves.toBeUndefined();
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
}
);
it('accepts a resolved prerelease pin for matching warm adoption', async () => {
jest
.mocked(runSimulatorLauncherAsync)
.mockResolvedValue(JSON.stringify({ ...claim, serveSimVersion: '0.13.4-beta.1+build.2' }));
await expect(
tryClaimWarmIosSessionAsync({
engine: 'serve-sim',
packageVersion: '0.13.4-beta.1+build.2',
env,
logger,
})
).resolves.toMatchObject({ serveSimVersion: '0.13.4-beta.1+build.2' });
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
});
it('preserves exact build metadata through job resolution and warm claim', async () => {
const version = '0.13.4-beta.1+build.2';
jest
.mocked(runSimulatorLauncherAsync)
.mockResolvedValueOnce(JSON.stringify({ ...claim, serveSimVersion: version }));
const run = jest.fn(async (session, resolvedVersion) => {
expect(session?.serveSimVersion).toBe(version);
expect(resolvedVersion).toBe(version);
return 'warm';
});
await expect(
runOnWarmIosSimulatorAsync(
{ engine: 'serve-sim', packageVersion: `v${version}`, env, logger },
run
)
).resolves.toBe('warm');
expect(run).toHaveBeenCalledTimes(1);
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
});
@@ -0,0 +1,144 @@
import { type bunyan } from '@expo/logger';
import { type BuildStepGlobalContext } from '@expo/steps';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import {
type AndroidEmulatorLogCapture,
AndroidEmulatorLogRing,
type FinalizedAndroidEmulatorLog,
} from '../../utils/androidEmulatorLogCapture';
import { AndroidEmulatorLogFiles } from '../../utils/androidEmulatorLogFiles';
import { type AndroidEmulatorLogSource } from '../../utils/androidEmulatorLogSources';
import { formatBytes } from '../../utils/artifacts';
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
const UPLOAD_TIMEOUT_MS = 30_000;
const LOG_SOURCE_DETAILS = {
'emulator-output': { name: 'Emulator output', scope: 'emulator-process' },
'device-logcat': { name: 'Android device logcat', scope: 'device' },
} satisfies Record<AndroidEmulatorLogSource, { name: string; scope: string }>;
type Collection = {
capture: AndroidEmulatorLogCapture;
upload?: Promise<void>;
};
// A worker can execute more than one job; each workflow owns its capture and uploads.
const collections = new WeakMap<BuildStepGlobalContext, Collection>();
/** The first launch of a workflow fixes its source selection; later calls reuse the capture. */
export function getAndroidEmulatorLogCapture(
global: BuildStepGlobalContext,
env: NodeJS.ProcessEnv,
sources: readonly AndroidEmulatorLogSource[]
): AndroidEmulatorLogCapture {
let collection = collections.get(global);
if (!collection) {
collection = {
// Sessions upload a bounded ring per source; builds keep plain launch files.
capture: env.DEVICE_RUN_SESSION_ID
? new AndroidEmulatorLogRing({ sources })
: new AndroidEmulatorLogFiles({ sources }),
};
collections.set(global, collection);
}
return collection.capture;
}
export async function collectAndroidEmulatorLogsAsync(
ctx: CustomBuildContext,
{
global,
deviceRunSessionId,
logger,
}: {
global: BuildStepGlobalContext;
deviceRunSessionId: string;
logger: bunyan;
}
): Promise<void> {
const collection = collections.get(global);
if (!collection) {
logger.info('No Android emulator logs captured; skipping upload.');
return;
}
// Generated session workflows collect once. Repeated calls share that finalization/upload.
collection.upload ??= uploadCapturedLogsAsync(ctx, {
collection,
deviceRunSessionId,
logger,
});
await collection.upload;
}
async function uploadCapturedLogsAsync(
ctx: CustomBuildContext,
{
collection,
deviceRunSessionId,
logger,
}: {
collection: Collection;
deviceRunSessionId: string;
logger: bunyan;
}
): Promise<void> {
const logs = await collection.capture.finishAsync();
for (const log of logs) {
const filename = path.basename(log.filePath);
try {
const uploaded = await uploadLogAsync(ctx, {
deviceRunSessionId,
log,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
});
if (uploaded) {
logger.info(`Uploaded Android diagnostic log ${filename}.`);
}
} catch (err) {
logger.warn({ err }, `Could not upload Android log ${filename}; other logs will continue.`);
}
}
}
async function uploadLogAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
log: { filePath, source, bytesSeen },
signal,
}: {
deviceRunSessionId: string;
log: FinalizedAndroidEmulatorLog;
signal: AbortSignal;
}
): Promise<boolean> {
const filename = path.basename(filePath);
return await uploadDeviceRunSessionFileAsync(ctx, {
filePath,
describe: size => {
const truncationNote =
size < bytesSeen ? `; truncated, last ${formatBytes(size)} retained` : '';
return {
deviceRunSessionId,
artifactId: filename,
name: `${LOG_SOURCE_DETAILS[source].name} (${filename}${truncationNote})`,
filename,
kind: 'simulator-log',
metadata: {
__eas_type: 'simulator-log',
platform: 'android',
source,
scope: LOG_SOURCE_DETAILS[source].scope,
originalSizeBytes: bytesSeen,
// The capture keeps the newest bytes, so the omitted bytes are the head.
omittedBytes: bytesSeen - size,
truncated: size < bytesSeen,
},
};
},
signal,
attempts: 2,
});
}
@@ -11,7 +11,7 @@ import {
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
type ArtifactMetadata = {
export type ArtifactMetadata = {
deviceRunSessionId: string;
artifactId: string;
name: string;
@@ -432,6 +432,8 @@ export type DeviceSessionServerLaunch = {
*/
export type DeviceSessionServer = {
port: number;
/** The exact serve-sim version prepared by the warm launcher. */
serveSimVersion?: string;
turnArgs: Promise<string[]>;
/** Cold iOS setup runs after control readiness, alongside app installation and before launch. */
setupSimulatorAsync?(options: { signal: AbortSignal; logger: bunyan }): Promise<void>;
@@ -584,6 +586,7 @@ async function startDeviceSessionHostInternalAsync(
? await createAndroidDeviceSessionServerAsync(ctx, { logger })
: await createColdIosSessionAsync(ctx, { env, logger, iosSimulatorUdid }));
const port = server.port;
packageVersion = server.serveSimVersion ?? packageVersion;
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
const packageSpec = isAndroid
? createExpoDeviceHubPackageSpec(packageVersion)
@@ -1,173 +0,0 @@
import { type Env, SystemError } from '@expo/eas-build-job';
import { type Client, CombinedError } from '@urql/core';
import { graphql } from 'gql.tada';
import { setTimeout as delay } from 'node:timers/promises';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { type SandboxArtifactUploadSession } from './sandboxWorkerApi';
import { WorkerApiError, type WorkerApiRequestOptions } from './workerApi';
const MARK_SANDBOX_READY_MUTATION = graphql(`
mutation MarkSandboxReady($sandboxId: ID!) {
sandbox {
markSandboxReady(sandboxId: $sandboxId) {
id
}
}
}
`);
const CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateSandboxArtifactUploadSession(
$sandboxId: ID!
$input: CreateSandboxArtifactUploadSessionInput!
) {
sandbox {
createArtifactUploadSession(sandboxId: $sandboxId, input: $input) {
artifact {
id
}
uploadSession {
url
headers
}
}
}
}
`);
const FINALIZE_SANDBOX_ARTIFACT_MUTATION = graphql(`
mutation FinalizeSandboxArtifact($artifactId: ID!) {
sandbox {
finalizeArtifact(artifactId: $artifactId) {
id
downloadUrl
}
}
}
`);
const FINALIZE_ATTEMPTS = 3;
const FINALIZE_TIMEOUT_MS = 15_000;
export class LegacySandboxApi {
constructor(
private readonly graphqlClient: Client,
private readonly sandboxId: string
) {}
public async markReadyAsync({ signal }: WorkerApiRequestOptions = {}): Promise<void> {
signal?.throwIfAborted();
const result = await this.graphqlClient
.mutation(
MARK_SANDBOX_READY_MUTATION,
{ sandboxId: this.sandboxId },
graphqlAbortContext(signal)
)
.toPromise();
signal?.throwIfAborted();
if (result.error) {
throw result.error;
}
}
public async createArtifactUploadSessionAsync(
input: { name: string; filename: string; size: number },
{ signal, timeoutMs }: WorkerApiRequestOptions = {}
): Promise<SandboxArtifactUploadSession> {
const requestSignal = AbortSignal.any([
...(signal ? [signal] : []),
...(timeoutMs !== undefined ? [AbortSignal.timeout(timeoutMs)] : []),
]);
const result = await this.graphqlClient
.mutation(
CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION,
{ sandboxId: this.sandboxId, input },
graphqlAbortContext(requestSignal)
)
.toPromise();
requestSignal.throwIfAborted();
if (result.error) {
const { graphQLErrors } = result.error;
const codes = Array.from(new Set(graphQLErrors.map(error => error.extensions?.errorCode)));
throw new WorkerApiError(
graphQLErrors.length > 0
? graphQLErrors.map(error => error.message).join('\n')
: result.error.message,
result.error.response?.status,
{ cause: result.error },
codes.length === 1 && typeof codes[0] === 'string' ? codes[0] : undefined
);
}
const { artifact, uploadSession } = result.data!.sandbox.createArtifactUploadSession;
return {
artifact,
uploadSession: {
url: uploadSession.url,
headers: uploadSession.headers as Record<string, string>,
},
};
}
public async finalizeArtifactAsync(
artifactId: string,
{ signal }: WorkerApiRequestOptions = {}
): Promise<string> {
for (let attempt = 1; ; attempt++) {
const timeoutSignal = AbortSignal.timeout(FINALIZE_TIMEOUT_MS);
const finalizeSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
try {
const result = await this.graphqlClient
.mutation(
FINALIZE_SANDBOX_ARTIFACT_MUTATION,
{ artifactId },
graphqlAbortContext(finalizeSignal)
)
.toPromise();
finalizeSignal.throwIfAborted();
if (result.error) {
throw result.error;
}
const { downloadUrl } = result.data!.sandbox.finalizeArtifact;
if (downloadUrl === null) {
throw new SystemError(`Finalized sandbox artifact ${artifactId} has no download URL.`);
}
return downloadUrl;
} catch (error) {
signal?.throwIfAborted();
const retryable =
timeoutSignal.aborted ||
(error instanceof CombinedError && isTransientGraphqlError(error));
if (!retryable || attempt >= FINALIZE_ATTEMPTS) {
throw error;
}
await delay(500 * attempt, undefined, { signal });
}
}
}
}
export function getLegacySandboxConnection({
graphqlClient,
env,
sandboxId,
}: {
graphqlClient: Client;
env: Env;
sandboxId: string;
}): { sandboxApi: LegacySandboxApi; credential: string; commandEnv: Env } {
const { __EAS_SANDBOX_MCP_TOKEN: credential, ...commandEnv } = env;
if (!credential) {
throw new SystemError(
'This sandbox job has neither EAS_SANDBOX_WORKER_TOKEN nor __EAS_SANDBOX_MCP_TOKEN. ' +
'www gives one of them to every sandbox job. Start a new sandbox, and contact support if ' +
'it happens again.'
);
}
return { sandboxApi: new LegacySandboxApi(graphqlClient, sandboxId), credential, commandEnv };
}
function isTransientGraphqlError(error: CombinedError): boolean {
const status: number | undefined = error.response?.status;
return status === undefined ? error.networkError !== undefined : status >= 500;
}
@@ -0,0 +1,31 @@
import { UserError } from '@expo/eas-build-job';
import { BuildRuntimePlatform } from '@expo/steps';
import {
type AndroidEmulatorLogSource,
AndroidEmulatorLogSourcesSchema,
} from '../../utils/androidEmulatorLogSources';
/**
* `log_sources` is unprefixed like every other step input, so the platform
* rule is checked here at runtime, the way `network_capture` is.
*/
export function parseLogSourcesInput(
value: unknown,
{
runtimePlatform,
fallback,
}: { runtimePlatform: BuildRuntimePlatform; fallback: readonly AndroidEmulatorLogSource[] }
): readonly AndroidEmulatorLogSource[] {
if (value === undefined) {
return fallback;
}
const sources = AndroidEmulatorLogSourcesSchema.parse(value);
if (sources.length > 0 && runtimePlatform === BuildRuntimePlatform.DARWIN) {
throw new UserError(
'EAS_LOG_SOURCES_INVALID_INPUT',
`Input "log_sources" selects Android emulator logs, and this session runs on iOS. Drop "log_sources", or run the session on Android.`
);
}
return sources;
}
@@ -9,6 +9,7 @@ import { killProcessGroup } from '../../utils/processes';
export async function preparePreinstalledWdaAsync({
runtimeIdentifier,
appiumHome,
prepared = false,
appiumBinPath,
appiumEnv,
logger,
@@ -16,6 +17,8 @@ export async function preparePreinstalledWdaAsync({
}: {
runtimeIdentifier: string | undefined;
appiumHome: string;
/** A prepared Appium home that already holds the runner. */
prepared?: boolean;
appiumBinPath: string;
appiumEnv: BuildStepEnv;
logger: bunyan;
@@ -32,6 +35,13 @@ export async function preparePreinstalledWdaAsync({
const outdir = path.join(appiumHome, 'preinstalled-wda');
const runner = path.join(outdir, 'WebDriverAgentRunner-Runner.app');
if (prepared) {
logger.info('Using preinstalled WebDriverAgent to avoid xcodebuild during session creation.');
return {
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': runner,
};
}
try {
// The driver selects the release matching its installed WDA and the host architecture.
// Keep the Simulator runner intact; removing its XCTest frameworks breaks launch.
@@ -11,7 +11,6 @@ import { performance } from 'node:perf_hooks';
import { Readable } from 'node:stream';
import { SignedUrlUploadApiError, uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
import { type LegacySandboxApi } from './legacySandboxApi';
import { type SandboxArtifactUploadSession, type SandboxWorkerApi } from './sandboxWorkerApi';
import { WorkerApiError } from './workerApi';
@@ -29,7 +28,7 @@ export class SandboxArtifactUploadManager {
public constructor(
private readonly options: {
workerApi: SandboxWorkerApi | LegacySandboxApi;
workerApi: SandboxWorkerApi;
logger: bunyan;
signal: AbortSignal;
}
@@ -88,7 +87,7 @@ export async function startSandboxArtifactUploadAsync({
name,
signal,
}: {
workerApi: SandboxWorkerApi | LegacySandboxApi;
workerApi: SandboxWorkerApi;
logger: bunyan;
filePath: string;
name: string;
@@ -157,7 +156,7 @@ export async function startSandboxArtifactUploadAsync({
}
async function createSandboxArtifactUploadSessionAsync(
workerApi: SandboxWorkerApi | LegacySandboxApi,
workerApi: SandboxWorkerApi,
input: { name: string; filename: string; size: number },
signal: AbortSignal
): Promise<SandboxArtifactUploadSession> {
@@ -13,7 +13,6 @@ import { performance } from 'node:perf_hooks';
import { readBytesAsync, readTextAsync } from '../../utils/files';
import { detectFileType } from '../../utils/files/filetypes';
import { SandboxArtifactUploadManager } from './sandboxArtifacts';
import { type LegacySandboxApi } from './legacySandboxApi';
import { type SandboxWorkerApi } from './sandboxWorkerApi';
import { ShellSessionManager } from './shellSessionManager';
@@ -37,7 +36,7 @@ export function createSandboxCommandImplementations({
workingDirectory: string;
env: NodeJS.ProcessEnv;
signal: AbortSignal;
workerApi: SandboxWorkerApi | LegacySandboxApi;
workerApi: SandboxWorkerApi;
logger: bunyan;
}): {
commandImplementations: SandboxDaemonCommandImplementations;
@@ -16,7 +16,6 @@ import {
type SandboxDaemonCommandImplementations,
createSandboxCommandImplementations,
} from './sandboxCommandImplementations';
import { type LegacySandboxApi } from './legacySandboxApi';
import { type SandboxWorkerApi } from './sandboxWorkerApi';
export interface SandboxDaemonOptions {
@@ -27,7 +26,7 @@ export interface SandboxDaemonOptions {
signal?: AbortSignal;
workingDirectory: string;
env: NodeJS.ProcessEnv;
workerApi: SandboxWorkerApi | LegacySandboxApi;
workerApi: SandboxWorkerApi;
}
export interface SandboxDaemon {
@@ -1,4 +1,4 @@
import { type Env, type EnvironmentSecret, type Job } from '@expo/eas-build-job';
import { type Env, type EnvironmentSecret, type Job, SystemError } from '@expo/eas-build-job';
import { z } from 'zod';
import {
@@ -97,3 +97,19 @@ export function getSandboxWorkerApi(ctx: {
const token = getSandboxWorkerTokenFromEnvironmentSecrets(ctx.job.secrets?.environmentSecrets);
return token ? new SandboxWorkerApi(getExpoApiV2BaseUrl(ctx), token) : null;
}
export function requireSandboxWorkerApi(ctx: {
job: Job;
env: Env;
expoApiV2BaseUrl?: string;
}): SandboxWorkerApi {
const workerApi = getSandboxWorkerApi(ctx);
if (!workerApi) {
throw new SystemError(
`This sandbox job has no ${SANDBOX_WORKER_TOKEN_ENV} secret. ` +
'www gives one to every sandbox job, and the worker needs it to call the sandbox API. ' +
'Start a new sandbox, and contact support if it happens again.'
);
}
return workerApi;
}
@@ -1,9 +1,7 @@
import { type bunyan } from '@expo/logger';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { type CustomBuildContext } from '../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
export async function uploadServeSimLogsFileAsync(
ctx: CustomBuildContext,
@@ -22,21 +20,22 @@ export async function uploadServeSimLogsFileAsync(
}
): Promise<void> {
try {
const { size } = await stat(filePath);
if (size === 0) {
return;
}
const fileController = new AbortController();
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: `simulator-log-${udid}`,
name: `App logs (${udid.slice(0, 8)})`,
filename: 'app-logs.ndjson',
kind: 'simulator-log',
metadata: { __eas_type: 'simulator-log', udid, scope: 'user-apps', source: 'serve-sim/logs' },
size,
openStream: () => createReadStream(filePath).on('error', err => fileController.abort(err)),
signal: AbortSignal.any([fileController.signal, ...(signal ? [signal] : [])]),
await uploadDeviceRunSessionFileAsync(ctx, {
filePath,
describe: () => ({
deviceRunSessionId,
artifactId: `simulator-log-${udid}`,
name: `App logs (${udid.slice(0, 8)})`,
filename: 'app-logs.ndjson',
kind: 'simulator-log',
metadata: {
__eas_type: 'simulator-log',
udid,
scope: 'user-apps',
source: 'serve-sim/logs',
},
}),
signal,
});
} catch (err) {
logger.warn(
@@ -0,0 +1,44 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { type CustomBuildContext } from '../../customBuildContext';
import {
type ArtifactMetadata,
uploadDeviceRunSessionArtifactAsync,
} from './deviceRunSessionArtifacts';
/** Upload a finalized file, reopening it for each attempt. */
export async function uploadDeviceRunSessionFileAsync(
ctx: CustomBuildContext,
{
filePath,
describe,
signal,
attempts = 1,
}: {
filePath: string;
describe: (size: number) => Omit<ArtifactMetadata, 'size'>;
signal?: AbortSignal;
attempts?: number;
}
): Promise<boolean> {
const { size } = await stat(filePath);
if (size === 0) {
return false;
}
const readController = new AbortController();
await uploadDeviceRunSessionArtifactAsync(ctx, {
...describe(size),
size,
openStream: () =>
createReadStream(filePath).on('error', err => {
// An HTTP error aborts one request body, not the remaining upload attempts.
if (err.name !== 'AbortError') {
readController.abort(err);
}
}),
signal: AbortSignal.any([readController.signal, ...(signal ? [signal] : [])]),
attempts,
});
return true;
}
@@ -2,6 +2,8 @@ import type { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { type CustomBuildContext } from '../../customBuildContext';
import { resolveSimulatorPackageVersionAsync } from '../../utils/simulatorPackageVersion';
import { stopWarmSimulatorAsync } from '../../utils/warmSimulator';
import { IosSimulatorRecordingUtils } from './IosSimulatorRecordingUtils';
import { type DeviceSessionServer } from './deviceSessionHost';
import { fetchWebPreviewTurnArgsAsync } from './remoteDeviceRunSession';
@@ -28,6 +30,7 @@ export function createWarmDeviceSessionServer(
}
return {
port: warm.server.port,
serveSimVersion: warm.serveSimVersion,
turnArgs: fetchWebPreviewTurnArgsAsync(ctx, { logger }),
// Set by prepare-tools, which the host runs before it installs the app.
get preinstalledApp() {
@@ -68,14 +71,34 @@ export function createWarmDeviceSessionServer(
*/
export async function runOnWarmIosSimulatorAsync<T>(
claim: Parameters<typeof tryClaimWarmIosSessionAsync>[0],
run: (warm?: WarmIosSession) => Promise<T>
run: (warm?: WarmIosSession, packageVersion?: string) => Promise<T>
): Promise<T> {
const warm = await tryClaimWarmIosSessionAsync(claim);
let packageVersion = claim.packageVersion;
if (packageVersion !== undefined && claim.env.EAS_USE_WARM_SIMULATOR === '1') {
try {
packageVersion = await resolveSimulatorPackageVersionAsync(
claim.engine,
packageVersion,
claim
);
} catch (error) {
try {
await stopWarmSimulatorAsync(claim.env);
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
'Could not stop warmup after version resolution failed.'
);
}
throw error;
}
}
const warm = await tryClaimWarmIosSessionAsync({ ...claim, packageVersion });
if (!warm) {
return await run(undefined);
return await run(undefined, packageVersion);
}
try {
return await run(warm);
return await run(warm, packageVersion);
} catch (error) {
try {
await warm.process.stopAsync();
@@ -90,6 +113,6 @@ export async function runOnWarmIosSimulatorAsync<T>(
}
claim.logger.warn('Could not adopt the warm Simulator; starting a cold Simulator.');
claim.signal?.throwIfAborted();
return await run(undefined);
return await run(undefined, packageVersion);
}
}
@@ -2,6 +2,7 @@ import { type bunyan } from '@expo/logger';
import { type BuildStepEnv } from '@expo/steps';
import fs from 'node:fs/promises';
import path from 'node:path';
import semver from 'semver';
import { z } from 'zod';
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../utils/warmSimulator';
@@ -9,7 +10,11 @@ import { readLocalEgressHandoffAsync } from './localEgress';
import { type DetachedProcessHandle, isProcessRunning } from './remoteDeviceRunSession';
const ServiceSchema = z.object({ pid: z.number(), port: z.number(), token: z.string() });
const PackageVersionSchema = z.string().refine(version => semver.valid(version) !== null);
const ClaimSchema = z.object({
serveSimVersion: PackageVersionSchema.optional(),
agentDeviceVersion: PackageVersionSchema.optional(),
argentVersion: PackageVersionSchema.optional(),
device: z.object({ udid: z.string(), name: z.string() }),
server: ServiceSchema,
agentDeviceStateDir: z.string(),
@@ -53,10 +58,6 @@ export async function tryClaimWarmIosSessionAsync({
return undefined;
}
try {
// Warm tools run the latest release with the VM's Simulator settings.
if (packageVersion && packageVersion !== 'latest') {
throw new Error('A pinned package_version needs a cold start.');
}
const setting = Object.keys(env).find(
name => name === 'SERVE_SIM_ADDITIONAL_DYLIBS' || name.startsWith('SIMCTL_CHILD_')
);
@@ -71,6 +72,15 @@ export async function tryClaimWarmIosSessionAsync({
const claim = ClaimSchema.parse(
JSON.parse(await runSimulatorLauncherAsync(claimArgs, { env, signal }))
);
const selectedVersion = {
'serve-sim': claim.serveSimVersion,
'agent-device': claim.agentDeviceVersion,
argent: claim.argentVersion,
appium: undefined,
}[engine];
if (packageVersion !== undefined && selectedVersion !== packageVersion) {
throw new Error('The warm tool does not match the requested package version.');
}
logger.info(`Claimed the warm Simulator ${claim.device.name} (${claim.device.udid}).`);
let stopping: Promise<void> | undefined;
const session: WarmIosSession = {
@@ -13,6 +13,7 @@ import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { Sentry } from '../sentry';
import { type AndroidEmulatorLogCapture } from './androidEmulatorLogCapture';
import { retryAsync } from './retry';
/** Android Virtual Device is the device we run. */
@@ -327,19 +328,19 @@ export namespace AndroidEmulatorUtils {
env,
logcatDirectory,
launchGate,
logCapture,
}: {
deviceName: AndroidVirtualDeviceName;
env: NodeJS.ProcessEnv;
logcatDirectory: string;
launchGate?: AndroidEmulatorLaunchGate;
logCapture: AndroidEmulatorLogCapture;
}): Promise<{
emulatorPromise: SpawnPromise<SpawnResult>;
serialId: AndroidDeviceSerialId;
logcatOutputPath: string;
emulatorOutputPath: string;
}> {
let logcatOutputPath: string;
let emulatorOutputPath: string;
let logcatOutputPath: string | undefined;
let emulatorOutputPath: string | undefined;
try {
await fs.promises.mkdir(logcatDirectory, { recursive: true });
const safeDeviceName = deviceName.replace(/[^a-zA-Z0-9_.-]/g, '_');
@@ -347,16 +348,21 @@ export namespace AndroidEmulatorUtils {
.toString(16)
.padStart(8, '0');
const outputName = `${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}`;
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
await fs.promises.writeFile(logcatOutputPath, '');
await fs.promises.writeFile(emulatorOutputPath, '');
if (logCapture.sources.includes('device-logcat')) {
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
}
if (logCapture.sources.includes('emulator-output')) {
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
}
} catch (err) {
throw new SystemError(`Failed to prepare Android emulator output for ${deviceName}.`, {
cause: err,
});
}
const logcatOutput = logcatOutputPath
? await logCapture.prepareLogcatOutputAsync(logcatOutputPath)
: undefined;
const emulatorCommand = `${process.env.ANDROID_HOME}/emulator/emulator`;
const emulatorArgs = [
'-no-window',
@@ -364,10 +370,7 @@ export namespace AndroidEmulatorUtils {
'-writable-system',
'-noaudio',
'-no-snapshot-save',
'-logcat',
'*:v',
'-logcat-output',
logcatOutputPath,
...(logcatOutput ? ['-logcat', '*:v', '-logcat-output', logcatOutput.path] : []),
'-avd',
deviceName,
'-accel',
@@ -381,49 +384,37 @@ export namespace AndroidEmulatorUtils {
// We don't need to wait for emulator to exit gracefully.
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
};
const outputStdio = emulatorOutputPath ? 'pipe' : 'ignore';
const emulatorPromise = launchGate
? spawn(
launchGate.wrapperCommand,
[...launchGate.wrapperArgs, emulatorCommand, ...emulatorArgs, ...launchGate.emulatorArgs],
{
detached: true,
stdio: ['pipe', 'pipe', 'pipe'],
stdio: ['pipe', outputStdio, outputStdio],
ignoreStdio: true,
env: emulatorEnv,
}
)
: spawn(emulatorCommand, emulatorArgs, {
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['ignore', outputStdio, outputStdio],
ignoreStdio: true,
env: emulatorEnv,
});
const emulatorOutputStream = fs.createWriteStream(emulatorOutputPath, { flags: 'a' });
let reportedEmulatorOutputError = false;
emulatorOutputStream.on('error', err => {
process.stderr.write(
`Failed to write Android emulator output to ${emulatorOutputPath}: ${err}\n`
);
if (!reportedEmulatorOutputError) {
reportedEmulatorOutputError = true;
Sentry.capture('Failed to write Android emulator process output', err, {
level: 'warning',
tags: {
errorCode: (err as NodeJS.ErrnoException).code ?? 'unknown',
},
extras: {
deviceName,
emulatorOutputPath,
},
});
if (logcatOutput) {
emulatorPromise.child.once('close', logcatOutput.close);
}
if (emulatorOutputPath) {
for (const input of [emulatorPromise.child.stdout, emulatorPromise.child.stderr]) {
if (input) {
logCapture.attach(input, emulatorOutputPath, 'emulator-output');
}
}
});
// Only into the log file -- this process' stdout/stderr is not watched or uploaded.
emulatorPromise.child.stdout?.pipe(emulatorOutputStream, { end: false });
emulatorPromise.child.stderr?.pipe(emulatorOutputStream, { end: false });
emulatorPromise.child.once('close', () => {
emulatorOutputStream.end();
});
emulatorPromise.child.once('close', () => {
void logCapture.finishFileAsync(emulatorOutputPath);
});
}
// Piped stdio creates socket handles in this process which, unlike inherited
// file descriptors, keep the event loop alive for as long as the emulator runs.
// We never stop the emulator explicitly, so without unref-ing these the process
@@ -473,7 +464,7 @@ export namespace AndroidEmulatorUtils {
// We don't want to await the SpawnPromise here.
// eslint-disable-next-line @typescript-eslint/return-await
return { emulatorPromise, serialId, logcatOutputPath, emulatorOutputPath };
return { emulatorPromise, serialId };
}
export async function waitForReadyAsync({
@@ -4,6 +4,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import semver from 'semver';
import { z } from 'zod';
import { retryAsync } from './retry';
@@ -202,7 +203,7 @@ export namespace IosSimulatorUtils {
env: NodeJS.ProcessEnv;
}): Promise<IosSimulatorUuid> {
if (UDID_PATTERN.test(deviceIdentifier)) {
return deviceIdentifier as IosSimulatorUuid;
return deviceIdentifier.toUpperCase() as IosSimulatorUuid;
}
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
const device = devices.find(candidate => candidate.name === deviceIdentifier);
@@ -284,9 +285,23 @@ export namespace IosSimulatorUtils {
const readinessScreenshotPath = path.join(os.tmpdir(), 'eas-simulator-readiness.png');
await retryAsync(
async () => {
await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', readinessScreenshotPath], {
env,
});
const { stdout: enumerateStdout } = await spawn(
'xcrun',
['simctl', 'io', udid, 'enumerate'],
{ env }
);
await spawn(
'xcrun',
[
'simctl',
'io',
udid,
'screenshot',
...displayFlagForScreenshot(enumerateStdout),
readinessScreenshotPath,
],
{ env }
);
},
{
retryOptions: {
@@ -298,22 +313,30 @@ export namespace IosSimulatorUtils {
);
await fs.promises.rm(readinessScreenshotPath, { force: true });
// Wait for data migration to complete before declaring the simulator ready
// Based on WebKit's approach: https://trac.webkit.org/changeset/231452/webkit
await retryAsync(
async () => {
const isDataMigrating = await isDataMigratorProcessRunning({ env });
if (isDataMigrating) {
throw new Error('com.apple.datamigrator still running');
}
},
{
retryOptions: {
retries: 30 * 60,
retryIntervalMs: 1_000,
},
}
const device = await getDeviceAsync({ udid, env });
const runtimes = await getAvailableRuntimesAsync({ env });
const iosVersion = semver.coerce(
runtimes.find(runtime => runtime.identifier === device?.runtime)?.version
);
// From iOS 27.2 on, `com.apple.datamigrator` stays resident after the boot's data migration, so
// waiting for it to exit never ends; earlier runtimes exit it within about a minute of boot.
if (!iosVersion || semver.lt(iosVersion, '27.2.0')) {
// Based on WebKit's approach: https://trac.webkit.org/changeset/231452/webkit
await retryAsync(
async () => {
const isDataMigrating = await isDataMigratorProcessRunning({ env });
if (isDataMigrating) {
throw new Error('com.apple.datamigrator still running');
}
},
{
retryOptions: {
retries: 30 * 60,
retryIntervalMs: 1_000,
},
}
);
}
}
export async function disableApsdAsync({
@@ -522,6 +545,20 @@ async function isLaunchctlServiceLoadedAsync({
}
}
/**
* `simctl io screenshot` refuses to choose between the iPhone Duo's two integrated screens on
* iOS 27.2, while the simctl in Xcode 15.3 rejects a numeric `--display` ("Device does not have a
* '1' display port"), so the flag is passed only when there is a choice to make.
*/
function displayFlagForScreenshot(enumerateStdout: string): string[] {
// `simctl io enumerate` lists each screen as an indented "(n) Name:" header followed by its fields.
const screens = enumerateStdout.split(/^ {4}\(\d+\) .*:$/m);
const integratedScreenIds = screens
.filter(screen => /^\s*Screen Type: Integrated$/m.test(screen))
.flatMap(screen => screen.match(/^\s*Screen ID: (\d+)$/m)?.[1] ?? []);
return integratedScreenIds.length > 1 ? [`--display=${integratedScreenIds[0]}`] : [];
}
function parseUdidFromBootstatusStdout(stdout: string): IosSimulatorUuid | null {
const matches = stdout.match(/^Monitoring boot status for .+ \((.+)\)\.$/m);
if (!matches) {
@@ -13,6 +13,8 @@ import {
AndroidVirtualDeviceName,
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
import { AndroidEmulatorLogFiles } from '../androidEmulatorLogFiles';
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../androidEmulatorLogSources';
// We need to use real fs for cloning devices to work.
jest.unmock('fs');
@@ -78,6 +80,7 @@ describe('AndroidEmulatorUtils', () => {
logger: createMockLogger({ logToConsole: true }),
});
({ serialId } = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -105,6 +108,7 @@ describe('AndroidEmulatorUtils', () => {
logger: createMockLogger({ logToConsole: true }),
});
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -135,6 +139,7 @@ describe('AndroidEmulatorUtils', () => {
const { serialId: serialIdClone, emulatorPromise: emulatorPromiseClone } =
await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
deviceName: cloneDeviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -172,6 +177,7 @@ describe('AndroidEmulatorUtils', () => {
});
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -226,6 +232,7 @@ describe('AndroidEmulatorUtils', () => {
});
const startResult = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
deviceName,
env: envForAttempt,
logcatDirectory,
@@ -309,6 +316,7 @@ describe('AndroidEmulatorUtils', () => {
logger,
});
const startResult = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory: testLogcatDirectory,
@@ -319,7 +327,16 @@ describe('AndroidEmulatorUtils', () => {
await AndroidEmulatorUtils.waitForReadyAsync({ serialId, env: process.env });
await retryAsync(
async () => {
const contents = await fs.promises.readFile(startResult.emulatorOutputPath, 'utf-8');
const filename = (await fs.promises.readdir(testLogcatDirectory)).find(file =>
file.endsWith('-emulator.log')
);
if (!filename) {
throw new Error('Missing emulator output');
}
const contents = await fs.promises.readFile(
path.join(testLogcatDirectory, filename),
'utf-8'
);
if (!contents.includes('Android emulator version')) {
throw new Error('Did not find the emulator version in emulator process output yet.');
}
@@ -340,7 +357,16 @@ describe('AndroidEmulatorUtils', () => {
await retryAsync(
async () => {
const contents = await fs.promises.readFile(startResult.logcatOutputPath, 'utf-8');
const filename = (await fs.promises.readdir(testLogcatDirectory)).find(file =>
file.endsWith('-logcat.log')
);
if (!filename) {
throw new Error('Missing logcat output');
}
const contents = await fs.promises.readFile(
path.join(testLogcatDirectory, filename),
'utf-8'
);
if (!contents.includes(marker)) {
throw new Error(`Did not find marker ${marker} in logcat output yet.`);
}
@@ -384,6 +410,7 @@ describe('AndroidEmulatorUtils', () => {
});
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -1,6 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { EventEmitter, once } from 'node:events';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import { Socket } from 'node:net';
import os from 'node:os';
@@ -14,6 +14,17 @@ import {
AndroidVirtualDeviceName,
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
import { AndroidEmulatorLogRing } from '../androidEmulatorLogCapture';
import { AndroidEmulatorLogFiles } from '../androidEmulatorLogFiles';
import {
ALL_ANDROID_EMULATOR_LOG_SOURCES,
AndroidEmulatorLogSourcesSchema,
} from '../androidEmulatorLogSources';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
@@ -35,9 +46,11 @@ const mockedRetryAsync = jest.mocked(retryAsync);
describe('AndroidEmulatorUtils', () => {
let temporaryDirectories: string[] = [];
let homeDirectory: string;
beforeEach(() => {
temporaryDirectories = [];
beforeEach(async () => {
homeDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-utils-home-'));
temporaryDirectories = [homeDirectory];
jest.mocked(Sentry.capture).mockReset();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedRetryAsync.mockImplementation(async fn => await fn(0));
@@ -55,7 +68,7 @@ describe('AndroidEmulatorUtils', () => {
describe(AndroidEmulatorUtils.createAsync, () => {
it('applies an explicit LCD configuration after screen scaling', async () => {
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
const avdDirectory = `${homeDirectory}/.android/avd/${deviceName}.avd`;
await fs.promises.mkdir(avdDirectory, { recursive: true });
await fs.promises.writeFile(
`${avdDirectory}/config.ini`,
@@ -83,7 +96,7 @@ describe('AndroidEmulatorUtils', () => {
lcdHeight: 1600,
lcdDensity: 262,
env: {
HOME: '/home/expo',
HOME: homeDirectory,
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
},
@@ -141,7 +154,7 @@ describe('AndroidEmulatorUtils', () => {
['device-run-session-id', 4096],
])('configures memory for session %s without an LCD override', async (sessionId, ramSize) => {
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
const avdDirectory = `${homeDirectory}/.android/avd/${deviceName}.avd`;
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
await fs.promises.mkdir(avdDirectory, { recursive: true });
await fs.promises.writeFile(`${avdDirectory}/config.ini`, initialConfig);
@@ -157,7 +170,7 @@ describe('AndroidEmulatorUtils', () => {
lcdHeight: null,
lcdDensity: null,
env: {
HOME: '/home/expo',
HOME: homeDirectory,
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
DEVICE_RUN_SESSION_ID: sessionId,
},
@@ -172,6 +185,24 @@ describe('AndroidEmulatorUtils', () => {
});
describe(AndroidEmulatorUtils.startAsync, () => {
let buildCapture: AndroidEmulatorLogFiles;
beforeEach(() => {
buildCapture = new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES });
});
afterEach(async () => {
await buildCapture.finishAsync();
});
async function logFile(directory: string, source: 'logcat' | 'emulator'): Promise<string> {
const filename = (await fs.promises.readdir(directory)).find(file =>
file.endsWith(`-${source}.log`)
);
if (!filename) {
throw new Error(`Missing ${source} log in ${directory}`);
}
return path.join(directory, filename);
}
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
// narrows on before unref-ing them.
@@ -202,35 +233,31 @@ describe('AndroidEmulatorUtils', () => {
return { child };
}
it('captures logcat and emulator process output', async () => {
it('names log files after the device and detaches the emulator', async () => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
temporaryDirectories.push(outputDir);
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const createWriteStreamSpy = jest.spyOn(fs, 'createWriteStream');
const result = await AndroidEmulatorUtils.startAsync({
await AndroidEmulatorUtils.startAsync({
logCapture: buildCapture,
deviceName,
env: process.env,
logcatDirectory: outputDir,
});
child.emit('close');
await buildCapture.finishAsync();
expect(result.logcatOutputPath).toMatch(
expect(await logFile(outputDir, 'logcat')).toMatch(
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
);
expect(result.emulatorOutputPath).toMatch(
expect(await logFile(outputDir, 'emulator')).toMatch(
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-emulator\\.log$`)
);
await expect(fs.promises.access(result.logcatOutputPath)).resolves.toBeUndefined();
await expect(fs.promises.access(result.emulatorOutputPath)).resolves.toBeUndefined();
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/\/emulator\/emulator$/),
expect.arrayContaining(['-logcat', '*:v', '-logcat-output', result.logcatOutputPath]),
expect.objectContaining({
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
ignoreStdio: true,
})
expect.any(Array),
expect.objectContaining({ detached: true, ignoreStdio: true })
);
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
@@ -242,28 +269,91 @@ describe('AndroidEmulatorUtils', () => {
// for as long as the emulator runs.
expect(child.stdout.unref).toHaveBeenCalled();
expect(child.stderr.unref).toHaveBeenCalled();
const emulatorOutputStream = createWriteStreamSpy.mock.results[0].value;
const stderrWriteSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
const writeError = Object.assign(new Error('disk full'), { code: 'ENOSPC' });
emulatorOutputStream.emit('error', writeError);
emulatorOutputStream.emit('error', writeError);
expect(stderrWriteSpy).toHaveBeenCalledTimes(2);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to write Android emulator process output',
writeError,
{
level: 'warning',
tags: { errorCode: 'ENOSPC' },
extras: {
deviceName,
emulatorOutputPath: result.emulatorOutputPath,
},
}
});
it.each([
{ sources: [] },
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('records only $sources', async ({ sources }) => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'selected-logs-'));
temporaryDirectories.push(outputDir);
const deviceName = 'selected-emulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const stdoutPipe = jest.spyOn(child.stdout, 'pipe');
const stderrPipe = jest.spyOn(child.stderr, 'pipe');
const capture = new AndroidEmulatorLogFiles({
sources: AndroidEmulatorLogSourcesSchema.parse(sources),
});
const prepare = jest.spyOn(capture, 'prepareLogcatOutputAsync');
await AndroidEmulatorUtils.startAsync({
logCapture: capture,
deviceName,
env: {},
logcatDirectory: outputDir,
});
const capturesOutput = sources.includes('emulator-output');
const capturesLogcat = sources.includes('device-logcat');
const [, args, options] = mockedSpawn.mock.calls[0];
expect(prepare).toHaveBeenCalledTimes(capturesLogcat ? 1 : 0);
expect(args.includes('-logcat')).toBe(capturesLogcat);
expect(args.includes('-logcat-output')).toBe(capturesLogcat);
expect(options?.stdio).toEqual([
'ignore',
capturesOutput ? 'pipe' : 'ignore',
capturesOutput ? 'pipe' : 'ignore',
]);
expect(stdoutPipe).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
expect(stderrPipe).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
if (capturesOutput) {
child.stdout.emit('data', Buffer.from('stdout marker\n'));
child.stderr.emit('data', Buffer.from('stderr marker\n'));
child.emit('close');
}
await capture.finishAsync();
if (capturesOutput) {
await expect(
fs.promises.readFile(await logFile(outputDir, 'emulator'), 'utf8')
).resolves.toBe('stdout marker\nstderr marker\n');
}
const files = await fs.promises.readdir(outputDir);
expect(files).toHaveLength(sources.length);
expect(files.some(file => file.endsWith('-logcat.log'))).toBe(capturesLogcat);
expect(files.some(file => file.endsWith('-emulator.log'))).toBe(capturesOutput);
});
it('routes a session logcat through the capture pipe and closes it with the emulator', async () => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'bounded-logs-'));
temporaryDirectories.push(outputDir);
const deviceName = 'bounded-emulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const capture = new AndroidEmulatorLogRing({ sources: ['device-logcat'] });
const close = jest.fn();
const createPipe = jest.spyOn(capture, 'prepareLogcatOutputAsync').mockResolvedValue({
path: '/logcat.pipe',
close,
closed: Promise.resolve(),
});
await AndroidEmulatorUtils.startAsync({
deviceName,
env: {},
logcatDirectory: outputDir,
logCapture: capture,
});
expect(createPipe).toHaveBeenCalledWith(
expect.stringMatching(
new RegExp(`^${outputDir}/bounded-emulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
)
);
const emulatorOutputStreamClosed = once(emulatorOutputStream, 'close');
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining(['-logcat-output', '/logcat.pipe'])
);
expect(close).not.toHaveBeenCalled();
child.emit('close');
await emulatorOutputStreamClosed;
await capture.finishAsync();
expect(close).toHaveBeenCalledTimes(1);
expect(await fs.promises.readdir(outputDir)).toEqual([]);
});
it('spawns the emulator directly with the same arguments when there is no launch gate', async () => {
@@ -273,7 +363,8 @@ describe('AndroidEmulatorUtils', () => {
mockSuccessfulStart(deviceName);
const env = { ANDROID_EMULATOR_EXTRA_ARGS: '-gpu swiftshader_indirect' };
const result = await AndroidEmulatorUtils.startAsync({
await AndroidEmulatorUtils.startAsync({
logCapture: buildCapture,
deviceName,
env,
logcatDirectory: outputDir,
@@ -294,7 +385,7 @@ describe('AndroidEmulatorUtils', () => {
'-logcat',
'*:v',
'-logcat-output',
result.logcatOutputPath,
await logFile(outputDir, 'logcat'),
'-avd',
deviceName,
'-accel',
@@ -331,7 +422,8 @@ describe('AndroidEmulatorUtils', () => {
: gatedSpawn(command, args, options as any)) as any);
const admitAsync = jest.fn().mockResolvedValue(undefined);
const result = await AndroidEmulatorUtils.startAsync({
await AndroidEmulatorUtils.startAsync({
logCapture: buildCapture,
deviceName,
env: {},
logcatDirectory: outputDir,
@@ -358,7 +450,7 @@ describe('AndroidEmulatorUtils', () => {
'-logcat',
'*:v',
'-logcat-output',
result.logcatOutputPath,
await logFile(outputDir, 'logcat'),
'-avd',
deviceName,
'-accel',
@@ -395,6 +487,7 @@ describe('AndroidEmulatorUtils', () => {
await expect(
AndroidEmulatorUtils.startAsync({
logCapture: buildCapture,
deviceName,
env: {},
logcatDirectory: outputDir,
@@ -419,6 +512,7 @@ describe('AndroidEmulatorUtils', () => {
try {
await expect(
AndroidEmulatorUtils.startAsync({
logCapture: buildCapture,
deviceName,
env: process.env,
logcatDirectory: outputDir,
@@ -252,24 +252,179 @@ describe('IosSimulatorUtils', () => {
});
describe(IosSimulatorUtils.waitForReadyAsync, () => {
const readinessScreenshotPath = path.join(os.tmpdir(), 'eas-simulator-readiness.png');
it('takes the readiness screenshot into a writable temp file, not /dev/null', async () => {
await IosSimulatorUtils.waitForReadyAsync({
udid: 'test-udid' as any,
env: process.env,
});
mockSimulator({});
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
[
'simctl',
'io',
'test-udid',
'screenshot',
path.join(os.tmpdir(), 'eas-simulator-readiness.png'),
],
['simctl', 'io', 'test-udid', 'screenshot', readinessScreenshotPath],
{ env: process.env }
);
});
it('names the first integrated screen when the device has several (iPhone Duo on iOS 27.2)', async () => {
// Connected screens from `xcrun simctl io <udid> enumerate` on an iPhone Duo (iOS 27.2).
mockSimulator({
version: '27.2',
connectedScreens: ` Connected Screens:
(1) LCD:
Screen ID: 1
Screen Type: Integrated
(2) TVOut:
Screen ID: 2
Screen Type: TVOut
(3) LCD-1:
Screen ID: 3
Screen Type: Integrated
`,
});
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'io', 'test-udid', 'screenshot', '--display=1', readinessScreenshotPath],
{ env: process.env }
);
});
it('takes the first listed integrated screen, skipping screens that are not integrated', async () => {
mockSimulator({
connectedScreens: ` Connected Screens:
(1) TVOut:
Screen ID: 1
Screen Type: TVOut
(2) LCD-1:
Screen ID: 5
Screen Type: Integrated
(3) LCD:
Screen ID: 3
Screen Type: Integrated
`,
});
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'io', 'test-udid', 'screenshot', '--display=5', readinessScreenshotPath],
{ env: process.env }
);
});
it('keeps the plain screenshot when the device has one integrated screen', async () => {
mockSimulator({
connectedScreens: ` Connected Screens:
(2) TVOut:
Screen ID: 2
Screen Type: TVOut
(1) LCD:
Screen ID: 1
Screen Type: Integrated
`,
});
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'io', 'test-udid', 'screenshot', readinessScreenshotPath],
{ env: process.env }
);
});
it.each(['27.2', '27.3', '28.0'])(
'does not wait for com.apple.datamigrator to exit on iOS %s, where it stays resident',
async version => {
mockSimulator({ version, dataMigratorRunning: true });
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
expect(mockedSpawn).not.toHaveBeenCalledWith('ps', expect.anything(), expect.anything());
}
);
it.each(['27.1', '26.3.1', '18.6'])(
'waits for com.apple.datamigrator to exit on iOS %s',
async version => {
mockSimulator({ version, dataMigratorRunning: true });
await expect(
IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env })
).rejects.toThrow('com.apple.datamigrator still running');
expect(mockedSpawn).toHaveBeenCalledWith('ps', ['-eo', 'pid,comm'], { env: process.env });
}
);
it('waits for com.apple.datamigrator to exit when the device is not listed', async () => {
mockSimulator({ listed: false, dataMigratorRunning: true });
await expect(
IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env })
).rejects.toThrow('com.apple.datamigrator still running');
});
it('waits for com.apple.datamigrator to exit when the runtime reports no version', async () => {
mockSimulator({ version: null, dataMigratorRunning: true });
await expect(
IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env })
).rejects.toThrow('com.apple.datamigrator still running');
});
function mockSimulator({
version = '26.4',
listed = true,
connectedScreens = '',
dataMigratorRunning = false,
}: {
version?: string | null;
listed?: boolean;
connectedScreens?: string;
dataMigratorRunning?: boolean;
}): void {
const runtime = 'com.apple.CoreSimulator.SimRuntime.iOS-26-4';
const otherRuntime = {
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-27-2',
version: '27.2',
};
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'ps') {
return {
stdout: dataMigratorRunning
? ' 123 /sbin/launchd_sim\n 456 /Library/Developer/CoreSimulator/Volumes/iOS_23E244/XPCServices/com.apple.datamigrator.xpc/com.apple.datamigrator\n'
: ' 123 /sbin/launchd_sim\n',
stderr: '',
} as any;
}
if (args.includes('runtimes')) {
return {
stdout: JSON.stringify({
runtimes: [
otherRuntime,
version === null ? { identifier: runtime } : { identifier: runtime, version },
],
}),
stderr: '',
} as any;
}
if (args.includes('list')) {
return {
stdout: JSON.stringify({
devices: { [runtime]: listed ? [{ udid: 'test-udid' }] : [] },
}),
stderr: '',
} as any;
}
if (args.includes('enumerate')) {
return { stdout: connectedScreens, stderr: '' } as any;
}
return { stdout: '', stderr: '' } as any;
}) as any);
}
});
describe(IosSimulatorUtils.disableApsdAsync, () => {
@@ -427,6 +582,15 @@ describe('IosSimulatorUtils', () => {
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('uppercases a udid given in lowercase, matching how simctl lists devices', async () => {
await expect(
IosSimulatorUtils.resolveUdidAsync({
deviceIdentifier: '8027f627-9534-4679-85bf-0f14aff228e8' as any,
env: process.env,
})
).resolves.toBe('8027F627-9534-4679-85BF-0F14AFF228E8');
});
it('resolves a device name to the first available device with that name', async () => {
mockedSpawn.mockResolvedValue({
stdout: JSON.stringify({
@@ -0,0 +1,271 @@
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import fs, { type FileHandle } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { PassThrough, Readable } from 'node:stream';
import { Sentry } from '../../sentry';
import {
AndroidEmulatorLogRing,
MAX_ANDROID_LOG_BYTES_PER_SOURCE,
} from '../androidEmulatorLogCapture';
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../androidEmulatorLogSources';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.mock('../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
let directory: string;
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'android-capped-logs-'));
});
afterEach(async () => {
jest.restoreAllMocks();
await fs.rm(directory, { recursive: true, force: true });
});
function createCapture(maxBytes?: number): AndroidEmulatorLogRing {
return new AndroidEmulatorLogRing({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES, maxBytes });
}
/** `==== <name> <ISO time> ====\n` */
function markerLength(name: string): number {
return name.length + 36;
}
async function record(
capture: AndroidEmulatorLogRing,
file: string,
text: string,
source: 'emulator-output' | 'device-logcat' = 'emulator-output'
): Promise<void> {
const input = Readable.from([Buffer.from(text)]);
capture.attach(input, file, source);
await once(input, 'end');
await capture.finishFileAsync(file);
}
function trackOpenedFiles(): FileHandle[] {
const opened: FileHandle[] = [];
const open = fs.open;
jest.spyOn(fs, 'open').mockImplementation(async (file, flags, mode) => {
const handle = await open(file, flags, mode);
opened.push(handle);
return handle;
});
return opened;
}
it('keeps one ring per source across launches and names each launch that wrote', async () => {
const capture = createCapture(120);
const emulatorRing = path.join(directory, 'emulator-output.log');
const logcatRing = path.join(directory, 'device-logcat.log');
await record(capture, path.join(directory, 'first-emulator.log'), 'first!');
await record(capture, path.join(directory, 'retry-emulator.log'), 'second!!');
await record(capture, path.join(directory, 'one-logcat.log'), 'device log', 'device-logcat');
const logs = await capture.finishAsync();
const seen = markerLength('first-emulator') + 6 + 1 + markerLength('retry-emulator') + 8;
expect(logs).toEqual([
{ filePath: emulatorRing, source: 'emulator-output', bytesSeen: seen },
{ filePath: logcatRing, source: 'device-logcat', bytesSeen: markerLength('one-logcat') + 10 },
]);
expect(await fs.readFile(emulatorRing, 'utf8')).toMatch(
/^==== first-emulator \S+ ====\nfirst!\n==== retry-emulator \S+ ====\nsecond!!$/
);
expect(await fs.readFile(logcatRing, 'utf8')).toMatch(/^==== one-logcat \S+ ====\ndevice log$/);
expect((await fs.readdir(directory)).sort()).toEqual([
'device-logcat.log',
'emulator-output.log',
]);
});
it('keeps the tail of the final launch when the ring overflows', async () => {
const capture = createCapture(120);
await record(capture, path.join(directory, 'first-emulator.log'), 'first!');
await record(capture, path.join(directory, 'retry-emulator.log'), 'y'.repeat(100));
const [log] = await capture.finishAsync();
const result = await fs.readFile(log.filePath, 'utf8');
expect(result).toHaveLength(120);
expect(result.endsWith('y'.repeat(100))).toBe(true);
expect(result).not.toContain('first!');
expect(log.bytesSeen).toBeGreaterThan(120);
});
it('keeps the tail of a wrapped ring in order', async () => {
const capture = createCapture(10);
const file = path.join(directory, 'wrapped.log');
const input = new PassThrough();
capture.attach(input, file, 'emulator-output');
for (const piece of ['0123', '4567', '89ab', 'cdef']) {
input.write(piece);
}
const [log] = await capture.finishAsync();
expect(await fs.readFile(log.filePath, 'utf8')).toBe('6789abcdef');
});
it('lets overlapping launches of one source share the ring', async () => {
const capture = createCapture(400);
const a = new PassThrough();
const b = new PassThrough();
capture.attach(a, path.join(directory, 'clone-1.log'), 'emulator-output');
capture.attach(b, path.join(directory, 'clone-2.log'), 'emulator-output');
a.write('from clone 1 first\n');
b.write('from clone 2\n');
a.write('from clone 1 second\n');
const [log] = await capture.finishAsync();
const result = await fs.readFile(log.filePath, 'utf8');
expect(result).toMatch(/==== clone-1 \S+ ====\n/);
expect(result).toMatch(/==== clone-2 \S+ ====\n/);
expect(result).toContain('from clone 2\n');
expect(result.indexOf('from clone 1 first')).toBeLessThan(result.indexOf('from clone 1 second'));
expect(result).toHaveLength(log.bytesSeen);
a.end('ignored after cleanup');
b.end('also ignored');
expect(a.readableFlowing).toBe(true);
expect(b.readableFlowing).toBe(true);
});
it('drains a real FIFO beyond 20 MiB, retains the tail, and closes after the writer exits', async () => {
const file = path.join(directory, 'logcat.log');
const capture = createCapture();
const pipe = await capture.prepareLogcatOutputAsync(file);
expect((await fs.stat(pipe.path)).isFIFO()).toBe(true);
const writer = spawn(
process.execPath,
[
'-e',
`
const fs = require('node:fs');
const fd = fs.openSync(process.argv[1], 'w');
fs.writeSync(fd, 'BEGIN\\n');
const chunk = Buffer.alloc(64 * 1024, 'x');
for (let i = 0; i < 1024; i++) {
let offset = 0;
while (offset < chunk.length) offset += fs.writeSync(fd, chunk, offset);
}
fs.writeSync(fd, '\\nEND');
fs.closeSync(fd);
`,
pipe.path,
],
{ stdio: 'ignore' }
);
try {
expect(await once(writer, 'close')).toEqual([0, null]);
pipe.close();
pipe.close();
await pipe.closed;
const logs = await capture.finishAsync();
const ring = path.join(directory, 'device-logcat.log');
const result = await fs.readFile(ring);
expect(result.length).toBe(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
expect(result.subarray(0, 4).toString()).toBe('xxxx');
expect(result.subarray(-4).toString()).toBe('\nEND');
expect(logs).toEqual([
{
filePath: ring,
source: 'device-logcat',
bytesSeen: 64 * 1024 * 1024 + 10 + markerLength('logcat'),
},
]);
await expect(fs.stat(pipe.path)).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
writer.kill();
pipe.close();
}
}, 30_000);
it('closes an unused logcat pipe when the emulator fails to launch', async () => {
const file = path.join(directory, 'empty.log');
const capture = createCapture();
const pipe = await capture.prepareLogcatOutputAsync(file);
pipe.close();
await pipe.closed;
await expect(fs.stat(pipe.path)).rejects.toMatchObject({ code: 'ENOENT' });
const ring = path.join(directory, 'device-logcat.log');
expect(await capture.finishAsync()).toEqual([
{ filePath: ring, source: 'device-logcat', bytesSeen: 0 },
]);
expect((await fs.stat(ring)).size).toBe(0);
}, 5_000);
it('keeps draining the FIFO after capture finishes while the emulator is alive', async () => {
const file = path.join(directory, 'live-logcat.log');
const capture = createCapture(10);
const output = await capture.prepareLogcatOutputAsync(file);
const writer = spawn(
process.execPath,
[
'-e',
`
const fs = require('node:fs');
const fd = fs.openSync(process.argv[1], 'w');
process.stdout.write('ready');
process.stdin.once('data', () => {
const chunk = Buffer.alloc(64 * 1024, 'x');
for (let i = 0; i < 128; i++) {
let offset = 0;
while (offset < chunk.length) offset += fs.writeSync(fd, chunk, offset);
}
fs.closeSync(fd);
process.stdin.destroy();
});
`,
output.path,
],
{ stdio: ['pipe', 'pipe', 'pipe'] }
);
try {
await once(writer.stdout, 'data');
await capture.finishAsync();
writer.stdin.end('go');
expect(await once(writer, 'close')).toEqual([0, null]);
output.close();
await output.closed;
expect((await fs.stat(path.join(directory, 'device-logcat.log'))).size).toBe(0);
await expect(fs.stat(output.path)).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
writer.kill();
output.close();
await output.closed;
}
}, 10_000);
it('reports a disk write failure once and continues draining output', async () => {
const capture = createCapture(10);
const input = new PassThrough();
const file = path.join(directory, 'failed.log');
const diskError = Object.assign(new Error('full'), { code: 'ENOSPC' });
jest.spyOn(fs, 'open').mockRejectedValueOnce(diskError);
capture.attach(input, file, 'emulator-output');
input.write('first');
input.write('second');
await capture.finishAsync();
input.end('more');
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to write Android diagnostic log',
diskError,
expect.anything()
);
expect(input.readableFlowing).toBe(true);
});
it('closes the file and reports once when sealing fails', async () => {
const opened = trackOpenedFiles();
const capture = createCapture(10);
await record(capture, path.join(directory, 'wrapped.log'), '0123456789abcdef');
const diskError = Object.assign(new Error('full'), { code: 'ENOSPC' });
jest.spyOn(opened[0], 'truncate').mockRejectedValueOnce(diskError);
await capture.finishAsync();
expect(opened[0].fd).toBe(-1);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to write Android diagnostic log',
diskError,
expect.anything()
);
});
@@ -0,0 +1,89 @@
import { once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PassThrough, Readable } from 'node:stream';
import { Sentry } from '../../sentry';
import { AndroidEmulatorLogFiles } from '../androidEmulatorLogFiles';
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../androidEmulatorLogSources';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.mock('../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
let directory: string;
beforeEach(async () => {
directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-build-logs-'));
});
afterEach(async () => {
jest.restoreAllMocks();
await fs.promises.rm(directory, { recursive: true, force: true });
});
function trackWriteStreams(): fs.WriteStream[] {
const streams: fs.WriteStream[] = [];
const createWriteStream = fs.createWriteStream;
jest.spyOn(fs, 'createWriteStream').mockImplementation((file, options) => {
const stream = createWriteStream(file, options);
streams.push(stream);
return stream;
});
return streams;
}
it('appends one plain file per launch, closes it when the launch ends, and finalizes nothing', async () => {
const streams = trackWriteStreams();
const capture = new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES });
const first = path.join(directory, 'first-emulator.log');
const stdout = Readable.from([Buffer.from('out ')]);
const stderr = Readable.from([Buffer.from('err')]);
capture.attach(stdout, first, 'emulator-output');
capture.attach(stderr, first, 'emulator-output');
await Promise.all([once(stdout, 'end'), once(stderr, 'end')]);
await capture.finishFileAsync(first);
expect(streams).toHaveLength(1);
expect(streams[0].closed).toBe(true);
expect(await fs.promises.readFile(first, 'utf8')).toBe('out err');
const retry = path.join(directory, 'retry-emulator.log');
const input = Readable.from([Buffer.from('retry')]);
capture.attach(input, retry, 'emulator-output');
await once(input, 'end');
expect(await capture.finishAsync()).toEqual([]);
expect(streams[1].closed).toBe(true);
expect((await fs.promises.readdir(directory)).sort()).toEqual([
'first-emulator.log',
'retry-emulator.log',
]);
});
it('gives the emulator the logcat file itself without creating a FIFO', async () => {
const capture = new AndroidEmulatorLogFiles({ sources: ['device-logcat'] });
const file = path.join(directory, 'build-logcat.log');
const output = await capture.prepareLogcatOutputAsync(file);
expect(output.path).toBe(file);
expect(await fs.promises.readdir(directory)).toEqual(['build-logcat.log']);
output.close();
await output.closed;
expect(await capture.finishAsync()).toEqual([]);
});
it('reports a write failure once and keeps draining the emulator', async () => {
const capture = new AndroidEmulatorLogFiles({ sources: ['emulator-output'] });
const input = new PassThrough();
const file = path.join(directory, 'missing-directory', 'failed.log');
capture.attach(input, file, 'emulator-output');
input.write('first');
await capture.finishFileAsync(file);
input.write('second');
input.end('more');
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to write Android diagnostic log',
expect.objectContaining({ code: 'ENOENT' }),
expect.anything()
);
expect(input.readableFlowing).toBe(true);
});
@@ -0,0 +1,78 @@
import spawn from '@expo/turtle-spawn';
import { resolveSimulatorPackageVersionAsync } from '../simulatorPackageVersion';
jest.mock('@expo/turtle-spawn');
beforeEach(() => jest.resetAllMocks());
it.each(['1.2.3', 'v1.2.3', '1.2.3-beta.1', '1.2.3-beta.1+build.2', 'v1.2.3+build.2'])(
'compares an exact request without a registry call (%s)',
async requested => {
await expect(
resolveSimulatorPackageVersionAsync('serve-sim', requested, { env: {} })
).resolves.toBe(requested.replace(/^v/, ''));
expect(spawn).not.toHaveBeenCalled();
}
);
it.each([
['serve-sim', '@expo/serve-sim'],
['agent-device', 'agent-device'],
['argent', '@swmansion/argent'],
['appium', 'appium'],
] as const)('checks the current dist-tag for %s', async (engine, name) => {
jest.mocked(spawn).mockResolvedValue({ stdout: '"1.2.4"' } as never);
const env = { NPM_CONFIG_REGISTRY: 'https://registry.example' };
const signal = new AbortController().signal;
await expect(
resolveSimulatorPackageVersionAsync(engine, 'latest', { env, signal })
).resolves.toBe('1.2.4');
expect(spawn).toHaveBeenCalledWith(
'npm',
[
'view',
`${name}@latest`,
'version',
'--json',
'--prefer-online',
'--prefer-offline=false',
'--offline=false',
'--fetch-retries=0',
],
{ env, signal, stdio: 'pipe', timeout: 30_000 }
);
});
it('selects the newest matching version from range metadata without relying on output order', async () => {
jest.mocked(spawn).mockResolvedValue({ stdout: '["3.9.0","3.10.0","3.8.0"]' } as never);
await expect(resolveSimulatorPackageVersionAsync('appium', '^3', { env: {} })).resolves.toBe(
'3.10.0'
);
});
it.each(['not json', '{}', '[]', '"latest"'])(
'rejects unusable registry output (%s)',
async stdout => {
jest.mocked(spawn).mockResolvedValue({ stdout } as never);
await expect(
resolveSimulatorPackageVersionAsync('serve-sim', 'latest', { env: {} })
).rejects.toThrow();
}
);
it('preserves registry lookup failures instead of trusting the prepared version', async () => {
const error = new Error('registry unavailable');
jest.mocked(spawn).mockRejectedValue(error);
await expect(resolveSimulatorPackageVersionAsync('argent', 'latest', { env: {} })).rejects.toBe(
error
);
});
it('does not look up a cancelled request', async () => {
const signal = AbortSignal.abort(new Error('cancelled'));
await expect(
resolveSimulatorPackageVersionAsync('serve-sim', 'latest', { env: {}, signal })
).rejects.toThrow('cancelled');
expect(spawn).not.toHaveBeenCalled();
});
@@ -1,7 +1,10 @@
import { SystemError } from '@expo/eas-build-job';
import downloadFile from '@expo/downloader';
import spawn from '@expo/turtle-spawn';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
@@ -164,10 +167,14 @@ describe(startUptermHostAsync, () => {
};
}
function makeCtx(env: Record<string, string> = {}): BuildContext {
function makeCtx(
env: Record<string, string> = {},
buildDirectory = '/workingdir/build'
): BuildContext {
return {
logger: createMockLogger(),
env,
buildDirectory,
} as unknown as BuildContext;
}
@@ -227,6 +234,33 @@ describe(startUptermHostAsync, () => {
);
});
it('starts each client shell in the build directory', async () => {
const realFs = jest.requireActual<typeof import('node:fs')>('node:fs');
const tmpDir = realFs.mkdtempSync(path.join(os.tmpdir(), 'eas-upterm-join-'));
try {
const buildDirectory = path.join(tmpDir, "it's $HOME `pwd`", 'build');
realFs.mkdirSync(buildDirectory, { recursive: true });
await startUptermHostAsync(makeCtx({}, buildDirectory), { relayServerUrl: 'wss://r' });
const joinScriptCall = mockedFs.writeFile.mock.calls.find(
([file]) => file === '/tmp/eas-ssh-1/join.sh'
);
const joinScriptPath = path.join(tmpDir, 'join.sh');
realFs.writeFileSync(joinScriptPath, String(joinScriptCall?.[1]));
const result = spawnSync('bash', [joinScriptPath], {
cwd: tmpDir,
env: { PATH: process.env.PATH, HOME: tmpDir },
input: 'pwd\n',
encoding: 'utf8',
});
expect(result.stdout.trim()).toBe(buildDirectory);
} finally {
realFs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('downloads from GCS into a fresh temp dir when upterm is not on PATH', async () => {
uptermOnPath = false;
@@ -1,9 +1,18 @@
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as sleepAsync } from 'node:timers/promises';
import { z } from 'zod';
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../warmSimulator';
import {
readPreparedSimulatorToolAsync,
runSimulatorLauncherAsync,
stopWarmSimulatorAsync,
} from '../warmSimulator';
jest.mock('@expo/turtle-spawn');
jest.mock('node:timers/promises');
const originalPlatform = process.platform;
@@ -11,12 +20,86 @@ beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
jest.resetAllMocks();
jest.mocked(spawn).mockResolvedValue({ stdout: '{"ok":true}' } as never);
jest.spyOn(os, 'uptime').mockReturnValue(600);
jest.spyOn(fs, 'access').mockRejectedValue(new Error('no startup script'));
jest.mocked(sleepAsync).mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('reads different prepared tool schemas without Appium-specific fields', async () => {
const directory = path.join(os.homedir(), 'Library', 'Caches', 'expo-simulator-startup');
const appium = { home: path.join(directory, 'appium'), version: '3.9.0', xcuitest: '12.22.8' };
const otherTool = { version: '2.4.0' };
const expoGo = { sdk: '55.0.0', url: 'https://example.test/ExpoGo.tar.gz' };
const read = jest
.spyOn(fs, 'readFile')
.mockResolvedValue(
JSON.stringify({ tools: { appium, 'other-tool': otherTool, 'expo-go': expoGo } })
);
await expect(
readPreparedSimulatorToolAsync(
'appium',
z.object({ home: z.string(), version: z.string(), xcuitest: z.string() })
)
).resolves.toEqual(appium);
const prepared = await readPreparedSimulatorToolAsync(
'other-tool',
z.object({ version: z.string() })
);
expect(prepared?.version).toBe('2.4.0');
await expect(
readPreparedSimulatorToolAsync('expo-go', z.object({ sdk: z.string(), url: z.string() }))
).resolves.toEqual(expoGo);
expect(read.mock.calls).toEqual([
[path.join(directory, 'prepared.json'), 'utf8'],
[path.join(directory, 'prepared.json'), 'utf8'],
[path.join(directory, 'prepared.json'), 'utf8'],
]);
expect(spawn).not.toHaveBeenCalled();
});
it.each(['missing-tool', 'toString'])(
'does not invent %s metadata from schema defaults or inherited properties',
async tool => {
jest.spyOn(fs, 'readFile').mockResolvedValue('{"tools":{}}');
await expect(
readPreparedSimulatorToolAsync(tool, z.unknown().default({ version: '1.2.3' }))
).resolves.toBeUndefined();
}
);
it.each(['missing', 'invalid JSON', 'invalid fields', 'missing tool', 'older format'])(
'ignores prepared tool metadata with %s',
async failure => {
const read = jest.spyOn(fs, 'readFile');
switch (failure) {
case 'missing':
read.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
break;
case 'invalid JSON':
read.mockResolvedValue('not JSON');
break;
case 'invalid fields':
read.mockResolvedValue(JSON.stringify({ tools: { tool: { version: 42 } } }));
break;
case 'missing tool':
read.mockResolvedValue('{"tools":{}}');
break;
case 'older format':
read.mockResolvedValue('{"appium":"3.9.0","xcuitest":"12.22.8","home":"/old/home"}');
break;
}
await expect(
readPreparedSimulatorToolAsync('tool', z.object({ version: z.string() }))
).resolves.toBeUndefined();
expect(spawn).not.toHaveBeenCalled();
}
);
it('runs the launcher version that start recorded and returns its output', async () => {
jest.spyOn(fs, 'readFile').mockResolvedValue('{"launcherVersion":"27.1.0"}');
await expect(runSimulatorLauncherAsync(['claim', 'serve-sim'], { env: {} })).resolves.toBe(
@@ -59,3 +142,79 @@ it('does nothing on other platforms', async () => {
await stopWarmSimulatorAsync({});
expect(spawn).not.toHaveBeenCalled();
});
const missing = Object.assign(new Error('missing'), { code: 'ENOENT' });
const warmEnv = { EAS_USE_WARM_SIMULATOR: '1', EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
it('waits for startup to write state, then claims with the recorded launcher version', async () => {
jest.mocked(os.uptime).mockReturnValue(10);
jest.mocked(fs.access).mockResolvedValue();
jest
.spyOn(fs, 'readFile')
.mockRejectedValueOnce(missing)
.mockRejectedValueOnce(missing)
.mockResolvedValue('{"launcherVersion":"27.6.0"}');
await runSimulatorLauncherAsync(['claim', 'serve-sim'], { env: warmEnv });
expect(sleepAsync).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
'bun',
['x', '@expo/[email protected]', 'claim', 'serve-sim'],
expect.objectContaining({ env: warmEnv })
);
});
it.each([
['a later session request', ['claim', 'serve-sim'], warmEnv, 300, true],
['a system without startup', ['claim', 'serve-sim'], warmEnv, 10, false],
['a job without warmup', ['claim', 'serve-sim'], {}, 10, true],
['stop', ['stop'], warmEnv, 10, true],
['prepare-tools', ['prepare-tools'], warmEnv, 10, true],
])('does not wait for %s', async (_name, args, env, uptime, installed) => {
jest.mocked(os.uptime).mockReturnValue(uptime);
if (installed) {
jest.mocked(fs.access).mockResolvedValue();
}
jest.spyOn(fs, 'readFile').mockRejectedValue(missing);
await runSimulatorLauncherAsync(args, { env });
expect(sleepAsync).not.toHaveBeenCalled();
expect(spawn).toHaveBeenCalled();
});
it('times out before invoking claim so its caller can stop warmup before starting cold', async () => {
jest.mocked(os.uptime).mockReturnValue(10);
jest.mocked(fs.access).mockResolvedValue();
jest.spyOn(fs, 'readFile').mockRejectedValue(missing);
let now = 0;
jest.spyOn(Date, 'now').mockImplementation(() => now);
jest.mocked(sleepAsync).mockImplementation(async delay => {
now += delay ?? 0;
});
await expect(runSimulatorLauncherAsync(['claim', 'serve-sim'], { env: warmEnv })).rejects.toThrow(
'Timed out waiting for the warm Simulator launcher to start.'
);
expect(now).toBe(60_000);
expect(spawn).not.toHaveBeenCalled();
await stopWarmSimulatorAsync(warmEnv);
expect(spawn).toHaveBeenCalledWith(
'bun',
['x', '@expo/simulator-launcher@latest', 'stop'],
expect.anything()
);
});
it('cancels the missing-state wait without invoking claim', async () => {
jest.mocked(os.uptime).mockReturnValue(10);
jest.mocked(fs.access).mockResolvedValue();
jest.spyOn(fs, 'readFile').mockRejectedValue(missing);
const controller = new AbortController();
jest.mocked(sleepAsync).mockImplementation(async () => {
controller.abort(new Error('cancelled'));
});
await expect(
runSimulatorLauncherAsync(['claim', 'serve-sim'], {
env: warmEnv,
signal: controller.signal,
})
).rejects.toThrow('cancelled');
expect(spawn).not.toHaveBeenCalled();
});
@@ -0,0 +1,250 @@
import fs from 'node:fs';
import { type FileHandle } from 'node:fs/promises';
import path from 'node:path';
import { type Readable, Writable } from 'node:stream';
import { finished } from 'node:stream/promises';
import { Sentry } from '../sentry';
import { type AndroidEmulatorLogSource } from './androidEmulatorLogSources';
import { createAndroidLogcatPipeAsync } from './androidLogcatPipe';
export const MAX_ANDROID_LOG_BYTES_PER_SOURCE = 20 * 1024 * 1024;
export type FinalizedAndroidEmulatorLog = {
readonly filePath: string;
readonly source: AndroidEmulatorLogSource;
readonly bytesSeen: number;
};
export type AndroidLogcatOutput = {
/** Where the emulator writes its logcat: a FIFO for sessions, the file itself for builds. */
path: string;
close: () => void;
/**
* Resolves once the pipe reader exited and the FIFO is removed. Emulator
* launch does not wait for it, because the emulator can outlive log
* collection; tests use it to observe the cleanup.
*/
closed: Promise<void>;
};
/** Records emulator output per launch file; `getAndroidEmulatorLogCapture` picks the writer. */
export type AndroidEmulatorLogCapture = {
readonly sources: readonly AndroidEmulatorLogSource[];
attach(input: Readable, filePath: string, source: AndroidEmulatorLogSource): void;
finishFileAsync(filePath: string): Promise<void>;
finishAsync(): Promise<readonly FinalizedAndroidEmulatorLog[]>;
prepareLogcatOutputAsync(filePath: string): Promise<AndroidLogcatOutput>;
};
/** One ring of `maxBytes` bytes: `writePos` wraps and the newest `retained` bytes end at it. */
type Ring = {
filePath: string;
source: AndroidEmulatorLogSource;
/** Runs one file operation after every earlier one on the same handle. */
run: (operation: (handle: FileHandle) => Promise<void>) => Promise<void>;
sealed?: Promise<void>;
bytesSeen: number;
writePos: number;
retained: number;
};
/** One emulator launch. Launches of a source can overlap: clones, or a late exit. */
type Launch = {
ring: Ring;
stream: Writable;
inputs: Set<Readable>;
done: Promise<void>;
};
/**
* One budget per session, shared by all emulator launches and retries.
*
* Each source has one ring file, `<source>.log` beside the launch files, so
* the last `maxBytes` bytes of a source survive across launches and the tail
* of the final attempt is always kept. A marker line names each launch that
* produced output. Overlapping launches share the ring, so their lines can
* interleave. Logcat arrives through a named pipe so the budget applies to it.
*/
export class AndroidEmulatorLogRing implements AndroidEmulatorLogCapture {
readonly sources: readonly AndroidEmulatorLogSource[];
private readonly maxBytes: number;
private readonly rings = new Map<AndroidEmulatorLogSource, Ring>();
private readonly launches = new Map<string, Launch>();
private finishing = false;
constructor({
sources,
maxBytes = MAX_ANDROID_LOG_BYTES_PER_SOURCE,
}: {
sources: readonly AndroidEmulatorLogSource[];
maxBytes?: number;
}) {
this.sources = sources;
this.maxBytes = maxBytes;
}
attach(input: Readable, filePath: string, source: AndroidEmulatorLogSource): void {
if (this.finishing) {
input.resume();
return;
}
let launch = this.launches.get(filePath);
if (!launch) {
launch = this.startLaunch(filePath, this.ringFor(filePath, source));
this.launches.set(filePath, launch);
}
launch.inputs.add(input);
input.pipe(launch.stream, { end: false });
}
async finishFileAsync(filePath: string): Promise<void> {
const launch = this.launches.get(filePath);
if (!launch) {
return;
}
for (const input of launch.inputs) {
input.unpipe(launch.stream);
// Keep draining: closing a full log must never block or break the emulator.
input.resume();
}
launch.inputs.clear();
launch.stream.end();
await launch.done;
}
async finishAsync(): Promise<readonly FinalizedAndroidEmulatorLog[]> {
this.finishing = true;
await Promise.all([...this.launches.keys()].map(file => this.finishFileAsync(file)));
await Promise.all([...this.rings.values()].map(ring => this.sealAsync(ring)));
// Every writer is closed, so uploads can reopen these files on each retry.
return [...this.rings.values()].map(({ filePath, source, bytesSeen }) => ({
filePath,
source,
bytesSeen,
}));
}
async prepareLogcatOutputAsync(filePath: string): Promise<AndroidLogcatOutput> {
const pipe = await createAndroidLogcatPipeAsync(`${filePath}.pipe`);
this.attach(pipe.stream, filePath, 'device-logcat');
const closed = pipe.closed.then(() => this.finishFileAsync(filePath)).catch(() => {});
return { path: pipe.path, close: pipe.close, closed };
}
private ringFor(filePath: string, source: AndroidEmulatorLogSource): Ring {
let ring = this.rings.get(source);
if (!ring) {
ring = this.open(path.join(path.dirname(filePath), `${source}.log`), source);
this.rings.set(source, ring);
}
return ring;
}
private open(filePath: string, source: AndroidEmulatorLogSource): Ring {
const file = fs.promises.open(filePath, 'w+');
// Handle open errors even if the process never produces output.
void file.catch(() => {});
let failed = false;
const report = (err: unknown): void => {
if (!failed) {
failed = true;
Sentry.capture(
'Failed to write Android diagnostic log',
err instanceof Error ? err : new Error(String(err)),
{
level: 'warning',
extras: { filePath, source },
}
);
}
};
let queue: Promise<void> = Promise.resolve();
const run = (operation: (handle: FileHandle) => Promise<void>): Promise<void> => {
queue = queue
.then(() => file)
.then(operation)
.catch(report);
return queue;
};
return { filePath, source, run, bytesSeen: 0, writePos: 0, retained: 0 };
}
private startLaunch(filePath: string, ring: Ring): Launch {
let marked = false;
const stream = new Writable({
write: (chunk: Buffer, _encoding, callback) => {
if (!marked) {
marked = true;
// Written before the first chunk only, so a silent launch leaves no trace.
const name = path.basename(filePath, '.log');
const marker = `==== ${name} ${new Date().toISOString()} ====\n`;
void this.write(ring, Buffer.from(ring.bytesSeen > 0 ? `\n${marker}` : marker));
}
void this.write(ring, chunk).then(() => callback());
},
});
return { ring, stream, inputs: new Set(), done: finished(stream) };
}
/** Account synchronously, then queue the I/O, so overlapping launches share one ring consistently. */
private write(ring: Ring, chunk: Buffer): Promise<void> {
const max = this.maxBytes;
ring.bytesSeen += chunk.length;
const data = chunk.length > max ? chunk.subarray(chunk.length - max) : chunk;
const position = ring.writePos;
const head = Math.min(data.length, max - position);
ring.writePos = (position + data.length) % max;
ring.retained = Math.min(ring.retained + data.length, max);
return ring.run(async handle => {
await writeAtAsync(handle, data.subarray(0, head), position);
await writeAtAsync(handle, data.subarray(head), 0);
});
}
/** Rewrite the ring in log order, drop the overwritten bytes, and close the file. */
private sealAsync(ring: Ring): Promise<void> {
ring.sealed ??= ring.run(async handle => {
try {
const { retained } = ring;
let start = ring.writePos - retained;
if (start < 0) {
start += this.maxBytes;
}
if (start > 0) {
const tail = Buffer.alloc(retained);
const head = Math.min(retained, this.maxBytes - start);
await readAtAsync(handle, tail.subarray(0, head), start);
await readAtAsync(handle, tail.subarray(head), 0);
await writeAtAsync(handle, tail, 0);
}
await handle.truncate(retained);
} finally {
await handle.close();
}
});
return ring.sealed;
}
}
async function writeAtAsync(handle: FileHandle, data: Buffer, position: number): Promise<void> {
for (let offset = 0; offset < data.length; ) {
const { bytesWritten } = await handle.write(
data,
offset,
data.length - offset,
position + offset
);
offset += bytesWritten;
}
}
async function readAtAsync(handle: FileHandle, into: Buffer, position: number): Promise<void> {
for (let offset = 0; offset < into.length; ) {
const { bytesRead } = await handle.read(into, offset, into.length - offset, position + offset);
if (bytesRead === 0) {
throw new Error(`Android diagnostic log ended before offset ${position + into.length}`);
}
offset += bytesRead;
}
}
@@ -0,0 +1,74 @@
import fs from 'node:fs';
import { type Readable } from 'node:stream';
import { finished } from 'node:stream/promises';
import { Sentry } from '../sentry';
import {
type AndroidEmulatorLogCapture,
type AndroidLogcatOutput,
type FinalizedAndroidEmulatorLog,
} from './androidEmulatorLogCapture';
import { type AndroidEmulatorLogSource } from './androidEmulatorLogSources';
type LaunchFile = { stream: fs.WriteStream; inputs: Set<Readable> };
/**
* Ordinary builds: one plain file per launch, appended without a budget and
* closed when the launch ends. The emulator writes logcat to its file directly.
* Nothing is finalized or uploaded.
*/
export class AndroidEmulatorLogFiles implements AndroidEmulatorLogCapture {
readonly sources: readonly AndroidEmulatorLogSource[];
private readonly files = new Map<string, LaunchFile>();
constructor({ sources }: { sources: readonly AndroidEmulatorLogSource[] }) {
this.sources = sources;
}
attach(input: Readable, filePath: string, source: AndroidEmulatorLogSource): void {
let file = this.files.get(filePath);
if (!file) {
const inputs = new Set<Readable>();
const stream = fs.createWriteStream(filePath, { flags: 'a' });
stream.once('error', err => {
Sentry.capture('Failed to write Android diagnostic log', err, {
level: 'warning',
extras: { filePath, source },
});
// The pipe detaches on error; keep draining so the emulator never blocks.
for (const attached of inputs) {
attached.resume();
}
});
file = { stream, inputs };
this.files.set(filePath, file);
}
file.inputs.add(input);
input.pipe(file.stream, { end: false });
}
async finishFileAsync(filePath: string): Promise<void> {
const file = this.files.get(filePath);
if (!file) {
return;
}
for (const input of file.inputs) {
input.unpipe(file.stream);
input.resume();
}
file.inputs.clear();
file.stream.end();
// A write error was reported when it happened.
await finished(file.stream).catch(() => {});
}
async finishAsync(): Promise<readonly FinalizedAndroidEmulatorLog[]> {
await Promise.all([...this.files.keys()].map(file => this.finishFileAsync(file)));
return [];
}
async prepareLogcatOutputAsync(filePath: string): Promise<AndroidLogcatOutput> {
await fs.promises.writeFile(filePath, '');
return { path: filePath, close: () => {}, closed: Promise.resolve() };
}
}
@@ -0,0 +1,12 @@
import { z } from 'zod';
export const AndroidEmulatorLogSourcesSchema = z.array(
z.enum(['emulator-output', 'device-logcat'])
);
export type AndroidEmulatorLogSource = z.infer<typeof AndroidEmulatorLogSourcesSchema>[number];
// Existing build workflows capture both sources. Device sessions pass their selection explicitly.
export const ALL_ANDROID_EMULATOR_LOG_SOURCES: readonly AndroidEmulatorLogSource[] = [
'emulator-output',
'device-logcat',
];
@@ -0,0 +1,66 @@
import { execFile, spawn } from 'node:child_process';
import fs from 'node:fs';
import { Socket } from 'node:net';
import { type Readable } from 'node:stream';
import { promisify } from 'node:util';
import { Sentry } from '../sentry';
/** Bridge the emulator's path-based logcat output to a readable stream. */
export async function createAndroidLogcatPipeAsync(
pipePath: string
): Promise<{ path: string; stream: Readable; close: () => void; closed: Promise<void> }> {
await promisify(execFile)('mkfifo', ['-m', '600', pipePath]);
// The sentinel keeps the FIFO open before emulator launch. The reader signals
// after opening it, so even a failed launch can close the pipe without a race.
const sentinel = fs.openSync(pipePath, fs.constants.O_RDWR | fs.constants.O_NONBLOCK);
let sentinelOpen = true;
const close = (): void => {
if (sentinelOpen) {
sentinelOpen = false;
fs.closeSync(sentinel);
}
};
const reader = spawn(
'sh',
['-c', 'exec 3<"$1" || exit 1; printf ready >&2; exec cat <&3', 'android-logcat', pipePath],
{
stdio: ['ignore', 'pipe', 'pipe'],
}
);
const ready = new Promise<void>((resolve, reject) => {
reader.once('error', reject);
reader.once('exit', () =>
reject(new Error('Android logcat reader exited before opening its pipe.'))
);
reader.stderr.once('data', () => resolve());
});
reader.on('error', err => {
close();
Sentry.capture('Failed to read Android logcat pipe', err, { level: 'warning' });
});
const closed = new Promise<void>(resolve => {
reader.once('close', () => {
close();
void fs.promises
.rm(pipePath, { force: true })
.catch(() => {})
.then(resolve);
});
});
try {
await ready;
} catch (err) {
close();
reader.kill();
await closed;
throw err;
}
reader.unref();
for (const stream of [reader.stdout, reader.stderr]) {
if (stream instanceof Socket) {
stream.unref();
}
}
return { path: pipePath, stream: reader.stdout, close, closed };
}
@@ -0,0 +1,48 @@
import { type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import semver from 'semver';
import { z } from 'zod';
const PackageNames = {
'serve-sim': '@expo/serve-sim',
'agent-device': 'agent-device',
argent: '@swmansion/argent',
appium: 'appium',
};
/** Resolve an explicit request once, so comparison and installation use the same version. */
export async function resolveSimulatorPackageVersionAsync(
engine: keyof typeof PackageNames,
requested: string,
{ env, signal }: { env: BuildStepEnv; signal?: AbortSignal }
): Promise<string> {
signal?.throwIfAborted();
const exact = semver.parse(requested);
if (exact) {
return exact.version + (exact.build.length ? `+${exact.build.join('.')}` : '');
}
// Use npm's configured registry and revalidate metadata instead of trusting a cached dist-tag.
const { stdout } = await spawn(
'npm',
[
'view',
`${PackageNames[engine]}@${requested}`,
'version',
'--json',
'--prefer-online',
'--prefer-offline=false',
'--offline=false',
'--fetch-retries=0',
],
{ env, signal, stdio: 'pipe', timeout: 30_000 }
);
signal?.throwIfAborted();
const version = z.string().refine(value => semver.valid(value) !== null);
const versions = z.union([version, z.array(version)]).parse(JSON.parse(stdout));
const candidates = typeof versions === 'string' ? [versions] : versions;
const resolved = semver.rsort(candidates)[0];
if (!resolved) {
throw new Error(`Could not resolve the requested ${engine} package version.`);
}
return resolved;
}
+6 -1
View File
@@ -202,7 +202,12 @@ export async function startUptermHostAsync(
await spawn('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', hostKeyPath, '-q'], {
logger: ctx.logger,
});
await fs.writeFile(forceCommandPath, '#!/usr/bin/env bash\nexec bash -l\n', { mode: 0o755 });
const quotedBuildDirectory = `'${ctx.buildDirectory.replaceAll("'", "'\\''")}'`;
await fs.writeFile(
forceCommandPath,
`#!/usr/bin/env bash\ncd -- ${quotedBuildDirectory}\nexec bash -l\n`,
{ mode: 0o755 }
);
let currentProcess: SpawnPromise<SpawnResult> | null = null;
@@ -1,6 +1,10 @@
import { type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as sleepAsync } from 'node:timers/promises';
import { type ZodType, z } from 'zod';
import {
PackageManager,
@@ -8,6 +12,28 @@ import {
resolvePackageExec,
} from './packageManager';
/** Read one tool's prepared metadata; its consumer defines the schema and compatibility checks. */
export async function readPreparedSimulatorToolAsync<T>(
tool: string,
schema: ZodType<T>
): Promise<T | undefined> {
const manifestPath = path.join(
os.homedir(),
'Library',
'Caches',
'expo-simulator-startup',
'prepared.json'
);
try {
const manifest = z
.object({ tools: z.record(z.string(), z.unknown()) })
.parse(JSON.parse(await fs.readFile(manifestPath, 'utf8')));
return Object.hasOwn(manifest.tools, tool) ? schema.parse(manifest.tools[tool]) : undefined;
} catch {
return undefined;
}
}
/**
* Runs a Simulator launcher command with the version that `start` recorded, which warmup cached.
* The output can contain credentials, so it is returned and never logged.
@@ -16,10 +42,29 @@ export async function runSimulatorLauncherAsync(
launcherArgs: string[],
{ env, signal }: { env: BuildStepEnv; signal?: AbortSignal }
): Promise<string> {
const version = await fs
signal?.throwIfAborted();
const contents = await fs
.readFile('/tmp/expo-simulator-startup.json', 'utf8')
.then(text => JSON.parse(text).launcherVersion)
.catch(() => undefined);
.catch(async error => {
if (
error.code === 'ENOENT' &&
launcherArgs[0] === 'claim' &&
env.EAS_USE_WARM_SIMULATOR === '1' &&
process.platform === 'darwin' &&
os.uptime() < 300 &&
(await fs.access('/usr/local/share/vm-tools/startup.sh').then(
() => true,
() => false
))
) {
return await waitForLauncherStateFileAsync(signal);
}
return undefined;
});
let version: unknown;
try {
version = JSON.parse(contents ?? '{}').launcherVersion;
} catch {}
const { command, args } = resolvePackageExec(
resolveConfiguredPackageManager(env, PackageManager.BUN),
[
@@ -31,6 +76,27 @@ export async function runSimulatorLauncherAsync(
return stdout;
}
/** A session request can arrive before startup writes launcher state. */
async function waitForLauncherStateFileAsync(signal?: AbortSignal): Promise<string> {
const deadline = Date.now() + 60_000;
for (;;) {
signal?.throwIfAborted();
try {
return await fs.readFile('/tmp/expo-simulator-startup.json', 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
const remaining = deadline - Date.now();
if (remaining <= 0) {
// The claim caller must still stop warmup before it can start cold.
throw new Error('Timed out waiting for the warm Simulator launcher to start.');
}
await sleepAsync(Math.min(250, remaining), undefined, { signal });
}
}
/** Stops warmup; resolves only after the launcher confirms cleanup. */
export async function stopWarmSimulatorAsync(env: BuildStepEnv): Promise<void> {
if (process.platform === 'darwin') {
@@ -1,5 +1,21 @@
# create-eas-build-function
## 27.7.2
No changes in this release.
## 27.7.1
No changes in this release.
## 27.7.0
No changes in this release.
## 27.6.0
No changes in this release.
## 27.5.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "27.5.0",
"version": "27.7.2",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "27.5.0"
"@expo/steps": "27.7.2"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+16
View File
@@ -1,5 +1,21 @@
# @expo/downloader
## 27.7.2
No changes in this release.
## 27.7.1
No changes in this release.
## 27.7.0
No changes in this release.
## 27.6.0
No changes in this release.
## 27.5.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "27.5.0",
"version": "27.7.2",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,5 +1,21 @@
# eas-build-cache-provider
## 27.7.2
No changes in this release.
## 27.7.1
No changes in this release.
## 27.7.0
No changes in this release.
## 27.6.0
No changes in this release.
## 27.5.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "27.5.0",
"version": "27.7.2",
"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",
+18
View File
@@ -1,5 +1,23 @@
# @expo/eas-build-job
## 27.7.2
No changes in this release.
## 27.7.1
### Patch Changes
- Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
## 27.7.0
No changes in this release.
## 27.6.0
No changes in this release.
## 27.5.0
No changes in this release.
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "27.5.0",
"version": "27.7.2",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,12 +25,12 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "27.5.0",
"@expo/logger": "27.7.2",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "27.5.0",
"joi": "^17.13.1",
"@expo/turtle-spawn": "27.7.2",
"joi": "^17.13.9",
"semver": "^7.6.2",
"zod": "^4.3.5"
"zod": "^4.6.7"
},
"devDependencies": {
"@types/jest": "^29.5.12",
+34
View File
@@ -1,5 +1,39 @@
# eas-cli
## 27.7.2
### Patch Changes
- Add `--log-source` to `eas simulator` to select which Android logs a session records. ([#4823](https://github.com/expo/eas-cli/pull/4823) by [@vonovak](https://github.com/vonovak))
- Show that the ssh session has ended, instead of saying SSH was not enabled, when `eas workflow:ssh` is given the ID of a finished build. ([#4842](https://github.com/expo/eas-cli/pull/4842) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- Show the ID argument and examples in `eas workflow:ssh --help`. ([#4829](https://github.com/expo/eas-cli/pull/4829) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## 27.7.1
### Patch Changes
- Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
## 27.7.0
### Minor Changes
- Add `--git-branch` and `--git-commit-hash` to `eas workflow:run` to record the branch and commit of the uploaded project. ([#4815](https://github.com/expo/eas-cli/pull/4815) by [@hSATAC](https://github.com/hSATAC))
### Patch Changes
- Stop spinners from flickering when their text wraps onto a second line, such as the `simulator:start` queue message in a narrow terminal. ([#4850](https://github.com/expo/eas-cli/pull/4850) by [@brentvatne](https://github.com/brentvatne))
## 27.6.0
### Minor Changes
- Add the `--property key=value` flag to `eas observe:events` to filter events by a property value. ([#4812](https://github.com/expo/eas-cli/pull/4812) by [@Ubax](https://github.com/Ubax))
### Patch Changes
- Stop suggesting other event names in `eas observe:events` when an event name with `--session-id`, `--app-version`, `--build-number`, or `--update-id` returns no events. ([#4813](https://github.com/expo/eas-cli/pull/4813) by [@Ubax](https://github.com/Ubax))
## 27.5.0
### Minor Changes
+215 -178
View File
File diff suppressed because it is too large. Load diff
+270 -19
View File
@@ -1907,6 +1907,18 @@
"name": "environmentSecrets",
"description": "Environment secrets for an account",
"args": [
{
"name": "excludeProductionOnly",
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filterNames",
"description": null,
@@ -1951,7 +1963,20 @@
{
"name": "environmentVariableEnvironments",
"description": null,
"args": [],
"args": [
{
"name": "excludeProductionOnly",
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
@@ -2004,6 +2029,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "excludeProductionOnly",
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filterNames",
"description": null,
@@ -2061,6 +2098,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "excludeProductionOnly",
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filterNames",
"description": null,
@@ -4876,7 +4925,7 @@
},
{
"name": "setEnvironmentVariableProtectionEnabled",
"description": "Set whether environment variable protection is on for this account. When it is on, reading,\nusing, creating, changing, or deleting an environment variable whose environments are\nexclusively production needs the PUBLISH_PROTECTED permission on the account, whatever its\nvisibility, in every project of the account. Requires the ADMIN permission on the account.\nTurning protection off also requires a sudo session.",
"description": "Set whether environment variable protection is on for this account. When it is on, reading,\nusing, creating, changing, or deleting an environment variable whose environments are\nexclusively production needs the PUBLISH_PROTECTED permission on the account, whatever its\nvisibility, in every project of the account. Requires the ADMIN permission on the account.",
"args": [
{
"name": "accountID",
@@ -11640,6 +11689,18 @@
"name": "environmentSecrets",
"description": "Environment secrets for an app",
"args": [
{
"name": "excludeProductionOnly",
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filterNames",
"description": null,
@@ -11684,7 +11745,20 @@
{
"name": "environmentVariableEnvironments",
"description": null,
"args": [],
"args": [
{
"name": "excludeProductionOnly",
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
@@ -11721,6 +11795,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "excludeProductionOnly",
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filterNames",
"description": null,
@@ -11778,6 +11864,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "excludeProductionOnly",
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filterNames",
"description": null,
@@ -20634,7 +20732,7 @@
"fields": [
{
"name": "affectedSessions",
"description": "Distinct sessions on this platform that hit any exception.",
"description": "Distinct sessions on this platform that hit any exception, including installations outside the sampleRate sample.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -20650,7 +20748,7 @@
},
{
"name": "affectedUsers",
"description": "Distinct users on this platform that hit any exception.",
"description": "Distinct users on this platform that hit any exception, including installations outside the sampleRate sample.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -20666,7 +20764,7 @@
},
{
"name": "crashFreeSessions",
"description": "Fraction (0..1) of this platform's active sessions without a fatal error.",
"description": "Fraction (0..1) of this platform's active sessions without a fatal error. Counts only installations in the sampleRate sample.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -20682,7 +20780,7 @@
},
{
"name": "crashFreeUsers",
"description": "Fraction (0..1) of this platform's active users without a fatal error.",
"description": "Fraction (0..1) of this platform's active users without a fatal error. Counts only installations in the sampleRate sample.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -20796,7 +20894,7 @@
"fields": [
{
"name": "affectedSessions",
"description": "Distinct sessions that hit any exception.",
"description": "Distinct sessions that hit any exception, including installations outside the sampleRate sample.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -20812,7 +20910,7 @@
},
{
"name": "affectedUsers",
"description": "Distinct users that hit any exception.",
"description": "Distinct users that hit any exception, including installations outside the sampleRate sample that send errors because of errorSampleRate.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -20828,7 +20926,7 @@
},
{
"name": "crashFreeSessions",
"description": "Fraction (0..1) of active sessions without a fatal error. Denominator spans app_metrics and app_events.",
"description": "Fraction (0..1) of active sessions without a fatal error. Counts only installations in the sampleRate sample. Denominator spans app_metrics and app_events.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -20844,7 +20942,7 @@
},
{
"name": "crashFreeUsers",
"description": "Fraction (0..1) of active users without a fatal error.",
"description": "Fraction (0..1) of active users without a fatal error. Counts only installations in the sampleRate sample.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -26227,7 +26325,7 @@
},
{
"name": "crashFreeSessions",
"description": "Fraction (0..1) of active sessions without a fatal error.",
"description": "Fraction (0..1) of active sessions without a fatal error. Counts only installations in the sampleRate sample.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -26243,7 +26341,7 @@
},
{
"name": "crashFreeUsers",
"description": "Fraction (0..1) of active users without a fatal error.",
"description": "Fraction (0..1) of active users without a fatal error. Counts only installations in the sampleRate sample.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -26259,7 +26357,7 @@
},
{
"name": "crashedUsers",
"description": "Distinct users that hit a fatal error in range.",
"description": "Distinct users that hit a fatal error in range, including installations outside the sampleRate sample.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -27930,6 +28028,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "scopes",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "statusCodes",
"description": null,
@@ -28380,6 +28498,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "scopeName",
"description": "The instrumentation scope that produced the span (OTLP scopeSpans.scope.name), such as\nexpo-observe.network. Empty when the span was sent without a scope or stored before scopes were kept.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sessionId",
"description": null,
@@ -29074,6 +29208,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "scopes",
"description": "Spans produced by any of these instrumentation scopes, such as expo-observe.network.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sessionId",
"description": null,
@@ -47370,7 +47524,7 @@
"inputFields": [
{
"name": "deviceIdentifier",
"description": "AVD hardware profile id to use for the emulator (e.g. \"pixel_9\").",
"description": "AVD hardware profile id to use for the emulator (e.g. \"pixel_9\"). Defaults to \"medium_phone\".",
"type": {
"kind": "SCALAR",
"name": "String",
@@ -47380,6 +47534,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "logSources",
"description": "Logs to record during the session and upload afterward: emulator-output and device-logcat. Defaults to\n[\"emulator-output\"]. An empty list disables recording and uploading both sources. Device-wide logcat can contain\ncredentials and personal data; include device-logcat only when you want to retain it.\nLogs are not redacted and use the existing 90-day artifact retention policy.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "systemImagePackage",
"description": "Android SDK system image package to use for the emulator (e.g.\n\"system-images;android-35;google_apis;x86_64\").",
@@ -48791,7 +48965,7 @@
"inputFields": [
{
"name": "deviceIdentifier",
"description": "Simulator device name or UDID to use for the session (e.g. \"iPhone 16 Pro\").",
"description": "Simulator device name or UDID to use for the session (e.g. \"iPhone 16 Pro\"). Defaults to\n\"iPhone 17\".",
"type": {
"kind": "SCALAR",
"name": "String",
@@ -52297,6 +52471,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deviceIdentifier",
"description": "The simulator or emulator the session starts: the requested simulator device\nname or UDID on iOS, or the AVD hardware profile id on Android, or the platform\ndefault when none is requested. Null on sessions created before it was saved.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "finishedAt",
"description": null,
@@ -60746,9 +60932,13 @@
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
@@ -102791,12 +102981,67 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "WorkflowRunGitInput",
"description": "Git metadata for a run started from an uploaded project. The commit must exist in the\nproject's linked GitHub repository. The run records the branch and commit but is not\nlinked to the repository.",
"fields": null,
"inputFields": [
{
"name": "branch",
"description": "Branch name such as \"main\". Stored as refs/heads/<branch>. Fully qualified refs are rejected.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "commitHash",
"description": "Full commit SHA. The commit message is read from GitHub.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "WorkflowRunInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "git",
"description": null,
"type": {
"kind": "INPUT_OBJECT",
"name": "WorkflowRunGitInput",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "inputs",
"description": null,
@@ -103446,6 +103691,12 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "OBSERVE_NEW_ERROR",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "REPACK_EXPO_GO",
"description": null,
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "27.5.0",
"version": "27.7.2",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,12 +48,12 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "27.5.0",
"@expo/eas-json": "27.5.0",
"@expo/eas-build-job": "27.7.2",
"@expo/eas-json": "27.7.2",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "27.5.0",
"@expo/logger": "27.7.2",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -65,7 +65,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "27.5.0",
"@expo/steps": "27.7.2",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -74,7 +74,7 @@
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
"agent-cli-detector": "0.1.7",
"ajv": "8.11.0",
"ajv": "8.20.0",
"ajv-formats": "2.1.1",
"better-opn": "3.0.2",
"bplist-parser": "^0.3.0",
@@ -101,15 +101,15 @@
"indent-string": "4.0.0",
"invariant": "^2.2.2",
"jks-js": "1.1.0",
"joi": "17.11.0",
"joi": "17.13.9",
"keychain": "1.5.0",
"log-symbols": "4.1.0",
"mime": "3.0.0",
"minimatch": "5.1.2",
"minimatch": "5.1.9",
"minizlib": "3.1.0",
"nanoid": "3.3.8",
"nanoid": "3.3.20",
"node-fetch": "2.6.7",
"node-forge": "1.4.0",
"node-forge": "1.4.3",
"node-stream-zip": "1.15.0",
"nullthrows": "1.1.1",
"open": "8.4.0",
@@ -126,7 +126,7 @@
"semver": "7.5.4",
"set-interval-async": "3.0.3",
"slash": "3.0.0",
"tar": "7.5.19",
"tar": "7.5.22",
"tar-stream": "3.1.7",
"terminal-link": "2.1.1",
"ts-deepmerge": "6.2.0",
@@ -136,8 +136,8 @@
"uuid": "11.1.1",
"wrap-ansi": "7.0.0",
"ws": "8.21.1",
"yaml": "2.6.0",
"zod": "^4.1.3"
"yaml": "2.9.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@graphql-codegen/cli": "6.3.1",
+60 -10
View File
@@ -1,29 +1,40 @@
import { EventEmitter } from 'events';
import readline from 'readline';
import { Writable } from 'stream';
import type { ora as oraType } from '../ora';
jest.unmock('ora');
class FakeTTYStream extends EventEmitter {
const CLEAR_LINE = '\x1b[2K';
// Implements _writev like a TTY stream, so corked writes arrive in one call.
class FakeTTYStream extends Writable {
isTTY = true;
columns = 200;
clearLineCalls = 0;
writes: string[] = [];
write(): boolean {
return true;
override _write(chunk: Buffer, _encoding: BufferEncoding, callback: () => void): void {
this.writes.push(chunk.toString());
callback();
}
clearLine(): boolean {
override _writev(chunks: { chunk: Buffer }[], callback: () => void): void {
this.writes.push(chunks.map(({ chunk }) => chunk.toString()).join(''));
callback();
}
clearLine(dir: readline.Direction): boolean {
this.clearLineCalls++;
return true;
return readline.clearLine(this, dir);
}
cursorTo(): boolean {
return true;
cursorTo(x: number): boolean {
return readline.cursorTo(this, x);
}
moveCursor(): boolean {
return true;
moveCursor(dx: number, dy: number): boolean {
return readline.moveCursor(this, dx, dy);
}
}
@@ -68,4 +79,43 @@ describe('ora', () => {
spinner.stop();
expect(stream.listenerCount('resize')).toBe(0);
});
it('sends each frame as one write', () => {
const text = 'x'.repeat(150);
const stream = new FakeTTYStream();
stream.columns = 80;
const spinner = ora({
text,
stream: stream as unknown as NodeJS.WritableStream,
discardStdin: false,
}).start();
stream.writes = [];
jest.advanceTimersByTime(500);
expect(stream.writes.length).toBeGreaterThan(1);
for (const frame of stream.writes) {
expect(frame.split(CLEAR_LINE)).toHaveLength(3);
expect(frame).toContain(text);
}
spinner.stop();
});
it('clears the spinner and writes the final line as one write', () => {
const stream = new FakeTTYStream();
stream.columns = 80;
const spinner = ora({
text: 'x'.repeat(150),
stream: stream as unknown as NodeJS.WritableStream,
discardStdin: false,
}).start();
stream.writes = [];
spinner.succeed('done');
expect(stream.writes).toHaveLength(1);
expect(stream.writes[0].split(CLEAR_LINE)).toHaveLength(3);
expect(stream.writes[0]).toContain('done');
});
});
@@ -12,6 +12,7 @@ import {
buildObserveCustomEventsEmptyWithSuggestionsJson,
buildObserveCustomEventsEmptyWithSuggestionsTable,
buildObserveCustomEventsJson,
buildObserveCustomEventsTable,
} from '../../../observe/formatCustomEvents';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveEvents from '../events';
@@ -42,6 +43,7 @@ jest.mock('../../../utils/json');
const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync);
const mockBuildObserveCustomEventsJson = jest.mocked(buildObserveCustomEventsJson);
const mockBuildObserveCustomEventsTable = jest.mocked(buildObserveCustomEventsTable);
const mockBuildObserveCustomEventNamesJson = jest.mocked(buildObserveCustomEventNamesJson);
const mockBuildObserveCustomEventsEmptyWithSuggestionsTable = jest.mocked(
buildObserveCustomEventsEmptyWithSuggestionsTable
@@ -299,6 +301,103 @@ describe(ObserveEvents, () => {
expect(options.sessionId).toBe('session-xyz');
});
it('passes repeated --property flags as property filters', async () => {
const command = createCommand([
'expo.feedback',
'--property',
'expo.feedback.id=fb-1',
'--property',
'note=a=b',
]);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.propertyFilters).toEqual([
{ key: 'expo.feedback.id', value: 'fb-1' },
{ key: 'note', value: 'a=b' },
]);
});
it('lists events instead of the event-name summary when --property is set with no positional arg', async () => {
const command = createCommand(['--property', 'expo.feedback.id=fb-1']);
await command.runAsync();
expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1);
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.propertyFilters).toEqual([{ key: 'expo.feedback.id', value: 'fb-1' }]);
});
it('rejects --property without "="', async () => {
const command = createCommand(['my_event', '--property', 'expo.feedback.id']);
await expect(command.runAsync()).rejects.toThrow(/Expected key=value/);
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
});
it('rejects --property with an empty key', async () => {
const command = createCommand(['my_event', '--property', '=fb-1']);
await expect(command.runAsync()).rejects.toThrow(/Expected key=value/);
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
});
it('rejects --property with an empty value', async () => {
const command = createCommand(['my_event', '--property', 'note=']);
await expect(command.runAsync()).rejects.toThrow(/value cannot be empty/);
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
});
it('rejects more than 5 --property flags', async () => {
const command = createCommand([
'my_event',
'--property',
'a=1',
'--property',
'b=2',
'--property',
'c=3',
'--property',
'd=4',
'--property',
'e=5',
'--property',
'f=6',
]);
await expect(command.runAsync()).rejects.toThrow(/--property can be used up to 5 times/);
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
});
it('accepts exactly 5 --property flags', async () => {
const command = createCommand([
'my_event',
'--property',
'a=1',
'--property',
'b=2',
'--property',
'c=3',
'--property',
'd=4',
'--property',
'e=5',
]);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.propertyFilters).toEqual([
{ key: 'a', value: '1' },
{ key: 'b', value: '2' },
{ key: 'c', value: '3' },
{ key: 'd', value: '4' },
{ key: 'e', value: '5' },
]);
});
it('rejects a repeated --property key', async () => {
const command = createCommand(['my_event', '--property', 'a=1', '--property', 'a=2']);
await expect(command.runAsync()).rejects.toThrow(/key "a" is used more than once/);
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
});
it('does not pass platforms, appVersion, updateId, or sessionId when flags are not provided', async () => {
const command = createCommand(['my_event']);
await command.runAsync();
@@ -308,6 +407,7 @@ describe(ObserveEvents, () => {
expect(options.appVersion).toBeUndefined();
expect(options.updateId).toBeUndefined();
expect(options.sessionId).toBeUndefined();
expect(options.propertyFilters).toBeUndefined();
});
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided with an event name', async () => {
@@ -395,6 +495,64 @@ describe(ObserveEvents, () => {
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
});
it.each([
['--session-id', 'session-xyz'],
['--property', 'expo.feedback.id=fb-1'],
])(
'does not suggest event names when an event name and %s return 0 events',
async (flag, value) => {
const command = createCommand(['my_event', flag, value]);
await command.runAsync();
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
expect(mockBuildObserveCustomEventsTable).toHaveBeenCalledWith(
[],
expect.anything(),
expect.objectContaining({ eventName: 'my_event' })
);
}
);
it('emits empty events JSON without suggestions when an event name and --session-id return 0 events and --json is set', async () => {
const pageInfo = { hasNextPage: false, hasPreviousPage: false };
mockFetchObserveCustomEventsAsync.mockResolvedValue({ events: [], pageInfo });
const command = createCommand([
'my_event',
'--session-id',
'session-xyz',
'--json',
'--non-interactive',
]);
await command.runAsync();
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsJson).not.toHaveBeenCalled();
expect(mockBuildObserveCustomEventsJson).toHaveBeenCalledWith([], pageInfo);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
it.each([
['--app-version', '2.1.0'],
['--build-number', '42'],
['--update-id', 'update-xyz'],
])(
'does not suggest event names when an event name and %s return 0 events',
async (flag, value) => {
const command = createCommand(['my_event', flag, value]);
await command.runAsync();
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
expect(mockBuildObserveCustomEventsTable).toHaveBeenCalledWith(
[],
expect.anything(),
expect.objectContaining({ eventName: 'my_event' })
);
}
);
it('emits empty-with-suggestions JSON when filtered fetch returns 0 events and --json is set', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
@@ -8,6 +8,7 @@ import {
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import Log from '../../log';
import { AppObserveUserEventPropertyFilter } from '../../graphql/generated';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents';
import {
@@ -33,6 +34,7 @@ import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_EVENTS_LIMIT = 10;
const MAX_PROPERTY_FILTERS = 5;
export default class ObserveEvents extends EasCommand {
static override description =
@@ -61,6 +63,11 @@ export default class ObserveEvents extends EasCommand {
description:
'Filter by session ID. When no event name is given, lists the events in the session instead of the event-name summary.',
}),
property: Flags.custom<AppObserveUserEventPropertyFilter>({
description: `Filter by event property (key=value, exact match). Repeat up to ${MAX_PROPERTY_FILTERS} times to require all (each key may be used once). Without an event name, lists matching events.`,
multiple: true,
parse: parsePropertyFilterAsync,
})(),
'all-events': Flags.boolean({
description:
'When no event name argument is provided, list all events across all event names instead of a summary of event names + counts.',
@@ -85,6 +92,22 @@ export default class ObserveEvents extends EasCommand {
);
}
if (flags.property) {
if (flags.property.length > MAX_PROPERTY_FILTERS) {
throw new Error(
`--property can be used up to ${MAX_PROPERTY_FILTERS} times, but got ${flags.property.length}.`
);
}
const keys = flags.property.map(filter => filter.key);
const repeatedKey = keys.find((key, index) => keys.indexOf(key) !== index);
if (repeatedKey) {
throw new Error(
`--property key "${repeatedKey}" is used more than once. Each key can be filtered by only one value.`
);
}
}
const {
projectId,
loggedIn: { graphqlClient },
@@ -101,10 +124,10 @@ export default class ObserveEvents extends EasCommand {
const platforms = observePlatformsFromFlag(flags.platform);
// A session ID narrows to a single session, so show that session's events
// A session ID or property filter narrows the events, so show them
// (like --all-events) instead of the account-wide name+count summary, which
// has no session filter.
if (!args.eventName && !flags['all-events'] && !flags['session-id']) {
// supports neither filter.
if (!args.eventName && !flags['all-events'] && !flags['session-id'] && !flags.property) {
const { names, isTruncated } = await withObservePlanGateHandlingAsync(() =>
ObserveQuery.customEventNamesAsync(graphqlClient, {
appId: projectId,
@@ -144,10 +167,22 @@ export default class ObserveEvents extends EasCommand {
updateId: flags['update-id'],
sessionId: flags['session-id'],
environment: flags.environment,
propertyFilters: flags.property,
})
);
if (args.eventName && events.length === 0) {
// A session, app version, build number, update ID, or property may not
// contain the event even when the name is valid, so only suggest other names
// when the event name is the sole narrowing filter.
if (
args.eventName &&
events.length === 0 &&
!flags['session-id'] &&
!flags['app-version'] &&
!flags['build-number'] &&
!flags['update-id'] &&
!flags.property
) {
const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
appId: projectId,
startTime,
@@ -189,3 +224,16 @@ export default class ObserveEvents extends EasCommand {
}
}
}
// Split on the first "=" so values may contain "=".
async function parsePropertyFilterAsync(input: string): Promise<AppObserveUserEventPropertyFilter> {
const separatorIndex = input.indexOf('=');
if (separatorIndex <= 0) {
throw new Error(`Invalid property filter "${input}". Expected key=value format.`);
}
const value = input.slice(separatorIndex + 1);
if (!value) {
throw new Error(`Invalid property filter "${input}". The value cannot be empty.`);
}
return { key: input.slice(0, separatorIndex), value };
}
@@ -1685,6 +1685,38 @@ describe(Simulator, () => {
);
});
it('accumulates repeated --log-source flags in order', async () => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
'--log-source',
'emulator-output',
'--log-source',
'device-logcat',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ android: { logSources: ['emulator-output', 'device-logcat'] } })
);
});
it('leaves Android log sources omitted unless explicitly requested', async () => {
const { command } = createCommand(['--platform', 'android', '--non-interactive']);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1].android?.logSources).toBeUndefined();
});
it.each([
{ args: ['--platform', 'ios', '--log-source', 'device-logcat'] },
{ args: ['--platform', 'android', '--log-source', 'unknown'] },
])('rejects invalid Android logging options $args', async ({ args }) => {
const { command } = createCommand([...args, '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('passes --network-capture to the createDeviceRunSession mutation', async () => {
const { command } = createCommand([
'--platform',
@@ -190,6 +190,12 @@ With local egress, actions wait for the connection and require interactive mode.
description:
'Version of the package backing the simulator session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
}),
'log-source': Flags.option({
description:
'Android logs to record during the session and upload afterward. Repeat to select both sources; replaces the default emulator-output selection. Device-wide device-logcat may contain credentials and personal data. Logs are not redacted.',
options: ['emulator-output', 'device-logcat'],
multiple: true,
})(),
'network-capture': Flags.boolean({
allowNo: true,
description:
@@ -300,6 +306,7 @@ With local egress, actions wait for the connection and require interactive mode.
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 logSources = flags['log-source'];
const buildId = flags['build-id']?.trim() || undefined;
const buildFingerprint = flags['build-fingerprint']?.trim() || undefined;
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
@@ -346,6 +353,11 @@ With local egress, actions wait for the connection and require interactive mode.
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined;
if (logSources && platform !== AppPlatform.Android) {
throw new EasCommandError(
'--log-source selects Android emulator logs. Pass --platform android.'
);
}
if (systemImagePackage && platform !== AppPlatform.Android) {
throw new EasCommandError('--system-image is only supported with --platform android.');
}
@@ -421,6 +433,19 @@ With local egress, actions wait for the connection and require interactive mode.
packageVersion: flags['package-version'],
nonInteractive,
});
const android = {
...(deviceIdentifier ? { deviceIdentifier } : {}),
...(systemImagePackage ? { systemImagePackage } : {}),
...(logSources ? { logSources } : {}),
};
const platformInput =
platform === AppPlatform.Ios
? deviceIdentifier
? { ios: { deviceIdentifier } }
: {}
: Object.keys(android).length
? { android }
: {};
const session = await withSimulatorRequestAnalyticsAsync(
analytics,
requestProperties,
@@ -436,18 +461,7 @@ With local egress, actions wait for the connection and require interactive mode.
packageVersion: flags['package-version'],
networkCapture: flags['network-capture'],
...(networkCaptureFields.length ? { networkCaptureFields } : {}),
...(platform === AppPlatform.Ios
? deviceIdentifier
? { ios: { deviceIdentifier } }
: {}
: deviceIdentifier || systemImagePackage
? {
android: {
...(deviceIdentifier ? { deviceIdentifier } : {}),
...(systemImagePackage ? { systemImagePackage } : {}),
},
}
: {}),
...platformInput,
...(buildId ? { buildId } : {}),
...(buildFingerprint ? { buildFingerprint } : {}),
...(applicationArchiveUrlFromFlag
@@ -0,0 +1,131 @@
import { Config } from '@oclif/core';
import { fileExistsAsync, maybeReadStdinAsync } from '../../../commandUtils/workflow/utils';
import { WorkflowRevisionMutation } from '../../../graphql/mutations/WorkflowRevisionMutation';
import { WorkflowRunMutation } from '../../../graphql/mutations/WorkflowRunMutation';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
import { uploadAccountScopedFileAsync } from '../../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../../project/uploadAccountScopedProjectSourceAsync';
import { WorkflowFile } from '../../../utils/workflowFile';
import WorkflowRun from '../run';
jest.mock('../../../graphql/mutations/WorkflowRevisionMutation');
jest.mock('../../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../../project/projectUtils');
jest.mock('../../../project/uploadAccountScopedFileAsync');
jest.mock('../../../project/uploadAccountScopedProjectSourceAsync');
jest.mock('../../../utils/workflowFile');
jest.mock('../../../commandUtils/workflow/utils', () => ({
...jest.requireActual('../../../commandUtils/workflow/utils'),
fileExistsAsync: jest.fn(),
maybeReadStdinAsync: jest.fn(),
}));
jest.mock('../../../log', () => ({
__esModule: true,
default: {
log: jest.fn(),
newLine: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
link: jest.fn((url: string) => url),
}));
const mockValidate = jest.mocked(WorkflowRevisionMutation.validateWorkflowYamlConfigAsync);
const mockCreateRun = jest.mocked(WorkflowRunMutation.createWorkflowRunAsync);
const mockGetOwner = jest.mocked(getOwnerAccountForProjectIdAsync);
const mockUploadProject = jest.mocked(uploadAccountScopedProjectSourceAsync);
const mockUploadFile = jest.mocked(uploadAccountScopedFileAsync);
const mockReadWorkflow = jest.mocked(WorkflowFile.readWorkflowFileContentsAsync);
const mockFileExists = jest.mocked(fileExistsAsync);
const mockReadStdin = jest.mocked(maybeReadStdinAsync);
describe('WorkflowRun --git-branch and --git-commit-hash', () => {
const commitHash = '1234567890abcdef1234567890abcdef12345678';
let mockConfig: Config;
let exitSpy: jest.SpyInstance;
beforeAll(async () => {
mockConfig = new Config({ root: __dirname });
mockConfig.runHook = async () => ({ failures: [], successes: [] });
});
beforeEach(() => {
jest.clearAllMocks();
exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
mockReadStdin.mockResolvedValue(null);
mockGetOwner.mockResolvedValue({ id: 'acct-1', name: 'testuser' } as never);
mockValidate.mockResolvedValue(undefined as never);
mockReadWorkflow.mockResolvedValue({
filePath: '.eas/workflows/test.yml',
yamlConfig: 'jobs:\n a:\n steps: []\n',
} as never);
mockFileExists.mockResolvedValue(false);
mockUploadProject.mockResolvedValue({ projectArchiveBucketKey: 'archive-key' } as never);
mockUploadFile.mockResolvedValue({ fileBucketKey: 'file-key' } as never);
mockCreateRun.mockResolvedValue({ id: 'run-1' } as never);
});
afterEach(() => {
exitSpy.mockRestore();
});
function createCommand(flags: {
'git-branch'?: string;
'git-commit-hash'?: string;
}): WorkflowRun {
const command = new WorkflowRun(['.eas/workflows/test.yml'], mockConfig);
// @ts-expect-error getContextAsync/parse are protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
getDynamicPrivateProjectConfigAsync: async () => ({
projectId: 'app-1',
exp: { slug: 'testapp' },
}),
loggedIn: { graphqlClient: {} },
vcsClient: { getRootPathAsync: async () => '/test/project' },
projectDir: '/test/project',
});
// @ts-expect-error parse is protected on the oclif base
jest.spyOn(command, 'parse').mockResolvedValue({
flags: {
json: false,
wait: false,
'non-interactive': true,
input: undefined,
ref: undefined,
ssh: false,
'ssh-idle-timeout': undefined,
'git-branch': flags['git-branch'],
'git-commit-hash': flags['git-commit-hash'],
},
args: { file: '.eas/workflows/test.yml' },
});
return command;
}
it('passes the branch and commit hash into createWorkflowRunAsync', async () => {
await expect(
createCommand({ 'git-branch': 'feature/foo', 'git-commit-hash': commitHash }).runAsync()
).rejects.toThrow('process.exit:0');
expect(mockCreateRun).toHaveBeenCalledWith(
{},
expect.objectContaining({
workflowRunInput: expect.objectContaining({
git: { branch: 'feature/foo', commitHash },
}),
})
);
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('leaves git undefined when the flags are unset', async () => {
await expect(createCommand({}).runAsync()).rejects.toThrow('process.exit:0');
expect(mockCreateRun).toHaveBeenCalledTimes(1);
expect(mockCreateRun.mock.calls[0][1].workflowRunInput.git).toBeUndefined();
});
});
@@ -3,7 +3,7 @@ import {
parseJsonInputs,
parseWorkflowInputsFromYaml,
} from '../../../commandUtils/workflow/inputs';
import { resolveWorkflowRunSshInput } from '../run';
import { resolveWorkflowRunGitInput, resolveWorkflowRunSshInput } from '../run';
describe(resolveWorkflowRunSshInput, () => {
it('is null when --ssh is not set', () => {
@@ -29,6 +29,35 @@ describe(resolveWorkflowRunSshInput, () => {
});
});
describe(resolveWorkflowRunGitInput, () => {
const commitHash = '1234567890abcdef1234567890abcdef12345678';
it('is undefined when neither flag is set', () => {
expect(
resolveWorkflowRunGitInput({ branch: undefined, commitHash: undefined })
).toBeUndefined();
});
it('forwards the branch and commit hash when both flags are set', () => {
expect(resolveWorkflowRunGitInput({ branch: 'main', commitHash })).toEqual({
branch: 'main',
commitHash,
});
});
it('lowercases the commit hash', () => {
expect(
resolveWorkflowRunGitInput({ branch: 'main', commitHash: commitHash.toUpperCase() })
).toEqual({ branch: 'main', commitHash });
});
it.each(['1234567', 'main'])('rejects the commit hash %j', badCommitHash => {
expect(() => resolveWorkflowRunGitInput({ branch: 'main', commitHash: badCommitHash })).toThrow(
'--git-commit-hash must be the full 40-character commit SHA'
);
});
});
describe('parseInputs', () => {
it('should parse single key=value pair', () => {
const inputs = parseInputs(['key=value']);
+40 -1
View File
@@ -68,6 +68,7 @@ import {
WorkflowProjectSourceType,
WorkflowReferencedFileInput,
WorkflowRevision,
WorkflowRunGitInput,
WorkflowRunStatus,
} from '../../graphql/generated';
import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation';
@@ -93,6 +94,25 @@ export function resolveWorkflowRunSshInput({
return ssh ? { idleTimeoutSeconds } : null;
}
export function resolveWorkflowRunGitInput({
branch,
commitHash,
}: {
branch: string | undefined;
commitHash: string | undefined;
}): WorkflowRunGitInput | undefined {
if (branch === undefined || commitHash === undefined) {
return undefined;
}
const normalizedCommitHash = commitHash.toLowerCase();
if (!/^[0-9a-f]{40}$/.test(normalizedCommitHash)) {
throw new Error(
`--git-commit-hash must be the full 40-character commit SHA, got "${commitHash}".`
);
}
return { branch, commitHash: normalizedCommitHash };
}
export default class WorkflowRun extends EasCommand {
static override description =
'run an EAS workflow. The entire local project directory will be packaged and uploaded to EAS servers for the workflow run, unless the --ref flag is used';
@@ -124,6 +144,21 @@ export default class WorkflowRun extends EasCommand {
description:
"The git reference must exist in the project's git repository, and the workflow file must exist at that reference. When this flag is used, the local project is not uploaded; instead, the workflow is run from the exact state of the project at the chosen reference.",
summary: 'Git reference to run the workflow on',
exclusive: ['git-branch', 'git-commit-hash'],
}),
'git-branch': Flags.string({
description:
'Name of the branch the uploaded project comes from, for example "main". The run is listed under this branch on the EAS dashboard. Requires --git-commit-hash. Cannot be used with --ref.',
summary: 'Git branch of the uploaded project',
dependsOn: ['git-commit-hash'],
exclusive: ['ref'],
}),
'git-commit-hash': Flags.string({
description:
"Full 40-character SHA of the commit the uploaded project comes from. The commit must exist in the project's linked GitHub repository. Requires --git-branch. Cannot be used with --ref.",
summary: 'Git commit of the uploaded project',
dependsOn: ['git-branch'],
exclusive: ['ref'],
}),
ssh: Flags.boolean({
default: false,
@@ -158,7 +193,6 @@ export default class WorkflowRun extends EasCommand {
ssh: flags.ssh,
idleTimeoutSeconds: flags['ssh-idle-timeout'],
});
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
@@ -319,6 +353,10 @@ export default class WorkflowRun extends EasCommand {
workflowRunId = runResult.id;
} else {
// Run from local file
const gitInput = resolveWorkflowRunGitInput({
branch: flags['git-branch'],
commitHash: flags['git-commit-hash'],
});
try {
({ projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
graphqlClient,
@@ -383,6 +421,7 @@ export default class WorkflowRun extends EasCommand {
projectRootDirectory,
},
ssh: sshInput,
git: gitInput,
},
}));
} catch (err) {
@@ -105,6 +105,13 @@ export default class WorkflowSsh extends EasCommand {
static override description =
'[EXPERIMENTAL] open an ssh session on the worker running a workflow job';
static override usage = 'workflow:ssh [--show-connect] ID';
static override examples = [
'$ eas workflow:ssh <ID> \t # Open an SSH session on the worker. Use the ID in the job logs ("Start SSH session" step)',
'$ eas workflow:ssh --show-connect <ID> \t # Print the ssh command to connect, without opening the SSH session',
];
static override strict = false;
static override flags = {
+81 -15
View File
@@ -19,14 +19,22 @@ export type Scalars = {
Boolean: { input: boolean; output: boolean; }
Int: { input: number; output: number; }
Float: { input: number; output: number; }
/** A non-negative integer number of bytes. */
ByteSize: { input: any; output: any; }
/** Date custom scalar type */
DateTime: { input: any; output: any; }
/** A DevDomainName for a deployed serverless app */
DevDomainName: { input: any; output: any; }
/** A name of an environment variable environment */
EnvironmentVariableEnvironment: { input: any; output: any; }
/** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
JSON: { input: any; output: any; }
/** The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
JSONObject: { input: any; output: any; }
SubmissionAndroidTrack: { input: any; output: any; }
/** A alias name for a serverless deployment */
WorkerDeploymentIdentifier: { input: any; output: any; }
/** A Worker Deployment Request ID (also known as the CF Ray ID) */
WorkerDeploymentRequestID: { input: any; output: any; }
};
@@ -489,16 +497,27 @@ export type Account_DeviceRunSessionsPaginatedArgs = {
* data and settings. Actors may own and be members of accounts.
*/
export type Account_EnvironmentSecretsArgs = {
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
};
/**
* An account is a container owning projects, credentials, billing and other organization
* data and settings. Actors may own and be members of accounts.
*/
export type Account_EnvironmentVariableEnvironmentsArgs = {
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
};
/**
* An account is a container owning projects, credentials, billing and other organization
* data and settings. Actors may own and be members of accounts.
*/
export type Account_EnvironmentVariablesArgs = {
environment?: InputMaybe<Scalars['EnvironmentVariableEnvironment']['input']>;
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
};
@@ -509,6 +528,7 @@ export type Account_EnvironmentVariablesArgs = {
*/
export type Account_EnvironmentVariablesIncludingSensitiveArgs = {
environment?: InputMaybe<Scalars['EnvironmentVariableEnvironment']['input']>;
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
};
@@ -795,7 +815,6 @@ export type AccountMutation = {
* using, creating, changing, or deleting an environment variable whose environments are
* exclusively production needs the PUBLISH_PROTECTED permission on the account, whatever its
* visibility, in every project of the account. Requires the ADMIN permission on the account.
* Turning protection off also requires a sudo session.
*/
setEnvironmentVariableProtectionEnabled: Account;
/** Set whether EAS Observe ingestion is paused for this account. */
@@ -1940,13 +1959,21 @@ export type App_EmbeddedUpdatesPaginatedArgs = {
/** Represents an Exponent App (or Experience in legacy terms) */
export type App_EnvironmentSecretsArgs = {
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type App_EnvironmentVariableEnvironmentsArgs = {
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type App_EnvironmentVariablesArgs = {
environment?: InputMaybe<Scalars['EnvironmentVariableEnvironment']['input']>;
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
};
@@ -1954,6 +1981,7 @@ export type App_EnvironmentVariablesArgs = {
/** Represents an Exponent App (or Experience in legacy terms) */
export type App_EnvironmentVariablesIncludingSensitiveArgs = {
environment?: InputMaybe<Scalars['EnvironmentVariableEnvironment']['input']>;
excludeProductionOnly?: InputMaybe<Scalars['Boolean']['input']>;
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
};
@@ -3068,13 +3096,13 @@ export enum AppObserveErrorOccurrencesOrderByField {
/** AppObserveErrorStats for one platform; iOS includes iPadOS and tvOS. */
export type AppObserveErrorPlatformStats = {
__typename?: 'AppObserveErrorPlatformStats';
/** Distinct sessions on this platform that hit any exception. */
/** Distinct sessions on this platform that hit any exception, including installations outside the sampleRate sample. */
affectedSessions: Scalars['Int']['output'];
/** Distinct users on this platform that hit any exception. */
/** Distinct users on this platform that hit any exception, including installations outside the sampleRate sample. */
affectedUsers: Scalars['Int']['output'];
/** Fraction (0..1) of this platform's active sessions without a fatal error. */
/** Fraction (0..1) of this platform's active sessions without a fatal error. Counts only installations in the sampleRate sample. */
crashFreeSessions: Scalars['Float']['output'];
/** Fraction (0..1) of this platform's active users without a fatal error. */
/** Fraction (0..1) of this platform's active users without a fatal error. Counts only installations in the sampleRate sample. */
crashFreeUsers: Scalars['Float']['output'];
fatalCount: Scalars['Int']['output'];
nonFatalCount: Scalars['Int']['output'];
@@ -3090,13 +3118,13 @@ export enum AppObserveErrorSeverity {
export type AppObserveErrorStats = {
__typename?: 'AppObserveErrorStats';
/** Distinct sessions that hit any exception. */
/** Distinct sessions that hit any exception, including installations outside the sampleRate sample. */
affectedSessions: Scalars['Int']['output'];
/** Distinct users that hit any exception. */
/** Distinct users that hit any exception, including installations outside the sampleRate sample that send errors because of errorSampleRate. */
affectedUsers: Scalars['Int']['output'];
/** Fraction (0..1) of active sessions without a fatal error. Denominator spans app_metrics and app_events. */
/** Fraction (0..1) of active sessions without a fatal error. Counts only installations in the sampleRate sample. Denominator spans app_metrics and app_events. */
crashFreeSessions: Scalars['Float']['output'];
/** Fraction (0..1) of active users without a fatal error. */
/** Fraction (0..1) of active users without a fatal error. Counts only installations in the sampleRate sample. */
crashFreeUsers: Scalars['Float']['output'];
fatalCount: Scalars['Int']['output'];
nonFatalCount: Scalars['Int']['output'];
@@ -3753,11 +3781,11 @@ export type AppObserveOverviewStability = {
__typename?: 'AppObserveOverviewStability';
/** Fatal exception events in range. */
crashCount: Scalars['Int']['output'];
/** Fraction (0..1) of active sessions without a fatal error. */
/** Fraction (0..1) of active sessions without a fatal error. Counts only installations in the sampleRate sample. */
crashFreeSessions: Scalars['Float']['output'];
/** Fraction (0..1) of active users without a fatal error. */
/** Fraction (0..1) of active users without a fatal error. Counts only installations in the sampleRate sample. */
crashFreeUsers: Scalars['Float']['output'];
/** Distinct users that hit a fatal error in range. */
/** Distinct users that hit a fatal error in range, including installations outside the sampleRate sample. */
crashedUsers: Scalars['Int']['output'];
/** Fatal exception events in the equal-length window before startTime. Null when the window is longer than 45 days. */
previousPeriodCrashCount?: Maybe<Scalars['Int']['output']>;
@@ -3963,6 +3991,7 @@ export type AppObserveSessionSpanFilter = {
minDurationMs?: InputMaybe<Scalars['Float']['input']>;
names?: InputMaybe<Array<Scalars['String']['input']>>;
rootOnly?: InputMaybe<Scalars['Boolean']['input']>;
scopes?: InputMaybe<Array<Scalars['String']['input']>>;
statusCodes?: InputMaybe<Array<AppObserveSpanStatusCode>>;
};
@@ -4002,6 +4031,11 @@ export type AppObserveSpan = {
/** Null for a root span. */
parentSpanId?: Maybe<Scalars['String']['output']>;
reactNativeVersion?: Maybe<Scalars['String']['output']>;
/**
* The instrumentation scope that produced the span (OTLP scopeSpans.scope.name), such as
* expo-observe.network. Empty when the span was sent without a scope or stored before scopes were kept.
*/
scopeName: Scalars['String']['output'];
sessionId?: Maybe<Scalars['String']['output']>;
spanId: Scalars['String']['output'];
startTimestamp: Scalars['DateTime']['output'];
@@ -4084,6 +4118,8 @@ export type AppObserveSpanListFilter = {
platforms?: InputMaybe<Array<AppObservePlatform>>;
/** Only spans with no parent. */
rootOnly?: InputMaybe<Scalars['Boolean']['input']>;
/** Spans produced by any of these instrumentation scopes, such as expo-observe.network. */
scopes?: InputMaybe<Array<Scalars['String']['input']>>;
sessionId?: InputMaybe<Scalars['String']['input']>;
startTime: Scalars['DateTime']['input'];
statusCodes?: InputMaybe<Array<AppObserveSpanStatusCode>>;
@@ -6620,8 +6656,15 @@ export type CreateAndConfigureRepositoryInput = {
};
export type CreateAndroidDeviceRunSessionInput = {
/** AVD hardware profile id to use for the emulator (e.g. "pixel_9"). */
/** AVD hardware profile id to use for the emulator (e.g. "pixel_9"). Defaults to "medium_phone". */
deviceIdentifier?: InputMaybe<Scalars['String']['input']>;
/**
* Logs to record during the session and upload afterward: emulator-output and device-logcat. Defaults to
* ["emulator-output"]. An empty list disables recording and uploading both sources. Device-wide logcat can contain
* credentials and personal data; include device-logcat only when you want to retain it.
* Logs are not redacted and use the existing 90-day artifact retention policy.
*/
logSources?: InputMaybe<Array<Scalars['String']['input']>>;
/**
* Android SDK system image package to use for the emulator (e.g.
* "system-images;android-35;google_apis;x86_64").
@@ -6869,7 +6912,10 @@ export type CreateGitHubRepositorySettingsInput = {
};
export type CreateIosDeviceRunSessionInput = {
/** Simulator device name or UDID to use for the session (e.g. "iPhone 16 Pro"). */
/**
* Simulator device name or UDID to use for the session (e.g. "iPhone 16 Pro"). Defaults to
* "iPhone 17".
*/
deviceIdentifier?: InputMaybe<Scalars['String']['input']>;
};
@@ -7377,6 +7423,12 @@ export type DeviceRunSession = ActivityTimelineProjectActivity & {
*/
build?: Maybe<Build>;
createdAt: Scalars['DateTime']['output'];
/**
* The simulator or emulator the session starts: the requested simulator device
* name or UDID on iOS, or the AVD hardware profile id on Android, or the platform
* default when none is requested. Null on sessions created before it was saved.
*/
deviceIdentifier?: Maybe<Scalars['String']['output']>;
finishedAt?: Maybe<Scalars['DateTime']['output']>;
id: Scalars['ID']['output'];
initiatingActor?: Maybe<Actor>;
@@ -8682,7 +8734,7 @@ export type GitHubRepository = {
export type GitHubRepositoryMetadata = {
__typename?: 'GitHubRepositoryMetadata';
defaultBranch?: Maybe<Scalars['String']['output']>;
defaultBranch: Scalars['String']['output'];
githubRepoDescription?: Maybe<Scalars['String']['output']>;
githubRepoName: Scalars['String']['output'];
githubRepoOwnerName: Scalars['String']['output'];
@@ -14556,7 +14608,20 @@ export type WorkflowRunGitBranchFilterInput = {
searchTerm?: InputMaybe<Scalars['String']['input']>;
};
/**
* Git metadata for a run started from an uploaded project. The commit must exist in the
* project's linked GitHub repository. The run records the branch and commit but is not
* linked to the repository.
*/
export type WorkflowRunGitInput = {
/** Branch name such as "main". Stored as refs/heads/<branch>. Fully qualified refs are rejected. */
branch: Scalars['String']['input'];
/** Full commit SHA. The commit message is read from GitHub. */
commitHash: Scalars['String']['input'];
};
export type WorkflowRunInput = {
git?: InputMaybe<WorkflowRunGitInput>;
inputs?: InputMaybe<Scalars['JSONObject']['input']>;
projectSource: WorkflowProjectSourceInput;
ssh?: InputMaybe<WorkflowRunSshInput>;
@@ -14663,6 +14728,7 @@ export enum WorkflowRunTriggerEventType {
GithubPush = 'GITHUB_PUSH',
GithubRefDelete = 'GITHUB_REF_DELETE',
Manual = 'MANUAL',
ObserveNewError = 'OBSERVE_NEW_ERROR',
RepackExpoGo = 'REPACK_EXPO_GO',
Schedule = 'SCHEDULE',
SentryIssueArchived = 'SENTRY_ISSUE_ARCHIVED',
@@ -215,9 +215,10 @@ export const WorkflowJobSshQuery = {
throw error;
}
const build = data.builds.byId;
const jobCompleted = FINAL_BUILD_STATUSES.has(build.status);
return {
sshRequested: build.sshSession !== null,
jobCompleted: FINAL_BUILD_STATUSES.has(build.status),
sshRequested: build.sshSession !== null || jobCompleted,
jobCompleted,
session: build.sshSession,
};
},
@@ -1,4 +1,5 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { resolveSshConnectStatus, terminalSshStatusMessage } from '../../../commands/workflow/ssh';
import { GraphqlError } from '../../client';
import { BuildStatus, JobRunStatus, WorkflowJobStatus } from '../../generated';
import { WorkflowJobSshQuery } from '../WorkflowJobSshQuery';
@@ -227,6 +228,19 @@ describe(WorkflowJobSshQuery.connectInfoForBuildAsync.name, () => {
}
);
it.each([BuildStatus.Errored, BuildStatus.Finished, BuildStatus.Canceled])(
'reports the ssh session as ended for a %s build without an sshSession',
async status => {
const { graphqlClient } = makeClient({ id: 'build-1', status, sshSession: null });
const sshStatus = resolveSshConnectStatus(
await WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, 'build-1')
);
expect(sshStatus).toBe('ended');
expect(terminalSshStatusMessage(sshStatus, 'build-1')).toBe('This ssh session has ended.');
}
);
it.each([BuildStatus.New, BuildStatus.InQueue, BuildStatus.PendingCancel])(
'treats %s as not completed',
async status => {
@@ -99,6 +99,18 @@ describe('fetchObserveCustomEventsAsync', () => {
expect(filter).not.toHaveProperty('appUpdateId');
});
it('forwards propertyFilters when provided', async () => {
await fetchObserveCustomEventsAsync(mockGraphqlClient, 'project-123', {
limit: 10,
propertyFilters: [{ key: 'expo.feedback.id', value: 'fb-1' }],
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
});
const filter = mockCustomEventListAsync.mock.calls[0][1].filter;
expect(filter?.propertyFilters).toEqual([{ key: 'expo.feedback.id', value: 'fb-1' }]);
});
it('does not send optional filters when their inputs are undefined', async () => {
await fetchObserveCustomEventsAsync(mockGraphqlClient, 'project-123', {
limit: 10,
@@ -112,6 +124,7 @@ describe('fetchObserveCustomEventsAsync', () => {
expect(filter).not.toHaveProperty('appVersion');
expect(filter).not.toHaveProperty('appUpdateId');
expect(filter).not.toHaveProperty('sessionId');
expect(filter).not.toHaveProperty('propertyFilters');
});
it('forwards after cursor when provided', async () => {
@@ -4,6 +4,7 @@ import {
AppObserveUserEvent,
AppObserveUserEventListFilter,
AppObserveUserEventListOrderBy,
AppObserveUserEventPropertyFilter,
PageInfo,
} from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
@@ -20,6 +21,7 @@ interface FetchCustomEventsOptions {
updateId?: string;
sessionId?: string;
environment?: string;
propertyFilters?: AppObserveUserEventPropertyFilter[];
orderBy?: AppObserveUserEventListOrderBy;
}
@@ -43,6 +45,7 @@ export async function fetchObserveCustomEventsAsync(
...(options.updateId && { appUpdateId: options.updateId }),
...(options.sessionId && { sessionId: options.sessionId }),
...(options.environment && { environment: options.environment }),
...(options.propertyFilters?.length && { propertyFilters: options.propertyFilters }),
};
return await ObserveQuery.customEventListAsync(graphqlClient, {
+21 -3
View File
@@ -1,6 +1,7 @@
import { boolish } from 'getenv';
// eslint-disable-next-line
import oraReal, { Options, Ora } from 'ora';
import type { Writable } from 'stream';
import Log from './log';
@@ -46,12 +47,29 @@ export function ora(options?: Options | string): Ora {
spinner.text = spinner.text;
};
// Cork so each frame is one write; separate clear and text writes flicker.
const corkable = stream as Partial<Pick<Writable, 'cork' | 'uncork'>>;
const corked = <T>(write: () => T): T => {
if (!corkable.cork || !corkable.uncork) {
return write();
}
corkable.cork();
try {
return write();
} finally {
corkable.uncork();
}
};
const oraRender = spinner.render.bind(spinner);
spinner.render = (): Ora => corked(oraRender);
const oraStart = spinner.start.bind(spinner);
const oraStop = spinner.stop.bind(spinner);
const oraStopAndPersist = spinner.stopAndPersist.bind(spinner);
const logWrap = (method: any, args: any[]): void => {
oraStop();
corked(oraStop);
method(...args);
spinner.start();
};
@@ -111,14 +129,14 @@ export function ora(options?: Options | string): Ora {
};
spinner.stopAndPersist = (options): Ora => {
const result = oraStopAndPersist(options);
const result = corked(() => oraStopAndPersist(options));
resetNativeLogs();
stream.off('resize', onResize);
return result;
};
spinner.stop = (): Ora => {
const result = oraStop();
const result = corked(oraStop);
resetNativeLogs();
stream.off('resize', onResize);
return result;
+18
View File
@@ -1,5 +1,23 @@
# @expo/eas-json
## 27.7.2
No changes in this release.
## 27.7.1
### Patch Changes
- Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
## 27.7.0
No changes in this release.
## 27.6.0
No changes in this release.
## 27.5.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "27.5.0",
"version": "27.7.2",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,12 +32,12 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "27.5.0",
"@expo/eas-build-job": "27.7.2",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
"golden-fleece": "1.0.9",
"joi": "17.11.0",
"joi": "17.13.9",
"log-symbols": "4.1.0",
"semver": "7.5.2",
"terminal-link": "2.1.1",
+18
View File
@@ -1,5 +1,23 @@
# eas-cli-local-build-plugin
## 27.7.2
No changes in this release.
## 27.7.1
### Patch Changes
- Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
## 27.7.0
No changes in this release.
## 27.6.0
No changes in this release.
## 27.5.0
No changes in this release.
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "27.5.0",
"version": "27.7.2",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,20 +26,20 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "27.5.0",
"@expo/build-tools": "27.7.2",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "27.5.0",
"@expo/logger": "27.5.0",
"@expo/eas-build-job": "27.7.2",
"@expo/logger": "27.7.2",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "27.5.0",
"@expo/turtle-spawn": "27.7.2",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
"fs-extra": "^11.2.0",
"joi": "^17.13.1",
"joi": "^17.13.9",
"lodash": "^4.18.1",
"nullthrows": "^1.1.1",
"semver": "^7.6.2",
"tar": "7.5.19",
"tar": "7.5.22",
"uuid": "^9.0.1"
},
"devDependencies": {
+16
View File
@@ -1,5 +1,21 @@
# @expo/logger
## 27.7.2
No changes in this release.
## 27.7.1
No changes in this release.
## 27.7.0
No changes in this release.
## 27.6.0
No changes in this release.
## 27.5.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/logger",
"version": "27.5.0",
"version": "27.7.2",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more