Compare commits

...
32 Commits
Author SHA1 Message Date
Expo CI b5ac163b37 v24.12.0 2026-10-07 21:15:21 +00:00
Douglas LowderandClaude Opus 5 92c8944cda [eas-cli] Include sourceMapUrl and assetMapUrl in eas update:view --json (#4534)
For update:view, add both asset map and source map URLs to the returned results when --json is used. This allows CLI users to retrieve source maps needed for symbolicating JS crashes.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-10-07 21:06:12 +00:00
Douglas LowderandClaude Opus 5.5 96581b8094 [ci] Move ffmpeg-dependent tests to a separate workflow (#4565)
For better performance of the main test workflow, move ffmpeg-dependent tests to a separate workflow, with a restricted set of source files in the workflow trigger, and ensure that this workflow is not required for PRs.

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:00:32 -07:00
Gabe Debes e229f5378f [build-tools] Show eas update:embedded:upload output in build logs (#4557) 2026-10-07 13:38:05 -07:00
Gabe Debes 19657cc7ff [build-tools] Fix unnotarized mitmproxy cask install on iOS network capture sessions (#4556) 2026-10-07 11:04:55 -07:00
Tomasz Mazur be7acb7ab5 [eas-cli] Fix workflow:ssh lookup for job run ids (#4529) 2026-10-07 17:42:49 +02:00
Gabe DebesandClaude 3c302a055d [build-tools] Upload embedded bundle from custom builds (#4494)
* [build-tools] Upload embedded bundle from eas/build custom builds

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

* [build-tools] Add ignore_error to eas/upload_embedded_bundle and share archive path resolvers

Co-Authored-By: Claude <[email protected]>
2026-10-06 15:06:48 -07:00
Samuel Newman 449bd4981e Select containing iOS app when archive includes an App Clip (#4541)
For build:download, exclude nested app bundles (like app clips) from the installable candidates. Return the sole outer app without prompting, and keep the selection prompt when there are genuinely separate apps.
2026-10-06 09:58:17 -07:00
Szymon Dziedzic 4fd36987ba [build-tools] Encode macOS session previews with cwebp (#4549)
* [build-tools] Encode macOS session previews with cwebp

Homebrew's FFmpeg is built without libwebp, so every macOS preview failed
with "Encoder not found" (exit code 8). Resize the screenshot with sips and
encode it with cwebp from Homebrew's webp package instead. Android keeps
using FFmpeg.

* [build-tools] Add changelog entry for macOS session preview encoding

* [build-tools] Format the changelog entry
2026-10-06 18:14:16 +02:00
Szymon Dziedzic 01c1ffcaf4 [build-tools] Fix the session preview device reference on main (#4548)
#4398 was based on #4499, which renamed device to readyDevice in
startDeviceSessionHostAsync. #4540 reverted #4499 before #4398 merged, so
main referred to a readyDevice name that no longer exists and the
build-tools typecheck failed. Use device instead.
2026-10-06 14:10:28 +02:00
Szymon Dziedzic b10cc829bd [build-tools] Refresh simulator session preview artifacts (#4398)
Capture a WebP thumbnail of the session device every minute while the
session host runs, and upload it to one reusable preview artifact. Upload
request errors no longer include the signed upload URL in logs.
2026-10-06 13:54:42 +02:00
SJ Kim 3d8ed4c81f [eas-cli] Log client request events for simulator sessions (#4483)
* [eas-cli] Log client request events for simulator sessions (ENG-27138)

* [eas-cli] Flush analytics and exit with 130 when Ctrl+C cancels a simulator session request (ENG-27138)

* [eas-cli] Add changelog entry for simulator request events (ENG-27138)

* [eas-cli] Bound the Ctrl+C analytics flush and tighten its tests (ENG-27138)

* [eas-cli] Don't count an HTTP error answer as a failed simulator session request (ENG-27138)

* [eas-cli] Keep the simulator request analytics wrapper with its only caller (ENG-27138)
2026-10-05 23:10:34 -04:00
Expo CI a344c0e1e8 bump STAGING and PRODUCTION to 24.11.0 in cli-versions.json 2026-10-05 22:05:17 +00:00
Expo CI f80f548e2f update CHANGELOG.md for v24.11.0 2026-10-05 22:03:02 +00:00
Expo CI c72ae9f9f6 v24.11.0 2026-10-05 22:00:12 +00:00
SJ Kim bf0420cd29 [eas-cli] Send the request origin and coding agent when creating a simulator session (#4523)
* [eas-cli] Regenerate GraphQL types (ENG-27135)

* [eas-cli] Send the request origin when eas simulator creates a session (ENG-26853)

* [eas-cli] Send the coding agent when eas simulator creates a session (ENG-27135)

* [eas-cli] Add the PR number to the changelog entry (ENG-27135)

* [eas-cli] Leave out the coding agent when analytics are disabled (ENG-27135)
2026-10-05 13:49:07 -04:00
Szymon Dziedzic 738ccb1ff6 Revert "[build-tools] Confine agent-device sessions to the session device (#4…" (#4540)
This reverts commit c487bcf1df.
2026-10-05 19:03:12 +02:00
Szymon DziedzicandClaude Opus 5.5 c487bcf1df [build-tools] Confine agent-device sessions to the session device (#4499)
* [build-tools] Confine agent-device sessions to the session device

Launch the agent-device daemon with a daemon policy
(AGENT_DEVICE_DAEMON_POLICY, agent-device 0.21.17 and later) that allows
only the booted device and denies booting and shutting down devices.

agent-device still installs during the boot. The daemon launches when
the boot reports the device. The policy file is removed when the daemon
stops or fails to launch. A daemon without `policyDigest` in daemon.json
does not enforce the policy, so the step logs a warning.

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

* [build-tools] Serve the booted Simulator in agent-device sessions

Pass the booted UDID to serve-sim, so the web preview shows the device
that the daemon policy allows. Without it, serve-sim picks a simulator
itself and can show another booted one. The session host fails when
serve-sim reports another device.

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

* [build-tools] Launch the agent-device daemon during the iOS Simulator boot

Look up the Simulator UDID before the boot and boot that UDID. The
daemon policy needs only the UDID, so on iOS the daemon launches during
the boot again, as before the policy. On Android the emulator serial is
known only after the boot, so the daemon still launches after it.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 16:25:51 +02:00
Szymon DziedzicandClaude Opus 5.5 bcaaaddab8 [build-tools] Report the web preview as webPreviewUrl and webPreviewToken (#4467)
* [build-tools] Report the web preview as webPreviewUrl and webPreviewToken

Also keep the legacy previewUrl and previewToken keys, because SERVE_SIM
sessions still read them.

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

* [build-tools] Add changelog entry

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-05 16:23:24 +02:00
Szymon DziedzicandClaude Opus 5.5 4e432fa537 [build-tools] Upload the agent-device screenshot display rotation as artifact metadata (#4508)
* [build-tools] Upload the agent-device screenshot display rotation as artifact metadata

agent-device reports displayRotation on screenshot entries in its /artifacts
inventory. Read it and pass it to the device run session artifact upload as
metadata.displayRotation. An unknown value is dropped so it cannot block the
upload, and an artifact without the field uploads no metadata, as before.

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

* [changelog] Add entry for #4508

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 15:49:47 +02:00
Szymon DziedzicandClaude Opus 5.5 817d06e66e [build-tools] Remove the eas/start_agent_device_remote_session step (#4518)
* [build-tools] Remove the eas/start_agent_device_remote_session step

The server now submits the single `eas/start_agent_device_session` step
for every agent-device session (expo/universe#31785), so remove the old
`eas/start_agent_device_remote_session` step.

- `runAgentDeviceRemoteSessionAsync` and the daemon helpers stay; the
  single step uses them. Its `launch` option goes away: only the old step
  passed serve-sim launch inputs, and the single step launches the app
  with simctl / adb.
- The tests that ran the shared session code through the old step now
  call `runAgentDeviceRemoteSessionAsync`, or run the single step with a
  mocked boot.

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

* [build-tools] Add changelog entry

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 15:19:54 +02:00
Stanisław Chmiela 76a9d17ea7 [build-tools] Diagnose TestFlight group assignment failures (#4521)
## Why

Group assignment can fail after upload processing completes because the build needs export compliance information. The error should identify the Apple state and explain how to resolve it on the already-uploaded build.

Example: https://expo.dev/uuid/01a0f7f6-42df-7673-af23-da067af13847

## How

After any failed group assignment, call `verifyInternalBuildStateValidForGroupAssignmentAsync` to fetch `buildBetaDetail`, log the internal TestFlight state, and capture a Sentry event. Cache its promise so subsequent failures do not repeat the lookup, log, or capture. The function throws a specific error for missing or pending export compliance. Otherwise it returns, and each group retains its own assignment error and the existing internal-group guidance.

Capture all internal states before throwing. Include the state in the Sentry message title so states have separate message groups, with the build ID and original assignment error as context. Use UNKNOWN when the diagnostic lookup fails.

Continue trying other groups before failing the job. Successful assignments do not fetch the state. There is no polling loop.

## Test Plan

- All 32 metadata tests passed, including two failed assignments followed by a successful assignment with one shared lookup, log, and capture. Export-compliance failures share the rejection; other failures retain each group's original error.
- Build-tools typecheck, repository lint, formatting, and diff checks passed.
- No new live Apple test for this change.
2026-10-05 11:36:22 +02:00
Hirbod 8ce87bfa56 [build-tools] allow expo.test subdomains for simulator previews (#4480) 2026-10-03 14:31:52 +02:00
2b7e8454e3 [build-tools] Replace record-sim with serve-sim recording (#4474)
* [build-tools] Replace record-sim with serve-sim recording

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

* [build-tools] Avoid failure warnings for saved iOS recordings
2026-10-02 15:33:19 -07:00
Stanisław Chmiela 962a2a733e [build-tools] Retry temporary Apple request failures safely (#4520)
* [build-tools] retry temporary Apple request failures safely

* [build-tools] distinguish complete assets from uploaded chunks

* [build-tools] document Apple request recovery

* [build-tools] use connection retry policy and isolate commit recovery

* [build-tools] simplify upload commit recovery and limit reset retries to GET

* [build-tools] accept object-only retry options

* [build-tools] define simple explicit retry options

* [build-tools] adapt commit tests to the key-based ASC client

* [build-tools] keep Apple retry changes in the unreleased changelog

* [build-tools] recover uncertain UPLOAD_COMPLETE commits

* [build-tools] require upload commit logger

* [build-tools] retry uncertain upload commits through the outer retry

* [build-tools] check upload state after the final uncertain commit

* [build-tools] use try catch and classify pending commits as system errors

* [build-tools] limit upload recovery to interrupted connections
2026-10-02 21:40:18 +00:00
Eliot Hertenstein 9e13c1e820 [eas-cli] Add resumable device login (#4502) 2026-10-02 14:37:02 -07:00
Expo CI cadb7b2d1e bump STAGING and PRODUCTION to 24.10.0 in cli-versions.json 2026-10-02 20:58:58 +00:00
Expo CI 718c82d8e1 update CHANGELOG.md for v24.10.0 2026-10-02 20:55:55 +00:00
Expo CI 5d31ecc2bc v24.10.0 2026-10-02 20:53:41 +00:00
Gabe DebesandClaude Opus 5.5 890286a08d [eas-cli] add --network-capture to eas simulator (#4308)
* [eas-cli] add network capture flags to eas simulator

* [eas-cli] regenerate GraphQL for network capture inputs

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

* [eas-cli] move the network capture changelog entry to main

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 13:31:43 -07:00
Expo CI 33ff59a75e bump STAGING and PRODUCTION to 24.9.0 in cli-versions.json 2026-10-02 17:41:52 +00:00
Expo CI c2bbba6e78 update CHANGELOG.md for v24.9.0 2026-10-02 17:35:45 +00:00
99 changed files with 6372 additions and 2502 deletions

No files matched your search

@@ -0,0 +1,43 @@
name: Test recording recovery
on:
push:
branches: [main]
paths: &recording-paths
- packages/build-tools/src/steps/utils/deviceRunSessionScreenRecordings.ts
- packages/build-tools/src/steps/utils/deviceRunSessionArtifacts.ts
- packages/build-tools/src/steps/utils/__tests__/*.test.ffmpeg.ts
- packages/build-tools/jest/ffmpeg-config.ts
- .github/workflows/test-recording-recovery.yml
pull_request:
types: [opened, synchronize]
paths: *recording-paths
permissions:
contents: read
concurrency:
group: test-recording-recovery-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
name: Test recording recovery with ffmpeg
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
with:
persist-credentials: false
- name: Setup tools
uses: ./.github/actions/setup-mise
- name: Install ffmpeg
# The recording recovery test cuts and decodes real MP4 files with ffmpeg and ffprobe.
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
- name: Install dependencies
run: yarn install --immutable
- name: Build build-tools dependencies
# tsc emits the dist files that the test imports from workspace packages such as @expo/logger.
run: yarn run -T lerna run typecheck --scope @expo/build-tools --include-dependencies
- name: Run recording recovery test
working-directory: packages/build-tools
run: yarn jest-ffmpeg
-3
View File
@@ -26,9 +26,6 @@ jobs:
uses: ./.github/actions/setup-mise
env:
MISE_NODE_VERSION: ${{ matrix.node }}
- name: Install ffmpeg
# build-tools recording-recovery tests cut and decode real MP4 files with ffmpeg and ffprobe.
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
- name: Install dependencies
run: yarn install --immutable
env:
+46 -2
View File
@@ -8,6 +8,50 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Fix `eas workflow:ssh` handling of missing builds IDs. ([#4529](https://github.com/expo/eas-cli/pull/4529) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Upload the embedded bundle from custom builds that use `eas/build`. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add the `eas/upload_embedded_bundle` step for custom build configs. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Include `assetMapUrl` and `sourceMapUrl` in the `eas update:view --json` output. Each value is a signed download URL, or null when the update was published without that file. ([#4534](https://github.com/expo/eas-cli/pull/4534) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [build-tools] Improve the embedded bundle upload output in build logs. ([#4557](https://github.com/expo/eas-cli/pull/4557) by [@gwdp](https://github.com/gwdp))
- [build-tools] Allow the unnotarized mitmproxy cask to run in iOS network capture sessions. ([#4556](https://github.com/expo/eas-cli/pull/4556) by [@gwdp](https://github.com/gwdp))
- [build-tools] Encode macOS simulator session previews with cwebp, because Homebrew's FFmpeg cannot encode WebP. ([#4549](https://github.com/expo/eas-cli/pull/4549) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
## [24.11.0](https://github.com/expo/eas-cli/releases/tag/v24.11.0) - 2026-10-05
### 🎉 New features
- [build-tools] Record iOS Simulator sessions through serve-sim. ([#4474](https://github.com/expo/eas-cli/pull/4474) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add browser device login with resumable commands for chat agents. ([#4502](https://github.com/expo/eas-cli/pull/4502) by [@eiiot](https://github.com/eiiot))
- [build-tools] Upload the display rotation agent-device reports for a screenshot as device run session artifact metadata. ([#4508](https://github.com/expo/eas-cli/pull/4508) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Refresh a WebP thumbnail of the simulator session screen every minute as a reusable session preview artifact. ([#4398](https://github.com/expo/eas-cli/pull/4398) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [eas-cli] Select the containing iOS app when its archive also includes a nested App Clip. ([#4541](https://github.com/expo/eas-cli/pull/4541) by [@mozzius](https://github.com/mozzius))
- [build-tools] Allow expo.test subdomains to open local and staging simulator previews. ([#4480](https://github.com/expo/eas-cli/pull/4480) by [@hirbod](https://github.com/hirbod))
- [build-tools] Retry App Store Connect connection failures and safely recover interrupted status reads and upload commits. ([#4520](https://github.com/expo/eas-cli/pull/4520) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- [eas-cli] Log analytics events when an `eas simulator` session request is sent, cancelled, or fails before the server answers. ([#4483](https://github.com/expo/eas-cli/pull/4483) by [@sjkim-expo](https://github.com/sjkim-expo))
- [build-tools] Remove the `eas/start_agent_device_remote_session` step; agent-device sessions use `eas/start_agent_device_session`. ([#4518](https://github.com/expo/eas-cli/pull/4518) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Report `eas-cli` as the request origin, and the coding agent when one is detected, when `eas simulator` creates a session, for analytics. ([#4523](https://github.com/expo/eas-cli/pull/4523) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.10.0](https://github.com/expo/eas-cli/releases/tag/v24.10.0) - 2026-10-02
### 🎉 New features
- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device, and `--network-capture-field` to also keep headers, query values, or request and response bodies. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp))
## [24.9.0](https://github.com/expo/eas-cli/releases/tag/v24.9.0) - 2026-10-02
### 🎉 New features
- [eas-cli] Add `--force-end-active-rollout` to `eas update`, `eas update:republish`, `eas update:roll-back-to-embedded` and `eas update:rollback`. ([#4233](https://github.com/expo/eas-cli/pull/4233) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Allow `eas simulator:start --platform android --egress local`. On Android, `--egress-allow localhost:<port>` also covers `10.0.2.2:<port>`, and refused destinations are shown as warnings. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Support `eas/start_local_egress` on Linux for Android emulators: an always-listening relay for the emulator's proxy, a systemd scope and nftables fence that refuse traffic that cannot use the tunnel, and fence refusals in the session log. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
@@ -32,14 +76,13 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Correct the `eas simulator:start --max-duration-minutes` help text: every plan can set a custom duration up to the plan's maximum, which is also the default. ([#4524](https://github.com/expo/eas-cli/pull/4524) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Fix `eas workflow:validate` crashing with certain workflow schemas, and exit with a non-zero code when validation fails. ([#4507](https://github.com/expo/eas-cli/pull/4507) by [@yuvalgnessin-qz](https://github.com/yuvalgnessin-qz))
- [build-tools] Reuse App Store Connect tokens and refresh them before expiry so long uploads and processing waits remain authenticated. ([#4497](https://github.com/expo/eas-cli/pull/4497) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain TestFlight group assignment failures with the Apple build state and report unexpected readiness failures. ([#4521](https://github.com/expo/eas-cli/pull/4521) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Clear old Maestro CLI HTML reports before each test attempt so retries cannot upload an earlier report. ([#4409](https://github.com/expo/eas-cli/pull/4409) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Poll the web preview readiness every 250 ms instead of every second, so a session goes live sooner after its host is ready. ([#4496](https://github.com/expo/eas-cli/pull/4496) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Keep the `eas simulator` session spinner on one line, so resizing the terminal no longer leaves copies of it behind. The `eas simulator:stop` command is now printed once before the spinner starts. ([#4488](https://github.com/expo/eas-cli/pull/4488) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Disable agent-device's internal idle timeouts in remote sessions, so only the session's own idle and duration limits end it. ([#4485](https://github.com/expo/eas-cli/pull/4485) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Keep `eas simulator:events --follow` running while a session is queued or starting. ([#4506](https://github.com/expo/eas-cli/pull/4506) by [@sjkim-expo](https://github.com/sjkim-expo))
### 🧹 Chores
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
### 🎉 New features
@@ -69,6 +112,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#4400](https://github.com/expo/eas-cli/pull/4400) by [@wschurman](https://github.com/wschurman))
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
- [build-tools] Report the web preview as `webPreviewUrl` and `webPreviewToken` in addition to the legacy serve-sim keys. ([#4467](https://github.com/expo/eas-cli/pull/4467) by [@szdziedzic](https://github.com/szdziedzic))
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.8.0",
"PRODUCTION": "24.8.0"
"STAGING": "24.11.0",
"PRODUCTION": "24.11.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.9.0",
"version": "24.12.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
-1
View File
@@ -1,6 +1,5 @@
node_modules/
dist/
bin/record-sim
.secrets*
!.secrets.example
@@ -0,0 +1,13 @@
import type { Config } from '@jest/types';
import baseConfig from '../../../jest/jest.shared.config';
const config: Config.InitialOptions = {
...baseConfig,
rootDir: '../src',
testMatch: ['**/__tests__/*.test.ffmpeg.ts'],
clearMocks: true,
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
};
export default config;
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.9.0",
"version": "24.12.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -23,7 +23,6 @@
"watch": "tsc --watch --preserveWatchOutput",
"prebuild": "yarn gql",
"build": "tsc",
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
"build:egress-guard": "resources/egress-guard/build.sh",
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
"typecheck": "tsc",
@@ -31,6 +30,7 @@
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
"jest-ffmpeg": "jest --config jest/ffmpeg-config.ts",
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
@@ -1,4 +0,0 @@
.build/
.swiftpm/
DerivedData/
.DS_Store
@@ -1,28 +0,0 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "record-sim",
platforms: [
.macOS(.v14),
],
products: [
.library(name: "RecordSim", targets: ["RecordSim"]),
.executable(name: "record-sim", targets: ["record-sim"]),
],
targets: [
.target(
name: "RecordSim",
linkerSettings: [
.linkedFramework("AVFoundation"),
.linkedFramework("CoreGraphics"),
.linkedFramework("CoreMedia"),
.linkedFramework("CoreVideo"),
.linkedFramework("IOSurface"),
.linkedFramework("UniformTypeIdentifiers"),
]
),
.executableTarget(name: "record-sim", dependencies: ["RecordSim"]),
]
)
@@ -1,79 +0,0 @@
# RecordSim
Small Swift library for non-exclusive iOS Simulator screen recording.
It implements one recording path:
1. Listen to SimRenderServer framebuffer callbacks.
2. Copy the live `IOSurface` immediately into an owned `CVPixelBuffer`.
3. Feed one continuous `AVAssetWriter`.
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
The output is suitable for uploading while recording. `session.json` contains
the ordered segment metadata needed to build a playlist later.
## Output
```text
session/
init.mp4
session.json
segments/
segment-000000.m4s
segment-000001.m4s
```
`init.mp4` is the fMP4 initialization segment. Individual `.m4s` files are not
standalone MP4 files. `session.json` stores the wall-clock timestamp for the
first video frame plus the metadata needed to build a playlist: HLS version,
target duration, media sequence, `initSegment`, and the ordered `segments` array
with file path and duration for each media segment.
## CLI
```bash
swift run record-sim \
--udid <BOOTED_SIMULATOR_UDID> \
--output /tmp/sim-recording \
--segment-duration 120 \
--fps 30 \
--bitrate 30000000 \
--codec h264
```
For sparse 2h sessions, `--segment-duration 120` is a reasonable default: about
60 media objects plus the init segment. The recorder appends a single duplicate
hold frame near each segment boundary when the simulator is idle, so long idle
periods still produce uploadable segments without encoding idle frames at 60fps.
Use `--segment-duration 0` to write one continuous `recording.mp4` instead of
`init.mp4` plus media segments.
Frame timestamps use monotonic host time, not wall-clock time. The recorder also
probes the framebuffer seed once per second; if the surface changes without
callbacks for 5 seconds, it captures that frame and rewires the private callback
registration.
## Library
```swift
let recorder = SimulatorRecorder(
configuration: SimulatorRecordingConfiguration(
deviceUDID: udid,
outputDirectory: sessionDirectory,
segmentDuration: 120
)
)
recorder.onSegment = { segment in
// Enqueue upload of segment.url.
}
try recorder.start()
try recorder.waitUntilFirstFrame()
// ...
let manifest = try recorder.stop()
```
`session.json` contains the precise wall-clock time for video PTS zero. In
segmented mode it also contains the init segment and ordered media segment
file/duration entries; in single-file mode it points at `recording.mp4`.
@@ -1,192 +0,0 @@
import Foundation
import IOSurface
import ObjectiveC
final class FramebufferDisplaySource {
struct SurfaceSnapshot {
let surface: IOSurface
let width: Int
let height: Int
let seed: UInt32
}
private let deviceUDID: String
private let callbackQueue: DispatchQueue
private let onFrame: () -> Void
private let onSurfaceChange: () -> Void
private var descriptors: [NSObject] = []
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
private var retainedBlocks: [AnyObject] = []
private var ioClient: NSObject?
init(
deviceUDID: String,
callbackQueue: DispatchQueue,
onFrame: @escaping () -> Void,
onSurfaceChange: @escaping () -> Void
) {
self.deviceUDID = deviceUDID
self.callbackQueue = callbackQueue
self.onFrame = onFrame
self.onSurfaceChange = onSurfaceChange
}
func start() throws {
try PrivateSimulatorFrameworks.load()
guard let device = SimulatorDeviceLookup.find(udid: deviceUDID) else {
throw RecorderError.make(1, "Simulator \(deviceUDID) not found")
}
let state = device.value(forKey: "stateString") as? String ?? "unknown"
guard state == "Booted" else {
throw RecorderError.make(2, "Simulator \(deviceUDID) is not booted (state: \(state))")
}
guard
let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject
else {
throw RecorderError.make(3, "Failed to get simulator IO client")
}
ioClient = io
try wireUpFramebuffer()
}
func stop() {
unregisterCallbacks()
descriptors.removeAll()
retainedBlocks.removeAll()
ioClient = nil
}
func surfaceSnapshot() -> SurfaceSnapshot? {
let surfaceSelector = NSSelectorFromString("framebufferSurface")
var bestSnapshot: SurfaceSnapshot?
var bestArea = 0
for descriptor in descriptors {
guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue()
else {
continue
}
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
let width = IOSurfaceGetWidth(surface)
let height = IOSurfaceGetHeight(surface)
let area = width * height
if area > bestArea {
bestSnapshot = SurfaceSnapshot(
surface: surface,
width: width,
height: height,
seed: IOSurfaceGetSeed(surface)
)
bestArea = area
}
}
return bestSnapshot
}
func rewireFramebuffer() throws {
try wireUpFramebuffer()
}
private func wireUpFramebuffer() throws {
guard let io = ioClient else {
throw RecorderError.make(3, "No simulator IO client")
}
io.perform(NSSelectorFromString("updateIOPorts"))
let nextDescriptors = try findFramebufferDescriptors(io: io)
unregisterCallbacks()
descriptors = nextDescriptors
retainedBlocks.removeAll()
do {
for descriptor in descriptors {
try registerCallbacks(descriptor: descriptor)
}
} catch {
unregisterCallbacks()
descriptors.removeAll()
retainedBlocks.removeAll()
throw error
}
}
private func findFramebufferDescriptors(io: NSObject) throws -> [NSObject] {
guard let ports = io.value(forKey: "deviceIOPorts") as? [NSObject] else {
throw RecorderError.make(4, "Failed to read simulator IO ports")
}
let portIdentifierSelector = NSSelectorFromString("portIdentifier")
let descriptorSelector = NSSelectorFromString("descriptor")
let surfaceSelector = NSSelectorFromString("framebufferSurface")
var candidates: [NSObject] = []
for port in ports {
guard port.responds(to: portIdentifierSelector),
let portIdentifier = port.perform(portIdentifierSelector)?.takeUnretainedValue(),
"\(portIdentifier)" == "com.apple.framebuffer.display",
port.responds(to: descriptorSelector),
let descriptor = port.perform(descriptorSelector)?.takeUnretainedValue()
as? NSObject,
descriptor.responds(to: surfaceSelector)
else {
continue
}
candidates.append(descriptor)
}
if candidates.isEmpty {
throw RecorderError.make(5, "No framebuffer display descriptor found")
}
return candidates
}
private func registerCallbacks(descriptor: NSObject) throws {
let selector = NSSelectorFromString(
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
)
guard descriptor.responds(to: selector) else {
throw RecorderError.make(6, "Framebuffer descriptor does not support screen callbacks")
}
guard let msgSendPointer = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "objc_msgSend")
else {
throw RecorderError.make(7, "objc_msgSend not found")
}
typealias MsgSend =
@convention(c) (
AnyObject, Selector, AnyObject, AnyObject, AnyObject, AnyObject, AnyObject
) -> Void
let msgSend = unsafeBitCast(msgSendPointer, to: MsgSend.self)
let uuid = NSUUID()
callbackUUIDs[ObjectIdentifier(descriptor)] = uuid
let frameCallback: @convention(block) () -> Void = { [weak self] in
self?.onFrame()
}
let surfacesCallback: @convention(block) (AnyObject?, AnyObject?) -> Void = {
[weak self] _, _ in
self?.onSurfaceChange()
}
let propertiesCallback: @convention(block) () -> Void = {}
retainedBlocks.append(frameCallback as AnyObject)
retainedBlocks.append(surfacesCallback as AnyObject)
retainedBlocks.append(propertiesCallback as AnyObject)
msgSend(
descriptor,
selector,
uuid,
callbackQueue as AnyObject,
frameCallback as AnyObject,
surfacesCallback as AnyObject,
propertiesCallback as AnyObject
)
}
private func unregisterCallbacks() {
let selector = NSSelectorFromString("unregisterScreenCallbacksWithUUID:")
for descriptor in descriptors {
if let uuid = callbackUUIDs[ObjectIdentifier(descriptor)],
descriptor.responds(to: selector)
{
descriptor.perform(selector, with: uuid)
}
}
callbackUUIDs.removeAll()
}
}
@@ -1,96 +0,0 @@
import Foundation
import ObjectiveC
enum XcodeDeveloperDirectory {
static let current: String = {
if let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"],
!developerDir.isEmpty
{
return developerDir
}
let fallback = "/Applications/Xcode.app/Contents/Developer"
let pipe = Pipe()
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
process.arguments = ["-p"]
process.standardOutput = pipe
do {
try process.run()
} catch {
return fallback
}
process.waitUntilExit()
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback
}()
}
enum PrivateSimulatorFrameworks {
static func load() throws {
try loadResult.get()
}
private static let loadResult: Result<Void, Error> = {
let developerDir = XcodeDeveloperDirectory.current
let candidates = [
"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
"\(developerDir)/Library/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
"\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit",
"\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit",
]
var failures: [String] = []
for path in candidates {
if dlopen(path, RTLD_NOW) == nil {
let message = dlerror().map { String(cString: $0) } ?? "unknown error"
failures.append("\(path): \(message)")
}
}
guard NSClassFromString("SimServiceContext") != nil else {
let tried = candidates.joined(separator: ", ")
let errors = failures.isEmpty ? "no dlopen errors" : failures.joined(separator: "; ")
return .failure(
RecorderError.make(
8,
"Failed to load CoreSimulator/SimulatorKit (tried: \(tried); errors: \(errors))"
)
)
}
return .success(())
}()
}
enum SimulatorDeviceLookup {
private static let context: NSObject? = {
guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type else {
return nil
}
let sharedSelector = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:")
return contextClass.perform(
sharedSelector, with: XcodeDeveloperDirectory.current, with: nil)?
.takeUnretainedValue() as? NSObject
}()
static func find(udid: String) -> NSObject? {
guard let context else {
return nil
}
let deviceSetSelector = NSSelectorFromString("defaultDeviceSetWithError:")
guard
let deviceSet = context.perform(deviceSetSelector, with: nil)?
.takeUnretainedValue() as? NSObject
else {
return nil
}
guard let devices = deviceSet.value(forKey: "devices") as? [NSObject] else {
return nil
}
return devices.first {
($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid
}
}
static func stateString(udid: String) -> String? {
find(udid: udid)?.value(forKey: "stateString") as? String
}
}
@@ -1,77 +0,0 @@
import Foundation
public enum RecorderCodec: String, Codable, Sendable {
case h264
case hevc
}
public struct SimulatorRecordingConfiguration: Sendable {
public var deviceUDID: String
public var outputDirectory: URL
public var fps: Int
public var bitrate: Int
public var codec: RecorderCodec
public var segmentDuration: TimeInterval
public var maxPendingFrames: Int
public var overwrite: Bool
public init(
deviceUDID: String,
outputDirectory: URL,
fps: Int = 30,
bitrate: Int = 30_000_000,
codec: RecorderCodec = .h264,
segmentDuration: TimeInterval = 120,
maxPendingFrames: Int = 8,
overwrite: Bool = true
) {
self.deviceUDID = deviceUDID
self.outputDirectory = outputDirectory
self.fps = fps
self.bitrate = bitrate
self.codec = codec
self.segmentDuration = segmentDuration
self.maxPendingFrames = maxPendingFrames
self.overwrite = overwrite
}
}
public enum SegmentKind: String, Codable, Sendable {
case initialization
case media
}
public struct SegmentOutput: Sendable {
public let kind: SegmentKind
public let relativePath: String
public let url: URL
public let byteCount: Int
public let durationSeconds: Double?
}
public struct MediaSegmentRecord: Codable, Sendable {
public let file: String
public let durationSeconds: Double
}
public struct FirstFrameWallClock: Codable, Sendable {
public let unixMs: Int64
public let iso8601: String
}
public enum RecordingFinalizationStage: Sendable {
case captureStopped
case videoSaved
}
public struct RecordingManifest: Codable, Sendable {
public let firstFrameWallClock: FirstFrameWallClock
public let width: Int
public let height: Int
public let hlsVersion: Int?
public let hlsTargetDurationSeconds: Int?
public let hlsMediaSequence: Int?
public let recording: String?
public let initSegment: String?
public let segments: [MediaSegmentRecord]
}
@@ -1,140 +0,0 @@
import AVFoundation
import Foundation
final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
private let rootDirectory: URL
private let segmentsDirectory: URL
let singleRecordingURL: URL
private let stateQueue = DispatchQueue(label: "record-sim.segment-writer")
private let notificationQueue = DispatchQueue(label: "record-sim.segment-notifications")
private let onSegment: ((SegmentOutput) -> Void)?
private var initSegmentPath: String?
private var segments: [MediaSegmentRecord] = []
private var nextMediaSegmentIndex = 0
private var firstError: Error?
init(rootDirectory: URL, onSegment: ((SegmentOutput) -> Void)?) {
self.rootDirectory = rootDirectory
self.segmentsDirectory = rootDirectory.appendingPathComponent("segments", isDirectory: true)
self.singleRecordingURL = rootDirectory.appendingPathComponent("recording.mp4")
self.onSegment = onSegment
super.init()
}
func prepare(overwrite: Bool, segmented: Bool) throws {
if overwrite {
try? FileManager.default.removeItem(at: rootDirectory)
}
try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
if segmented {
try FileManager.default.createDirectory(at: segmentsDirectory, withIntermediateDirectories: true)
}
}
var error: Error? {
stateQueue.sync { firstError }
}
func assetWriter(
_ writer: AVAssetWriter,
didOutputSegmentData segmentData: Data,
segmentType: AVAssetSegmentType,
segmentReport: AVAssetSegmentReport?
) {
var notification: SegmentOutput?
stateQueue.sync {
if firstError != nil {
return
}
do {
switch segmentType {
case .initialization:
let relativePath = "init.mp4"
let url = rootDirectory.appendingPathComponent(relativePath)
try segmentData.write(to: url, options: .atomic)
initSegmentPath = relativePath
notification = SegmentOutput(
kind: .initialization,
relativePath: relativePath,
url: url,
byteCount: segmentData.count,
durationSeconds: nil
)
case .separable:
let index = nextMediaSegmentIndex
let relativePath = String(format: "segments/segment-%06d.m4s", index)
let url = rootDirectory.appendingPathComponent(relativePath)
try segmentData.write(to: url, options: .atomic)
let videoReport = segmentReport?.trackReports.first(where: { $0.mediaType == .video })
let duration = videoReport.flatMap { timeSeconds($0.duration) } ?? 0
let record = MediaSegmentRecord(
file: relativePath,
durationSeconds: duration
)
nextMediaSegmentIndex += 1
segments.append(record)
notification = SegmentOutput(
kind: .media,
relativePath: relativePath,
url: url,
byteCount: segmentData.count,
durationSeconds: duration
)
default:
break
}
} catch {
firstError = error
}
}
if let notification, let onSegment {
notificationQueue.async {
onSegment(notification)
}
}
}
@discardableResult
func writeManifest(
configuration: SimulatorRecordingConfiguration,
firstFrameWallClock: Date,
width: Int,
height: Int
) throws -> RecordingManifest {
try stateQueue.sync {
if let firstError {
throw firstError
}
let segmented = configuration.segmentDuration > 0
if segmented, initSegmentPath == nil {
throw RecorderError.make(50, "Missing initialization segment")
}
if segmented, segments.isEmpty {
throw RecorderError.make(51, "No media segments were written")
}
let targetDuration = segmented
? max(1, Int(ceil(segments.map(\.durationSeconds).max() ?? configuration.segmentDuration)))
: nil
let manifest = RecordingManifest(
firstFrameWallClock: FirstFrameWallClock(
unixMs: unixMs(firstFrameWallClock),
iso8601: iso8601(firstFrameWallClock)
),
width: width,
height: height,
hlsVersion: segmented ? 7 : nil,
hlsTargetDurationSeconds: targetDuration,
hlsMediaSequence: segmented ? 0 : nil,
recording: segmented ? nil : "recording.mp4",
initSegment: segmented ? initSegmentPath : nil,
segments: segments
)
let data = try JSONEncoder.prettySorted.encode(manifest)
try data.write(to: rootDirectory.appendingPathComponent("session.json"), options: .atomic)
return manifest
}
}
}
@@ -1,644 +0,0 @@
import AVFoundation
import CoreVideo
import Foundation
import IOSurface
import UniformTypeIdentifiers
public final class SimulatorRecorder {
public var onSegment: ((SegmentOutput) -> Void)?
public var onSimulatorStopped: ((String) -> Void)?
public var onFinalizationStage: ((RecordingFinalizationStage) -> Void)?
private static let callbackStalenessTimeout: TimeInterval = 5
private static let firstFrameRewireInterval: TimeInterval = 1
private let configuration: SimulatorRecordingConfiguration
private let callbackQueue = DispatchQueue(
label: "record-sim.frame-callbacks", qos: .userInteractive)
private let writerQueue = DispatchQueue(label: "record-sim.asset-writer", qos: .userInitiated)
private let eventQueue = DispatchQueue(label: "record-sim.events", qos: .utility)
private let pendingLock = NSLock()
private var pendingFrames = 0
private var displaySource: FramebufferDisplaySource?
private var outputWriter: RecordingOutputWriter?
private var writer: AVAssetWriter?
private var input: AVAssetWriterInput?
private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
private var boundaryTimer: DispatchSourceTimer?
private var monotonicClock = MonotonicClock()
private var firstAcceptedCaptureTime: CMTime?
private var firstAcceptedWallClock: Date?
private var recordingWidth: Int?
private var recordingHeight: Int?
private var lastPTS: CMTime?
private var lastSeed: UInt32?
private var lastFrameCallbackElapsed: TimeInterval?
private var lastFirstFrameRewireElapsed: TimeInterval = 0
private var lastAppendedPixelBuffer: CVPixelBuffer?
private var nextBoundaryElapsed: TimeInterval = 0
private var simulatorStoppedReason: String?
private var stopped = false
private var firstFrameReady = false
private let firstFrameSemaphore = DispatchSemaphore(value: 0)
private var firstError: Error?
public init(configuration: SimulatorRecordingConfiguration) {
self.configuration = configuration
}
public func start() throws {
try validateConfiguration()
let outputWriter = RecordingOutputWriter(
rootDirectory: configuration.outputDirectory,
onSegment: { [weak self] segment in
self?.onSegment?(segment)
})
try outputWriter.prepare(
overwrite: configuration.overwrite, segmented: configuration.segmentDuration > 0)
self.outputWriter = outputWriter
monotonicClock = MonotonicClock()
nextBoundaryElapsed = configuration.segmentDuration
lastFirstFrameRewireElapsed = 0
stopped = false
let source = FramebufferDisplaySource(
deviceUDID: configuration.deviceUDID,
callbackQueue: callbackQueue,
onFrame: { [weak self] in
self?.captureFrame(force: false, reason: .callback)
},
onSurfaceChange: { [weak self] in
self?.captureFrame(force: true, reason: .surfaceChange)
}
)
try source.start()
displaySource = source
startBoundaryTimer()
}
public func waitUntilFirstFrame(timeoutSeconds: TimeInterval = 15) throws {
let alreadyReady = try writerQueue.sync {
if let firstError {
throw firstError
}
return firstFrameReady
}
if alreadyReady {
return
}
if firstFrameSemaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
throw RecorderError.make(20, "Timed out waiting for first frame")
}
try writerQueue.sync {
if let firstError {
throw firstError
}
if !firstFrameReady {
throw RecorderError.make(21, "Recorder stopped before first frame")
}
}
}
public func simulatorStopReason() -> String? {
callbackQueue.sync {
simulatorStoppedReason
}
}
@discardableResult
public func stop() throws -> RecordingManifest {
callbackQueue.sync {
stopped = true
boundaryTimer?.cancel()
boundaryTimer = nil
displaySource?.stop()
displaySource = nil
}
onFinalizationStage?(.captureStopped)
return try writerQueue.sync {
if let firstError {
throw firstError
}
guard let writer, let input, let outputWriter else {
let error = RecorderError.make(22, "No frames were captured")
firstError = error
signalFirstFrameReadyIfNeeded()
throw error
}
try appendTailFrameIfNeeded(finalPTS: monotonicClock.elapsedTime())
input.markAsFinished()
let semaphore = DispatchSemaphore(value: 0)
writer.finishWriting {
semaphore.signal()
}
if semaphore.wait(timeout: .now() + 60) == .timedOut {
throw RecorderError.make(23, "Timed out finishing AVAssetWriter")
}
if writer.status == .failed {
throw writer.error ?? RecorderError.make(24, "AVAssetWriter failed")
}
if let error = outputWriter.error {
throw error
}
onFinalizationStage?(.videoSaved)
guard let firstAcceptedWallClock else {
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
}
guard let recordingWidth, let recordingHeight else {
throw RecorderError.make(27, "Missing recording dimensions")
}
return try outputWriter.writeManifest(
configuration: configuration,
firstFrameWallClock: firstAcceptedWallClock,
width: recordingWidth,
height: recordingHeight
)
}
}
private enum CaptureReason {
case callback
case surfaceChange
case boundary
case healthProbe
}
private func validateConfiguration() throws {
guard !configuration.deviceUDID.isEmpty else {
throw RecorderError.make(30, "deviceUDID must not be empty")
}
guard configuration.fps > 0, configuration.fps <= 120 else {
throw RecorderError.make(31, "fps must be between 1 and 120")
}
guard configuration.bitrate > 0 else {
throw RecorderError.make(32, "bitrate must be positive")
}
guard configuration.segmentDuration >= 0 else {
throw RecorderError.make(33, "segmentDuration must be non-negative")
}
guard configuration.maxPendingFrames > 0 else {
throw RecorderError.make(34, "maxPendingFrames must be positive")
}
}
private func startBoundaryTimer() {
let timer = DispatchSource.makeTimerSource(queue: callbackQueue)
timer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100))
timer.setEventHandler { [weak self] in
self?.probeSimulatorState()
self?.probeFramebufferHealth()
self?.appendBoundaryFrameIfNeeded()
}
timer.resume()
boundaryTimer = timer
}
private func appendBoundaryFrameIfNeeded() {
if stopped {
return
}
guard configuration.segmentDuration > 0 else {
return
}
writerQueue.sync {
guard firstAcceptedCaptureTime != nil else {
return
}
let currentPTSSeconds = CMTimeGetSeconds(
normalizedPresentationTime(for: monotonicClock.elapsedTime()))
guard currentPTSSeconds >= nextBoundaryElapsed else {
return
}
let boundary = nextBoundaryElapsed
guard let lastPTS else {
return
}
do {
if CMTimeGetSeconds(lastPTS) < boundary {
let boundaryPTS = CMTime(seconds: boundary, preferredTimescale: 1_000_000_000)
guard try appendHeldFrame(at: boundaryPTS) else {
return
}
}
nextBoundaryElapsed = boundary + configuration.segmentDuration
} catch {
firstError = error
signalFirstFrameReadyIfNeeded()
}
}
}
private func captureFrame(force: Bool, reason: CaptureReason) {
if stopped {
return
}
if reason == .callback {
lastFrameCallbackElapsed = monotonicClock.elapsedSeconds()
}
guard let snapshot = displaySource?.surfaceSnapshot() else {
return
}
let surface = snapshot.surface
let surfaceWidth = snapshot.width
let surfaceHeight = snapshot.height
guard surfaceWidth > 0, surfaceHeight > 0 else {
return
}
let seed = snapshot.seed
let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
if !force, matchesLastAppendedSeed {
return
}
guard reservePendingFrame() else {
return
}
let capturedAt = monotonicClock.elapsedTime()
let capturedAtWallClock = Date()
do {
let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
surface,
width: surfaceWidth,
height: surfaceHeight
)
appendOwned(
pixelBuffer: pixelBuffer,
seed: seed,
capturedAt: capturedAt,
capturedAtWallClock: capturedAtWallClock
)
} catch {
releasePendingFrame()
writerQueue.async {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func probeFramebufferHealth() {
guard !stopped, let displaySource else {
return
}
let elapsed = monotonicClock.elapsedSeconds()
if !isFirstFrameReady(),
elapsed - lastFirstFrameRewireElapsed >= Self.firstFrameRewireInterval
{
lastFirstFrameRewireElapsed = elapsed
rewireFramebuffer()
captureFrame(force: true, reason: .healthProbe)
return
}
guard let snapshot = displaySource.surfaceSnapshot(),
snapshot.width > 0,
snapshot.height > 0
else {
rewireFramebuffer()
return
}
let observedSeed = snapshot.seed
let lastAppendedSeed = writerQueue.sync { lastSeed }
guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
return
}
let lastCallback = lastFrameCallbackElapsed ?? 0
if elapsed - lastCallback >= Self.callbackStalenessTimeout {
captureFrame(force: false, reason: .healthProbe)
rewireFramebuffer()
}
}
private func probeSimulatorState() {
guard !stopped, simulatorStoppedReason == nil else {
return
}
let state = SimulatorDeviceLookup.stateString(udid: configuration.deviceUDID) ?? "missing"
guard state != "Booted" else {
return
}
simulatorStoppedReason = state
let error = RecorderError.make(26, "Simulator stopped before first frame (state: \(state))")
writerQueue.async {
if self.firstError == nil, !self.firstFrameReady {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
let onSimulatorStopped = onSimulatorStopped
eventQueue.async {
onSimulatorStopped?(state)
}
}
private func rewireFramebuffer() {
guard let displaySource else {
return
}
do {
try displaySource.rewireFramebuffer()
} catch {
writerQueue.async {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func reservePendingFrame() -> Bool {
pendingLock.lock()
defer { pendingLock.unlock() }
if pendingFrames >= configuration.maxPendingFrames {
return false
}
pendingFrames += 1
return true
}
private func releasePendingFrame() {
pendingLock.lock()
pendingFrames = max(0, pendingFrames - 1)
pendingLock.unlock()
}
private func copySurfaceToOwnedPixelBuffer(
_ surface: IOSurface,
width: Int,
height: Int
) throws -> CVPixelBuffer {
let pool = try writerQueue.sync {
if let firstError {
throw firstError
}
if writer == nil {
try startWriter(width: width, height: height)
}
guard let pool = adaptor?.pixelBufferPool else {
throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
}
return pool
}
var output: CVPixelBuffer?
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &output)
guard status == kCVReturnSuccess, let destination = output else {
throw RecorderError.make(41, "Failed to allocate pixel buffer from pool: \(status)")
}
let destinationLockStatus = CVPixelBufferLockBaseAddress(destination, [])
guard destinationLockStatus == kCVReturnSuccess else {
throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)")
}
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
guard surfaceLockStatus == KERN_SUCCESS else {
CVPixelBufferUnlockBaseAddress(destination, [])
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
}
defer {
IOSurfaceUnlock(surface, .readOnly, nil)
CVPixelBufferUnlockBaseAddress(destination, [])
}
guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else {
throw RecorderError.make(42, "Pixel buffer has no base address")
}
let sourceAddress = IOSurfaceGetBaseAddress(surface)
let sourceStride = IOSurfaceGetBytesPerRow(surface)
let destinationStride = CVPixelBufferGetBytesPerRow(destination)
let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination))
let copyHeight = min(
height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination))
let copyBytes = min(copyWidth * 4, sourceStride, destinationStride)
for row in 0..<copyHeight {
memcpy(
destinationAddress.advanced(by: row * destinationStride),
sourceAddress.advanced(by: row * sourceStride),
copyBytes
)
}
return destination
}
private func appendOwned(
pixelBuffer: CVPixelBuffer,
seed: UInt32,
capturedAt: CMTime,
capturedAtWallClock: Date
) {
writerQueue.async {
defer { self.releasePendingFrame() }
if self.firstError != nil {
return
}
do {
try self.appendOnWriterQueue(
pixelBuffer: pixelBuffer,
seed: seed,
capturedAt: capturedAt,
capturedAtWallClock: capturedAtWallClock
)
} catch {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func appendOnWriterQueue(
pixelBuffer: CVPixelBuffer,
seed: UInt32,
capturedAt: CMTime,
capturedAtWallClock: Date
) throws {
guard let writer, let input, let adaptor else {
throw RecorderError.make(43, "AVAssetWriter is not initialized")
}
let isFirstFrame = firstAcceptedCaptureTime == nil
let pts = isFirstFrame ? .zero : normalizedPresentationTime(for: capturedAt)
if let lastPTS, CMTimeCompare(pts, lastPTS) <= 0 {
return
}
guard try appendPendingBoundaryFrames(before: pts) else {
return
}
guard input.isReadyForMoreMediaData else {
return
}
if !adaptor.append(pixelBuffer, withPresentationTime: pts) {
throw writer.error ?? RecorderError.make(44, "Failed to append pixel buffer")
}
if isFirstFrame {
firstAcceptedCaptureTime = capturedAt
firstAcceptedWallClock = capturedAtWallClock
}
lastPTS = pts
lastSeed = seed
lastAppendedPixelBuffer = pixelBuffer
signalFirstFrameReadyIfNeeded()
}
private func appendTailFrameIfNeeded(finalPTS: CMTime) throws {
let deadline = Date().addingTimeInterval(5)
while true {
if try appendTailFrame(finalPTS: finalPTS) {
return
}
if Date() >= deadline {
throw RecorderError.make(45, "Timed out waiting to append tail frame")
}
Thread.sleep(forTimeInterval: 0.05)
}
}
private func appendTailFrame(finalPTS: CMTime) throws -> Bool {
let pts = normalizedPresentationTime(for: finalPTS)
return try appendHeldFrame(at: pts)
}
private func appendPendingBoundaryFrames(before pts: CMTime) throws -> Bool {
guard configuration.segmentDuration > 0,
firstAcceptedCaptureTime != nil
else {
return true
}
while CMTimeGetSeconds(pts) > nextBoundaryElapsed {
if let lastPTS, CMTimeGetSeconds(lastPTS) >= nextBoundaryElapsed {
nextBoundaryElapsed += configuration.segmentDuration
continue
}
let boundaryPTS = CMTime(
seconds: nextBoundaryElapsed, preferredTimescale: 1_000_000_000)
guard try appendHeldFrame(at: boundaryPTS) else {
return false
}
nextBoundaryElapsed += configuration.segmentDuration
}
return true
}
private func appendHeldFrame(at pts: CMTime) throws -> Bool {
guard let input,
let adaptor,
let lastPTS,
let lastAppendedPixelBuffer
else {
return true
}
if CMTimeCompare(pts, lastPTS) <= 0 {
return true
}
guard input.isReadyForMoreMediaData else {
return false
}
if !adaptor.append(lastAppendedPixelBuffer, withPresentationTime: pts) {
throw writer?.error ?? RecorderError.make(45, "Failed to append held frame")
}
self.lastPTS = pts
return true
}
private func normalizedPresentationTime(for capturedAt: CMTime) -> CMTime {
let start = firstAcceptedCaptureTime ?? capturedAt
return CMTimeSubtract(capturedAt, start)
}
private func startWriter(width: Int, height: Int) throws {
guard let outputWriter else {
throw RecorderError.make(46, "Missing output writer")
}
let writer: AVAssetWriter
if configuration.segmentDuration > 0 {
writer = AVAssetWriter(contentType: .mpeg4Movie)
writer.delegate = outputWriter
writer.outputFileTypeProfile = .mpeg4AppleHLS
writer.preferredOutputSegmentInterval = CMTime(
seconds: configuration.segmentDuration,
preferredTimescale: 600
)
} else {
writer = try AVAssetWriter(outputURL: outputWriter.singleRecordingURL, fileType: .mp4)
}
writer.initialSegmentStartTime = .zero
let input = AVAssetWriterInput(
mediaType: .video,
outputSettings: assetWriterSettings(width: width, height: height)
)
input.expectsMediaDataInRealTime = true
let attributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: width,
kCVPixelBufferHeightKey as String: height,
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
]
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: input,
sourcePixelBufferAttributes: attributes
)
guard writer.canAdd(input) else {
throw RecorderError.make(47, "AVAssetWriter cannot add video input")
}
writer.add(input)
guard writer.startWriting() else {
throw writer.error ?? RecorderError.make(48, "AVAssetWriter failed to start")
}
writer.startSession(atSourceTime: .zero)
self.writer = writer
self.input = input
self.adaptor = adaptor
recordingWidth = width
recordingHeight = height
}
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
let codecType: AVVideoCodecType = configuration.codec == .hevc ? .hevc : .h264
var compression: [String: Any] = [
AVVideoAverageBitRateKey: configuration.bitrate,
AVVideoExpectedSourceFrameRateKey: configuration.fps,
AVVideoAllowFrameReorderingKey: true,
]
if configuration.segmentDuration > 0 {
compression[AVVideoMaxKeyFrameIntervalKey] = max(
1,
Int((configuration.segmentDuration * Double(configuration.fps)).rounded())
)
}
if configuration.codec == .h264 {
compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel
}
return [
AVVideoCodecKey: codecType,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
AVVideoCompressionPropertiesKey: compression,
]
}
private func signalFirstFrameReadyIfNeeded() {
if firstFrameReady {
return
}
firstFrameReady = true
firstFrameSemaphore.signal()
}
private func isFirstFrameReady() -> Bool {
writerQueue.sync {
firstFrameReady
}
}
}
@@ -1,60 +0,0 @@
import CoreMedia
import Foundation
enum RecorderError {
static func make(_ code: Int, _ message: String) -> NSError {
NSError(
domain: "RecordSim",
code: code,
userInfo: [NSLocalizedDescriptionKey: message]
)
}
}
extension JSONEncoder {
static var prettySorted: JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}
}
func timeSeconds(_ time: CMTime) -> Double? {
guard time.isValid, !time.isIndefinite, !time.isNegativeInfinity, !time.isPositiveInfinity else {
return nil
}
return CMTimeGetSeconds(time)
}
func iso8601(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter.string(from: date)
}
func unixMs(_ date: Date) -> Int64 {
Int64((date.timeIntervalSince1970 * 1000).rounded())
}
struct MonotonicClock {
private static let timescale: CMTimeScale = 1_000_000_000
let startedAtUptimeNanoseconds: UInt64
init() {
startedAtUptimeNanoseconds = DispatchTime.now().uptimeNanoseconds
}
func elapsedSeconds() -> TimeInterval {
TimeInterval(elapsedNanoseconds()) / TimeInterval(Self.timescale)
}
func elapsedTime() -> CMTime {
CMTime(value: CMTimeValue(elapsedNanoseconds()), timescale: Self.timescale)
}
private func elapsedNanoseconds() -> UInt64 {
DispatchTime.now().uptimeNanoseconds - startedAtUptimeNanoseconds
}
}
@@ -1,159 +0,0 @@
import Foundation
import RecordSim
struct CLIOptions {
var udid: String?
var output: String?
var segmentDuration: TimeInterval = 120
var fps = 30
var bitrate = 30_000_000
var codec: RecorderCodec = .h264
}
private var requestedStopSignal: CInt = 0
private var simulatorStopped: CInt = 0
private func handleStopSignal(_ signal: CInt) {
requestedStopSignal = signal
}
private func isStopRequested() -> Bool {
requestedStopSignal != 0 || simulatorStopped != 0
}
func usage() -> String {
"""
Usage:
record-sim --udid <UDID> --output <session-dir> [options]
Options:
--segment-duration <seconds> Target fMP4 media segment duration. Use 0 for one MP4. Default: 120
--fps <fps> Expected source frame rate. Default: 30
--bitrate <bits/sec> AVAssetWriter average bitrate. Default: 30000000
--codec <h264|hevc> Video codec. Default: h264
-h, --help Show this help
"""
}
func parseOptions(_ args: [String]) throws -> CLIOptions {
var options = CLIOptions()
var i = 0
func next(_ flag: String) throws -> String {
i += 1
guard i < args.count else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing value for \(flag)"])
}
return args[i]
}
while i < args.count {
let arg = args[i]
switch arg {
case "--udid", "-u":
options.udid = try next(arg)
case "--output", "--output-dir", "--out", "-o":
options.output = try next(arg)
case "--segment-duration":
options.segmentDuration = Double(try next(arg)) ?? -1
case "--fps":
options.fps = Int(try next(arg)) ?? 0
case "--bitrate":
options.bitrate = Int(try next(arg)) ?? 0
case "--codec":
let raw = try next(arg)
guard let codec = RecorderCodec(rawValue: raw) else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--codec must be h264 or hevc"])
}
options.codec = codec
case "--help", "-h":
print(usage())
exit(0)
default:
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unknown argument: \(arg)"])
}
i += 1
}
guard options.udid != nil else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --udid"])
}
guard options.output != nil else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --output"])
}
guard options.segmentDuration >= 0 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--segment-duration must be non-negative"])
}
guard options.fps > 0, options.fps <= 120 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--fps must be between 1 and 120"])
}
guard options.bitrate > 0 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--bitrate must be positive"])
}
return options
}
do {
signal(SIGINT, handleStopSignal)
signal(SIGTERM, handleStopSignal)
let options = try parseOptions(Array(CommandLine.arguments.dropFirst()))
let configuration = SimulatorRecordingConfiguration(
deviceUDID: options.udid!,
outputDirectory: URL(fileURLWithPath: options.output!),
fps: options.fps,
bitrate: options.bitrate,
codec: options.codec,
segmentDuration: options.segmentDuration
)
let recorder = SimulatorRecorder(configuration: configuration)
recorder.onSimulatorStopped = { state in
simulatorStopped = 1
fputs("[record-sim] simulator stopped state=\(state); finalizing recording\n", stderr)
}
recorder.onFinalizationStage = { stage in
switch stage {
case .captureStopped:
fputs("[record-sim] screen capture stopped; saving final video\n", stderr)
case .videoSaved:
fputs("[record-sim] final video saved; writing recording details\n", stderr)
}
}
recorder.onSegment = { segment in
switch segment.kind {
case .initialization:
fputs("[record-sim] init \(segment.relativePath) bytes=\(segment.byteCount)\n", stderr)
case .media:
let duration = segment.durationSeconds ?? 0
fputs(String(format: "[record-sim] segment %@ duration=%.3fs bytes=%d\n", segment.relativePath, duration, segment.byteCount), stderr)
}
}
try recorder.start()
try recorder.waitUntilFirstFrame()
fputs("[record-sim] recording -> \(options.output!)\n", stderr)
while !isStopRequested() {
Thread.sleep(forTimeInterval: 0.1)
}
if requestedStopSignal != 0 {
let signalName = switch requestedStopSignal {
case SIGINT: "SIGINT"
case SIGTERM: "SIGTERM"
default: "signal \(requestedStopSignal)"
}
fputs("[record-sim] received \(signalName); finishing recording\n", stderr)
}
let manifest = try recorder.stop()
if let recording = manifest.recording {
fputs("[record-sim] done recording=\(recording)\n", stderr)
} else {
fputs("[record-sim] done segments=\(manifest.segments.count)\n", stderr)
}
fputs("[record-sim] manifest \(URL(fileURLWithPath: options.output!).appendingPathComponent("session.json").path)\n", stderr)
} catch {
fputs("error: \(error.localizedDescription)\n", stderr)
exit(1)
}
@@ -0,0 +1,5 @@
import { Android } from '@expo/eas-build-job';
export function resolveArtifactPath(job: Android.Job): string {
return job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}';
}
@@ -61,7 +61,7 @@ jest.mock('../../utils/expoUpdates', () => ({
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
uploadEmbeddedBundleAsync: jest.fn(),
uploadEmbeddedBundleAsync: jest.fn(async () => ({ status: 'uploaded' })),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
@@ -344,7 +344,29 @@ describe(androidBuilder, () => {
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job, env: ctx.env, projectDir: '/workingdir/build' })
);
});
it.each([
['uploaded', BuildPhaseResult.SUCCESS],
['skipped', BuildPhaseResult.SKIPPED],
['failed', BuildPhaseResult.WARNING],
] as const)('marks the phase from the %s result', async (uploadResult, phaseResult) => {
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValueOnce({ status: uploadResult });
const ctx = createContext({ sdkVersion: '58.0.0' });
await androidBuilder(ctx);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
build_phase: BuildPhase.UPLOAD_EMBEDDED_BUNDLE.toLowerCase(),
result: phaseResult,
})
);
});
it('skips by default on SDK 57 and earlier', async () => {
@@ -371,7 +393,9 @@ describe(androidBuilder, () => {
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job })
);
});
it('skips on SDK 58 and later when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 0', async () => {
@@ -393,7 +417,9 @@ describe(androidBuilder, () => {
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job })
);
});
});
});
+14 -2
View File
@@ -11,6 +11,7 @@ import {
runGradleCommand,
} from '../android/gradle';
import { formatGradleProfileReport, parseGradleProfile } from '../android/gradleProfile';
import { resolveArtifactPath } from '../android/resolve';
import { Sentry } from '../sentry';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
@@ -220,7 +221,7 @@ async function buildInnerAsync(
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}',
patternOrPath: resolveArtifactPath(ctx.job),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
@@ -228,7 +229,18 @@ async function buildInnerAsync(
if (shouldUploadEmbeddedBundle(ctx)) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync({
job: ctx.job,
env: ctx.env,
logger: ctx.logger,
projectDir: ctx.getReactNativeProjectDirectory(),
appConfig: await ctx.appConfig,
});
if (status === 'skipped') {
ctx.markBuildPhaseSkipped();
} else if (status === 'failed') {
ctx.markBuildPhaseHasWarnings();
}
});
}
+13 -2
View File
@@ -237,7 +237,7 @@ async function buildInnerAsync(
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx),
patternOrPath: resolveArtifactPath(ctx.job),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
@@ -245,7 +245,18 @@ async function buildInnerAsync(
if (shouldUploadEmbeddedBundle(ctx)) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync({
job: ctx.job,
env: ctx.env,
logger: ctx.logger,
projectDir: ctx.getReactNativeProjectDirectory(),
appConfig: await ctx.appConfig,
});
if (status === 'skipped') {
ctx.markBuildPhaseSkipped();
} else if (status === 'failed') {
ctx.markBuildPhaseHasWarnings();
}
});
}
+4 -4
View File
@@ -15,10 +15,10 @@ export function resolveScheme(ctx: BuildContext<Ios.Job>): string {
return schemes[0];
}
export function resolveArtifactPath(ctx: BuildContext<Ios.Job>): string {
if (ctx.job.applicationArchivePath) {
return ctx.job.applicationArchivePath;
} else if (ctx.job.simulator) {
export function resolveArtifactPath(job: Ios.Job): string {
if (job.applicationArchivePath) {
return job.applicationArchivePath;
} else if (job.simulator) {
return 'ios/build/Build/Products/*simulator/*.app';
} else {
return 'ios/build/*.ipa';
@@ -45,7 +45,6 @@ import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
@@ -62,6 +61,7 @@ import { createStartSandboxBuildFunction } from './functions/startSandbox';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
@@ -104,7 +104,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartAgentDeviceSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx),
@@ -147,6 +146,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
functions.push(
...[
createFindAndUploadBuildArtifactsBuildFunction(ctx),
createUploadEmbeddedBundleBuildFunction(ctx),
createResolveBuildConfigBuildFunction(ctx),
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
]
@@ -219,4 +219,71 @@ describe(createEasBuildBuildFunctionGroup, () => {
'Build function group is not supported in generic jobs.'
);
});
describe('embedded bundle upload', () => {
function getStepNames(
options: Parameters<typeof createMockBuildToolsContext>[0],
{ sdkVersion, env = {} }: { sdkVersion?: string; env?: Record<string, string> }
): string[] {
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { metadata: sdkVersion ? { sdkVersion } : null },
});
globalCtx.updateEnv(env);
return functionGroup
.createBuildStepsFromFunctionGroupCall(globalCtx)
.map(step => step.displayName);
}
it.each([
['Android', { platform: Platform.ANDROID }],
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
])('uploads the embedded bundle after the build artifacts on SDK 58 (%s)', (_, options) => {
const stepNames = getStepNames(options, { sdkVersion: '58.0.0' });
expect(stepNames.indexOf('Upload embedded bundle')).toBe(
stepNames.indexOf('Find and upload build artifacts') + 1
);
});
it.each([
['Android', { platform: Platform.ANDROID }],
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
])('passes ignore_error to the embedded bundle step (%s)', (_, options) => {
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { metadata: { sdkVersion: '58.0.0' } },
});
const uploadStep = functionGroup
.createBuildStepsFromFunctionGroupCall(globalCtx)
.find(step => step.displayName === 'Upload embedded bundle');
expect(uploadStep).toBeDefined();
expect(uploadStep!.inputs?.find(input => input.id === 'ignore_error')?.rawValue).toBe(true);
});
it('does not upload the embedded bundle on SDK 57 without the opt-in', () => {
expect(getStepNames({}, { sdkVersion: '57.0.0' })).not.toContain('Upload embedded bundle');
});
it('uploads the embedded bundle on SDK 57 with the opt-in', () => {
expect(
getStepNames(
{},
{ sdkVersion: '57.0.0', env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' } }
)
).toContain('Upload embedded bundle');
});
it('does not upload the embedded bundle for iOS simulator builds', () => {
expect(
getStepNames({ platform: Platform.IOS, simulator: true }, { sdkVersion: '58.0.0' })
).not.toContain('Upload embedded bundle');
});
});
});
@@ -10,6 +10,7 @@ import path from 'path';
import { shouldUseEagerBundle } from '../../common/eagerBundle';
import { CustomBuildContext } from '../../customBuildContext';
import { shouldUploadEmbeddedBundle } from '../../utils/expoUpdatesEmbedded';
import { calculateEASUpdateRuntimeVersionFunction } from '../functions/calculateEASUpdateRuntimeVersion';
import { createCheckoutBuildFunction } from '../functions/checkout';
import { configureAndroidVersionFunction } from '../functions/configureAndroidVersion';
@@ -32,6 +33,7 @@ import {
import { runFastlaneFunction } from '../functions/runFastlane';
import { runGradleFunction } from '../functions/runGradle';
import { createSaveBuildCacheFunction } from '../functions/saveBuildCache';
import { createUploadEmbeddedBundleBuildFunction } from '../functions/uploadEmbeddedBundle';
import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
interface HelperFunctionsInput {
@@ -303,6 +305,19 @@ function createStepsForIosBuildWithCredentials({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -390,6 +405,19 @@ function createStepsForAndroidBuildWithoutCredentials({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -483,6 +511,19 @@ function createStepsForAndroidBuildWithCredentials({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -16,7 +16,7 @@ import {
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
jest.mock('@expo/turtle-spawn');
@@ -27,6 +27,14 @@ jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceEvents');
jest.mock('../../utils/appiumEvents');
jest.mock('../../utils/agentDeviceArtifacts');
// The agent-device session step boots the emulator itself; this test covers the session host.
jest.mock('../startAndroidEmulator', () => ({
startAndroidEmulatorAsync: jest.fn().mockResolvedValue({
serialId: 'emulator-5554',
emulatorPromise: Promise.resolve(),
shouldAdjustAnimationScale: true,
}),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
spawnDetached: jest.fn(),
@@ -48,6 +56,15 @@ beforeEach(() => {
jest.clearAllMocks();
// The daemon package is external. No installation or daemon process runs in these tests.
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
// Only the emulator logcat folder: the Appium step writes into its own temp folder.
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises);
jest
.spyOn(fs.promises, 'mkdtemp')
.mockImplementation(async prefix =>
String(prefix).includes('eas-android-emulator-logcat-')
? '/tmp/eas-android-emulator-logcat-test'
: mkdtemp(prefix)
);
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
@@ -89,7 +106,7 @@ afterEach(() => {
describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceSessionBuildFunction],
] as const)('%s host ownership', (name, createFunction) => {
async function runAsync() {
const fn = createFunction({} as CustomBuildContext);
@@ -99,10 +116,10 @@ describe.each([
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session-id',
@@ -1,5 +1,6 @@
import { BuildRuntimePlatform, BuildStep } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { fs, vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createInstallMitmproxyBuildFunction } from '../installMitmproxy';
@@ -11,6 +12,58 @@ jest.mock('@expo/turtle-spawn', () => ({
const mockedSpawn = jest.mocked(spawn);
// An x86 Homebrew prefix, to check that the app is found from PATH and not from process.arch.
const APP = '/usr/local/Caskroom/mitmproxy/12.2.3/mitmproxy.app';
const LINK = '/usr/local/bin/mitmdump';
function installCask(): void {
vol.fromJSON({ [`${APP}/Contents/MacOS/mitmdump`]: '', '/usr/local/bin': null });
fs.symlinkSync(`${APP}/Contents/MacOS/mitmdump`, LINK);
}
/**
* `which` finds mitmdump once the cask is installed, `brew` installs it, `xattr -p` reports
* quarantine while `stuck` is set, and each `mitmdump --version` takes the next result.
*/
function mockCommands({
installed = false,
stuck = false,
runs = [],
}: {
installed?: boolean;
stuck?: boolean;
runs?: boolean[];
}): void {
if (installed) {
installCask();
}
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'which' && !installed) {
throw new Error('not found');
}
if (command === 'which') {
return { stdout: `${LINK}\n` };
}
if (command === 'brew' && !installed) {
installed = true;
installCask();
}
if (command === 'xattr' && args[0] === '-p' && !stuck) {
throw new Error('No such xattr: com.apple.quarantine');
}
if (command === 'mitmdump' && !runs.shift()) {
throw new Error('not found');
}
return { stdout: '' };
}) as never);
}
function spawnedCommands(): string[] {
return mockedSpawn.mock.calls.map(([command, args]) =>
command === 'xattr' ? `xattr ${args?.[0]}` : command
);
}
describe('createInstallMitmproxyBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -24,28 +77,51 @@ describe('createInstallMitmproxyBuildFunction', () => {
});
}
it('does not install when mitmdump is already on PATH', async () => {
mockedSpawn.mockResolvedValueOnce({} as never);
it('takes the app behind mitmdump on PATH out of quarantine before running it', async () => {
mockCommands({ installed: true, runs: [true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('mitmdump', ['--version'], expect.anything());
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(mockedSpawn).toHaveBeenCalledWith(
'xattr',
['-dr', 'com.apple.quarantine', APP],
expect.anything()
);
expect(spawnedCommands()).toEqual(['which', 'xattr -dr', 'xattr -p', 'mitmdump']);
});
it('warns instead of installing outside EAS Build VMs', async () => {
mockedSpawn.mockRejectedValueOnce(new Error('not found'));
it('kills a mitmdump --version that does not return', async () => {
mockCommands({ installed: true, runs: [true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'mitmdump',
['--version'],
expect.objectContaining({ stdio: 'ignore', timeout: 60_000, killSignal: 'SIGKILL' })
);
});
it('does not run a mitmdump that stays quarantined', async () => {
mockCommands({ installed: true, stuck: true });
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
).resolves.toBeUndefined();
expect(spawnedCommands()).not.toContain('mitmdump');
});
it('does not change quarantine outside EAS Build VMs', async () => {
mockCommands({ installed: true, stuck: true, runs: [true] });
await createStep({}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(spawnedCommands()).toEqual(['mitmdump']);
});
it('installs the cask with Homebrew and leaves auto-update off', async () => {
mockedSpawn
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce({} as never)
.mockResolvedValueOnce({} as never);
it('installs the cask with Homebrew and takes it out of quarantine', async () => {
mockCommands({ runs: [false, true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
@@ -54,13 +130,19 @@ describe('createInstallMitmproxyBuildFunction', () => {
['install', '--cask', 'mitmproxy'],
expect.objectContaining({ env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }) })
);
expect(spawnedCommands()).toEqual([
'which',
'mitmdump',
'brew',
'which',
'xattr -dr',
'xattr -p',
'mitmdump',
]);
});
it('does not fail the job when mitmdump is still not runnable', async () => {
mockedSpawn
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce({} as never)
.mockRejectedValueOnce(new Error('not found'));
mockCommands({ runs: [false, false] });
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
@@ -18,7 +18,7 @@ import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
@@ -29,6 +29,12 @@ jest.mock('../../../utils/processes');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceArtifacts');
// The agent-device session step boots the Simulator itself; this test covers local egress.
jest.mock('../startIosSimulator', () => ({
bootIosSimulatorAsync: jest
.fn()
.mockResolvedValue({ deviceIdentifier: 'sim', udid: 'sim', displayName: 'iPhone' }),
}));
jest.mock('../../utils/argentArtifacts');
jest.mock('../../utils/agentDeviceEvents', () => ({
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
@@ -78,7 +84,7 @@ const fields = {
egressPort: handoff.port,
};
const controllers = [
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['agent-device', createStartAgentDeviceSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
@@ -93,10 +99,10 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
await createFunction(ctx).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
outputs: {},
env,
signal,
@@ -1,10 +1,9 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
@@ -21,10 +20,7 @@ import {
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
import {
createStartAgentDeviceRemoteSessionBuildFunction,
runAgentDeviceRemoteSessionAsync,
} from '../startAgentDeviceRemoteSession';
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
// The daemon entry path and the state directory are resolved from the home directory when
// the module loads, so point it at a temp home we can populate.
@@ -70,28 +66,34 @@ const mockTunnelStopAsync = jest.fn();
const mockDaemonStopAsync = jest.fn();
const mockEventCollectionStopAsync = jest.fn();
type CaptureInputs = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['capture'];
const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: [] };
/** Runs the session for a device that is already booted, with the app (if any) launched. */
async function runAsync(
logger: { info: jest.Mock; warn: jest.Mock },
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
runtimePlatform: BuildRuntimePlatform,
launchInputs: Record<string, { value: unknown }> = {}
capture: CaptureInputs = NO_CAPTURE
): Promise<void> {
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
max_duration_seconds: { value: undefined },
...launchInputs,
},
outputs: {},
env: {},
} as never
);
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger: logger as never,
runtimePlatform,
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture,
tasks: createStartupTasks(logger as never),
device: { booted: Promise.resolve(), ready: Promise.resolve() },
});
}
describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () => {
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
beforeEach(async () => {
jest.clearAllMocks();
@@ -141,7 +143,6 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
await runAsync(logger, BuildRuntimePlatform.LINUX);
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
@@ -162,36 +163,12 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
it('hands network capture to serve-sim', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
launch_app_identifier: { value: 'host.exp.Exponent' },
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
open_url: { value: 'exp://127.0.0.1:8081' },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
runtimePlatform: BuildRuntimePlatform.DARWIN,
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
})
);
expect(logger.info).toHaveBeenCalledWith(
'serve-sim will launch host.exp.Exponent with arguments ' +
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
);
});
it('hands network capture to serve-sim on an iOS session', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
network_capture: { value: true },
network_capture_fields: { value: ['header'] },
networkCapture: true,
networkCaptureFields: ['header'],
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
@@ -200,26 +177,6 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
);
});
it('fails before starting the daemon when network capture is asked for on Android', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await expect(
runAsync(logger, BuildRuntimePlatform.LINUX, { network_capture: { value: true } })
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
expect(spawnDetached).not.toHaveBeenCalled();
});
it('fails before starting the daemon when a launch is asked for on Android', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await expect(
runAsync(logger, BuildRuntimePlatform.LINUX, {
launch_app_identifier: { value: 'host.exp.Exponent' },
})
).rejects.toThrow('runs on linux');
expect(spawnDetached).not.toHaveBeenCalled();
});
it('starts the session host without waiting for the agent-device daemon', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const sessionHost = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
@@ -323,8 +280,7 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
launch: {},
capture: { networkCapture: false, networkCaptureFields: [] },
capture: NO_CAPTURE,
tasks,
device: typeof device === 'function' ? device(tasks) : device,
});
@@ -415,13 +371,4 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
});
it('declares the launch inputs', () => {
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
});
@@ -85,8 +85,10 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: {
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
webPreviewToken: 'tok-1',
previewToken: 'tok-1',
},
})
@@ -132,6 +134,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
ctx,
deviceRunSessionId: 'device-run-session-id',
remoteConfig: {
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
},
@@ -3,6 +3,7 @@ import { generateKeyPairSync } from 'node:crypto';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Sentry } from '../../../sentry';
import { AscApiClient } from '../../utils/ios/AscApiClient';
import {
createUpdateTestFlightMetadataBuildFunction,
@@ -10,6 +11,7 @@ import {
} from '../updateTestFlightMetadata';
jest.unmock('node-fetch');
jest.mock('../../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const key = { keyId: 'TESTKEY', privateKey };
@@ -56,8 +58,21 @@ function mockAssignedGroups(ids: string[] = []): void {
.reply(200, { data: ids.map(id => ({ id })) });
}
function mockInternalBuildState(state: string | null = 'READY_FOR_BETA_TESTING'): void {
api()
.get('/v1/builds/build/buildBetaDetail')
.query(true)
.reply(
state ? 200 : 503,
state
? { data: { id: 'detail', attributes: { internalBuildState: state } } }
: { errors: [{ code: 'UNAVAILABLE' }] }
);
}
beforeAll(() => nock.disableNetConnect());
beforeEach(() => {
jest.mocked(Sentry.capture).mockClear();
options.logger = createMockLogger();
options.client = new AscApiClient({ key });
});
@@ -378,6 +393,7 @@ it('attempts all localization changes and reports every failed update', async ()
it.each([1, 2])('explains automatic internal group rejection (%s errors)', async errorCount => {
mockBuild();
mockInternalBuildState();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
@@ -438,6 +454,7 @@ it('tries group assignment if the changelog update fails', async () => {
it('reports both write failures', async () => {
mockBuild();
mockInternalBuildState();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
@@ -459,6 +476,7 @@ it.each([false, true])(
'attempts all groups and reports assignment errors (multiple: %s)',
async multiple => {
mockBuild();
mockInternalBuildState();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
@@ -484,7 +502,7 @@ it.each([false, true])(
.reply(204);
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
).rejects.toThrow(multiple ? /FIRST_GROUP_FAILED.*Partial assignment/ : 'Partial assignment');
).rejects.toThrow(multiple ? 'FIRST_GROUP_FAILED' : 'Partial assignment');
expect(options.logger.error).toHaveBeenCalledWith(
expect.stringContaining('Group "B" (B): assignment failed.')
);
@@ -531,3 +549,73 @@ it('propagates Apple write failures', async () => {
.reply(403, { errors: [{ code: 'FORBIDDEN', detail: 'Not allowed' }] });
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('403');
});
it.each([
['MISSING_EXPORT_COMPLIANCE', 'Complete the export compliance questions'],
['IN_EXPORT_COMPLIANCE_REVIEW', 'Wait for Apple to approve'],
['EXPIRED', 'ASSIGNMENT_FAILED'],
['PROCESSING', 'ASSIGNMENT_FAILED'],
['READY_FOR_BETA_TESTING', 'ASSIGNMENT_FAILED'],
['IN_BETA_TESTING', 'ASSIGNMENT_FAILED'],
[null, 'ASSIGNMENT_FAILED'],
] as const)(
'shares one diagnostic lookup across failed assignments (state: %s)',
async (state, guidance) => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, {
data: ['A', 'B', 'C'].map(id => ({ id, attributes: { name: id } })),
});
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'A' }],
})
.reply(403, { errors: [{ code: 'ASSIGNMENT_FAILED' }] });
mockInternalBuildState(state);
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'B' }],
})
.reply(422, { errors: [{ code: 'ASSIGNMENT_FAILED' }] });
// A failed assignment must not prevent the next group from being assigned.
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'C' }],
})
.reply(204);
const failure = await updateTestFlightMetadataAsync({
...options,
changelog: '',
groups: ['A', 'B', 'C'],
}).catch(error => error);
expect(failure).toBeInstanceOf(AggregateError);
expect(failure.errors).toHaveLength(2);
if (state === 'MISSING_EXPORT_COMPLIANCE' || state === 'IN_EXPORT_COMPLIANCE_REVIEW') {
expect(failure.errors[0]).toBe(failure.errors[1]);
} else {
expect(failure.errors[0]).toMatchObject({ status: 403 });
expect(failure.errors[1]).toMatchObject({ status: 422 });
}
expect(failure.errors[0].message).toContain(guidance);
expect(
jest
.mocked(options.logger.info)
.mock.calls.filter(([message]) => String(message).includes('internal TestFlight state ='))
).toEqual([[`Apple build build: internal TestFlight state = ${state ?? 'UNKNOWN'}.`]]);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
`TestFlight group assignment failed (state: ${state ?? 'UNKNOWN'})`,
{
level: 'error',
tags: { step: 'eas/update_testflight_metadata', internal_build_state: state ?? 'UNKNOWN' },
extras: {
buildId: 'build',
assignmentError: expect.stringContaining('ASSIGNMENT_FAILED'),
},
}
);
}
);
@@ -0,0 +1,129 @@
import { ProjectConfig } from '@expo/config';
import { BuildJob } from '@expo/eas-build-job';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestAndroidJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { BuildContext } from '../../../context';
import { CustomBuildContext } from '../../../customBuildContext';
import { readAppConfig } from '../../../utils/appConfig';
import { uploadEmbeddedBundleAsync } from '../../../utils/expoUpdatesEmbedded';
import { createUploadEmbeddedBundleBuildFunction } from '../uploadEmbeddedBundle';
jest.mock('../../../utils/appConfig');
jest.mock('../../../utils/expoUpdatesEmbedded');
function mockAppConfig(updatesUrl: string | undefined): void {
jest.mocked(readAppConfig).mockResolvedValue({
exp: { name: 'my-app', slug: 'my-app', updates: updatesUrl ? { url: updatesUrl } : undefined },
} as ProjectConfig);
}
function createCustomContext(job: BuildJob): CustomBuildContext<BuildJob> {
return new CustomBuildContext(
new BuildContext(job, {
env: { __API_SERVER_URL: 'http://api.expo.test' },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
})
);
}
describe(createUploadEmbeddedBundleBuildFunction, () => {
const job = createTestAndroidJob({});
const customContext = createCustomContext(job);
beforeEach(() => {
jest.resetAllMocks();
});
it('uploads the embedded bundle from the working directory', async () => {
mockAppConfig('https://u.expo.dev/project-id');
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'uploaded' });
const globalContext = createGlobalContextMock({});
globalContext.updateEnv({ EAS_BUILD_ID: 'build-123' });
const buildStep =
createUploadEmbeddedBundleBuildFunction(customContext).createBuildStepFromFunctionCall(
globalContext
);
await buildStep.executeAsync();
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({
job,
env: expect.objectContaining({ EAS_BUILD_ID: 'build-123' }),
projectDir: globalContext.defaultWorkingDirectory,
appConfig: expect.objectContaining({ updates: { url: 'https://u.expo.dev/project-id' } }),
})
);
});
it('logs when the embedded bundle upload is skipped', async () => {
mockAppConfig('https://u.expo.dev/project-id');
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'skipped' });
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
await buildStep.executeAsync();
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith('Skipping embedded bundle upload.');
});
it('fails the step when the upload fails', async () => {
mockAppConfig('https://u.expo.dev/project-id');
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'failed' });
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
await expect(buildStep.executeAsync()).rejects.toThrow('Failed to upload embedded bundle.');
});
it('does not fail the step when the upload fails and ignore_error is set', async () => {
mockAppConfig('https://u.expo.dev/project-id');
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'failed' });
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: { ignore_error: true },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload embedded bundle. Ignoring error.'
);
});
it('fails the step when the app config cannot be read', async () => {
jest.mocked(readAppConfig).mockRejectedValue(new Error('Invalid app config'));
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
await expect(buildStep.executeAsync()).rejects.toThrow('Invalid app config');
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('does not fail the step when the app config cannot be read and ignore_error is set', async () => {
jest.mocked(readAppConfig).mockRejectedValue(new Error('Invalid app config'));
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: { ignore_error: true },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload embedded bundle. Ignoring error.'
);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
});
@@ -2,6 +2,7 @@ import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildFunction, BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { Sentry } from '../../sentry';
@@ -13,7 +14,10 @@ export function createInstallMitmproxyBuildFunction(): BuildFunction {
__metricsId: 'eas/install_mitmproxy',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
if (await isMitmproxyAvailableAsync(env)) {
// Some images ship the cask, and its quarantined first launch can hang.
const canLaunch =
env.EAS_BUILD_RUNNER !== 'eas-build' || (await removeMitmproxyQuarantineAsync(env));
if (canLaunch && (await isMitmproxyAvailableAsync(env))) {
logger.info('mitmproxy is already installed.');
return;
}
@@ -28,9 +32,11 @@ export function createInstallMitmproxyBuildFunction(): BuildFunction {
try {
logger.info('Installing mitmproxy with Homebrew.');
await installMitmproxyWithHomebrewAsync({ env, logger });
if (!(await removeMitmproxyQuarantineAsync(env))) {
throw new Error('Could not launch mitmproxy because it is still quarantined.');
}
if (!(await isMitmproxyAvailableAsync(env))) {
throw new Error('`brew install --cask mitmproxy` succeeded but mitmdump is not on PATH.');
throw new Error('`brew install --cask mitmproxy` succeeded but mitmdump did not run.');
}
logger.info('Installed mitmproxy.');
} catch (err) {
@@ -48,7 +54,18 @@ export function createInstallMitmproxyBuildFunction(): BuildFunction {
}
async function isMitmproxyAvailableAsync(env: BuildStepEnv): Promise<boolean> {
return (await asyncResult(spawn('mitmdump', ['--version'], { env }))).ok;
// Kills a first launch that Gatekeeper still holds, if the quarantine check missed it. Without
// pipes, a child process that outlives mitmdump cannot hold the call open past the timeout.
return (
await asyncResult(
spawn('mitmdump', ['--version'], {
env,
stdio: 'ignore',
timeout: 60_000,
killSignal: 'SIGKILL',
})
)
).ok;
}
async function installMitmproxyWithHomebrewAsync({
@@ -63,3 +80,25 @@ async function installMitmproxyWithHomebrewAsync({
logger,
});
}
/**
* Gatekeeper has rejected the mitmproxy 12.2.3 cask as unnotarized since 2026-10-06, and a
* quarantined first launch then never returns. Takes the app behind the `mitmdump` on PATH out of
* quarantine, and returns false when it stays quarantined, so it is not launched.
*/
async function removeMitmproxyQuarantineAsync(env: BuildStepEnv): Promise<boolean> {
const which = await asyncResult(spawn('which', ['mitmdump'], { env, stdio: 'pipe' }));
const mitmdump = which.ok
? await fs.promises.realpath(which.value.stdout.trim()).catch(() => null)
: null;
if (!mitmdump) {
return true;
}
const appEnd = mitmdump.lastIndexOf('.app/');
const app = appEnd === -1 ? mitmdump : mitmdump.slice(0, appEnd + '.app'.length);
await asyncResult(spawn('xattr', ['-dr', 'com.apple.quarantine', app], { env, stdio: 'pipe' }));
const quarantined = await asyncResult(
spawn('xattr', ['-p', 'com.apple.quarantine', mitmdump], { env, stdio: 'pipe' })
);
return !quarantined.ok;
}
@@ -1,22 +1,13 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
type BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { type BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import { uploadRemoteSessionConfigWithLocalEgressAsync } from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { Sentry } from '../../sentry';
import {
@@ -26,22 +17,15 @@ import {
resolvePackageInstall,
} from '../../utils/packageManager';
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { type parseNetworkCaptureInputs } from '../utils/networkCaptureFields';
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
import { type StartupTasks, createStartupTasks } from '../utils/startupTasks';
import { type StartupTasks } from '../utils/startupTasks';
import {
type DetachedProcessHandle,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
@@ -66,81 +50,6 @@ const AGENT_DEVICE_DAEMON_ENV = {
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
};
export function createStartAgentDeviceRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_agent_device_remote_session',
name: 'Start agent device remote session',
__metricsId: 'eas/start_agent_device_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createNetworkCaptureInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env vars are missing.
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
// 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;
const { runtimePlatform } = global;
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value as string | undefined,
},
{ runtimePlatform }
);
const capture = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
// Earlier steps booted the device and installed and launched the app.
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
signal,
runtimePlatform,
sessionEnv,
packageVersion,
maxIdleTimeMinutes,
maxDurationSeconds,
launch,
capture,
tasks: createStartupTasks(logger),
device: { booted: Promise.resolve(), ready: Promise.resolve() },
});
}),
});
}
export type AgentDeviceRemoteSessionEnv = {
deviceRunSessionId: string;
ngrokTunnelDomain: string;
@@ -186,7 +95,6 @@ export async function runAgentDeviceRemoteSessionAsync(
packageVersion,
maxIdleTimeMinutes,
maxDurationSeconds,
launch,
capture,
tasks,
device,
@@ -199,7 +107,6 @@ export async function runAgentDeviceRemoteSessionAsync(
packageVersion: string | undefined;
maxIdleTimeMinutes: number | undefined;
maxDurationSeconds: number | undefined;
launch: ReturnType<typeof parseServeSimLaunchInputs>;
capture: ReturnType<typeof parseNetworkCaptureInputs>;
tasks: StartupTasks;
device: { booted: Promise<unknown>; ready: Promise<unknown> };
@@ -247,18 +154,11 @@ export async function runAgentDeviceRemoteSessionAsync(
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
await tasks.untilAborted(device.booted);
tasks.signal.throwIfAborted();
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
taskLogger.info(launchDescription);
}
sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
env,
logger: taskLogger,
timeoutMs: STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
});
@@ -17,10 +17,7 @@ import {
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { withLocalEgressSession } from '../utils/localEgressSession';
import {
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
} from '../utils/remoteDeviceRunSession';
import { selectXcodeDeveloperDirectoryAsync } from '../utils/remoteDeviceRunSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
@@ -47,9 +44,9 @@ const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
* One step for a whole agent-device session: boot the device, download, install and
* launch the app, and start the agent-device daemon and the web preview.
*
* It replaces eas/start_ios_simulator or eas/start_android_emulator, eas/download_build,
* eas/install_build, eas/launch_application and eas/start_agent_device_remote_session,
* so the parts that do not depend on each other can run at the same time:
* It does the work of eas/start_ios_simulator or eas/start_android_emulator,
* eas/download_build, eas/install_build and eas/launch_application, plus the agent-device
* session, so the parts that do not depend on each other can run at the same time:
*
* boot ─────────────┬─► install ─► launch ─┐
* download ─────────┘ │
@@ -261,8 +258,6 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
// 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,
// The app is launched with simctl / adb above, not by serve-sim.
launch: parseServeSimLaunchInputs({}, { runtimePlatform }),
capture,
tasks,
device: { booted, ready },
@@ -102,10 +102,16 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
signal,
ctx,
deviceRunSessionId,
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
// sessions read previewUrl and previewToken. The server drops the keys that the
// session type does not use.
remoteConfig: {
webPreviewUrl: webPreview.previewPageUrl,
previewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
...(webPreview.previewToken
? { webPreviewToken: webPreview.previewToken, previewToken: webPreview.previewToken }
: {}),
},
logger,
});
@@ -5,6 +5,7 @@ import path from 'node:path';
import limitFactory from 'promise-limit';
import { z } from 'zod';
import { Sentry } from '../../sentry';
import { AscApiClient, AscApiRequestError } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
@@ -191,6 +192,7 @@ export async function updateTestFlightMetadataAsync({
})
: [];
const assignedIds = new Set(assignedGroups.map(group => group.id));
let internalBuildStateVerification: Promise<void> | undefined;
const groupLimit = limitFactory<void>(1);
const groupResults = await Promise.allSettled(
requestedGroups.map(group =>
@@ -219,6 +221,17 @@ export async function updateTestFlightMetadataAsync({
);
} catch (error) {
logger.error(`❌ Group ${label}: assignment failed. ${String(error)}`);
internalBuildStateVerification ??=
verifyInternalBuildStateValidForGroupAssignmentAsync({
client,
buildId,
appId: app.id,
logger,
error,
});
await internalBuildStateVerification;
if (isInternalGroupAssignmentError(error)) {
throw new UserError(
'EAS_TESTFLIGHT_INTERNAL_GROUP_ASSIGNMENT_FAILED',
@@ -229,6 +242,7 @@ export async function updateTestFlightMetadataAsync({
{ cause: error }
);
}
throw error;
}
logger.info(`✅ Group ${label}: assignment completed.`);
@@ -269,6 +283,48 @@ export async function updateTestFlightMetadataAsync({
}
}
async function verifyInternalBuildStateValidForGroupAssignmentAsync({
client,
appId,
buildId,
logger,
error,
}: {
client: AscApiClient;
appId: string;
buildId: string;
logger: bunyan;
error: unknown;
}): Promise<void> {
let state = 'UNKNOWN';
try {
const { data } = await client.getAsync('/v1/builds/:id/buildBetaDetail', {}, { id: buildId });
state = data.attributes.internalBuildState;
} catch (diagnosticError) {
logger.warn(`Could not read the TestFlight state: ${String(diagnosticError)}`);
}
logger.info(`Apple build ${buildId}: internal TestFlight state = ${state}.`);
Sentry.capture(`TestFlight group assignment failed (state: ${state})`, {
level: 'error',
tags: { step: 'eas/update_testflight_metadata', internal_build_state: state },
extras: { buildId, assignmentError: String(error) },
});
if (state === 'MISSING_EXPORT_COMPLIANCE' || state === 'IN_EXPORT_COMPLIANCE_REVIEW') {
throw new UserError(
'EAS_TESTFLIGHT_GROUP_ASSIGNMENT_FAILED',
`Apple could not add the uploaded build to a TestFlight group (state: ${state}). ` +
(state === 'MISSING_EXPORT_COMPLIANCE'
? 'Complete the export compliance questions for this build in App Store Connect. For future builds, set ITSAppUsesNonExemptEncryption in the iOS Info.plist to the value that matches your app. '
: 'Wait for Apple to approve the export compliance information, then assign the group in App Store Connect. ') +
`The binary is already uploaded; do not upload it again to fix group assignment. Manage this build at https://appstoreconnect.apple.com/apps/${appId}/testflight`,
{ cause: error }
);
}
}
// Apple returns a generic 422 code, so match the title or detail too.
function isInternalGroupAssignmentError(error: unknown): boolean {
if (error instanceof AggregateError) {
@@ -0,0 +1,57 @@
import { BuildJob, UserError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import { readAppConfig } from '../../utils/appConfig';
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
export function createUploadEmbeddedBundleBuildFunction(
ctx: CustomBuildContext<BuildJob>
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'upload_embedded_bundle',
name: 'Upload embedded bundle',
__metricsId: 'eas/upload_embedded_bundle',
inputProviders: [
BuildStepInput.createProvider({
id: 'ignore_error',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
fn: async (stepCtx, { env, inputs }) => {
try {
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env,
logger: stepCtx.logger,
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
})
).exp;
const { status } = await uploadEmbeddedBundleAsync({
job: ctx.job,
env,
logger: stepCtx.logger,
projectDir: stepCtx.workingDirectory,
appConfig,
});
if (status === 'skipped') {
stepCtx.logger.info('Skipping embedded bundle upload.');
} else if (status === 'failed') {
throw new UserError(
'EAS_UPLOAD_EMBEDDED_BUNDLE_FAILED',
'Failed to upload embedded bundle. See the warning above for details.'
);
}
} catch (err) {
if (inputs.ignore_error.value) {
stepCtx.logger.warn({ err }, 'Failed to upload embedded bundle. Ignoring error.');
return;
}
throw err;
}
},
});
}
@@ -155,21 +155,11 @@ export function createUploadToAscBuildFunction(): BuildFunction {
});
stepsCtx.logger.info('Committing upload...');
await client.patchAsync(
`/v1/buildUploadFiles/:id`,
{
data: {
type: 'buildUploadFiles',
id: buildFileResponse.data.id,
attributes: {
uploaded: true,
},
},
},
{
id: buildFileResponse.data.id,
}
);
await AscApiUtils.commitBuildUploadFileAsync({
client,
fileId: buildFileResponse.data.id,
logger: stepsCtx.logger,
});
stepsCtx.logger.info('Checking upload file status...');
const waitingForFileStartedAt = Date.now();
@@ -10,12 +10,27 @@ import { setTimeout } from 'node:timers/promises';
import { Sentry } from '../../sentry';
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageExec,
} from '../../utils/packageManager';
import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000;
const RECORD_SIM_FINISH_TIMEOUT_MS = 70_000;
const RECORD_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
const RECORD_SIM_MAX_ATTEMPTS_PER_BOOT = 3;
const RECORD_SIM_COMMAND = 'record-sim';
// Once the poller stops, each recording stops in parallel within 60 + 130 + 5 + 30 = 225 s.
const SERVE_SIM_START_TIMEOUT_MS = 60_000;
/** serve-sim finalizes an active recording within this grace period after SIGTERM. */
export const SERVE_SIM_STOP_GRACE_PERIOD_MS = 90_000;
const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS = 30_000;
// serve-sim may be stopping at the same time: its grace period, the manifest wait, and 10 s.
const SERVE_SIM_FINISH_TIMEOUT_MS =
SERVE_SIM_STOP_GRACE_PERIOD_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS + 10_000;
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started';
// The serve-sim package the session host started; every recorder runs `record-video` from it.
let serveSimPackageSpec: string | null = null;
type IosSimulatorRecording = {
id: string;
@@ -25,6 +40,7 @@ type IosSimulatorRecording = {
outputDirectory: string;
startedAt: Date;
getOutput: () => string;
hasStarted: () => boolean;
};
type ActiveIosSimulatorRecording = IosSimulatorRecording & {
@@ -35,11 +51,15 @@ type ActiveIosSimulatorRecording = IosSimulatorRecording & {
type IosSimulatorRecordingSession = {
env: Env;
logger: bunyan;
recordSimCommand: string;
recordingsRootDirectory: string;
activeRecordings: Map<IosSimulatorUuid, ActiveIosSimulatorRecording>;
completedRecordings: IosSimulatorRecording[];
recordingFailureCounts: Map<IosSimulatorUuid, number>;
/** Failed attempts for the whole session; unlike recordingFailureCounts, never reset. */
failedAttemptCounts: Map<IosSimulatorUuid, number>;
recordingRetryAt: Map<IosSimulatorUuid, number>;
serveBearerTokens: Map<IosSimulatorUuid, string>;
completedServeBearerTokens: Map<IosSimulatorUuid, string>;
pollingPromise: Promise<void>;
abortController: AbortController;
};
@@ -47,31 +67,31 @@ type IosSimulatorRecordingSession = {
let activeIosSimulatorRecordingSession: IosSimulatorRecordingSession | null = null;
export namespace IosSimulatorRecordingUtils {
/** One session host serves every device of a job, so the first package wins. */
export function useServeSimPackage(packageSpec: string): void {
serveSimPackageSpec ??= packageSpec;
}
export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise<void> {
if (activeIosSimulatorRecordingSession) {
logger.info('iOS Simulator screen recording polling is already running.');
return;
}
const recordSimCommand = await resolveRecordSimCommandAsync({ env });
if (!recordSimCommand) {
logger.warn(
'record-sim binary is not available; iOS Simulator screen recordings are disabled.'
);
return;
}
const recordingsRootDirectory = await mkdtemp(
path.join(os.tmpdir(), 'ios-simulator-recordings-')
);
const session: IosSimulatorRecordingSession = {
env,
logger,
recordSimCommand,
recordingsRootDirectory,
activeRecordings: new Map(),
completedRecordings: [],
recordingFailureCounts: new Map(),
failedAttemptCounts: new Map(),
recordingRetryAt: new Map(),
serveBearerTokens: new Map(),
completedServeBearerTokens: new Map(),
pollingPromise: Promise.resolve(),
abortController: new AbortController(),
};
@@ -94,6 +114,7 @@ export namespace IosSimulatorRecordingUtils {
}[]
> {
const session = activeIosSimulatorRecordingSession;
serveSimPackageSpec = null;
if (!session) {
logger.info('No iOS Simulator screen recordings are running.');
return [];
@@ -105,41 +126,55 @@ export namespace IosSimulatorRecordingUtils {
await Promise.all(
[...session.activeRecordings.values()].map(async recording => {
logger.info(`Stopping screen recording for ${recording.deviceName}.`);
recording.recordingProcess.kill('SIGINT');
const startState = await waitForRecorderStartAsync(recording, SERVE_SIM_START_TIMEOUT_MS);
if (startState === 'exited') {
return;
}
if (startState === 'timeout') {
logger.warn(
{ recorderOutput: recording.getOutput().trim() },
`Screen recording for ${recording.deviceName} did not report its start within ${Math.round(
SERVE_SIM_START_TIMEOUT_MS / 1_000
)} seconds and will be stopped.`
);
}
const finishTimeoutMs =
startState === 'started' ? SERVE_SIM_FINISH_TIMEOUT_MS : SERVE_SIM_FORCE_STOP_TIMEOUT_MS;
signalRecorder(recording.recordingProcess, 'SIGINT');
const finished = await Promise.race([
recording.completionPromise.then(() => true),
setTimeout(RECORD_SIM_FINISH_TIMEOUT_MS, false, { ref: false }),
setTimeout(finishTimeoutMs, false, { ref: false }),
]);
if (finished) {
return;
}
const recordSimOutput = recording.getOutput().trim();
const finishTimeoutSeconds = Math.round(RECORD_SIM_FINISH_TIMEOUT_MS / 1_000);
const recorderOutput = recording.getOutput().trim();
const finishTimeoutSeconds = Math.round(finishTimeoutMs / 1_000);
const stopFailure =
startState === 'started'
? `did not finish within ${finishTimeoutSeconds} seconds`
: `did not stop within ${finishTimeoutSeconds} seconds of SIGINT`;
logger.warn(
{ recordSimOutput },
`Screen recording for ${recording.deviceName} did not finish within ${finishTimeoutSeconds} seconds and will be stopped.${
recordSimOutput
? `\nRecent recorder messages:\n${recordSimOutput}`
{ recorderOutput },
`Screen recording for ${recording.deviceName} ${stopFailure} and will be killed.${
recorderOutput
? `\nRecent recorder messages:\n${recorderOutput}`
: '\nNo recorder messages were captured.'
}`
);
recording.recordingProcess.kill('SIGKILL');
signalRecorder(recording.recordingProcess, 'SIGKILL');
const killed = await Promise.race([
recording.completionPromise.then(() => true),
setTimeout(RECORD_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
setTimeout(SERVE_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
]);
if (!killed) {
logger.warn(
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
);
// Recorder output stays in the job log: it can contain the serve-sim bearer token.
Sentry.capture(
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`,
{
extras: {
output: recording.getOutput(),
},
}
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
);
}
})
@@ -148,7 +183,31 @@ export namespace IosSimulatorRecordingUtils {
const completedRecordings = [...session.completedRecordings].sort(
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
);
return completedRecordings.map(recording => ({
const recordingsWithManifests = await Promise.all(
completedRecordings.map(async recording => {
if (
await waitForRecordingManifestAsync(
recording.outputDirectory,
SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS
)
) {
return recording;
}
reportLostRecording(logger, recording.deviceName, recording.getOutput().trim());
return null;
})
);
const finalizedRecordings = recordingsWithManifests.filter(recording => recording !== null);
const recordedUdids = new Set(finalizedRecordings.map(recording => recording.udid));
for (const [failedUdid, attempts] of session.failedAttemptCounts) {
if (!recordedUdids.has(failedUdid)) {
Sentry.capture('iOS Simulator screen recording failed on every attempt', {
level: 'warning',
extras: { attempts },
});
}
}
return finalizedRecordings.map(recording => ({
udid: recording.udid,
deviceName: recording.deviceName,
runtimeDisplayName: recording.runtimeDisplayName,
@@ -174,18 +233,38 @@ async function pollIosSimulatorRecordingsAsync(
break;
}
listDevicesErrorCount = 0;
const readyServers = new Map(
(await readServeSimServersAsync(SERVE_SIM_STATE_DIR)).flatMap(server =>
server.token ? [[server.udid, server.token] as const] : []
)
);
const bootedUdids = new Set(bootedDevices.map(device => device.udid));
for (const udid of session.recordingFailureCounts.keys()) {
for (const udid of session.serveBearerTokens.keys()) {
if (!bootedUdids.has(udid)) {
session.recordingFailureCounts.delete(udid);
session.recordingRetryAt.delete(udid);
session.serveBearerTokens.delete(udid);
session.completedServeBearerTokens.delete(udid);
}
}
for (const device of bootedDevices) {
const token = readyServers.get(device.udid);
const packageSpec = serveSimPackageSpec;
if (!token || !packageSpec) {
continue;
}
if (session.serveBearerTokens.get(device.udid) !== token) {
session.serveBearerTokens.set(device.udid, token);
session.recordingFailureCounts.delete(device.udid);
session.recordingRetryAt.delete(device.udid);
session.completedServeBearerTokens.delete(device.udid);
}
if (
session.completedServeBearerTokens.get(device.udid) === token ||
session.activeRecordings.has(device.udid) ||
(session.recordingFailureCounts.get(device.udid) ?? 0) >= RECORD_SIM_MAX_ATTEMPTS_PER_BOOT
Date.now() < (session.recordingRetryAt.get(device.udid) ?? 0)
) {
continue;
}
@@ -193,6 +272,7 @@ async function pollIosSimulatorRecordingsAsync(
udid: device.udid,
deviceName: device.name,
runtimeDisplayName: device.runtimeDisplayName,
packageSpec,
});
}
} catch (err) {
@@ -227,50 +307,90 @@ async function startIosSimulatorRecordingAsync(
udid,
deviceName,
runtimeDisplayName,
packageSpec,
}: {
udid: IosSimulatorUuid;
deviceName: string;
runtimeDisplayName: string;
packageSpec: string;
}
): Promise<void> {
const startedAt = new Date();
const serveBearerToken = session.serveBearerTokens.get(udid);
const recordingId = randomUUID();
const outputDirectory = path.join(session.recordingsRootDirectory, recordingId);
await mkdir(outputDirectory, { recursive: true });
session.logger.info(`Starting screen recording for ${deviceName}.`);
const recorderExec = resolvePackageExec(
resolveConfiguredPackageManager(session.env, PackageManager.NPM),
[packageSpec, 'record-video']
);
const recordingSpawn = spawn(
session.recordSimCommand,
['--udid', udid, '--output', outputDirectory, '--segment-duration', '0'],
recorderExec.command,
[...recorderExec.args, '--udid', udid, '--output', outputDirectory],
{
env: session.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
}
);
const getOutput = captureProcessOutput(recordingSpawn.child);
const completionPromise = recordingSpawn
.then(() => undefined)
.catch((err: unknown) => {
session.recordingFailureCounts.set(udid, (session.recordingFailureCounts.get(udid) ?? 0) + 1);
const { getOutput, hasStarted } = captureProcessOutput(recordingSpawn.child);
const saveFinalizedRecordingAsync = async (): Promise<boolean> => {
if (
!(await waitForRecordingManifestAsync(outputDirectory, SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS))
) {
return false;
}
if (serveBearerToken) {
session.completedServeBearerTokens.set(udid, serveBearerToken);
}
session.completedRecordings.push({
id: recordingId,
udid,
deviceName,
runtimeDisplayName,
outputDirectory,
startedAt,
getOutput,
hasStarted,
});
return true;
};
const completionPromise = (async () => {
let spawnError: unknown;
try {
await recordingSpawn;
} catch (err) {
spawnError = err;
}
// A package-manager wrapper can exit while its record-video child still
// runs in the detached group. Keep this device active until the group exits.
await waitForRecordingProcessGroupExitAsync(recordingSpawn.child);
// Host shutdown can end the recorder's lease after its video has been saved.
if ((hasStarted() || !spawnError) && (await saveFinalizedRecordingAsync())) {
return;
}
if (spawnError) {
const err = spawnError;
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('iOS Simulator screen recording process failed', error);
session.logger.warn(
{ err: error, recordSimOutput: getOutput() },
{ err: error, recorderOutput: getOutput() },
`Screen recording process failed for ${deviceName}.`
);
})
.finally(() => {
session.activeRecordings.delete(udid);
session.completedRecordings.push({
id: recordingId,
udid,
deviceName,
runtimeDisplayName,
outputDirectory,
startedAt,
getOutput,
});
});
}
if (hasStarted()) {
reportLostRecording(session.logger, deviceName, getOutput().trim());
} else if (!spawnError) {
session.logger.warn(
{ recorderOutput: getOutput().trim() },
`Screen recording for ${deviceName} exited before it started; it will be retried.`
);
}
scheduleRecordingRetry(session, udid);
})().finally(() => {
session.activeRecordings.delete(udid);
});
session.activeRecordings.set(udid, {
id: recordingId,
@@ -282,31 +402,103 @@ async function startIosSimulatorRecordingAsync(
completionPromise,
startedAt,
getOutput,
hasStarted,
});
}
async function resolveRecordSimCommandAsync({ env }: { env: Env }): Promise<string | null> {
try {
await spawn('which', [RECORD_SIM_COMMAND], { env });
return RECORD_SIM_COMMAND;
} catch {}
async function waitForRecordingManifestAsync(
outputDirectory: string,
timeoutMs: number
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await access(path.join(outputDirectory, 'session.json'));
return true;
} catch {
await setTimeout(1_000);
}
}
return false;
}
const packagedRecordSimPath = path.join(__dirname, '..', '..', '..', 'bin', RECORD_SIM_COMMAND);
try {
await access(packagedRecordSimPath);
return packagedRecordSimPath;
} catch {
return null;
async function waitForRecorderStartAsync(
recording: ActiveIosSimulatorRecording,
timeoutMs: number
): Promise<'started' | 'exited' | 'timeout'> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (recording.hasStarted()) {
return 'started';
}
const exited = await Promise.race([
recording.completionPromise.then(() => true),
setTimeout(100, false),
]);
if (exited) {
return 'exited';
}
}
return 'timeout';
}
function reportLostRecording(logger: bunyan, deviceName: string, recorderOutput: string): void {
logger.warn(
{ recorderOutput },
`Screen recording for ${deviceName} ended without a manifest; its footage cannot be uploaded.`
);
Sentry.capture('iOS Simulator screen recording ended without a manifest', {
level: 'warning',
extras: { deviceName },
});
}
function scheduleRecordingRetry(
session: IosSimulatorRecordingSession,
udid: IosSimulatorUuid
): void {
session.failedAttemptCounts.set(udid, (session.failedAttemptCounts.get(udid) ?? 0) + 1);
const failures = (session.recordingFailureCounts.get(udid) ?? 0) + 1;
session.recordingFailureCounts.set(udid, failures);
session.recordingRetryAt.set(
udid,
Date.now() + Math.min(300_000, 25_000 * 2 ** Math.min(failures - 1, 4))
);
}
function signalRecorder(child: ChildProcess, signal: NodeJS.Signals): void {
// killProcessGroup skips a child without a pid.
if (child.pid === undefined) {
child.kill(signal);
return;
}
killProcessGroup(child, signal);
}
async function waitForRecordingProcessGroupExitAsync(child: ChildProcess): Promise<void> {
if (child.pid === undefined) {
return;
}
// Bounded by finishAsync's SIGKILL; unref'd so it cannot keep the worker alive.
while (isProcessGroupRunning(child.pid)) {
await setTimeout(100, undefined, { ref: false });
}
}
function captureProcessOutput(recordingProcess: ChildProcess): () => string {
function captureProcessOutput(recordingProcess: ChildProcess): {
getOutput: () => string;
hasStarted: () => boolean;
} {
let output = '';
let started = false;
const appendChunk = (chunk: Buffer | string): void => {
// Keep enough recorder stderr/stdout for diagnostics without retaining unbounded output.
output = `${output}${chunk.toString()}`.slice(-16_384);
output = `${output}${chunk.toString()}`;
if (output.includes(SERVE_SIM_RECORDING_STARTED)) {
started = true;
}
output = output.slice(-16_384);
};
recordingProcess.stdout?.on('data', appendChunk);
recordingProcess.stderr?.on('data', appendChunk);
return () => output;
return { getOutput: () => output, hasStarted: () => started };
}
@@ -0,0 +1,539 @@
import { type Env } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { type ChildProcess } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { rm, writeFile } from 'node:fs/promises';
import { PassThrough } from 'node:stream';
import { setTimeout } from 'node:timers/promises';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { Sentry } from '../../../sentry';
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
jest.mock('@expo/turtle-spawn');
jest.mock('../serveSimMetricsRecorder');
jest.mock('../../../sentry');
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as unknown as bunyan;
const env = {} as Env;
const udid = '06B546AC-7B06-4BED-83DB-83603E8EB537';
beforeEach(() => {
IosSimulatorRecordingUtils.useServeSimPackage('@expo/serve-sim@next');
jest
.spyOn(IosSimulatorUtils, 'getAvailableDevicesAsync')
.mockResolvedValue([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' } as never]);
jest.mocked(readServeSimServersAsync).mockReset();
jest.mocked(spawn).mockReset();
});
afterEach(async () => {
await IosSimulatorRecordingUtils.finishAsync({ logger });
jest.restoreAllMocks();
});
async function waitForRecorderSpawnAsync(): Promise<void> {
for (let attempt = 0; attempt < 50; attempt++) {
if (jest.mocked(spawn).mock.calls.length > 0) {
return;
}
await setTimeout(10);
}
throw new Error('serve-sim recorder did not start');
}
// A recorder the test controls: `exit` ends it cleanly, `fail` ends it with an error.
function mockRecorder({ kill }: { kill?: (signal: NodeJS.Signals) => void } = {}): {
child: ChildProcess;
exit: () => void;
fail: (error: Error) => void;
} {
let exit: () => void = () => {};
let fail: (error: Error) => void = () => {};
const child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn((signal: NodeJS.Signals) => {
kill?.(signal);
return true;
}),
}) as unknown as ChildProcess;
const done = new Promise<void>((resolve, reject) => {
exit = resolve;
fail = reject;
});
jest.mocked(spawn).mockReturnValue(Object.assign(done, { child }) as never);
return { child, exit: () => exit(), fail: error => fail(error) };
}
function mockFailingRecorder(): void {
jest.mocked(spawn).mockImplementation((_command, args) => {
void writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
return Object.assign(Promise.reject(new Error('recorder exited')), {
child: {
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn(),
},
}) as never;
});
}
test('waits for a token-gated serve-sim session before starting a recorder', async () => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563' }]);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await IosSimulatorRecordingUtils.finishAsync({ logger });
expect(jest.mocked(spawn)).not.toHaveBeenCalled();
});
test('starts serve-sim record-video and returns only a finalized recording', async () => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
let resolveProcess: () => void = () => {};
const stderr = new PassThrough();
const child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr,
kill: jest.fn(),
}) as unknown as ChildProcess;
const processPromise = Object.assign(
new Promise<void>(resolve => {
resolveProcess = resolve;
}),
{
child,
}
);
jest.mocked(spawn).mockReturnValue(processPromise as never);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
const [command, args, options] = jest.mocked(spawn).mock.calls[0];
expect(command).toBe('npx');
expect(args.slice(0, 5)).toEqual([
'--yes',
'@expo/serve-sim@next',
'record-video',
'--udid',
udid,
]);
expect(args[5]).toBe('--output');
expect(args).not.toContain('--segment-duration');
expect(options?.detached).toBe(true);
const outputDirectory = args[6];
jest.mocked(child.kill).mockImplementation(() => {
resolveProcess();
void setTimeout(20).then(() =>
writeFile(`${outputDirectory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
);
return true;
});
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await setTimeout(20);
expect(child.kill).not.toHaveBeenCalled();
stderr.write('serve-sim:recording-started\n');
stderr.write('x'.repeat(17_000));
const recordings = await finishing;
expect(child.kill).toHaveBeenCalledWith('SIGINT');
expect(recordings).toEqual([
{
udid,
deviceName: 'iPhone 17 Pro',
runtimeDisplayName: 'iOS 26.4',
directory: outputDirectory,
},
]);
});
test('reports a started recording that ends without a manifest to Sentry', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
let exitRecorder: () => void = () => {};
const child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn(),
}) as unknown as ChildProcess;
jest.mocked(spawn).mockReturnValue(
Object.assign(
new Promise<void>(resolve => {
exitRecorder = resolve;
}),
{ child }
) as never
);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
child.stderr?.emit('data', 'serve-sim:recording-started\n');
child.stderr?.emit('data', 'GET http://127.0.0.1:43563/record?token=session-token failed\n');
exitRecorder();
await setTimeout(50);
currentTime += 31_000;
await setTimeout(1_100);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator screen recording ended without a manifest',
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
);
expect(JSON.stringify(jest.mocked(Sentry.capture).mock.calls)).not.toContain('session-token');
});
test('says when a recorder never reported its start before it is stopped', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
let exitRecorder: () => void = () => {};
const child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn(() => {
exitRecorder();
return true;
}),
}) as unknown as ChildProcess;
jest.mocked(spawn).mockReturnValue(
Object.assign(
new Promise<void>(resolve => {
exitRecorder = resolve;
}),
{ child }
) as never
);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await setTimeout(50);
currentTime += 61_000;
await setTimeout(200);
currentTime += 31_000;
expect(await finishing).toEqual([]);
expect(child.kill).toHaveBeenCalledWith('SIGINT');
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
'Screen recording for iPhone 17 Pro did not report its start within 60 seconds and will be stopped.'
);
});
test('retries after a failed recorder when the serve-sim session token changes', async () => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValueOnce([{ udid, url: 'http://127.0.0.1:43563', token: 'old-token' }])
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'new-token' }]);
mockFailingRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
await setTimeout(2_100);
expect(spawn).toHaveBeenCalledTimes(2);
expect(Sentry.capture).not.toHaveBeenCalled();
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator screen recording failed on every attempt',
expect.objectContaining({ level: 'warning', extras: { attempts: 2 } })
);
});
test('reports a device that failed and then shut down', async () => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
mockFailingRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([]);
await setTimeout(2_100);
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator screen recording failed on every attempt',
expect.objectContaining({ extras: { attempts: 1 } })
);
});
test('retries after the server lease expires without a token change', async () => {
const startTime = Date.now();
let currentTime = startTime;
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
mockFailingRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
currentTime += 26_000;
await setTimeout(2_100);
expect(spawn).toHaveBeenCalledTimes(2);
});
test('does not start a second recording for a server token after a late manifest', async () => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
jest.mocked(spawn).mockImplementation((_command, args) => {
const child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn(),
}) as unknown as ChildProcess;
void setTimeout(20).then(() =>
writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
);
return Object.assign(Promise.resolve(), { child }) as never;
});
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
await setTimeout(2_100);
expect(spawn).toHaveBeenCalledTimes(1);
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toHaveLength(1);
});
test('keeps a recorder active after its package-manager wrapper exits', async () => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
let groupRunning = true;
const originalKill = process.kill.bind(process);
const signalGroup = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
if (pid !== -4321) {
return originalKill(pid, signal);
}
if (signal === 0) {
if (groupRunning) {
return true;
}
throw Object.assign(new Error('No such process group'), { code: 'ESRCH' });
}
if (signal === 'SIGINT') {
void setTimeout(20).then(async () => {
const args = jest.mocked(spawn).mock.calls[0][1];
await writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
groupRunning = false;
});
return true;
}
throw new Error(`Unexpected signal: ${signal}`);
});
const child = Object.assign(new EventEmitter(), {
pid: 4321,
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn(),
}) as unknown as ChildProcess;
jest.mocked(spawn).mockReturnValue(Object.assign(Promise.resolve(), { child }) as never);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
child.stderr?.emit('data', 'serve-sim:recording-started\n');
await setTimeout(2_100);
expect(spawn).toHaveBeenCalledTimes(1);
const recordings = await IosSimulatorRecordingUtils.finishAsync({ logger });
expect(signalGroup).toHaveBeenCalledWith(-4321, 'SIGINT');
expect(child.kill).not.toHaveBeenCalled();
expect(recordings).toHaveLength(1);
});
test('does not signal a recorder that exits before it reports its start', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
const recorder = mockRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await setTimeout(50);
recorder.exit();
await setTimeout(50);
// Past the manifest wait, so the recorder's completion settles.
currentTime += 31_000;
expect(await finishing).toEqual([]);
expect(recorder.child.kill).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
'Screen recording for iPhone 17 Pro exited before it started; it will be retried.'
);
});
test('kills a recorder that ignores SIGINT and reports one that survives SIGKILL', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
const recorder = mockRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
recorder.child.stderr?.emit('data', 'GET /record?token=session-token\n');
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await setTimeout(50);
currentTime += 61_000;
expect(await finishing).toEqual([]);
expect(recorder.child.kill).toHaveBeenCalledWith('SIGINT');
expect(recorder.child.kill).toHaveBeenCalledWith('SIGKILL');
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining(
'Screen recording for iPhone 17 Pro did not stop within 5 seconds of SIGINT and will be killed.'
)
);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator recording process for iPhone 17 Pro did not exit after SIGKILL.'
);
expect(JSON.stringify(jest.mocked(Sentry.capture).mock.calls)).not.toContain('session-token');
}, 20_000);
test('reports a finalized recording whose manifest is gone at finish', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
const recorder = mockRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
const manifest = `${jest.mocked(spawn).mock.calls[0][1][6]}/session.json`;
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
await writeFile(manifest, JSON.stringify({ recording: 'recording.mp4' }));
recorder.exit();
await setTimeout(100);
await rm(manifest);
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await setTimeout(50);
currentTime += 31_000;
expect(await finishing).toEqual([]);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator screen recording ended without a manifest',
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
);
});
test.each([0, 20])(
'keeps a started recording that exits with an error when its manifest appears after %i ms',
async manifestDelayMs => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
const recorder = mockRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
const outputDirectory = jest.mocked(spawn).mock.calls[0][1][6];
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
const manifestWritten = setTimeout(manifestDelayMs).then(() =>
writeFile(`${outputDirectory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
);
if (manifestDelayMs === 0) {
await manifestWritten;
}
recorder.fail(new Error('record-video exited with code 1'));
const recordings = await IosSimulatorRecordingUtils.finishAsync({ logger });
await manifestWritten;
expect(recordings).toEqual([
{
udid,
deviceName: 'iPhone 17 Pro',
runtimeDisplayName: 'iOS 26.4',
directory: outputDirectory,
},
]);
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
}
);
test('reports a started recorder that fails without a manifest', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
const recorder = mockRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
recorder.fail(new Error('record-video exited with code 1'));
await setTimeout(50);
currentTime += 31_000;
await setTimeout(1_100);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
'Screen recording process failed for iPhone 17 Pro.'
);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator screen recording ended without a manifest',
expect.objectContaining({ level: 'warning' })
);
});
test('records every booted device with the first serve-sim package', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
const secondUdid = '1C17CD51-5926-4838-951B-6D941C7BEDFF';
IosSimulatorRecordingUtils.useServeSimPackage('@expo/serve-sim@later');
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([
{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' } as never,
{ udid: secondUdid, name: 'iPhone 17', runtimeDisplayName: 'iOS 26.4' } as never,
]);
jest.mocked(readServeSimServersAsync).mockResolvedValue([
{ udid, url: 'http://127.0.0.1:43563', token: 'first-token' },
{ udid: secondUdid, url: 'http://127.0.0.1:43563', token: 'second-token' },
]);
const recorder = mockRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
for (let attempt = 0; attempt < 50 && jest.mocked(spawn).mock.calls.length < 2; attempt++) {
await setTimeout(10);
}
const calls = jest.mocked(spawn).mock.calls.map(([, args]) => args);
expect(calls.map(args => args[4]).sort()).toEqual([udid, secondUdid].sort());
expect(calls.every(args => args[1] === '@expo/serve-sim@next')).toBe(true);
recorder.exit();
await setTimeout(50);
currentTime += 31_000;
await IosSimulatorRecordingUtils.finishAsync({ logger });
});
@@ -104,6 +104,39 @@ describe(listAgentDeviceArtifactsAsync, () => {
})
).rejects.toThrow('agent-device daemon does not expose artifact inventory.');
});
it('keeps a screenshot display rotation and drops an unknown one', async () => {
jest.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({
artifacts: [
{
id: 'rotated',
artifactType: 'screenshot',
filename: 'rotated.png',
displayRotation: 'landscape-left',
},
{
id: 'future',
artifactType: 'screenshot',
filename: 'future.png',
displayRotation: 'sideways',
},
],
})
)
);
const artifacts = await listAgentDeviceArtifactsAsync({
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
});
expect(artifacts.map(artifact => artifact.displayRotation)).toEqual([
'landscape-left',
undefined,
]);
});
});
describe(uploadAgentDeviceArtifactAsync, () => {
@@ -181,6 +214,63 @@ describe(uploadAgentDeviceArtifactAsync, () => {
})
);
});
it('uploads a screenshot display rotation as artifact metadata', async () => {
const data = Buffer.from('png-data');
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadAgentDeviceArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger: createLoggerMock(),
artifact: {
id: 'artifact-id',
artifactType: 'screenshot',
filename: 'shot.png',
displayRotation: 'landscape-right',
},
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
kind: 'screenshot',
metadata: { displayRotation: 'landscape-right' },
})
);
});
it('uploads no metadata when the artifact has no display rotation', async () => {
const data = Buffer.from('artifact-data');
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadAgentDeviceArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger: createLoggerMock(),
artifact: { id: 'artifact-id', artifactType: 'screenshot', filename: 'shot.png' },
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls[0][1]).not.toHaveProperty(
'metadata'
);
});
});
describe(pollAgentDeviceArtifactsForUploadAsync, () => {
@@ -0,0 +1,400 @@
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { Client, fetchExchange } from '@urql/core';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';
import os from 'node:os';
import fetch from 'node-fetch';
import { type CustomBuildContext } from '../../../customBuildContext';
import {
captureDeviceRunSessionPreviewAsync,
ensureMacosPreviewEncoderInstalledAsync,
startDeviceRunSessionPreview,
} from '../deviceRunSessionPreview';
jest.mock('node-fetch');
jest.mock('@expo/turtle-spawn');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
describe(startDeviceRunSessionPreview, () => {
const image = Buffer.from('webp-image');
let captureAsync: jest.Mock<Promise<Buffer>, [AbortSignal]>;
let mutation: jest.Mock;
let logger: bunyan;
let ctx: CustomBuildContext;
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
beforeEach(() => {
jest.useFakeTimers();
captureAsync = jest.fn().mockResolvedValue(image);
mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createPreviewUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/preview',
headers: { 'content-type': 'image/webp' },
},
},
},
},
}),
});
ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
logger = { warn: jest.fn() } as unknown as bunyan;
jest
.mocked(fetch)
.mockReset()
.mockResolvedValue(new Response('', { status: 200 }));
});
afterEach(async () => {
await preview?.stopAsync();
preview = undefined;
jest.useRealTimers();
});
const start = () => {
preview = startDeviceRunSessionPreview({
ctx,
deviceRunSessionId: 'session-id',
captureAsync,
logger,
});
return preview;
};
it('uploads immediately and reuses the URL every 60 seconds', async () => {
start();
await jest.advanceTimersByTimeAsync(0);
expect(fetch).toHaveBeenCalledWith(
'https://uploads.expo.test/preview',
expect.objectContaining({
method: 'PUT',
body: image,
headers: { 'content-type': 'image/webp' },
})
);
await jest.advanceTimersByTimeAsync(59_999);
expect(captureAsync).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(captureAsync).toHaveBeenCalledTimes(2);
expect(mutation).toHaveBeenCalledTimes(1);
await preview!.stopAsync();
await jest.advanceTimersByTimeAsync(120_000);
expect(captureAsync).toHaveBeenCalledTimes(2);
});
it('retries capture errors without creating an empty artifact', async () => {
captureAsync.mockRejectedValueOnce(new Error('device not ready'));
start();
await jest.advanceTimersByTimeAsync(0);
expect(mutation).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('retries a failed upload with the same URL', async () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 }));
start();
await jest.advanceTimersByTimeAsync(60_000);
expect(mutation).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('does not log the signed upload URL when the upload request fails', async () => {
const { FetchError } = jest.requireActual('node-fetch') as typeof import('node-fetch');
jest
.mocked(fetch)
.mockRejectedValueOnce(
new FetchError('network timeout at: https://uploads.expo.test/preview', 'request-timeout')
);
start();
await jest.advanceTimersByTimeAsync(0);
expect(logger.warn).toHaveBeenCalledTimes(1);
const [fields] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
expect(fields.err.message).toBe(
'Session preview upload request failed (name: FetchError, type: request-timeout).'
);
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('uploads.expo.test');
});
it('reuses the upload URL for the full session', async () => {
start();
await jest.advanceTimersByTimeAsync(119 * 60_000);
expect(mutation).toHaveBeenCalledTimes(1);
});
it('does not overlap captures and aborts pending work when stopped', async () => {
captureAsync.mockImplementation(
signal =>
new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
})
);
const handle = start();
await jest.advanceTimersByTimeAsync(60_000);
expect(captureAsync).toHaveBeenCalledTimes(1);
await handle.stopAsync();
expect(captureAsync.mock.calls[0][0].aborted).toBe(true);
expect(fetch).not.toHaveBeenCalled();
});
it('does not upload a capture that finishes after shutdown', async () => {
let finish!: (image: Buffer) => void;
captureAsync.mockImplementation(
() =>
new Promise(resolve => {
finish = resolve;
})
);
const handle = start();
const stopping = handle.stopAsync();
finish(image);
await stopping;
expect(mutation).not.toHaveBeenCalled();
});
});
describe('session preview with the real GraphQL client', () => {
const uploadSession = {
url: 'https://uploads.expo.test/preview',
headers: { 'content-type': 'image/webp' },
};
let graphqlFetch: jest.SpyInstance;
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
let logger: bunyan;
beforeEach(() => {
jest.useFakeTimers();
graphqlFetch = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
new globalThis.Response(
JSON.stringify({
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
}),
{ headers: { 'content-type': 'application/json' } }
)
);
logger = { warn: jest.fn() } as unknown as bunyan;
jest
.mocked(fetch)
.mockReset()
.mockResolvedValue(new Response('', { status: 200 }));
});
afterEach(async () => {
await preview?.stopAsync();
preview = undefined;
jest.restoreAllMocks();
jest.useRealTimers();
});
const start = () => {
const graphqlClient = new Client({
url: 'https://api.expo.test/graphql',
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer worker-token' } },
});
preview = startDeviceRunSessionPreview({
ctx: { graphqlClient } as CustomBuildContext,
deviceRunSessionId: 'session-id',
captureAsync: async () => Buffer.from('webp-image'),
logger,
});
return preview;
};
it('preserves the worker authentication when creating the upload session', async () => {
start();
await jest.advanceTimersByTimeAsync(0);
const [, options] = graphqlFetch.mock.calls[0];
expect(new Headers(options.headers).get('authorization')).toBe('Bearer worker-token');
expect(fetch).toHaveBeenCalledTimes(1);
});
it.each(['shutdown', 'timeout'])('aborts a stalled GraphQL request on %s', async reason => {
const timeout = new AbortController();
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal);
let requestSignal: AbortSignal | undefined;
let rejectRequest!: (error: Error) => void;
graphqlFetch.mockImplementation(
(_url, options) =>
new Promise((_, reject) => {
rejectRequest = reject;
requestSignal = options.signal;
requestSignal!.addEventListener('abort', () => reject(requestSignal!.reason), {
once: true,
});
})
);
const handle = start();
await jest.advanceTimersByTimeAsync(0);
let stopped = false;
let stopping: Promise<void> | undefined;
if (reason === 'shutdown') {
stopping = handle.stopAsync().then(() => {
stopped = true;
});
} else {
timeout.abort(new Error('preview attempt timed out'));
}
await jest.advanceTimersByTimeAsync(0);
const aborted = requestSignal!.aborted;
const stoppedAfterAbort = stopped;
// Always release the fake transport so a regression cannot hang test cleanup.
rejectRequest(new Error('test cleanup'));
await jest.advanceTimersByTimeAsync(0);
expect(aborted).toBe(true);
expect(fetch).not.toHaveBeenCalled();
if (stopping) {
expect(stoppedAfterAbort).toBe(true);
await stopping;
} else {
expect(logger.warn).toHaveBeenCalled();
graphqlFetch.mockResolvedValue(
new globalThis.Response(
JSON.stringify({
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
}),
{ headers: { 'content-type': 'application/json' } }
)
);
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(new AbortController().signal);
await jest.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
}
});
});
describe(captureDeviceRunSessionPreviewAsync, () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00]);
const webp = Buffer.from('encoded-webp');
const resized = Buffer.from('resized-png');
it.each([
[BuildRuntimePlatform.DARWIN, 'selected-device'],
[BuildRuntimePlatform.LINUX, 'selected-device'],
[BuildRuntimePlatform.LINUX, 'no-device-id'],
] as const)(
'captures the selected device on %s and removes temporary files',
async (runtimePlatform, device) => {
jest.mocked(spawn).mockImplementation((command, args) => {
if (command === 'adb' && args[0] === 'devices') {
return Promise.resolve({
stdout: 'List of devices attached\nselected-device\tdevice\noffline-device\toffline\n',
}) as ReturnType<typeof spawn>;
}
if (command === 'adb') {
expect(args).toEqual(['-s', 'selected-device', 'exec-out', 'screencap', '-p']);
return Object.assign(Promise.resolve({ stdout: '', stderr: '' }), {
child: { stdout: Readable.from([png]) },
}) as ReturnType<typeof spawn>;
}
return (async () => {
if (command === 'xcrun') {
expect(args.slice(0, 4)).toEqual(['simctl', 'io', 'selected-device', 'screenshot']);
await writeFile(args[4], Uint8Array.from(png));
} else if (command === 'sips') {
expect(args.slice(0, 2)).toEqual(['-Z', '320']);
expect(await readFile(args[2])).toEqual(png);
await writeFile(args[args.indexOf('--out') + 1], Uint8Array.from(resized));
} else if (command === 'cwebp') {
expect(await readFile(args[args.indexOf('-o') - 1])).toEqual(resized);
await writeFile(args[args.indexOf('-o') + 1], Uint8Array.from(webp));
} else {
// Homebrew's FFmpeg cannot encode WebP, so macOS must not use it.
expect(runtimePlatform).toBe(BuildRuntimePlatform.LINUX);
expect(command).toBe('ffmpeg');
expect(await readFile(args[args.indexOf('-i') + 1])).toEqual(png);
expect(args).toContain('libwebp');
await writeFile(args[args.length - 1], Uint8Array.from(webp));
}
return { stdout: '', stderr: '' };
})() as ReturnType<typeof spawn>;
});
expect(
await captureDeviceRunSessionPreviewAsync({
runtimePlatform,
device,
env: {},
signal: new AbortController().signal,
})
).toEqual(webp);
expect(
(await readdir(os.tmpdir())).filter(name => name.startsWith('session-preview-'))
).toEqual([]);
}
);
it('skips ambiguous Android devices instead of capturing the wrong screen', async () => {
jest
.mocked(spawn)
.mockResolvedValue({ stdout: 'device-one\tdevice\ndevice-two\tdevice\n' } as Awaited<
ReturnType<typeof spawn>
>);
await expect(
captureDeviceRunSessionPreviewAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
device: 'no-device-id',
env: {},
signal: new AbortController().signal,
})
).rejects.toThrow('Expected exactly one connected Android device');
});
it('cleans up temporary files when capture fails', async () => {
jest.mocked(spawn).mockRejectedValue(new Error('capture failed'));
await expect(
captureDeviceRunSessionPreviewAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
device: 'device',
env: {},
signal: new AbortController().signal,
})
).rejects.toThrow('capture failed');
expect(
(await readdir(os.tmpdir())).filter(name => name.startsWith('session-preview-'))
).toEqual([]);
});
});
describe(ensureMacosPreviewEncoderInstalledAsync, () => {
const logger = { info: jest.fn() } as unknown as bunyan;
it.each([true, false])(
'installs webp only when cwebp is missing (present: %s)',
async present => {
jest.mocked(spawn).mockReset();
jest
.mocked(spawn)
.mockImplementation(
command =>
(command === 'cwebp' && !present
? Promise.reject(new Error('command not found: cwebp'))
: Promise.resolve({ stdout: '', stderr: '' })) as ReturnType<typeof spawn>
);
await ensureMacosPreviewEncoderInstalledAsync({ env: { HOME: '/Users/expo' }, logger });
const brewCalls = jest.mocked(spawn).mock.calls.filter(([command]) => command === 'brew');
if (present) {
expect(brewCalls).toEqual([]);
} else {
expect(brewCalls).toEqual([
[
'brew',
['install', 'webp'],
{ env: { HOME: '/Users/expo', HOMEBREW_NO_AUTO_UPDATE: '1' }, logger },
],
]);
}
}
);
it('rejects when webp cannot be installed', async () => {
jest.mocked(spawn).mockReset();
jest.mocked(spawn).mockRejectedValue(new Error('brew failed'));
await expect(ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger })).rejects.toThrow(
'brew failed'
);
});
});
@@ -0,0 +1,200 @@
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import { Client, fetchExchange } from '@urql/core';
import { execFileSync } from 'node:child_process';
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { CustomBuildContext } from '../../../customBuildContext';
import {
findUnlistedDeviceScreenRecordingsAsync,
uploadDeviceRunSessionScreenRecordingsAsync,
} from '../deviceRunSessionScreenRecordings';
// Runs with `yarn jest-ffmpeg`, which needs ffmpeg and ffprobe on PATH.
jest.unmock('node-fetch');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
let handle: (request: IncomingMessage, response: ServerResponse) => void;
let server: ReturnType<typeof createServer>;
let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creationBodies: string[];
beforeEach(async () => {
creationBodies = [];
directory = await mkdtemp(path.join(tmpdir(), 'recording-recovery-'));
server = createServer((request, response) => {
if (request.url === '/graphql') {
request.setEncoding('utf8');
let body = '';
request.on('data', chunk => (body += chunk));
request.on('end', () => {
creationBodies.push(body);
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact`, headers: {} },
},
},
},
})
);
});
} else {
handle(request, response);
}
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Missing test server address');
}
url = `http://127.0.0.1:${address.port}`;
ctx = {
graphqlClient: new Client({
url: `${url}/graphql`,
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
}),
} as CustomBuildContext;
});
afterEach(async () => {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
await rm(directory, { recursive: true, force: true });
});
it('recovers unlisted recordings from a killed host and flags the unfinished ones as partial', async () => {
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const env = process.env as BuildStepEnv;
const cut = path.join(directory, 'cut');
const empty = path.join(directory, 'empty');
const failed = path.join(directory, 'failed');
const done = path.join(directory, 'done');
await mkdir(cut);
await mkdir(empty);
await mkdir(failed);
await mkdir(done);
const manifest = {
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
status: 'recording',
recording: 'recording.mp4.partial',
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
width: 128,
height: 96,
};
execFileSync('ffmpeg', [
'-v',
'error',
'-f',
'lavfi',
'-i',
'color=c=red:s=128x96:r=1',
'-t',
'3',
'-c:v',
'libx264',
'-g',
'1',
'-pix_fmt',
'yuv420p',
'-movflags',
'frag_keyframe+empty_moov',
'-f',
'mp4',
path.join(cut, 'recording.mp4.partial'),
]);
await writeFile(path.join(cut, 'session.json'), JSON.stringify(manifest));
await writeFile(path.join(empty, 'recording.mp4.partial'), new Uint8Array(28));
await writeFile(path.join(empty, 'session.json'), JSON.stringify(manifest));
await copyFile(
path.join(cut, 'recording.mp4.partial'),
path.join(failed, 'recording.mp4.partial')
);
await writeFile(
path.join(failed, 'session.json'),
JSON.stringify({
...manifest,
status: 'failed',
error: 'Recording finalization exceeded 30000 ms.',
})
);
await copyFile(path.join(cut, 'recording.mp4.partial'), path.join(done, 'recording.mp4'));
await writeFile(
path.join(done, 'session.json'),
JSON.stringify({ ...manifest, status: 'complete', recording: 'recording.mp4' })
);
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
root: directory,
env,
logger,
});
expect(recordings).toEqual([
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: cut,
},
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: done,
},
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: failed,
},
]);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
let putBytes = 0;
handle = (request, response) => {
request.on('data', chunk => (putBytes += chunk.length));
request.on('end', () => {
response.statusCode = 200;
response.end();
});
};
await expect(
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
logger,
deviceRunSessionId: 'drs-id',
recordings,
})
).resolves.toBe(true);
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
const inputs = creationBodies
.map(body => JSON.parse(body).variables.input)
.sort((a, b) => a.filename.localeCompare(b.filename));
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
expect(inputs[1].metadata).not.toHaveProperty('partial');
expect(inputs[0].metadata).toMatchObject({
partial: true,
partialReason: 'The Device Hub stopped before it could finalize the recording.',
width: 128,
height: 96,
});
expect(inputs[2].metadata).toMatchObject({
partial: true,
partialReason: 'Recording finalization exceeded 30000 ms.',
});
});
@@ -1,9 +1,8 @@
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import { Client, fetchExchange } from '@urql/core';
import { execFileSync } from 'node:child_process';
import { createReadStream } from 'node:fs';
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import type { Socket } from 'node:net';
import { tmpdir } from 'node:os';
@@ -13,10 +12,7 @@ import { setTimeout as delay } from 'node:timers/promises';
import type { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import {
findUnlistedDeviceScreenRecordingsAsync,
uploadDeviceRunSessionScreenRecordingsAsync,
} from '../deviceRunSessionScreenRecordings';
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
jest.unmock('node-fetch');
jest.unmock('node:fs');
@@ -47,13 +43,11 @@ let creates: number;
let authorization: string | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
let creationBodies: string[];
beforeEach(async () => {
creates = 0;
authorization = undefined;
stallCreation = false;
creationBodies = [];
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
@@ -61,10 +55,7 @@ beforeEach(async () => {
creates++;
authorization = request.headers.authorization;
creationStarted.resolve(request);
request.setEncoding('utf8');
let body = '';
request.on('data', chunk => (body += chunk));
request.on('end', () => creationBodies.push(body));
request.resume();
request.on('end', () => {
if (stallCreation) {
return;
@@ -336,128 +327,3 @@ it('says ffprobe is missing instead of calling an unlisted recording undecodable
'ffprobe is not available; cannot recover the unlisted recording session/recording.mp4.partial.'
);
});
it('recovers unlisted recordings from a killed host and flags the unfinished ones as partial', async () => {
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const env = process.env as BuildStepEnv;
const cut = path.join(directory, 'cut');
const empty = path.join(directory, 'empty');
const failed = path.join(directory, 'failed');
const done = path.join(directory, 'done');
await mkdir(cut);
await mkdir(empty);
await mkdir(failed);
await mkdir(done);
const manifest = {
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
status: 'recording',
recording: 'recording.mp4.partial',
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
width: 128,
height: 96,
};
execFileSync('ffmpeg', [
'-v',
'error',
'-f',
'lavfi',
'-i',
'color=c=red:s=128x96:r=1',
'-t',
'3',
'-c:v',
'libx264',
'-g',
'1',
'-pix_fmt',
'yuv420p',
'-movflags',
'frag_keyframe+empty_moov',
'-f',
'mp4',
path.join(cut, 'recording.mp4.partial'),
]);
await writeFile(path.join(cut, 'session.json'), JSON.stringify(manifest));
await writeFile(path.join(empty, 'recording.mp4.partial'), new Uint8Array(28));
await writeFile(path.join(empty, 'session.json'), JSON.stringify(manifest));
await copyFile(
path.join(cut, 'recording.mp4.partial'),
path.join(failed, 'recording.mp4.partial')
);
await writeFile(
path.join(failed, 'session.json'),
JSON.stringify({
...manifest,
status: 'failed',
error: 'Recording finalization exceeded 30000 ms.',
})
);
await copyFile(path.join(cut, 'recording.mp4.partial'), path.join(done, 'recording.mp4'));
await writeFile(
path.join(done, 'session.json'),
JSON.stringify({ ...manifest, status: 'complete', recording: 'recording.mp4' })
);
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
root: directory,
env,
logger,
});
expect(recordings).toEqual([
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: cut,
},
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: done,
},
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: failed,
},
]);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
let putBytes = 0;
handle = (request, response) => {
request.on('data', chunk => (putBytes += chunk.length));
request.on('end', () => {
response.statusCode = 200;
response.end();
});
};
await expect(
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
logger,
deviceRunSessionId: 'drs-id',
recordings,
})
).resolves.toBe(true);
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
const inputs = creationBodies
.map(body => JSON.parse(body).variables.input)
.sort((a, b) => a.filename.localeCompare(b.filename));
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
expect(inputs[1].metadata).not.toHaveProperty('partial');
expect(inputs[0].metadata).toMatchObject({
partial: true,
partialReason: 'The Device Hub stopped before it could finalize the recording.',
width: 128,
height: 96,
});
expect(inputs[2].metadata).toMatchObject({
partial: true,
partialReason: 'Recording finalization exceeded 30000 ms.',
});
});
@@ -12,8 +12,12 @@ import {
findUnlistedDeviceScreenRecordingsAsync,
uploadDeviceRunSessionScreenRecordingsAsync,
} from '../deviceRunSessionScreenRecordings';
import {
ensureMacosPreviewEncoderInstalledAsync,
startDeviceRunSessionPreview,
} from '../deviceRunSessionPreview';
import { startDeviceSessionHostAsync } from '../deviceSessionHost';
import { spawnDetached } from '../remoteDeviceRunSession';
import { ensureFfmpegInstalledOnceAsync, spawnDetached } from '../remoteDeviceRunSession';
jest.mock('@ngrok/ngrok');
jest.mock('../deviceRunSessionArtifacts');
@@ -30,6 +34,11 @@ jest.mock('../remoteDeviceRunSession', () => ({
fetchWebPreviewTurnArgsAsync: jest.fn().mockResolvedValue([]),
spawnDetached: jest.fn(),
}));
jest.mock('../deviceRunSessionPreview', () => ({
...jest.requireActual('../deviceRunSessionPreview'),
ensureMacosPreviewEncoderInstalledAsync: jest.fn(),
startDeviceRunSessionPreview: jest.fn(),
}));
jest.mock('../deviceRunSessionScreenRecordings', () => ({
...jest.requireActual('../deviceRunSessionScreenRecordings'),
findUnlistedDeviceScreenRecordingsAsync: jest.fn(),
@@ -64,8 +73,14 @@ async function startHostAsync() {
});
}
const stopSessionPreview = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
stopSessionPreview.mockResolvedValue(undefined);
jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview });
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
jest.mocked(ensureMacosPreviewEncoderInstalledAsync).mockResolvedValue(undefined);
stopServer.mockResolvedValue(undefined);
closeTunnel.mockResolvedValue(undefined);
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset().mockResolvedValue(false);
@@ -502,3 +517,60 @@ it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])(
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
}
);
it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])(
'refreshes the session preview of the ready device until the %s host finishes',
async runtimePlatform => {
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
env,
logger,
timeoutMs: 10_000,
});
await new Promise(resolve => setImmediate(resolve));
expect(startDeviceRunSessionPreview).toHaveBeenCalledTimes(1);
expect(startDeviceRunSessionPreview).toHaveBeenCalledWith(
expect.objectContaining({ deviceRunSessionId: 'drs-id', captureAsync: expect.any(Function) })
);
await host.finishAsync();
expect(stopSessionPreview).toHaveBeenCalledTimes(1);
expect(stopSessionPreview.mock.invocationCallOrder[0]).toBeLessThan(
stopServer.mock.invocationCallOrder[0]
);
}
);
it('does not delay macOS readiness on encoder setup or start the preview after finish', async () => {
const install = deferred<void>();
jest.mocked(ensureMacosPreviewEncoderInstalledAsync).mockReturnValueOnce(install.promise);
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger,
timeoutMs: 10_000,
});
await host.finishAsync();
install.resolve();
await new Promise(resolve => setImmediate(resolve));
expect(startDeviceRunSessionPreview).not.toHaveBeenCalled();
expect(stopServer).toHaveBeenCalledTimes(1);
});
it('keeps the session running when encoder setup for the preview fails', async () => {
jest
.mocked(ensureMacosPreviewEncoderInstalledAsync)
.mockRejectedValueOnce(new Error('brew failed'));
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger,
timeoutMs: 10_000,
});
await new Promise(resolve => setImmediate(resolve));
expect(startDeviceRunSessionPreview).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.objectContaining({ message: 'brew failed' }) },
'Could not start refreshing the session preview.'
);
await host.finishAsync();
});
@@ -16,6 +16,11 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
IosSimulatorRecordingUtils,
SERVE_SIM_STOP_GRACE_PERIOD_MS,
} from '../IosSimulatorRecordingUtils';
import * as remoteDeviceRunSession from '../remoteDeviceRunSession';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
import { sleepAsync } from '../../../utils/retry';
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
@@ -592,19 +597,26 @@ describe(websiteOriginServeSimArgs, () => {
]);
});
it('names staging, its deploy previews and each website dev port on staging', () => {
it('names staging, its deploy previews and local website subdomains on staging', () => {
const args = websiteOriginServeSimArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
expect(args.slice(0, 4)).toEqual([
expect(args).toEqual([
'--cors-origin',
'https://staging.expo.dev',
'--frame-ancestor',
'https://staging.expo.dev',
'--cors-origin',
'https://*.expo.dev',
'--frame-ancestor',
'https://*.expo.dev',
'--cors-origin',
'https://expo.test',
'--frame-ancestor',
'https://expo.test',
'--cors-origin',
'https://*.expo.test',
'--frame-ancestor',
'https://*.expo.test',
]);
expect(args).toContain('https://*.expo.dev');
expect(args).toContain('https://expo.test:13001');
expect(args).toContain('https://expo.test:13215');
expect(args).not.toContain('https://expo.test:13216');
expect(args).not.toContain('https://expo.dev');
});
it('names only https origins', () => {
@@ -614,12 +626,18 @@ describe(websiteOriginServeSimArgs, () => {
}
});
it('names the website dev ports on local, without the deploy-preview wildcard', () => {
it('names local website subdomains without the deploy-preview wildcard on local', () => {
for (const env of [{ EXPO_LOCAL: '1' }, { EXPO_LOCAL: '1', EXPO_STAGING: '1' }]) {
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
expect(args).toContain('https://expo.test:13001');
expect(args).not.toContain('https://*.expo.dev');
expect(args.filter(value => value === 'https://expo.test')).toHaveLength(2);
expect(websiteOriginServeSimArgs(env as BuildStepEnv)).toEqual([
'--cors-origin',
'https://expo.test',
'--frame-ancestor',
'https://expo.test',
'--cors-origin',
'https://*.expo.test',
'--frame-ancestor',
'https://*.expo.test',
]);
}
});
});
@@ -692,6 +710,56 @@ describe(startNgrokTunnelAsync, () => {
});
});
describe('spawnDetached process group shutdown', () => {
const env = {} as BuildStepEnv;
beforeEach(() => {
const spawned = Object.assign(Promise.resolve(undefined), {
child: { pid: 4321, unref: jest.fn(), once: jest.fn() },
});
jest.mocked(spawn).mockReturnValue(spawned as never);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('waits for a detached child after its package-manager wrapper exits', async () => {
let groupChecks = 0;
const kill = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
if (pid === -4321 && signal === 0) {
groupChecks += 1;
if (groupChecks < 4) {
return true;
}
throw new Error('Process group exited');
}
if (pid === -4321 && signal === 'SIGTERM') {
return true;
}
throw new Error(`Unexpected process signal: ${pid} ${signal}`);
});
const detached = spawnDetached({ command: 'npx', args: [], env, stopGracePeriodMs: 90_000 });
await detached.stopAsync();
expect(jest.mocked(sleepAsync)).toHaveBeenCalledWith(100);
expect(kill).toHaveBeenCalledWith(-4321, 'SIGTERM');
expect(kill).not.toHaveBeenCalledWith(-4321, 'SIGKILL');
expect(kill).not.toHaveBeenCalledWith(4321, 0);
});
it('kills a detached child that outlives the shutdown deadline', async () => {
const kill = jest.spyOn(process, 'kill').mockReturnValue(true);
const detached = spawnDetached({ command: 'npx', args: [], env, stopGracePeriodMs: 0 });
await detached.stopAsync();
expect(kill).toHaveBeenCalledWith(-4321, 'SIGTERM');
expect(kill).toHaveBeenCalledWith(-4321, 'SIGKILL');
});
});
describe(startDeviceSessionHostAsync, () => {
const baseDomain = 'eas-simulator.ngrok.dev';
const turnArgs = [
@@ -748,6 +816,25 @@ describe(startDeviceSessionHostAsync, () => {
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('gives serve-sim its recording grace period on shutdown', async () => {
const spawnDetachedSpy = jest.spyOn(remoteDeviceRunSession, 'spawnDetached');
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
expect(spawnDetachedSpy).toHaveBeenCalledWith(
expect.objectContaining({ stopGracePeriodMs: SERVE_SIM_STOP_GRACE_PERIOD_MS })
);
await host.finishAsync();
});
it('installs ffmpeg before starting expo-device-hub for Linux', async () => {
const packageVersion = '1.2.3';
const close = jest.fn().mockResolvedValue(undefined);
@@ -940,6 +1027,7 @@ describe(startDeviceSessionHostAsync, () => {
});
it('launches serve-sim with bun x when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
const usePackage = jest.spyOn(IosSimulatorRecordingUtils, 'useServeSimPackage');
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://ios-preview.example.test',
@@ -969,6 +1057,7 @@ describe(startDeviceSessionHostAsync, () => {
packageVersion: '4.5.6',
}),
]);
expect(usePackage).toHaveBeenCalledWith('@expo/[email protected]');
await host.finishAsync();
expect(close).toHaveBeenCalledTimes(1);
@@ -20,6 +20,11 @@ const AgentDeviceArtifactSchema = z.object({
id: z.string(),
artifactType: z.string().nullish(),
filename: z.string(),
// Display rotation of a screenshot. An unknown value is dropped so it cannot block the upload.
displayRotation: z
.enum(['portrait', 'portrait-upside-down', 'landscape-left', 'landscape-right'])
.nullish()
.catch(undefined),
});
const AgentDeviceArtifactsListResponseSchema = z.object({
artifacts: z.array(AgentDeviceArtifactSchema),
@@ -154,6 +159,9 @@ export async function uploadAgentDeviceArtifactAsync(
name: `${artifact.filename} (${artifact.id})`,
filename: artifact.filename,
kind: artifact.artifactType ?? undefined,
...(artifact.displayRotation
? { metadata: { displayRotation: artifact.displayRotation } }
: {}),
size,
stream: createReadStream(temporaryArtifactPath),
});
@@ -0,0 +1,264 @@
import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
const PREVIEW_INTERVAL_MS = 60_000;
const PREVIEW_MAX_DIMENSION = 320;
const PREVIEW_QUALITY = 70;
const MAX_PREVIEW_SIZE_BYTES = 5 * 1024 * 1024;
const CREATE_PREVIEW_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionPreviewUploadSession($deviceRunSessionId: ID!) {
deviceRunSession {
createPreviewUploadSession(deviceRunSessionId: $deviceRunSessionId) {
uploadSession {
url
headers
}
}
}
}
`);
/**
* node-fetch includes the request URL in its error messages, and this URL is a signed upload URL
* that is valid for the whole session. Replace such errors with one that names only the failure.
*/
async function putPreviewAsync(
uploadSession: { url: string; headers: Record<string, string> },
{ image, signal }: { image: Buffer; signal: AbortSignal }
): Promise<Awaited<ReturnType<typeof fetch>>> {
try {
return await fetch(uploadSession.url, {
method: 'PUT',
headers: uploadSession.headers,
body: image,
signal,
timeout: 30_000,
});
} catch (err) {
const { name, type, code } = err as { name?: unknown; type?: unknown; code?: unknown };
const details = Object.entries({ name, type, code })
.filter(([, value]) => typeof value === 'string' && value.length > 0)
.map(([key, value]) => `${key}: ${value}`)
.join(', ');
throw new Error(`Session preview upload request failed (${details || 'unknown error'}).`);
}
}
export function startDeviceRunSessionPreview({
ctx,
deviceRunSessionId,
captureAsync,
logger,
}: {
ctx: CustomBuildContext;
deviceRunSessionId: string;
captureAsync: (signal: AbortSignal) => Promise<Buffer>;
logger: bunyan;
}): { stopAsync: () => Promise<void> } {
const controller = new AbortController();
let timer: NodeJS.Timeout | undefined;
let uploadSession: { url: string; headers: Record<string, string> } | undefined;
let pending: Promise<void>;
const uploadAsync = async (): Promise<void> => {
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(60_000)]);
try {
const image = await captureAsync(signal);
if (controller.signal.aborted) {
return;
}
if (image.length === 0 || image.length > MAX_PREVIEW_SIZE_BYTES) {
throw new Error('Session preview is empty or exceeds the 5 MiB upload limit.');
}
if (!uploadSession) {
const result = await ctx.graphqlClient
.mutation(
CREATE_PREVIEW_UPLOAD_SESSION_MUTATION,
{ deviceRunSessionId },
{
// Keep the client's auth headers and combine cancellation after URQL sets its signal.
fetch: (url, options) =>
globalThis.fetch(url, {
...options,
signal: AbortSignal.any([signal, ...(options?.signal ? [options.signal] : [])]),
}),
}
)
.toPromise();
if (result.error) {
throw result.error;
}
const session = result.data?.deviceRunSession.createPreviewUploadSession.uploadSession;
if (!session) {
throw new Error('No session preview upload URL was returned.');
}
uploadSession = {
url: session.url,
headers: session.headers as Record<string, string>,
};
}
if (controller.signal.aborted) {
return;
}
const response = await putPreviewAsync(uploadSession, { image, signal });
if (!response.ok) {
throw new Error(`Session preview upload failed: ${response.status} ${response.statusText}`);
}
} catch (err) {
if (!controller.signal.aborted) {
logger.warn({ err }, 'Could not refresh the session preview; retrying in 60 seconds.');
}
}
};
const tick = (): void => {
pending = uploadAsync().finally(() => {
if (!controller.signal.aborted) {
timer = setTimeout(tick, PREVIEW_INTERVAL_MS);
timer.unref();
}
});
};
tick();
return {
stopAsync: async () => {
controller.abort();
clearTimeout(timer);
await pending;
},
};
}
/**
* Homebrew's FFmpeg is built without libwebp, so macOS encodes previews with `cwebp` from
* Homebrew's webp package instead. Throws when it cannot be installed.
*/
export async function ensureMacosPreviewEncoderInstalledAsync({
env,
logger,
}: {
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
if ((await asyncResult(spawn('cwebp', ['-version'], { env, stdio: 'pipe' }))).ok) {
return;
}
logger.info('Installing webp with Homebrew for the session preview.');
await spawn('brew', ['install', 'webp'], {
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
logger,
});
}
export async function captureDeviceRunSessionPreviewAsync({
runtimePlatform,
device,
env,
signal,
}: {
runtimePlatform: BuildRuntimePlatform;
device: string;
env: BuildStepEnv;
signal: AbortSignal;
}): Promise<Buffer> {
const directory = await mkdtemp(path.join(os.tmpdir(), 'session-preview-'));
try {
const options = { env, signal, timeout: 30_000, stdio: 'pipe' as const };
const preview = path.join(directory, 'preview.webp');
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await captureIosPreviewAsync({ device, directory, preview, options });
} else {
await captureAndroidPreviewAsync({ device, directory, preview, options });
}
return await readFile(preview);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
type CaptureOptions = {
env: BuildStepEnv;
signal: AbortSignal;
timeout: number;
stdio: 'pipe';
};
async function captureIosPreviewAsync({
device,
directory,
preview,
options,
}: {
device: string;
directory: string;
preview: string;
options: CaptureOptions;
}): Promise<void> {
const screenshot = path.join(directory, 'screen.png');
const resized = path.join(directory, 'resized.png');
await spawn('xcrun', ['simctl', 'io', device, 'screenshot', screenshot], options);
await spawn('sips', ['-Z', String(PREVIEW_MAX_DIMENSION), screenshot, '--out', resized], options);
await spawn('cwebp', ['-quiet', '-q', String(PREVIEW_QUALITY), resized, '-o', preview], options);
}
async function captureAndroidPreviewAsync({
device,
directory,
preview,
options,
}: {
device: string;
directory: string;
preview: string;
options: CaptureOptions;
}): Promise<void> {
const screenshot = path.join(directory, 'screen.png');
let serial = device;
// expo-device-hub's readiness endpoint currently reports a placeholder instead of a serial.
if (serial === 'no-device-id') {
const result = await spawn('adb', ['devices'], options);
const devices = result.stdout
.split('\n')
.map(line => line.trim().split(/\s+/))
.filter(([, status]) => status === 'device');
if (devices.length !== 1) {
throw new Error('Expected exactly one connected Android device for the session preview.');
}
serial = devices[0][0];
}
const capture = spawn('adb', ['-s', serial, 'exec-out', 'screencap', '-p'], options);
// Preserve binary PNG output instead of spawn-async's UTF-8 stdout conversion.
await Promise.all([
capture,
pipeline(capture.child.stdout!, createWriteStream(screenshot), { signal: options.signal }),
]);
await spawn(
'ffmpeg',
[
'-nostdin',
'-y',
'-i',
screenshot,
'-vf',
`scale=${PREVIEW_MAX_DIMENSION}:${PREVIEW_MAX_DIMENSION}:force_original_aspect_ratio=decrease`,
'-frames:v',
'1',
'-c:v',
'libwebp',
'-quality',
String(PREVIEW_QUALITY),
preview,
],
options
);
}
@@ -21,6 +21,11 @@ import {
parseDeviceScreenRecordings,
uploadDeviceRunSessionScreenRecordingsAsync,
} from './deviceRunSessionScreenRecordings';
import {
captureDeviceRunSessionPreviewAsync,
ensureMacosPreviewEncoderInstalledAsync,
startDeviceRunSessionPreview,
} from './deviceRunSessionPreview';
import { startDeviceRunSessionScreenshotsAsync } from './deviceRunSessionScreenshots';
import {
type DetachedProcessHandle,
@@ -35,6 +40,10 @@ import {
startNgrokTunnelAsync,
} from './remoteDeviceRunSession';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import {
IosSimulatorRecordingUtils,
SERVE_SIM_STOP_GRACE_PERIOD_MS,
} from './IosSimulatorRecordingUtils';
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
const WEB_PREVIEW_HOST = '127.0.0.1';
@@ -68,12 +77,8 @@ export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string):
return new URL(`/simulator-preview/${subdomainId}`, websiteOrigin(env)).toString();
}
// Website dev servers on expo.test use staging sessions, and CORS matches ports exactly.
const WEBSITE_DEV_ORIGINS = [
'https://expo.test',
'https://expo.test:13001',
...Array.from({ length: 16 }, (_, index) => `https://expo.test:${13200 + index}`),
];
// Local website hosts are shared by local and staging simulator sessions.
const WEBSITE_DEV_ORIGINS = ['https://expo.test', 'https://*.expo.test'];
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
const origins = new Set([websiteOrigin(env)]);
@@ -364,7 +369,10 @@ export async function startDeviceSessionHostAsync(
EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory,
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
},
stopGracePeriodMs: recording ? RECORDING_STOP_GRACE_PERIOD_MS : undefined,
// Only an Android host records through expo-device-hub; serve-sim records on iOS.
stopGracePeriodMs: recording
? RECORDING_STOP_GRACE_PERIOD_MS
: SERVE_SIM_STOP_GRACE_PERIOD_MS,
});
} catch (error) {
// Nothing was spawned, so nothing can still write into the directory.
@@ -376,6 +384,7 @@ export async function startDeviceSessionHostAsync(
let previewTask: Promise<DeviceWebPreview> | null = null;
let finishTask: Promise<void> | null = null;
let hostReady = false;
let sessionPreview: ReturnType<typeof startDeviceRunSessionPreview> | null = null;
const host: DeviceSessionHost = {
openPreviewAsync({ baseDomain }) {
@@ -433,6 +442,7 @@ export async function startDeviceSessionHostAsync(
finishAsync() {
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
previewTask,
stopSessionPreviewAsync: async () => await sessionPreview?.stopAsync(),
previewServer,
screenshots,
serverName,
@@ -462,7 +472,31 @@ export async function startDeviceSessionHostAsync(
'report it if it repeats.'
);
}
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
}
// Android installed FFmpeg before launching the host. The optional thumbnail must not delay
// readiness on macOS, so it starts after its encoder is installed there.
void (async () => {
if (!isAndroid) {
await ensureMacosPreviewEncoderInstalledAsync({ env, logger });
}
if (!finishTask) {
sessionPreview = startDeviceRunSessionPreview({
ctx,
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
logger,
captureAsync: signal =>
captureDeviceRunSessionPreviewAsync({
runtimePlatform,
device,
env,
signal,
}),
});
}
})().catch(err => {
logger.warn({ err }, 'Could not start refreshing the session preview.');
});
return host;
} catch (error) {
await host.finishAsync();
@@ -474,6 +508,7 @@ async function finishDeviceSessionHostAsync(
ctx: CustomBuildContext,
{
previewTask,
stopSessionPreviewAsync,
previewServer,
screenshots,
serverName,
@@ -482,6 +517,7 @@ async function finishDeviceSessionHostAsync(
logger,
}: {
previewTask: Promise<DeviceWebPreview> | null;
stopSessionPreviewAsync: () => Promise<void>;
previewServer: DetachedProcessHandle;
screenshots: { finishAsync(hostStopped: boolean): Promise<void> };
serverName: string;
@@ -490,6 +526,8 @@ async function finishDeviceSessionHostAsync(
logger: bunyan;
}
): Promise<void> {
// Stop capturing before the host stops, so the last thumbnail shows the session, not shutdown.
await stopSessionPreviewAsync();
// Native ngrok operations have no scoped cancellation. Retire a late listener too.
const retirePreview = withDeviceRunSessionTimeoutAsync(
{ name: 'Preview tunnel retirement', timeoutMs: 5_000 },
@@ -5,6 +5,9 @@ import * as jose from 'jose';
import fetch from 'node-fetch';
import { ZodError, z } from 'zod';
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
const TOKEN_LIFETIME_SECONDS = 20 /* minutes */ * 60 /* seconds */;
const TOKEN_REFRESH_MARGIN_SECONDS = 60 /* seconds */;
@@ -32,6 +35,27 @@ const AscErrorResponseSchema = z.object({
});
const GetApi = {
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-builds-_id_-buildbetadetail
'/v1/builds/:id/buildBetaDetail': {
path: z.object({ id: z.string() }),
request: z.object({}),
response: z.object({
data: z.object({
id: z.string(),
attributes: z.object({
internalBuildState: z.enum([
'PROCESSING',
'PROCESSING_EXCEPTION',
'MISSING_EXPORT_COMPLIANCE',
'READY_FOR_BETA_TESTING',
'IN_BETA_TESTING',
'EXPIRED',
'IN_EXPORT_COMPLIANCE_REVIEW',
]),
}),
}),
}),
},
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-builds-_id_-app
'/v1/builds/:id/app': {
path: z.object({ id: z.string() }),
@@ -538,7 +562,22 @@ export class AscApiClient {
});
}
private async sendRequestAsync({
private async sendRequestAsync(
args: Parameters<AscApiClient['sendRequestOnceAsync']>[0]
): Promise<any> {
return await promiseRetryWithCondition(
() => this.sendRequestOnceAsync(args),
error =>
isConnectFailure(error) || (args.method === 'GET' && isConnectionInterruptedError(error)),
{ retries: 3, factor: 2, minTimeout: 100 },
({ attemptNumber, maxAttemptsCount, error }) =>
this.logger?.warn(
`Retrying Apple request (${args.method} ${args.path}, attempt ${attemptNumber}/${maxAttemptsCount}): ${String(error)}`
)
)();
}
private async sendRequestOnceAsync({
path,
method,
body,
@@ -1,8 +1,12 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import fs from 'fs-extra';
import * as jose from 'jose';
import { z } from 'zod';
import { isConnectionInterruptedError } from '../../../utils/networkErrors';
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
import {
AscApiClient,
AscApiClientGetApi,
@@ -13,6 +17,65 @@ import {
} from './AscApiClient';
export namespace AscApiUtils {
export async function commitBuildUploadFileAsync({
client,
fileId,
logger,
}: {
client: AscApiClient;
fileId: string;
logger: bunyan;
}): Promise<void> {
try {
await promiseRetryWithCondition(
async () => {
const { data } = await client.getAsync(
'/v1/buildUploadFiles/:id',
{ 'fields[buildUploadFiles]': ['assetDeliveryState'] },
{ id: fileId }
);
if (['COMPLETE', 'FAILED'].includes(data.attributes.assetDeliveryState.state)) {
return;
}
await client.patchAsync(
'/v1/buildUploadFiles/:id',
{ data: { type: 'buildUploadFiles', id: fileId, attributes: { uploaded: true } } },
{ id: fileId }
);
},
error => isConnectionInterruptedError(error),
{ retries: 3, factor: 1, minTimeout: 2000 },
({ attemptNumber, maxAttemptsCount, error }) =>
logger.warn(
{ err: error },
`Checking upload file before commit attempt ${attemptNumber}/${maxAttemptsCount}.`
)
)();
} catch (error) {
if (!isConnectionInterruptedError(error)) {
throw error;
}
// Apple may have accepted the final commit even though its response was lost.
try {
const { data } = await client.getAsync(
'/v1/buildUploadFiles/:id',
{ 'fields[buildUploadFiles]': ['assetDeliveryState'] },
{ id: fileId }
);
if (data.attributes.assetDeliveryState.state === 'COMPLETE') {
logger.info(`Upload file ${fileId}: COMPLETE confirmed after the final commit error.`);
return;
}
} catch (readError) {
logger.warn(
{ err: readError },
`Could not check upload file ${fileId} after the final commit error.`
);
}
throw error;
}
}
export async function getAllBetaBuildLocalizationsAsync({
client,
buildId,
@@ -468,14 +468,15 @@ describe(AscApiClient, () => {
it('throws regular Error for non-structured ASC error payload', async () => {
const appId = '1491144534';
nock('https://api.appstoreconnect.apple.com')
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/apps/${appId}`)
.query({ 'fields[apps]': 'bundleId,name' })
.reply(503, 'Service unavailable');
await expect(
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
).rejects.not.toBeInstanceOf(AscApiRequestError);
).rejects.toThrow('Unexpected response (503)');
expect(scope.isDone()).toBe(true);
});
it('throws controlled error when success response body is not valid JSON', async () => {
@@ -490,4 +491,108 @@ describe(AscApiClient, () => {
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
).rejects.toThrow('Malformed JSON response from App Store Connect (200): not-json');
});
it.each(['ECONNRESET', 'EAI_AGAIN'])('recovers a status read after %s', async code => {
const fixture = require('./fixtures/buildUploadFiles/get-buildUploadFiles-200.json');
const scope = nock('https://api.appstoreconnect.apple.com')
.get('/v1/buildUploadFiles/file')
.query(true)
.replyWithError({ code, message: 'Temporary network failure' })
.get('/v1/buildUploadFiles/file')
.query(true)
.reply(200, fixture);
await expect(
client.getAsync(
'/v1/buildUploadFiles/:id',
{ 'fields[buildUploadFiles]': ['assetDeliveryState'] },
{ id: 'file' }
)
).resolves.toMatchObject({ data: { id: fixture.data.id } });
expect(scope.isDone()).toBe(true);
});
it.each([401, 422, 429, 503])('does not retry permanent HTTP %s errors', async status => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get('/v1/apps/app')
.query(true)
.reply(status, { errors: [{ status: String(status) }] });
await expect(
client.getAsync(
'/v1/apps/:id',
{
'fields[apps]': ['bundleId', 'name'],
},
{ id: 'app' }
)
).rejects.toBeInstanceOf(AscApiRequestError);
expect(scope.isDone()).toBe(true);
});
it('does not retry creating a localization after a lost response', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.post('/v1/betaBuildLocalizations')
.replyWithError({ code: 'ECONNRESET', message: 'Lost response' });
await expect(
client.postAsync('/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: 'en-US', whatsNew: 'Hello' },
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
})
).rejects.toMatchObject({ code: 'ECONNRESET' });
expect(scope.isDone()).toBe(true);
});
it('retries connection establishment failure before a creation request', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.post('/v1/betaBuildLocalizations')
.replyWithError({ code: 'EAI_AGAIN', message: 'DNS failure' })
.post('/v1/betaBuildLocalizations')
.reply(201, { data: { id: 'localization' } });
await expect(
client.postAsync('/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: 'en-US', whatsNew: 'Hello' },
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
})
).resolves.toEqual({ data: { id: 'localization' } });
expect(scope.isDone()).toBe(true);
});
it('stops after three retries of an interrupted read', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get('/v1/apps/app')
.query(true)
.times(4)
.replyWithError({ code: 'ECONNRESET', message: 'Connection reset' });
await expect(
client.getAsync(
'/v1/apps/:id',
{
'fields[apps]': ['bundleId', 'name'],
},
{ id: 'app' }
)
).rejects.toMatchObject({ code: 'ECONNRESET' });
expect(scope.isDone()).toBe(true);
});
it('does not repeat an interrupted changelog update', async () => {
const body = {
data: { type: 'betaBuildLocalizations', id: 'locale', attributes: { whatsNew: 'Hello' } },
} as const;
const scope = nock('https://api.appstoreconnect.apple.com')
.patch('/v1/betaBuildLocalizations/locale', body)
.replyWithError({ code: 'ECONNRESET', message: 'Response lost' })
.patch('/v1/betaBuildLocalizations/locale', body)
.optionally()
.reply(200, { data: { id: 'locale' } });
await expect(
client.patchAsync('/v1/betaBuildLocalizations/:id', body, { id: 'locale' })
).rejects.toMatchObject({ code: 'ECONNRESET' });
expect(scope.isDone()).toBe(true);
});
});
@@ -1,3 +1,4 @@
import { createLogger } from '@expo/logger';
import fs from 'fs-extra';
import * as jose from 'jose';
import nock from 'nock';
@@ -321,3 +322,112 @@ describe('AscApiUtils', () => {
});
});
});
describe('commitBuildUploadFileAsync', () => {
const logger = createLogger({ name: 'test' });
const fileResponse = (state: string) => ({
data: {
type: 'buildUploadFiles',
id: 'file',
attributes: { assetDeliveryState: { state } },
},
});
let client: AscApiClient;
beforeAll(async () => {
const { privateKey } = await jose.generateKeyPair('ES256');
client = new AscApiClient({ key: { keyId: 'TESTKEY', privateKey } });
nock.disableNetConnect();
});
afterAll(() => nock.enableNetConnect());
afterEach(() => {
try {
expect(nock.pendingMocks()).toEqual([]);
} finally {
nock.cleanAll();
}
});
it.each(['COMPLETE', 'FAILED'])('does not commit a terminal file: %s', async state => {
nock('https://api.appstoreconnect.apple.com')
.get('/v1/buildUploadFiles/file')
.query(true)
.reply(200, fileResponse(state));
await expect(
AscApiUtils.commitBuildUploadFileAsync({ client, fileId: 'file', logger })
).resolves.toBeUndefined();
});
it.each(['COMPLETE', 'AWAITING_UPLOAD'])(
'reads the file before retrying an interrupted commit: %s',
async state => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get('/v1/buildUploadFiles/file')
.query(true)
.reply(200, fileResponse('AWAITING_UPLOAD'))
.patch('/v1/buildUploadFiles/file')
.replyWithError({ code: 'ECONNRESET', message: 'Lost response' })
.get('/v1/buildUploadFiles/file')
.query(true)
.reply(200, fileResponse(state));
if (state === 'AWAITING_UPLOAD') {
scope.patch('/v1/buildUploadFiles/file').reply(200, fileResponse('COMPLETE'));
}
await expect(
AscApiUtils.commitBuildUploadFileAsync({ client, fileId: 'file', logger })
).resolves.toBeUndefined();
}
);
it.each([
[401, 'NOT_AUTHORIZED'],
[403, 'FORBIDDEN'],
[409, 'STATE_ERROR.INVALID_STATE'],
])('preserves Apple errors without retrying: %s %s', async (status, code) => {
nock('https://api.appstoreconnect.apple.com')
.get('/v1/buildUploadFiles/file')
.query(true)
.reply(200, fileResponse('UPLOAD_COMPLETE'))
.patch('/v1/buildUploadFiles/file')
.reply(status, { errors: [{ code }] });
await expect(
AscApiUtils.commitBuildUploadFileAsync({ client, fileId: 'file', logger })
).rejects.toMatchObject({ status, code });
});
it('does not commit when the initial state read fails', async () => {
nock('https://api.appstoreconnect.apple.com')
.get('/v1/buildUploadFiles/file')
.query(true)
.reply(503, 'Unavailable');
await expect(
AscApiUtils.commitBuildUploadFileAsync({ client, fileId: 'file', logger })
).rejects.toThrow('503');
});
it.each(['COMPLETE', 'AWAITING_UPLOAD', 'UPLOAD_COMPLETE', 'FAILED', 'read-error'])(
'checks the file after the final interrupted commit: %s',
async finalState => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get('/v1/buildUploadFiles/file')
.query(true)
.times(4)
.reply(200, fileResponse('AWAITING_UPLOAD'))
.patch('/v1/buildUploadFiles/file')
.times(4)
.replyWithError({ code: 'ECONNRESET', message: 'Lost response' });
const finalRead = scope.get('/v1/buildUploadFiles/file').query(true);
if (finalState === 'read-error') {
finalRead.reply(503, 'Unavailable');
} else {
finalRead.reply(200, fileResponse(finalState));
}
const result = AscApiUtils.commitBuildUploadFileAsync({ client, fileId: 'file', logger });
if (finalState === 'COMPLETE') {
await expect(result).resolves.toBeUndefined();
} else {
await expect(result).rejects.toMatchObject({ code: 'ECONNRESET' });
}
},
15_000
);
});
@@ -97,7 +97,6 @@ async function resolvePackagedFileAsync(binDir: string, file: string): Promise<s
}
}
/** The packaged library, next to the compiled package like record-sim. */
export async function resolveEgressGuardLibraryAsync(
binDir: string = PACKAGED_BIN_DIR
): Promise<string | null> {
@@ -26,6 +26,7 @@ import {
parseOpenUrlInput,
} from '../functions/launchApplication';
import { Sentry } from '../../sentry';
import { isProcessGroupRunning } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
@@ -548,7 +549,7 @@ async function stopDetachedProcessAsync(
pid: number | undefined,
gracePeriodMs = 5_000
): Promise<void> {
if (pid === undefined || !isProcessRunning(pid)) {
if (pid === undefined || !isProcessGroupRunning(pid)) {
return;
}
try {
@@ -564,10 +565,10 @@ async function stopDetachedProcessAsync(
}
const deadline = Date.now() + gracePeriodMs;
while (Date.now() < deadline && isProcessRunning(pid)) {
while (Date.now() < deadline && isProcessGroupRunning(pid)) {
await sleepAsync(100);
}
if (!isProcessRunning(pid)) {
if (!isProcessGroupRunning(pid)) {
return;
}
try {
@@ -579,7 +580,7 @@ async function stopDetachedProcessAsync(
}
// kill(pid, 0) succeeds on the zombie until libuv reaps it on a later loop turn.
const killDeadline = Date.now() + 5_000;
while (Date.now() < killDeadline && isProcessRunning(pid)) {
while (Date.now() < killDeadline && isProcessGroupRunning(pid)) {
await sleepAsync(100);
}
}
@@ -1,12 +1,13 @@
import { Platform } from '@expo/eas-build-job';
import { PipeMode } from '@expo/logger';
import { BuildContext } from '../../context';
import * as expoUpdates from '../expoUpdates';
import * as expoUpdates from '../../steps/utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../expoUpdatesEmbedded';
import * as easCli from '../easCli';
import * as artifacts from '../artifacts';
import { createMockLogger } from '../../__tests__/utils/logger';
jest.mock('../expoUpdates');
jest.mock('../../steps/utils/expoUpdates');
jest.mock('../easCli');
jest.mock('../artifacts');
@@ -29,13 +30,13 @@ function zipEntryMap(entries: Record<string, true>): Record<string, { name: stri
return Object.fromEntries(Object.keys(entries).map(name => [name, { name }]));
}
function makeCtx(overrides: {
function makeArgs(overrides: {
platform: Platform;
simulator?: boolean;
developmentClient?: boolean;
channel?: string;
env?: Record<string, string>;
}): BuildContext<any> {
}): Parameters<typeof uploadEmbeddedBundleAsync>[0] {
const job =
overrides.platform === Platform.IOS
? {
@@ -53,22 +54,19 @@ function makeCtx(overrides: {
return {
job,
env: overrides.env ?? {},
appConfig: Promise.resolve({
appConfig: {
name: 'my-app',
slug: 'my-app',
updates: { url: 'https://u.expo.dev/project-id' },
}),
logger: {
info: jest.fn(),
warn: jest.fn(),
},
markBuildPhaseSkipped: jest.fn(),
markBuildPhaseHasWarnings: jest.fn(),
getReactNativeProjectDirectory: () => '/project',
} as any;
logger: createMockLogger(),
projectDir: '/project',
} as Parameters<typeof uploadEmbeddedBundleAsync>[0];
}
describe('uploadEmbeddedBundleAsync', () => {
beforeEach(() => {
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(true);
jest.mocked(expoUpdates.isEASUpdateConfigured).mockReturnValue(true);
jest.mocked(easCli.runEasCliCommand).mockResolvedValue({} as any);
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
mockZipEntries.mockResolvedValue({});
@@ -82,33 +80,36 @@ describe('uploadEmbeddedBundleAsync', () => {
});
it('skips when EAS Update is not configured', async () => {
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(false);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
jest.mocked(expoUpdates.isEASUpdateConfigured).mockReturnValue(false);
const args = makeArgs({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync(args);
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
expect(status).toBe('skipped');
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('warns when no channel is configured and does not look for the archive', async () => {
const ctx = makeCtx({ platform: Platform.ANDROID });
const args = makeArgs({ platform: Platform.ANDROID });
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync(args);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect(args.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: no channel configured for this build profile.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(status).toBe('failed');
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('throws for an unsupported platform', async () => {
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
(ctx.job as { platform: string }).platform = 'web';
it('warns for an unsupported platform', async () => {
const args = makeArgs({ platform: Platform.ANDROID, channel: 'production' });
(args.job as { platform: string }).platform = 'web';
await expect(uploadEmbeddedBundleAsync(ctx)).rejects.toThrow(
'Uploading embedded updates is not supported for the web platform.'
const { status } = await uploadEmbeddedBundleAsync(args);
expect(status).toBe('failed');
expect(args.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: the web platform is not supported.'
);
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
@@ -121,14 +122,18 @@ describe('uploadEmbeddedBundleAsync', () => {
'assets/app.manifest': true,
})
);
const ctx = makeCtx({
const args = makeArgs({
platform: Platform.ANDROID,
channel: 'production',
env: { EAS_BUILD_ID: 'build-123' },
});
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync(args);
expect(status).toBe('uploaded');
expect(artifacts.findArtifacts).toHaveBeenCalledWith(
expect.objectContaining({ patternOrPath: 'android/app/build/outputs/**/*.{apk,aab}' })
);
expect(mockZipExtract).toHaveBeenCalledWith(
'assets/index.android.bundle',
expect.stringContaining('index.android.bundle')
@@ -144,10 +149,24 @@ describe('uploadEmbeddedBundleAsync', () => {
'--build-id',
'build-123',
]),
options: expect.objectContaining({ mode: PipeMode.COMBINED_AS_STDOUT }),
})
);
});
it('uses applicationArchivePath to find the archive', async () => {
const args = makeArgs({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync({
...args,
job: { ...args.job, applicationArchivePath: 'custom/app.aab' },
});
expect(artifacts.findArtifacts).toHaveBeenCalledWith(
expect.objectContaining({ patternOrPath: 'custom/app.aab' })
);
});
it('uploads from Android AAB archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.aab']);
mockZipEntries.mockResolvedValue(
@@ -156,10 +175,11 @@ describe('uploadEmbeddedBundleAsync', () => {
'base/assets/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
const args = makeArgs({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync(args);
expect(status).toBe('uploaded');
expect(mockZipExtract).toHaveBeenCalledWith(
'base/assets/index.android.bundle',
expect.stringContaining('index.android.bundle')
@@ -175,10 +195,14 @@ describe('uploadEmbeddedBundleAsync', () => {
'Payload/App.app/EXUpdates.bundle/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.IOS, channel: 'production' });
const args = makeArgs({ platform: Platform.IOS, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync(args);
expect(status).toBe('uploaded');
expect(artifacts.findArtifacts).toHaveBeenCalledWith(
expect.objectContaining({ patternOrPath: 'ios/build/*.ipa' })
);
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--platform', Platform.IOS]),
@@ -187,25 +211,24 @@ describe('uploadEmbeddedBundleAsync', () => {
});
it('skips simulator builds', async () => {
const ctx = makeCtx({ platform: Platform.IOS, simulator: true, channel: 'preview' });
const args = makeArgs({ platform: Platform.IOS, simulator: true, channel: 'preview' });
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync(args);
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
expect(status).toBe('skipped');
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('skips development client builds', async () => {
const ctx = makeCtx({
const args = makeArgs({
platform: Platform.ANDROID,
developmentClient: true,
channel: 'development',
});
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync(args);
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
expect(ctx.markBuildPhaseHasWarnings).not.toHaveBeenCalled();
expect(status).toBe('skipped');
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
@@ -216,11 +239,12 @@ describe('uploadEmbeddedBundleAsync', () => {
'assets/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
const args = makeArgs({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync(args);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect(status).toBe('failed');
expect(args.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: bundle or manifest not found in archive.'
);
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
@@ -228,27 +252,27 @@ describe('uploadEmbeddedBundleAsync', () => {
it('warns when build archive is not found', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
const args = makeArgs({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync(args);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect(args.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: build archive not found.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(status).toBe('failed');
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('treats findArtifacts errors as no archive found', async () => {
jest.mocked(artifacts.findArtifacts).mockRejectedValue(new Error('glob failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
const args = makeArgs({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync(args);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect(args.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: build archive not found.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(status).toBe('failed');
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
@@ -261,15 +285,15 @@ describe('uploadEmbeddedBundleAsync', () => {
})
);
jest.mocked(easCli.runEasCliCommand).mockRejectedValue(new Error('upload failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
const args = makeArgs({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync(args);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect(args.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload embedded bundle.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(status).toBe('failed');
});
it('swallows zip.close() failures so they do not mask the upload result', async () => {
@@ -281,9 +305,9 @@ describe('uploadEmbeddedBundleAsync', () => {
})
);
mockZipClose.mockRejectedValue(new Error('close failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
const args = makeArgs({ platform: Platform.ANDROID, channel: 'production' });
await expect(uploadEmbeddedBundleAsync(ctx)).resolves.toBeUndefined();
await expect(uploadEmbeddedBundleAsync(args)).resolves.toEqual({ status: 'uploaded' });
expect(easCli.runEasCliCommand).toHaveBeenCalled();
expect(mockZipClose).toHaveBeenCalled();
});
@@ -0,0 +1,36 @@
import { isConnectFailure, isConnectionInterruptedError } from '../networkErrors';
it.each([
'EAI_AGAIN',
'ECONNREFUSED',
'EHOSTDOWN',
'EHOSTUNREACH',
'ENETDOWN',
'ENETUNREACH',
'ENOTFOUND',
])('recognizes wrapped %s as a connection establishment failure', code => {
const error = new Error('Request failed', {
cause: new TypeError('fetch failed', {
cause: Object.assign(new Error('connect failed'), { code }),
}),
});
expect(isConnectFailure(error)).toBe(true);
expect(isConnectionInterruptedError(error)).toBe(false);
});
it.each(['ECONNRESET', 'UND_ERR_SOCKET'])(
'requires caller-specific recovery for wrapped %s',
code => {
const error = new Error('Request failed', {
cause: Object.assign(new Error('response lost'), { code }),
});
expect(isConnectFailure(error)).toBe(false);
expect(isConnectionInterruptedError(error)).toBe(true);
}
);
it.each(['ETIMEDOUT', 'CERT_HAS_EXPIRED', undefined])('does not automatically retry %s', code => {
const error = Object.assign(new Error('Request failed'), { code });
expect(isConnectFailure(error)).toBe(false);
expect(isConnectionInterruptedError(error)).toBe(false);
});
@@ -1,6 +1,6 @@
import { ChildProcess } from 'node:child_process';
import { isChildProcessAlive } from '../processes';
import { isChildProcessAlive, isProcessGroupRunning } from '../processes';
function child(partial: {
exitCode: number | null;
@@ -41,3 +41,29 @@ describe(isChildProcessAlive, () => {
);
});
});
describe(isProcessGroupRunning, () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('signals the group, not the leader', () => {
const kill = jest.spyOn(process, 'kill').mockReturnValue(true);
expect(isProcessGroupRunning(4321)).toBe(true);
expect(kill).toHaveBeenCalledWith(-4321, 0);
});
it('is not running once the group is gone', () => {
jest.spyOn(process, 'kill').mockImplementation(() => {
throw Object.assign(new Error('No such process'), { code: 'ESRCH' });
});
expect(isProcessGroupRunning(4321)).toBe(false);
});
it('is running when the group exists but cannot be signaled', () => {
jest.spyOn(process, 'kill').mockImplementation(() => {
throw Object.assign(new Error('Operation not permitted'), { code: 'EPERM' });
});
expect(isProcessGroupRunning(4321)).toBe(true);
});
});
@@ -0,0 +1,29 @@
import { promiseRetryWithCondition } from '../promiseRetryWithCondition';
it('reports the previous error and retry budget only when the next attempt starts', async () => {
const errors = [new Error('First failure'), new Error('Second failure')];
const fn = jest
.fn()
.mockRejectedValueOnce(errors[0])
.mockRejectedValueOnce(errors[1])
.mockResolvedValue('done');
const onRetry = jest.fn();
await expect(
promiseRetryWithCondition(fn, () => true, { retries: 2, minTimeout: 1 }, onRetry)()
).resolves.toBe('done');
expect(onRetry.mock.calls).toEqual([
[{ attemptNumber: 2, maxAttemptsCount: 3, error: errors[0] }],
[{ attemptNumber: 3, maxAttemptsCount: 3, error: errors[1] }],
]);
});
it('does not report a retry after the final failure', async () => {
const error = new Error('Persistent failure');
const fn = jest.fn().mockRejectedValue(error);
const onRetry = jest.fn();
await expect(
promiseRetryWithCondition(fn, () => true, { retries: 1, minTimeout: 1 }, onRetry)()
).rejects.toBe(error);
expect(onRetry.mock.calls).toEqual([[{ attemptNumber: 2, maxAttemptsCount: 2, error }]]);
expect(fn).toHaveBeenCalledTimes(2);
});
@@ -1,5 +1,6 @@
import { Android, BuildJob, Ios, Platform } from '@expo/eas-build-job';
import { PipeMode } from '@expo/logger';
import { ExpoConfig } from '@expo/config';
import { Android, BuildJob, Env, Ios, Metadata, Platform } from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fs from 'fs-extra';
import os from 'os';
@@ -9,9 +10,9 @@ import semver from 'semver';
import { findArtifacts } from './artifacts';
import { runEasCliCommand } from './easCli';
import { resolveArtifactPath } from '../ios/resolve';
import { BuildContext } from '../context';
import { isEASUpdateConfigured } from './expoUpdates';
import { resolveArtifactPath as resolveAndroidArtifactPath } from '../android/resolve';
import { resolveArtifactPath as resolveIosArtifactPath } from '../ios/resolve';
import { isEASUpdateConfigured } from '../steps/utils/expoUpdates';
function parseBooleanEnvVar(value: string | undefined): boolean | undefined {
if (!value) {
@@ -27,55 +28,64 @@ function parseBooleanEnvVar(value: string | undefined): boolean | undefined {
* On SDK 57 and below the feature is still experimental and off by default. Projects opt in by
* setting EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE to "1".
*/
export function shouldUploadEmbeddedBundle(ctx: BuildContext<BuildJob>): boolean {
export function shouldUploadEmbeddedBundle({
env,
metadata,
}: {
env: Env;
metadata?: Metadata | null;
}): boolean {
const explicitFlag =
parseBooleanEnvVar(ctx.env.EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE) ??
parseBooleanEnvVar(ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE);
parseBooleanEnvVar(env.EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE) ??
parseBooleanEnvVar(env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE);
if (explicitFlag !== undefined) {
return explicitFlag;
}
const sdkVersion = ctx.metadata?.sdkVersion;
const sdkVersion = metadata?.sdkVersion;
return !!sdkVersion && semver.satisfies(sdkVersion, '>=58');
}
export async function uploadEmbeddedBundleAsync(ctx: BuildContext<BuildJob>): Promise<void> {
if (!(await isEASUpdateConfigured(ctx))) {
ctx.markBuildPhaseSkipped();
return;
export async function uploadEmbeddedBundleAsync({
job,
env,
logger,
projectDir,
appConfig,
}: {
job: BuildJob;
env: Env;
logger: bunyan;
projectDir: string;
appConfig: ExpoConfig;
}): Promise<{ status: 'uploaded' | 'skipped' | 'failed' }> {
if (!isEASUpdateConfigured(appConfig, logger)) {
return { status: 'skipped' };
}
if (ctx.job.developmentClient) {
ctx.markBuildPhaseSkipped();
return;
if (job.developmentClient) {
return { status: 'skipped' };
}
const { platform } = ctx.job;
if (platform === Platform.IOS && (ctx.job as Ios.Job).simulator) {
ctx.markBuildPhaseSkipped();
return;
const { platform } = job;
if (platform === Platform.IOS && (job as Ios.Job).simulator) {
return { status: 'skipped' };
}
const channel = ctx.job.updates?.channel;
const channel = job.updates?.channel;
if (!channel) {
ctx.logger.warn(
'Skipping embedded bundle upload: no channel configured for this build profile.'
);
ctx.markBuildPhaseHasWarnings();
return;
logger.warn('Skipping embedded bundle upload: no channel configured for this build profile.');
return { status: 'failed' };
}
const projectDir = ctx.getReactNativeProjectDirectory();
let archivePattern: string;
if (platform === Platform.IOS) {
archivePattern = resolveArtifactPath(ctx as BuildContext<Ios.Job>);
archivePattern = resolveIosArtifactPath(job as Ios.Job);
} else if (platform === Platform.ANDROID) {
archivePattern =
(ctx as BuildContext<Android.Job>).job.applicationArchivePath ??
'android/app/build/outputs/**/*.{apk,aab}';
archivePattern = resolveAndroidArtifactPath(job as Android.Job);
} else {
throw new Error(`Uploading embedded updates is not supported for the ${platform} platform.`);
logger.warn(`Skipping embedded bundle upload: the ${platform} platform is not supported.`);
return { status: 'failed' };
}
const [archivePath] = await findArtifacts({
@@ -85,9 +95,8 @@ export async function uploadEmbeddedBundleAsync(ctx: BuildContext<BuildJob>): Pr
}).catch(() => [] as string[]);
if (!archivePath) {
ctx.logger.warn('Skipping embedded bundle upload: build archive not found.');
ctx.markBuildPhaseHasWarnings();
return;
logger.warn('Skipping embedded bundle upload: build archive not found.');
return { status: 'failed' };
}
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-embedded-bundle-'));
@@ -109,9 +118,8 @@ export async function uploadEmbeddedBundleAsync(ctx: BuildContext<BuildJob>): Pr
);
if (!bundleEntry || !manifestEntry) {
ctx.logger.warn('Skipping embedded bundle upload: bundle or manifest not found in archive.');
ctx.markBuildPhaseHasWarnings();
return;
logger.warn('Skipping embedded bundle upload: bundle or manifest not found in archive.');
return { status: 'failed' };
}
await zip.extract(bundleEntry.name, bundlePath);
@@ -129,21 +137,22 @@ export async function uploadEmbeddedBundleAsync(ctx: BuildContext<BuildJob>): Pr
channel,
'--non-interactive',
];
if (ctx.env.EAS_BUILD_ID) {
args.push('--build-id', ctx.env.EAS_BUILD_ID);
if (env.EAS_BUILD_ID) {
args.push('--build-id', env.EAS_BUILD_ID);
}
await runEasCliCommand({
args,
options: {
cwd: projectDir,
env: ctx.env,
logger: ctx.logger,
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
env,
logger,
mode: PipeMode.COMBINED_AS_STDOUT,
},
});
return { status: 'uploaded' };
} catch (err: any) {
ctx.logger.warn({ err }, 'Failed to upload embedded bundle.');
ctx.markBuildPhaseHasWarnings();
logger.warn({ err }, 'Failed to upload embedded bundle.');
return { status: 'failed' };
} finally {
await asyncResult(zip.close());
}
@@ -0,0 +1,32 @@
const CONNECT_FAILURE_CODES = new Set([
'EAI_AGAIN',
'ECONNREFUSED',
'EHOSTDOWN',
'EHOSTUNREACH',
'ENETDOWN',
'ENETUNREACH',
'ENOTFOUND',
]);
export function isConnectFailure(error: unknown): boolean {
for (let current = error; current instanceof Error; current = current.cause) {
if (
'code' in current &&
typeof current.code === 'string' &&
CONNECT_FAILURE_CODES.has(current.code)
) {
return true;
}
}
return false;
}
// The server may already have applied a request. The caller must decide whether repetition is safe.
export function isConnectionInterruptedError(error: unknown): boolean {
for (let current = error; current instanceof Error; current = current.cause) {
if ('code' in current && (current.code === 'ECONNRESET' || current.code === 'UND_ERR_SOCKET')) {
return true;
}
}
return false;
}
@@ -6,6 +6,16 @@ export function isChildProcessAlive(child: ChildProcess): boolean {
return child.exitCode === null && child.signalCode === null && !child.killed;
}
/** Whether any process in the group led by `pgid` exists. EPERM means it does. */
export function isProcessGroupRunning(pgid: number): boolean {
try {
process.kill(-pgid, 0);
return true;
} catch (error: any) {
return error?.code === 'EPERM';
}
}
/**
* Kill a detached spawn's process group. Negated pid targets the group so bash/sleep
* children cannot survive after the parent is gone (e.g. across upterm redial).
@@ -1,20 +1,30 @@
import promiseRetry from 'promise-retry';
import { OperationOptions } from 'retry';
export function promiseRetryWithCondition<TFn extends (...args: any[]) => Promise<any>>(
fn: TFn,
retryConditionFn: (error: any) => boolean,
options: OperationOptions = { retries: 3, factor: 2 }
options: { retries: number; factor?: number; minTimeout?: number; maxTimeout?: number } = {
retries: 3,
factor: 2,
},
onRetry?: (params: { attemptNumber: number; maxAttemptsCount: number; error: unknown }) => void
): (...funcArgs: Parameters<TFn>) => Promise<ReturnType<TFn>> {
return (...funcArgs) =>
promiseRetry<ReturnType<TFn>>(async retry => {
return (...funcArgs) => {
let lastError: unknown;
const maxAttemptsCount = options.retries + 1;
return promiseRetry<ReturnType<TFn>>(async (retry, attemptNumber) => {
if (attemptNumber > 1) {
onRetry?.({ attemptNumber, maxAttemptsCount, error: lastError });
}
try {
return await fn(...funcArgs);
} catch (e) {
if (retryConditionFn(e)) {
lastError = e;
retry(e);
}
throw e;
}
}, options);
};
}
+203 -154
View File
File diff suppressed because it is too large. Load diff
+509 -133
View File
@@ -124,6 +124,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "scopes",
"description": "Scopes this token is restricted to. Null for a full token that authenticates as the owner\nwith all of their permissions.",
"args": [],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AccessTokenScope",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": null,
@@ -284,6 +304,155 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AccessTokenScope",
"description": "One scope narrowing an AccessToken below its owner's full access",
"fields": [
{
"name": "accountID",
"description": "The account this scope applies to. Only present for ACCOUNT scopes.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "permissions",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "Permission",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "type",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AccessTokenScopeType",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "AccessTokenScopeInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "accountID",
"description": "The account this scope applies to. Required for ACCOUNT scopes and must be omitted for\nALL_ACCOUNTS scopes.",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "permissions",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "Permission",
"ofType": null
}
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "type",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AccessTokenScopeType",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "AccessTokenScopeType",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "ACCOUNT",
"description": "One account, named by accountID, narrowed to the scope's permissions.",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "ALL_ACCOUNTS",
"description": "Every account the token's owner has permissions on at request time, narrowed to the\nscope's permissions.",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AccessibleGitHubAppInstallation",
@@ -1629,6 +1798,83 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deviceRunSessionsPaginated",
"description": "Simulator sessions for all apps associated with this account, sorted newest first.",
"args": [
{
"name": "after",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "before",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filter",
"description": null,
"type": {
"kind": "INPUT_OBJECT",
"name": "AccountDeviceRunSessionFilterInput",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "first",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "last",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AccountDeviceRunSessionsConnection",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "displayName",
"description": null,
@@ -3555,6 +3801,131 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "AccountDeviceRunSessionFilterInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "statuses",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "DeviceRunSessionStatus",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AccountDeviceRunSessionsConnection",
"description": null,
"fields": [
{
"name": "edges",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AccountDeviceRunSessionsEdge",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AccountDeviceRunSessionsEdge",
"description": null,
"fields": [
{
"name": "cursor",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "DeviceRunSession",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AccountGoogleServiceAccountKeysConnection",
@@ -4011,55 +4382,6 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "changeAdditionalAgentCreditsCount",
"description": "Buys or revokes account's additional agent credits, charging the account the appropriate amount if needed.",
"args": [
{
"name": "accountID",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "newAdditionalAgentCreditsCount",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Account",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "changeAdditionalConcurrenciesCount",
"description": "Buys or revokes account's additional concurrencies, charging the account the appropriate amount if needed.",
@@ -44871,6 +45193,26 @@
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "scopes",
"description": "When given, requests authenticated with the new token are restricted to these scopes instead\nof the actor's full access. Either a single ALL_ACCOUNTS scope or one ACCOUNT scope per\naccount.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "AccessTokenScopeInput",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
@@ -45660,6 +46002,38 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "networkCapture",
"description": "Record HTTP(S) traffic from apps on the device for the whole session. iOS only.\nHTTPS is decrypted, so recordings contain credentials and cookies in cleartext.\nIf omitted, no traffic is recorded.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "networkCaptureFields",
"description": "What network capture may keep beyond method, URL, status, timing and size: header, query,\nrequest-body, response-body. Each can carry credentials, so nothing here is recorded unless\nit is asked for. Requires networkCapture.",
"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": "openUrl",
"description": "Expo or development-client URL to open after launching the installed application. Requires\nbuildId, applicationArchiveUrl, or expoGo.",
@@ -47432,6 +47806,18 @@
"description": null,
"fields": null,
"inputFields": [
{
"name": "agentRunId",
"description": null,
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "appId",
"description": null,
@@ -51306,7 +51692,7 @@
},
{
"name": "createEventLogUploadSession",
"description": "Create a standard artifact and a two-hour upload URL for repeatedly\noverwriting its structured event log while the backing job run is running.",
"description": "Create or reuse a standard artifact and a two-hour upload URL for repeatedly\noverwriting its structured event log while the backing job run is running.",
"args": [
{
"name": "deviceRunSessionId",
@@ -51337,6 +51723,39 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createPreviewUploadSession",
"description": "Create or renew a two-hour upload URL for overwriting the session preview.\nReuses one WebP artifact while the backing job run is active.",
"args": [
{
"name": "deviceRunSessionId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "CreateDeviceRunSessionArtifactUploadSessionResult",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deleteDeviceRunSession",
"description": "Permanently delete a finished session and its artifacts. The backing job,\nif present, must also have finished. Returns the deleted session ID.",
@@ -52109,12 +52528,6 @@
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "AGENT",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "BUILDS",
"description": null,
@@ -52174,12 +52587,6 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "CREDIT_USAGE",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "LOCAL_BUILDS",
"description": null,
@@ -65385,51 +65792,6 @@
"name": "InvoiceQuery",
"description": null,
"fields": [
{
"name": "previewInvoiceForAdditionalAgentCreditsCountUpdate",
"description": "Previews the invoice for the specified number of additional agent credit units.",
"args": [
{
"name": "accountID",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "additionalAgentCreditsCount",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "OBJECT",
"name": "Invoice",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previewInvoiceForAdditionalConcurrenciesCountUpdate",
"description": "Previews the invoice for the specified number of additional concurrencies.\nThis is the total number of concurrencies the customer wishes to purchase\non top of their base plan, not the relative change in concurrencies\nthe customer wishes to make. For example, specify \"3\" if the customer has\ntwo add-on concurrencies and wishes to purchase one more.",
@@ -82226,14 +82588,14 @@
{
"name": "GCS_BUILD_APPLICATION_ARCHIVE",
"description": null,
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Submit with the build ID instead."
},
{
"name": "GCS_BUILD_APPLICATION_ARCHIVE_ORCHESTRATOR",
"description": null,
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Submit with the build ID instead."
},
{
"name": "GCS_SUBMIT_ARCHIVE",
@@ -82682,22 +83044,6 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "includedAgentCreditsInCents",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "isDowngrading",
"description": null,
@@ -85437,6 +85783,18 @@
"isDeprecated": true,
"deprecationReason": "Use 'runtime' field ."
},
{
"name": "sourceMapUrl",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": null,
@@ -90015,6 +90373,18 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "simulatorMinutes",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@@ -90039,6 +90409,18 @@
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "simulatorMinutes",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
@@ -90240,12 +90622,6 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "CREDIT",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "EVENT",
"description": null,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "24.9.0",
"version": "24.12.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -20,7 +20,8 @@ export type AnalyticsEvent =
| BuildEvent
| SubmissionEvent
| MetadataEvent
| BillingEvent;
| BillingEvent
| SimulatorEvent;
export enum CommandEvent {
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
@@ -77,6 +78,12 @@ export enum BuildEvent {
ANDROID_KEYSTORE_CREATE = 'build cli credentials keystore create',
}
export enum SimulatorEvent {
REQUEST_SENT = 'sim session request sent',
REQUEST_CANCELLED = 'sim session request cancelled',
REQUEST_FAILED = 'sim session request failed',
}
export enum MetadataEvent {
APPLE_METADATA_DOWNLOAD = 'metadata cli download apple response',
APPLE_METADATA_UPLOAD = 'metadata cli upload apple response',
@@ -121,6 +128,16 @@ export async function getAnalyticsEnabledAsync(): Promise<boolean> {
return !!analyticsEnabled;
}
/**
* Returns true when the user opted out of analytics with DISABLE_EAS_ANALYTICS or `eas analytics off`.
*/
export async function isAnalyticsOptedOutAsync(): Promise<boolean> {
if (process.env.DISABLE_EAS_ANALYTICS) {
return true;
}
return (await UserSettings.getAsync(USER_SETTINGS_KEY_ANALYTICS_ENABLED, true)) === false;
}
/**
* Create an instance of Analytics based on the user's analytics enabled preferences.
*/
@@ -1,7 +1,7 @@
import RudderAnalytics from '@expo/rudder-sdk-node';
import UserSettings from '../../user/UserSettings';
import { CommandEvent, createAnalyticsAsync } from '../AnalyticsManager';
import { CommandEvent, createAnalyticsAsync, isAnalyticsOptedOutAsync } from '../AnalyticsManager';
import { getAgentTelemetryContext } from '../agent';
import { getSandboxTelemetryContext } from '../sandbox';
@@ -122,3 +122,21 @@ it('adds detected sandbox context to analytics events', async () => {
})
);
});
describe(isAnalyticsOptedOutAsync, () => {
it('is false by default', async () => {
await expect(isAnalyticsOptedOutAsync()).resolves.toBe(false);
});
it('is true when DISABLE_EAS_ANALYTICS is set', async () => {
process.env.DISABLE_EAS_ANALYTICS = '1';
await expect(isAnalyticsOptedOutAsync()).resolves.toBe(true);
});
it('is true when analytics were turned off with eas analytics off', async () => {
userSettingsMock.getAsync.mockImplementation(async (key, defaultValue) =>
key === 'analyticsEnabled' ? false : defaultValue
);
await expect(isAnalyticsOptedOutAsync()).resolves.toBe(true);
});
});
@@ -1,8 +1,8 @@
import ContextField, { ContextOptions } from './ContextField';
import { Analytics } from '../../analytics/AnalyticsManager';
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
export default class AnalyticsContextField extends ContextField<Analytics> {
async getValueAsync({ analytics }: ContextOptions): Promise<Analytics> {
export default class AnalyticsContextField extends ContextField<AnalyticsWithOrchestration> {
async getValueAsync({ analytics }: ContextOptions): Promise<AnalyticsWithOrchestration> {
return analytics;
}
}
@@ -1,10 +1,10 @@
import { Analytics } from '../../analytics/AnalyticsManager';
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
import SessionManager from '../../user/SessionManager';
import { Client } from '../../vcs/vcs';
export interface ContextOptions {
sessionManager: SessionManager;
analytics: Analytics;
analytics: AnalyticsWithOrchestration;
nonInteractive: boolean;
vcsClientOverride?: Client;
/** Short-circuits project ID resolution. */
@@ -0,0 +1,180 @@
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import Log from '../../../log';
import { confirmAsync, promptAsync } from '../../../prompts';
import SessionManager from '../../../user/SessionManager';
import { resumeDeviceLoginAsync, startDeviceLoginAsync } from '../../../user/deviceLogin';
import AccountLogin from '../login';
jest.mock('../../../prompts');
jest.mock('../../../log');
jest.mock('../../../user/deviceLogin', () => ({
...jest.requireActual('../../../user/deviceLogin'),
startDeviceLoginAsync: jest.fn(),
resumeDeviceLoginAsync: jest.fn(),
}));
const pending = {
request_id: 'request-id',
status: 'authorization_pending',
verification_uri: 'https://expo.dev/oauth/device',
verification_uri_complete: 'https://expo.dev/oauth/device?user_code=BCDF-GHJK',
user_code: 'BCDF-GHJK',
expires_at: '2026-10-01T00:00:00Z',
retry_after: 5,
} satisfies Awaited<ReturnType<typeof startDeviceLoginAsync>>;
const resumeCommand = `eas login --device --non-interactive --resume ${pending.request_id}`;
const sessionManager = new SessionManager({
logEvent: jest.fn(),
setActor: jest.fn(),
flushAsync: jest.fn(),
});
const originalIsTTY = process.stdin.isTTY;
const originalCI = process.env.CI;
function command(args: string[]): AccountLogin {
const instance = new AccountLogin(['--device', ...args], getMockOclifConfig());
// @ts-expect-error Mock the protected context without initializing a real session.
jest.spyOn(instance, 'getContextAsync').mockResolvedValue({
sessionManager,
maybeLoggedIn: { actor: null },
});
return instance;
}
beforeEach(() => {
jest.clearAllMocks();
process.stdin.isTTY = false;
delete process.env.CI;
jest.spyOn(sessionManager, 'getAccessToken').mockReturnValue(null);
jest.spyOn(global, 'setTimeout');
});
afterEach(() => {
try {
expect(confirmAsync).not.toHaveBeenCalled();
expect(promptAsync).not.toHaveBeenCalled();
expect(setTimeout).not.toHaveBeenCalled();
} finally {
process.stdin.isTTY = originalIsTTY;
if (originalCI === undefined) {
delete process.env.CI;
} else {
process.env.CI = originalCI;
}
jest.restoreAllMocks();
}
});
it('starts once with closed stdin and explains optional matching', async () => {
jest.mocked(startDeviceLoginAsync).mockResolvedValue(pending);
await command([]).runAsync();
expect(Log.log).toHaveBeenCalledWith(`Open ${pending.verification_uri_complete}`);
expect(Log.log).toHaveBeenCalledWith(`Code: ${pending.user_code}`);
expect(Log.log).toHaveBeenCalledWith(
'Ask the user to approve the login in their browser and send you any number shown.'
);
expect(Log.log).toHaveBeenCalledWith(
`Then run ${resumeCommand}, adding --match NUMBER_FROM_USER if they saw a number.`
);
expect(startDeviceLoginAsync).toHaveBeenCalledTimes(1);
expect(resumeDeviceLoginAsync).not.toHaveBeenCalled();
});
it.each([
['--non-interactive', undefined],
[undefined, '1'],
] as const)('runs one step with flag %s and CI=%s in a TTY', async (flag, ci) => {
process.stdin.isTTY = true;
if (ci) {
process.env.CI = ci;
}
jest.mocked(startDeviceLoginAsync).mockResolvedValue(pending);
await command(flag ? [flag] : []).runAsync();
expect(startDeviceLoginAsync).toHaveBeenCalledTimes(1);
expect(Log.log).toHaveBeenCalledWith(
'Ask the user to approve the login in their browser and send you any number shown.'
);
expect(Log.log).toHaveBeenCalledWith(
`Then run ${resumeCommand}, adding --match NUMBER_FROM_USER if they saw a number.`
);
});
it('reports pending approval and when to retry', async () => {
jest.mocked(resumeDeviceLoginAsync).mockResolvedValue(pending);
await command(['--resume', pending.request_id]).runAsync();
expect(resumeDeviceLoginAsync).toHaveBeenCalledTimes(1);
expect(resumeDeviceLoginAsync).toHaveBeenCalledWith(
pending.request_id,
sessionManager,
undefined
);
expect(Log.log).toHaveBeenCalledWith('Approval pending. Retry after 5 seconds:');
expect(Log.log).toHaveBeenCalledWith(resumeCommand);
});
it('requests the browser number when matching is required', async () => {
jest.mocked(resumeDeviceLoginAsync).mockResolvedValue({
...pending,
status: 'matching_required',
match_options: ['12', '42', '87'],
});
await command(['--resume', pending.request_id]).runAsync();
expect(Log.log).toHaveBeenCalledWith(
'Ask the user for the number shown in their browser, then run:'
);
expect(Log.log).toHaveBeenCalledWith(`${resumeCommand} --match NUMBER_FROM_USER`);
expect(resumeDeviceLoginAsync).toHaveBeenCalledTimes(1);
});
it('preserves the supplied number when matching must be retried', async () => {
jest.mocked(resumeDeviceLoginAsync).mockResolvedValue({
...pending,
status: 'slow_down',
retry_after: 10,
});
await command(['--resume', pending.request_id, '--match', '42']).runAsync();
expect(resumeDeviceLoginAsync).toHaveBeenCalledWith(pending.request_id, sessionManager, '42');
expect(Log.log).toHaveBeenCalledWith('Approval pending. Retry after 10 seconds:');
expect(Log.log).toHaveBeenCalledWith(`${resumeCommand} --match 42`);
});
it.each([
{ label: 'with a browser number', args: ['--match', '42'], match: '42' },
{ label: 'without a browser number', args: [], match: undefined },
])('authenticates on the first resume $label', async ({ args, match }) => {
jest.mocked(resumeDeviceLoginAsync).mockResolvedValue({
request_id: pending.request_id,
status: 'authenticated',
username: 'demo-user',
});
await command(['--resume', pending.request_id, ...args]).runAsync();
expect(Log.log).toHaveBeenCalledWith('Logged in as demo-user');
expect(resumeDeviceLoginAsync).toHaveBeenCalledWith(pending.request_id, sessionManager, match);
expect(resumeDeviceLoginAsync).toHaveBeenCalledTimes(1);
});
it.each(['access_denied', 'expired_token', 'invalid_grant'] as const)(
'fails on %s without retrying',
async status => {
jest
.mocked(resumeDeviceLoginAsync)
.mockResolvedValue({ request_id: pending.request_id, status });
await expect(command(['--resume', pending.request_id]).runAsync()).rejects.toThrow(
`Device login failed (${status}). Start again with eas login --device.`
);
expect(resumeDeviceLoginAsync).toHaveBeenCalledTimes(1);
}
);
it('rejects the removed JSON flag', async () => {
await expect(command(['--json']).runAsync()).rejects.toThrow();
expect(startDeviceLoginAsync).not.toHaveBeenCalled();
});
it('requires device login for non-interactive sign-in', async () => {
const instance = new AccountLogin(['--non-interactive'], getMockOclifConfig());
await expect(instance.runAsync()).rejects.toThrow(
'Use eas login --device --non-interactive to log in without prompts.'
);
});
+107 -8
View File
@@ -2,15 +2,40 @@ import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { confirmAsync, promptAsync } from '../../prompts';
import SessionManager from '../../user/SessionManager';
import { getActorDisplayName } from '../../user/User';
import {
isDeviceLoginFailure,
resumeDeviceLoginAsync,
startDeviceLoginAsync,
} from '../../user/deviceLogin';
export default class AccountLogin extends EasCommand {
static override description = 'log in with your Expo account';
static override aliases = ['login'];
static override examples = [
'<%= config.bin %> login --device',
'<%= config.bin %> login --device --non-interactive',
'<%= config.bin %> login --device --non-interactive --resume REQUEST_ID --match NUMBER_FROM_USER',
];
static override flags = {
device: Flags.boolean({
description: 'Log in using a code in a browser on any device',
exclusive: ['sso'],
}),
...EASNonInteractiveFlag,
resume: Flags.string({
description: 'Resume a saved device login using its request ID (one command at a time)',
dependsOn: ['device'],
}),
match: Flags.string({
description: 'Submit the number the user sees in their browser',
dependsOn: ['device', 'resume'],
}),
// can pass either --sso or -s
sso: Flags.boolean({
description: 'Log in with SSO',
@@ -32,32 +57,106 @@ export default class AccountLogin extends EasCommand {
async runAsync(): Promise<void> {
const {
flags: { sso, browser },
flags: { sso, browser, device, 'non-interactive': nonInteractive, resume, match },
} = await this.parse(AccountLogin);
if (nonInteractive && !device) {
throw new Error('Use eas login --device --non-interactive to log in without prompts.');
}
const {
sessionManager,
maybeLoggedIn: { actor },
} = await this.getContextAsync(AccountLogin, { nonInteractive: false });
} = await this.getContextAsync(AccountLogin, { nonInteractive });
if (sessionManager.getAccessToken()) {
throw new Error(
'EXPO_TOKEN is set in your environment, and is being used for all EAS authentication. To use username/password authentication, unset EXPO_TOKEN in your environment and re-run the command.'
'EXPO_TOKEN is set in your environment, and is being used for all EAS authentication. Unset EXPO_TOKEN to log in with an Expo account.'
);
}
if (actor) {
if (actor && !resume) {
Log.warn(`You are already logged in as ${chalk.bold(getActorDisplayName(actor))}.`);
const shouldContinue = await confirmAsync({
message: 'Do you want to continue?',
});
const shouldContinue =
nonInteractive ||
(await confirmAsync({
message: 'Do you want to continue?',
}));
if (!shouldContinue) {
Errors.error('Aborted', { exit: 1 });
}
}
if (device) {
await this.runDeviceLoginAsync(sessionManager, { nonInteractive, resume, match });
return;
}
await sessionManager.showLoginPromptAsync({ sso, browser });
Log.log('Logged in');
}
private async runDeviceLoginAsync(
sessionManager: SessionManager,
options: { nonInteractive: boolean; resume?: string; match?: string }
): Promise<void> {
let result = options.resume
? await resumeDeviceLoginAsync(options.resume, sessionManager, options.match)
: await startDeviceLoginAsync();
if (options.nonInteractive) {
if (isDeviceLoginFailure(result)) {
throw new Error(
`Device login failed (${result.status}). Start again with eas login --device.`
);
}
if (result.status === 'authenticated') {
Log.log(`Logged in as ${result.username}`);
return;
}
const resumeCommand = `eas login --device --non-interactive --resume ${result.request_id}`;
if (!options.resume) {
Log.log(`Open ${result.verification_uri_complete}`);
Log.log(`Code: ${result.user_code}`);
Log.log(
'Ask the user to approve the login in their browser and send you any number shown.'
);
Log.log(`Then run ${resumeCommand}, adding --match NUMBER_FROM_USER if they saw a number.`);
} else if (result.status === 'matching_required') {
Log.log('Ask the user for the number shown in their browser, then run:');
Log.log(`${resumeCommand} --match NUMBER_FROM_USER`);
} else {
Log.log(`Approval pending. Retry after ${result.retry_after} seconds:`);
Log.log(`${resumeCommand}${options.match ? ` --match ${options.match}` : ''}`);
}
return;
}
if ('verification_uri_complete' in result) {
Log.log(`Open ${result.verification_uri_complete}`);
Log.log(`Code: ${result.user_code}`);
Log.log(`You can resume with eas login --device --resume ${result.request_id}`);
}
let match = options.match;
while (result.status !== 'authenticated') {
if (isDeviceLoginFailure(result)) {
throw new Error(
`Device login failed (${result.status}). Start again with eas login --device.`
);
}
if (result.status === 'matching_required') {
const answer = await promptAsync({
type: 'select',
name: 'match',
message: 'Select the number shown in your browser',
choices: result.match_options.map(value => ({ title: value, value })),
});
match = answer.match;
}
const delayMs = result.retry_after * 1000;
await new Promise(resolve => setTimeout(resolve, delayMs));
result = await resumeDeviceLoginAsync(result.request_id, sessionManager, match);
}
Log.log(`Logged in as ${result.username}`);
}
}
@@ -1,12 +1,17 @@
import { Config } from '@oclif/core';
import { CombinedError } from '@urql/core';
import * as fs from 'fs-extra';
import { GraphQLError } from 'graphql';
import { SimulatorEvent, isAnalyticsOptedOutAsync } from '../../../analytics/AnalyticsManager';
import { getAgentTelemetryContext } from '../../../analytics/agent';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
CreateDeviceRunSessionMutation,
DeviceRunSessionByIdQuery,
DeviceRunSessionEgress,
DeviceRunSessionRequestOrigin,
DeviceRunSessionResourceClass,
DeviceRunSessionStatus,
DeviceRunSessionType,
@@ -28,9 +33,15 @@ import {
} from '../../../simulator/env';
import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo';
import * as promiseUtils from '../../../utils/promise';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import Simulator from '../index';
jest.mock('fs-extra');
jest.mock('../../../analytics/AnalyticsManager', () => ({
...jest.requireActual('../../../analytics/AnalyticsManager'),
isAnalyticsOptedOutAsync: jest.fn(),
}));
jest.mock('../../../analytics/agent');
jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
jest.mock('../../../graphql/queries/DeviceRunSessionAvailabilityQuery');
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
@@ -53,6 +64,11 @@ jest.mock('../../../simulator/env', () => ({
resetSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../simulator/expoGo');
jest.mock('../../../utils/json');
jest.mock('../../../utils/promise', () => ({
...jest.requireActual('../../../utils/promise'),
sleepAsync: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../../prompts');
jest.mock('../../../simulator/egress', () => ({
...jest.requireActual('../../../simulator/egress'),
@@ -89,10 +105,16 @@ const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked(
const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQuery.byAppIdAsync);
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
const mockLogEvent = jest.fn();
const mockFlushAsync = jest.fn(async () => {});
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
const mockResolveExpoGoSdkVersionAsync = jest.mocked(resolveExpoGoSdkVersionAsync);
const mockOra = jest.mocked(ora);
const mockPromptAsync = jest.mocked(promptAsync);
const mockGetAgentTelemetryContext = jest.mocked(getAgentTelemetryContext);
const mockIsAnalyticsOptedOutAsync = jest.mocked(isAnalyticsOptedOutAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeCreatedDeviceRunSession(
overrides: Partial<CreatedDeviceRunSession> = {}
@@ -164,6 +186,7 @@ describe(Simulator, () => {
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
delete process.env[EAS_SIMULATOR_SESSION_ID];
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
@@ -177,6 +200,8 @@ describe(Simulator, () => {
mockResetSimulatorEnvAsync.mockResolvedValue();
mockResolveExpoGoSdkVersionAsync.mockResolvedValue('55.0.0');
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
mockGetAgentTelemetryContext.mockReturnValue(null);
mockIsAnalyticsOptedOutAsync.mockResolvedValue(false);
});
afterAll(() => {
@@ -198,6 +223,7 @@ describe(Simulator, () => {
// @ts-expect-error getContextAsync is protected
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { actor: { isExpoAdmin }, graphqlClient },
analytics: { logEvent: mockLogEvent, flushAsync: mockFlushAsync, setActor: jest.fn() },
projectDir,
projectId: 'project-123',
});
@@ -230,6 +256,203 @@ describe(Simulator, () => {
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalled();
});
it('logs "request sent" with the funnel properties before creating the session', async () => {
mockLogEvent.mockClear();
mockCreateDeviceRunSessionAsync.mockImplementationOnce(async () => {
expect(mockLogEvent).toHaveBeenCalledWith(SimulatorEvent.REQUEST_SENT, {
project_id: 'project-123',
origin: 'eas-cli',
type: 'agent-device',
platform: 'ios',
has_build_id: false,
has_archive_url: false,
expo_go: false,
non_interactive: true,
});
return makeCreatedDeviceRunSession();
});
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--out-config-type',
'env',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledTimes(1);
expect(mockLogEvent).toHaveBeenCalledTimes(1);
});
it.each([
['network_error', Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' })],
['timeout', Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' })],
])(
'logs "request failed" with reason %s when the create request gets no answer, and rethrows',
async (reason, networkError) => {
mockLogEvent.mockClear();
const error = new CombinedError({ networkError });
mockCreateDeviceRunSessionAsync.mockRejectedValueOnce(error);
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toBe(error);
expect(mockLogEvent).toHaveBeenCalledTimes(2);
expect(mockLogEvent).toHaveBeenLastCalledWith(
SimulatorEvent.REQUEST_FAILED,
expect.objectContaining({ project_id: 'project-123', reason })
);
}
);
it.each([
[
'a GraphQL refusal',
new CombinedError({
graphQLErrors: [new GraphQLError('Simulator sessions are not enabled')],
}),
],
[
'an HTTP error answer',
new CombinedError({
networkError: new Error('Gateway Timeout'),
response: { status: 504, statusText: 'Gateway Timeout' },
}),
],
])('logs only "request sent" when the server answers with %s', async (_, error) => {
mockLogEvent.mockClear();
mockCreateDeviceRunSessionAsync.mockRejectedValueOnce(error);
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toBe(error);
expect(mockLogEvent).toHaveBeenCalledTimes(1);
expect(mockLogEvent).toHaveBeenCalledWith(SimulatorEvent.REQUEST_SENT, expect.anything());
});
function mockCreateThatNeverAnswers(): Promise<void> {
return new Promise<void>(notifyCreateStarted => {
mockCreateDeviceRunSessionAsync.mockImplementationOnce(
() =>
new Promise(() => {
notifyCreateStarted();
})
);
});
}
it('logs "request cancelled", stops the spinner, and exits with 130 only after the flush on Ctrl+C while the session is being created', async () => {
mockLogEvent.mockClear();
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(code => {
throw new Error(`process.exit(${code})`);
});
try {
let finishFlush!: () => void;
const flushStarted = new Promise<void>(notifyFlushStarted => {
mockFlushAsync.mockImplementationOnce(
() =>
new Promise<void>(resolve => {
finishFlush = resolve;
notifyFlushStarted();
})
);
});
const createStarted = mockCreateThatNeverAnswers();
const existingSigintListeners = new Set(process.listeners('SIGINT'));
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
const commandPromise = command.runAsync();
await createStarted;
process.listeners('SIGINT').find(listener => !existingSigintListeners.has(listener))?.(
'SIGINT'
);
await flushStarted;
const exitCallsDuringFlush = [...processExitSpy.mock.calls];
finishFlush();
await expect(commandPromise).rejects.toThrow('process.exit(130)');
expect(exitCallsDuringFlush).toEqual([]);
expect(mockLogEvent).toHaveBeenLastCalledWith(
SimulatorEvent.REQUEST_CANCELLED,
expect.objectContaining({ project_id: 'project-123', reason: 'user_abort' })
);
expect(mockOra.mock.results[0]?.value.fail).toHaveBeenCalledWith(
'Simulator session request canceled'
);
expect(mockEnsureDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(process.listeners('SIGINT')).toEqual([...existingSigintListeners]);
} finally {
processExitSpy.mockRestore();
}
});
it('removes its Ctrl+C listener on the first Ctrl+C during the request, so a second Ctrl+C is not swallowed during the flush', async () => {
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(code => {
throw new Error(`process.exit(${code})`);
});
try {
const createStarted = mockCreateThatNeverAnswers();
const existingSigintListeners = [...process.listeners('SIGINT')];
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
const commandPromise = command.runAsync();
await createStarted;
process.emit('SIGINT');
const listenersAfterFirstCtrlC = process.listeners('SIGINT');
await expect(commandPromise).rejects.toThrow('process.exit(130)');
expect(listenersAfterFirstCtrlC).toEqual(existingSigintListeners);
} finally {
processExitSpy.mockRestore();
}
});
it('exits with 130 after 1 second when the analytics flush hangs after Ctrl+C', async () => {
jest.useFakeTimers();
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(code => {
throw new Error(`process.exit(${code})`);
});
try {
const { sleepAsync } = jest.requireActual<typeof promiseUtils>('../../../utils/promise');
jest.spyOn(promiseUtils, 'sleepAsync').mockImplementation(sleepAsync);
mockFlushAsync.mockImplementationOnce(() => new Promise<void>(() => {}));
const createStarted = mockCreateThatNeverAnswers();
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
const commandPromise = command.runAsync();
const exited = expect(commandPromise).rejects.toThrow('process.exit(130)');
await createStarted;
process.emit('SIGINT');
await jest.advanceTimersByTimeAsync(999);
const exitCallsBeforeOneSecond = [...processExitSpy.mock.calls];
await jest.advanceTimersByTimeAsync(1);
await exited;
expect(exitCallsBeforeOneSecond).toEqual([]);
expect(mockFlushAsync).toHaveBeenCalled();
} finally {
processExitSpy.mockRestore();
jest.useRealTimers();
}
});
it('removes its Ctrl+C listener once the create request answers or fails', async () => {
const existingSigintListeners = [...process.listeners('SIGINT')];
let listenersWhilePolling: Function[] | undefined;
mockByIdAsync.mockImplementationOnce(async () => {
listenersWhilePolling = [...process.listeners('SIGINT')];
return makeDeviceRunSession();
});
await createCommand(['--platform', 'ios', '--non-interactive']).command.runAsync();
// Only the session's own Ctrl+C handler, registered after the request answered, is left.
expect(listenersWhilePolling).toHaveLength(existingSigintListeners.length + 1);
expect(process.listeners('SIGINT')).toEqual(existingSigintListeners);
mockCreateDeviceRunSessionAsync.mockRejectedValueOnce(new Error('boom'));
await expect(
createCommand(['--platform', 'ios', '--non-interactive']).command.runAsync()
).rejects.toThrow('boom');
expect(process.listeners('SIGINT')).toEqual(existingSigintListeners);
});
it('prints environment variables without saving when outputting env', async () => {
const { command, getContextAsync } = createCommand([
'--platform',
@@ -249,6 +472,7 @@ describe(Simulator, () => {
packageVersion: undefined,
platform: AppPlatform.Ios,
type: DeviceRunSessionType.AgentDevice,
requestOrigin: DeviceRunSessionRequestOrigin.EasCli,
});
expect(fs.writeFile).not.toHaveBeenCalled();
expect(mockOra.mock.results[0]?.value.succeed).toHaveBeenCalledWith(
@@ -411,6 +635,7 @@ describe(Simulator, () => {
packageVersion: undefined,
platform: AppPlatform.Ios,
type: DeviceRunSessionType.AgentDevice,
requestOrigin: DeviceRunSessionRequestOrigin.EasCli,
});
});
@@ -431,6 +656,7 @@ describe(Simulator, () => {
packageVersion: undefined,
platform: AppPlatform.Ios,
type: DeviceRunSessionType.AgentDevice,
requestOrigin: DeviceRunSessionRequestOrigin.EasCli,
});
});
@@ -451,9 +677,106 @@ describe(Simulator, () => {
platform: AppPlatform.Ios,
type: DeviceRunSessionType.AgentDevice,
maxIdleTimeMinutes: 30,
requestOrigin: DeviceRunSessionRequestOrigin.EasCli,
});
});
it('passes --network-capture to the createDeviceRunSession mutation', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--network-capture',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'project-123',
name: undefined,
networkCapture: true,
packageVersion: undefined,
platform: AppPlatform.Ios,
type: DeviceRunSessionType.AgentDevice,
requestOrigin: DeviceRunSessionRequestOrigin.EasCli,
});
});
it('passes the requested capture fields, splitting a comma-separated value', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--network-capture',
'--network-capture-field',
'header,query',
'--network-capture-field',
'request-body',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
networkCapture: true,
networkCaptureFields: ['header', 'query', 'request-body'],
})
);
});
it('does not send a capture field list when none was asked for', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--network-capture',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.not.objectContaining({ networkCaptureFields: expect.anything() })
);
});
it('rejects a capture field the recorder does not know', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--network-capture',
'--network-capture-field',
'cookies',
]);
await expect(command.runAsync()).rejects.toThrow(/Unknown network capture field 'cookies'/);
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('rejects capture fields without --network-capture', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--network-capture-field',
'header',
]);
await expect(command.runAsync()).rejects.toThrow();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('rejects --network-capture on android', async () => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
'--network-capture',
]);
await expect(command.runAsync()).rejects.toThrow(/only supported on iOS/);
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it(`throws when ${EAS_SIMULATOR_SESSION_ID} is already present with --no-force`, async () => {
process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session';
@@ -532,6 +855,43 @@ describe(Simulator, () => {
);
});
it('sends the detected coding agent with the create request', async () => {
mockGetAgentTelemetryContext.mockReturnValue({ id: 'claude', sessionId: 'agent-session-1' });
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
requestOrigin: DeviceRunSessionRequestOrigin.EasCli,
agentId: 'claude',
agentSessionId: 'agent-session-1',
})
);
});
it('leaves out the agent session when the detected agent has none', async () => {
mockGetAgentTelemetryContext.mockReturnValue({ id: 'codex', sessionId: undefined });
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await command.runAsync();
const input = mockCreateDeviceRunSessionAsync.mock.calls[0]?.[1];
expect(input).toMatchObject({ agentId: 'codex' });
expect(input).not.toHaveProperty('agentSessionId');
});
it('leaves out the coding agent when the user opted out of analytics', async () => {
mockIsAnalyticsOptedOutAsync.mockResolvedValue(true);
mockGetAgentTelemetryContext.mockReturnValue({ id: 'claude', sessionId: 'agent-session-1' });
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await command.runAsync();
const input = mockCreateDeviceRunSessionAsync.mock.calls[0]?.[1];
expect(input).toMatchObject({ requestOrigin: DeviceRunSessionRequestOrigin.EasCli });
expect(input).not.toHaveProperty('agentId');
expect(input).not.toHaveProperty('agentSessionId');
});
it('forwards --device in the iOS create options', async () => {
const { command } = createCommand([
'--platform',
@@ -1324,4 +1684,280 @@ describe(Simulator, () => {
expect(mockPromptAsync).not.toHaveBeenCalled();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('fails the create spinner and rethrows when session creation fails', async () => {
mockCreateDeviceRunSessionAsync.mockRejectedValue(new Error('create boom'));
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow('create boom');
expect(mockByIdAsync).not.toHaveBeenCalled();
});
it('throws when the session errors before it becomes ready', async () => {
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null })
);
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(/errored before the .* session was ready/);
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled();
});
it('throws when the turtle job run finishes before the session is ready', async () => {
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({
remoteConfig: null,
turtleJobRun: { id: 'job-123', status: JobRunStatus.Errored },
})
);
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
/Turtle job run for simulator session .* errored before/
);
});
it('keeps polling until the remote config appears', async () => {
mockByIdAsync
.mockResolvedValueOnce(makeDeviceRunSession({ remoteConfig: null }))
.mockResolvedValueOnce(makeDeviceRunSession());
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await command.runAsync();
expect(mockByIdAsync).toHaveBeenCalledTimes(2);
});
it('stops the session and rethrows when polling for readiness fails', async () => {
mockByIdAsync.mockRejectedValue(new Error('poll boom'));
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow('poll boom');
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
graphqlClient,
'session-123'
);
});
it('times out when the session never becomes ready', async () => {
mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ remoteConfig: null }));
const realNow = Date.now();
jest
.spyOn(Date, 'now')
.mockReturnValueOnce(realNow)
.mockReturnValue(realNow + 60 * 60 * 1000);
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(/Timed out after \d+s waiting for/);
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled();
});
it('prints JSON only and skips the human-readable output with --json', async () => {
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({ id: 'session-123', deviceRunSessionUrl })
);
});
it('warns but continues when the dotenv file cannot be written', async () => {
jest.mocked(fs.writeFile).mockRejectedValue(new Error('disk full') as never);
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await command.runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining(
`Failed to write simulator environment variables to ${SIMULATOR_DOTENV_FILE_NAME}`
)
);
});
it('retries after a transient poll failure while the session is running', async () => {
mockByIdAsync
.mockResolvedValueOnce(makeDeviceRunSession())
.mockRejectedValueOnce(new Error('transient'))
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped }));
const { command } = createCommand(['--platform', 'ios']);
await command.runAsync();
expect(Log.debug).toHaveBeenCalledWith(
expect.stringContaining('Failed to poll simulator session')
);
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
});
it('throws when the session errors while it is running', async () => {
mockByIdAsync
.mockResolvedValueOnce(makeDeviceRunSession())
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored }));
const { command } = createCommand(['--platform', 'ios']);
await expect(command.runAsync()).rejects.toThrow('Simulator session session-123 errored.');
});
it('keeps waiting while the session is still in progress', async () => {
mockByIdAsync
.mockResolvedValueOnce(makeDeviceRunSession())
.mockResolvedValueOnce(makeDeviceRunSession())
.mockResolvedValueOnce(
makeDeviceRunSession({ turtleJobRun: { id: 'job-123', status: JobRunStatus.Finished } })
);
const { command } = createCommand(['--platform', 'ios']);
await command.runAsync();
expect(mockByIdAsync).toHaveBeenCalledTimes(3);
});
it('rethrows when clearing the dotenv file fails after the session ends', async () => {
mockByIdAsync
.mockResolvedValueOnce(makeDeviceRunSession())
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped }));
mockResetSimulatorEnvAsync.mockRejectedValue(new Error('unlink failed'));
const { command } = createCommand(['--platform', 'ios']);
await expect(command.runAsync()).rejects.toThrow('unlink failed');
expect(Log.error).toHaveBeenCalledWith(`Failed to clean up ${SIMULATOR_DOTENV_FILE_NAME}`);
});
it('warns and reports failure when the session cannot be stopped', async () => {
mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession()).mockImplementationOnce(async () => {
process.emit('SIGINT');
return makeDeviceRunSession();
});
mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue(new Error('stop boom'));
const { command } = createCommand(['--platform', 'ios']);
await command.runAsync();
expect(Log.warn).toHaveBeenCalledWith(
'Failed to stop simulator session session-123: stop boom'
);
});
it('stops the session when interrupted while it is running', async () => {
mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession()).mockImplementationOnce(async () => {
process.emit('SIGINT');
return makeDeviceRunSession();
});
const { command } = createCommand(['--platform', 'ios']);
await command.runAsync();
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
graphqlClient,
'session-123'
);
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
});
it('stops the session and reports when the stop cannot be confirmed after an interrupt before ready', async () => {
jest.spyOn(process, 'exit').mockImplementation(code => {
throw new Error(`process.exit(${code})`);
});
mockByIdAsync.mockImplementation(async () => {
process.emit('SIGINT');
return makeDeviceRunSession({ remoteConfig: null });
});
mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue(new Error('stop boom'));
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow('process.exit(130)');
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
graphqlClient,
'session-123'
);
});
it('stops the session when interrupted after it is ready', async () => {
jest.spyOn(process, 'exit').mockImplementation(code => {
throw new Error(`process.exit(${code})`);
});
jest
.mocked(fs.writeFile)
.mockImplementationOnce((async () => undefined) as never)
.mockImplementationOnce((async () => {
process.emit('SIGINT');
}) as never);
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow('process.exit(130)');
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
graphqlClient,
'session-123'
);
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
});
it('force exits when a second interrupt arrives', async () => {
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(code => {
throw new Error(`process.exit(${code})`);
});
mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession()).mockImplementationOnce(async () => {
process.emit('SIGINT');
process.emit('SIGINT');
return makeDeviceRunSession();
});
const { command } = createCommand(['--platform', 'ios']);
await command.runAsync();
expect(Log.error).toHaveBeenCalledWith(
'Aborted before the simulator session could be stopped. Run `eas simulator:stop --id session-123` to terminate it and avoid unexpected charges.'
);
expect(exitSpy).toHaveBeenCalledWith(130);
});
it('stringifies a non-Error dotenv write failure', async () => {
jest.mocked(fs.writeFile).mockRejectedValue('disk full' as never);
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await command.runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('disk full'));
});
it('stringifies a non-Error poll failure while the session is running', async () => {
mockByIdAsync
.mockResolvedValueOnce(makeDeviceRunSession())
.mockRejectedValueOnce('network down')
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped }));
const { command } = createCommand(['--platform', 'ios']);
await command.runAsync();
expect(Log.debug).toHaveBeenCalledWith(expect.stringContaining('network down'));
});
it('stringifies a non-Error stop failure', async () => {
jest.spyOn(process, 'exit').mockImplementation(code => {
throw new Error(`process.exit(${code})`);
});
mockByIdAsync.mockImplementation(async () => {
process.emit('SIGINT');
return makeDeviceRunSession({ remoteConfig: null });
});
mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue('gateway timeout');
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow('process.exit(130)');
expect(Log.warn).toHaveBeenCalledWith(
'Failed to stop simulator session session-123: gateway timeout'
);
});
});
+152 -23
View File
@@ -1,6 +1,13 @@
import { Flags } from '@oclif/core';
import nullthrows from 'nullthrows';
import {
AnalyticsEventProperties,
AnalyticsWithOrchestration,
SimulatorEvent,
isAnalyticsOptedOutAsync,
} from '../../analytics/AnalyticsManager';
import { getAgentTelemetryContext } from '../../analytics/agent';
import { getDeviceRunSessionUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
@@ -12,6 +19,7 @@ import {
import {
AppPlatform,
DeviceRunSessionEgress,
DeviceRunSessionRequestOrigin,
DeviceRunSessionStatus,
DeviceRunSessionType,
JobRunStatus,
@@ -20,9 +28,10 @@ import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessi
import { DeviceRunSessionAvailabilityQuery } from '../../graphql/queries/DeviceRunSessionAvailabilityQuery';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { Ora, ora } from '../../ora';
import { promptAsync } from '../../prompts';
import { parseEgressAllowList, runLocalEgressAsync } from '../../simulator/egress';
import { parseNetworkCaptureFields } from '../../simulator/networkCapture';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
@@ -31,6 +40,10 @@ import {
writeSimulatorEnvAsync,
} from '../../simulator/env';
import { resolveExpoGoSdkVersionAsync } from '../../simulator/expoGo';
import {
simulatorRequestFailureReason,
simulatorRequestProperties,
} from '../../simulator/requestAnalytics';
import {
DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES,
@@ -128,6 +141,16 @@ export default class Simulator extends EasCommand {
description:
'Version of the package backing the simulator session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
}),
'network-capture': Flags.boolean({
description:
'Record HTTP(S) traffic from apps on the device (iOS only). HTTPS is decrypted, so recordings contain credentials in cleartext and certificate-pinned apps fail to connect.',
}),
'network-capture-field': Flags.string({
description:
'What a recording may keep beyond method, URL, status, timing and size: header, query, request-body, response-body. Repeatable or comma-separated. Defaults to none of them, because each can carry credentials.',
multiple: true,
dependsOn: ['network-capture'],
}),
'max-duration-minutes': Flags.integer({
description:
"Maximum duration of the simulator session in minutes before it is automatically stopped. Defaults to, and cannot exceed, the maximum session duration of the account's plan.",
@@ -172,6 +195,7 @@ export default class Simulator extends EasCommand {
...this.ContextOptions.ProjectId,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
};
async runAsync(): Promise<void> {
@@ -186,6 +210,7 @@ export default class Simulator extends EasCommand {
projectId,
projectDir,
loggedIn: { actor, graphqlClient },
analytics,
} = await this.getContextAsync(Simulator, {
nonInteractive,
});
@@ -241,6 +266,14 @@ export default class Simulator extends EasCommand {
);
}
// Before the platform prompt: a typo here does not depend on the answer.
let networkCaptureFields: string[] = [];
try {
networkCaptureFields = parseNetworkCaptureFields(flags['network-capture-field'] ?? []);
} catch (err) {
throw new EasCommandError(err instanceof Error ? err.message : String(err));
}
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined;
let egressAllow: string[] = [];
@@ -249,6 +282,12 @@ export default class Simulator extends EasCommand {
} catch (err) {
throw new EasCommandError(err instanceof Error ? err.message : String(err));
}
if (flags['network-capture'] && platform !== AppPlatform.Ios) {
throw new EasCommandError(
'Network capture is only supported on iOS simulator sessions. Re-run without --network-capture, or pass --platform ios.'
);
}
if (platform === AppPlatform.Android) {
Log.warn(
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
@@ -273,31 +312,52 @@ export default class Simulator extends EasCommand {
let deviceRunSessionUrl: string;
let sessionInterrupt: SessionInterrupt | undefined;
try {
const session = await DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, {
appId: projectId,
name,
...(tags?.length ? { tags } : {}),
const agentIdentity = await agentIdentityInputAsync();
const requestProperties = simulatorRequestProperties({
projectId,
type: flags.type,
platform,
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
hasBuildId: !!buildId || !!buildFingerprint,
hasArchiveUrl: !!applicationArchiveUrlFromFlag,
expoGo: !!expoGoSdkVersion,
packageVersion: flags['package-version'],
...(deviceIdentifier
? platform === AppPlatform.Ios
? { ios: { deviceIdentifier } }
: { android: { deviceIdentifier } }
: {}),
...(buildId ? { buildId } : {}),
...(buildFingerprint ? { buildFingerprint } : {}),
...(applicationArchiveUrlFromFlag
? { applicationArchiveUrl: applicationArchiveUrlFromFlag }
: {}),
...(expoGoSdkVersion ? { expoGo: true, sdkVersion: expoGoSdkVersion } : {}),
...(launchArgs?.length ? { launchArgs } : {}),
...(openUrl ? { openUrl } : {}),
...(resourceClass ? { resourceClass } : {}),
...(egress ? { egress } : {}),
maxRunTimeMinutes: flags['max-duration-minutes'],
maxIdleTimeMinutes: flags['max-idle-time-minutes'],
nonInteractive,
});
const session = await withSimulatorRequestAnalyticsAsync(
analytics,
requestProperties,
createSpinner,
() =>
DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, {
appId: projectId,
name,
...(tags?.length ? { tags } : {}),
platform,
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
packageVersion: flags['package-version'],
networkCapture: flags['network-capture'],
...(networkCaptureFields.length ? { networkCaptureFields } : {}),
...(deviceIdentifier
? platform === AppPlatform.Ios
? { ios: { deviceIdentifier } }
: { android: { deviceIdentifier } }
: {}),
...(buildId ? { buildId } : {}),
...(buildFingerprint ? { buildFingerprint } : {}),
...(applicationArchiveUrlFromFlag
? { applicationArchiveUrl: applicationArchiveUrlFromFlag }
: {}),
...(expoGoSdkVersion ? { expoGo: true, sdkVersion: expoGoSdkVersion } : {}),
...(launchArgs?.length ? { launchArgs } : {}),
...(openUrl ? { openUrl } : {}),
...(resourceClass ? { resourceClass } : {}),
...(egress ? { egress } : {}),
requestOrigin: DeviceRunSessionRequestOrigin.EasCli,
...agentIdentity,
maxRunTimeMinutes: flags['max-duration-minutes'],
maxIdleTimeMinutes: flags['max-idle-time-minutes'],
})
);
deviceRunSessionId = session.id;
nullthrows(session.turtleJobRun?.id, 'Expected simulator session to start');
deviceRunSessionUrl = getDeviceRunSessionUrl(
@@ -317,6 +377,11 @@ export default class Simulator extends EasCommand {
simulatorEnvWritten ? `, saved to ${SIMULATOR_DOTENV_FILE_NAME}` : ''
}) ${link(deviceRunSessionUrl)}`
);
if (flags['network-capture']) {
Log.warn(
'Network capture was requested. HTTPS is decrypted, so recordings contain credentials in cleartext. Relaunch an installed app to record its traffic.'
);
}
} catch (err) {
createSpinner.fail('Failed to create simulator session');
sessionInterrupt?.dispose();
@@ -729,3 +794,67 @@ async function ensureDeviceRunSessionStoppedSafelyAsync(
return false;
}
}
/**
* How long Ctrl+C waits for analytics to flush. The analytics client has no request timeout, so
* without a limit a hung network would make Ctrl+C look ignored.
*/
const CANCEL_FLUSH_TIMEOUT_MS = 1_000;
class SimulatorRequestCancelledError extends Error {}
/**
* Runs the create request and logs the client-side funnel events around it: "request sent" before
* it leaves, "request cancelled" on Ctrl+C before an answer, and "request failed" when no answer
* arrives. Ctrl+C stops the spinner, flushes analytics, and exits with 130, like the session's own
* Ctrl+C handler: going through the command's error handling would print the exit as an error and
* report it to Sentry.
*/
async function withSimulatorRequestAnalyticsAsync<T>(
analytics: AnalyticsWithOrchestration,
properties: AnalyticsEventProperties,
spinner: Ora,
createAsync: () => Promise<T>
): Promise<T> {
let onSigint: (() => void) | undefined;
const cancelled = new Promise<never>((_, reject) => {
onSigint = () => {
reject(new SimulatorRequestCancelledError());
};
});
process.once('SIGINT', onSigint!);
analytics.logEvent(SimulatorEvent.REQUEST_SENT, properties);
try {
return await Promise.race([createAsync(), cancelled]);
} catch (error) {
if (error instanceof SimulatorRequestCancelledError) {
analytics.logEvent(SimulatorEvent.REQUEST_CANCELLED, { ...properties, reason: 'user_abort' });
spinner.fail('Simulator session request canceled');
const flushTimeout = new AbortController();
await Promise.race([
analytics.flushAsync(),
sleepAsync(CANCEL_FLUSH_TIMEOUT_MS, flushTimeout.signal),
]);
flushTimeout.abort();
process.exit(130);
}
const reason = simulatorRequestFailureReason(error);
if (reason) {
analytics.logEvent(SimulatorEvent.REQUEST_FAILED, { ...properties, reason });
}
throw error;
} finally {
process.removeListener('SIGINT', onSigint!);
}
}
async function agentIdentityInputAsync(): Promise<{ agentId?: string; agentSessionId?: string }> {
if (await isAnalyticsOptedOutAsync()) {
return {};
}
const agent = getAgentTelemetryContext();
if (!agent) {
return {};
}
return { agentId: agent.id, ...(agent.sessionId ? { agentSessionId: agent.sessionId } : {}) };
}
@@ -7,7 +7,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import UpdateView from '../view';
jest.mock('../../../graphql/queries/UpdateQuery', () => ({
UpdateQuery: { viewUpdateGroupAsync: jest.fn(), viewByUpdateAsync: jest.fn() },
UpdateQuery: { viewUpdateGroupWithFileUrlsAsync: jest.fn(), viewByUpdateAsync: jest.fn() },
}));
jest.mock('../../../graphql/queries/UpdateInsightsQuery', () => ({
UpdateInsightsQuery: { viewUpdateGroupInsightsAsync: jest.fn() },
@@ -15,7 +15,9 @@ jest.mock('../../../graphql/queries/UpdateInsightsQuery', () => ({
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockViewUpdateGroupAsync = jest.mocked(UpdateQuery.viewUpdateGroupAsync);
const mockViewUpdateGroupWithFileUrlsAsync = jest.mocked(
UpdateQuery.viewUpdateGroupWithFileUrlsAsync
);
const mockViewByUpdateAsync = jest.mocked(UpdateQuery.viewByUpdateAsync);
const mockViewUpdateGroupInsightsAsync = jest.mocked(
UpdateInsightsQuery.viewUpdateGroupInsightsAsync
@@ -95,7 +97,7 @@ describe(UpdateView, () => {
beforeEach(() => {
jest.clearAllMocks();
mockViewUpdateGroupAsync.mockResolvedValue(updateGroup);
mockViewUpdateGroupWithFileUrlsAsync.mockResolvedValue(updateGroup);
mockViewUpdateGroupInsightsAsync.mockResolvedValue(insightsResponse);
});
@@ -160,13 +162,15 @@ describe(UpdateView, () => {
it('does not look up a single update when the ID resolves as a group', async () => {
const command = createCommand(['group-1']);
await command.runAsync();
expect(mockViewUpdateGroupAsync).toHaveBeenCalledWith(graphqlClient, { groupId: 'group-1' });
expect(mockViewUpdateGroupWithFileUrlsAsync).toHaveBeenCalledWith(graphqlClient, {
groupId: 'group-1',
});
expect(mockViewByUpdateAsync).not.toHaveBeenCalled();
});
it('resolves a platform-specific update ID to its group', async () => {
mockViewUpdateGroupAsync.mockReset();
mockViewUpdateGroupAsync
mockViewUpdateGroupWithFileUrlsAsync.mockReset();
mockViewUpdateGroupWithFileUrlsAsync
.mockRejectedValueOnce(new Error('Could not find any updates with group ID: "u1"'))
.mockResolvedValueOnce(updateGroup);
mockViewByUpdateAsync.mockResolvedValue({ ...updateGroup[0], group: 'group-1' });
@@ -175,7 +179,7 @@ describe(UpdateView, () => {
await command.runAsync();
expect(mockViewByUpdateAsync).toHaveBeenCalledWith(graphqlClient, { updateId: 'u1' });
expect(mockViewUpdateGroupAsync).toHaveBeenLastCalledWith(graphqlClient, {
expect(mockViewUpdateGroupWithFileUrlsAsync).toHaveBeenLastCalledWith(graphqlClient, {
groupId: 'group-1',
});
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
@@ -185,8 +189,8 @@ describe(UpdateView, () => {
});
it('surfaces the original group lookup error when the ID matches neither a group nor an update', async () => {
mockViewUpdateGroupAsync.mockReset();
mockViewUpdateGroupAsync.mockRejectedValue(
mockViewUpdateGroupWithFileUrlsAsync.mockReset();
mockViewUpdateGroupWithFileUrlsAsync.mockRejectedValue(
new Error('Could not find any updates with group ID: "bogus"')
);
mockViewByUpdateAsync.mockRejectedValue(new Error('update not found'));
+9 -7
View File
@@ -4,7 +4,7 @@ import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { UpdateFragment } from '../../graphql/generated';
import { UpdateFragment, UpdateWithFileUrlsFragment } from '../../graphql/generated';
import { UpdateInsightsQuery } from '../../graphql/queries/UpdateInsightsQuery';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import { resolveInsightsTimeRange } from '../../insights/timeRange';
@@ -18,7 +18,7 @@ import {
import {
formatUpdateGroup,
getUpdateGroupDescriptions,
getUpdateJsonInfosForUpdates,
getUpdateWithFileUrlsJsonInfosForUpdates,
} from '../../update/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -98,11 +98,11 @@ export default class UpdateView extends EasCommand {
if (jsonFlag) {
if (insightsSummary) {
printJsonOnlyOutput({
updates: getUpdateJsonInfosForUpdates(updatesByGroup),
updates: getUpdateWithFileUrlsJsonInfosForUpdates(updatesByGroup),
insights: buildUpdateInsightsJson(insightsSummary),
});
} else {
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(updatesByGroup));
printJsonOnlyOutput(getUpdateWithFileUrlsJsonInfosForUpdates(updatesByGroup));
}
} else {
const [updateGroupDescription] = getUpdateGroupDescriptions([updatesByGroup]);
@@ -127,9 +127,11 @@ export default class UpdateView extends EasCommand {
private static async resolveUpdateGroupAsync(
graphqlClient: ExpoGraphqlClient,
id: string
): Promise<{ groupId: string; updatesByGroup: UpdateFragment[] }> {
): Promise<{ groupId: string; updatesByGroup: UpdateWithFileUrlsFragment[] }> {
try {
const updatesByGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, { groupId: id });
const updatesByGroup = await UpdateQuery.viewUpdateGroupWithFileUrlsAsync(graphqlClient, {
groupId: id,
});
return { groupId: id, updatesByGroup };
} catch (groupError) {
let update: UpdateFragment | undefined;
@@ -141,7 +143,7 @@ export default class UpdateView extends EasCommand {
throw groupError;
}
const updatesByGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
const updatesByGroup = await UpdateQuery.viewUpdateGroupWithFileUrlsAsync(graphqlClient, {
groupId: update.group,
});
return { groupId: update.group, updatesByGroup };
+119 -21
View File
@@ -44,6 +44,11 @@ export type AccessToken = {
note?: Maybe<Scalars['String']['output']>;
owner: Actor;
revokedAt?: Maybe<Scalars['DateTime']['output']>;
/**
* Scopes this token is restricted to. Null for a full token that authenticates as the owner
* with all of their permissions.
*/
scopes?: Maybe<Array<AccessTokenScope>>;
updatedAt: Scalars['DateTime']['output'];
visibleTokenPrefix: Scalars['String']['output'];
};
@@ -74,6 +79,35 @@ export type AccessTokenMutation_SetAccessTokenRevokedArgs = {
revoked?: InputMaybe<Scalars['Boolean']['input']>;
};
/** One scope narrowing an AccessToken below its owner's full access */
export type AccessTokenScope = {
__typename?: 'AccessTokenScope';
/** The account this scope applies to. Only present for ACCOUNT scopes. */
accountID?: Maybe<Scalars['ID']['output']>;
permissions: Array<Permission>;
type: AccessTokenScopeType;
};
export type AccessTokenScopeInput = {
/**
* The account this scope applies to. Required for ACCOUNT scopes and must be omitted for
* ALL_ACCOUNTS scopes.
*/
accountID?: InputMaybe<Scalars['ID']['input']>;
permissions: Array<Permission>;
type: AccessTokenScopeType;
};
export enum AccessTokenScopeType {
/** One account, named by accountID, narrowed to the scope's permissions. */
Account = 'ACCOUNT',
/**
* Every account the token's owner has permissions on at request time, narrowed to the
* scope's permissions.
*/
AllAccounts = 'ALL_ACCOUNTS'
}
/** A GitHub App installation visible to a viewer, not necessarily linked yet. */
export type AccessibleGitHubAppInstallation = {
__typename?: 'AccessibleGitHubAppInstallation';
@@ -149,6 +183,8 @@ export type Account = {
/** Convex team connections for this account */
convexTeamConnections: Array<ConvexTeamConnection>;
createdAt: Scalars['DateTime']['output'];
/** Simulator sessions for all apps associated with this account, sorted newest first. */
deviceRunSessionsPaginated: AccountDeviceRunSessionsConnection;
displayName?: Maybe<Scalars['String']['output']>;
/** Echo projects for this account (paginated, most recent first) */
echoProjects: EchoProjectConnection;
@@ -419,6 +455,19 @@ export type Account_ConcurrencyConsumersArgs = {
};
/**
* 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_DeviceRunSessionsPaginatedArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<AccountDeviceRunSessionFilterInput>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
/**
* An account is a container owning projects, credentials, billing and other organization
* data and settings. Actors may own and be members of accounts.
@@ -622,6 +671,22 @@ export type AccountDataInput = {
userSpecifiedAccountUsage?: InputMaybe<UserSpecifiedAccountUsage>;
};
export type AccountDeviceRunSessionFilterInput = {
statuses?: InputMaybe<Array<DeviceRunSessionStatus>>;
};
export type AccountDeviceRunSessionsConnection = {
__typename?: 'AccountDeviceRunSessionsConnection';
edges: Array<AccountDeviceRunSessionsEdge>;
pageInfo: PageInfo;
};
export type AccountDeviceRunSessionsEdge = {
__typename?: 'AccountDeviceRunSessionsEdge';
cursor: Scalars['String']['output'];
node: DeviceRunSession;
};
export type AccountGoogleServiceAccountKeysConnection = {
__typename?: 'AccountGoogleServiceAccountKeysConnection';
edges: Array<AccountGoogleServiceAccountKeysEdge>;
@@ -696,8 +761,6 @@ export type AccountMutation = {
cancelAllSubscriptionsImmediately: Account;
/** Cancel scheduled subscription change */
cancelScheduledSubscriptionChange: Account;
/** Buys or revokes account's additional agent credits, charging the account the appropriate amount if needed. */
changeAdditionalAgentCreditsCount: Account;
/** Buys or revokes account's additional concurrencies, charging the account the appropriate amount if needed. */
changeAdditionalConcurrenciesCount: Account;
/** Upgrades or downgrades the active subscription to the newPlanIdentifier, which must be one of the EAS plans (i.e., Production or Enterprise). */
@@ -740,12 +803,6 @@ export type AccountMutation_CancelScheduledSubscriptionChangeArgs = {
};
export type AccountMutation_ChangeAdditionalAgentCreditsCountArgs = {
accountID: Scalars['ID']['input'];
newAdditionalAgentCreditsCount: Scalars['Int']['input'];
};
export type AccountMutation_ChangeAdditionalConcurrenciesCountArgs = {
accountID: Scalars['ID']['input'];
newAdditionalConcurrenciesCount: Scalars['Int']['input'];
@@ -6295,6 +6352,12 @@ export type CrashesFilters = {
export type CreateAccessTokenInput = {
actorID: Scalars['ID']['input'];
note?: InputMaybe<Scalars['String']['input']>;
/**
* When given, requests authenticated with the new token are restricted to these scopes instead
* of the actor's full access. Either a single ALL_ACCOUNTS scope or one ACCOUNT scope per
* account.
*/
scopes?: InputMaybe<Array<AccessTokenScopeInput>>;
};
export type CreateAccessTokenResponse = {
@@ -6451,6 +6514,18 @@ export type CreateDeviceRunSessionInput = {
* fall back to identifying it by id.
*/
name?: InputMaybe<Scalars['String']['input']>;
/**
* Record HTTP(S) traffic from apps on the device for the whole session. iOS only.
* HTTPS is decrypted, so recordings contain credentials and cookies in cleartext.
* If omitted, no traffic is recorded.
*/
networkCapture?: InputMaybe<Scalars['Boolean']['input']>;
/**
* What network capture may keep beyond method, URL, status, timing and size: header, query,
* request-body, response-body. Each can carry credentials, so nothing here is recorded unless
* it is asked for. Requires networkCapture.
*/
networkCaptureFields?: InputMaybe<Array<Scalars['String']['input']>>;
/**
* Expo or development-client URL to open after launching the installed application. Requires
* buildId, applicationArchiveUrl, or expoGo.
@@ -6655,6 +6730,7 @@ export type CreatePostHogDeepLinkInput = {
};
export type CreateSandboxInput = {
agentRunId?: InputMaybe<Scalars['ID']['input']>;
appId: Scalars['ID']['input'];
image?: InputMaybe<Scalars['String']['input']>;
operatingSystem: SandboxOperatingSystem;
@@ -7220,10 +7296,15 @@ export type DeviceRunSessionMutation = {
/** Create a device run session */
createDeviceRunSession: DeviceRunSession;
/**
* Create a standard artifact and a two-hour upload URL for repeatedly
* Create or reuse a standard artifact and a two-hour upload URL for repeatedly
* overwriting its structured event log while the backing job run is running.
*/
createEventLogUploadSession: CreateDeviceRunSessionEventLogUploadSessionResult;
/**
* Create or renew a two-hour upload URL for overwriting the session preview.
* Reuses one WebP artifact while the backing job run is active.
*/
createPreviewUploadSession: CreateDeviceRunSessionArtifactUploadSessionResult;
/**
* Permanently delete a finished session and its artifacts. The backing job,
* if present, must also have finished. Returns the deleted session ID.
@@ -7261,6 +7342,11 @@ export type DeviceRunSessionMutation_CreateEventLogUploadSessionArgs = {
};
export type DeviceRunSessionMutation_CreatePreviewUploadSessionArgs = {
deviceRunSessionId: Scalars['ID']['input'];
};
export type DeviceRunSessionMutation_DeleteDeviceRunSessionArgs = {
deviceRunSessionId: Scalars['ID']['input'];
};
@@ -7392,7 +7478,6 @@ export enum EasBuildWaiverType {
}
export enum EasService {
Agent = 'AGENT',
Builds = 'BUILDS',
Jobs = 'JOBS',
Mcp = 'MCP',
@@ -7404,7 +7489,6 @@ export enum EasServiceMetric {
AssetsRequests = 'ASSETS_REQUESTS',
BandwidthUsage = 'BANDWIDTH_USAGE',
Builds = 'BUILDS',
CreditUsage = 'CREDIT_USAGE',
LocalBuilds = 'LOCAL_BUILDS',
ManifestRequests = 'MANIFEST_REQUESTS',
McpRequests = 'MCP_REQUESTS',
@@ -9373,8 +9457,6 @@ export type InvoicePeriod = {
export type InvoiceQuery = {
__typename?: 'InvoiceQuery';
/** Previews the invoice for the specified number of additional agent credit units. */
previewInvoiceForAdditionalAgentCreditsCountUpdate?: Maybe<Invoice>;
/**
* Previews the invoice for the specified number of additional concurrencies.
* This is the total number of concurrencies the customer wishes to purchase
@@ -9388,12 +9470,6 @@ export type InvoiceQuery = {
};
export type InvoiceQuery_PreviewInvoiceForAdditionalAgentCreditsCountUpdateArgs = {
accountID: Scalars['ID']['input'];
additionalAgentCreditsCount: Scalars['Int']['input'];
};
export type InvoiceQuery_PreviewInvoiceForAdditionalConcurrenciesCountUpdateArgs = {
accountID: Scalars['ID']['input'];
additionalConcurrenciesCount: Scalars['Int']['input'];
@@ -11709,7 +11785,9 @@ export type SubmissionArchiveSourceInput = {
};
export enum SubmissionArchiveSourceType {
/** @deprecated Submit with the build ID instead. */
GcsBuildApplicationArchive = 'GCS_BUILD_APPLICATION_ARCHIVE',
/** @deprecated Submit with the build ID instead. */
GcsBuildApplicationArchiveOrchestrator = 'GCS_BUILD_APPLICATION_ARCHIVE_ORCHESTRATOR',
GcsSubmitArchive = 'GCS_SUBMIT_ARCHIVE',
Url = 'URL'
@@ -11792,7 +11870,6 @@ export type SubscriptionDetails = {
endedAt?: Maybe<Scalars['DateTime']['output']>;
futureSubscription?: Maybe<FutureSubscription>;
id: Scalars['ID']['output'];
includedAgentCreditsInCents: Scalars['Int']['output'];
isDowngrading?: Maybe<Scalars['Boolean']['output']>;
meteredBillingStatus: MeteredBillingStatus;
name?: Maybe<Scalars['String']['output']>;
@@ -12173,6 +12250,7 @@ export type Update = ActivityTimelineProjectActivity & {
runtime: Runtime;
/** @deprecated Use 'runtime' field . */
runtimeVersion: Scalars['String']['output'];
sourceMapUrl?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
workflowJob?: Maybe<WorkflowJob>;
};
@@ -12837,10 +12915,12 @@ export type UsageBudget = {
export type UsageBudgetLimits = {
__typename?: 'UsageBudgetLimits';
builds?: Maybe<Scalars['Int']['output']>;
simulatorMinutes?: Maybe<Scalars['Int']['output']>;
};
export type UsageBudgetLimitsInput = {
builds?: InputMaybe<Scalars['Int']['input']>;
simulatorMinutes?: InputMaybe<Scalars['Int']['input']>;
};
export type UsageBudgetMutation = {
@@ -12868,7 +12948,6 @@ export type UsageMetricTotal = {
export enum UsageMetricType {
Bandwidth = 'BANDWIDTH',
Build = 'BUILD',
Credit = 'CREDIT',
Event = 'EVENT',
Minute = 'MINUTE',
Request = 'REQUEST',
@@ -17025,6 +17104,18 @@ export type ViewUpdatesByGroupQuery = { __typename?: 'RootQuery', updatesByGroup
| { __typename: 'User', username: string, id: string }
| null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null, rolloutControlUpdate?: { __typename?: 'Update', id: string, group: string } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, source?: { __typename?: 'FingerprintSource', type: FingerprintSourceType, bucketKey: string, isDebugFingerprint?: boolean | null } | null } | null }> };
export type ViewUpdatesByGroupWithFileUrlsQueryVariables = Exact<{
groupId: Scalars['ID']['input'];
}>;
export type ViewUpdatesByGroupWithFileUrlsQuery = { __typename?: 'RootQuery', updatesByGroup: Array<{ __typename?: 'Update', id: string, assetMapUrl?: string | null, sourceMapUrl?: string | null, group: string, message?: string | null, createdAt: any, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, isGitWorkingTreeDirty: boolean, environment?: any | null, rolloutPercentage?: number | null, manifestHostOverride?: string | null, assetHostOverride?: string | null, runtime: { __typename?: 'Runtime', id: string, version: string }, actor?:
| { __typename: 'PartnerActor', username: string, id: string }
| { __typename: 'Robot', firstName?: string | null, id: string }
| { __typename: 'SSOUser', username: string, id: string }
| { __typename: 'User', username: string, id: string }
| null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null, rolloutControlUpdate?: { __typename?: 'Update', id: string, group: string } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, source?: { __typename?: 'FingerprintSource', type: FingerprintSourceType, bucketKey: string, isDebugFingerprint?: boolean | null } | null } | null }> };
export type ViewUpdateGroupsOnBranchQueryVariables = Exact<{
appId: Scalars['String']['input'];
branchName: Scalars['String']['input'];
@@ -17406,6 +17497,13 @@ export type UpdateFragment = { __typename?: 'Update', id: string, group: string,
| { __typename: 'User', username: string, id: string }
| null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null, rolloutControlUpdate?: { __typename?: 'Update', id: string, group: string } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, source?: { __typename?: 'FingerprintSource', type: FingerprintSourceType, bucketKey: string, isDebugFingerprint?: boolean | null } | null } | null };
export type UpdateWithFileUrlsFragment = { __typename?: 'Update', id: string, assetMapUrl?: string | null, sourceMapUrl?: string | null, group: string, message?: string | null, createdAt: any, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, isGitWorkingTreeDirty: boolean, environment?: any | null, rolloutPercentage?: number | null, manifestHostOverride?: string | null, assetHostOverride?: string | null, runtime: { __typename?: 'Runtime', id: string, version: string }, actor?:
| { __typename: 'PartnerActor', username: string, id: string }
| { __typename: 'Robot', firstName?: string | null, id: string }
| { __typename: 'SSOUser', username: string, id: string }
| { __typename: 'User', username: string, id: string }
| null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null, rolloutControlUpdate?: { __typename?: 'Update', id: string, group: string } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, source?: { __typename?: 'FingerprintSource', type: FingerprintSourceType, bucketKey: string, isDebugFingerprint?: boolean | null } | null } | null };
export type UpdateBranchFragment = { __typename?: 'UpdateBranch', id: string, name: string, updates: Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, isGitWorkingTreeDirty: boolean, environment?: any | null, rolloutPercentage?: number | null, manifestHostOverride?: string | null, assetHostOverride?: string | null, runtime: { __typename?: 'Runtime', id: string, version: string }, actor?:
| { __typename: 'PartnerActor', username: string, id: string }
| { __typename: 'Robot', firstName?: string | null, id: string }
@@ -7,6 +7,7 @@ import {
UpdateByIdQuery,
UpdateByIdQueryVariables,
UpdateFragment,
UpdateWithFileUrlsFragment,
ViewUpdateGroupsOnAppQuery,
ViewUpdateGroupsOnAppQueryVariables,
ViewUpdateGroupsOnBranchQuery,
@@ -15,8 +16,10 @@ import {
ViewUpdateGroupsPaginatedOnBranchQueryVariables,
ViewUpdatesByGroupQuery,
ViewUpdatesByGroupQueryVariables,
ViewUpdatesByGroupWithFileUrlsQuery,
ViewUpdatesByGroupWithFileUrlsQueryVariables,
} from '../generated';
import { UpdateFragmentNode } from '../types/Update';
import { UpdateFragmentNode, UpdateWithFileUrlsFragmentNode } from '../types/Update';
export const UpdateQuery = {
async viewUpdateGroupAsync(
@@ -49,6 +52,40 @@ export const UpdateQuery = {
return updatesByGroup;
},
/**
* Same as viewUpdateGroupAsync, but also fetches the signed asset map and source map URLs.
* Those are signed per request on the server, so only use this when the URLs are shown.
*/
async viewUpdateGroupWithFileUrlsAsync(
graphqlClient: ExpoGraphqlClient,
{ groupId }: ViewUpdatesByGroupWithFileUrlsQueryVariables
): Promise<UpdateWithFileUrlsFragment[]> {
const { updatesByGroup } = await withErrorHandlingAsync(
graphqlClient
.query<ViewUpdatesByGroupWithFileUrlsQuery, ViewUpdatesByGroupWithFileUrlsQueryVariables>(
gql`
query ViewUpdatesByGroupWithFileUrls($groupId: ID!) {
updatesByGroup(group: $groupId) {
id
...UpdateWithFileUrlsFragment
}
}
${print(UpdateWithFileUrlsFragmentNode)}
`,
{
groupId,
},
{ additionalTypenames: ['Update'] }
)
.toPromise()
);
if (updatesByGroup.length === 0) {
throw new Error(`Could not find any updates with group ID: "${groupId}"`);
}
return updatesByGroup;
},
async viewUpdateGroupsOnBranchAsync(
graphqlClient: ExpoGraphqlClient,
{ limit, offset, appId, branchName, filter }: ViewUpdateGroupsOnBranchQueryVariables
@@ -106,6 +106,8 @@ function isNotFoundError(error: unknown): boolean {
const code = e?.extensions?.errorCode ?? e?.extensions?.code;
return (
code === 'ENTITY_NOT_FOUND' ||
code === 'BUILD_NOT_FOUND' ||
code === 'NOT_FOUND_ERROR' ||
/Entity not found/i.test(e?.message ?? '') ||
/not found/i.test(e?.message ?? '')
);
@@ -3,6 +3,19 @@ import { GraphqlError } from '../../client';
import { BuildStatus, JobRunStatus, WorkflowJobStatus } from '../../generated';
import { WorkflowJobSshQuery } from '../WorkflowJobSshQuery';
const workflowJobNotFoundError = {
message: 'Entity Not Found: Entity not found: WorkflowJobEntity (id = missing)',
extensions: { errorCode: 'NOT_FOUND_ERROR', errorType: 'USER' },
};
const buildNotFoundError = {
message: "Build with id 'missing' does not exist.",
extensions: { errorCode: 'BUILD_NOT_FOUND', errorType: 'USER' },
};
const jobRunNotFoundError = {
message: 'Entity Not Found: Entity not found: TurtleJobRunEntity (id = missing)',
extensions: { errorCode: 'NOT_FOUND_ERROR', errorType: 'USER' },
};
describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => {
function makeClient(byId: unknown): {
graphqlClient: ExpoGraphqlClient;
@@ -64,9 +77,7 @@ describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => {
toPromise: async () => ({
data: undefined,
error: new GraphqlError({
graphQLErrors: [
{ message: 'Entity not found', extensions: { errorCode: 'ENTITY_NOT_FOUND' } },
],
graphQLErrors: [workflowJobNotFoundError],
networkError: undefined,
response: undefined,
}),
@@ -82,6 +93,7 @@ describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => {
for (const graphQLErrors of [
[{ message: 'Workflow job not found', extensions: {} }],
[{ message: 'missing', extensions: { code: 'ENTITY_NOT_FOUND' } }],
[{ message: 'missing', extensions: { errorCode: 'NOT_FOUND_ERROR' } }],
]) {
const query = jest.fn().mockReturnValue({
toPromise: async () => ({
@@ -231,9 +243,7 @@ describe(WorkflowJobSshQuery.connectInfoForBuildAsync.name, () => {
toPromise: async () => ({
data: undefined,
error: new GraphqlError({
graphQLErrors: [
{ message: 'Entity not found', extensions: { errorCode: 'ENTITY_NOT_FOUND' } },
],
graphQLErrors: [buildNotFoundError],
networkError: undefined,
response: undefined,
}),
@@ -260,11 +270,13 @@ describe(WorkflowJobSshQuery.connectInfoForBuildAsync.name, () => {
});
describe(WorkflowJobSshQuery.connectInfoForResourceIdAsync.name, () => {
const notFound = new GraphqlError({
graphQLErrors: [{ message: 'Entity not found', extensions: { errorCode: 'ENTITY_NOT_FOUND' } }],
networkError: undefined,
response: undefined,
});
function notFound(graphQLError: typeof workflowJobNotFoundError): GraphqlError {
return new GraphqlError({
graphQLErrors: [graphQLError],
networkError: undefined,
response: undefined,
});
}
const session = {
id: 'ts-1',
connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false },
@@ -279,16 +291,16 @@ describe(WorkflowJobSshQuery.connectInfoForResourceIdAsync.name, () => {
if ('workflowJobId' in variables) {
return found.workflowJob
? { data: { workflowJobs: { byId: found.workflowJob } } }
: { data: undefined, error: notFound };
: { data: undefined, error: notFound(workflowJobNotFoundError) };
}
if ('buildId' in variables) {
return found.build
? { data: { builds: { byId: found.build } } }
: { data: undefined, error: notFound };
: { data: undefined, error: notFound(buildNotFoundError) };
}
return found.jobRun
? { data: { jobRun: { byId: found.jobRun } } }
: { data: undefined, error: notFound };
: { data: undefined, error: notFound(jobRunNotFoundError) };
},
}));
return { graphqlClient: { query } as unknown as ExpoGraphqlClient, query };
@@ -1,3 +1,4 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
export const UpdateFragmentNode = gql`
@@ -58,3 +59,19 @@ export const UpdateFragmentNode = gql`
assetHostOverride
}
`;
/**
* UpdateFragment plus the stored file URLs.
*
* Each URL is signed on demand by the server, which is not free, so only query this where the
* URLs are actually shown. List queries should use UpdateFragment.
*/
export const UpdateWithFileUrlsFragmentNode = gql`
fragment UpdateWithFileUrlsFragment on Update {
id
...UpdateFragment
assetMapUrl
sourceMapUrl
}
${print(UpdateFragmentNode)}
`;
@@ -0,0 +1,25 @@
import { print } from 'graphql';
import { UpdateFragmentNode, UpdateWithFileUrlsFragmentNode } from '../Update';
const FILE_URL_FIELDS = ['assetMapUrl', 'sourceMapUrl'];
describe('UpdateFragment', () => {
// The server signs each of these URLs per request, so list queries must not ask for them.
it.each(FILE_URL_FIELDS)('does not request %s', field => {
expect(print(UpdateFragmentNode)).not.toContain(field);
});
});
describe('UpdateWithFileUrlsFragment', () => {
it.each(FILE_URL_FIELDS)('requests %s', field => {
expect(print(UpdateWithFileUrlsFragmentNode)).toContain(field);
});
it('builds on UpdateFragment rather than duplicating its fields', () => {
const printed = print(UpdateWithFileUrlsFragmentNode);
expect(printed).toContain('...UpdateFragment');
expect(printed).toContain('fragment UpdateFragment on Update');
});
});
@@ -0,0 +1,32 @@
import { parseNetworkCaptureFields } from '../networkCapture';
describe(parseNetworkCaptureFields, () => {
it('accepts repeated and comma-separated values, like serve-sim does', () => {
expect(parseNetworkCaptureFields(['header,query', 'request-body'])).toEqual([
'header',
'query',
'request-body',
]);
});
it('matches the name case-insensitively, like serve-sim does', () => {
expect(parseNetworkCaptureFields(['Header', ' QUERY '])).toEqual(['header', 'query']);
});
it('keeps one of each', () => {
expect(parseNetworkCaptureFields(['header', 'header'])).toEqual(['header']);
});
it('rejects a field the recorder does not know', () => {
expect(() => parseNetworkCaptureFields(['cookies'])).toThrow(/Unknown network capture field/);
});
it('rejects an empty value rather than quietly keeping nothing', () => {
expect(() => parseNetworkCaptureFields([''])).toThrow(/Empty network capture field/);
expect(() => parseNetworkCaptureFields([',,'])).toThrow(/Empty network capture field/);
});
it('returns nothing when nothing was asked for', () => {
expect(parseNetworkCaptureFields([])).toEqual([]);
});
});
@@ -0,0 +1,99 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { simulatorRequestFailureReason, simulatorRequestProperties } from '../requestAnalytics';
const properties = simulatorRequestProperties({
projectId: 'project-123',
type: 'serve-sim',
platform: 'IOS',
hasBuildId: false,
hasArchiveUrl: false,
expoGo: false,
nonInteractive: true,
});
function networkError(fields: { code?: string; name?: string; causeCode?: string } = {}): Error {
const error = new Error('fetch failed') as Error & { code?: string; cause?: { code?: string } };
if (fields.name) {
error.name = fields.name;
}
if (fields.code) {
error.code = fields.code;
}
if (fields.causeCode) {
error.cause = { code: fields.causeCode };
}
return error;
}
describe(simulatorRequestProperties, () => {
it('names the funnel reference properties and leaves out an unset package version', () => {
expect(properties).toEqual({
project_id: 'project-123',
origin: 'eas-cli',
type: 'serve-sim',
platform: 'ios',
has_build_id: false,
has_archive_url: false,
expo_go: false,
non_interactive: true,
});
expect(
simulatorRequestProperties({
projectId: 'p',
type: 'argent',
platform: 'ANDROID',
hasBuildId: true,
hasArchiveUrl: true,
expoGo: true,
packageVersion: '0.22.1',
nonInteractive: false,
})
).toMatchObject({ platform: 'android', requested_package_version: '0.22.1' });
});
});
describe(simulatorRequestFailureReason, () => {
it('reports a timeout from the error code, its cause, or the error name', () => {
for (const error of [
networkError({ code: 'ETIMEDOUT' }),
networkError({ causeCode: 'UND_ERR_CONNECT_TIMEOUT' }),
networkError({ name: 'AbortError' }),
networkError({ name: 'TimeoutError' }),
]) {
expect(simulatorRequestFailureReason(new CombinedError({ networkError: error }))).toBe(
'timeout'
);
}
});
it('reports any other missing answer as a network error', () => {
expect(
simulatorRequestFailureReason(
new CombinedError({ networkError: networkError({ code: 'ECONNRESET' }) })
)
).toBe('network_error');
});
it('is null when the server answered, since www reports refusals', () => {
expect(
simulatorRequestFailureReason(
new CombinedError({
graphQLErrors: [new GraphQLError('Simulator sessions are not enabled')],
})
)
).toBeNull();
expect(
simulatorRequestFailureReason(new Error('Returned query result data is null!'))
).toBeNull();
expect(
simulatorRequestFailureReason(
new CombinedError({
networkError: new Error('Gateway Timeout'),
response: { status: 504, statusText: 'Gateway Timeout' },
})
)
).toBeNull();
});
});
@@ -0,0 +1,27 @@
export const NETWORK_CAPTURE_FIELDS = ['header', 'query', 'request-body', 'response-body'] as const;
export type NetworkCaptureField = (typeof NETWORK_CAPTURE_FIELDS)[number];
/**
* Resolve `--network-capture-field` the way serve-sim's own flag does: comma-separated values are
* accepted, and the name is matched case-insensitively.
*/
export function parseNetworkCaptureFields(entries: readonly string[]): NetworkCaptureField[] {
const fields = entries
.flatMap(entry => entry.split(','))
.map(field => field.trim().toLowerCase());
if (fields.some(field => field.length === 0)) {
throw new Error(
`Empty network capture field. Choose from: ${NETWORK_CAPTURE_FIELDS.join(', ')}.`
);
}
const unknown = fields.find(
field => !NETWORK_CAPTURE_FIELDS.includes(field as NetworkCaptureField)
);
if (unknown !== undefined) {
throw new Error(
`Unknown network capture field '${unknown}'. Supported: ${NETWORK_CAPTURE_FIELDS.join(', ')}.`
);
}
return [...new Set(fields as NetworkCaptureField[])];
}
@@ -0,0 +1,75 @@
import { CombinedError } from '@urql/core';
import { AnalyticsEventProperties } from '../analytics/AnalyticsManager';
/** Matches www's `request_origin` value for eas-cli, though this CLI does not send it to www yet. */
export const SIMULATOR_REQUEST_ORIGIN = 'eas-cli';
export type SimulatorRequestFailureReason = 'network_error' | 'timeout';
const TIMEOUT_CODES = new Set([
'ETIMEDOUT',
'ESOCKETTIMEDOUT',
'UND_ERR_CONNECT_TIMEOUT',
'UND_ERR_HEADERS_TIMEOUT',
]);
export function simulatorRequestProperties({
projectId,
type,
platform,
hasBuildId,
hasArchiveUrl,
expoGo,
packageVersion,
nonInteractive,
}: {
projectId: string;
type: string;
platform: string;
hasBuildId: boolean;
hasArchiveUrl: boolean;
expoGo: boolean;
packageVersion?: string;
nonInteractive: boolean;
}): AnalyticsEventProperties {
return {
project_id: projectId,
origin: SIMULATOR_REQUEST_ORIGIN,
type,
platform: platform.toLowerCase(),
has_build_id: hasBuildId,
has_archive_url: hasArchiveUrl,
expo_go: expoGo,
...(packageVersion ? { requested_package_version: packageVersion } : {}),
non_interactive: nonInteractive,
};
}
/**
* Why the create request got no answer, or null when the server answered. A server refusal is
* not a client failure: www reports it as an admission denial, so counting it here would count
* the same attempt twice.
*/
export function simulatorRequestFailureReason(
error: unknown
): SimulatorRequestFailureReason | null {
if (!(error instanceof CombinedError) || !error.networkError || error.graphQLErrors.length > 0) {
return null;
}
// urql attaches the HTTP response to non-2xx answers too, such as a 504 from the load balancer.
// An answer arrived, so www may have seen the request already.
if (error.response) {
return null;
}
const networkError = error.networkError as Error & { code?: string; cause?: { code?: string } };
const code = networkError.code ?? networkError.cause?.code;
if (
(code && TIMEOUT_CODES.has(code)) ||
networkError.name === 'AbortError' ||
networkError.name === 'TimeoutError'
) {
return 'timeout';
}
return 'network_error';
}
@@ -1,4 +1,29 @@
import { getPlatformsForGroup, truncateString } from '../utils';
import { UpdateWithFileUrlsFragment } from '../../graphql/generated';
import {
getPlatformsForGroup,
getUpdateWithFileUrlsJsonInfosForUpdates,
truncateString,
} from '../utils';
function makeUpdate(
overrides: Partial<UpdateWithFileUrlsFragment> = {}
): UpdateWithFileUrlsFragment {
return {
id: 'update-1',
group: 'group-1',
message: 'a message',
createdAt: '2026-01-01T00:00:00Z',
platform: 'ios',
manifestFragment: '{}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://expo.dev/manifest',
gitCommitHash: 'abc123',
isGitWorkingTreeDirty: false,
branch: { id: 'branch-1', name: 'main' },
runtime: { id: 'runtime-1', version: '1.0.0' },
...overrides,
} as UpdateWithFileUrlsFragment;
}
describe('update utility functions', () => {
describe(truncateString, () => {
@@ -16,6 +41,41 @@ describe('update utility functions', () => {
});
});
describe(getUpdateWithFileUrlsJsonInfosForUpdates, () => {
it('includes the signed asset map and source map URLs', () => {
const [info] = getUpdateWithFileUrlsJsonInfosForUpdates([
makeUpdate({
assetMapUrl: 'https://storage.example/assetmap.json?signature',
sourceMapUrl: 'https://storage.example/updates/ios.map?signature',
}),
]);
expect(info.assetMapUrl).toBe('https://storage.example/assetmap.json?signature');
expect(info.sourceMapUrl).toBe('https://storage.example/updates/ios.map?signature');
});
it.each([[null], [undefined]])(
'reports both URLs as null when the server returns %p',
value => {
const [info] = getUpdateWithFileUrlsJsonInfosForUpdates([
makeUpdate({ assetMapUrl: value, sourceMapUrl: value }),
]);
expect(info.assetMapUrl).toBeNull();
expect(info.sourceMapUrl).toBeNull();
}
);
it('reports sourceMapUrl as null for an update published without source maps', () => {
const [info] = getUpdateWithFileUrlsJsonInfosForUpdates([
makeUpdate({ assetMapUrl: 'https://storage.example/assetmap.json?signature' }),
]);
expect(info.assetMapUrl).not.toBeNull();
expect(info.sourceMapUrl).toBeNull();
});
});
describe(getPlatformsForGroup.name, () => {
it.each([
{ group: 'abc', updates: [] },
+20
View File
@@ -14,6 +14,7 @@ import {
UpdateBranchFragment,
UpdateFragment,
UpdatePublishMutation,
UpdateWithFileUrlsFragment,
User,
} from '../graphql/generated';
import { AssetQuery } from '../graphql/queries/AssetQuery';
@@ -39,6 +40,9 @@ export type UpdateJsonInfo = { branch: string; runtimeVersion: string } & Pick<
'id' | 'createdAt' | 'group' | 'message' | 'platform' | 'manifestPermalink' | 'gitCommitHash'
>;
export type UpdateWithFileUrlsJsonInfo = UpdateJsonInfo &
Pick<UpdateWithFileUrlsFragment, 'assetMapUrl' | 'sourceMapUrl'>;
export type UpdateGroupDescription = FormatUpdateParameter & {
branch: string;
group: string;
@@ -224,6 +228,22 @@ export function getUpdateJsonInfosForUpdates(updates: UpdateFragment[]): UpdateJ
}));
}
/**
* Same as getUpdateJsonInfosForUpdates, with the stored file URLs. Only `eas update:view` queries
* those, because the server signs each one per request.
*/
export function getUpdateWithFileUrlsJsonInfosForUpdates(
updates: UpdateWithFileUrlsFragment[]
): UpdateWithFileUrlsJsonInfo[] {
return getUpdateJsonInfosForUpdates(updates).map((info, index) => ({
...info,
// Null unless the update was published with an asset map. The URL is signed and expires.
assetMapUrl: updates[index].assetMapUrl ?? null,
// Null unless published with `--upload-source-maps`. The URL is signed and expires.
sourceMapUrl: updates[index].sourceMapUrl ?? null,
}));
}
export function getUpdateGroupDescriptions(
updateGroups: UpdateFragment[][]
): FormattedUpdateGroupDescription[] {
@@ -5,6 +5,7 @@ import nullthrows from 'nullthrows';
import { fetchSessionSecretAndUserAsync } from './fetchSessionSecretAndUser';
import { fetchSessionSecretAndUserFromBrowserAuthFlowAsync } from './fetchSessionSecretAndUserFromBrowserAuthFlow';
import { fetchUserAsync } from './fetchUser';
import { ApiV2Error } from '../ApiV2Error';
import { AnalyticsWithOrchestration } from '../analytics/AnalyticsManager';
import { ApiV2Client } from '../api';
@@ -211,6 +212,18 @@ export default class SessionManager {
});
}
public async loginWithDeviceSessionAsync(sessionSecret: string): Promise<string> {
const { id, username } = await fetchUserAsync({ sessionSecret });
await this.setSessionAsync({
sessionSecret,
userId: id,
username,
currentConnection: 'Browser-Flow-Authentication',
});
this.currentActor = undefined;
return username;
}
private async loginAsync(input: {
username: string;
password: string;
@@ -0,0 +1,209 @@
import fs from 'fs-extra';
import MockDate from 'mockdate';
import nock from 'nock';
import os from 'os';
import path from 'path';
import { getExpoApiBaseUrl } from '../../api';
import { getStateJsonPath } from '../../utils/paths';
import SessionManager from '../SessionManager';
import { resumeDeviceLoginAsync, startDeviceLoginAsync } from '../deviceLogin';
import { fetchUserAsync } from '../fetchUser';
jest.mock('../../utils/paths');
jest.mock('../fetchUser');
const apiUrl = getExpoApiBaseUrl();
const now = Date.parse('2026-09-29T12:00:00Z');
let directory: string;
function manager(): SessionManager {
return new SessionManager({ logEvent: jest.fn(), setActor: jest.fn(), flushAsync: jest.fn() });
}
async function startAsync(): Promise<string> {
nock(apiUrl)
.post('/v2/auth/device_authorization', body => body.client_id === 'eas-cli')
.reply(200, {
data: {
device_code: 'PRIVATE_DEVICE_CODE',
user_code: 'BCDF-GHJK',
verification_uri: 'https://expo.dev/oauth/device',
expires_in: 600,
interval: 5,
},
});
const result = await startDeviceLoginAsync();
expect(JSON.stringify(result)).not.toContain('PRIVATE_DEVICE_CODE');
expect(result.verification_uri_complete).toBe(
'https://expo.dev/oauth/device?user_code=BCDF-GHJK'
);
return result.request_id;
}
function token(data: object, match?: string): nock.Scope {
return nock(apiUrl)
.post('/v2/auth/token', {
client_id: 'eas-cli',
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: 'PRIVATE_DEVICE_CODE',
...(match ? { match_value: match } : {}),
})
.reply(200, { data });
}
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-device-login-test-'));
jest.mocked(getStateJsonPath).mockReturnValue(path.join(directory, 'state.json'));
jest.mocked(fetchUserAsync).mockResolvedValue({ id: 'user-id', username: 'demo-user' });
MockDate.set(now);
nock.disableNetConnect();
});
afterEach(async () => {
const pending = nock.pendingMocks();
nock.cleanAll();
nock.enableNetConnect();
MockDate.reset();
jest.restoreAllMocks();
jest.clearAllMocks();
await fs.remove(directory);
expect(pending).toEqual([]);
});
it('resumes across fresh sessions, keeps secrets private, and installs a normal Expo session', async () => {
await fs.writeJson(getStateJsonPath(), { unrelatedSetting: true });
const id = await startAsync();
const requestPath = path.join(directory, 'device-login', `${id}.json`);
expect((await fs.stat(requestPath)).mode & 0o777).toBe(0o600);
expect((await fs.stat(path.dirname(requestPath))).mode & 0o777).toBe(0o700);
MockDate.set(now + 5000);
token({ error: 'authorization_pending' });
expect((await resumeDeviceLoginAsync(id, manager())).status).toBe('authorization_pending');
expect((await fs.stat(requestPath)).mode & 0o777).toBe(0o600);
MockDate.set(now + 10000);
token({ error: 'matching_required', match_options: ['12', '42', '87'] });
expect(await resumeDeviceLoginAsync(id, manager())).toMatchObject({
status: 'matching_required',
match_options: ['12', '42', '87'],
});
MockDate.set(now + 15000);
token({ session_secret: 'PRIVATE_SESSION', expires_at: '2027-01-01T00:00:00Z' }, '42');
expect(await resumeDeviceLoginAsync(id, manager(), '42')).toEqual({
request_id: id,
status: 'authenticated',
username: 'demo-user',
});
expect(await fs.readJson(getStateJsonPath())).toEqual({
unrelatedSetting: true,
auth: {
sessionSecret: 'PRIVATE_SESSION',
userId: 'user-id',
username: 'demo-user',
currentConnection: 'Browser-Flow-Authentication',
},
});
expect(await fs.readdir(path.dirname(requestPath))).toEqual([]);
});
it('accepts a direct session secret without a matching response', async () => {
const id = await startAsync();
MockDate.set(now + 5000);
token({ session_secret: 'PRIVATE_SESSION', expires_at: '2027-01-01T00:00:00Z' });
expect(await resumeDeviceLoginAsync(id, manager())).toEqual({
request_id: id,
status: 'authenticated',
username: 'demo-user',
});
expect(await fs.readJson(getStateJsonPath())).toMatchObject({
auth: { sessionSecret: 'PRIVATE_SESSION' },
});
expect(await fs.readdir(path.join(directory, 'device-login'))).toEqual([]);
});
it('keeps the poll interval across invocations, including slow_down', async () => {
const id = await startAsync();
expect(await resumeDeviceLoginAsync(id, manager())).toMatchObject({ retry_after: 5 });
MockDate.set(now + 5000);
token({ error: 'slow_down' });
expect(await resumeDeviceLoginAsync(id, manager())).toMatchObject({
status: 'slow_down',
retry_after: 10,
});
MockDate.set(now + 6000);
expect(await resumeDeviceLoginAsync(id, manager())).toMatchObject({ retry_after: 9 });
});
it.each([
{ header: '30', delay: 30 },
{ header: new Date(now + 35000).toUTCString(), delay: 30 },
{ header: undefined, delay: 60 },
{ header: 'invalid', delay: 60 },
])('honors HTTP 429 Retry-After ($header) across invocations', async ({ header, delay }) => {
const id = await startAsync();
MockDate.set(now + 5000);
nock(apiUrl)
.post('/v2/auth/token')
.reply(429, {}, header ? { 'Retry-After': header } : {});
expect(await resumeDeviceLoginAsync(id, manager())).toMatchObject({
status: 'slow_down',
retry_after: delay,
});
expect(await resumeDeviceLoginAsync(id, manager())).toMatchObject({ retry_after: delay });
});
it('expires without a network call and removes the private request', async () => {
const id = await startAsync();
MockDate.set(now + 600000);
expect((await resumeDeviceLoginAsync(id, manager())).status).toBe('expired_token');
await expect(resumeDeviceLoginAsync(id, manager())).rejects.toThrow('not found');
});
it.each(['access_denied', 'expired_token', 'invalid_grant'])(
'stops on %s without retrying or choosing another number',
async error => {
const id = await startAsync();
MockDate.set(now + 5000);
token({ error }, '12');
expect((await resumeDeviceLoginAsync(id, manager(), '12')).status).toBe(error);
await expect(resumeDeviceLoginAsync(id, manager(), '42')).rejects.toThrow('not found');
expect(fetchUserAsync).not.toHaveBeenCalled();
}
);
it('recovers after token exchange succeeds but fetching the user fails', async () => {
const id = await startAsync();
MockDate.set(now + 5000);
token({ session_secret: 'PRIVATE_SESSION', expires_at: '2027-01-01T00:00:00Z' }, '42');
jest.mocked(fetchUserAsync).mockRejectedValueOnce(new Error('offline'));
await expect(resumeDeviceLoginAsync(id, manager(), '42')).rejects.toThrow('offline');
const requestPath = path.join(directory, 'device-login', `${id}.json`);
expect((await fs.stat(requestPath)).mode & 0o777).toBe(0o600);
expect((await fs.readJson(requestPath)).sessionSecret).toBe('PRIVATE_SESSION');
// No second token exchange; the one-use grant was already consumed.
expect((await resumeDeviceLoginAsync(id, manager())).status).toBe('authenticated');
});
it('rejects another API environment, invalid request IDs, and invalid number input', async () => {
const id = await startAsync();
const requestPath = path.join(directory, 'device-login', `${id}.json`);
const state = await fs.readJson(requestPath);
await fs.writeJson(requestPath, { ...state, apiUrl: 'https://another.example.com' });
await expect(resumeDeviceLoginAsync(id, manager())).rejects.toThrow('same Expo API environment');
await expect(resumeDeviceLoginAsync('../state', manager())).rejects.toThrow(
'Invalid device login request ID'
);
await expect(resumeDeviceLoginAsync(id, manager(), 'guess')).rejects.toThrow(
'number the user sees'
);
});
it('preserves a pending request after a network failure', async () => {
const id = await startAsync();
MockDate.set(now + 5000);
nock(apiUrl).post('/v2/auth/token').replyWithError('offline');
await expect(resumeDeviceLoginAsync(id, manager())).rejects.toThrow('offline');
MockDate.set(now + 10000);
token({ error: 'matching_required', match_options: ['12', '42', '87'] });
expect((await resumeDeviceLoginAsync(id, manager())).status).toBe('matching_required');
});
+216
View File
@@ -0,0 +1,216 @@
import JsonFile from '@expo/json-file';
import { randomUUID } from 'crypto';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { z } from 'zod';
import SessionManager from './SessionManager';
import { getExpoApiBaseUrl } from '../api';
import fetch, { RequestError } from '../fetch';
import { getStateJsonPath } from '../utils/paths';
const grantSchema = z.object({
device_code: z.string().min(1),
user_code: z.string().min(1),
verification_uri: z.url(),
expires_in: z.number().positive(),
interval: z.number().positive(),
});
const terminalErrorSchema = z.enum(['access_denied', 'expired_token', 'invalid_grant']);
const tokenSchema = z.union([
z.object({ session_secret: z.string().min(1), expires_at: z.iso.datetime() }),
z.object({ error: z.literal('matching_required'), match_options: z.array(z.string()).min(1) }),
z.object({ error: z.enum(['authorization_pending', 'slow_down']).or(terminalErrorSchema) }),
]);
const stateSchema = z.object({
apiUrl: z.string(),
deviceCode: z.string(),
userCode: z.string(),
verificationUri: z.string(),
expiresAt: z.number(),
interval: z.number(),
nextPollAt: z.number(),
sessionSecret: z.string().optional(),
});
type DeviceLoginState = z.infer<typeof stateSchema>;
type PendingDeviceLogin = {
request_id: string;
verification_uri: string;
verification_uri_complete: string;
user_code: string;
expires_at: string;
retry_after: number;
};
export type DeviceLoginResult =
| { request_id: string; status: 'authenticated'; username: string }
| { request_id: string; status: z.infer<typeof terminalErrorSchema> }
| (PendingDeviceLogin &
(
| { status: 'authorization_pending' | 'slow_down' }
| { status: 'matching_required'; match_options: string[] }
));
export function isDeviceLoginFailure(result: {
status: string;
}): result is { status: z.infer<typeof terminalErrorSchema> } {
return terminalErrorSchema.safeParse(result.status).success;
}
function getRequestPath(requestId: string): string {
if (!z.uuid().safeParse(requestId).success) {
throw new Error('Invalid device login request ID. Start again with eas login --device.');
}
return path.join(path.dirname(getStateJsonPath()), 'device-login', `${requestId}.json`);
}
async function postAsync(endpoint: string, body: object): Promise<unknown> {
const response = await fetch(`${getExpoApiBaseUrl()}/v2/auth/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: 'eas-cli', ...body }),
timeout: 30_000,
});
return (await response.json()).data;
}
function publicResult(
requestId: string,
state: DeviceLoginState
): PendingDeviceLogin & { status: 'authorization_pending' } {
const verificationUrl = new URL(state.verificationUri);
verificationUrl.searchParams.set('user_code', state.userCode);
return {
request_id: requestId,
status: 'authorization_pending',
verification_uri: state.verificationUri,
verification_uri_complete: verificationUrl.toString(),
user_code: state.userCode,
expires_at: new Date(state.expiresAt).toISOString(),
retry_after: Math.max(0, Math.ceil((state.nextPollAt - Date.now()) / 1000)),
};
}
export async function startDeviceLoginAsync(): Promise<
PendingDeviceLogin & { status: 'authorization_pending' }
> {
const result = grantSchema.safeParse(
await postAsync('device_authorization', {
device_name: os.hostname().slice(0, 255),
device_platform: os.platform(),
device_os_version: os.release().slice(0, 64),
})
);
if (!result.success) {
throw new Error('Unexpected device authorization response. Try again or update EAS CLI.');
}
const grant = result.data;
const requestId = randomUUID();
const requestPath = getRequestPath(requestId);
await fs.mkdir(path.dirname(requestPath), { recursive: true, mode: 0o700 });
const state: DeviceLoginState = {
apiUrl: getExpoApiBaseUrl(),
deviceCode: grant.device_code,
userCode: grant.user_code,
verificationUri: grant.verification_uri,
expiresAt: Date.now() + grant.expires_in * 1000,
interval: Math.max(5, grant.interval),
nextPollAt: Date.now() + Math.max(5, grant.interval) * 1000,
};
// JsonFile preserves this mode when atomically replacing the file on subsequent writes.
await fs.writeFile(requestPath, '{}', { mode: 0o600, flag: 'wx' });
await JsonFile.writeAsync(requestPath, state);
return publicResult(requestId, state);
}
// Resume one request at a time, as with other CLI session updates. State survives between
// invocations; concurrent commands for the same request are not serialized.
export async function resumeDeviceLoginAsync(
requestId: string,
sessionManager: SessionManager,
match?: string
): Promise<DeviceLoginResult> {
if (match !== undefined && !/^\d{1,8}$/.test(match)) {
throw new Error('--match must be the number the user sees in their browser.');
}
const requestPath = getRequestPath(requestId);
if (!(await fs.pathExists(requestPath))) {
throw new Error('Device login request not found. Start again with eas login --device.');
}
const parsed = stateSchema.safeParse(await JsonFile.readAsync(requestPath));
if (!parsed.success) {
throw new Error('Invalid saved device login. Start again with eas login --device.');
}
const state = parsed.data;
if (state.apiUrl !== getExpoApiBaseUrl()) {
throw new Error('Resume device login in the same Expo API environment where it was started.');
}
if (state.expiresAt <= Date.now()) {
await fs.remove(requestPath);
return { request_id: requestId, status: 'expired_token' };
}
if (!state.sessionSecret) {
if (state.nextPollAt > Date.now()) {
return publicResult(requestId, state);
}
// Persist the deadline before polling so errors and process restarts respect the interval.
state.nextPollAt = Date.now() + state.interval * 1000;
await JsonFile.writeAsync(requestPath, state);
let data: unknown;
let retryAfter = 0;
try {
data = await postAsync('token', {
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: state.deviceCode,
...(match !== undefined ? { match_value: match } : {}),
});
} catch (error) {
if (!(error instanceof RequestError) || error.response.status !== 429) {
throw error;
}
const header = error.response.headers.get('retry-after');
const seconds = header ? Number(header) : NaN;
const delay = Number.isFinite(seconds)
? seconds
: (Date.parse(header ?? '') - Date.now()) / 1000;
retryAfter = Number.isFinite(delay) ? delay : 60;
data = { error: 'slow_down' };
}
const result = tokenSchema.safeParse(data);
if (!result.success) {
throw new Error('Unexpected device token response. Resume this request to retry.');
}
if ('error' in result.data) {
const response = result.data;
const failure = { request_id: requestId, status: response.error };
if (isDeviceLoginFailure(failure)) {
await fs.remove(requestPath);
return failure;
}
switch (response.error) {
case 'slow_down':
state.interval += 5;
state.nextPollAt = Date.now() + Math.max(state.interval, retryAfter) * 1000;
await JsonFile.writeAsync(requestPath, state);
return { ...publicResult(requestId, state), status: 'slow_down' };
case 'matching_required':
return {
...publicResult(requestId, state),
status: response.error,
match_options: response.match_options,
};
}
return publicResult(requestId, state);
}
// The grant is single-use. Persist its secret before fetching the user/installing the
// session, so a later invocation can finish if those operations fail.
state.sessionSecret = result.data.session_secret;
state.expiresAt = Date.parse(result.data.expires_at);
await JsonFile.writeAsync(requestPath, state);
}
const username = await sessionManager.loginWithDeviceSessionAsync(state.sessionSecret);
await fs.remove(requestPath);
return { request_id: requestId, status: 'authenticated', username };
}
@@ -0,0 +1,86 @@
import os from 'os';
import path from 'path';
import fs from 'fs-extra';
import { create } from 'tar';
import { extractAppFromLocalArchiveAsync } from '../download';
import * as Paths from '../paths';
import { AppPlatform } from '../../graphql/generated';
import { promptAsync } from '../../prompts';
jest.mock('../../prompts');
const mockPromptAsync = jest.mocked(promptAsync);
describe(extractAppFromLocalArchiveAsync, () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-download-test-'));
jest.spyOn(Paths, 'getTmpDirectory').mockReturnValue(tmpDir);
mockPromptAsync.mockReset();
});
afterEach(async () => {
jest.restoreAllMocks();
await fs.remove(tmpDir);
});
async function makeArchiveAsync(appPaths: string[]): Promise<string> {
const contentsDir = path.join(tmpDir, 'contents');
await fs.ensureDir(contentsDir);
for (const appPath of appPaths) {
await fs.ensureDir(path.join(contentsDir, appPath));
}
const archivePath = path.join(tmpDir, 'apps.tar.gz');
await create({ file: archivePath, cwd: contentsDir, gzip: true }, ['.']);
return archivePath;
}
it('selects the containing app without prompting for its nested App Clip', async () => {
const archivePath = await makeArchiveAsync([
'Bluesky.app',
'Bluesky.app/AppClips/BlueskyClip.app',
]);
const appPath = await extractAppFromLocalArchiveAsync(archivePath, AppPlatform.Ios);
expect(path.basename(appPath)).toBe('Bluesky.app');
expect(await fs.pathExists(path.join(appPath, 'AppClips/BlueskyClip.app'))).toBe(true);
expect(mockPromptAsync).not.toHaveBeenCalled();
});
it('prompts for separate apps but excludes nested App Clips from the choices', async () => {
const archivePath = await makeArchiveAsync([
'Bluesky.app',
'Bluesky.app/AppClips/BlueskyClip.app',
'Bluesky.app.backup.app',
]);
mockPromptAsync.mockResolvedValueOnce({ selectedFile: './Bluesky.app.backup.app' });
const appPath = await extractAppFromLocalArchiveAsync(archivePath, AppPlatform.Ios);
expect(path.basename(appPath)).toBe('Bluesky.app.backup.app');
const [question] = mockPromptAsync.mock.calls[0];
if (Array.isArray(question)) {
throw new Error('Expected a single app selection prompt');
}
expect(question.choices).toHaveLength(2);
expect(question.choices).toEqual(
expect.arrayContaining([
expect.objectContaining({ title: expect.stringMatching(/Bluesky\.app$/) }),
expect.objectContaining({ title: expect.stringMatching(/Bluesky\.app\.backup\.app$/) }),
])
);
});
it('throws when the archive contains no apps', async () => {
const archivePath = await makeArchiveAsync([]);
await expect(extractAppFromLocalArchiveAsync(archivePath, AppPlatform.Ios)).rejects.toThrow(
'Did not find any installable apps inside tarball.'
);
expect(mockPromptAsync).not.toHaveBeenCalled();
});
});
+8 -3
View File
@@ -172,8 +172,13 @@ async function getAppPathAsync(outputDir: string, applicationExtension: string):
throw Error('Did not find any installable apps inside tarball.');
}
if (appFilePaths.length === 1) {
return path.join(outputDir, appFilePaths[0]);
// App Clips are nested inside their containing iOS app and cannot be run independently.
const installableAppPaths = appFilePaths.filter(
filePath => !appFilePaths.some(parentPath => filePath.startsWith(`${parentPath}/`))
);
if (installableAppPaths.length === 1) {
return path.join(outputDir, installableAppPaths[0]);
}
Log.newLine();
@@ -184,7 +189,7 @@ async function getAppPathAsync(outputDir: string, applicationExtension: string):
message: 'Select the app to run:',
name: 'selectedFile',
choices: [
...appFilePaths.map(filePath => ({
...installableAppPaths.map(filePath => ({
title: filePath,
value: filePath,
})),
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "24.9.0",
"version": "24.12.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,7 +26,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "24.9.0",
"@expo/build-tools": "24.12.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "24.9.0",
"@expo/logger": "24.8.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "24.9.0",
"version": "24.12.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,7 +19,7 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "24.9.0",
"@expo/build-tools": "24.12.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "24.9.0",
+3 -21
View File
@@ -23,21 +23,16 @@ fi
echo "Building $OUTPUT_FILE"
tmp_dir=""
record_sim_build_dir=""
cleanup() {
if [[ -n "$tmp_dir" ]]; then
rm -rf "$tmp_dir"
fi
if [[ -n "$record_sim_build_dir" ]]; then
rm -rf "$record_sim_build_dir"
fi
}
trap cleanup EXIT
tmp_dir=$(mktemp -d)
target_root_dir="$tmp_dir"
target_worker_dir="$tmp_dir/packages/worker"
record_sim_build_dir=$(mktemp -d)
mkdir -p "$target_worker_dir"
@@ -98,29 +93,16 @@ rm -rf tsconfig.json tsconfig.build.json
popd >/dev/null 2>&1
if [[ "$PLATFORM" != "ios" ]]; then
rm -f "$target_root_dir/packages/build-tools/bin/record-sim"
rm -f "$target_root_dir/packages/build-tools/bin/egress-guard.dylib"
fi
if [[ "$PLATFORM" == "ios" ]]; then
record_sim_package_dir="$ROOT_DIR/packages/build-tools/resources/record-sim"
record_sim_bin_dir="$target_root_dir/packages/build-tools/bin"
mkdir -p "$record_sim_bin_dir"
record_sim_bin_path=$(swift build \
-c release \
--package-path "$record_sim_package_dir" \
--build-path "$record_sim_build_dir" \
--show-bin-path)
swift build \
-c release \
--package-path "$record_sim_package_dir" \
--build-path "$record_sim_build_dir"
cp "$record_sim_bin_path/record-sim" "$record_sim_bin_dir/record-sim"
chmod +x "$record_sim_bin_dir/record-sim"
build_tools_bin_dir="$target_root_dir/packages/build-tools/bin"
mkdir -p "$build_tools_bin_dir"
# The local egress guard, injected into simulator processes; see
# packages/build-tools/resources/egress-guard/README.md.
"$ROOT_DIR/packages/build-tools/resources/egress-guard/build.sh" "$record_sim_bin_dir"
"$ROOT_DIR/packages/build-tools/resources/egress-guard/build.sh" "$build_tools_bin_dir"
# build plugin
pushd "$ROOT_DIR/packages/expo-cocoapods-proxy" >/dev/null 2>&1
Generated
+3 -3
View File
@@ -2303,7 +2303,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:24.9.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:24.12.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
@@ -2984,7 +2984,7 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:24.9.0"
"@expo/build-tools": "npm:24.12.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/eas-build-job": "npm:24.9.0"
@@ -10418,7 +10418,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:24.9.0"
"@expo/build-tools": "npm:24.12.0"
"@expo/bunyan": "npm:^4.0.1"
"@expo/eas-build-job": "npm:24.9.0"
"@expo/logger": "npm:24.8.0"