Compare commits

..
50 Commits
Author SHA1 Message Date
Expo CI 97553334d4 v24.2.0 2026-09-11 10:52:46 +00:00
Stanisław Chmiela 31d12b9888 [eas-build-job] Remove repository URLs from Git job inputs (#4360)
* [eas-build-job] Allow jobs to omit the Git repository URL

* [build-tools] Separate resolved archive sources from job inputs

* [eas-build-job] Remove repository URLs from Git job inputs

* [eas-build-job] Remove the legacy repository URL schema field

* [eas-build-job] Move changelog entry to unreleased section

* [eas-build-job] Correct unreleased changelog placement
2026-09-11 12:19:58 +02:00
Ash Wu dc2658f63b [eas-cli] add eas workflow:insights:maestro command (#4370)
Signed-off-by: Ash Wu <[email protected]>
2026-09-11 17:57:30 +08:00
Ash Wu b8b9de78fc [eas-cli] add eas workflow:insights command (#4367)
* [eas-cli] add eas workflow:insights command

Signed-off-by: Ash Wu <[email protected]>

* [eas-cli] address review feedback on workflow:insights

- Show workflow file names in the workflows table, which is the value
  --workflow accepts, and add fileName to the JSON output
- Omit buckets with no runs from the runs-over-time table; the JSON output
  keeps every bucket
- Replace the per-command --project-id context resolvers with a protected
  EasCommand helper shared with the observe commands

Signed-off-by: Ash Wu <[email protected]>

* [eas-cli] send a bucket-aligned time range from workflow:insights

The server widens only the runs-over-time query to whole buckets and leaves the
overview and per-workflow queries on the exact bounds, so an unaligned window
made those sections count different runs and the bucket totals could exceed the
overview's total. Widen the window client-side instead, the way the dashboard
does, so the server's own widening is a no-op and every section covers the same
period.

Widening lengthens the window, which can push it past the threshold of the
bucket size it was widened by, so the bucket size is chosen by checking the
widened window rather than the requested one. Without that, --days 4 would ask
for hourly buckets over a 97-hour window and the server would reject it.

- Reject a range that does not run forwards in resolveTimeRange, next to the
  existing flag checks; widening would otherwise turn a backwards range into a
  valid one-bucket query
- Enable JSON output before resolving the time range and the project context,
  both of which can write prose to stdout
- Show a metric's change in parentheses, matching how the observe formatters
  pair a statistic with its secondary figure

Signed-off-by: Ash Wu <[email protected]>

---------

Signed-off-by: Ash Wu <[email protected]>
2026-09-11 17:57:30 +08:00
Stanisław Chmiela 8ff599b1f2 [build-tools] Require PATH sources for local builds (#4362)
* [build-tools] Require PATH sources for local builds

* [build-tools] Document local source validation

* [build-tools] Report invalid local sources as user errors

* [build-tools] Track invalid local sources as system errors
2026-09-11 11:03:45 +02:00
Expo CI e6f02af973 bump STAGING and PRODUCTION to 24.1.2 in cli-versions.json 2026-09-11 00:59:22 +00:00
Expo CI a163b5a76d update CHANGELOG.md for v24.1.2 2026-09-11 00:57:25 +00:00
Expo CI cc37c1c360 v24.1.2 2026-09-11 00:55:13 +00:00
Brent Vatne e78771e0f4 [eas-cli] drop the duplicate non-interactive egress hint and the disconnect warning on stop (#4384) 2026-09-10 17:52:34 -07:00
Brent Vatne 288bc44dfc [eas-cli] shorten the local egress banner and drop the run-it-yourself instruction in interactive mode (#4383) 2026-09-10 17:42:22 -07:00
Expo CI d3db3926e3 bump STAGING and PRODUCTION to 24.1.1 in cli-versions.json 2026-09-11 00:15:23 +00:00
Expo CI 5b87ba0adb update CHANGELOG.md for v24.1.1 2026-09-11 00:13:05 +00:00
Expo CI 0b2cea7bb7 v24.1.1 2026-09-11 00:10:40 +00:00
Brent Vatne eab4fc3cd5 [eas-cli] describe the inline egress client instead of asking to start one (#4382) 2026-09-10 17:07:16 -07:00
Brent Vatne e2e8cd245c [eas-cli] improve EasCommand test reliability (#4381) 2026-09-10 17:05:16 -07:00
Expo CI 80d46f0783 bump STAGING and PRODUCTION to 24.1.0 in cli-versions.json 2026-09-10 23:27:17 +00:00
Expo CI 3b6b63a273 update CHANGELOG.md for v24.1.0 2026-09-10 23:24:59 +00:00
Expo CI 8373ae353c v24.1.0 2026-09-10 23:22:26 +00:00
Brent Vatne 994794dbad [eas-cli] add --egress-allow to reach local dev servers through local egress (#4369)
* [eas-cli] add --egress-allow so local egress sessions can reach dev servers on this machine

Adds repeatable --egress-allow host:port, requiring --egress local. Entries
are normalized and deduplicated; wildcards, ranges, and malformed values are
rejected before session creation. Exceptions match the exact host and port;
unlisted destinations keep the default policy. Exceptions persist in the
session configuration and are shown when starting or reconnecting, across all
iOS controllers.

Squashes the earlier --egress-allow commits and base-branch sync merges so the
branch sits directly on the rebased base.

* [eas-cli] forward loopback --egress-allow ports from the simulator host like adb reverse

iOS never sends 127.0.0.1 requests to the system proxy and Expo CLI rewrites
localhost to 127.0.0.1 in manifests, so --egress-allow localhost:<port> alone
could not serve a dev server to Expo Go. For each localhost or 127.0.0.1 entry
the egress client now opens a chisel reverse remote so 127.0.0.1:<port> on the
device host reaches the same port on this machine. Privileged ports and the
proxy port stay name-only. The start output explains the forwarding, and
rejected remotes are surfaced instead of looping silently.
2026-09-10 14:12:01 -07:00
Brent Vatne d103da3bc5 [eas-cli][build-tools] local egress for iOS simulator sessions (--egress local) (#4364)
* [build-tools] add start_local_egress step for local egress simulator sessions

* [eas-cli] add --egress local to simulator:start and add simulator:egress

* [eas-cli] update CHANGELOG

* [build-tools] drop the pf UDP block from local egress and report direct simulator connections

* [eas-cli] harden the local egress proxy and narrow the contract wording

* [build-tools][eas-cli] report the egress token separately from the fixed username

* [build-tools][eas-cli] harden local egress startup and proxy resource limits

* [build-tools][eas-cli] support local egress across iOS simulator controllers

* [build-tools] allow local egress reverse remotes on unprivileged loopback ports

iOS never sends loopback-literal requests to the system proxy, and Expo CLI
rewrites localhost to 127.0.0.1 in manifests, so a dev server allowed as
localhost:<port> could not serve bundles through the proxy. Let the EAS CLI
open one chisel reverse remote per loopback allow entry (like adb reverse)
by permitting reverse remotes on any unprivileged loopback port, not only
the proxy port. Other interfaces and privileged ports stay denied.

* [eas-cli] stop logging completed proxied responses as failed in the local egress client

Releasing a proxy operation aborts its shared signal after the response has
been sent, which errors the already-completed upstream request and logged
"failed: The operation was aborted" for every successful plain-HTTP request in
debug output. Track completion (response end or upgrade handoff) and treat the
late abort as cleanup. Truncated upstream responses still log as failed.
2026-09-10 14:12:01 -07:00
Stanisław Chmiela ee3c62683b [build-tools] Require project source refresh for remote builds (#4359)
# Why

I want to start skipping authenticated repository URL in the job payload. We never expect to use it — we'd rather use the just-loaded one from www.

# How

Change fallback to a `SystemError`.

# Test Plan

CI should pass.
2026-09-10 22:40:41 +02:00
Gabe Debes 8ac9d5f101 [build-tools] gate serve-sim with the session token (#4306)
* [eas-cli] gate device-run-session serve-sim with the session token

* [eas-cli] describe the state the missing-token check actually catches

* [eas-cli] match the missing-token test to the message it asserts
2026-09-09 17:40:01 -07:00
Expo CI 1eaa799b9f bump STAGING and PRODUCTION to 24.0.0 in cli-versions.json 2026-09-09 20:13:34 +00:00
Expo CI bb0f2dfb21 update CHANGELOG.md for v24.0.0 2026-09-09 20:11:16 +00:00
Expo CI 5409c03d58 v24.0.0 2026-09-09 20:07:38 +00:00
Gabe Debes a941930ea7 [eas-cli] adds serve-sim session token in simulator preview links (#4312)
* [eas-cli] carry the serve-sim session token in simulator preview links

* [eas-cli] sanitize the preview config in json output
2026-09-09 12:52:08 -07:00
Szymon Świerk 74572101ac [eas-cli] Make ASC API key issuer identifier optional in types (#4248)
* [eas-cli] Make ASC API key issuer identifier optional in types

* [eas-cli] Bump @expo/apple-utils to 2.2.0

* [eas-cli] Pass optional issuer identifier through to TestFlight app lookup
2026-09-09 21:22:32 +02:00
Stanisław Chmiela 7b050cae3f [worker] Add MCP server URL to build context (#4335)
# Why

Worker has config for API server URL. I now need MCP server URL too.

# How

Added `mcpServerUrl` in `config` and in contexts.

# Test Plan

CI should pass.
2026-09-09 16:56:49 +02:00
Douglas LowderandClaude Opus 4.8 d193860668 [eas-cli] Add observe:event command to fetch a single event by ID (#4252)
* [eas-cli] Add observe:event command to fetch a single event by ID

Add `eas observe:event <id>` to display one Observe event (either metric or userEvent) by its ID. Also, ensure event IDs are propagated to the results of `eas observe:session <sessionId>` so that they appear in JSON output.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-09 06:07:57 -07:00
Krystof WoldrichandCodex 1da0be8529 [build-tools] Use bun x to start Argent remote sessions (#4366)
Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-08 15:22:47 +02:00
Douglas LowderandClaude Opus 4.8 08b4346620 [eas-cli] Add observe:errors command (#4339)
* [eas-cli] Add observe:errors command

Add `eas observe:errors` to display error and exception issue groups grouped by
fingerprint, using the new observe.errors.groups GraphQL namespace. Exceptions
are excluded from observe:events (userEvents.list) and surfaced here instead.
Supports --platform, --severity (fatal|error), time-range, --app-version,
--build-number, --update-id, and --environment filters, with table and --json
output.

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

* [eas-cli] Add --fingerprint drill-down to observe:errors

Add `eas observe:errors --fingerprint <fingerprint>` to list an error group's
individual occurrences with stack traces, instead of only the grouped summary.
Occurrences are fetched via observe.errors.occurrences (newest first) with
--limit and --after paging, and rendered as per-occurrence detail (fields,
stack trace, body, properties) or as --json. The grouped table now shows a
Fingerprint column and a hint pointing to the flag.

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

* Respond to review comments

* Regenerate GraphQL from server

* Fix errors from nullability change in issuerIdentifier

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-09-07 20:29:56 -07:00
4d6d9b1fc4 [build-tools] Allow configuring Android emulator display (#4349)
Co-authored-by: Krystof Woldrich <[email protected]>
Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-07 15:39:56 +02:00
Stanisław Chmiela 59f9add53c [build-tools] Add custom log phase helper (#4334)
# Why

I want a single EAS function to appear as running as two separate phases.

# How

Added `withLogPhaseAsync` which is going to create a logger child with necessary fields overwritten so a new log phase appears.

# Test Plan

CI should pass. Verified locally.

<img width="1343" height="712" alt="Zrzut ekranu 2026-09-4 o 12 17 25" src="https://github.com/user-attachments/assets/f4733c83-3119-42d6-b7dc-fc46aedaf434" />
2026-09-07 15:11:57 +02:00
1946562247 [build-tools] Update Appium command list (#4356)
Co-authored-by: Expo Bot <[email protected]>
Co-authored-by: Stanisław Chmiela <[email protected]>
2026-09-07 10:41:29 +00:00
SJ Kim 24a65ad80f [eas-cli] Support protected update channels (#4319)
* [eas-cli] Support protected update channels

* [eas-cli] Add protected channels changelog entry

* [eas-cli] Handle missing protected channels

* [eas-cli] Address channel protection review feedback (ENG-25975)
2026-09-04 14:03:53 -04:00
Hirbod 37ea60c20c [eas-cli] remove hidden AI chat command (#4348) 2026-09-04 17:03:29 +02:00
Douglas LowderandClaude Opus 4.8 99cbcae249 [eas-cli] Align Observe code with new server schema (#4338)
* [eas-cli] Align observe commands with the new Observe GraphQL schema

The Observe GraphQL schema was reorganized into scoped namespaces (universe
#30606). Migrate the observe queries onto the new fields — metrics.list,
userEvents.list/names, navigation.routes, session(id){metrics logs} — and adopt
the renamed fields in --json output (metricName->name, metricValue->value,
eventName->name). observe:events now lists user-defined events only.
observe:session timelines now include errors via session.logs. appVersions
(observe:versions, observe:metrics-summary) is unchanged. Regenerate GraphQL and
update tests.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 06:29:01 -07:00
Szymon Świerk a426042784 [build-tools] Support individual ASC API keys in upload_to_asc (#4247)
* [build-tools] Support individual ASC API keys in upload_to_asc

* [build-tools] In upoload_to_asc function, set jwt subject with a dedicated function instead of setting the claim manually
2026-09-03 16:34:50 +02:00
1cb06a6cb2 [build-tools] Install FFmpeg before Android device hub (#4332)
Co-authored-by: Krystof Woldrich <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-09-03 15:08:04 +02:00
Krystof WoldrichandCodex 5236871b0b fix(ci): changelog for PRs from forks (#4331)
Co-authored-by: Codex <[email protected]>
2026-09-03 12:49:30 +02:00
ceb20c9dd6 [build-tools] Use lower 980px Android preview resolution (#4326)
Co-authored-by: Krystof Woldrich <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-09-03 10:43:09 +02:00
Krystof WoldrichandCodex 83fa42c179 [eas-cli] Avoid GraphQL argument type collisions (#4328)
Co-authored-by: Codex <[email protected]>
2026-09-02 16:50:52 +02:00
Stanisław Chmiela 7727b9c347 [ci] Gate worker CLI versions on npm availability (#4313)
We currently:
- publish to NPM without awaiting the release being ready and public
- immediately change `cli-versions.json`
- this makes builds immediately try to use a new `eas-cli` version despite it NOT being public yet, which causes problems, see [Datadog](https://app.datadoghq.com/logs?query=%22No%20matching%20version%20found%20for%20eas-cli%22&agg_m=count&agg_m_source=base&agg_t=count&cols=host%2Cservice&fromUser=true&messageDisplay=inline&refresh_mode=paused&storage=hot&stream_sort=desc&viz=stream&from_ts=1788174847023&to_ts=1788178586558&live=false).

This pull request makes it so we:
- publish to NPM, make a GitHub release
- update changelog and push to main
- await NPM readiness for 10 minutes (last release took 6)
- only once it's ready, update `cli-versions.json`.
2026-09-02 09:02:12 +00:00
Stanisław Chmiela b015c0b226 [worker] Keep legacy cache failures non-fatal (#4295)
Add Sentry logging to legacy build cache.
2026-09-02 09:29:59 +02:00
Kadi Kraman 882687818f [eas-cli] add --build-number to observe:metrics and observe:events (#4278)
* Add --build-number to observe:metrics and observe:events

* Add changelog
2026-09-01 20:46:41 +01:00
Kadi Kraman 37f5163872 [eas-cli] add apple platform value to observe commands (#4276)
* Regenerate GraphQL schema

* Add apple platform target model

* Query observe data by platform target

* Wire --platform apple through the observe commands

* Update observe tests for platform targets

* Add changelog

* Format

* Regenerate gql

* Sync gql
2026-09-01 20:08:39 +01:00
Stanisław Chmiela df2c81d131 [eas-cli] Update simulator device GraphQL inputs (#4327) 2026-09-01 19:37:08 +01:00
Kadi Kraman 236398a823 [eas-cli] add environment filter to observe commands (#4275)
* Add shared --environment flag and thread it through the observe query layer

* Add --environment to observe:metrics-summary

* Add --environment to observe:metrics

* Add --environment to observe:routes

* Add --environment to observe:events

* Add --environment to observe:versions

* Add --environment to the observe:session picker

* Add changelog

* Add links to changelog
2026-09-01 13:49:33 +01:00
Jon Samp eebe02eef2 [eas-cli] Add project:status command (#4006)
* [eas-cli] Add project:status command

Adds `eas project:status` (alias `eas status`), a snapshot of the project
overview: recent production builds, development builds, workflow runs,
submissions, and updates. Mirrors the website project dashboard and
supports --json and --non-interactive for agent consumption.

* [eas-cli] Fix changelog PR link

* [eas-cli] Address project status review feedback

* [eas-cli] Improve project status for agents

* [eas-cli] Clarify project status pagination lookahead

* [eas-cli] Adapt project status to current GraphQL fields

* [eas-cli] Remove dead README link to simulator/start.ts

* [eas-cli] Normalize unknown workflow run statuses in project status
2026-08-31 22:43:59 -05:00
Expo CI 4b1e5a76e2 update CHANGELOG.md and bump STAGING and PRODUCTION in cli-versions.json 2026-08-31 20:28:03 +00:00
207 changed files with 26126 additions and 5978 deletions

No files matched your search

+3 -1
View File
@@ -31,7 +31,9 @@ jobs:
script: |
core.setFailed('Please add a changelog entry!')
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: always()
if: >-
always() &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
with:
message-id: changelog-entry-check
refresh-message-position: true
+41 -5
View File
@@ -53,6 +53,10 @@ jobs:
run: yarn install --immutable
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
- name: Configure git
run: |
git config user.email "[email protected]"
git config user.name "Expo CI"
- name: Update changelog
working-directory: ./scripts
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
@@ -60,20 +64,52 @@ jobs:
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit and push
- name: Commit and push changelog
run: |
git fetch
git checkout main
git add CHANGELOG.md
git commit --allow-empty -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
git push
- name: Verify eas-cli is available on npm
run: |
availability_deadline_epoch=$(( $(date +%s) + 600 ))
while true; do
published="$(npm view "eas-cli@$EAS_CLI_VERSION" version --prefer-online 2>/dev/null || true)"
if [ "$published" = "$EAS_CLI_VERSION" ]; then
echo "Verified eas-cli@$EAS_CLI_VERSION is available on npm."
exit 0
fi
current_epoch=$(date +%s)
if [ "$current_epoch" -ge "$availability_deadline_epoch" ]; then
break
fi
retry_delay=$((availability_deadline_epoch - current_epoch))
if [ "$retry_delay" -gt 10 ]; then
retry_delay=10
fi
echo "eas-cli@$EAS_CLI_VERSION is not available on npm yet. Retrying in ${retry_delay}s."
sleep "$retry_delay"
done
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after 10 minutes." >&2
exit 1
- name: Bump STAGING and PRODUCTION
run: |
git pull --rebase --autostash origin main
jq --arg version "$EAS_CLI_VERSION" '.STAGING = $version' \
cli-versions.json > cli-versions.json.tmp
mv cli-versions.json.tmp cli-versions.json
jq --arg version "$EAS_CLI_VERSION" '.PRODUCTION = $version' \
cli-versions.json > cli-versions.json.tmp
mv cli-versions.json.tmp cli-versions.json
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
git add cli-versions.json CHANGELOG.md
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING and PRODUCTION in cli-versions.json"
- name: Commit and push version update
run: |
git add cli-versions.json
git commit --allow-empty -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in cli-versions.json"
git push
- name: Prepare changelog for Slack
run: |
+72 -2
View File
@@ -8,14 +8,80 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-build-job] Remove repository URLs from Git job inputs and fetch them when resolving project sources. ([#4360](https://github.com/expo/eas-cli/pull/4360) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Add `eas workflow:insights` to display Workflows Insights for a time range: run counts and success rate compared with the previous period, runs over time, and a per-workflow breakdown, with `--json` output for scripts. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Add `eas workflow:insights:maestro` to display Maestro test insights for a time range: pass and flake rates, a sortable per-flow table, and a single flow's history with error patterns and recent runs, with `--json` output for scripts. ([#4370](https://github.com/expo/eas-cli/pull/4370) by [@hSATAC](https://github.com/hSATAC))
### 🐛 Bug fixes
- [eas-cli] Reject `--end` without `--start` in the `observe:*`, `update:insights`, `channel:insights`, and `update:view --insights` commands instead of silently falling back to the default time range. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Reject a time range that does not run forwards in the `observe:*`, `update:insights`, `channel:insights`, `update:view --insights`, and `workflow:insights` commands instead of sending it to the server. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Reject non-integer values for `--limit` and `--offset` instead of sending them to the server. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
### 🧹 Chores
- [build-tools] Require PATH project sources for local builds. ([#4362](https://github.com/expo/eas-cli/pull/4362) by [@sjchmiela](https://github.com/sjchmiela))
## [24.1.2](https://github.com/expo/eas-cli/releases/tag/v24.1.2) - 2026-09-11
### 🐛 Bug fixes
- [eas-cli] Shorten the local egress banner printed by `eas simulator:start` and stop mentioning `eas simulator:egress` in interactive mode, where the client runs in the same terminal. ([#4383](https://github.com/expo/eas-cli/pull/4383) by [@brentvatne](https://github.com/brentvatne))
## [24.1.1](https://github.com/expo/eas-cli/releases/tag/v24.1.1) - 2026-09-11
### 🐛 Bug fixes
- [eas-cli] Stop telling interactive `eas simulator:start --egress local` users to run `eas simulator:egress` when the egress client already runs in the same terminal. ([#4382](https://github.com/expo/eas-cli/pull/4382) by [@brentvatne](https://github.com/brentvatne))
## [24.1.0](https://github.com/expo/eas-cli/releases/tag/v24.1.0) - 2026-09-10
### 🎉 New features
- [eas-cli] Add `--egress-allow <host:port>` to `eas simulator:start` so local egress sessions can reach dev servers on this machine or its network. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli][build-tools] Forward `--egress-allow localhost:<port>` and `127.0.0.1:<port>` entries from the simulator host's loopback interface to this machine (like `adb reverse`), so dev server URLs that use `127.0.0.1` work through local egress. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Add the `eas/start_local_egress` step for iOS simulator sessions with local egress: the device host's system proxy points at a reverse tunnel to the EAS CLI machine, so HTTP(S) requests that honor it exit from that machine and third parties see its IP. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `--egress local` to `eas simulator:start` and the `eas simulator:egress` command, which run the local egress client for the session. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Require the serve-sim session token for device run session previews, so the tunnel no longer exposes the shell-exec route. ([#4306](https://github.com/expo/eas-cli/pull/4306) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
### 🛠 Breaking changes
- [eas-cli] Align the `eas observe:*` commands with the renamed Observe GraphQL schema. `--json` output now uses `name`/`value` instead of `metricName`/`metricValue` (metrics) and `name` instead of `eventName` (log events and event-name summaries), and `eas observe:events` now lists user-defined events only — exceptions moved to the new `eas observe:errors`. ([#4338](https://github.com/expo/eas-cli/pull/4338) by [@douglowder](https://github.com/douglowder))
### 🎉 New features
- [build-tools] Add `lcd_width`, `lcd_height`, and `lcd_density` inputs to configure the Android emulator display. ([#4349](https://github.com/expo/eas-cli/pull/4349) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
- [eas-cli] Add `eas channel:protect` and `eas channel:unprotect` to manage EAS Update channel protection, and show protection in channel list and view output. ([#4319](https://github.com/expo/eas-cli/pull/4319) by [@sjkim-expo](https://github.com/sjkim-expo))
- [eas-cli] Add `eas observe:errors` to display error and exception issue groups (grouped by fingerprint), with `--fingerprint <fingerprint>` to drill into a group's individual occurrences and stack traces. ([#4339](https://github.com/expo/eas-cli/pull/4339) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add `eas observe:event` to display a single Observe event (metric, log, or error) by its ID. ([#4252](https://github.com/expo/eas-cli/pull/4252) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [build-tools] Start Argent remote sessions with `bun x` so they work when the `bunx` symlink is missing. ([491b372a](https://github.com/expo/eas-cli/commit/491b372a) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Reduce `expo-device-hub` preview resolution from 1280 px to 960 px to match `serve-sim` and lower streaming bandwidth. ([#4326](https://github.com/expo/eas-cli/pull/4326) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
- [build-tools] Install FFmpeg before launching `expo-device-hub` in Android device sessions. ([#4332](https://github.com/expo/eas-cli/pull/4332) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
- [eas-cli] Print the session token in simulator preview links, so they still open once the preview is gated. ([#4312](https://github.com/expo/eas-cli/pull/4312) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Make ASC API key issuer identifier optional in GraphQL and credentials types. ([#4248](https://github.com/expo/eas-cli/pull/4248) by [@sswrk](https://github.com/sswrk))
## [23.2.0](https://github.com/expo/eas-cli/releases/tag/v23.2.0) - 2026-08-31
### 🎉 New features
- [eas-cli] Add `--tag` to `eas simulator` and `eas simulator:list` to group and filter simulator sessions by app variant. ([#4318](https://github.com/expo/eas-cli/pull/4318) by [@tchayen](https://github.com/tchayen))
- [eas-cli] Update workflow run logs in real time, instead of every 10 seconds. ([#4228](https://github.com/expo/eas-cli/pull/4228) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Add `expo-device-hub` web previews to Android web-preview-only, Agent Device, Argent, and Appium remote sessions. ([#4304](https://github.com/expo/eas-cli/pull/4304) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `eas workflow:ssh <workflow-job-id> [command...]` to open an ssh session on the worker running a workflow job. ([#4032](https://github.com/expo/eas-cli/pull/4032) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `--ssh` (and `--ssh-idle-timeout`) to `eas workflow:run` to enable ssh on the run's VM jobs. ([#4033](https://github.com/expo/eas-cli/pull/4033) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
### 🧹 Chores
- [eas-cli] Warn when starting Android EAS Simulator sessions that support is still in development and iOS parity is coming soon. ([#4320](https://github.com/expo/eas-cli/pull/4320) by [@szdziedzic](https://github.com/szdziedzic))
@@ -51,6 +117,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [build-tools] Cache CocoaPods dependencies between iOS builds. ([#4266](https://github.com/expo/eas-cli/pull/4266) by [@AbbanMustafa](https://github.com/AbbanMustafa))
- [build-tools] Support `EAS_BUN_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `bun install --filter`. ([#4292](https://github.com/expo/eas-cli/pull/4292) by [@konrad-armatys](https://github.com/konrad-armatys))
- [eas-cli] Add `eas project:status` (alias `eas status`) command that prints a snapshot of the project's recent builds, development builds, workflow runs, submissions, and updates, with agent-friendly JSON metadata, activity links, structured errors, submission-to-build correlation, and `--non-interactive` support. ([#4006](https://github.com/expo/eas-cli/pull/4006) by [@jonsamp](https://github.com/jonsamp))
### 🐛 Bug fixes
@@ -96,6 +163,9 @@ This is the log of notable changes to EAS CLI and related packages.
- [build-tools] Support an optional `package_version` input on `eas/start_serve_sim_remote_session`, so a simulator session can pin the `@expo/serve-sim` version instead of always running `latest`. ([#4253](https://github.com/expo/eas-cli/pull/4253) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add composable custom build functions for downloading, installing, and launching application archives in simulator sessions. ([#4222](https://github.com/expo/eas-cli/pull/4222) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add `--build-id`, `--application-archive-url`, and `--expo-go` to `eas simulator` to install and launch an application before the session is ready. ([#4223](https://github.com/expo/eas-cli/pull/4223) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add `--environment` flag to the `eas observe:*` commands. ([#4275](https://github.com/expo/eas-cli/pull/4275) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `apple` value to the `--platform` flag of the `eas observe:*` commands. ([#4276](https://github.com/expo/eas-cli/pull/4276) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `--build-number` flag to `eas observe:metrics` and `eas observe:events`. ([#4278](https://github.com/expo/eas-cli/pull/4278) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "23.1.0",
"PRODUCTION": "23.1.0"
"STAGING": "24.1.2",
"PRODUCTION": "24.1.2"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "23.2.0",
"version": "24.2.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "23.2.0",
"version": "24.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -39,17 +39,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "23.0.0",
"@expo/eas-build-job": "23.1.0",
"@expo/downloader": "24.0.0",
"@expo/eas-build-job": "24.2.0",
"@expo/env": "^0.4.0",
"@expo/logger": "23.1.0",
"@expo/logger": "24.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "23.1.0",
"@expo/template-file": "23.0.0",
"@expo/turtle-spawn": "23.1.0",
"@expo/steps": "24.2.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -11,6 +11,19 @@ jest.mock('../utils/stepMetrics');
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
describe(CustomBuildContext, () => {
it('copies the MCP server URL from the build context', () => {
const ctx = new BuildContext(createTestIosJob(), {
env: { __API_SERVER_URL: 'http://api.expo.test' },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
mcpServerUrl: 'ws://localhost:8787',
});
expect(new CustomBuildContext(ctx).mcpServerUrl).toBe('ws://localhost:8787');
});
it('should not lose workflowInterpolationContext', () => {
const contextUploadArtifact = jest.fn();
const ctx = new BuildContext(
@@ -4,9 +4,11 @@ import { BuildStepGlobalContext, StepsConfigParser } from '@expo/steps';
import { runGenericJobAsync } from '../generic';
import { CustomBuildContext } from '../customBuildContext';
import { uploadJobOutputsToWwwAsync } from '../utils/outputs';
import { stopLocalEgressResourcesAsync } from '../steps/utils/localEgress';
jest.mock('../customBuildContext');
jest.mock('../utils/outputs');
jest.mock('../steps/utils/localEgress', () => ({ stopLocalEgressResourcesAsync: jest.fn() }));
jest.mock('../common/projectSources');
jest.mock('../steps/easFunctions', () => ({ getEasFunctions: jest.fn().mockReturnValue([]) }));
jest.mock('../steps/easFunctionGroups', () => ({
@@ -58,6 +60,25 @@ describe(runGenericJobAsync, () => {
mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined);
});
it.each(['success', 'failure'])(
'releases local egress after workflow %s, before uploading outputs',
async outcome => {
const executeAsync =
outcome === 'success'
? jest.fn().mockResolvedValue(undefined)
: jest.fn().mockRejectedValue(new Error(outcome));
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue({ executeAsync }),
}));
const { runResult } = await runGenericJobAsync(mockCtx);
expect(runResult.ok).toBe(outcome === 'success');
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledWith(mockCtx.logger);
expect(jest.mocked(stopLocalEgressResourcesAsync).mock.invocationCallOrder[0]).toBeLessThan(
mockUploadJobOutputsToWwwAsync.mock.invocationCallOrder[0]
);
}
);
it('awaits drainPendingMetricUploads in COMPLETE_JOB phase', async () => {
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
@@ -2,8 +2,10 @@ import {
ArchiveSourceType,
BuildMode,
BuildTrigger,
ErrorCode,
Job,
Platform,
SystemError,
Workflow,
} from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
@@ -45,7 +47,7 @@ describe('projectSources', () => {
expect(logger.info).toHaveBeenCalledWith('Normalizing project source permissions');
});
it('should use the refreshed repository URL', async () => {
it.each([true, false])('uses the refreshed URL (legacy URL: %s)', async includeRepositoryUrl => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
@@ -61,7 +63,9 @@ describe('projectSources', () => {
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
...(includeRepositoryUrl
? { repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git' }
: {}),
gitRef: 'refs/heads/main',
gitCommitHash,
},
@@ -111,102 +115,88 @@ describe('projectSources', () => {
);
});
it('should fallback to the original repository URL if the refresh fails', async () => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
it.each(['http', 'network', 'json', 'schema', 'missing-url', 'PATH', 'GCS', 'R2'])(
'throws a system error if refresh fails (%s)',
async failure => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
type: Workflow.MANAGED,
mode: BuildMode.BUILD,
initiatingUserId: randomUUID(),
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
gitRef: 'refs/heads/main',
gitCommitHash: randomBytes(20).toString('hex'),
},
platform: Platform.IOS,
secrets: {
robotAccessToken,
environmentSecrets: [],
},
} as Job,
{
env: {
__API_SERVER_URL: 'https://api.expo.dev',
EXPO_TOKEN: robotAccessToken,
EAS_BUILD_ID: buildId,
EAS_BUILD_RUNNER: 'eas-build',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
type: Workflow.MANAGED,
mode: BuildMode.BUILD,
initiatingUserId: randomUUID(),
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: 'refs/heads/main',
gitCommitHash: randomBytes(20).toString('hex'),
},
platform: Platform.IOS,
secrets: {
robotAccessToken,
environmentSecrets: [],
},
} as Job,
{
env: {
__API_SERVER_URL: 'https://api.expo.dev',
EXPO_TOKEN: robotAccessToken,
EAS_BUILD_ID: buildId,
EAS_BUILD_RUNNER: 'eas-build',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
const fetchMock = jest.mocked(fetch);
const cause = new Error('Source request failed');
fetchMock.mockImplementation(async () => {
if (failure === 'network') {
throw cause;
}
return {
ok: failure !== 'http',
status: failure === 'http' ? 500 : 200,
json: async () => {
if (failure === 'json') {
throw cause;
}
if (['PATH', 'GCS', 'R2'].includes(failure)) {
return { data: { type: failure, path: '/project.tar.gz', bucketKey: 'project' } };
}
if (failure === 'missing-url') {
return {
data: {
type: ArchiveSourceType.GIT,
gitRef: 'refs/heads/main',
gitCommitHash: randomBytes(20).toString('hex'),
},
};
}
return { data: { repository_url: 'https://github.com/expo/eas-cli.git' } };
},
} as Response;
});
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
await expect(result).rejects.toBeInstanceOf(SystemError);
await expect(result).rejects.toMatchObject({
errorCode: ErrorCode.SERVER_ERROR,
cause: expect.any(Error),
});
if (failure === 'network') {
await expect(result).rejects.toMatchObject({ cause });
}
);
const fetchMock = jest.mocked(fetch);
fetchMock.mockImplementation(
async () =>
({
ok: false,
text: async () => 'Failed to generate repository URL',
}) as Response
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
archiveSource: {
...ctx.job.projectArchive,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
},
})
);
fetchMock.mockImplementation(
async () =>
({
ok: false,
json: async () => ({
// repositoryUrl is the right key
repository_url: 'https://x-access-token:[email protected]/expo/eas-cli.git',
}),
}) as Response
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
archiveSource: {
...ctx.job.projectArchive,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
},
})
);
fetchMock.mockImplementation(
async () =>
({
ok: false,
json: () => Promise.reject(new Error('Failed to generate repository URL')),
}) as Response
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
archiveSource: {
...ctx.job.projectArchive,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
},
})
);
expect(shallowCloneRepositoryAsync).toHaveBeenCalledTimes(3);
}, 15_000);
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
},
15_000
);
it('should retry fetching the repository URL', async () => {
const robotAccessToken = randomUUID();
@@ -224,7 +214,6 @@ describe('projectSources', () => {
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
gitRef: 'refs/heads/main',
gitCommitHash,
},
@@ -281,7 +270,7 @@ describe('projectSources', () => {
);
}, 15_000);
it(`should fallback to the original repository URL if we're missing some config`, async () => {
it(`throws a system error if refresh configuration is missing`, async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
const logger = createMockLogger();
@@ -295,7 +284,6 @@ describe('projectSources', () => {
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
gitRef: 'refs/heads/main',
gitCommitHash: randomBytes(20).toString('hex'),
},
@@ -319,12 +307,11 @@ describe('projectSources', () => {
}
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(logger.error).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Failed to refresh project archive, falling back to the original one'
);
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).rejects.toMatchObject({
errorCode: ErrorCode.SERVER_ERROR,
cause: new Error('EAS_BUILD_ID is not set'),
});
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
});
describe('uploadProjectMetadataAsFireAndForget', () => {
@@ -404,6 +391,10 @@ describe('projectSources', () => {
const fetchMock = jest.mocked(fetch);
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: ctx.job.projectArchive }),
} as Response);
// Call prepareProjectSourcesAsync and don't await metadata upload
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
@@ -517,6 +508,10 @@ describe('projectSources', () => {
const fetchMock = jest.mocked(fetch);
fetchMock.mockResolvedValue({ ok: true } as Response);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: ctx.job.projectArchive }),
} as Response);
const startTime = Date.now();
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
@@ -581,6 +576,10 @@ describe('projectSources', () => {
const fetchMock = jest.mocked(fetch);
fetchMock.mockResolvedValue({ ok: true } as Response);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: ctx.job.projectArchive }),
} as Response);
// Should not throw even though upload will fail
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.not.toThrow();
@@ -595,3 +594,55 @@ describe('projectSources', () => {
});
});
});
describe('local project sources', () => {
function createContext(type: ArchiveSourceType): BuildContext<Job> {
return new BuildContext(
{
platform: Platform.IOS,
projectArchive: { type, path: '/source.tar.gz' },
} as Job,
{
env: { EAS_BUILD_RUNNER: 'local-build-plugin', __API_SERVER_URL: 'https://api.expo.dev' },
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
}
it('copies and unpacks a PATH source without fetching from www', async () => {
await vol.promises.mkdir('/workingdir', { recursive: true });
await vol.promises.writeFile('/source.tar.gz', 'local archive');
const ctx = createContext(ArchiveSourceType.PATH);
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.toEqual({
handled: true,
});
expect(await vol.promises.readFile('/workingdir/project.tar.gz', 'utf8')).toBe('local archive');
expect(spawn).toHaveBeenCalledWith(
'tar',
['-C', ctx.buildDirectory, '--strip-components', '1', '-zxf', '/workingdir/project.tar.gz'],
{ logger: ctx.logger }
);
expect(fetch).not.toHaveBeenCalled();
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
});
it.each(Object.values(ArchiveSourceType).filter(type => type !== ArchiveSourceType.PATH))(
'rejects local %s sources before fetching or unpacking',
async type => {
const ctx = createContext(type);
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
await expect(result).rejects.toBeInstanceOf(SystemError);
await expect(result).rejects.toMatchObject({
errorCode: 'SERVER_ERROR',
trackingCode: 'INVALID_LOCAL_PROJECT_SOURCE',
message: `Expected a PATH project source for a local build, received ${type}.`,
});
expect(fetch).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
}
);
});
+1 -1
View File
@@ -10,7 +10,7 @@ export async function shallowCloneRepositoryAsync({
destinationDirectory,
}: {
logger: bunyan;
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT };
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT; repositoryUrl: string };
destinationDirectory: string;
}): Promise<void> {
const { repositoryUrl } = archiveSource;
@@ -1,5 +1,5 @@
import downloadFile from '@expo/downloader';
import { ArchiveSource, ArchiveSourceSchemaZ, ArchiveSourceType, Job } from '@expo/eas-build-job';
import { ArchiveSourceType, Job, SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
@@ -19,33 +19,26 @@ export async function prepareProjectSourcesAsync<TJob extends Job>(
destinationDirectory: string
): // Return type required to make switch exhaustive.
Promise<{ handled: boolean }> {
let projectArchive: ArchiveSource = ctx.job.projectArchive;
if (ctx.isLocal) {
console.warn('Local build, skipping project archive refresh');
} else {
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
if (!projectArchiveResult.ok) {
ctx.logger.error(
{ err: projectArchiveResult.reason },
'Failed to refresh project archive, falling back to the original one'
if (ctx.job.projectArchive.type !== ArchiveSourceType.PATH) {
throw new SystemError(
`Expected a PATH project source for a local build, received ${ctx.job.projectArchive.type}.`,
{ trackingCode: 'INVALID_LOCAL_PROJECT_SOURCE' }
);
}
projectArchive = projectArchiveResult.value ?? ctx.job.projectArchive;
await prepareProjectSourcesLocallyAsync(ctx, ctx.job.projectArchive.path, destinationDirectory);
return { handled: true };
}
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
if (!projectArchiveResult.ok) {
throw new SystemError('Failed to fetch project sources. Re-run the job.', {
cause: projectArchiveResult.reason,
});
}
const projectArchive = projectArchiveResult.value;
switch (projectArchive.type) {
case ArchiveSourceType.R2:
case ArchiveSourceType.GCS: {
throw new Error('Remote project sources should be resolved earlier to URL');
}
case ArchiveSourceType.PATH: {
await prepareProjectSourcesLocallyAsync(ctx, projectArchive.path, destinationDirectory); // used in eas build --local
return { handled: true };
}
case ArchiveSourceType.NONE: {
// May be used in no-sources jobs like submission jobs.
return { handled: true };
@@ -249,7 +242,7 @@ async function uploadProjectMetadataAsync(
}
}
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<ArchiveSource> {
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
const taskId = nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const expoApiServerURL = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const robotAccessToken = nullthrows(
@@ -287,7 +280,20 @@ async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<A
);
}
const dataResult = z.object({ data: ArchiveSourceSchemaZ }).safeParse(jsonResult.value);
const dataResult = z
.object({
data: z.discriminatedUnion('type', [
z.object({
type: z.literal(ArchiveSourceType.GIT),
repositoryUrl: z.string().url(),
gitRef: z.string().nullable(),
gitCommitHash: z.string(),
}),
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
]),
})
.safeParse(jsonResult.value);
if (!dataResult.success) {
throw new Error(
`Unexpected data from server (${response.status}): ${z.prettifyError(dataResult.error)}`
+3
View File
@@ -68,6 +68,7 @@ export interface BuildContextOptions {
skipNativeBuild?: boolean;
metadata?: Metadata;
expoApiV2BaseUrl?: string;
mcpServerUrl?: string;
}
export class SkipNativeBuildError extends Error {}
@@ -84,6 +85,7 @@ export class BuildContext<TJob extends Job = Job> {
) => void;
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
public readonly mcpServerUrl?: string;
public artifacts: Artifacts = {};
private readonly _isLocal: boolean;
@@ -112,6 +114,7 @@ export class BuildContext<TJob extends Job = Job> {
this._metadata = options.metadata;
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.mcpServerUrl = options.mcpServerUrl;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -63,6 +63,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
public readonly logger: bunyan;
public readonly graphqlClient: Client;
public readonly runtimeApi: BuilderRuntimeApi;
public readonly mcpServerUrl?: string;
public job: TJob;
public metadata?: Metadata;
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.runtimeApi = {
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
};
this.mcpServerUrl = buildCtx.mcpServerUrl;
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
this.startTime = new Date();
}
+12 -1
View File
@@ -16,6 +16,7 @@ import { BuildContext } from './context';
import { CustomBuildContext } from './customBuildContext';
import { getEasFunctionGroups } from './steps/easFunctionGroups';
import { getEasFunctions } from './steps/easFunctions';
import { stopLocalEgressResourcesAsync } from './steps/utils/localEgress';
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
import { retryAsync } from './utils/retry';
@@ -82,7 +83,17 @@ export async function runGenericJobAsync(
}
});
const runResult = await asyncResult(workflow.executeAsync());
const runResult = await asyncResult(
(async () => {
try {
await workflow.executeAsync();
} finally {
// The session-owning step may never run if simulator boot/setup fails,
// or may time out while resource acquisition is still in flight.
await stopLocalEgressResourcesAsync(ctx.logger);
}
})()
);
await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => {
const results = await Promise.allSettled([
@@ -51,6 +51,7 @@ import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAp
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
@@ -103,6 +104,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
createStartLocalEgressBuildFunction(),
createStartIosSimulatorRecordingsBuildFunction(),
createFinishIosSimulatorRecordingsBuildFunction(),
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
@@ -14,7 +14,6 @@ const projectTargetDirectory = '/workingdir/project';
const GIT_PROJECT_ARCHIVE = {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/app.git',
gitRef: 'refs/heads/main',
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
};
@@ -0,0 +1,205 @@
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { type CustomBuildContext } from '../../../customBuildContext';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
monitorLocalEgressAsync,
readLocalEgressHandoffAsync,
registerLocalEgressResources,
stopLocalEgressResourcesAsync,
} from '../../utils/localEgress';
import {
startDeviceWebPreviewWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/processes');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../utils/agentDeviceArtifacts');
jest.mock('../../utils/argentArtifacts');
jest.mock('../../utils/agentDeviceEvents', () => ({
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
}));
jest.mock('../../utils/argentEvents', () => ({
ARGENT_EVENT_LOG_FILENAME: 'events.jsonl',
startArgentEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
}));
jest.mock('../../utils/appiumEvents', () => ({
startAppiumEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
}));
jest.mock('../../utils/localEgress', () => ({
...jest.requireActual('../../utils/localEgress'),
readLocalEgressHandoffAsync: jest.fn(),
monitorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
getDeviceRunSessionIdOrThrow: () => 'session-id',
getNgrokTunnelDomainOrThrow: () => 'example.test',
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
selectXcodeDeveloperDirectoryAsync: jest.fn(),
ensureFfmpegInstalledOnceAsync: jest.fn(),
spawnDetached: () => ({ pid: 123, getOutput: () => '', stopAsync: jest.fn() }),
waitForFileAsync: async () => ({ port: 1234, token: 'controller-token' }),
startNgrokTunnelAsync: async () => ({ url: 'https://controller.test', stopAsync: jest.fn() }),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const ctx = {} as CustomBuildContext;
const env = { DEVICE_RUN_SESSION_ID: 'session-id' };
const handoff = { url: 'https://egress.test', token: 'secret', fingerprint: 'key=', port: 8899 };
const fields = {
egressUrl: handoff.url,
egressToken: handoff.token,
egressFingerprint: handoff.fingerprint,
egressPort: handoff.port,
};
const controllers = [
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
] as const;
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
const stopPreview = jest.fn();
const stopEgress = jest.fn();
let lifetime: AbortSignal;
async function run(signal?: AbortSignal): Promise<void> {
await createFunction(ctx).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env,
signal,
} as never
);
}
beforeEach(() => {
jest.clearAllMocks();
lifetime = registerLocalEgressResources(stopEgress);
stopEgress.mockResolvedValue(undefined);
stopPreview.mockResolvedValue(undefined);
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/appium-egress-test');
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'readdir').mockResolvedValue(['tool-server.json'] as never);
jest
.spyOn(fs.promises, 'readFile')
.mockResolvedValue(JSON.stringify({ port: 1234, pid: 124, token: 'controller-token' }));
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'sim' }] as never);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://preview.test',
previewToken: 'preview-secret',
stopAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
expect(lifetime.aborted).toBe(false);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
});
});
afterEach(async () => {
await stopLocalEgressResourcesAsync(logger);
jest.restoreAllMocks();
});
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
await run();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'session-id',
logger,
remoteConfig: expect.objectContaining({
...fields,
[controllerField]: expect.any(String),
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
}),
});
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
port: handoff.port,
env,
logger,
signal: expect.any(AbortSignal),
});
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
});
it('leaves ordinary remote config unchanged and does not monitor', async () => {
await stopLocalEgressResourcesAsync(logger);
stopEgress.mockClear();
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
await run();
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
expect(remoteConfig).toHaveProperty(controllerField);
expect(Object.keys(remoteConfig).some(key => key.startsWith('egress'))).toBe(false);
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopEgress).not.toHaveBeenCalled();
});
it.each(['startup', 'report', 'wait', 'teardown'])(
'releases egress after %s fails',
async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'startup') {
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
} else if (phase === 'report') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
} else if (phase === 'wait') {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
} else {
stopPreview.mockRejectedValue(error);
}
await expect(run()).rejects.toThrow(error);
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
phase === 'startup' || phase === 'report' ? 0 : 1
);
}
);
it('passes cancellation to the monitor and releases the lifetime', async () => {
const abort = new AbortController();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
await run(abort.signal);
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
expect.objectContaining({ signal: abort.signal })
);
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
});
it('does not report or start a monitor after cancellation', async () => {
const abort = new AbortController();
abort.abort();
await expect(run(abort.signal)).rejects.toThrow();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopEgress).toHaveBeenCalledTimes(1);
});
});
@@ -83,6 +83,31 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedMkdtemp.mockRestore();
});
it('passes profile and LCD inputs to emulator creation', async () => {
const systemImagePackage = 'system-images;android-35;default;x86_64';
await createStep({
device_identifier: 'medium_phone',
system_image_package: systemImagePackage,
lcd_width: 720,
lcd_height: 1600,
lcd_density: 262,
}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('sdkmanager', [systemImagePackage], expect.anything());
expect(mockedAndroidUtils.createAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceIdentifier: 'medium_phone',
systemImagePackage,
lcdWidth: 720,
lcdHeight: 1600,
lcdDensity: 262,
})
);
expect(mockedAndroidUtils.createAsync.mock.invocationCallOrder[0]).toBeLessThan(
mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]
);
});
it('retries base emulator startup with increasing readiness timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
@@ -9,6 +9,7 @@ import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
import {
ensureFfmpegInstalledOnceAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
@@ -41,7 +42,7 @@ jest.mock('../../utils/argentEvents', () => ({
startArgentEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
ensureFfmpegInstalledAsync: jest.fn(),
ensureFfmpegInstalledOnceAsync: jest.fn(),
getDeviceRunSessionIdOrThrow: jest.fn(),
getNgrokAuthtokenOrThrow: jest.fn(),
getNgrokTunnelDomainOrThrow: jest.fn(),
@@ -127,18 +128,31 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
} as never
);
// (1) The event log flag is enabled, before the tool-server is launched.
// (1) FFmpeg setup starts in the background before Argent setup.
expect(ensureFfmpegInstalledOnceAsync).toHaveBeenCalledWith({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: { EXISTING: 'value' },
logger: expect.anything(),
});
expect(jest.mocked(ensureFfmpegInstalledOnceAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(spawn).mock.invocationCallOrder[0]
);
// (2) The event log flag is enabled, before the tool-server is launched.
const spawnCalls = jest.mocked(spawn).mock.calls;
const enableEventLogIndex = spawnCalls.findIndex(
([command, args]) =>
command === 'bunx' && Array.isArray(args) && args.includes('tool-server-event-log')
command === 'bun' &&
Array.isArray(args) &&
args[0] === 'x' &&
args.includes('tool-server-event-log')
);
expect(enableEventLogIndex).toBeGreaterThanOrEqual(0);
expect(jest.mocked(spawn).mock.invocationCallOrder[enableEventLogIndex]).toBeLessThan(
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
);
// (2) The tool-server and the collector are pinned to the exact same event log path.
// (3) The tool-server and the collector are pinned to the exact same event log path.
const serverEnv = jest.mocked(spawnDetached).mock.calls[0][0].env;
expect(serverEnv.ARGENT_EVENT_LOG).toBe(EXPECTED_EVENT_LOG_PATH);
expect(serverEnv.EXISTING).toBe('value');
@@ -147,7 +161,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
eventLogPath: EXPECTED_EVENT_LOG_PATH,
});
// (3) Collection starts (before we wait for the session to stop) and (4) is torn down
// (4) Collection starts (before we wait for the session to stop) and (5) is torn down
// afterwards, exactly once.
expect(startArgentEventCollectionAsync).toHaveBeenCalledTimes(1);
expect(mockStopAsync).toHaveBeenCalledTimes(1);
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting bunx resolve it.')
expect.stringContaining('Continuing and letting bun x resolve it.')
);
});
});
@@ -0,0 +1,187 @@
import { type bunyan } from '@expo/logger';
import { type BuildStepContext } from '@expo/steps';
import fs from 'node:fs';
import {
LOCAL_EGRESS_HANDOFF_PATH,
configureSystemProxyAsync,
downloadChiselAsync,
startChiselServerAsync,
stopLocalEgressResourcesAsync,
writeLocalEgressHandoffAsync,
} from '../../utils/localEgress';
import { findAvailablePortAsync, startNgrokTunnelAsync } from '../../utils/remoteDeviceRunSession';
import { createStartLocalEgressBuildFunction } from '../startLocalEgress';
jest.mock('../../utils/localEgress', () => ({
...jest.requireActual('../../utils/localEgress'),
configureSystemProxyAsync: jest.fn(),
downloadChiselAsync: jest.fn(),
startChiselServerAsync: jest.fn(),
writeLocalEgressHandoffAsync: jest.fn(),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
findAvailablePortAsync: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
getNgrokTunnelDomainOrThrow: () => 'example.com',
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
const tunnel = { url: 'https://egress.example.com', stopAsync: jest.fn() };
async function start(signal?: AbortSignal): Promise<void> {
await createStartLocalEgressBuildFunction().fn!({ logger } as BuildStepContext, {
env: {},
inputs: {},
outputs: {},
signal,
});
}
describe('local egress acquisition', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/egress-acquisition-test');
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
server.stopAsync.mockResolvedValue(undefined);
tunnel.stopAsync.mockResolvedValue(undefined);
jest.mocked(findAvailablePortAsync).mockResolvedValue(52001);
jest.mocked(downloadChiselAsync).mockResolvedValue('/tmp/egress-acquisition-test/chisel');
jest.mocked(startChiselServerAsync).mockResolvedValue({ process: server, fingerprint: 'key=' });
jest.mocked(startNgrokTunnelAsync).mockResolvedValue(tunnel);
jest.mocked(configureSystemProxyAsync).mockResolvedValue({ service: 'Ethernet' });
jest.mocked(writeLocalEgressHandoffAsync).mockResolvedValue(undefined);
});
afterEach(async () => {
await stopLocalEgressResourcesAsync(logger);
jest.restoreAllMocks();
});
it('stops the detached server and removes credentials when ngrok acquisition fails', async () => {
jest.mocked(startNgrokTunnelAsync).mockRejectedValue(new Error('listener quota'));
await expect(start()).rejects.toThrow('listener quota');
expect(server.stopAsync).toHaveBeenCalledTimes(1);
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
recursive: true,
force: true,
});
});
it('attempts every release even when one resource cannot stop', async () => {
await start();
tunnel.stopAsync.mockRejectedValue(new Error('ngrok close failed'));
await stopLocalEgressResourcesAsync(logger);
expect(server.stopAsync).toHaveBeenCalledTimes(1);
expect(fs.promises.rm).toHaveBeenCalledWith(LOCAL_EGRESS_HANDOFF_PATH, { force: true });
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Could not stop a local egress resource.'
);
});
it.each([false, true])(
'disposes late listeners without delaying cleanup (close fails: %s)',
async closeFails => {
let finishTunnel!: (value: typeof tunnel) => void;
jest.mocked(startNgrokTunnelAsync).mockImplementation(
() =>
new Promise(resolve => {
finishTunnel = resolve;
})
);
const started = start();
const rejected = expect(started).rejects.toThrow();
await new Promise(resolve => setImmediate(resolve));
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
const stopped = stopLocalEgressResourcesAsync(logger);
await Promise.all([stopped, rejected]);
expect(server.stopAsync).toHaveBeenCalledTimes(1);
expect(tunnel.stopAsync).not.toHaveBeenCalled();
if (closeFails) {
tunnel.stopAsync.mockRejectedValue(new Error('late close failed'));
}
finishTunnel(tunnel);
await new Promise(resolve => setImmediate(resolve));
if (closeFails) {
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Could not release a cancelled local egress acquisition.'
);
}
expect(tunnel.stopAsync).toHaveBeenCalledTimes(1);
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
}
);
it.each(['job', 'step'])('finishes %s cancellation when ngrok never settles', async source => {
jest.mocked(startNgrokTunnelAsync).mockReturnValue(new Promise(() => {}));
const controller = new AbortController();
const started = start(controller.signal);
const rejected = expect(started).rejects.toThrow();
await new Promise(resolve => setImmediate(resolve));
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
if (source === 'step') {
controller.abort(new Error('cancelled'));
}
const stopped = source === 'job' ? stopLocalEgressResourcesAsync(logger) : Promise.resolve();
const outcome = await Promise.race([
Promise.all([stopped, rejected]).then(() => 'stopped'),
new Promise(resolve => setImmediate(() => resolve('still waiting'))),
]);
expect(outcome).toBe('stopped');
expect(server.stopAsync).toHaveBeenCalledTimes(1);
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
});
it('handles ngrok rejection after finalization without an unhandled rejection', async () => {
let rejectTunnel!: (error: Error) => void;
jest.mocked(startNgrokTunnelAsync).mockReturnValue(
new Promise((_resolve, reject) => {
rejectTunnel = reject;
})
);
const started = start();
const rejected = expect(started).rejects.toThrow();
await new Promise(resolve => setImmediate(resolve));
await Promise.all([stopLocalEgressResourcesAsync(logger), rejected]);
rejectTunnel(new Error('late connection failure'));
await new Promise(resolve => setImmediate(resolve));
expect(server.stopAsync).toHaveBeenCalledTimes(1);
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
});
it('does not launch a server after a download finishes following cancellation', async () => {
let finishDownload!: (value: string) => void;
jest.mocked(downloadChiselAsync).mockImplementation(
() =>
new Promise(resolve => {
finishDownload = resolve;
})
);
const controller = new AbortController();
const started = start(controller.signal);
const rejected = expect(started).rejects.toThrow('cancelled');
await new Promise(resolve => setImmediate(resolve));
controller.abort(new Error('cancelled'));
await rejected;
finishDownload('/tmp/chisel');
await new Promise(resolve => setImmediate(resolve));
expect(startChiselServerAsync).not.toHaveBeenCalled();
expect(
jest
.mocked(fs.promises.rm)
.mock.calls.filter(([target]) => target === '/tmp/egress-acquisition-test')
).toHaveLength(2);
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
recursive: true,
force: true,
});
});
});
@@ -52,6 +52,25 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
stopAsync.mockResolvedValue(undefined);
});
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: {
previewUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
},
})
);
});
it.each([
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.LINUX, false],
@@ -13,6 +13,10 @@ import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import { Sentry } from '../../sentry';
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
@@ -25,7 +29,6 @@ import {
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../utils/remoteDeviceRunSession';
@@ -66,7 +69,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger, global }, { inputs, env, signal }) => {
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
@@ -121,13 +124,16 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
await uploadRemoteSessionConfigAsync({
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
webPreviewUrl: webPreview.previewUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
@@ -173,7 +179,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
}
await daemonProcess.stopAsync();
}
},
}),
});
}
@@ -46,6 +46,21 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
defaultValue: AndroidEmulatorUtils.defaultSystemImagePackage,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'lcd_width',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'lcd_height',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'lcd_density',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'count',
required: false,
@@ -86,6 +101,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
const systemImagePackage = `${inputs.system_image_package.value}`;
// We can cast because allowedValueTypeName validated this is a string.
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
const lcdWidth = inputs.lcd_width.value as number | undefined;
const lcdHeight = inputs.lcd_height.value as number | undefined;
const lcdDensity = inputs.lcd_density.value as number | undefined;
const shouldAdjustAnimationScale =
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
@@ -129,6 +147,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
lcdWidth: lcdWidth ?? null,
lcdHeight: lcdHeight ?? null,
lcdDensity: lcdDensity ?? null,
env,
logger,
});
@@ -15,6 +15,10 @@ import semver from 'semver';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { sleepAsync } from '../../utils/retry';
@@ -28,7 +32,6 @@ import {
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -62,7 +65,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger, global }, { inputs, env, signal }) => {
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
@@ -138,7 +141,9 @@ export function createStartAppiumRemoteSessionBuildFunction(
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
});
await uploadRemoteSessionConfigAsync({
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
@@ -149,6 +154,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
'appium:udid': device.udid,
},
webPreviewUrl: webPreview.previewUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
@@ -177,7 +183,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
await appiumProcess.stopAsync();
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
},
}),
});
}
@@ -14,13 +14,17 @@ import semver from 'semver';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import { Sentry } from '../../sentry';
import { isProcessDescendantOfAsync } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
import {
ensureFfmpegInstalledAsync,
ensureFfmpegInstalledOnceAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
@@ -28,7 +32,6 @@ import {
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -79,7 +82,7 @@ export function createStartArgentRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger, global }, { inputs, env, signal }) => {
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
@@ -103,32 +106,39 @@ export function createStartArgentRemoteSessionBuildFunction(
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
// Argent shells out to ffmpeg to record the screen. Installing it pulls a
// large Homebrew dependency tree, so do not block session readiness on it:
// the session comes up at its usual speed and only a recording started in
// the first moments misses ffmpeg. Never rejects, so `void` is safe.
void ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
// Start the potentially slow installation while Argent is being prepared.
// On Linux expo-device-hub calls this again and awaits the same in-flight
// setup before launching. On macOS this remains non-blocking, so only a
// recording started in the first moments may miss ffmpeg.
// Never rejects, so `void` is safe.
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
logger.info('Enabling the Argent artifacts list endpoint flag.');
await spawn(
'bunx',
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG],
'bun',
[
'x',
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
],
{ env, logger }
);
logger.info('Enabling the Argent tool-server event log flag.');
await spawn(
'bunx',
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
'bun',
['x', `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
{ env, logger }
);
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
// Keep Argent itself in foreground mode under the detached bunx process. This preserves
// the bunx -> Argent CLI -> tool-server ancestry used to identify the matching state file.
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bun x.`);
// Keep Argent itself in foreground mode under the detached bun process. This preserves
// the bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const argentServer = spawnDetached({
command: 'bunx',
command: 'bun',
args: [
'x',
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
@@ -208,13 +218,16 @@ export function createStartArgentRemoteSessionBuildFunction(
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
await uploadRemoteSessionConfigAsync({
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
webPreviewUrl: webPreview.previewUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
@@ -251,7 +264,7 @@ export function createStartArgentRemoteSessionBuildFunction(
}
await argentServer.stopAsync();
}
},
}),
});
}
@@ -293,7 +306,7 @@ export function warnIfArgentPackageVersionCannotBeVerified({
logger.warn(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
`Continuing and letting bunx resolve it.`
`Continuing and letting bun x resolve it.`
);
return;
}
@@ -0,0 +1,199 @@
import { type bunyan } from '@expo/logger';
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
CHISEL_VERSION,
LOCAL_EGRESS_HANDOFF_PATH,
LOCAL_EGRESS_PROXY_PORT,
configureSystemProxyAsync,
createChiselAuthfileContents,
downloadChiselAsync,
generateEgressCredentials,
registerLocalEgressResources,
startChiselServerAsync,
stopLocalEgressResourcesAsync,
writeLocalEgressHandoffAsync,
} from '../utils/localEgress';
import {
type DetachedProcessHandle,
type NgrokTunnelHandle,
findAvailablePortAsync,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
startNgrokTunnelAsync,
} from '../utils/remoteDeviceRunSession';
/**
* The downloader and ngrok SDK do not accept an AbortSignal. Stop awaiting them
* on cancellation, while retaining handlers that dispose any eventual result.
*/
function awaitLocalEgressAcquisitionAsync<T>(
pending: Promise<T>,
signal: AbortSignal,
disposeAbandonedAsync: (result?: T) => Promise<void>,
logger: bunyan
): Promise<T> {
return new Promise<T>((resolve, reject) => {
let abandoned = false;
const abort = (): void => {
signal.removeEventListener('abort', abort);
abandoned = true;
reject(signal.reason);
};
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) {
abort();
}
void pending
.then(
async result => {
signal.removeEventListener('abort', abort);
if (abandoned) {
await disposeAbandonedAsync(result);
} else {
resolve(result);
}
},
async error => {
signal.removeEventListener('abort', abort);
if (abandoned) {
await disposeAbandonedAsync();
} else {
reject(error);
}
}
)
.catch(err =>
logger.warn({ err }, 'Could not release a cancelled local egress acquisition.')
);
});
}
/**
* Points the device host's system HTTP(S) proxy at the EAS CLI's egress client.
* Must run before `eas/start_ios_simulator`: the simulator reads the system proxy
* at boot. Nothing else on the host changes, so requests from libraries that
* bypass the system proxy are not covered; the shared session monitor reports
* them. The shared session cleanup releases the resources started here when
* the session ends, with a job finalizer as a fallback.
*/
export function createStartLocalEgressBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_local_egress',
name: 'Start local egress',
__metricsId: 'eas/start_local_egress',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env, signal }) => {
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
let workDir: string | undefined;
let server: DetachedProcessHandle | undefined;
let tunnel: NgrokTunnelHandle | undefined;
let finishSetup!: () => void;
const setupFinished = new Promise<void>(resolve => {
finishSetup = resolve;
});
// Register before acquisition. Cancellation ends setup promptly; pending
// SDK calls retain their own late-result disposal handlers.
const lifetimeSignal = registerLocalEgressResources(async () => {
await setupFinished;
const results = await Promise.allSettled([
Promise.resolve().then(() => tunnel?.stopAsync()),
Promise.resolve().then(() => server?.stopAsync()),
]);
for (const result of results) {
if (result.status === 'rejected') {
logger.warn({ err: result.reason }, 'Could not stop a local egress resource.');
}
}
await Promise.all([
fs.promises.rm(LOCAL_EGRESS_HANDOFF_PATH, { force: true }),
workDir ? fs.promises.rm(workDir, { recursive: true, force: true }) : undefined,
]);
});
const startupSignal = signal ? AbortSignal.any([signal, lifetimeSignal]) : lifetimeSignal;
try {
startupSignal.throwIfAborted();
workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-local-egress-'));
startupSignal.throwIfAborted();
logger.info(`Downloading the reverse tunnel server (chisel ${CHISEL_VERSION}).`);
const downloadDir = workDir;
const chiselPath = await awaitLocalEgressAcquisitionAsync(
downloadChiselAsync({ destinationDir: downloadDir, logger }),
startupSignal,
async () => await fs.promises.rm(downloadDir, { recursive: true, force: true }),
logger
);
startupSignal.throwIfAborted();
const credentials = generateEgressCredentials();
const authfilePath = path.join(workDir, 'authfile.json');
await fs.promises.writeFile(
authfilePath,
createChiselAuthfileContents({ ...credentials, port: LOCAL_EGRESS_PROXY_PORT }),
{ encoding: 'utf8', mode: 0o600 }
);
startupSignal.throwIfAborted();
const controlPort = await findAvailablePortAsync();
startupSignal.throwIfAborted();
logger.info(`Starting the reverse tunnel server on 127.0.0.1:${controlPort}.`);
const started = await startChiselServerAsync({
chiselPath,
controlPort,
authfilePath,
env,
signal: startupSignal,
});
server = started.process;
startupSignal.throwIfAborted();
tunnel = await awaitLocalEgressAcquisitionAsync(
startNgrokTunnelAsync({
port: controlPort,
subdomainPrefix: 'egress',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
}),
startupSignal,
async listener => await listener?.stopAsync(),
logger
);
startupSignal.throwIfAborted();
const { service } = await configureSystemProxyAsync({
env,
logger,
port: LOCAL_EGRESS_PROXY_PORT,
signal: startupSignal,
});
startupSignal.throwIfAborted();
await writeLocalEgressHandoffAsync({
url: tunnel.url,
token: credentials.password,
fingerprint: started.fingerprint,
port: LOCAL_EGRESS_PROXY_PORT,
});
startupSignal.throwIfAborted();
logger.info(
`Local egress is configured on network service "${service}". HTTP(S) and WebSocket ` +
'requests that honor the system proxy (WebKit, URLSession and other CFNetwork clients) ' +
'fail until the EAS CLI egress client connects, then exit from that machine. Requests ' +
'from libraries that bypass the system proxy are not covered and exit from this worker. ' +
'Connection sampling may report these bypasses, but can miss short connections and unconnected UDP traffic.'
);
} catch (error) {
finishSetup();
await stopLocalEgressResourcesAsync(logger);
throw error;
} finally {
finishSetup();
}
},
});
}
@@ -6,12 +6,15 @@ import {
} from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
startDeviceWebPreviewWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -37,7 +40,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger, global }, { inputs, env, signal }) => {
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
@@ -61,10 +64,15 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
logger.info(`Preview URL: ${webPreview.previewUrl}`);
try {
await uploadRemoteSessionConfigAsync({
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: { previewUrl: webPreview.previewUrl },
remoteConfig: {
previewUrl: webPreview.previewUrl,
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
},
logger,
});
@@ -78,6 +86,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
} finally {
await webPreview.stopAsync();
}
},
}),
});
}
@@ -90,19 +90,24 @@ export function createUploadToAscBuildFunction(): BuildFunction {
const ascApiKeyJson = await fs.readJson(ascApiKeyPath);
const ascApiKey = z
.object({
issuer_id: z.string(),
issuer_id: z.string().nullish(),
key_id: z.string(),
key: z.string(),
})
.parse(ascApiKeyJson);
const privateKey = await jose.importPKCS8(ascApiKey.key, 'ES256');
const token = await new jose.SignJWT({})
const jwt = new jose.SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: ascApiKey.key_id })
.setIssuer(ascApiKey.issuer_id)
.setAudience('appstoreconnect-v1')
.setExpirationTime('20m')
.sign(privateKey);
.setExpirationTime('20m');
if (ascApiKey.issuer_id) {
jwt.setIssuer(ascApiKey.issuer_id);
} else {
// Nullish issuer_id means an individual API key
jwt.setSubject('user');
}
const token = await jwt.sign(privateKey);
const client = new AscApiClient({ token, logger: stepsCtx.logger });
@@ -0,0 +1,425 @@
import { type bunyan } from '@expo/logger';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnDetached } from '../remoteDeviceRunSession';
import {
CHISEL_VERSION,
LOCAL_EGRESS_PROXY_PORT,
buildEgressRemoteConfigFields,
buildNetworksetupProxyArgs,
collectSimulatorProcessIds,
createChiselAuthfileContents,
getChiselAssetName,
getChiselDownloadUrl,
parseChiselFingerprint,
parseDefaultRouteInterface,
parseDirectSimulatorConnections,
parseExitIpResponse,
parseNetworkServiceNameForDevice,
readLocalEgressHandoffAsync,
registerLocalEgressResources,
startChiselServerAsync,
stopLocalEgressResourcesAsync,
writeLocalEgressHandoffAsync,
} from '../localEgress';
jest.mock('@ngrok/ngrok');
jest.mock('@expo/turtle-spawn');
jest.mock('../remoteDeviceRunSession', () => ({ spawnDetached: jest.fn() }));
describe(getChiselAssetName, () => {
it.each([
['darwin', 'arm64', `chisel_${CHISEL_VERSION}_darwin_arm64.gz`],
['darwin', 'x64', `chisel_${CHISEL_VERSION}_darwin_amd64.gz`],
['linux', 'x64', `chisel_${CHISEL_VERSION}_linux_amd64.gz`],
['linux', 'arm64', `chisel_${CHISEL_VERSION}_linux_arm64.gz`],
] as const)('maps %s/%s to %s', (platform, arch, expected) => {
expect(getChiselAssetName({ platform, arch })).toBe(expected);
});
it('rejects platforms without a pinned binary', () => {
expect(() => getChiselAssetName({ platform: 'win32', arch: 'x64' })).toThrow(
'Local egress is not supported on win32/x64'
);
expect(() => getChiselAssetName({ platform: 'linux', arch: 'ia32' })).toThrow(
'Local egress is not supported on linux/ia32'
);
});
it('downloads from the pinned GitHub release', () => {
expect(getChiselDownloadUrl(`chisel_${CHISEL_VERSION}_darwin_arm64.gz`)).toBe(
`https://github.com/jpillora/chisel/releases/download/v${CHISEL_VERSION}/chisel_${CHISEL_VERSION}_darwin_arm64.gz`
);
});
});
describe(createChiselAuthfileContents, () => {
it('allows reverse remotes on the loopback proxy port and unprivileged loopback ports only', () => {
const contents = createChiselAuthfileContents({ user: 'eas', password: 'pw', port: 8899 });
const parsed = JSON.parse(contents) as Record<string, string[]>;
expect(parsed['eas:pw']).toHaveLength(2);
expect(parsed['eas:pw'][0]).toBe('^R:127\\.0\\.0\\.1:8899$');
const patterns = parsed['eas:pw'].map(source => new RegExp(source));
const allowed = (remote: string): boolean => patterns.some(pattern => pattern.test(remote));
expect(allowed('R:127.0.0.1:8899')).toBe(true);
expect(allowed('R:127.0.0.1:1024')).toBe(true);
expect(allowed('R:127.0.0.1:3000')).toBe(true);
expect(allowed('R:127.0.0.1:8081')).toBe(true);
expect(allowed('R:127.0.0.1:65535')).toBe(true);
// Privileged and out-of-range ports, other interfaces, and forward remotes stay denied.
expect(allowed('R:127.0.0.1:1023')).toBe(false);
expect(allowed('R:127.0.0.1:80')).toBe(false);
expect(allowed('R:127.0.0.1:0')).toBe(false);
expect(allowed('R:127.0.0.1:65536')).toBe(false);
expect(allowed('R:127.0.0.1:88990')).toBe(false);
expect(allowed('R:0.0.0.0:8899')).toBe(false);
expect(allowed('R:0.0.0.0:3000')).toBe(false);
expect(allowed('R:[::1]:3000')).toBe(false);
expect(allowed('R:127x0x0x1:8899')).toBe(false);
expect(allowed('R:socks')).toBe(false);
expect(allowed('example.com:443')).toBe(false);
expect(allowed('127.0.0.1:3000')).toBe(false);
});
});
describe(parseChiselFingerprint, () => {
it('extracts the fingerprint chisel prints at startup', () => {
const output =
'2026/09/07 22:00:00 server: Fingerprint eZm5d2ZQ+Zb2aS0lJm7A5GkWm9G9ZqnPz2W0i2Y6Q1c=\n' +
'2026/09/07 22:00:00 server: Reverse tunnelling enabled\n' +
'2026/09/07 22:00:00 server: Listening on http://127.0.0.1:52001\n';
expect(parseChiselFingerprint(output)).toBe('eZm5d2ZQ+Zb2aS0lJm7A5GkWm9G9ZqnPz2W0i2Y6Q1c=');
});
it('returns null before the fingerprint line appears', () => {
expect(parseChiselFingerprint('')).toBeNull();
expect(parseChiselFingerprint('2026/09/07 22:00:00 server: starting')).toBeNull();
});
});
describe(parseDefaultRouteInterface, () => {
it('reads the interface from `route -n get default`', () => {
const output =
' route to: default\n' +
'destination: default\n' +
' mask: default\n' +
' gateway: 172.20.10.1\n' +
' interface: en0\n' +
' flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>\n';
expect(parseDefaultRouteInterface(output)).toBe('en0');
});
it('returns null when there is no default route', () => {
expect(
parseDefaultRouteInterface('route: writing to routing socket: not in table\n')
).toBeNull();
});
});
describe(parseNetworkServiceNameForDevice, () => {
const listOutput =
'An asterisk (*) denotes that a network service is disabled.\n' +
'(1) Thunderbolt Ethernet Slot 0\n' +
'(Hardware Port: Thunderbolt Ethernet Slot 0, Device: en8)\n' +
'\n' +
'(2) Thunderbolt Bridge\n' +
'(Hardware Port: Thunderbolt Bridge, Device: bridge0)\n' +
'\n' +
'(3) Wi-Fi\n' +
'(Hardware Port: Wi-Fi, Device: en0)\n' +
'\n' +
'(4) iPhone USB\n' +
'(Hardware Port: iPhone USB, Device: en9)\n';
it('returns the service whose device matches', () => {
expect(parseNetworkServiceNameForDevice(listOutput, 'en0')).toBe('Wi-Fi');
expect(parseNetworkServiceNameForDevice(listOutput, 'en8')).toBe('Thunderbolt Ethernet Slot 0');
});
it('returns null for an unknown device', () => {
expect(parseNetworkServiceNameForDevice(listOutput, 'utun3')).toBeNull();
});
});
describe(buildNetworksetupProxyArgs, () => {
it('sets both the HTTP and HTTPS proxy to the loopback egress port', () => {
expect(
buildNetworksetupProxyArgs({ service: 'Ethernet', host: '127.0.0.1', port: 8899 })
).toEqual([
['-setwebproxy', 'Ethernet', '127.0.0.1', '8899'],
['-setsecurewebproxy', 'Ethernet', '127.0.0.1', '8899'],
]);
});
});
describe(collectSimulatorProcessIds, () => {
const runtimeRoot =
'/Library/Developer/CoreSimulator/Volumes/iOS_23F77/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.5.simruntime/Contents/Resources/RuntimeRoot';
const psOutput =
' 1 0 /sbin/launchd\n' +
' 501 1 /usr/local/bin/node\n' +
` 600 1 ${runtimeRoot}/sbin/launchd_sim\n` +
' 610 600 /Users/expo/Library/Developer/CoreSimulator/Devices/ABC/data/Containers/Bundle/Application/DEF/App.app/App\n' +
` 611 600 ${runtimeRoot}/System/Library/ExtensionKit/Extensions/NetworkingExtension.appex/com.apple.WebKit.Networking\n` +
' 620 610 /usr/bin/helper\n' +
' 700 1 /usr/sbin/lsof\n';
it('returns every descendant of launchd_sim and nothing else', () => {
expect(collectSimulatorProcessIds(psOutput).sort((a, b) => a - b)).toEqual([610, 611, 620]);
});
it('returns nothing when no simulator is running', () => {
expect(
collectSimulatorProcessIds(' 1 0 /sbin/launchd\n 501 1 /usr/local/bin/node\n')
).toEqual([]);
});
});
describe(parseDirectSimulatorConnections, () => {
const lsofOutput = [
'p610',
'cApp',
'f10',
'PTCP',
'n127.0.0.1:52344->127.0.0.1:8899',
'TST=ESTABLISHED',
'f11',
'PTCP',
'n192.168.64.2:52345->93.184.216.34:443',
'TST=ESTABLISHED',
'f12',
'PTCP',
'n192.168.64.2:52346->104.16.0.1:443',
'TST=SYN_SENT',
'f13',
'PTCP',
'n*:8080',
'TST=LISTEN',
'f14',
'PUDP',
'n*:5353',
'f15',
'PUDP',
'n192.168.64.2:60000->1.1.1.1:443',
'f16',
'PTCP',
'n[::1]:52347->[::1]:8899',
'TST=ESTABLISHED',
'f17',
'PTCP',
'n[fd00::2]:52348->[2606:4700:4700::1111]:443',
'TST=ESTABLISHED',
'p700',
'cnode',
'f20',
'PTCP',
'n192.168.64.2:52349->93.184.216.34:443',
'TST=ESTABLISHED',
'',
].join('\n');
it('reports simulator connections to non-loopback peers only', () => {
expect(parseDirectSimulatorConnections(lsofOutput, new Set([610]))).toEqual([
{ pid: 610, command: 'App', protocol: 'TCP', remote: '93.184.216.34:443' },
{ pid: 610, command: 'App', protocol: 'TCP', remote: '104.16.0.1:443' },
{ pid: 610, command: 'App', protocol: 'UDP', remote: '1.1.1.1:443' },
{ pid: 610, command: 'App', protocol: 'TCP', remote: '[2606:4700:4700::1111]:443' },
]);
});
it('ignores processes outside the simulator', () => {
expect(parseDirectSimulatorConnections(lsofOutput, new Set([700]))).toEqual([
{ pid: 700, command: 'node', protocol: 'TCP', remote: '93.184.216.34:443' },
]);
expect(parseDirectSimulatorConnections(lsofOutput, new Set([999]))).toEqual([]);
});
it('returns nothing for an empty listing', () => {
expect(parseDirectSimulatorConnections('', new Set([610]))).toEqual([]);
});
});
describe(buildEgressRemoteConfigFields, () => {
it('returns nothing without a handoff', () => {
expect(buildEgressRemoteConfigFields(null)).toEqual({});
});
it('maps the handoff to the remoteConfig field names', () => {
expect(
buildEgressRemoteConfigFields({
url: 'https://egress-abc.eas-simulator.ngrok.dev',
token: 'pw',
fingerprint: 'fp=',
port: LOCAL_EGRESS_PROXY_PORT,
})
).toEqual({
egressUrl: 'https://egress-abc.eas-simulator.ngrok.dev',
egressToken: 'pw',
egressFingerprint: 'fp=',
egressPort: LOCAL_EGRESS_PROXY_PORT,
});
});
});
describe(parseExitIpResponse, () => {
it('reads the ip field', () => {
expect(parseExitIpResponse('{"ip":"64.114.211.44"}')).toBe('64.114.211.44');
});
it('rejects responses without an ip', () => {
expect(() => parseExitIpResponse('{"error":"rate limited"}')).toThrow(
'Unexpected exit IP response'
);
});
});
describe('local egress handoff', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'local-egress-test-'));
});
afterEach(async () => {
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
it('round-trips through the handoff file', async () => {
const handoffPath = path.join(tempDir, 'handoff.json');
const handoff = {
url: 'https://egress-abc.eas-simulator.ngrok.dev',
token: 'pw',
fingerprint: 'fp=',
port: 8899,
};
await writeLocalEgressHandoffAsync(handoff, handoffPath);
await expect(readLocalEgressHandoffAsync(handoffPath)).resolves.toEqual(handoff);
});
it('returns null when no egress step ran', async () => {
await expect(
readLocalEgressHandoffAsync(path.join(tempDir, 'missing.json'))
).resolves.toBeNull();
});
it('rejects a malformed handoff', async () => {
const handoffPath = path.join(tempDir, 'handoff.json');
await fs.promises.writeFile(handoffPath, JSON.stringify({ url: 'x' }), 'utf8');
await expect(readLocalEgressHandoffAsync(handoffPath)).rejects.toThrow('malformed');
});
});
describe(startChiselServerAsync, () => {
const options = {
chiselPath: '/tmp/chisel',
controlPort: 52001,
authfilePath: '/tmp/authfile.json',
env: { PATH: '/bin', AUTH: 'unexpected:password' },
};
let output: string;
let stopAsync: jest.Mock;
beforeEach(() => {
jest.useFakeTimers();
output = 'server: Fingerprint pinned-key=\n';
stopAsync = jest.fn().mockResolvedValue(undefined);
jest.spyOn(process, 'kill').mockReturnValue(true);
jest.mocked(spawnDetached).mockReturnValue({
pid: 12345,
getOutput: () => output,
stopAsync,
});
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
it('waits for the child to bind its exact port and clears inherited AUTH', async () => {
let resolved = false;
const started = startChiselServerAsync(options).then(result => {
resolved = true;
return result;
});
output += 'server: Listening on http://127.0.0.1:520010\n';
await jest.advanceTimersByTimeAsync(250);
expect(resolved).toBe(false);
output += 'server: Listening on http://127.0.0.1:52001\n';
await jest.advanceTimersByTimeAsync(250);
await expect(started).resolves.toMatchObject({ fingerprint: 'pinned-key=' });
expect(spawnDetached).toHaveBeenCalledWith(
expect.objectContaining({ env: { PATH: '/bin', AUTH: '' } })
);
expect(options.env.AUTH).toBe('unexpected:password');
expect(stopAsync).not.toHaveBeenCalled();
});
it('rejects and stops a child that prints its fingerprint then fails to bind', async () => {
const started = startChiselServerAsync(options);
const rejected = expect(started).rejects.toThrow('address already in use');
output += 'listen tcp 127.0.0.1:52001: bind: address already in use\n';
jest.mocked(process.kill).mockImplementation(() => {
throw new Error('ESRCH');
});
await jest.advanceTimersByTimeAsync(250);
await rejected;
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it('stops the child when startup is cancelled', async () => {
const controller = new AbortController();
const started = startChiselServerAsync({ ...options, signal: controller.signal });
const rejected = expect(started).rejects.toThrow('cancelled');
controller.abort(new Error('cancelled'));
await jest.advanceTimersByTimeAsync(250);
await rejected;
expect(stopAsync).toHaveBeenCalledTimes(1);
});
});
describe('local egress resource lifetime', () => {
const logger = { warn: jest.fn() } as unknown as bunyan;
it('aborts the lifetime and awaits the same cleanup for concurrent callers', async () => {
let finish!: () => void;
const cleanup = jest.fn(
() =>
new Promise<void>(resolve => {
finish = resolve;
})
);
const signal = registerLocalEgressResources(cleanup);
const first = stopLocalEgressResourcesAsync(logger);
const second = stopLocalEgressResourcesAsync(logger);
expect(signal.aborted).toBe(true);
await Promise.resolve();
expect(cleanup).toHaveBeenCalledTimes(1);
let complete = false;
void second.then(() => {
complete = true;
});
await Promise.resolve();
expect(complete).toBe(false);
finish();
await Promise.all([first, second]);
await stopLocalEgressResourcesAsync(logger);
expect(cleanup).toHaveBeenCalledTimes(1);
});
it('reports cleanup failure without masking the job result', async () => {
const error = new Error('cleanup failed');
registerLocalEgressResources(async () => {
throw error;
});
await expect(stopLocalEgressResourcesAsync(logger)).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
{ err: error },
'Could not stop a local egress resource.'
);
});
});
@@ -11,14 +11,16 @@ import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
import { sleepAsync } from '../../../utils/retry';
import {
createExpoDeviceHubArgs,
createServeSimArgs,
ensureFfmpegInstalledAsync,
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
metricsCorsOriginToServeSimArgs,
startDeviceWebPreviewWithTunnelAsync,
startExpoDeviceHubWithTunnelAsync,
startNgrokTunnelAsync,
turnIceServersToWebPreviewArgs,
waitForDeviceRunSessionStoppedAsync,
@@ -32,6 +34,11 @@ jest.mock('../../../utils/turtleFetch');
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
jest.mock('../../../sentry');
jest.mock('@expo/turtle-spawn');
// Spyable so a test can stand in for the serve-sim state directory, which a local serve-sim owns.
jest.mock('../serveSimMetricsRecorder', () => {
const actual = jest.requireActual('../serveSimMetricsRecorder');
return { ...actual, readServeSimServersAsync: jest.fn(actual.readServeSimServersAsync) };
});
function createLoggerMock(): bunyan {
return {
@@ -129,6 +136,7 @@ describe(createServeSimArgs, () => {
'4321',
'--host',
'127.0.0.1',
'--require-token',
'--transport',
'webrtc',
'--webrtc-codec',
@@ -198,7 +206,7 @@ describe(createExpoDeviceHubArgs, () => {
'--webrtc-ice-policy',
'all',
'--max-dimension',
'1280',
'960',
'--video-bitrate',
'6000000',
'--video-fps',
@@ -328,6 +336,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
jest.mocked(spawn).mockReset();
jest.mocked(ngrok.forward).mockReset();
jest.mocked(turtleFetch).mockReset();
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1', token: 'tok-1' }]);
const spawnPromise = Object.assign(Promise.resolve(undefined), {
child: {
@@ -359,13 +370,20 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
});
});
it('starts expo-device-hub for Linux and cleans up the preview resources', async () => {
it('installs ffmpeg before starting expo-device-hub for Linux', async () => {
const packageVersion = '1.2.3';
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://android-preview.example.test',
close,
} as never);
jest
.mocked(spawn)
.mockReturnValueOnce(
Promise.reject(new Error('ffmpeg is missing')) as ReturnType<typeof spawn>
)
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>)
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.LINUX,
@@ -376,7 +394,28 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
packageVersion,
});
const [command, args] = jest.mocked(spawn).mock.calls[0];
const spawnCalls = jest.mocked(spawn).mock.calls;
expect(spawnCalls[0]).toEqual(['ffmpeg', ['-version'], { env }]);
expect(spawnCalls[1]).toEqual([
'sudo',
['apt-get', 'update'],
expect.objectContaining({
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
}),
]);
expect(spawnCalls[2]).toEqual([
'sudo',
['apt-get', 'install', '-y', 'ffmpeg'],
expect.objectContaining({
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
}),
]);
const expoDeviceHubCallIndex = spawnCalls.findIndex(([command]) => command === 'npx');
expect(expoDeviceHubCallIndex).toBeGreaterThan(2);
expect(jest.mocked(spawn).mock.invocationCallOrder[2]).toBeLessThan(
jest.mocked(spawn).mock.invocationCallOrder[expoDeviceHubCallIndex]
);
const [command, args] = spawnCalls[expoDeviceHubCallIndex];
const port = Number(args[args.indexOf('--port') + 1]);
expect(port).toBeGreaterThan(0);
expect(command).toBe('npx');
@@ -388,6 +427,63 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
expect(close).toHaveBeenCalledTimes(1);
});
it('carries the serve-sim session token for Darwin', async () => {
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://preview.example.test',
close: jest.fn().mockResolvedValue(undefined),
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
expect(preview.previewToken).toBe('tok-1');
expect(preview.previewUrl).toBe('https://preview.example.test');
});
// serve-sim is always launched with --require-token, so a missing token means it is running
// ungated on a public tunnel. Failing beats handing out a preview that is dead or unprotected.
it('fails for Darwin when serve-sim reports no token', async () => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1' }]);
await expect(
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
})
).rejects.toThrow(/wrote no session token/);
});
// expo-device-hub mints no token, so the Android preview must not require one.
it('starts for Linux without a token, and does not gate expo-device-hub', async () => {
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://android-preview.example.test',
close: jest.fn().mockResolvedValue(undefined),
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.LINUX,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
expect(preview.previewToken).toBeUndefined();
const [, args] = jest.mocked(spawn).mock.calls[0];
expect(args).not.toContain('--require-token');
});
it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => {
const packageVersion = '4.5.6';
const close = jest.fn().mockResolvedValue(undefined);
@@ -416,6 +512,32 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('does not install ffmpeg before starting expo-device-hub outside Linux', async () => {
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://android-preview.example.test',
close,
} as never);
const preview = await startExpoDeviceHubWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
expect(jest.mocked(spawn).mock.calls[0][0]).toBe('npx');
expect(jest.mocked(spawn)).not.toHaveBeenCalledWith(
'ffmpeg',
expect.anything(),
expect.anything()
);
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
});
describe(turnIceServersToWebPreviewArgs, () => {
@@ -785,7 +907,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
});
});
describe(ensureFfmpegInstalledAsync, () => {
describe(ensureFfmpegInstalledOnceAsync, () => {
const spawnMock = jest.mocked(spawn);
function spawnResolved(): ReturnType<typeof spawn> {
@@ -804,7 +926,7 @@ describe(ensureFfmpegInstalledAsync, () => {
it('does not install when ffmpeg is on PATH', async () => {
spawnMock.mockReturnValueOnce(spawnResolved());
await ensureFfmpegInstalledAsync({
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
logger: createLoggerMock(),
@@ -817,7 +939,7 @@ describe(ensureFfmpegInstalledAsync, () => {
it('installs ffmpeg with Homebrew on darwin when it is missing', async () => {
spawnMock.mockReturnValueOnce(spawnRejected()).mockReturnValueOnce(spawnResolved());
await ensureFfmpegInstalledAsync({
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
logger: createLoggerMock(),
@@ -838,7 +960,7 @@ describe(ensureFfmpegInstalledAsync, () => {
.mockReturnValueOnce(spawnResolved()) // apt-get update
.mockReturnValueOnce(spawnResolved()); // apt-get install
await ensureFfmpegInstalledAsync({
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: createEnvMock(),
logger: createLoggerMock(),
@@ -860,6 +982,33 @@ describe(ensureFfmpegInstalledAsync, () => {
);
});
it('shares an in-flight ffmpeg setup between callers', async () => {
let finishInstall: (() => void) | undefined;
const pendingInstall = new Promise<void>(resolve => {
finishInstall = resolve;
});
spawnMock
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
.mockReturnValueOnce(spawnResolved()) // apt-get update
.mockReturnValueOnce(pendingInstall as unknown as ReturnType<typeof spawn>); // apt-get install
const options = {
runtimePlatform: BuildRuntimePlatform.LINUX,
env: createEnvMock(),
logger: createLoggerMock(),
};
const firstSetup = ensureFfmpegInstalledOnceAsync(options);
await new Promise<void>(resolve => setImmediate(resolve));
expect(spawnMock).toHaveBeenCalledTimes(3);
const secondSetup = ensureFfmpegInstalledOnceAsync(options);
expect(spawnMock).toHaveBeenCalledTimes(3);
finishInstall?.();
await Promise.all([firstSetup, secondSetup]);
expect(spawnMock).toHaveBeenCalledTimes(3);
});
it('still installs on linux when the apt index refresh fails', async () => {
spawnMock
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
@@ -867,7 +1016,7 @@ describe(ensureFfmpegInstalledAsync, () => {
.mockReturnValueOnce(spawnResolved()); // apt-get install
const logger = createLoggerMock();
await ensureFfmpegInstalledAsync({
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: createEnvMock(),
logger,
@@ -886,7 +1035,7 @@ describe(ensureFfmpegInstalledAsync, () => {
const logger = createLoggerMock();
await expect(
ensureFfmpegInstalledAsync({
ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
logger,
@@ -907,7 +1056,7 @@ describe(ensureFfmpegInstalledAsync, () => {
const logger = createLoggerMock();
await expect(
ensureFfmpegInstalledAsync({
ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
logger,
@@ -101,6 +101,14 @@ describe(readServeSimServersAsync, () => {
]);
});
it('includes the session token when the state file carries one', async () => {
await writeServerStateAsync('E', { device: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' });
expect(await readServeSimServersAsync(stateDir)).toEqual([
{ udid: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' },
]);
});
it('returns [] when the state dir does not exist', async () => {
expect(await readServeSimServersAsync(path.join(workDir, 'nope'))).toEqual([]);
});
@@ -120,6 +128,20 @@ describe(streamServeSimMetricsToFileAsync, () => {
expect(String(jest.mocked(fetch).mock.calls[0][0])).toBe('https://sim.example/metrics');
});
it('sends the session token as a bearer header when given one', async () => {
jest.mocked(fetch).mockResolvedValue(sseResponse());
await streamServeSimMetricsToFileAsync({
serveSimUrl: 'https://sim.example',
serveSimToken: 'tok-abc',
filePath: path.join(workDir, 'auth.ndjson'),
signal: new AbortController().signal,
logger: createLoggerMock(),
});
expect(jest.mocked(fetch).mock.calls[0][1]?.headers).toMatchObject({
Authorization: 'Bearer tok-abc',
});
});
it('writes nothing on a non-ok response', async () => {
jest.mocked(fetch).mockResolvedValue(new Response('nope', { status: 502 }));
const filePath = path.join(workDir, 'empty.ndjson');
@@ -370,6 +370,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
mobileSimctl: 'Ran a simctl command',
mobileSiriCommand: 'Sent a Siri command',
mobileStartActivity: 'Started an activity',
mobileStartAutomationSession: 'Started an automation session',
mobileStartLogsBroadcast: 'Started log streaming',
mobileStartMediaProjectionRecording: 'Started media projection recording',
mobileStartNetworkMonitor: 'Started network monitoring',
@@ -379,6 +380,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
mobileStartService: 'Started a service',
mobileStartSystemMonitor: 'Started system monitoring',
mobileStartXctestScreenRecording: 'Started XCTest screen recording',
mobileStopAutomationSession: 'Stopped an automation session',
mobileStopLogsBroadcast: 'Stopped log streaming',
mobileStopMediaProjectionRecording: 'Stopped media projection recording',
mobileStopNetworkMonitor: 'Stopped network monitoring',
@@ -400,6 +402,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
mobileUnscheduleAction: 'Canceled a scheduled action',
mobileUpdateSafariPreferences: 'Updated Safari preferences',
mobileViewPortRect: 'Read the viewport size',
mobileViewportElementRect: 'Read an element position and size in the viewport',
mobileViewportScreenshot: 'Took a viewport screenshot',
mobileVoiceOverCurrentSpeech: 'Read current VoiceOver speech',
mobileVoiceOverMove: 'Moved the VoiceOver cursor',
@@ -234,12 +234,14 @@ export type XCUITestAppiumCommand =
| 'mobileShake'
| 'mobileSimctl'
| 'mobileSiriCommand'
| 'mobileStartAutomationSession'
| 'mobileStartLogsBroadcast'
| 'mobileStartNetworkMonitor'
| 'mobileStartPerfRecord'
| 'mobileStartScreenRecording'
| 'mobileStartSystemMonitor'
| 'mobileStartXctestScreenRecording'
| 'mobileStopAutomationSession'
| 'mobileStopLogsBroadcast'
| 'mobileStopNetworkMonitor'
| 'mobileStopPerfRecord'
@@ -374,6 +376,7 @@ export type UiAutomator2AppiumCommand =
| 'mobileUnlock'
| 'mobileUnscheduleAction'
| 'mobileViewPortRect'
| 'mobileViewportElementRect'
| 'mobileViewportScreenshot'
| 'networkSpeed'
| 'openNotifications'
@@ -0,0 +1,724 @@
import downloadFile from '@expo/downloader';
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { createHash, randomBytes } from 'node:crypto';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import zlib from 'node:zlib';
import { sleepAsync } from '../../utils/retry';
import { type DetachedProcessHandle, spawnDetached } from './remoteDeviceRunSession';
/**
* Local egress provides an HTTP(S) proxy through the machine running the EAS CLI.
* Requests that use this proxy exit through that machine's network.
*
* Worker side: a chisel server in reverse mode listens on loopback and is exposed
* through the session's ngrok domain. When the CLI's egress client connects, chisel
* opens LOCAL_EGRESS_PROXY_PORT on this host and forwards every connection to the
* HTTP proxy the CLI runs. The macOS system proxy points at that port. Until the
* client connects, nothing listens on the port and proxied requests fail.
*
* Contract: HTTP(S) and WebSocket requests that honor the system proxy (WebKit,
* URLSession and other CFNetwork clients) exit from the egress client's network
* and fail while the client is disconnected. Requests from libraries that bypass
* the system proxy are not covered and exit from this host. Nothing here enforces
* routing; the session monitor reports such direct connections instead.
*/
export const LOCAL_EGRESS_PROXY_HOST = '127.0.0.1';
export const LOCAL_EGRESS_PROXY_PORT = 8899;
export const LOCAL_EGRESS_USERNAME = 'eas';
export const LOCAL_EGRESS_HANDOFF_PATH = path.join(os.tmpdir(), 'eas-simulator-local-egress.json');
export const CHISEL_VERSION = '1.12.0';
// sha256 of the release .gz assets, cross-checked against chisel_1.12.0_checksums.txt.
const CHISEL_SHA256_BY_ASSET: Record<string, string> = {
'chisel_1.12.0_darwin_arm64.gz':
'707a4b932eea214765146504a0df246cefc415b4297af65a80dd67cf69ba85a9',
'chisel_1.12.0_darwin_amd64.gz':
'4aeae36c867f11c8e8c3f2b913a0e063ea3c6d29e1c14a52ed2e6eef8cfc4395',
'chisel_1.12.0_linux_amd64.gz':
'f3f180f1d93aa72cce4e6386f98cc06569a0146fbd65eb4423cf83e6434bcfe6',
'chisel_1.12.0_linux_arm64.gz':
'2ec6152cd2c74fe0146d4d79e4e7aa174521368c56e433d55e023a92ea404ec3',
};
const CHISEL_STARTUP_TIMEOUT_MS = 15_000;
const EGRESS_MONITOR_INTERVAL_MS = 2_000;
const EGRESS_ESCAPE_SCAN_INTERVAL_MS = 5_000;
const EGRESS_ESCAPE_LOG_LIMIT = 50;
export type LocalEgressHandoff = {
/** Public URL of the reverse tunnel server, reachable through ngrok. */
url: string;
/** Secret the client presents to the tunnel server, paired with LOCAL_EGRESS_USERNAME. */
token: string;
/** Fingerprint of the tunnel server key, for the client to pin. */
fingerprint: string;
/** Loopback port on this host that the client must serve. */
port: number;
};
export function getChiselAssetName({
platform,
arch,
}: {
platform: NodeJS.Platform;
arch: string;
}): string {
const os = platform === 'darwin' ? 'darwin' : platform === 'linux' ? 'linux' : null;
const cpu = arch === 'arm64' ? 'arm64' : arch === 'x64' ? 'amd64' : null;
if (!os || !cpu) {
throw new SystemError(
`Local egress is not supported on ${platform}/${arch}. The reverse tunnel binary is available for macOS and Linux on arm64 and x64.`
);
}
return `chisel_${CHISEL_VERSION}_${os}_${cpu}.gz`;
}
export function getChiselDownloadUrl(assetName: string): string {
return `https://github.com/jpillora/chisel/releases/download/v${CHISEL_VERSION}/${assetName}`;
}
async function sha256FileAsync(filePath: string): Promise<string> {
const hash = createHash('sha256');
await pipeline(fs.createReadStream(filePath), hash);
return hash.digest('hex');
}
export async function downloadChiselAsync({
destinationDir,
logger,
platform = process.platform,
arch = process.arch,
}: {
destinationDir: string;
logger: bunyan;
platform?: NodeJS.Platform;
arch?: string;
}): Promise<string> {
const assetName = getChiselAssetName({ platform, arch });
const expectedSha256 = CHISEL_SHA256_BY_ASSET[assetName];
if (!expectedSha256) {
throw new SystemError(`No pinned checksum for ${assetName}.`);
}
const url = getChiselDownloadUrl(assetName);
const archivePath = path.join(destinationDir, assetName);
logger.info(`Downloading ${url}.`);
await downloadFile(url, archivePath, { retry: 3, timeout: 60_000 });
const actualSha256 = await sha256FileAsync(archivePath);
if (actualSha256 !== expectedSha256) {
throw new SystemError(
`Checksum mismatch for ${assetName}: expected ${expectedSha256}, got ${actualSha256}. ` +
'The download may be corrupted or tampered with; the session was not started.'
);
}
const binaryPath = path.join(destinationDir, 'chisel');
await pipeline(
fs.createReadStream(archivePath),
zlib.createGunzip(),
fs.createWriteStream(binaryPath)
);
await fs.promises.chmod(binaryPath, 0o755);
return binaryPath;
}
export function generateEgressCredentials(): { user: string; password: string } {
return { user: LOCAL_EGRESS_USERNAME, password: randomBytes(24).toString('base64url') };
}
// Matches 1024-65535: every unprivileged TCP port.
const UNPRIVILEGED_PORT_PATTERN =
'(102[4-9]|10[3-9][0-9]|1[1-9][0-9]{2}|[2-9][0-9]{3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])';
/**
* chisel authfile: one user, allowed to open reverse remotes on loopback only.
* The proxy port carries proxied requests. Any unprivileged loopback port may be
* forwarded too: the EAS CLI opens one per `--egress-allow localhost:<port>`
* entry so `127.0.0.1:<port>` inside the simulator reaches that port on the
* developer's machine, like `adb reverse`. iOS never sends loopback-literal
* requests to the system proxy, so the proxy alone cannot serve them.
* Reverse remotes are matched as `R:<interface>:<port>`.
*/
export function createChiselAuthfileContents({
user,
password,
port,
}: {
user: string;
password: string;
port: number;
}): string {
const escapedHost = LOCAL_EGRESS_PROXY_HOST.replace(/\./g, '\\.');
return JSON.stringify({
[`${user}:${password}`]: [
`^R:${escapedHost}:${port}$`,
`^R:${escapedHost}:${UNPRIVILEGED_PORT_PATTERN}$`,
],
});
}
export function parseChiselFingerprint(output: string): string | null {
const match = /Fingerprint\s+(\S+)/.exec(output);
return match?.[1] ?? null;
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export async function startChiselServerAsync({
chiselPath,
controlPort,
authfilePath,
env,
signal,
}: {
chiselPath: string;
controlPort: number;
authfilePath: string;
env: BuildStepEnv;
signal?: AbortSignal;
}): Promise<{ process: DetachedProcessHandle; fingerprint: string }> {
const server = spawnDetached({
command: chiselPath,
args: [
'server',
'--host',
LOCAL_EGRESS_PROXY_HOST,
'--port',
String(controlPort),
'--reverse',
'--authfile',
authfilePath,
],
// AUTH adds an unrestricted user even when --authfile is supplied.
env: { ...env, AUTH: '' },
});
try {
const deadline = Date.now() + CHISEL_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
signal?.throwIfAborted();
if (server.pid === undefined || !isProcessRunning(server.pid)) {
break;
}
const output = server.getOutput();
const fingerprint = parseChiselFingerprint(output);
// Chisel prints the fingerprint before binding. This line comes from the
// child only after its listener succeeds, unlike a probe of a reused port.
const listening = output
.split('\n')
.some(line =>
line
.trimEnd()
.endsWith(`server: Listening on http://${LOCAL_EGRESS_PROXY_HOST}:${controlPort}`)
);
if (fingerprint && listening) {
return { process: server, fingerprint };
}
await sleepAsync(250);
}
throw new SystemError(
`The reverse tunnel server did not start within ${CHISEL_STARTUP_TIMEOUT_MS / 1000}s. Output:\n${
server.getOutput() || '<empty>'
}`
);
} catch (error) {
await server.stopAsync();
throw error;
}
}
export function parseDefaultRouteInterface(routeOutput: string): string | null {
const match = /^\s*interface:\s*(\S+)/m.exec(routeOutput);
return match?.[1] ?? null;
}
/**
* `networksetup -listnetworkserviceorder` prints each service as two lines:
* "(1) Wi-Fi" followed by "(Hardware Port: Wi-Fi, Device: en0)". Return the
* service name whose device matches.
*/
export function parseNetworkServiceNameForDevice(
listOutput: string,
device: string
): string | null {
const lines = listOutput.split('\n').map(line => line.trim());
for (let i = 0; i < lines.length; i++) {
const hardwarePortMatch = /^\(Hardware Port: (.+), Device: (\S+)\)$/.exec(lines[i]);
if (!hardwarePortMatch || hardwarePortMatch[2] !== device) {
continue;
}
const serviceLineMatch = /^\(\d+\) (.+)$/.exec(lines[i - 1] ?? '');
return serviceLineMatch?.[1] ?? hardwarePortMatch[1];
}
return null;
}
export async function resolveActiveNetworkServiceNameAsync({
env,
}: {
env: BuildStepEnv;
}): Promise<string> {
const routeResult = await spawn('route', ['-n', 'get', 'default'], { env, stdio: 'pipe' });
const device = parseDefaultRouteInterface(routeResult.stdout);
if (!device) {
throw new SystemError(
'Could not determine the default network interface of the device host, so the system proxy ' +
'cannot be configured. Output of `route -n get default`:\n' +
(routeResult.stdout || '<empty>')
);
}
const listResult = await spawn('networksetup', ['-listnetworkserviceorder'], {
env,
stdio: 'pipe',
});
const service = parseNetworkServiceNameForDevice(listResult.stdout, device);
if (!service) {
throw new SystemError(
`Could not find the network service for interface ${device}, so the system proxy cannot be ` +
'configured. Output of `networksetup -listnetworkserviceorder`:\n' +
(listResult.stdout || '<empty>')
);
}
return service;
}
export function buildNetworksetupProxyArgs({
service,
host,
port,
}: {
service: string;
host: string;
port: number;
}): string[][] {
return [
['-setwebproxy', service, host, String(port)],
['-setsecurewebproxy', service, host, String(port)],
];
}
/**
* Point the macOS system HTTP and HTTPS proxy at the loopback egress port. The
* simulator reads these settings when it boots, so this must run before
* `start_ios_simulator`. Existing bypass and automatic proxy settings are left
* unchanged. Requests that bypass this proxy can leave from this host's address.
*/
export async function configureSystemProxyAsync({
env,
logger,
port,
signal,
}: {
env: BuildStepEnv;
logger: bunyan;
port: number;
signal?: AbortSignal;
}): Promise<{ service: string }> {
if (process.env.ENVIRONMENT === 'development') {
logger.info('Job running outside of EAS, not changing the system proxy.');
return { service: '<development>' };
}
const service = await resolveActiveNetworkServiceNameAsync({ env });
for (const args of buildNetworksetupProxyArgs({ service, host: LOCAL_EGRESS_PROXY_HOST, port })) {
signal?.throwIfAborted();
await spawn('networksetup', args, { env, logger, signal });
}
logger.info(`System proxy for "${service}" set to ${LOCAL_EGRESS_PROXY_HOST}:${port}.`);
return { service };
}
export async function writeLocalEgressHandoffAsync(
handoff: LocalEgressHandoff,
handoffPath: string = LOCAL_EGRESS_HANDOFF_PATH
): Promise<void> {
await fs.promises.writeFile(handoffPath, JSON.stringify(handoff), {
encoding: 'utf8',
mode: 0o600,
});
}
export async function readLocalEgressHandoffAsync(
handoffPath: string = LOCAL_EGRESS_HANDOFF_PATH
): Promise<LocalEgressHandoff | null> {
let raw: string;
try {
raw = await fs.promises.readFile(handoffPath, 'utf8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw err;
}
const parsed = JSON.parse(raw) as Partial<LocalEgressHandoff>;
if (
typeof parsed.url !== 'string' ||
typeof parsed.token !== 'string' ||
typeof parsed.fingerprint !== 'string' ||
typeof parsed.port !== 'number'
) {
throw new SystemError(`Local egress handoff at ${handoffPath} is malformed.`);
}
return {
url: parsed.url,
token: parsed.token,
fingerprint: parsed.fingerprint,
port: parsed.port,
};
}
/** remoteConfig fields the CLI needs to start the egress client. */
export function buildEgressRemoteConfigFields(
handoff: LocalEgressHandoff | null
): Record<string, string | number> {
if (!handoff) {
return {};
}
return {
egressUrl: handoff.url,
egressToken: handoff.token,
egressFingerprint: handoff.fingerprint,
egressPort: handoff.port,
};
}
// The egress step returns before the session ends, so the resources it created
// are held here and released by the session step or the job's finally block.
let activeLocalEgressResources:
| { stopAsync: () => Promise<void>; controller: AbortController; stopping?: Promise<void> }
| undefined;
export function registerLocalEgressResources(stopAsync: () => Promise<void>): AbortSignal {
if (activeLocalEgressResources) {
throw new SystemError('Local egress resources are already registered for this job.');
}
const controller = new AbortController();
activeLocalEgressResources = { stopAsync, controller };
return controller.signal;
}
export async function stopLocalEgressResourcesAsync(logger: bunyan): Promise<void> {
const resources = activeLocalEgressResources;
if (!resources) {
return;
}
resources.controller.abort();
resources.stopping ??= Promise.resolve()
.then(resources.stopAsync)
.catch(err => logger.warn({ err }, 'Could not stop a local egress resource.'))
.finally(() => {
activeLocalEgressResources = undefined;
});
await resources.stopping;
}
export async function isPortListeningAsync({
host,
port,
timeoutMs = 1_000,
}: {
host: string;
port: number;
timeoutMs?: number;
}): Promise<boolean> {
return await new Promise<boolean>(resolve => {
const socket = net.connect({ host, port });
const finish = (listening: boolean): void => {
socket.destroy();
resolve(listening);
};
socket.setTimeout(timeoutMs, () => finish(false));
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
});
}
export function parseExitIpResponse(body: string): string {
const parsed = JSON.parse(body) as { ip?: unknown };
if (typeof parsed.ip !== 'string' || parsed.ip.length === 0) {
throw new SystemError(`Unexpected exit IP response: ${body}`);
}
return parsed.ip;
}
export async function fetchExitIpThroughProxyAsync({
port,
env,
}: {
port: number;
env: BuildStepEnv;
}): Promise<string> {
const result = await spawn(
'curl',
[
'-sS',
'--max-time',
'10',
'-x',
`http://${LOCAL_EGRESS_PROXY_HOST}:${port}`,
'https://api.ipify.org?format=json',
],
{ env, stdio: 'pipe' }
);
return parseExitIpResponse(result.stdout);
}
/**
* Processes inside the simulator run on the host as descendants of launchd_sim,
* so `ps -axo pid=,ppid=,comm=` ancestry identifies them. Returns every
* descendant pid; launchd_sim itself opens no network connections.
*/
export function collectSimulatorProcessIds(psOutput: string): number[] {
const childrenByParent = new Map<number, number[]>();
const roots: number[] = [];
for (const line of psOutput.split('\n')) {
const match = /^\s*(\d+)\s+(\d+)\s+(\S.*)$/.exec(line);
if (!match) {
continue;
}
const pid = Number(match[1]);
const parentPid = Number(match[2]);
const command = match[3].trim();
const siblings = childrenByParent.get(parentPid) ?? [];
siblings.push(pid);
childrenByParent.set(parentPid, siblings);
if (path.basename(command) === 'launchd_sim') {
roots.push(pid);
}
}
const descendants: number[] = [];
const seen = new Set<number>(roots);
const queue = [...roots];
for (let i = 0; i < queue.length; i++) {
for (const child of childrenByParent.get(queue[i]) ?? []) {
if (seen.has(child)) {
continue;
}
seen.add(child);
descendants.push(child);
queue.push(child);
}
}
return descendants;
}
export type DirectSimulatorConnection = {
pid: number;
command: string;
protocol: string;
/** Remote peer as lsof prints it, e.g. `93.184.216.34:443` or `[2606::1]:443`. */
remote: string;
};
function remoteHostOf(remote: string): string {
if (remote.startsWith('[')) {
const end = remote.indexOf(']');
return end === -1 ? remote : remote.slice(1, end);
}
const colon = remote.lastIndexOf(':');
return colon === -1 ? remote : remote.slice(0, colon);
}
function isLoopbackHost(host: string): boolean {
return (
host === 'localhost' || host.startsWith('127.') || host === '::1' || host === '::ffff:127.0.0.1'
);
}
/**
* Parse `lsof -nP -i -F pcPnT` output. Returns the TCP and UDP connections that
* simulator processes hold to peers outside loopback. Those did not go through
* the proxy on 127.0.0.1, so they exit from this host instead of the egress
* client. Listening sockets, unconnected UDP sockets and connections to
* loopback (the proxy itself) are ignored.
*/
export function parseDirectSimulatorConnections(
lsofOutput: string,
simulatorPids: ReadonlySet<number>
): DirectSimulatorConnection[] {
const connections: DirectSimulatorConnection[] = [];
let pid: number | null = null;
let command = '';
let protocol: string | null = null;
let name: string | null = null;
let tcpState: string | null = null;
const flush = (): void => {
if (pid !== null && simulatorPids.has(pid) && protocol && name) {
const arrow = name.indexOf('->');
const remote = arrow === -1 ? null : name.slice(arrow + 2);
const active =
protocol !== 'TCP' ||
tcpState === null ||
tcpState === 'ESTABLISHED' ||
tcpState === 'SYN_SENT';
if (remote && active && !isLoopbackHost(remoteHostOf(remote))) {
connections.push({ pid, command, protocol, remote });
}
}
protocol = null;
name = null;
tcpState = null;
};
for (const line of lsofOutput.split('\n')) {
const field = line[0];
const value = line.slice(1);
switch (field) {
case 'p':
flush();
pid = Number(value);
command = '';
break;
case 'c':
command = value;
break;
case 'f':
flush();
break;
case 'P':
protocol = value;
break;
case 'n':
name = value;
break;
case 'T':
if (value.startsWith('ST=')) {
tcpState = value.slice('ST='.length);
}
break;
default:
break;
}
}
flush();
return connections;
}
async function findDirectSimulatorConnectionsAsync({
env,
}: {
env: BuildStepEnv;
}): Promise<DirectSimulatorConnection[]> {
const ps = await spawn('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' });
const pids = collectSimulatorProcessIds(ps.stdout);
if (pids.length === 0) {
return [];
}
let lsofOutput: string;
try {
const lsof = await spawn('lsof', ['-nP', '-i', '-F', 'pcPnT', '-a', '-p', pids.join(',')], {
env,
stdio: 'pipe',
});
lsofOutput = lsof.stdout;
} catch (err) {
// lsof exits with 1 when none of the processes holds a matching socket, and
// when one of them exited between `ps` and `lsof`. Its stdout is still valid.
const result = err as { status?: number | null; stdout?: string };
if (result.status !== 1) {
throw err;
}
lsofOutput = result.stdout ?? '';
}
return parseDirectSimulatorConnections(lsofOutput, new Set(pids));
}
/**
* Log proxy listener availability, the exit IP observed by a worker request
* through it, and simulator connections that bypassed the proxy. Neither check
* verifies that proxied simulator requests reach the egress client. Never
* rejects: it runs in the background for the whole session.
*/
export async function monitorLocalEgressAsync({
port,
env,
logger,
signal,
}: {
port: number;
env: BuildStepEnv;
logger: bunyan;
signal: AbortSignal;
}): Promise<void> {
let connected = false;
let lastEscapeScanAt = 0;
let escapeScanBroken = false;
const reportedEscapes = new Set<string>();
const lifetimeSignal = activeLocalEgressResources?.controller.signal;
try {
while (!signal.aborted && !lifetimeSignal?.aborted) {
const listening = await isPortListeningAsync({ host: LOCAL_EGRESS_PROXY_HOST, port });
if (listening && !connected) {
connected = true;
logger.info('Local egress proxy listener is available.');
try {
const exitIp = await fetchExitIpThroughProxyAsync({ port, env });
logger.info(
`Worker proxy exit-IP check observed ${exitIp}. This does not verify simulator routing.`
);
} catch (err) {
logger.warn(
{ err },
'The local egress proxy listener is available, but the worker exit-IP check through it failed.'
);
}
} else if (!listening && connected) {
connected = false;
logger.warn(
'Local egress proxy listener is unavailable. Proxied HTTP(S) requests fail until it returns.'
);
}
if (!escapeScanBroken && Date.now() - lastEscapeScanAt >= EGRESS_ESCAPE_SCAN_INTERVAL_MS) {
lastEscapeScanAt = Date.now();
try {
for (const connection of await findDirectSimulatorConnectionsAsync({ env })) {
const key = `${connection.pid}|${connection.protocol}|${connection.remote}`;
if (reportedEscapes.has(key)) {
continue;
}
reportedEscapes.add(key);
if (reportedEscapes.size > EGRESS_ESCAPE_LOG_LIMIT) {
if (reportedEscapes.size === EGRESS_ESCAPE_LOG_LIMIT + 1) {
logger.warn(
`Local egress: more than ${EGRESS_ESCAPE_LOG_LIMIT} direct connections were reported; further ones are not logged.`
);
}
continue;
}
logger.warn(
`Local egress: ${connection.command} (pid ${connection.pid}) opened a direct ${connection.protocol} connection to ${connection.remote}, bypassing the system proxy. That traffic exits from this worker, not from the egress client.`
);
}
} catch (err) {
escapeScanBroken = true;
logger.warn(
{ err },
'Local egress: could not inspect simulator connections. Direct connections that bypass the proxy will not be reported.'
);
}
}
await sleepAsync(EGRESS_MONITOR_INTERVAL_MS);
}
} catch (err) {
logger.warn({ err }, 'Local egress monitoring stopped unexpectedly.');
}
}
@@ -0,0 +1,55 @@
import { type BuildFunction, type BuildStepEnv } from '@expo/steps';
import {
buildEgressRemoteConfigFields,
monitorLocalEgressAsync,
readLocalEgressHandoffAsync,
stopLocalEgressResourcesAsync,
} from './localEgress';
import { uploadRemoteSessionConfigAsync } from './remoteDeviceRunSession';
/** Release the pre-boot egress resources even if controller startup or teardown fails. */
export function withLocalEgressSession(
fn: NonNullable<BuildFunction['fn']>
): NonNullable<BuildFunction['fn']> {
return async (context, args) => {
try {
args.signal?.throwIfAborted();
await fn(context, args);
} finally {
await stopLocalEgressResourcesAsync(context.logger);
}
};
}
/** All simulator controllers publish the same egress handoff and run the same monitor. */
export async function uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
...options
}: Parameters<typeof uploadRemoteSessionConfigAsync>[0] & {
env: BuildStepEnv;
signal?: AbortSignal;
}): Promise<void> {
// Written by start_local_egress before simulator boot; absent for ordinary sessions.
const localEgress = await readLocalEgressHandoffAsync();
signal?.throwIfAborted();
await uploadRemoteSessionConfigAsync({
...options,
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
});
if (localEgress && !signal?.aborted) {
options.logger.info(
'Local egress: waiting for the EAS CLI egress client to connect. Proxied HTTP(S) ' +
'requests are unavailable until it does.'
);
// The monitor also observes the registered resources' lifetime signal, which
// withLocalEgressSession aborts on success, failure, or cancellation.
void monitorLocalEgressAsync({
port: localEgress.port,
env,
logger: options.logger,
signal: signal ?? new AbortController().signal,
});
}
}
@@ -17,6 +17,7 @@ import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const WEB_PREVIEW_HOST = '127.0.0.1';
@@ -26,7 +27,7 @@ const SERVE_SIM_MJPEG_QUALITY = '0.55';
const SERVE_SIM_VIDEO_BITRATE = '6000000';
const SERVE_SIM_VIDEO_FPS = '60';
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
const EXPO_DEVICE_HUB_MAX_DIMENSION = '1280';
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
@@ -297,10 +298,9 @@ async function sleepUntilAbortedAsync(
}
}
// Argent encodes screen recordings by piping simulator frames into `ffmpeg`,
// which it resolves from PATH. The tool-server inherits this step's env, so
// spawning ffmpeg resolves against the same PATH argent will search: it rejects
// with ENOENT when the binary is absent, and running it also proves it works.
// Device-session tools resolve `ffmpeg` from PATH. Spawning it with the step's
// environment rejects with ENOENT when the binary is absent, and running it also
// proves that the installed binary works.
async function isFfmpegAvailableAsync(env: BuildStepEnv): Promise<boolean> {
return (await asyncResult(spawn('ffmpeg', ['-version'], { env }))).ok;
}
@@ -333,14 +333,15 @@ async function installFfmpegWithAptAsync({
await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger });
}
let ffmpegSetupPromise: Promise<void> | undefined;
/**
* Install ffmpeg when the runtime does not already provide it, so argent's
* `screen-recording-start` tool can encode a video. The worker images do not
* ship ffmpeg yet, so without this the tool fails with "`ffmpeg` was not found
* on PATH" — on macOS (iOS simulators) and Linux (Android emulators) alike.
* Install ffmpeg when the runtime does not already provide it. Device-session
* tools use it for video encoding on macOS (iOS simulators) and Linux (Android
* emulators) alike, but the worker images do not ship it yet.
*
* Best-effort by design: screen recording is one optional argent tool, so a
* failure here is logged and the session continues without it.
* Best-effort by design: a failure here is logged and the session continues
* without FFmpeg-dependent features.
*
* The whole body is wrapped because the caller runs this in the background with
* `void`. There is no unhandledRejection handler in the worker, so a rejection
@@ -348,7 +349,7 @@ async function installFfmpegWithAptAsync({
* `spawn` is not an async function and can throw synchronously, which
* `asyncResult` cannot catch — it only wraps an already-created promise.
*/
export async function ensureFfmpegInstalledAsync({
async function ensureFfmpegInstalledAsync({
runtimePlatform,
env,
logger,
@@ -367,7 +368,7 @@ export async function ensureFfmpegInstalledAsync({
logger.info(
`ffmpeg is not installed, installing it with ${
isDarwin ? 'Homebrew' : 'apt'
} for argent screen recording.`
} for the device session.`
);
if (isDarwin) {
await installFfmpegWithHomebrewAsync({ env, logger });
@@ -377,16 +378,41 @@ export async function ensureFfmpegInstalledAsync({
logger.info('Installed ffmpeg.');
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not install ffmpeg for argent screen recording', error, {
Sentry.capture('Could not install ffmpeg for the device session', error, {
level: 'warning',
});
logger.warn(
{ err: error },
'Could not install ffmpeg. Argent screen recording will not work in this session.'
'Could not install ffmpeg. FFmpeg-dependent features may not work in this session.'
);
}
}
export async function ensureFfmpegInstalledOnceAsync({
runtimePlatform,
env,
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
if (ffmpegSetupPromise) {
await ffmpegSetupPromise;
return;
}
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
ffmpegSetupPromise = setupPromise;
try {
await setupPromise;
} finally {
if (ffmpegSetupPromise === setupPromise) {
ffmpegSetupPromise = undefined;
}
}
}
const TurnIceServersResponseSchema = z.object({
data: z.object({
iceServers: TurnIceServersSchema,
@@ -627,6 +653,7 @@ export function createServeSimArgs({
String(port),
'--host',
WEB_PREVIEW_HOST,
'--require-token',
'--transport',
'webrtc',
'--webrtc-codec',
@@ -680,7 +707,7 @@ export function createExpoDeviceHubArgs({
];
}
async function findAvailablePortAsync(): Promise<number> {
export async function findAvailablePortAsync(): Promise<number> {
const server = createServer();
server.unref();
await new Promise<void>((resolve, reject) => {
@@ -712,7 +739,7 @@ export async function waitForWebPreviewReadyAsync({
serverName: string;
port: number;
timeoutMs: number;
}): Promise<void> {
}): Promise<string> {
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
@@ -729,8 +756,8 @@ export async function waitForWebPreviewReadyAsync({
retries: 0,
timeout: 2_000,
});
WebPreviewReadyResponseSchema.parse(await response.json());
return;
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
return ready.device;
} catch (error) {
lastError = error;
}
@@ -745,6 +772,8 @@ export async function waitForWebPreviewReadyAsync({
export type DeviceWebPreviewHandle = {
previewUrl: string;
/** Session token gating the preview. Only serve-sim mints one. */
previewToken?: string;
stopAsync: () => Promise<void>;
};
@@ -760,6 +789,7 @@ async function startWebPreviewWithTunnelAsync(
serverName,
packageSpec,
createArgs,
readPreviewTokenAsync,
}: {
baseDomain: string;
env: BuildStepEnv;
@@ -768,6 +798,7 @@ async function startWebPreviewWithTunnelAsync(
serverName: string;
packageSpec: string;
createArgs: (port: number, turnArgs: string[]) => string[];
readPreviewTokenAsync?: (device: string) => Promise<string>;
}
): Promise<DeviceWebPreviewHandle> {
const port = await findAvailablePortAsync();
@@ -781,7 +812,13 @@ async function startWebPreviewWithTunnelAsync(
try {
logger.info(`Waiting for ${serverName} to become ready.`);
await waitForWebPreviewReadyAsync({ previewServer, serverName, port, timeoutMs });
const device = await waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
});
const previewToken = await readPreviewTokenAsync?.(device);
const tunnel = await startNgrokTunnelAsync({
port,
subdomainPrefix: 'web-preview',
@@ -791,6 +828,7 @@ async function startWebPreviewWithTunnelAsync(
});
return {
previewUrl: tunnel.url,
previewToken,
stopAsync: async () => {
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
for (const result of results) {
@@ -806,6 +844,14 @@ async function startWebPreviewWithTunnelAsync(
}
}
export async function readServeSimPreviewTokenAsync(
udid: string,
stateDir: string = SERVE_SIM_STATE_DIR
): Promise<string | undefined> {
const servers = await readServeSimServersAsync(stateDir);
return servers.find(server => server.udid === udid)?.token;
}
export async function startServeSimWithTunnelAsync(
ctx: CustomBuildContext,
{
@@ -832,18 +878,34 @@ export async function startServeSimWithTunnelAsync(
packageSpec: createServeSimPackageSpec(packageVersion),
createArgs: (port, turnArgs) =>
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
readPreviewTokenAsync: async device => {
const previewToken = await readServeSimPreviewTokenAsync(device);
if (!previewToken) {
// A serve-sim that does not know --require-token fails earlier, in the readiness check, so
// reaching here means it started and left no token in its state file.
throw new SystemError(
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
'This usually means the state file was not written as expected; retry the session, and ' +
'report it if it repeats.'
);
}
return previewToken;
},
});
}
export async function startExpoDeviceHubWithTunnelAsync(
ctx: CustomBuildContext,
{
runtimePlatform,
baseDomain,
env,
logger,
timeoutMs,
packageVersion,
}: {
runtimePlatform: BuildRuntimePlatform;
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
@@ -851,6 +913,9 @@ export async function startExpoDeviceHubWithTunnelAsync(
packageVersion?: string;
}
): Promise<DeviceWebPreviewHandle> {
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
}
return await startWebPreviewWithTunnelAsync(ctx, {
baseDomain,
env,
@@ -880,7 +945,7 @@ export async function startDeviceWebPreviewWithTunnelAsync(
case BuildRuntimePlatform.DARWIN:
return await startServeSimWithTunnelAsync(ctx, options);
case BuildRuntimePlatform.LINUX:
return await startExpoDeviceHubWithTunnelAsync(ctx, options);
return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform });
}
}
@@ -13,9 +13,9 @@ const POLL_INTERVAL_MS = 2_000;
const MAX_CONSECUTIVE_STREAM_FAILURES_PER_DEVICE = 10;
const END_STREAM_TIMEOUT_MS = 2_000;
// serve-sim's own per-device registry while serving: `${tmpdir}/serve-sim/server-<udid>.json`.
const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim');
export const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim');
type ServeSimServer = { udid: string; url: string };
type ServeSimServer = { udid: string; url: string; token?: string };
type ServeSimMetricsSession = {
logger: bunyan;
@@ -130,6 +130,7 @@ async function pollServeSimMetricsAsync(session: ServeSimMetricsSession): Promis
logger.info(`Collecting serve-sim metrics for ${server.udid}.`);
const donePromise = streamServeSimMetricsToFileAsync({
serveSimUrl: server.url,
serveSimToken: server.token,
filePath,
signal: streamSignal,
logger,
@@ -172,9 +173,14 @@ export async function readServeSimServersAsync(stateDir: string): Promise<ServeS
const state = JSON.parse(await readFile(path.join(stateDir, entry), 'utf-8')) as {
device?: unknown;
url?: unknown;
token?: unknown;
};
if (typeof state.device === 'string' && typeof state.url === 'string') {
servers.push({ udid: state.device, url: state.url });
servers.push({
udid: state.device,
url: state.url,
...(typeof state.token === 'string' ? { token: state.token } : {}),
});
}
} catch {
continue;
@@ -185,11 +191,13 @@ export async function readServeSimServersAsync(stateDir: string): Promise<ServeS
export async function streamServeSimMetricsToFileAsync({
serveSimUrl,
serveSimToken,
filePath,
signal,
logger,
}: {
serveSimUrl: string;
serveSimToken?: string;
filePath: string;
signal: AbortSignal;
logger: bunyan;
@@ -201,7 +209,10 @@ export async function streamServeSimMetricsToFileAsync({
let receivedData = false;
let metadata: Record<string, unknown> | undefined;
try {
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), { signal });
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), {
signal,
...(serveSimToken ? { headers: { Authorization: `Bearer ${serveSimToken}` } } : {}),
});
if (!response.ok || !response.body) {
logger.warn(`serve-sim /metrics responded ${response.status} for ${serveSimUrl}.`);
return { receivedData, metadata };
@@ -99,12 +99,18 @@ export namespace AndroidEmulatorUtils {
deviceName,
systemImagePackage,
deviceIdentifier,
lcdWidth,
lcdHeight,
lcdDensity,
env,
logger,
}: {
deviceName: AndroidVirtualDeviceName;
systemImagePackage: string;
deviceIdentifier: AndroidDeviceName | null;
lcdWidth: number | null;
lcdHeight: number | null;
lcdDensity: number | null;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
@@ -176,6 +182,13 @@ export namespace AndroidEmulatorUtils {
}
}
if (lcdWidth !== null && lcdHeight !== null && lcdDensity !== null) {
logger.info(
`Setting screen resolution to ${lcdWidth}x${lcdHeight} and density to ${lcdDensity} ppi.`
);
configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${lcdHeight}\nhw.lcd.width=${lcdWidth}\nhw.lcd.density=${lcdDensity}\n`;
}
const shouldAdjustHeapSize =
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== 'false' &&
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== '0';
@@ -71,6 +71,9 @@ describe('AndroidEmulatorUtils', () => {
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
lcdWidth: null,
lcdHeight: null,
lcdDensity: null,
env: process.env,
logger: createMockLogger({ logToConsole: true }),
});
@@ -95,6 +98,9 @@ describe('AndroidEmulatorUtils', () => {
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
lcdWidth: null,
lcdHeight: null,
lcdDensity: null,
env: process.env,
logger: createMockLogger({ logToConsole: true }),
});
@@ -158,6 +164,9 @@ describe('AndroidEmulatorUtils', () => {
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
lcdWidth: null,
lcdHeight: null,
lcdDensity: null,
env: process.env,
logger: createMockLogger({ logToConsole: true }),
});
@@ -209,6 +218,9 @@ describe('AndroidEmulatorUtils', () => {
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
lcdWidth: null,
lcdHeight: null,
lcdDensity: null,
env: envForAttempt,
logger: createMockLogger({ logToConsole: true }),
});
@@ -290,6 +302,9 @@ describe('AndroidEmulatorUtils', () => {
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
lcdWidth: null,
lcdHeight: null,
lcdDensity: null,
env: process.env,
logger,
});
@@ -361,6 +376,9 @@ describe('AndroidEmulatorUtils', () => {
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
lcdWidth: null,
lcdHeight: null,
lcdDensity: null,
env: process.env,
logger: createMockLogger({ logToConsole: true }),
});
@@ -8,7 +8,11 @@ import path from 'node:path';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { AndroidEmulatorUtils, AndroidVirtualDeviceName } from '../AndroidEmulatorUtils';
import {
AndroidDeviceName,
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
jest.mock('@expo/turtle-spawn', () => ({
@@ -48,6 +52,120 @@ describe('AndroidEmulatorUtils', () => {
);
});
describe(AndroidEmulatorUtils.createAsync, () => {
it('applies an explicit LCD configuration after screen scaling', async () => {
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
await fs.promises.mkdir(avdDirectory, { recursive: true });
await fs.promises.writeFile(
`${avdDirectory}/config.ini`,
[
'hw.ramSize=1536',
'hw.lcd.height=2424',
'hw.lcd.width=1080',
'hw.lcd.density=420',
'skin.path=pixel_9',
'showDeviceFrame=yes',
].join('\n')
);
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
const write = jest.fn();
const end = jest.fn();
avdManagerPromise.child = { stdin: { write, end } };
mockedSpawn.mockReturnValue(avdManagerPromise);
const logger = createMockLogger();
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: 'system-images;android-35;default;x86_64',
deviceIdentifier: 'medium_phone' as AndroidDeviceName,
lcdWidth: 720,
lcdHeight: 1600,
lcdDensity: 262,
env: {
HOME: '/home/expo',
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
},
logger,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'avdmanager',
[
'create',
'avd',
'--name',
deviceName,
'--package',
'system-images;android-35;default;x86_64',
'--force',
'--device',
'medium_phone',
],
{ env: expect.any(Object), stdio: 'pipe' }
);
expect(write).toHaveBeenCalledWith('no');
expect(end).toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
'Setting scaled screen resolution: hw.lcd.height to 1270 and hw.lcd.width to 566.'
);
expect(logger.info).toHaveBeenCalledWith(
'Setting screen resolution to 720x1600 and density to 262 ppi.'
);
await expect(fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8')).resolves.toBe(
[
'hw.ramSize=1536',
'hw.lcd.height=2424',
'hw.lcd.width=1080',
'hw.lcd.density=420',
'skin.path=pixel_9',
'showDeviceFrame=yes',
'hw.ramSize=2048',
'',
'hw.lcd.density=220',
'',
'hw.lcd.height=1270',
'hw.lcd.width=566',
'',
'hw.lcd.height=1600',
'hw.lcd.width=720',
'hw.lcd.density=262',
'',
].join('\n')
);
});
it('does not apply an LCD configuration when it is omitted', async () => {
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
await fs.promises.mkdir(avdDirectory, { recursive: true });
await fs.promises.writeFile(`${avdDirectory}/config.ini`, initialConfig);
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
avdManagerPromise.child = { stdin: { write: jest.fn(), end: jest.fn() } };
mockedSpawn.mockReturnValue(avdManagerPromise);
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
lcdWidth: null,
lcdHeight: null,
lcdDensity: null,
env: {
HOME: '/home/expo',
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
},
logger: createMockLogger(),
});
const config = await fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8');
expect(config).toContain(initialConfig);
expect(config).not.toContain('hw.lcd.');
});
});
describe(AndroidEmulatorUtils.startAsync, () => {
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
@@ -0,0 +1,51 @@
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
import { withLogPhaseAsync } from '../logPhase';
describe(withLogPhaseAsync.name, () => {
const phaseLogger = { info: jest.fn() };
const logger = { child: jest.fn(() => phaseLogger) };
beforeEach(() => {
jest.clearAllMocks();
});
it('returns the callback result and ends the phase successfully', async () => {
const result = await withLogPhaseAsync(logger as any, 'Phase name', async callbackLogger => {
expect(callbackLogger).toBe(phaseLogger);
return 'result';
});
expect(result).toBe('result');
expect(logger.child).toHaveBeenCalledWith({
phase: BuildPhase.CUSTOM,
buildStepId: expect.stringMatching(/^step-\d{3,}$/),
buildStepDisplayName: 'Phase name',
});
expect(phaseLogger.info).toHaveBeenNthCalledWith(
1,
{ marker: LogMarker.START_PHASE },
'Start phase: Phase name'
);
expect(phaseLogger.info).toHaveBeenNthCalledWith(
2,
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.SUCCESS },
'End phase: Phase name'
);
});
it('ends the phase with failure and rethrows the error', async () => {
const error = new Error('failed');
await expect(
withLogPhaseAsync(logger as any, 'Phase name', async () => {
throw error;
})
).rejects.toBe(error);
expect(phaseLogger.info).toHaveBeenLastCalledWith(
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.FAIL },
'End phase: Phase name'
);
});
});
@@ -0,0 +1,30 @@
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { BuildStep } from '@expo/steps';
export async function withLogPhaseAsync<T>(
logger: bunyan,
name: string,
fn: (logger: bunyan) => Promise<T>
): Promise<T> {
const phaseLogger = logger.child({
phase: BuildPhase.CUSTOM,
buildStepId: BuildStep.getNewId(),
buildStepDisplayName: name,
});
phaseLogger.info({ marker: LogMarker.START_PHASE }, `Start phase: ${name}`);
try {
const result = await fn(phaseLogger);
phaseLogger.info(
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.SUCCESS },
`End phase: ${name}`
);
return result;
} catch (error) {
phaseLogger.info(
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.FAIL },
`End phase: ${name}`
);
throw error;
}
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "23.1.0",
"version": "24.2.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "23.1.0"
"@expo/steps": "24.2.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "23.0.0",
"version": "24.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "23.0.0",
"version": "24.0.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "23.1.0",
"version": "24.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "23.1.0",
"@expo/logger": "24.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "23.1.0",
"@expo/turtle-spawn": "24.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -240,7 +240,6 @@ describe('Android.JobSchema', () => {
username: 'turtle-tutorial',
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'http://localhost:3000',
gitRef: 'master',
gitCommitHash: '1b57db5b1cd12638aba0d12da71a2d691416700d',
},
@@ -1,4 +1,8 @@
import {
ArchiveSource,
ArchiveSourceSchema,
ArchiveSourceSchemaZ,
ArchiveSourceType,
EasCliVersionsFetchTimeoutError,
EnvSchema,
SshSettingsZ,
@@ -675,3 +679,36 @@ describe('fetchEasCliVersionsAsync', () => {
}
});
});
describe('Git archive sources', () => {
const source: ArchiveSource = {
type: ArchiveSourceType.GIT,
gitRef: null,
gitCommitHash: '1234567890',
};
it('accepts Git job sources without a repository URL', () => {
expect(ArchiveSourceSchema.validate(source)).toMatchObject({ value: source });
expect(ArchiveSourceSchema.validate(source).error).toBeUndefined();
expect(ArchiveSourceSchemaZ.parse(source)).toEqual(source);
});
it('strips the legacy repository URL with the job validation options', () => {
const archive = {
...source,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
};
const result = ArchiveSourceSchema.validate(archive, { stripUnknown: true });
expect(result.error).toBeUndefined();
expect(result.value).toEqual(source);
expect(ArchiveSourceSchemaZ.parse(archive)).toEqual(source);
});
it('still requires the commit and ref', () => {
for (const field of ['gitCommitHash', 'gitRef'] as const) {
const archive = { ...source, [field]: undefined };
expect(ArchiveSourceSchema.validate(archive).error).toBeDefined();
expect(ArchiveSourceSchemaZ.safeParse(archive).success).toBe(false);
}
});
});
@@ -9,7 +9,6 @@ describe('Generic.JobZ', () => {
const job: Generic.Job = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
@@ -51,7 +50,6 @@ describe('Generic.JobZ', () => {
const job: Generic.Job = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
@@ -96,7 +94,6 @@ describe('Generic.JobZ', () => {
const job: Generic.Job = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
@@ -131,7 +128,6 @@ describe('Generic.JobZ', () => {
const job: Generic.Job = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
@@ -183,7 +179,6 @@ describe('Generic.JobZ', () => {
const job = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
@@ -232,7 +227,6 @@ describe('Generic.JobZ', () => {
const job: Omit<Generic.Job, 'customBuildConfig' | 'steps'> = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
-7
View File
@@ -45,11 +45,6 @@ export type ArchiveSource =
| { type: ArchiveSourceType.PATH; path: string }
| {
type: ArchiveSourceType.GIT;
/**
* Url that can be used to clone repository.
* It should contain embedded credentials for private registries.
*/
repositoryUrl: string;
/** A Git ref - points to a branch head, tag head or a branch name. */
gitRef: string | null;
/**
@@ -79,7 +74,6 @@ export const ArchiveSourceSchema = Joi.object<ArchiveSource>({
.when(Joi.object({ type: ArchiveSourceType.GIT }).unknown(), {
then: Joi.object({
type: Joi.string().valid(ArchiveSourceType.GIT).required(),
repositoryUrl: Joi.string().required(),
gitCommitHash: Joi.string().required(),
gitRef: Joi.string().allow(null).required(),
}),
@@ -94,7 +88,6 @@ export const ArchiveSourceSchema = Joi.object<ArchiveSource>({
export const ArchiveSourceSchemaZ = z.discriminatedUnion('type', [
z.object({
type: z.literal(ArchiveSourceType.GIT),
repositoryUrl: z.string().url(),
gitRef: z.string().nullable(),
gitCommitHash: z.string(),
}),
+742 -331
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -13,6 +13,7 @@ const config: CodegenConfig = {
'src/graphql/generated.ts': {
plugins: ['typescript', 'typescript-operations'],
config: {
addUnderscoreToArgsType: true,
dedupeOperationSuffix: true,
},
hooks: {
+8314 -1741
View File
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "23.2.0",
"version": "24.2.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -44,15 +44,15 @@
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"dependencies": {
"@expo/apple-utils": "2.1.22",
"@expo/apple-utils": "2.2.0",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "23.1.0",
"@expo/eas-json": "23.1.0",
"@expo/eas-build-job": "24.2.0",
"@expo/eas-json": "24.2.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "23.1.0",
"@expo/logger": "24.0.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "23.1.0",
"@expo/steps": "24.2.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -1,4 +1,4 @@
import { getProjectDashboardUrl, getProjectPageUrl } from '../url';
import { getProjectDashboardUrl, getProjectPageUrl, getSubmissionUrl } from '../url';
describe(getProjectPageUrl, () => {
it('builds the project dashboard URL when no page is provided', () => {
@@ -33,3 +33,11 @@ describe(getProjectDashboardUrl, () => {
);
});
});
describe(getSubmissionUrl, () => {
it('builds an encoded submission URL', () => {
expect(getSubmissionUrl('my org', 'my app', 'submission/id')).toBe(
'https://expo.dev/accounts/my%20org/projects/my%20app/submissions/submission%2Fid'
);
});
});
+13
View File
@@ -56,6 +56,19 @@ export function getUpdateGroupUrl(
).toString();
}
export function getSubmissionUrl(
accountName: string,
projectName: string,
submissionId: string
): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}/submissions/${encodeURIComponent(submissionId)}`,
getExpoWebsiteBaseUrl()
).toString();
}
export function getWorkflowRunUrl(
accountName: string,
projectName: string,
@@ -133,6 +133,7 @@ export const testChannelObject: UpdateChannelObject = {
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
updateBranches: [testUpdateBranch1, testUpdateBranch2],
isPaused: false,
isProtected: false,
__typename: 'UpdateChannel',
};
@@ -141,6 +142,7 @@ export const testBasicChannelInfo: UpdateChannelBasicInfoFragment = {
name: 'production',
branchMapping:
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
isProtected: false,
__typename: 'UpdateChannel',
};
@@ -149,5 +151,6 @@ export const testBasicChannelInfo2: UpdateChannelBasicInfoFragment = {
name: 'staging',
branchMapping:
'{"data":[{"branchId":"d7d68e32-d9c9-4a8d-8d1b-21e53100a5e8","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"f9f708c2-0c91-4360-b2a4-0b61834aef4a","branchMappingLogic":"true"}],"version":0}',
isProtected: false,
__typename: 'UpdateChannel',
};
@@ -0,0 +1,74 @@
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { protectUpdateChannelAsync, unprotectUpdateChannelAsync } from '../protection';
function makeGraphqlClient(data: unknown): {
graphqlClient: ExpoGraphqlClient;
mutation: jest.Mock;
} {
const mutation = jest.fn().mockReturnValue({
toPromise: jest.fn().mockResolvedValue({ data }),
});
return { graphqlClient: { mutation } as unknown as ExpoGraphqlClient, mutation };
}
describe(protectUpdateChannelAsync.name, () => {
it('protects a channel by ID and returns the server state', async () => {
const channel = {
id: 'channel-id',
name: 'production',
branchMapping: '{"version":0,"data":[]}',
isProtected: true,
};
const { graphqlClient, mutation } = makeGraphqlClient({
updateChannel: { protectUpdateChannel: channel },
});
await expect(
protectUpdateChannelAsync(graphqlClient, { channelId: 'channel-id' })
).resolves.toEqual(channel);
expect(mutation.mock.calls[0][0].loc.source.body).toContain('protectUpdateChannel');
expect(mutation.mock.calls[0][1]).toEqual({ channelId: 'channel-id' });
});
it('throws a clear error when the channel is not returned', async () => {
const { graphqlClient } = makeGraphqlClient({
updateChannel: { protectUpdateChannel: null },
});
await expect(
protectUpdateChannelAsync(graphqlClient, { channelId: 'missing-channel-id' })
).rejects.toThrow('Could not find a channel with id: missing-channel-id');
});
});
describe(unprotectUpdateChannelAsync.name, () => {
it('unprotects a channel by ID and returns the server state', async () => {
const channel = {
id: 'channel-id',
name: 'production',
branchMapping: '{"version":0,"data":[]}',
isProtected: false,
};
const { graphqlClient, mutation } = makeGraphqlClient({
updateChannel: { unprotectUpdateChannel: channel },
});
await expect(
unprotectUpdateChannelAsync(graphqlClient, { channelId: 'channel-id' })
).resolves.toEqual(channel);
expect(mutation.mock.calls[0][0].loc.source.body).toContain('unprotectUpdateChannel');
expect(mutation.mock.calls[0][1]).toEqual({ channelId: 'channel-id' });
});
it('throws a clear error when the channel is not returned', async () => {
const { graphqlClient } = makeGraphqlClient({
updateChannel: { unprotectUpdateChannel: null },
});
await expect(
unprotectUpdateChannelAsync(graphqlClient, { channelId: 'missing-channel-id' })
).rejects.toThrow('Could not find a channel with id: missing-channel-id');
});
});
@@ -0,0 +1,26 @@
import { renderChannelHeaderContent } from '../queries';
import Log from '../../log';
jest.mock('../../log');
describe(renderChannelHeaderContent.name, () => {
beforeEach(() => {
jest.clearAllMocks();
});
it.each([
[true, 'Protected'],
[false, 'Unprotected'],
])('renders protection state when isProtected is %s', (isProtected, expected) => {
renderChannelHeaderContent({
channelName: 'production',
channelId: 'channel-id',
isPaused: false,
isProtected,
});
const output = jest.mocked(Log.log).mock.calls.flat().join('\n');
expect(output).toContain('Protection');
expect(output).toContain(expected);
});
});
@@ -1,7 +1,7 @@
import chalk from 'chalk';
import { ChannelRuntimeInsights } from '../../graphql/queries/ChannelInsightsQuery';
import { formatTimespan } from '../../insights/formatTimespan';
import { formatTimespan, toTimespanJson } from '../../insights/formatTimespan';
import formatFields from '../../utils/formatFields';
import renderTextTable from '../../utils/renderTextTable';
@@ -90,11 +90,7 @@ export function buildChannelInsightsJson(summary: ChannelInsightsSummary): objec
return {
channel: summary.channelName,
runtimeVersion: summary.runtimeVersion,
timespan: {
start: summary.startTime,
end: summary.endTime,
...(summary.daysBack !== undefined ? { daysBack: summary.daysBack } : {}),
},
timespan: toTimespanJson(summary),
embeddedUpdateTotalUniqueUsers: summary.embeddedUpdateTotalUniqueUsers,
otaTotalUniqueUsers: summary.otaTotalUniqueUsers,
mostPopularUpdates: summary.mostPopularUpdates,
@@ -0,0 +1,71 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../graphql/client';
import {
ProtectUpdateChannelMutation,
ProtectUpdateChannelMutationVariables,
UnprotectUpdateChannelMutation,
UnprotectUpdateChannelMutationVariables,
UpdateChannelBasicInfoFragment,
} from '../graphql/generated';
import { UpdateChannelBasicInfoFragmentNode } from '../graphql/types/UpdateChannelBasicInfo';
export async function protectUpdateChannelAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId }: ProtectUpdateChannelMutationVariables
): Promise<UpdateChannelBasicInfoFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<ProtectUpdateChannelMutation, ProtectUpdateChannelMutationVariables>(
gql`
mutation ProtectUpdateChannel($channelId: ID!) {
updateChannel {
protectUpdateChannel(channelId: $channelId) {
id
...UpdateChannelBasicInfoFragment
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ channelId }
)
.toPromise()
);
const channel = data.updateChannel.protectUpdateChannel;
if (!channel) {
throw new Error(`Could not find a channel with id: ${channelId}`);
}
return channel;
}
export async function unprotectUpdateChannelAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId }: UnprotectUpdateChannelMutationVariables
): Promise<UpdateChannelBasicInfoFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UnprotectUpdateChannelMutation, UnprotectUpdateChannelMutationVariables>(
gql`
mutation UnprotectUpdateChannel($channelId: ID!) {
updateChannel {
unprotectUpdateChannel(channelId: $channelId) {
id
...UpdateChannelBasicInfoFragment
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ channelId }
)
.toPromise()
);
const channel = data.updateChannel.unprotectUpdateChannel;
if (!channel) {
throw new Error(`Could not find a channel with id: ${channelId}`);
}
return channel;
}
+6 -1
View File
@@ -125,6 +125,7 @@ export async function listAndRenderBranchesAndUpdatesOnChannelAsync(
channelName: channel.name,
channelId: channel.id,
isPaused: channel.isPaused,
isProtected: channel.isProtected,
});
if (paginatedQueryOptions.nonInteractive) {
@@ -186,6 +187,7 @@ function renderPageOfChannels(
channelName: channel.name,
channelId: channel.id,
isPaused: channel.isPaused,
isProtected: channel.isProtected,
});
Log.addNewLineIfNone();
logChannelDetails(channel);
@@ -212,14 +214,16 @@ function renderPageOfBranchesOnChannel(
}
}
function renderChannelHeaderContent({
export function renderChannelHeaderContent({
channelName,
channelId,
isPaused,
isProtected,
}: {
channelName: string;
channelId: string;
isPaused: boolean;
isProtected: boolean;
}): void {
Log.addNewLineIfNone();
Log.log(chalk.bold('Channel:'));
@@ -228,6 +232,7 @@ function renderChannelHeaderContent({
{ label: 'Name', value: channelName },
{ label: 'ID', value: channelId },
{ label: 'Status', value: isPaused ? 'Paused' : 'Active' },
{ label: 'Protection', value: isProtected ? 'Protected' : 'Unprotected' },
])
);
Log.addNewLineIfNone();
@@ -1,241 +0,0 @@
import nock from 'nock';
import { makeUserMessage, streamChatResponseAsync } from '../chatClient';
const mockSpinners: { isSpinning: boolean; text: string }[] = [];
jest.mock('../../log');
jest.mock('../../ora', () => ({
ora: () => {
const spinner = {
isSpinning: true,
text: '',
start: () => spinner,
stop: () => {
spinner.isSpinning = false;
return spinner;
},
};
mockSpinners.push(spinner);
return spinner;
},
}));
const WEBSITE_ORIGIN = 'https://expo.dev';
function sseBody(frames: object[]): string {
return [...frames.map(frame => `data: ${JSON.stringify(frame)}`), 'data: [DONE]', ''].join(
'\n\n'
);
}
describe(streamChatResponseAsync, () => {
let writeSpy: jest.SpyInstance;
beforeEach(() => {
nock.cleanAll();
mockSpinners.length = 0;
writeSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
});
afterEach(() => {
writeSpy.mockRestore();
});
it('streams text deltas to stdout and returns the full text', async () => {
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(
200,
sseBody([
{ type: 'start' },
{ type: 'tool-input-available', toolCallId: 't1', toolName: 'get_latest_builds' },
{ type: 'text-start', id: '0' },
{ type: 'text-delta', id: '0', delta: 'Your latest ' },
{ type: 'text-delta', id: '0', delta: 'build passed.' },
{ type: 'finish' },
]),
{ 'content-type': 'text/event-stream' }
);
const result = await streamChatResponseAsync({
messages: [makeUserMessage('how are my builds?')],
accountName: 'my-account',
sessionSecret: '{"id":"session-id","version":"1"}',
stream: true,
});
const written = writeSpy.mock.calls.map(call => call[0]).join('');
expect(written).toContain('Your latest ');
expect(written).toContain('build passed.');
expect(result.text).toBe('Your latest build passed.');
});
it('neutralizes terminal control characters in streamed text deltas', async () => {
const delta = 'Safe \u001b[2Jtext\u009b31m\u0007.';
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(200, sseBody([{ type: 'text-delta', id: '0', delta }]), {
'content-type': 'text/event-stream',
});
const result = await streamChatResponseAsync({
messages: [makeUserMessage('show the build log')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: true,
});
const written = writeSpy.mock.calls.map(call => call[0]).join('');
expect(written).toContain('Safe [2Jtext31m.');
expect(written).not.toContain('\u001b[2J');
expect(written).not.toContain('\u009b31m');
expect(written).not.toContain('\u0007');
expect(result.text).toBe(delta);
});
it('keeps the spinner active until a full line is ready instead of clearing it on the first delta', async () => {
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(
200,
sseBody([
{ type: 'text-start', id: '0' },
{ type: 'text-delta', id: '0', delta: 'Looking good' },
{ type: 'tool-input-available', toolCallId: 't1', toolName: 'get_latest_builds' },
{ type: 'text-delta', id: '0', delta: ' so far.' },
{ type: 'finish' },
]),
{ 'content-type': 'text/event-stream' }
);
const result = await streamChatResponseAsync({
messages: [makeUserMessage('how are my builds?')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: true,
});
const written = writeSpy.mock.calls.map(call => call[0]).join('');
// No newline arrives until the end, so nothing is flushed mid-stream: the tool activity
// is shown on the still-running spinner rather than being printed inline.
expect(mockSpinners[0].text).toContain('Looking up builds');
expect(written).not.toContain('Looking up builds');
// The buffered text is rendered in one go once the response completes.
expect(written).toContain('Looking good so far.');
expect(result.text).toBe('Looking good so far.');
});
it('captures tool calls with inputs and outputs without writing to stdout when not streaming', async () => {
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(
200,
sseBody([
{ type: 'start-step' },
{
type: 'tool-input-available',
toolCallId: 't1',
toolName: 'get_latest_builds',
input: { limit: 1 },
},
{ type: 'tool-output-available', toolCallId: 't1', output: { builds: ['b1'] } },
{ type: 'finish-step' },
{ type: 'start-step' },
{ type: 'text-delta', delta: 'Done.' },
{ type: 'finish-step' },
{ type: 'finish' },
])
);
const result = await streamChatResponseAsync({
messages: [makeUserMessage('builds?')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
});
expect(writeSpy).not.toHaveBeenCalled();
expect(result.text).toBe('Done.');
expect(result.toolCalls).toEqual([
{
toolName: 'get_latest_builds',
input: { limit: 1 },
output: { builds: ['b1'] },
errorText: undefined,
},
]);
expect(result.assistantMessage).toEqual(
expect.objectContaining({
role: 'assistant',
parts: [
{ type: 'step-start' },
{
type: 'tool-get_latest_builds',
toolCallId: 't1',
state: 'output-available',
input: { limit: 1 },
output: { builds: ['b1'] },
},
{ type: 'step-start' },
{ type: 'text', text: 'Done.' },
],
})
);
});
it('sends the session secret as a cookie and the account as a header', async () => {
let capturedHeaders: Record<string, string | string[]> = {};
let capturedBody: any;
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(function (_uri, body) {
capturedHeaders = this.req.headers;
capturedBody = body;
return [200, sseBody([{ type: 'text-delta', delta: 'hi' }])];
});
await streamChatResponseAsync({
messages: [makeUserMessage('hello')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
});
const cookie = [capturedHeaders.cookie].flat().join('; ');
expect(cookie).toContain('io.expo.auth.sessionSecret=');
expect(cookie).toContain(encodeURIComponent('{"id":"abc","version":"1"}'));
expect([capturedHeaders['x-account-name']].flat()[0]).toBe('my-account');
expect(capturedBody.messages[0].role).toBe('system');
expect(capturedBody.messages[0].parts[0].text).toContain('https://expo.dev');
expect(capturedBody.messages[1]).toEqual(
expect.objectContaining({ role: 'user', parts: [{ type: 'text', text: 'hello' }] })
);
});
it('maps a 429 response to a usage-limit error', async () => {
nock(WEBSITE_ORIGIN).post('/api/chat').reply(429, 'Chat usage limit reached');
await expect(
streamChatResponseAsync({
messages: [makeUserMessage('hello')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
})
).rejects.toThrow(/usage limit/);
});
it('maps a 403 response to a not-enabled error', async () => {
nock(WEBSITE_ORIGIN).post('/api/chat').reply(403, 'Forbidden');
await expect(
streamChatResponseAsync({
messages: [makeUserMessage('hello')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
})
).rejects.toThrow(/not enabled/);
});
});
@@ -1,77 +0,0 @@
import chalk from 'chalk';
import {
createMarkdownRenderState,
renderInlineMarkdown,
renderMarkdownLine,
wrapToWidth,
} from '../renderMarkdown';
describe(renderInlineMarkdown, () => {
it('renders bold with chalk.bold', () => {
expect(renderInlineMarkdown('a **bold** word')).toBe(`a ${chalk.bold('bold')} word`);
expect(renderInlineMarkdown('a __bold__ word')).toBe(`a ${chalk.bold('bold')} word`);
});
it('renders inline code with chalk.cyan', () => {
expect(renderInlineMarkdown('run `eas build` now')).toBe(`run ${chalk.cyan('eas build')} now`);
});
it('leaves underscores inside a word untouched', () => {
expect(renderInlineMarkdown('my_project_name')).toBe('my_project_name');
});
it('renders links as underlined label with dimmed url', () => {
expect(renderInlineMarkdown('see [build](https://expo.dev/b/1)')).toBe(
`see ${chalk.underline('build')} ${chalk.dim('(https://expo.dev/b/1)')}`
);
});
});
describe(renderMarkdownLine, () => {
it('renders bullet lines with a bullet glyph', () => {
const state = createMarkdownRenderState();
expect(renderMarkdownLine('- first', state)).toBe(`${chalk.dim('•')} first`);
});
it('drops code fences and dims code block contents', () => {
const state = createMarkdownRenderState();
expect(renderMarkdownLine('```ts', state)).toBeNull();
expect(state.inCodeBlock).toBe(true);
expect(renderMarkdownLine('const x = 1;', state)).toBe(chalk.gray('const x = 1;'));
expect(renderMarkdownLine('```', state)).toBeNull();
expect(state.inCodeBlock).toBe(false);
});
it('applies inline styling to plain lines', () => {
const state = createMarkdownRenderState();
expect(renderMarkdownLine('the **latest** build', state)).toBe(
`the ${chalk.bold('latest')} build`
);
});
});
describe(wrapToWidth, () => {
it('word-wraps to the given width', () => {
const segments = wrapToWidth('one two three four five', 12);
expect(segments.length).toBeGreaterThan(1);
for (const segment of segments) {
expect(segment.length).toBeLessThanOrEqual(12);
}
expect(segments.join(' ').replace(/\s+/g, ' ').trim()).toBe('one two three four five');
});
it('hard-breaks long unbreakable tokens like URLs', () => {
const url = 'https://expo.dev/accounts/acme/projects/mobile/builds/cb7c9bdd-56e6-42b8';
const segments = wrapToWidth(url, 20);
expect(segments.length).toBeGreaterThan(1);
for (const segment of segments) {
expect(segment.length).toBeLessThanOrEqual(20);
}
expect(segments.join('')).toBe(url);
});
it('returns the text unchanged when the width is too small to wrap into', () => {
expect(wrapToWidth('anything at all here', 4)).toEqual(['anything at all here']);
});
});
@@ -1,51 +0,0 @@
import { PassThrough } from 'node:stream';
import { createChatReplInput } from '../replInput';
describe(createChatReplInput, () => {
it('resolves the typed line', async () => {
const input = new PassThrough();
const output = new PassThrough();
const replInput = createChatReplInput({ input, output, terminal: false });
const pending = replInput.askAsync('> ');
input.write('how are my builds?\n');
expect(await pending).toBe('how are my builds?');
replInput.close();
});
it('reads input even when the stream was left paused (e.g. by a spinner)', async () => {
const input = new PassThrough();
const output = new PassThrough();
const replInput = createChatReplInput({ input, output, terminal: false });
input.pause();
const pending = replInput.askAsync('> ');
input.write('hello\n');
expect(await pending).toBe('hello');
replInput.close();
});
it('resolves null when the input stream ends', async () => {
const input = new PassThrough();
const output = new PassThrough();
const replInput = createChatReplInput({ input, output, terminal: false });
const pending = replInput.askAsync('> ');
input.end();
expect(await pending).toBeNull();
replInput.close();
});
it('resolves null immediately once closed', async () => {
const input = new PassThrough();
const output = new PassThrough();
const replInput = createChatReplInput({ input, output, terminal: false });
replInput.close();
expect(await replInput.askAsync('> ')).toBeNull();
});
});
-449
View File
@@ -1,449 +0,0 @@
import { StringDecoder } from 'node:string_decoder';
import chalk from 'chalk';
import { v4 as uuidv4 } from 'uuid';
import { getExpoWebsiteBaseUrl } from '../api';
import fetch, { RequestError } from '../fetch';
import Log from '../log';
import { ora } from '../ora';
import {
MarkdownRenderState,
createMarkdownRenderState,
renderMarkdownLine,
wrapToWidth,
} from './renderMarkdown';
const CHAT_SYSTEM_GUIDANCE = [
'You are being used through the EAS CLI in a terminal, not the web dashboard.',
'The user cannot see the interactive tables the dashboard renders from tool results, so include the important details directly in your text answer instead of assuming a table is shown.',
'Whenever you reference a specific artifact (a build, update, submission, deployment, workflow run, channel, or branch), include its direct https://expo.dev link so the user can open it.',
'Use only simple markdown: bold, inline code, and bullet or numbered lists.',
].join(' ');
function getWebsiteAuthCookieName(): string {
if (process.env.EXPO_STAGING) {
return 'staging.expo.auth.sessionSecret';
} else if (process.env.EXPO_LOCAL) {
return 'local.expo.auth.sessionSecret';
} else {
return 'io.expo.auth.sessionSecret';
}
}
const TOOL_LABELS: Record<string, string> = {
get_projects: 'Looking up your projects',
get_latest_builds: 'Looking up builds',
get_build: 'Looking up a build',
get_build_logs: 'Reading build logs',
get_activity: 'Looking up recent activity',
get_updates: 'Looking up updates',
get_workflow_runs: 'Looking up workflow runs',
get_workflow_run: 'Looking up a workflow run',
get_workflow_run_logs: 'Reading workflow logs',
get_submissions: 'Looking up submissions',
get_channels: 'Looking up channels',
get_branches: 'Looking up branches',
get_worker_deployments: 'Looking up deployments',
get_environment_variables: 'Looking up environment variables',
get_account_members: 'Looking up account members',
get_usage: 'Looking up usage',
get_usage_history: 'Looking up usage history',
get_invoices: 'Looking up invoices',
search_expo_docs: 'Searching Expo docs',
navigate: 'Finding the right page',
};
function describeTool(toolName: string): string {
return TOOL_LABELS[toolName] ?? `Using ${toolName.replace(/_/g, ' ')}`;
}
const ASSISTANT_LABEL_TEXT = 'Expo';
const ASSISTANT_LABEL = `${chalk.bold.magenta(ASSISTANT_LABEL_TEXT)}${chalk.dim(' > ')}`;
const ASSISTANT_INDENT = ' '.repeat(`${ASSISTANT_LABEL_TEXT} > `.length);
const ASSISTANT_SPINNER_PREFIX = `${chalk.bold.magenta(ASSISTANT_LABEL_TEXT)}${chalk.dim(' >')}`;
type UIMessageStreamFrame = {
type: string;
id?: string;
delta?: string;
toolCallId?: string;
toolName?: string;
input?: unknown;
output?: unknown;
errorText?: string;
};
type ChatMessagePart =
| { type: 'text'; text: string }
| { type: 'step-start' }
| {
type: `tool-${string}`;
toolCallId: string;
state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error';
input: unknown;
output?: unknown;
errorText?: string;
};
export type ChatMessage = {
id: string;
role: 'system' | 'user' | 'assistant';
parts: ChatMessagePart[];
};
function makeSystemMessage(text: string): ChatMessage {
return { id: uuidv4(), role: 'system', parts: [{ type: 'text', text }] };
}
export function makeUserMessage(text: string): ChatMessage {
return { id: uuidv4(), role: 'user', parts: [{ type: 'text', text }] };
}
function makeAssistantMessage(parts: ChatMessagePart[]): ChatMessage {
return { id: uuidv4(), role: 'assistant', parts };
}
export type ChatToolCall = {
toolName: string;
input?: unknown;
output?: unknown;
errorText?: string;
};
export type ChatResult = {
text: string;
toolCalls: ChatToolCall[];
assistantMessage: ChatMessage;
};
export async function streamChatResponseAsync({
messages,
accountName,
sessionSecret,
stream,
}: {
messages: ChatMessage[];
accountName: string | undefined;
sessionSecret: string;
stream: boolean;
}): Promise<ChatResult> {
let response;
try {
response = await fetch(`${getExpoWebsiteBaseUrl()}/api/chat`, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'text/event-stream',
cookie: `${getWebsiteAuthCookieName()}=${encodeURIComponent(sessionSecret)}`,
...(accountName ? { 'x-account-name': accountName } : {}),
},
body: JSON.stringify({ messages: [makeSystemMessage(CHAT_SYSTEM_GUIDANCE), ...messages] }),
});
} catch (error) {
throw toFriendlyChatError(error);
}
if (!response.body) {
throw new Error('The chat service returned an empty response.');
}
const spinner = stream
? ora({ text: 'Thinking…', discardStdin: false, prefixText: ASSISTANT_SPINNER_PREFIX }).start()
: undefined;
let spinnerStopped = false;
const stopSpinner = (): void => {
if (!spinner || spinnerStopped) {
return;
}
spinnerStopped = true;
spinner.stop();
if (process.stdout.isTTY) {
process.stdout.write('\r');
}
};
const toolMessagePartsById = new Map<string, Extract<ChatMessagePart, { toolCallId: string }>>();
const textMessagePartsById = new Map<string, Extract<ChatMessagePart, { type: 'text' }>>();
const assistantMessageParts: ChatMessagePart[] = [];
const announcedTools = new Set<string>();
const markdownState: MarkdownRenderState = createMarkdownRenderState();
let fullText = '';
let streamingText = false;
let errorText: string | undefined;
let buffer = '';
let displayBuffer = '';
let wroteAssistantLine = false;
let currentTextMessagePart: Extract<ChatMessagePart, { type: 'text' }> | undefined;
const getOrCreateToolMessagePart = (
toolCallId: string,
toolName: string
): Extract<ChatMessagePart, { toolCallId: string }> => {
const existing = toolMessagePartsById.get(toolCallId);
if (existing) {
return existing;
}
const part: Extract<ChatMessagePart, { toolCallId: string }> = {
type: `tool-${toolName}`,
toolCallId,
state: 'input-streaming',
input: undefined,
};
toolMessagePartsById.set(toolCallId, part);
assistantMessageParts.push(part);
return part;
};
const getOrCreateTextMessagePart = (
id: string | undefined
): Extract<ChatMessagePart, { type: 'text' }> => {
const existing = id ? textMessagePartsById.get(id) : currentTextMessagePart;
if (existing) {
return existing;
}
const part: Extract<ChatMessagePart, { type: 'text' }> = { type: 'text', text: '' };
if (id) {
textMessagePartsById.set(id, part);
}
currentTextMessagePart = part;
assistantMessageParts.push(part);
return part;
};
const writeAssistantLine = (rendered: string, withNewline: boolean): void => {
if (!streamingText) {
stopSpinner();
streamingText = true;
}
const columns = process.stdout.columns ?? 0;
const width = columns > 0 ? columns - ASSISTANT_INDENT.length : 0;
const segments = wrapToWidth(rendered, width);
segments.forEach((segment, index) => {
const prefix = wroteAssistantLine ? ASSISTANT_INDENT : ASSISTANT_LABEL;
wroteAssistantLine = true;
const isLastSegment = index === segments.length - 1;
const needsNewline = !isLastSegment || withNewline;
process.stdout.write(`${prefix}${segment}${needsNewline ? '\n' : ''}`);
});
};
const flushDisplayLines = (flushRemainder: boolean): void => {
let newlineIndex: number;
while ((newlineIndex = displayBuffer.indexOf('\n')) !== -1) {
const rawLine = displayBuffer.slice(0, newlineIndex);
displayBuffer = displayBuffer.slice(newlineIndex + 1);
const rendered = renderMarkdownLine(rawLine, markdownState);
if (rendered !== null) {
writeAssistantLine(rendered, true);
}
}
if (flushRemainder && displayBuffer.length > 0) {
const rendered = renderMarkdownLine(displayBuffer, markdownState);
displayBuffer = '';
if (rendered !== null) {
writeAssistantLine(rendered, false);
}
}
};
const announceTool = (toolName: string): void => {
if (!stream || announcedTools.has(toolName)) {
return;
}
announcedTools.add(toolName);
const label = describeTool(toolName);
if (streamingText) {
flushDisplayLines(true);
process.stdout.write(chalk.dim(`\n${ASSISTANT_INDENT}(${label})\n`));
} else if (spinner) {
spinner.text = `${label}…`;
}
};
const handleFrame = (frame: UIMessageStreamFrame): void => {
switch (frame.type) {
case 'start-step': {
assistantMessageParts.push({ type: 'step-start' });
currentTextMessagePart = undefined;
break;
}
case 'text-start': {
currentTextMessagePart = getOrCreateTextMessagePart(frame.id);
break;
}
case 'text-delta': {
const delta = typeof frame.delta === 'string' ? frame.delta : '';
if (!delta) {
return;
}
fullText += delta;
getOrCreateTextMessagePart(frame.id).text += delta;
if (stream) {
displayBuffer += delta;
flushDisplayLines(false);
}
break;
}
case 'text-end': {
currentTextMessagePart = undefined;
break;
}
case 'tool-input-start':
case 'tool-input-available': {
const { toolCallId, toolName } = frame;
if (!toolCallId || !toolName) {
return;
}
const toolPart = getOrCreateToolMessagePart(toolCallId, toolName);
if (frame.type === 'tool-input-available') {
toolPart.state = 'input-available';
toolPart.input = frame.input;
delete toolPart.output;
delete toolPart.errorText;
} else if (frame.input !== undefined) {
toolPart.input = frame.input;
}
announceTool(toolName);
break;
}
case 'tool-input-error': {
const { toolCallId, toolName } = frame;
if (!toolCallId || !toolName || typeof frame.errorText !== 'string') {
return;
}
const toolPart = getOrCreateToolMessagePart(toolCallId, toolName);
toolPart.state = 'output-error';
toolPart.input = frame.input;
delete toolPart.output;
toolPart.errorText = frame.errorText;
announceTool(toolName);
break;
}
case 'tool-output-available': {
const { toolCallId } = frame;
if (!toolCallId) {
return;
}
const toolPart = toolMessagePartsById.get(toolCallId);
if (toolPart) {
toolPart.state = 'output-available';
toolPart.output = frame.output;
delete toolPart.errorText;
}
break;
}
case 'tool-output-error': {
const { toolCallId } = frame;
if (!toolCallId || typeof frame.errorText !== 'string') {
return;
}
const toolPart = toolMessagePartsById.get(toolCallId);
if (toolPart) {
toolPart.state = 'output-error';
delete toolPart.output;
toolPart.errorText = frame.errorText;
}
break;
}
case 'error': {
if (typeof frame.errorText === 'string') {
errorText = frame.errorText;
}
break;
}
}
};
const decoder = new StringDecoder('utf8');
try {
for await (const chunk of response.body) {
buffer += decoder.write(chunk as Buffer);
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line.startsWith('data:')) {
continue;
}
const data = line.slice('data:'.length).trim();
if (!data || data === '[DONE]') {
continue;
}
let frame: UIMessageStreamFrame;
try {
frame = JSON.parse(data);
} catch {
continue;
}
handleFrame(frame);
}
}
} finally {
if (stream) {
flushDisplayLines(true);
}
stopSpinner();
}
if (errorText) {
throw new Error(errorText);
}
const toolCalls: ChatToolCall[] = assistantMessageParts
.filter((part): part is Extract<ChatMessagePart, { toolCallId: string }> =>
part.type.startsWith('tool-')
)
.map(part => ({
toolName: part.type.slice('tool-'.length),
input: part.input,
output: part.output,
errorText: part.errorText,
}));
const assistantMessage = makeAssistantMessage(
assistantMessageParts.filter(
part =>
part.type === 'text' ||
part.type === 'step-start' ||
part.state === 'output-available' ||
part.state === 'output-error'
)
);
if (stream) {
if (fullText.length > 0) {
Log.newLine();
} else {
Log.log(
chalk.dim(
toolCalls.length > 0
? 'The assistant looked up your data but did not return a text response. Try asking a more specific question.'
: 'The assistant did not return a response.'
)
);
}
}
return { text: fullText, toolCalls, assistantMessage };
}
function toFriendlyChatError(error: unknown): Error {
if (error instanceof RequestError) {
const status = error.response.status;
if (status === 401) {
return new Error('Your Expo session is not valid for chat. Run `eas login` and try again.');
}
if (status === 403) {
return new Error(
'Chat is not enabled for this account yet. It is still rolling out. If you think you should have access, reach out to Expo.'
);
}
if (status === 429) {
return new Error(
'You have reached your chat usage limit. Try again later or upgrade your plan for a larger allowance.'
);
}
return new Error(`The chat request failed (HTTP ${status}).`);
}
return error instanceof Error ? error : new Error(String(error));
}
@@ -1,43 +0,0 @@
import { getConfigFilePaths } from '@expo/config';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { findProjectDirAndVerifyProjectSetupAsync } from '../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { AppQuery } from '../graphql/queries/AppQuery';
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
export async function detectCurrentProjectAsync(
graphqlClient: ExpoGraphqlClient
): Promise<{ accountName: string; label: string } | null> {
let projectDir: string;
try {
projectDir = await findProjectDirAndVerifyProjectSetupAsync();
} catch {
return null;
}
if (!projectDir) {
return null;
}
const configPaths = getConfigFilePaths(projectDir);
if (!configPaths.staticConfigPath && !configPaths.dynamicConfigPath) {
return null;
}
let projectId: unknown;
try {
const expoConfig = await getPrivateExpoConfigAsync(projectDir);
projectId = expoConfig.extra?.eas?.projectId;
} catch {
return null;
}
if (typeof projectId !== 'string' || !projectId) {
return null;
}
try {
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
return { accountName: app.ownerAccount.name, label: app.fullName };
} catch {
return null;
}
}
@@ -1,53 +0,0 @@
import chalk from 'chalk';
import wrapAnsi from 'wrap-ansi';
export function wrapToWidth(text: string, width: number): string[] {
if (width < 10) {
return [text];
}
return wrapAnsi(text, width, { hard: true, trim: false }).split('\n');
}
export type MarkdownRenderState = { inCodeBlock: boolean };
export function createMarkdownRenderState(): MarkdownRenderState {
return { inCodeBlock: false };
}
const FENCE_REGEX = /^\s*```/;
const BULLET_REGEX = /^(\s*)[-*+]\s+(.*)$/;
const ORDERED_REGEX = /^(\s*)(\d+)\.\s+(.*)$/;
const TERMINAL_CONTROL_CHARACTERS_REGEX = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
export function renderMarkdownLine(line: string, state: MarkdownRenderState): string | null {
const sanitizedLine = line.replace(TERMINAL_CONTROL_CHARACTERS_REGEX, '');
if (FENCE_REGEX.test(sanitizedLine)) {
state.inCodeBlock = !state.inCodeBlock;
return null;
}
if (state.inCodeBlock) {
return chalk.gray(sanitizedLine);
}
const bullet = sanitizedLine.match(BULLET_REGEX);
if (bullet) {
return `${bullet[1]}${chalk.dim('•')} ${renderInlineMarkdown(bullet[2])}`;
}
const ordered = sanitizedLine.match(ORDERED_REGEX);
if (ordered) {
return `${ordered[1]}${chalk.dim(`${ordered[2]}.`)} ${renderInlineMarkdown(ordered[3])}`;
}
return renderInlineMarkdown(sanitizedLine);
}
export function renderInlineMarkdown(text: string): string {
return text
.replace(/`([^`]+)`/g, (_match, code) => chalk.cyan(code))
.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(_match, label, url) => `${chalk.underline(label)} ${chalk.dim(`(${url})`)}`
)
.replace(/(\*\*|__)(.+?)\1/g, (_match, _marker, content) => chalk.bold(content));
}
-61
View File
@@ -1,61 +0,0 @@
import readline from 'node:readline';
export type ChatReplInput = {
askAsync(prompt: string): Promise<string | null>;
close(): void;
};
export function createChatReplInput(options?: {
input?: NodeJS.ReadableStream;
output?: NodeJS.WritableStream;
terminal?: boolean;
history?: string[];
}): ChatReplInput {
const inputStream = options?.input ?? process.stdin;
const readlineInterface = readline.createInterface({
input: inputStream,
output: options?.output ?? process.stdout,
terminal: options?.terminal ?? true,
historySize: 100,
history: options?.history,
});
let closed = false;
readlineInterface.on('close', () => {
closed = true;
});
readlineInterface.on('SIGINT', () => {
readlineInterface.close();
});
return {
async askAsync(prompt: string): Promise<string | null> {
if (closed) {
return null;
}
inputStream.resume();
return await new Promise<string | null>(resolve => {
let settled = false;
const onClose = (): void => {
if (!settled) {
settled = true;
resolve(null);
}
};
readlineInterface.once('close', onClose);
readlineInterface.question(prompt, answer => {
if (!settled) {
settled = true;
readlineInterface.removeListener('close', onClose);
resolve(answer);
}
});
});
},
close(): void {
if (!closed) {
readlineInterface.close();
}
},
};
}
@@ -57,6 +57,7 @@ const BASE_GRAPHQL_ERROR_MESSAGE: string = 'GraphQL request failed.';
interface BaseGetContextAsyncArgs {
nonInteractive: boolean;
vcsClientOverride?: Client;
projectIdOverride?: string;
}
interface GetContextAsyncArgsWithRequiredServerSideEnvironmentArgument extends BaseGetContextAsyncArgs {
@@ -183,6 +184,7 @@ export default abstract class EasCommand extends Command {
{
nonInteractive,
vcsClientOverride,
projectIdOverride,
// if specified and not null, the env vars from the selected environment will be fetched from the server
// to resolve dynamic config (if dynamic config context is used) and enable getServerSideEnvironmentVariablesAsync function (if server side environment variables context is used)
withServerSideEnvironment,
@@ -206,6 +208,7 @@ export default abstract class EasCommand extends Command {
sessionManager: this.sessionManager,
analytics: this.analytics,
vcsClientOverride,
projectIdOverride,
withServerSideEnvironment,
}),
]);
@@ -1,3 +1,4 @@
import { Config } from '@oclif/core';
import { v4 as uuidv4 } from 'uuid';
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
@@ -24,17 +25,24 @@ jest.mock('../../analytics/AnalyticsManager', () => {
});
jest.mock('../../log');
let originalProcessArgv: string[];
const mockRequestId = uuidv4();
beforeAll(() => {
originalProcessArgv = process.argv;
process.argv = [];
});
/**
* A Config that is never loaded. Letting oclif load one discovers and requires
* every command in the package, which takes seconds under coverage with a cold
* transform cache and has timed out on slow CI runners. Nothing here needs the
* command table or hooks.
*/
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
afterAll(() => {
process.argv = originalProcessArgv;
});
let mockConfig: Config;
const analytics: AnalyticsWithOrchestration = {
logEvent: jest.fn((): void => {}),
@@ -44,42 +52,57 @@ const analytics: AnalyticsWithOrchestration = {
beforeEach(() => {
jest.resetAllMocks();
mockConfig = getMockOclifConfig();
});
/**
* The config oclif handed to the most recently run command. Command.run returns
* a pre-built Config untouched only when it was created by the same @oclif/core
* copy the command extends; otherwise it silently builds and fully loads a new
* one, bringing back the command discovery this suite avoids.
*/
let lastCommandConfig: Config | undefined;
const createTestEasCommand = (): any => {
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
createAnalyticsAsync.mockResolvedValue(analytics);
const EasCommand = require('../EasCommand').default;
class TestEasCommand extends EasCommand {
async runAsync(): Promise<void> {}
async runAsync(): Promise<void> {
lastCommandConfig = this.config;
}
}
TestEasCommand.id = 'testEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
return TestEasCommand;
};
// These tests go through the static Command.run rather than constructing an
// instance and calling run() like other command tests, because they cover
// oclif's catch and finally wiring, which only runs inside the static path.
describe('EasCommand', () => {
describe('without exceptions', () => {
// The first test in this suite should have an increased timeout
// because of the implementation of Command from @oclif/command.
// It seems that loading config takes significant amount of time
// and I'm not sure how to mock it.
//
// See https://github.com/oclif/command/blob/master/src/command.ts#L80
// and look for "Config.load"
it('ensures the user data is read', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
const SessionManager = jest.requireMock('../../user/SessionManager').default;
const sessionManagerSpy = jest.spyOn(SessionManager.prototype, 'getUserAsync');
expect(sessionManagerSpy).toBeCalledTimes(1);
}, 60_000);
});
it('runs with the unloaded config instead of loading one', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run([], mockConfig);
expect(lastCommandConfig).toBe(mockConfig);
expect(mockConfig.plugins.size).toBe(0);
});
it('initializes analytics', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
expect(createAnalyticsAsync).toHaveBeenCalled();
@@ -87,14 +110,14 @@ describe('EasCommand', () => {
it('flushes analytics', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
expect(analytics.flushAsync).toHaveBeenCalled();
});
it('flushes Sentry', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
const Sentry = jest.requireMock('../../sentry').default;
expect(Sentry.flush).toHaveBeenCalled();
@@ -102,7 +125,7 @@ describe('EasCommand', () => {
it('logs events', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
expect(analytics.logEvent).toHaveBeenCalledWith('action', {
action: `eas ${TestEasCommand.id}`,
@@ -114,7 +137,7 @@ describe('EasCommand', () => {
it('flushes analytics', async () => {
const TestEasCommand = createTestEasCommand();
try {
await TestEasCommand.run().then(() => {
await TestEasCommand.run([], mockConfig).then(() => {
throw new Error('foo');
});
} catch {}
@@ -140,7 +163,7 @@ describe('EasCommand', () => {
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch {}
expect(sentryScope.setTag).toHaveBeenCalledWith('command', TestEasCommand.id);
@@ -167,7 +190,7 @@ describe('EasCommand', () => {
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch {}
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
@@ -184,7 +207,7 @@ describe('EasCommand', () => {
throw error;
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch {}
expect(logErrorSpy).toBeCalledWith('Unexpected, internal error message');
@@ -204,7 +227,7 @@ describe('EasCommand', () => {
throw error;
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch {}
expect(logErrorSpy).toBeCalledWith('Unexpected GraphQL error message');
@@ -237,7 +260,7 @@ describe('EasCommand', () => {
throw error;
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch {}
expect(logErrorSpy).toBeCalledWith(
@@ -273,7 +296,7 @@ describe('EasCommand', () => {
throw error;
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch {}
expect(logErrorSpy).toBeCalledWith(
@@ -294,7 +317,7 @@ describe('EasCommand', () => {
throw new Error('Error message');
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch (caughtError) {
expect(caughtError).toBeInstanceOf(Error);
expect((caughtError as Error).message).toEqual('testEasCommand command failed.');
@@ -310,7 +333,7 @@ describe('EasCommand', () => {
throw new CombinedError({ graphQLErrors });
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch (caughtError) {
expect(caughtError).toBeInstanceOf(Error);
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
@@ -340,7 +363,7 @@ describe('EasCommand', () => {
throw new CombinedError({ graphQLErrors });
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch (caughtError) {
expect(caughtError).toBeInstanceOf(Error);
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
@@ -0,0 +1,33 @@
import { EasPaginatedQueryFlags, getLimitFlagWithCustomValues } from '../pagination';
async function parseFlagAsync(flag: { parse?: any }, input: string): Promise<number> {
return await flag.parse(input, {}, {});
}
describe(getLimitFlagWithCustomValues, () => {
const limit = getLimitFlagWithCustomValues({ defaultTo: 50, limit: 100 });
it('accepts integers within the range', async () => {
await expect(parseFlagAsync(limit, '1')).resolves.toBe(1);
await expect(parseFlagAsync(limit, '100')).resolves.toBe(100);
});
it('rejects values outside the range', async () => {
await expect(parseFlagAsync(limit, '0')).rejects.toThrow('--limit must be between 1 and 100');
await expect(parseFlagAsync(limit, '101')).rejects.toThrow('--limit must be between 1 and 100');
});
it('rejects non-integer input instead of passing it to the server', async () => {
await expect(parseFlagAsync(limit, '1.5')).rejects.toThrow('Unable to parse 1.5 as an integer');
await expect(parseFlagAsync(limit, 'ten')).rejects.toThrow('Unable to parse ten as an integer');
});
});
describe('EasPaginatedQueryFlags.offset', () => {
it('accepts zero and rejects non-integers', async () => {
await expect(parseFlagAsync(EasPaginatedQueryFlags.offset, '0')).resolves.toBe(0);
await expect(parseFlagAsync(EasPaginatedQueryFlags.offset, '2.5')).rejects.toThrow(
'Unable to parse 2.5 as an integer'
);
});
});
@@ -7,6 +7,8 @@ export interface ContextOptions {
analytics: Analytics;
nonInteractive: boolean;
vcsClientOverride?: Client;
/** Short-circuits project ID resolution. */
projectIdOverride?: string;
/**
* If specified, env variables from the selected environment will be fetched from the server and used to evaluate the dynamic config.
*/
@@ -4,7 +4,14 @@ import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
export class ProjectIdContextField extends ContextField<string> {
async getValueAsync({ nonInteractive, sessionManager }: ContextOptions): Promise<string> {
async getValueAsync({
nonInteractive,
sessionManager,
projectIdOverride,
}: ContextOptions): Promise<string> {
if (projectIdOverride) {
return projectIdOverride;
}
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
const expBefore = await getPrivateExpoConfigAsync(projectDir);
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
@@ -0,0 +1,16 @@
import { ContextOptions } from '../ContextField';
import { ProjectIdContextField } from '../ProjectIdContextField';
import { findProjectDirAndVerifyProjectSetupAsync } from '../contextUtils/findProjectDirAndVerifyProjectSetupAsync';
jest.mock('../contextUtils/findProjectDirAndVerifyProjectSetupAsync');
describe(ProjectIdContextField, () => {
it('returns the override without reading the project directory', async () => {
const projectId = await new ProjectIdContextField().getValueAsync({
projectIdOverride: 'override-project-id',
} as ContextOptions);
expect(projectId).toBe('override-project-id');
expect(findProjectDirAndVerifyProjectSetupAsync).not.toHaveBeenCalled();
});
});
@@ -17,6 +17,12 @@ export const EasNonInteractiveAndJsonFlags = {
}),
};
export const EasProjectIdFlag = {
'project-id': Flags.string({
description: 'EAS project ID (defaults to the project ID of the current directory)',
}),
};
export function resolveNonInteractiveAndJsonFlags(flags: {
json?: boolean;
'non-interactive'?: boolean;
@@ -23,8 +23,8 @@ const parseFlagInputStringAsInteger = (
upperLimit: number
): number => {
const inputAsNumber = Number(input);
if (isNaN(inputAsNumber)) {
throw new Error(`Unable to parse ${input} as a number`);
if (!Number.isInteger(inputAsNumber)) {
throw new Error(`Unable to parse ${input} as an integer`);
}
if (inputAsNumber < lowerLimit || inputAsNumber > upperLimit) {
throw new Error(`--${flagName} must be between ${lowerLimit} and ${upperLimit}`);
@@ -0,0 +1,13 @@
import { GraphQLError } from 'graphql';
import { GraphqlError } from '../graphql/client';
export function findPlanGateError(
error: unknown,
errorCodes: ReadonlySet<string>
): GraphQLError | undefined {
if (!(error instanceof GraphqlError)) {
return undefined;
}
return error.graphQLErrors.find(e => errorCodes.has(e?.extensions?.errorCode as string));
}
@@ -1,370 +0,0 @@
import { getMockOclifConfig } from '../../__tests__/commands/utils';
import { ChatResult, streamChatResponseAsync } from '../../chat/chatClient';
import { detectCurrentProjectAsync } from '../../chat/detectProject';
import { ChatReplInput, createChatReplInput } from '../../chat/replInput';
import * as flagsModule from '../../commandUtils/flags';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import Chat from '../chat';
jest.mock('../../chat/chatClient', () => ({
...jest.requireActual('../../chat/chatClient'),
streamChatResponseAsync: jest.fn(),
}));
jest.mock('../../chat/replInput');
jest.mock('../../chat/detectProject');
jest.mock('../../log');
jest.mock('../../utils/json');
jest.mock('../../graphql/queries/AppQuery', () => ({
AppQuery: { byFullNameAsync: jest.fn() },
}));
const mockStreamChatResponseAsync = jest.mocked(streamChatResponseAsync);
const mockCreateChatReplInput = jest.mocked(createChatReplInput);
const mockDetectCurrentProjectAsync = jest.mocked(detectCurrentProjectAsync);
const mockAppByFullNameAsync = jest.mocked(AppQuery.byFullNameAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeChatResult(text: string, toolCalls: ChatResult['toolCalls'] = []): ChatResult {
return {
text,
toolCalls,
assistantMessage: {
id: 'assistant-message',
role: 'assistant',
parts: [{ type: 'text', text }],
},
};
}
const emptyResult = makeChatResult('ok');
function mockReplInput(lines: (string | null)[]): { askAsync: jest.Mock; close: jest.Mock } {
const askAsync = jest.fn();
for (const line of lines) {
askAsync.mockResolvedValueOnce(line);
}
askAsync.mockResolvedValue(null);
const input = { askAsync, close: jest.fn() };
mockCreateChatReplInput.mockReturnValue(input as ChatReplInput);
return input;
}
function forceInteractive(): void {
jest
.spyOn(flagsModule, 'resolveNonInteractiveAndJsonFlags')
.mockReturnValue({ json: false, nonInteractive: false });
}
describe(Chat, () => {
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
mockStreamChatResponseAsync.mockResolvedValue(emptyResult);
mockDetectCurrentProjectAsync.mockResolvedValue(null);
});
function createCommand(
argv: string[],
{
sessionSecret = '{"id":"session-id","version":"1"}',
primaryAccountName = 'my-account',
}: { sessionSecret?: string | null; primaryAccountName?: string } = {}
): Chat {
const command = new Chat(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: {
actor: {
__typename: 'User',
id: 'user-id',
primaryAccount: { id: 'account-id', name: primaryAccountName },
accounts: [{ id: 'account-id', name: primaryAccountName }],
},
graphqlClient: {},
authenticationInfo: sessionSecret
? { accessToken: null, sessionSecret }
: { accessToken: 'token', sessionSecret: null },
},
});
return command;
}
it('sends the message for the primary account and does not prompt in non-interactive mode', async () => {
const command = createCommand(['how are my builds?', '--non-interactive']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.accountName).toBe('my-account');
expect(call.sessionSecret).toBe('{"id":"session-id","version":"1"}');
expect(call.stream).toBe(true);
expect(call.messages).toHaveLength(1);
expect(call.messages[0].parts[0]).toEqual({ type: 'text', text: 'how are my builds?' });
expect(mockCreateChatReplInput).not.toHaveBeenCalled();
});
it('prefers the --account flag over the primary account and skips project auto-detection', async () => {
const command = createCommand(['hi', '--account', 'other-account', '--non-interactive']);
await command.runAsync();
expect(mockStreamChatResponseAsync.mock.calls[0][0].accountName).toBe('other-account');
expect(mockDetectCurrentProjectAsync).not.toHaveBeenCalled();
});
it('auto-scopes to the current directory project when no scope flags are given', async () => {
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
const command = createCommand(['is my build ok?', '--non-interactive']);
await command.runAsync();
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.accountName).toBe('acme');
expect(call.messages[0].parts[0]).toEqual({
type: 'text',
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
});
});
it('does not auto-detect when --project is given', async () => {
mockAppByFullNameAsync.mockResolvedValue({
id: 'app1',
fullName: '@acme/mobile',
slug: 'mobile',
ownerAccount: { id: 'acc', name: 'acme' },
} as any);
const command = createCommand(['hi', '--project', 'acme/mobile', '--non-interactive']);
await command.runAsync();
expect(mockDetectCurrentProjectAsync).not.toHaveBeenCalled();
});
it('resolves --project to its owner account and frames the message', async () => {
mockAppByFullNameAsync.mockResolvedValue({
id: 'app1',
fullName: '@acme/mobile',
slug: 'mobile',
ownerAccount: { id: 'acc', name: 'acme' },
} as any);
const command = createCommand([
'is my build ok?',
'--project',
'acme/mobile',
'--non-interactive',
]);
await command.runAsync();
expect(mockAppByFullNameAsync).toHaveBeenCalledWith(expect.anything(), '@acme/mobile');
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.accountName).toBe('acme');
expect(call.messages[0].parts[0]).toEqual({
type: 'text',
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
});
});
it('throws a friendly error when --project cannot be found', async () => {
mockAppByFullNameAsync.mockRejectedValue(new Error('not found'));
const command = createCommand(['hi', '--project', 'acme/ghost', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(/@acme\/ghost was not found/);
});
it('continues the conversation with follow-up replies until the user types /exit', async () => {
forceInteractive();
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
const input = mockReplInput(['and my updates?', '/exit']);
const command = createCommand(['how are my builds?']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages).toHaveLength(3);
expect(secondCall.messages[1].role).toBe('assistant');
expect(secondCall.messages[2].role).toBe('user');
expect(secondCall.messages[2].parts[0]).toEqual({ type: 'text', text: 'and my updates?' });
expect(input.close).toHaveBeenCalled();
expect(mockCreateChatReplInput).toHaveBeenCalledWith(
expect.objectContaining({ history: ['how are my builds?'] })
);
});
it('retains tool results in the conversation history for follow-up questions', async () => {
forceInteractive();
const result = makeChatResult('The latest build passed.', [
{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: ['b1'] } },
]);
result.assistantMessage.parts = [
{ type: 'step-start' },
{
type: 'tool-get_latest_builds',
toolCallId: 't1',
state: 'output-available',
input: { limit: 1 },
output: { builds: ['b1'] },
},
{ type: 'step-start' },
{ type: 'text', text: result.text },
];
mockStreamChatResponseAsync.mockResolvedValue(result);
mockReplInput(['what SDK did it use?', '/exit']);
const command = createCommand(['how are my builds?']);
await command.runAsync();
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages[1]).toEqual(result.assistantMessage);
});
it('exits when the input stream closes (Ctrl-D)', async () => {
forceInteractive();
mockReplInput([null]);
const command = createCommand(['how are my builds?']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
});
it('keeps the interactive session alive when a request fails and re-prompts', async () => {
forceInteractive();
mockStreamChatResponseAsync
.mockRejectedValueOnce(new Error('You have reached your chat usage limit.'))
.mockResolvedValue(makeChatResult('answer'));
const input = mockReplInput(['try again', '/exit']);
const command = createCommand(['how are my builds?']);
await expect(command.runAsync()).resolves.toBeUndefined();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages).toHaveLength(1);
expect(secondCall.messages[0].parts[0]).toEqual({ type: 'text', text: 'try again' });
expect(input.close).toHaveBeenCalled();
});
it('propagates a request failure in non-interactive mode without retrying', async () => {
mockStreamChatResponseAsync.mockRejectedValue(new Error('boom'));
const command = createCommand(['hi', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow('boom');
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
});
it('starts a new conversation with /clear', async () => {
forceInteractive();
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
mockReplInput(['/clear', 'fresh question', '/exit']);
const command = createCommand(['first question']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages).toHaveLength(1);
expect(secondCall.messages[0].parts[0]).toEqual({ type: 'text', text: 'fresh question' });
});
it('restores project scope after /clear', async () => {
forceInteractive();
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
mockReplInput(['/clear', 'fresh question', '/exit']);
const command = createCommand(['first question']);
await command.runAsync();
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages).toHaveLength(1);
expect(secondCall.messages[0].parts[0]).toEqual({
type: 'text',
text: 'Regarding the EAS project @acme/mobile: fresh question',
});
});
it('ignores unknown slash commands and keeps prompting', async () => {
forceInteractive();
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
mockReplInput(['/bogus', 'a real question', '/exit']);
const command = createCommand(['first question']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
expect(mockStreamChatResponseAsync.mock.calls[1][0].messages[2].parts[0]).toEqual({
type: 'text',
text: 'a real question',
});
});
it('emits structured JSON and does not stream or prompt when --json is passed', async () => {
mockStreamChatResponseAsync.mockResolvedValue(
makeChatResult('Your latest build passed.', [
{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: [] } },
])
);
const command = createCommand(['how are my builds?', '--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockStreamChatResponseAsync.mock.calls[0][0].stream).toBe(false);
expect(mockCreateChatReplInput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
message: 'how are my builds?',
account: 'my-account',
project: null,
response: 'Your latest build passed.',
toolCalls: [{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: [] } }],
});
});
it('throws when authenticated with an access token instead of a session secret', async () => {
const command = createCommand(['hi', '--non-interactive'], { sessionSecret: null });
await expect(command.runAsync()).rejects.toThrow(/requires an interactive login/);
expect(mockStreamChatResponseAsync).not.toHaveBeenCalled();
});
it('requires a message argument', async () => {
const command = createCommand(['--non-interactive']);
await expect(command.runAsync()).rejects.toThrow();
});
it('opens an interactive prompt when no message is given', async () => {
forceInteractive();
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
const input = mockReplInput(['how are my builds?', '/exit']);
const command = createCommand([]);
await command.runAsync();
expect(mockCreateChatReplInput).toHaveBeenCalledWith(expect.objectContaining({ history: [] }));
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.messages).toHaveLength(1);
expect(call.messages[0].parts[0]).toEqual({ type: 'text', text: 'how are my builds?' });
expect(input.close).toHaveBeenCalled();
});
it('frames the first typed message with the auto-detected project when no message is given', async () => {
forceInteractive();
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
mockReplInput(['is my build ok?', '/exit']);
const command = createCommand([]);
await command.runAsync();
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.accountName).toBe('acme');
expect(call.messages[0].parts[0]).toEqual({
type: 'text',
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
});
});
});
@@ -0,0 +1,168 @@
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import {
protectUpdateChannelAsync,
unprotectUpdateChannelAsync,
} from '../../../channel/protection';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
import Log from '../../../log';
import { toggleConfirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ChannelProtect from '../protect';
import ChannelUnprotect from '../unprotect';
jest.mock('../../../channel/protection');
jest.mock('../../../graphql/queries/ChannelQuery');
jest.mock('../../../log');
jest.mock('../../../prompts');
jest.mock('../../../utils/json');
const graphqlClient = {} as ExpoGraphqlClient;
const channel = {
id: 'channel-id',
name: 'production',
branchMapping: '{"version":0,"data":[]}',
isProtected: true,
};
function setContext(command: ChannelProtect | ChannelUnprotect): void {
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId: 'project-id',
loggedIn: { graphqlClient },
});
}
describe(ChannelProtect, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue({
...channel,
isProtected: false,
});
jest.mocked(protectUpdateChannelAsync).mockResolvedValue(channel);
});
it('protects a named channel', async () => {
const command = new ChannelProtect(['production'], getMockOclifConfig());
setContext(command);
await command.runAsync();
expect(protectUpdateChannelAsync).toHaveBeenCalledWith(graphqlClient, {
channelId: 'channel-id',
});
expect(Log.withTick).toHaveBeenCalledWith(expect.stringContaining('production'));
});
it('prints the server response as JSON', async () => {
const command = new ChannelProtect(['production', '--json'], getMockOclifConfig());
setContext(command);
await command.runAsync();
expect(enableJsonOutput).toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith(channel);
expect(Log.withTick).not.toHaveBeenCalled();
});
it('does not mutate a channel that is already protected', async () => {
jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue(channel);
const command = new ChannelProtect(['production'], getMockOclifConfig());
setContext(command);
await command.runAsync();
expect(protectUpdateChannelAsync).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('already protected'));
});
it('requires a channel name in non-interactive mode', async () => {
const command = new ChannelProtect(['--non-interactive', '--json'], getMockOclifConfig());
await expect(command.runAsync()).rejects.toThrow(
'Channel name must be set when running in non-interactive mode'
);
expect(enableJsonOutput).toHaveBeenCalled();
expect(protectUpdateChannelAsync).not.toHaveBeenCalled();
});
});
describe(ChannelUnprotect, () => {
let processExitSpy: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue(channel);
jest.mocked(unprotectUpdateChannelAsync).mockResolvedValue({ ...channel, isProtected: false });
jest.mocked(toggleConfirmAsync).mockResolvedValue(true);
processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit');
}) as never);
});
afterEach(() => {
processExitSpy.mockRestore();
});
it('asks for confirmation before removing protection', async () => {
const command = new ChannelUnprotect(['production'], getMockOclifConfig());
setContext(command);
await command.runAsync();
expect(toggleConfirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('production'),
});
expect(unprotectUpdateChannelAsync).toHaveBeenCalledWith(graphqlClient, {
channelId: 'channel-id',
});
});
it('does not mutate the channel when confirmation is declined', async () => {
jest.mocked(toggleConfirmAsync).mockResolvedValue(false);
const command = new ChannelUnprotect(['production'], getMockOclifConfig());
setContext(command);
await expect(command.runAsync()).rejects.toThrow('process.exit');
expect(unprotectUpdateChannelAsync).not.toHaveBeenCalled();
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('production'));
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it('does not mutate a channel that is already unprotected', async () => {
jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue({
...channel,
isProtected: false,
});
const command = new ChannelUnprotect(['production'], getMockOclifConfig());
setContext(command);
await command.runAsync();
expect(toggleConfirmAsync).not.toHaveBeenCalled();
expect(unprotectUpdateChannelAsync).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('already unprotected'));
});
it('does not prompt in non-interactive mode', async () => {
const command = new ChannelUnprotect(['production', '--non-interactive'], getMockOclifConfig());
setContext(command);
await command.runAsync();
expect(toggleConfirmAsync).not.toHaveBeenCalled();
expect(unprotectUpdateChannelAsync).toHaveBeenCalled();
});
it('requires a channel name in non-interactive mode', async () => {
const command = new ChannelUnprotect(['--non-interactive', '--json'], getMockOclifConfig());
await expect(command.runAsync()).rejects.toThrow(
'Channel name must be set when running in non-interactive mode'
);
expect(enableJsonOutput).toHaveBeenCalled();
expect(unprotectUpdateChannelAsync).not.toHaveBeenCalled();
});
});
@@ -11,7 +11,7 @@ import {
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ChannelInsightsQuery } from '../../graphql/queries/ChannelInsightsQuery';
import { resolveInsightsTimeRange } from '../../insights/timeRange';
import { InsightsTimeRangeFlags, resolveInsightsTimeRange } from '../../insights/timeRange';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -28,20 +28,7 @@ export default class ChannelInsights extends EasCommand {
description: 'Runtime version to query insights for.',
required: true,
}),
days: Flags.integer({
description:
'Show insights from the last N days (default 7, mutually exclusive with --start/--end).',
min: 1,
exclusive: ['start', 'end'],
}),
start: Flags.string({
description: 'Start of insights time range (ISO date).',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of insights time range (ISO date).',
exclusive: ['days'],
}),
...InsightsTimeRangeFlags,
...EasNonInteractiveAndJsonFlags,
};
@@ -0,0 +1,77 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import { protectUpdateChannelAsync } from '../../channel/protection';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ChannelProtect extends EasCommand {
static override description = 'protect a channel so only account admins can publish to it';
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to protect',
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(ChannelProtect);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
if (!args.name && nonInteractive) {
throw new Error('Channel name must be set when running in non-interactive mode');
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelProtect, { nonInteractive });
const existingChannel = args.name
? await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
appId: projectId,
channelName: args.name,
})
: await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to protect',
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
});
if (existingChannel.isProtected) {
if (json) {
printJsonOnlyOutput(existingChannel);
} else {
Log.log(chalk`Channel {bold ${existingChannel.name}} is already protected.`);
}
return;
}
const channel = await protectUpdateChannelAsync(graphqlClient, {
channelId: existingChannel.id,
});
if (json) {
printJsonOnlyOutput(channel);
} else {
Log.withTick(chalk`Channel {bold ${channel.name}} is now protected.`);
}
}
}
@@ -0,0 +1,88 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import { unprotectUpdateChannelAsync } from '../../channel/protection';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ChannelUnprotect extends EasCommand {
static override description = "remove a channel's admin-only publishing restriction";
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to unprotect',
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(ChannelUnprotect);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
if (!args.name && nonInteractive) {
throw new Error('Channel name must be set when running in non-interactive mode');
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelUnprotect, { nonInteractive });
const existingChannel = args.name
? await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
appId: projectId,
channelName: args.name,
})
: await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to unprotect',
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
});
if (!existingChannel.isProtected) {
if (json) {
printJsonOnlyOutput(existingChannel);
} else {
Log.log(chalk`Channel {bold ${existingChannel.name}} is already unprotected.`);
}
return;
}
if (!nonInteractive) {
const confirmed = await toggleConfirmAsync({
message: chalk`Remove protection from channel {bold ${existingChannel.name}}?`,
});
if (!confirmed) {
Log.error(chalk`Canceled removing protection from channel {bold ${existingChannel.name}}.`);
process.exit(1);
}
}
const channel = await unprotectUpdateChannelAsync(graphqlClient, {
channelId: existingChannel.id,
});
if (json) {
printJsonOnlyOutput(channel);
} else {
Log.withTick(chalk`Channel {bold ${channel.name}} is no longer protected.`);
}
}
}
-274
View File
@@ -1,274 +0,0 @@
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import {
ChatMessage,
ChatResult,
makeUserMessage,
streamChatResponseAsync,
} from '../chat/chatClient';
import { detectCurrentProjectAsync } from '../chat/detectProject';
import { ChatReplInput, createChatReplInput } from '../chat/replInput';
import EasCommand from '../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../commandUtils/flags';
import { AppQuery } from '../graphql/queries/AppQuery';
import Log from '../log';
import { Actor } from '../user/User';
import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json';
const EXIT_WORDS = new Set(['exit', 'quit', 'q']);
const USER_LABEL = `${chalk.bold.cyan('Chat')}${chalk.dim(' > ')}`;
const CHAT_HELP = [
'Commands:',
' /help Show this help',
' /clear Start a new conversation',
' /exit Quit',
].join('\n');
export default class Chat extends EasCommand {
static override description = 'ask an AI assistant about your Expo account and EAS projects';
static override hidden = true;
static override args = {
message: Args.string({
required: false,
description: 'Message to send to the assistant (omit to open an interactive prompt)',
}),
};
static override flags = {
account: Flags.string({
char: 'a',
description: 'Account to scope the conversation to (defaults to your primary account)',
}),
project: Flags.string({
char: 'p',
description:
'Project to focus the conversation on, as @account-name/project-slug or a project slug',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(Chat);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
if (!args.message && (json || nonInteractive)) {
throw new Error('Provide a message, e.g. `eas chat "how are my builds?"`.');
}
const {
loggedIn: { actor, authenticationInfo, graphqlClient },
} = await this.getContextAsync(Chat, { nonInteractive });
if (!authenticationInfo.sessionSecret) {
throw new Error(
'`eas chat` requires an interactive login. Run `eas login` first. Chat does not support EXPO_TOKEN access tokens yet.'
);
}
const { sessionSecret } = authenticationInfo;
let accountName = flags.account ?? getDefaultAccountName(actor);
let projectLabel: string | undefined;
let projectDetectedFromDirectory = false;
if (flags.project) {
const resolved = await resolveProjectAsync(graphqlClient, flags.project, accountName);
accountName = resolved.accountName;
projectLabel = resolved.label;
} else if (!flags.account) {
const detected = await detectCurrentProjectAsync(graphqlClient);
if (detected) {
accountName = detected.accountName;
projectLabel = detected.label;
projectDetectedFromDirectory = true;
}
}
if (json) {
const messages: ChatMessage[] = [
makeUserMessage(scopeMessageToProject(args.message as string, projectLabel)),
];
const result = await streamChatResponseAsync({
messages: [...messages],
accountName,
sessionSecret,
stream: false,
});
printJsonOnlyOutput({
message: args.message,
account: accountName ?? null,
project: projectLabel ?? null,
response: result.text,
toolCalls: result.toolCalls,
});
return;
}
if (projectLabel) {
Log.log(
chalk.dim(
projectDetectedFromDirectory
? `Project: ${projectLabel} (from current directory; use --account to widen)`
: `Project: ${projectLabel}`
)
);
}
if (!nonInteractive) {
Log.log(chalk.dim('Use /help for commands, or /exit to quit.'));
}
const input = nonInteractive
? undefined
: createChatReplInput({ history: args.message ? [args.message] : [] });
const messages: ChatMessage[] = [];
if (args.message) {
Log.log(`${USER_LABEL}${args.message}`);
Log.newLine();
messages.push(makeUserMessage(scopeMessageToProject(args.message, projectLabel)));
}
try {
for (;;) {
const lastMessage = messages[messages.length - 1];
if (!lastMessage || lastMessage.role === 'assistant') {
if (!input) {
break;
}
const scopeNextMessage = messages.length === 0;
const nextMessage = await readNextUserMessageAsync(
input,
messages,
projectLabel,
scopeNextMessage
);
if (nextMessage === null) {
Log.log(chalk.dim('Ending chat.'));
break;
}
messages.push(makeUserMessage(nextMessage));
}
let result: ChatResult;
try {
result = await streamChatResponseAsync({
messages: [...messages],
accountName,
sessionSecret,
stream: true,
});
} catch (error) {
if (!input) {
throw error;
}
Log.error(error instanceof Error ? error.message : String(error));
messages.pop();
continue;
}
messages.push(result.assistantMessage);
}
} finally {
input?.close();
}
}
}
async function readNextUserMessageAsync(
input: ChatReplInput,
messages: ChatMessage[],
projectLabel: string | undefined,
scopeFirstMessage = false
): Promise<string | null> {
let shouldRestoreProjectScope = scopeFirstMessage;
for (;;) {
Log.newLine();
const line = await input.askAsync(USER_LABEL);
if (line === null) {
return null;
}
const trimmed = line.trim();
if (!trimmed) {
continue;
}
if (EXIT_WORDS.has(trimmed.toLowerCase())) {
return null;
}
if (trimmed.startsWith('/')) {
const command = trimmed.slice(1).split(/\s+/)[0].toLowerCase();
if (['exit', 'quit', 'q'].includes(command)) {
return null;
}
if (['clear', 'new', 'reset'].includes(command)) {
messages.length = 0;
shouldRestoreProjectScope = true;
Log.log(chalk.dim('Started a new conversation.'));
continue;
}
if (['help', 'h', '?'].includes(command)) {
Log.log(chalk.dim(CHAT_HELP));
continue;
}
Log.warn(`Unknown command "/${command}". Type /help for commands.`);
continue;
}
Log.newLine();
return shouldRestoreProjectScope ? scopeMessageToProject(trimmed, projectLabel) : trimmed;
}
}
function scopeMessageToProject(message: string, projectLabel: string | undefined): string {
return projectLabel ? `Regarding the EAS project ${projectLabel}: ${message}` : message;
}
function getDefaultAccountName(actor: Actor): string | undefined {
if ('primaryAccount' in actor && actor.primaryAccount) {
return actor.primaryAccount.name;
}
return actor.accounts[0]?.name ?? undefined;
}
async function resolveProjectAsync(
graphqlClient: ExpoGraphqlClient,
projectReference: string,
fallbackAccountName: string | undefined
): Promise<{ accountName: string; label: string }> {
const parsed = parseProjectReference(projectReference);
const accountForLookup = parsed.account ?? fallbackAccountName;
if (!accountForLookup) {
throw new Error(
`Could not determine which account owns project "${projectReference}". Pass it as @account-name/project-slug.`
);
}
const fullName = `@${accountForLookup}/${parsed.slug}`;
let app;
try {
app = await AppQuery.byFullNameAsync(graphqlClient, fullName);
} catch {
throw new Error(`Project ${fullName} was not found or you do not have access to it.`);
}
return { accountName: app.ownerAccount.name, label: app.fullName };
}
function parseProjectReference(reference: string): { account?: string; slug: string } {
const cleaned = reference.startsWith('@') ? reference.slice(1) : reference;
const parts = cleaned.split('/');
if (parts.length === 2 && parts[0] && parts[1]) {
return { account: parts[0], slug: parts[1] };
}
return { slug: cleaned };
}
@@ -0,0 +1,148 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppObserveErrorSeverity } from '../../../graphql/generated';
import {
fetchObserveErrorGroupsAsync,
fetchObserveErrorOccurrencesAsync,
} from '../../../observe/fetchErrors';
import {
buildObserveErrorGroupsJson,
buildObserveErrorGroupsTable,
buildObserveErrorOccurrencesJson,
buildObserveErrorOccurrencesTable,
} from '../../../observe/formatErrors';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveErrors from '../errors';
jest.mock('../../../observe/fetchErrors');
jest.mock('../../../observe/formatErrors', () => ({
buildObserveErrorGroupsTable: jest.fn().mockReturnValue('errors-table'),
buildObserveErrorGroupsJson: jest.fn().mockReturnValue({ errors: [], isTruncated: false }),
buildObserveErrorOccurrencesTable: jest.fn().mockReturnValue('occurrences-table'),
buildObserveErrorOccurrencesJson: jest
.fn()
.mockReturnValue({ occurrences: [], pageInfo: { hasNextPage: false, endCursor: null } }),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchErrorGroupsAsync = jest.mocked(fetchObserveErrorGroupsAsync);
const mockFetchErrorOccurrencesAsync = jest.mocked(fetchObserveErrorOccurrencesAsync);
const mockBuildTable = jest.mocked(buildObserveErrorGroupsTable);
const mockBuildJson = jest.mocked(buildObserveErrorGroupsJson);
const mockBuildOccurrencesTable = jest.mocked(buildObserveErrorOccurrencesTable);
const mockBuildOccurrencesJson = jest.mocked(buildObserveErrorOccurrencesJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveErrors, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchErrorGroupsAsync.mockResolvedValue({ groups: [], isTruncated: false });
mockFetchErrorOccurrencesAsync.mockResolvedValue({
occurrences: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false, endCursor: null } as any,
});
});
function createCommand(argv: string[]): ObserveErrors {
const command = new ObserveErrors(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('fetches error groups and renders a table by default', async () => {
await createCommand([]).runAsync();
expect(mockFetchErrorGroupsAsync).toHaveBeenCalledTimes(1);
expect(mockBuildTable).toHaveBeenCalledTimes(1);
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
});
it('emits JSON with --json', async () => {
await createCommand(['--json', '--non-interactive']).runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
expect(mockBuildJson).toHaveBeenCalledTimes(1);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ errors: [], isTruncated: false });
});
it('maps the --severity flag to the AppObserveErrorSeverity enum', async () => {
await createCommand(['--severity', 'fatal']).runAsync();
const options = mockFetchErrorGroupsAsync.mock.calls[0][2];
expect(options.severity).toBe(AppObserveErrorSeverity.Fatal);
});
it('fetches occurrences (not groups) and renders their table when --fingerprint is set', async () => {
await createCommand(['--fingerprint', 'fp-1']).runAsync();
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
expect(mockFetchErrorOccurrencesAsync).toHaveBeenCalledTimes(1);
expect(mockFetchErrorOccurrencesAsync.mock.calls[0][2]).toMatchObject({ fingerprint: 'fp-1' });
expect(mockBuildOccurrencesTable).toHaveBeenCalledTimes(1);
expect(mockBuildTable).not.toHaveBeenCalled();
});
it('emits occurrences JSON with --fingerprint --json', async () => {
await createCommand(['--fingerprint', 'fp-1', '--json', '--non-interactive']).runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
expect(mockBuildOccurrencesJson).toHaveBeenCalledTimes(1);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
occurrences: [],
pageInfo: { hasNextPage: false, endCursor: null },
});
});
it('forwards --limit to the occurrences fetch when --fingerprint is set', async () => {
await createCommand(['--fingerprint', 'fp-1', '--limit', '20']).runAsync();
expect(mockFetchErrorOccurrencesAsync.mock.calls[0][2]).toMatchObject({
fingerprint: 'fp-1',
limit: 20,
});
});
it('rejects --limit without --fingerprint', async () => {
await expect(createCommand(['--limit', '20']).runAsync()).rejects.toThrow(
/--after or --limit can only be used in combination with the --fingerprint flag/
);
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
expect(mockFetchErrorOccurrencesAsync).not.toHaveBeenCalled();
});
it('rejects --after without --fingerprint', async () => {
await expect(createCommand(['--after', 'cursor-1']).runAsync()).rejects.toThrow(
/--after or --limit can only be used in combination with the --fingerprint flag/
);
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
});
it('surfaces the plan-gate message when errors are not available on the plan', async () => {
mockFetchErrorGroupsAsync.mockRejectedValueOnce(
new CombinedError({
graphQLErrors: [
new GraphQLError(
'Subscription to EAS is required for this feature.',
null,
null,
null,
null,
null,
{
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
}
),
],
})
);
await expect(createCommand([]).runAsync()).rejects.toThrow(/Subscription to EAS is required/);
});
});
@@ -0,0 +1,190 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
import {
buildObserveCustomEventDetail,
buildObserveCustomEventJson,
} from '../../../observe/formatCustomEvents';
import { buildObserveErrorDetail, buildObserveErrorJson } from '../../../observe/formatErrors';
import { buildObserveEventDetail, buildObserveEventJson } from '../../../observe/formatEvents';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveEvent from '../event';
jest.mock('../../../observe/formatEvents', () => ({
buildObserveEventDetail: jest.fn().mockReturnValue('metric-detail'),
buildObserveEventJson: jest.fn().mockReturnValue({ id: 'metric-json' }),
}));
jest.mock('../../../observe/formatCustomEvents', () => ({
buildObserveCustomEventDetail: jest.fn().mockReturnValue('log-detail'),
buildObserveCustomEventJson: jest.fn().mockReturnValue({ id: 'log-json' }),
}));
jest.mock('../../../observe/formatErrors', () => ({
buildObserveErrorDetail: jest.fn().mockReturnValue('error-detail'),
buildObserveErrorJson: jest.fn().mockReturnValue({ id: 'error-json' }),
}));
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
ObserveQuery: {
logByIdAsync: jest.fn(),
metricByIdAsync: jest.fn(),
},
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockLogByIdAsync = jest.mocked(ObserveQuery.logByIdAsync);
const mockMetricByIdAsync = jest.mocked(ObserveQuery.metricByIdAsync);
const mockBuildObserveEventDetail = jest.mocked(buildObserveEventDetail);
const mockBuildObserveEventJson = jest.mocked(buildObserveEventJson);
const mockBuildObserveCustomEventDetail = jest.mocked(buildObserveCustomEventDetail);
const mockBuildObserveCustomEventJson = jest.mocked(buildObserveCustomEventJson);
const mockBuildObserveErrorDetail = jest.mocked(buildObserveErrorDetail);
const mockBuildObserveErrorJson = jest.mocked(buildObserveErrorJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
// A log event ID (user event or error) is a UUID; a metric event ID is
// base64url-encoded JSON.
const UUID_ID = '123e4567-e89b-12d3-a456-426614174000';
const BASE64_ID = 'eyJhIjoxfQ';
const INVALID_ID = 'not a valid id';
const userEventLog = { id: UUID_ID, __typename: 'AppObserveUserEvent' } as any;
const errorLog = { id: UUID_ID, __typename: 'AppObserveError' } as any;
describe(ObserveEvent, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockLogByIdAsync.mockResolvedValue(null);
mockMetricByIdAsync.mockResolvedValue(null);
});
function createCommand(argv: string[]): ObserveEvent {
const command = new ObserveEvent(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
function planGateError(): CombinedError {
const serverMessage =
'Subscription to EAS is required for this feature. ' +
'Subscribe: https://expo.dev/accounts/acme/settings/billing';
return new CombinedError({
graphQLErrors: [
new GraphQLError(serverMessage, null, null, null, null, null, {
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
}),
],
});
}
it('looks up a UUID id with the log query only', async () => {
mockLogByIdAsync.mockResolvedValue(userEventLog);
await createCommand([UUID_ID]).runAsync();
expect(mockLogByIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
id: UUID_ID,
});
expect(mockMetricByIdAsync).not.toHaveBeenCalled();
expect(mockBuildObserveCustomEventDetail).toHaveBeenCalledTimes(1);
});
it('renders an error detail when a UUID id resolves to an error', async () => {
mockLogByIdAsync.mockResolvedValue(errorLog);
await createCommand([UUID_ID]).runAsync();
expect(mockBuildObserveErrorDetail).toHaveBeenCalledTimes(1);
expect(mockBuildObserveCustomEventDetail).not.toHaveBeenCalled();
});
it('looks up a base64 id with the metric query only', async () => {
mockMetricByIdAsync.mockResolvedValue({ id: BASE64_ID } as any);
await createCommand([BASE64_ID]).runAsync();
expect(mockMetricByIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
id: BASE64_ID,
});
expect(mockLogByIdAsync).not.toHaveBeenCalled();
expect(mockBuildObserveEventDetail).toHaveBeenCalledTimes(1);
});
it('errors immediately for an ID that is neither a UUID nor base64, without querying', async () => {
await expect(createCommand([INVALID_ID]).runAsync()).rejects.toThrow(
/is not a valid Observe event ID/
);
expect(mockLogByIdAsync).not.toHaveBeenCalled();
expect(mockMetricByIdAsync).not.toHaveBeenCalled();
});
it('emits typed JSON for a metric event with --json', async () => {
mockMetricByIdAsync.mockResolvedValue({ id: BASE64_ID } as any);
await createCommand([BASE64_ID, '--json', '--non-interactive']).runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
expect(mockBuildObserveEventJson).toHaveBeenCalledTimes(1);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
type: 'metric',
event: { id: 'metric-json' },
});
});
it('emits typed JSON for a log event with --json', async () => {
mockLogByIdAsync.mockResolvedValue(userEventLog);
await createCommand([UUID_ID, '--json', '--non-interactive']).runAsync();
expect(mockBuildObserveCustomEventJson).toHaveBeenCalledTimes(1);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
type: 'log',
event: { id: 'log-json' },
});
});
it('emits typed JSON for an error event with --json', async () => {
mockLogByIdAsync.mockResolvedValue(errorLog);
await createCommand([UUID_ID, '--json', '--non-interactive']).runAsync();
expect(mockBuildObserveErrorJson).toHaveBeenCalledTimes(1);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
type: 'error',
event: { id: 'error-json' },
});
});
it('throws not-found when a well-formed ID resolves to nothing', async () => {
mockLogByIdAsync.mockResolvedValue(null);
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
new RegExp(`No Observe event found with ID "${UUID_ID}"`)
);
});
it('surfaces the plan-gate message when the lookup is not available on the plan', async () => {
mockLogByIdAsync.mockRejectedValueOnce(planGateError());
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
/Subscription to EAS is required/
);
});
it('wraps an unexpected server error with an ID-specific message and preserves the request ID', async () => {
const serverError = new CombinedError({
graphQLErrors: [
new GraphQLError('unexpected server error', null, null, null, null, null, {
requestId: 'req-123',
}),
],
});
mockLogByIdAsync.mockRejectedValue(serverError);
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
new RegExp(
`Could not retrieve Observe event with ID "${UUID_ID}"[\\s\\S]*unexpected server error \\(Request ID: req-123\\)`
)
);
});
});
@@ -115,6 +115,25 @@ describe(ObserveEvents, () => {
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
});
it('passes --environment to the custom events filter', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [{ id: 'evt-1' } as any],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const command = createCommand(['my_event', '--environment', 'production']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.environment).toBe('production');
});
it('passes --environment to customEventNamesAsync when listing event names', async () => {
const command = createCommand(['--environment', 'production']);
await command.runAsync();
expect(mockCustomEventNamesAsync.mock.calls[0][1].environment).toBe('production');
});
it('routes to customEventNamesAsync when no positional arg is provided', async () => {
const command = createCommand([]);
await command.runAsync();
@@ -164,7 +183,8 @@ describe(ObserveEvents, () => {
appId: projectId,
startTime: '2025-06-08T12:00:00.000Z',
endTime: '2025-06-15T12:00:00.000Z',
platform: AppObservePlatform.Ios,
platforms: [AppObservePlatform.Ios],
environment: undefined,
});
jest.useRealTimers();
@@ -231,7 +251,20 @@ describe(ObserveEvents, () => {
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Ios);
expect(options.platforms).toEqual([AppObservePlatform.Ios]);
});
it('passes --platform apple as every Apple platform', async () => {
const command = createCommand(['my_event', '--platform', 'apple']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.platforms).toEqual([
AppObservePlatform.Ios,
AppObservePlatform.Ipados,
AppObservePlatform.Tvos,
AppObservePlatform.Macos,
]);
});
it('passes --app-version', async () => {
@@ -242,6 +275,14 @@ describe(ObserveEvents, () => {
expect(options.appVersion).toBe('2.1.0');
});
it('passes --build-number', async () => {
const command = createCommand(['my_event', '--build-number', '42']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.buildNumber).toBe('42');
});
it('passes --update-id', async () => {
const command = createCommand(['my_event', '--update-id', 'update-xyz']);
await command.runAsync();
@@ -258,12 +299,12 @@ describe(ObserveEvents, () => {
expect(options.sessionId).toBe('session-xyz');
});
it('does not pass platform, appVersion, updateId, or sessionId when flags are not provided', async () => {
it('does not pass platforms, appVersion, updateId, or sessionId when flags are not provided', async () => {
const command = createCommand(['my_event']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.platform).toBeUndefined();
expect(options.platforms).toBeUndefined();
expect(options.appVersion).toBeUndefined();
expect(options.updateId).toBeUndefined();
expect(options.sessionId).toBeUndefined();
@@ -3,11 +3,12 @@ import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform } from '../../../graphql/generated';
import { AppObservePlatform } from '../../../graphql/generated';
import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import { ObservePlatformTarget } from '../../../observe/platforms';
import ObserveMetricsSummary from '../metrics-summary';
jest.mock('../../../observe/fetchMetrics', () => {
@@ -31,6 +32,10 @@ const mockBuildObserveMetricsSummaryJson = jest.mocked(buildObserveMetricsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function target(platform: AppObservePlatform): ObservePlatformTarget {
return { key: platform, platforms: [platform] };
}
describe(ObserveMetricsSummary, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
@@ -83,7 +88,7 @@ describe(ObserveMetricsSummary, () => {
expect(mockFetchObserveMetricsSummaryAsync).toHaveBeenCalledTimes(1);
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
expect(platforms).toEqual([target(AppObservePlatform.Android), target(AppObservePlatform.Ios)]);
jest.useRealTimers();
});
@@ -93,7 +98,7 @@ describe(ObserveMetricsSummary, () => {
await command.runAsync();
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android]);
expect(platforms).toEqual([target(AppObservePlatform.Android)]);
});
it('queries only iOS when --platform ios is passed', async () => {
@@ -101,7 +106,40 @@ describe(ObserveMetricsSummary, () => {
await command.runAsync();
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Ios]);
expect(platforms).toEqual([target(AppObservePlatform.Ios)]);
});
it('queries one combined target covering every Apple platform when --platform apple is passed', async () => {
const command = createCommand(['--platform', 'apple']);
await command.runAsync();
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
expect(platforms).toEqual([
{
key: 'APPLE',
platforms: [
AppObservePlatform.Ios,
AppObservePlatform.Ipados,
AppObservePlatform.Tvos,
AppObservePlatform.Macos,
],
},
]);
});
it('queries only macOS when --platform macos is passed', async () => {
const command = createCommand(['--platform', 'macos']);
await command.runAsync();
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
expect(platforms).toEqual([target(AppObservePlatform.Macos)]);
});
it('passes --environment through to fetchObserveMetricsAsync', async () => {
const command = createCommand(['--environment', 'production']);
await command.runAsync();
expect(mockFetchObserveMetricsSummaryAsync.mock.calls[0][6]).toBe('production');
});
it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => {
@@ -4,8 +4,8 @@ import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import {
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObserveMetricsListOrderByField,
AppObserveOrderDirection,
AppObservePlatform,
} from '../../../graphql/generated';
import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents';
@@ -135,6 +135,14 @@ describe(ObserveMetrics, () => {
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('passes --environment to the events filter', async () => {
const command = createCommand(['tti', '--environment', 'production']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.environment).toBe('production');
});
it('defaults endTime to now when only --start is provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
@@ -185,20 +193,20 @@ describe(ObserveMetrics, () => {
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
it('passes --platform ios to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--platform', 'ios']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Ios);
expect(options.platforms).toEqual([AppObservePlatform.Ios]);
});
it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => {
it('passes --platform android to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--platform', 'android']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Android);
expect(options.platforms).toEqual([AppObservePlatform.Android]);
});
it('passes --app-version to fetchObserveEventsAsync', async () => {
@@ -209,6 +217,14 @@ describe(ObserveMetrics, () => {
expect(options.appVersion).toBe('2.1.0');
});
it('passes --build-number to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--build-number', '42']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.buildNumber).toBe('42');
});
it('passes --update-id to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--update-id', 'update-xyz']);
await command.runAsync();
@@ -217,12 +233,12 @@ describe(ObserveMetrics, () => {
expect(options.updateId).toBe('update-xyz');
});
it('does not pass platform, appVersion, or updateId when flags are not provided', async () => {
it('does not pass platforms, appVersion, or updateId when flags are not provided', async () => {
const command = createCommand(['tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBeUndefined();
expect(options.platforms).toBeUndefined();
expect(options.appVersion).toBeUndefined();
expect(options.updateId).toBeUndefined();
});
@@ -231,8 +247,8 @@ describe(ObserveMetrics, () => {
const mockEvents = [
{
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
name: 'expo.app_startup.tti',
value: 1.23,
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
@@ -274,8 +290,8 @@ describe(ObserveMetrics, () => {
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.orderBy).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
});
});
@@ -299,29 +315,29 @@ describe(ObserveMetrics, () => {
describe(resolveOrderBy, () => {
it('resolves lowercase "slowest" to MetricValue DESC', () => {
expect(resolveOrderBy('slowest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
});
});
it('resolves lowercase "fastest" to MetricValue ASC', () => {
expect(resolveOrderBy('fastest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Asc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Asc,
});
});
it('resolves lowercase "newest" to Timestamp DESC', () => {
expect(resolveOrderBy('newest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Timestamp,
direction: AppObserveOrderDirection.Desc,
});
});
it('resolves lowercase "oldest" to Timestamp ASC', () => {
expect(resolveOrderBy('oldest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
field: AppObserveMetricsListOrderByField.Timestamp,
direction: AppObserveOrderDirection.Asc,
});
});
});
@@ -3,7 +3,7 @@ import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform } from '../../../graphql/generated';
import { AppObservePlatform } from '../../../graphql/generated';
import { fetchObserveNavigationRoutesAsync } from '../../../observe/fetchNavigationRoutes';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
import {
@@ -11,6 +11,7 @@ import {
buildObserveNavigationRoutesTable,
} from '../../../observe/formatNavigationRoutes';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import { ObservePlatformTarget } from '../../../observe/platforms';
import ObserveRoutes from '../routes';
jest.mock('../../../observe/fetchNavigationRoutes');
@@ -31,6 +32,10 @@ const mockBuildObserveNavigationRoutesJson = jest.mocked(buildObserveNavigationR
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function target(platform: AppObservePlatform): ObservePlatformTarget {
return { key: platform, platforms: [platform] };
}
describe(ObserveRoutes, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
@@ -78,7 +83,10 @@ describe(ObserveRoutes, () => {
expect(mockFetchObserveNavigationRoutesAsync).toHaveBeenCalledTimes(1);
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
expect(options.platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
expect(options.targets).toEqual([
target(AppObservePlatform.Android),
target(AppObservePlatform.Ios),
]);
expect(options.limit).toBe(50);
const tableCall = mockBuildObserveNavigationRoutesTable.mock.calls[0];
@@ -95,7 +103,7 @@ describe(ObserveRoutes, () => {
await command.runAsync();
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
expect(options.platforms).toEqual([AppPlatform.Ios]);
expect(options.targets).toEqual([target(AppObservePlatform.Ios)]);
});
it('resolves --metric short aliases to navigation metric full names and deduplicates', async () => {
@@ -174,6 +182,14 @@ describe(ObserveRoutes, () => {
expect(options.buildNumber).toBe('42');
});
it('passes --environment through to the fetcher', async () => {
const command = createCommand(['--environment', 'production']);
await command.runAsync();
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
expect(options.environment).toBe('production');
});
it('passes --route-name flags through as routeNames array', async () => {
const command = createCommand(['--route-name', '/home', '--route-name', '/profile']);
await command.runAsync();
@@ -56,10 +56,10 @@ const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeMetricEvent(overrides: any = {}): any {
return {
__typename: 'AppObserveEvent',
__typename: 'AppObserveMetric',
id: 'evt-m-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
name: 'expo.app_startup.tti',
value: 1.23,
timestamp: '2025-01-15T10:00:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
@@ -78,9 +78,9 @@ function makeMetricEvent(overrides: any = {}): any {
function makeCustomEvent(overrides: any = {}): any {
return {
__typename: 'AppObserveCustomEvent',
__typename: 'AppObserveUserEvent',
id: 'evt-c-1',
eventName: 'login_pressed',
name: 'login_pressed',
timestamp: '2025-01-15T10:00:00.000Z',
sessionId: 'session-log-1',
severityNumber: null,
@@ -239,6 +239,11 @@ describe(ObserveSession, () => {
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
});
it('rejects --environment when a session ID is also provided', async () => {
const command = createCommand(['session-abc', '--environment', 'production']);
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
});
it('picker mode with --event-name tti fetches metric events and threads selected sessionId', async () => {
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
makeMetricEvent({ sessionId: 'picked-session-1' }),
@@ -284,9 +289,7 @@ describe(ObserveSession, () => {
it('picker mode without --event-name or --sort prompts for event name, then sort, then event', async () => {
mockCustomEventNamesAsync.mockResolvedValue({
names: [
{ __typename: 'AppObserveCustomEventName', eventName: 'login_pressed', count: 12 } as any,
],
names: [{ __typename: 'AppObserveUserEventName', name: 'login_pressed', count: 12 } as any],
isTruncated: false,
});
// 1) event-name picker → metric choice
@@ -1,12 +1,13 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform } from '../../../graphql/generated';
import { AppObservePlatform } from '../../../graphql/generated';
import { fetchObserveVersionsAsync } from '../../../observe/fetchVersions';
import {
buildObserveVersionsJson,
buildObserveVersionsTable,
} from '../../../observe/formatVersions';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import { ObservePlatformTarget } from '../../../observe/platforms';
import ObserveVersions from '../versions';
jest.mock('../../../observe/fetchVersions');
@@ -23,6 +24,10 @@ const mockBuildObserveVersionsJson = jest.mocked(buildObserveVersionsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function target(platform: AppObservePlatform): ObservePlatformTarget {
return { key: platform, platforms: [platform] };
}
describe(ObserveVersions, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
@@ -35,11 +40,12 @@ describe(ObserveVersions, () => {
function createCommand(argv: string[]): ObserveVersions {
const command = new ObserveVersions(argv, mockConfig);
// @ts-expect-error getContextAsync is a protected method
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
jest
.spyOn(command as any, 'getContextAsync')
.mockImplementation(async (_commandClass: any, { projectIdOverride }: any) => ({
projectId: projectIdOverride ?? projectId,
loggedIn: { graphqlClient },
}));
return command;
}
@@ -52,7 +58,7 @@ describe(ObserveVersions, () => {
expect(mockFetchObserveVersionsAsync).toHaveBeenCalledTimes(1);
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
expect(platforms).toEqual([target(AppObservePlatform.Android), target(AppObservePlatform.Ios)]);
jest.useRealTimers();
});
@@ -62,7 +68,7 @@ describe(ObserveVersions, () => {
await command.runAsync();
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
expect(platforms).toEqual([AppPlatform.Android]);
expect(platforms).toEqual([target(AppObservePlatform.Android)]);
});
it('queries only iOS when --platform ios is passed', async () => {
@@ -70,7 +76,14 @@ describe(ObserveVersions, () => {
await command.runAsync();
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
expect(platforms).toEqual([AppPlatform.Ios]);
expect(platforms).toEqual([target(AppObservePlatform.Ios)]);
});
it('passes --environment through to fetchObserveVersionsAsync', async () => {
const command = createCommand(['--environment', 'production']);
await command.runAsync();
expect(mockFetchObserveVersionsAsync.mock.calls[0][5]).toBe('production');
});
it('uses default time range (60 days back) when no --start/--end flags', async () => {
@@ -164,7 +177,7 @@ describe(ObserveVersions, () => {
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
mockFetchObserveVersionsAsync.mockResolvedValue([
{
platform: AppPlatform.Ios,
platform: AppObservePlatform.Ios,
appVersions: [
{
appVersion: '1.0.0',
@@ -0,0 +1,157 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import { AppObserveErrorSeverity } from '../../graphql/generated';
import Log from '../../log';
import {
fetchObserveErrorGroupsAsync,
fetchObserveErrorOccurrencesAsync,
} from '../../observe/fetchErrors';
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
import {
buildObserveErrorGroupsJson,
buildObserveErrorGroupsTable,
buildObserveErrorOccurrencesJson,
buildObserveErrorOccurrencesTable,
} from '../../observe/formatErrors';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { observePlatformsFromFlag } from '../../observe/platforms';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const SEVERITY_BY_FLAG: Record<string, AppObserveErrorSeverity> = {
fatal: AppObserveErrorSeverity.Fatal,
error: AppObserveErrorSeverity.Error,
};
const DEFAULT_OCCURRENCES_LIMIT = 10;
export default class ObserveErrors extends EasCommand {
static override description =
'display error and exception issue groups (grouped by fingerprint) for the app';
static override flags = {
...ObservePlatformFlag,
fingerprint: Flags.string({
description:
'Show individual occurrences (with stack traces) for this error group fingerprint instead of the grouped summary',
required: false,
}),
severity: Flags.option({
description: 'Filter by severity (ignored when --fingerprint is set)',
options: Object.keys(SEVERITY_BY_FLAG),
required: false,
})(),
...ObserveAfterFlag,
limit: getLimitFlagWithCustomValues({
defaultTo: DEFAULT_OCCURRENCES_LIMIT,
limit: 100,
}),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveBuildNumberFlag,
...ObserveUpdateIdFlag,
...ObserveEnvironmentFlag,
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveErrors);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveErrors, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (!flags.fingerprint && (flags.after || flags.limit)) {
throw new Error(
'--after or --limit can only be used in combination with the --fingerprint flag.'
);
}
if (json) {
enableJsonOutput();
}
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
if (flags.fingerprint) {
const { occurrences, pageInfo } = await withObservePlanGateHandlingAsync(() =>
fetchObserveErrorOccurrencesAsync(graphqlClient, projectId, {
fingerprint: flags.fingerprint as string,
startTime,
endTime,
platforms: observePlatformsFromFlag(flags.platform),
appVersion: flags['app-version'],
buildNumber: flags['build-number'],
updateId: flags['update-id'],
environment: flags.environment,
limit: flags.limit ?? DEFAULT_OCCURRENCES_LIMIT,
...(flags.after && { after: flags.after }),
})
);
if (json) {
printJsonOnlyOutput(buildObserveErrorOccurrencesJson(occurrences, pageInfo));
} else {
Log.addNewLineIfNone();
Log.log(
buildObserveErrorOccurrencesTable(occurrences, pageInfo, {
fingerprint: flags.fingerprint,
daysBack,
startTime,
endTime,
})
);
}
return;
}
const { groups, isTruncated } = await withObservePlanGateHandlingAsync(() =>
fetchObserveErrorGroupsAsync(graphqlClient, projectId, {
startTime,
endTime,
platforms: observePlatformsFromFlag(flags.platform),
appVersion: flags['app-version'],
buildNumber: flags['build-number'],
updateId: flags['update-id'],
environment: flags.environment,
...(flags.severity && { severity: SEVERITY_BY_FLAG[flags.severity] }),
})
);
if (json) {
printJsonOnlyOutput(buildObserveErrorGroupsJson(groups, isTruncated));
} else {
Log.addNewLineIfNone();
Log.log(buildObserveErrorGroupsTable(groups, { daysBack, startTime, endTime }));
if (isTruncated) {
Log.warn('Result is truncated; not all error groups are shown.');
}
}
}
}
@@ -0,0 +1,169 @@
import { Args } from '@oclif/core';
import { validate as isUuid } from 'uuid';
import EasCommand from '../../commandUtils/EasCommand';
import { EasCommandError } from '../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { GraphqlError } from '../../graphql/client';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import Log from '../../log';
import {
buildObserveCustomEventDetail,
buildObserveCustomEventJson,
} from '../../observe/formatCustomEvents';
import { buildObserveErrorDetail, buildObserveErrorJson } from '../../observe/formatErrors';
import { buildObserveEventDetail, buildObserveEventJson } from '../../observe/formatEvents';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ObserveEvent extends EasCommand {
static override description =
'display a single Observe event (metric, log, or error) by its ID. IDs are included in event data when the `--json` flag is passed to `eas observe:session`, `eas observe:metrics`, or `eas observe:events`.';
static override args = {
id: Args.string({
description: 'ID of the event to display',
required: true,
}),
};
static override flags = {
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveEvent);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveEvent, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (json) {
enableJsonOutput();
}
const id = args.id;
// A log event ID (user event or error) is a UUID; a metric event ID is
// base64url-encoded JSON. Route to the matching query so we never issue the
// query that is guaranteed to fail for this ID, and reject anything that is
// neither up front.
if (isUuid(id)) {
const log = await fetchObserveEventAsync(id, () =>
ObserveQuery.logByIdAsync(graphqlClient, { appId: projectId, id })
);
if (!log) {
throw eventNotFoundError(id);
}
if (log.__typename === 'AppObserveError') {
if (json) {
printJsonOnlyOutput({ type: 'error', event: buildObserveErrorJson(log) });
} else {
Log.addNewLineIfNone();
Log.log(buildObserveErrorDetail(log));
}
} else if (json) {
printJsonOnlyOutput({ type: 'log', event: buildObserveCustomEventJson(log) });
} else {
Log.addNewLineIfNone();
Log.log(buildObserveCustomEventDetail(log));
}
return;
}
if (parsesAsBase64(id)) {
const event = await fetchObserveEventAsync(id, () =>
ObserveQuery.metricByIdAsync(graphqlClient, { appId: projectId, id })
);
if (!event) {
throw eventNotFoundError(id);
}
if (json) {
printJsonOnlyOutput({ type: 'metric', event: buildObserveEventJson(event) });
} else {
Log.addNewLineIfNone();
Log.log(buildObserveEventDetail(event));
}
return;
}
throw new EasCommandError(
`"${id}" is not a valid Observe event ID. IDs come from \`eas observe:events\` or \`eas observe:session\`.`
);
}
}
/**
* A metric event ID is canonical base64url (of JSON). Round-tripping rejects
* strings that only coincidentally contain base64 characters, so a value that
* is neither a UUID nor this is treated as an invalid ID.
*/
function parsesAsBase64(id: string): boolean {
if (id.length === 0) {
return false;
}
return Buffer.from(id, 'base64url').toString('base64url') === id;
}
function eventNotFoundError(id: string): EasCommandError {
return new EasCommandError(
`No Observe event found with ID "${id}". IDs come from \`eas observe:events\` or \`eas observe:session\`, and events age out of retention.`
);
}
/**
* Runs an Observe event lookup, translating plan-gate rejections to their
* upgrade message and any other server error into an actionable message that
* preserves the underlying error and request ID for support.
*/
async function fetchObserveEventAsync<T>(id: string, fn: () => Promise<T>): Promise<T> {
try {
return await withObservePlanGateHandlingAsync(fn);
} catch (error) {
if (error instanceof EasCommandError) {
throw error;
}
throw new EasCommandError(
`Could not retrieve Observe event with ID "${id}". ` +
'The ID may be invalid or the event may not exist (events also age out of retention). ' +
'Verify it was copied in full from `eas observe:events` or `eas observe:session`.' +
`\n\n${describeObserveServerError(error)}`
);
}
}
/**
* Extract a human-readable description from a GraphQL/server error, including
* the request ID(s) so a support request can reference them. Falls back to the
* error's own message for non-GraphQL errors.
*/
function describeObserveServerError(error: unknown): string {
if (error instanceof GraphqlError && error.graphQLErrors.length > 0) {
return error.graphQLErrors
.map(graphQLError => {
const message = graphQLError.message.replace('[GraphQL] ', '');
const requestId = graphQLError.extensions?.requestId;
return requestId ? `${message} (Request ID: ${String(requestId)})` : message;
})
.join('\n');
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}
+20 -17
View File
@@ -3,6 +3,7 @@ import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
@@ -12,8 +13,9 @@ import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents';
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
@@ -26,8 +28,7 @@ import {
buildObserveCustomEventsTable,
} from '../../observe/formatCustomEvents';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { appObservePlatformFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { observePlatformsFromFlag } from '../../observe/platforms';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -53,7 +54,9 @@ export default class ObserveEvents extends EasCommand {
}),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveBuildNumberFlag,
...ObserveUpdateIdFlag,
...ObserveEnvironmentFlag,
'session-id': Flags.string({
description:
'Filter by session ID. When no event name is given, lists the events in the session instead of the event-name summary.',
@@ -63,7 +66,7 @@ export default class ObserveEvents extends EasCommand {
'When no event name argument is provided, list all events across all event names instead of a summary of event names + counts.',
default: false,
}),
...ObserveProjectIdFlag,
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -72,10 +75,6 @@ export default class ObserveEvents extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveEvents);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
@@ -86,12 +85,12 @@ export default class ObserveEvents extends EasCommand {
);
}
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveEvents,
loggedInOnlyContextDefinition: ObserveEvents.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveEvents, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (json) {
@@ -100,7 +99,7 @@ export default class ObserveEvents extends EasCommand {
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platform = appObservePlatformFromFlag(flags.platform);
const platforms = observePlatformsFromFlag(flags.platform);
// A session ID narrows to a single session, so show that session's events
// (like --all-events) instead of the account-wide name+count summary, which
@@ -111,7 +110,8 @@ export default class ObserveEvents extends EasCommand {
appId: projectId,
startTime,
endTime,
platform,
platforms,
environment: flags.environment,
})
);
@@ -138,10 +138,12 @@ export default class ObserveEvents extends EasCommand {
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
platforms,
appVersion: flags['app-version'],
buildNumber: flags['build-number'],
updateId: flags['update-id'],
sessionId: flags['session-id'],
environment: flags.environment,
})
);
@@ -150,7 +152,8 @@ export default class ObserveEvents extends EasCommand {
appId: projectId,
startTime,
endTime,
platform,
platforms,
environment: flags.environment,
});
if (json) {
@@ -3,13 +3,14 @@ import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import Log from '../../log';
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
import {
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
} from '../../observe/flags';
import {
@@ -20,8 +21,7 @@ import {
} from '../../observe/formatMetrics';
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { appPlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -62,7 +62,8 @@ export default class ObserveMetricsSummary extends EasCommand {
options: DEFAULT_STATS_JSON,
})(),
...ObserveTimeRangeFlags,
...ObserveProjectIdFlag,
...ObserveEnvironmentFlag,
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -71,20 +72,16 @@ export default class ObserveMetricsSummary extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveMetricsSummary);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveMetricsSummary,
loggedInOnlyContextDefinition: ObserveMetricsSummary.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveMetricsSummary, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (json) {
@@ -97,7 +94,7 @@ export default class ObserveMetricsSummary extends EasCommand {
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platforms = appPlatformsFromFlag(flags.platform);
const targets = observePlatformTargetsFromFlag(flags.platform);
const { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts } =
await withObservePlanGateHandlingAsync(() =>
@@ -105,9 +102,10 @@ export default class ObserveMetricsSummary extends EasCommand {
graphqlClient,
projectId,
metricNames,
platforms,
targets,
startTime,
endTime
endTime,
flags.environment
)
);
@@ -4,6 +4,7 @@ import EasCommand from '../../commandUtils/EasCommand';
import { EasCommandError } from '../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
@@ -17,16 +18,16 @@ import {
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
import { appObservePlatformFromFlag, appPlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { observePlatformTargetsFromFlag, observePlatformsFromFlag } from '../../observe/platforms';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -59,8 +60,10 @@ export default class ObserveMetrics extends EasCommand {
}),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveBuildNumberFlag,
...ObserveUpdateIdFlag,
...ObserveProjectIdFlag,
...ObserveEnvironmentFlag,
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -69,20 +72,16 @@ export default class ObserveMetrics extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveMetrics);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveMetrics,
loggedInOnlyContextDefinition: ObserveMetrics.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveMetrics, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (json) {
@@ -108,8 +107,8 @@ export default class ObserveMetrics extends EasCommand {
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platform = appObservePlatformFromFlag(flags.platform);
const platforms = appPlatformsFromFlag(flags.platform);
const platforms = observePlatformsFromFlag(flags.platform);
const targets = observePlatformTargetsFromFlag(flags.platform);
const [{ events, pageInfo }, totalEventCount] = await withObservePlanGateHandlingAsync(() =>
Promise.all([
@@ -120,17 +119,20 @@ export default class ObserveMetrics extends EasCommand {
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
platforms,
appVersion: flags['app-version'],
buildNumber: flags['build-number'],
updateId: flags['update-id'],
environment: flags.environment,
}),
fetchTotalEventCountAsync(
graphqlClient,
projectId,
metricName,
platforms,
targets,
startTime,
endTime
endTime,
flags.environment
),
])
);
+15 -18
View File
@@ -3,6 +3,7 @@ import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
@@ -11,8 +12,9 @@ import { fetchObserveNavigationRoutesAsync } from '../../observe/fetchNavigation
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
@@ -25,8 +27,7 @@ import {
} from '../../observe/formatNavigationRoutes';
import { NAVIGATION_METRIC_ALIASES, resolveNavigationMetricName } from '../../observe/metricNames';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { appPlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -61,16 +62,15 @@ export default class ObserveRoutes extends EasCommand {
}),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveBuildNumberFlag,
...ObserveUpdateIdFlag,
'build-number': Flags.string({
description: 'Filter by app build number',
}),
'route-name': Flags.string({
description:
'Filter by route name (can be specified multiple times to include several routes)',
multiple: true,
}),
...ObserveProjectIdFlag,
...ObserveEnvironmentFlag,
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -79,20 +79,16 @@ export default class ObserveRoutes extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveRoutes);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveRoutes,
loggedInOnlyContextDefinition: ObserveRoutes.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveRoutes, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (json) {
@@ -112,19 +108,20 @@ export default class ObserveRoutes extends EasCommand {
: undefined;
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platforms = appPlatformsFromFlag(flags.platform);
const targets = observePlatformTargetsFromFlag(flags.platform);
const { routes, pageInfoByPlatform } = await withObservePlanGateHandlingAsync(() =>
fetchObserveNavigationRoutesAsync(graphqlClient, projectId, {
startTime,
endTime,
platforms,
targets,
limit: flags.limit ?? DEFAULT_ROUTES_LIMIT,
...(flags.after && { after: flags.after }),
appVersion: flags['app-version'],
updateId: flags['update-id'],
buildNumber: flags['build-number'],
routeNames,
environment: flags.environment,
})
);
@@ -4,6 +4,7 @@ import EasCommand from '../../commandUtils/EasCommand';
import { EasCommandError } from '../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
@@ -16,7 +17,7 @@ import {
fetchSessionMetricCandidatesAsync,
verifyObserveSessionAccessAsync,
} from '../../observe/fetchSessions';
import { ObserveProjectIdFlag, ObserveTimeRangeFlags } from '../../observe/flags';
import { ObserveEnvironmentFlag, ObserveTimeRangeFlags } from '../../observe/flags';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import {
buildObserveSessionEventsJson,
@@ -29,7 +30,6 @@ import {
isKnownMetricName,
resolveMetricName,
} from '../../observe/metricNames';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { ExpoChoice, selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -66,7 +66,8 @@ export default class ObserveSession extends EasCommand {
'Metric or log event name to pick candidate sessions by (e.g. tti, cold_launch, login_pressed). If omitted in interactive mode, you will be prompted.',
}),
...ObserveTimeRangeFlags,
...ObserveProjectIdFlag,
...ObserveEnvironmentFlag,
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -75,20 +76,16 @@ export default class ObserveSession extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveSession);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveSession,
loggedInOnlyContextDefinition: ObserveSession.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveSession, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (json) {
@@ -102,10 +99,11 @@ export default class ObserveSession extends EasCommand {
flags.sort !== undefined ||
flags.days !== undefined ||
flags.start !== undefined ||
flags.end !== undefined;
flags.end !== undefined ||
flags.environment !== undefined;
if (pickerFlagsProvided) {
throw new EasCommandError(
'The picker flags (--event-name, --sort, --days, --start, --end) describe how to find a session and cannot be combined with a session ID argument.'
'The picker flags (--event-name, --sort, --days, --start, --end, --environment) describe how to find a session and cannot be combined with a session ID argument.'
);
}
sessionId = args.sessionId;
@@ -127,6 +125,7 @@ export default class ObserveSession extends EasCommand {
eventNameFlag: flags['event-name'],
sort: flags.sort,
timeRangeFlags: { days: flags.days, start: flags.start, end: flags.end },
environment: flags.environment,
});
}
@@ -172,18 +171,20 @@ async function pickSessionIdInteractivelyAsync({
eventNameFlag,
sort,
timeRangeFlags,
environment,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
eventNameFlag: string | undefined;
sort: string | undefined;
timeRangeFlags: { days: number | undefined; start: string | undefined; end: string | undefined };
environment: string | undefined;
}): Promise<string> {
const { startTime, endTime } = resolveTimeRange(timeRangeFlags);
const eventNameChoice: EventNameChoice = eventNameFlag
? { name: eventNameFlag, isMetric: isKnownMetricName(eventNameFlag) }
: await promptForEventNameAsync({ graphqlClient, projectId, startTime, endTime });
: await promptForEventNameAsync({ graphqlClient, projectId, startTime, endTime, environment });
let sortValue: string;
if (sort) {
@@ -208,6 +209,7 @@ async function pickSessionIdInteractivelyAsync({
startTime,
endTime,
limit: PICKER_CANDIDATE_LIMIT,
environment,
})
).map(event => ({ sessionId: event.sessionId, title: formatMetricCandidateTitle(event) }))
: (
@@ -217,6 +219,7 @@ async function pickSessionIdInteractivelyAsync({
startTime,
endTime,
limit: PICKER_CANDIDATE_LIMIT,
environment,
})
).map(event => ({ sessionId: event.sessionId, title: formatLogCandidateTitle(event) }));
@@ -254,16 +257,19 @@ async function promptForEventNameAsync({
projectId,
startTime,
endTime,
environment,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
startTime: string;
endTime: string;
environment: string | undefined;
}): Promise<EventNameChoice> {
const { names: customEventNames } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
appId: projectId,
startTime,
endTime,
environment,
});
const metricChoices: ExpoChoice<EventNameChoice>[] = Object.entries(METRIC_SHORT_NAMES).map(
@@ -273,12 +279,10 @@ async function promptForEventNameAsync({
})
);
const logChoices: ExpoChoice<EventNameChoice>[] = customEventNames.map(
({ eventName, count }) => ({
title: `${eventName} (${count} log event${count === 1 ? '' : 's'})`,
value: { name: eventName, isMetric: false },
})
);
const logChoices: ExpoChoice<EventNameChoice>[] = customEventNames.map(({ name, count }) => ({
title: `${name} (${count} log event${count === 1 ? '' : 's'})`,
value: { name, isMetric: false },
}));
const choices = [...metricChoices, ...logChoices];
if (choices.length === 0) {
Loaded 100 of 207 files, more files were not shown because too many files have changed in this diff. Show more