Compare commits

...
35 Commits
Author SHA1 Message Date
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
Expo CI 74292d2ace v23.2.0 2026-08-31 20:25:25 +00:00
Szymon Dziedzic b0b7cad316 [eas-cli] Warn about Android simulator parity (#4320)
* [eas-cli] warn about Android simulator parity

* [eas-cli] add Android simulator warning changelog
2026-08-31 22:22:53 +02:00
expo-tuft[bot]andtchayen 926887d14e [eas-cli] Add --tag to simulator session commands (#4318)
Lets projects with several app variants group and filter their sessions.

Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
Co-authored-by: tchayen <[email protected]>
2026-08-31 22:40:52 +03:00
Gabe DebesandTomasz Mazur 2e6bb2d276 [eas-cli] Add --ssh to workflow:run (#4033)
* [eas-cli] add --ssh to workflow:run

* [eas-cli] Restore WorkflowRunSshInput types dropped by staging regen

Staging schema does not include the --ssh input yet (universe #29128),
but the CLI stack already depends on it. Keep the types until that ships.

* Regenerate GraphQL

* Dedupe changelog

---------

Co-authored-by: Tomasz Mazur <[email protected]>
2026-08-31 17:30:17 +02:00
Gabe DebesandTomasz Mazur 30656509ac [eas-cli] Add workflow:ssh command (#4032)
* [eas-cli] add workflow:ssh command

* Poll for an open SSH session before sleeping.

Avoids a needless wait when the first connect-info fetch is already ready.

* Update SSH wait test for poll-before-sleep.

The wait loop now polls first, so keep one pending poll before ready.

* Cover WorkflowJobSshQuery error and turtleBuild session paths.

* [eas-cli] Regen GraphQL types from staging (TurtleSshSession)

* [eas-cli] address workflow:ssh review feedback

* [eas-cli] drop the GET_BUILD check from sshRequested

* [eas-cli] accept a job run id in workflow:ssh

* Use URL to parse the host

* Regenerate GraphQL

* Move changelog entry

---------

Co-authored-by: Tomasz Mazur <[email protected]>
2026-08-31 17:30:17 +02:00
66e3f1ad7b [build-tools] Use expo-device-hub for Android web previews (#4304)
Co-authored-by: szdziedzic <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-08-31 15:49:49 +02:00
Tomasz Mazur 9aadf2d467 [eas-cli] Stream workflow run logs in real time (#4228)
* [eas-cli] Add centrifuge and ws to deps

* [eas-cli] Stream workflow job logs while waiting for a run

* [eas-cli] Add changelog entry for realtime workflow logs

* [eas-cli] Make msg optional and fix flickering

* [eas-cli] Fetch job logs without casing on job type

* [eas-cli] Ingest realtime incrementally instead of rebuilding full logs on every message (#4293)
2026-08-31 13:38:38 +00:00
Stanisław Chmiela ba80e57753 [eas-cli] Update GraphQL schema for web preview sessions (#4315)
Updated GraphQL and adjusted code.
2026-08-31 15:20:33 +02:00
Expo CI 50c76f41e3 update CHANGELOG.md and bump STAGING and PRODUCTION in cli-versions.json 2026-08-31 11:27:55 +00:00
164 changed files with 20348 additions and 5843 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: |
+40
View File
@@ -6,6 +6,42 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 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))
### 🧹 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))
## [23.1.0](https://github.com/expo/eas-cli/releases/tag/v23.1.0) - 2026-08-31
### 🎉 New features
- [eas-cli] Prompt for the Expo SDK version in `eas new` and add a `--sdk-version` flag. The template now downloads from npm instead of GitHub to support SDK-specific versions. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
@@ -35,6 +71,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
@@ -80,6 +117,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": "22.6.0",
"PRODUCTION": "22.4.0"
"STAGING": "23.2.0",
"PRODUCTION": "23.2.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "23.1.0",
"version": "24.0.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "23.1.0",
"version": "24.0.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.0.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.0.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(
+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();
}
@@ -51,7 +51,7 @@ import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAp
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
@@ -106,7 +106,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createStartIosSimulatorRecordingsBuildFunction(),
createFinishIosSimulatorRecordingsBuildFunction(),
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartServeSimRemoteSessionBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx),
createStartServeSimMetricsBuildFunction(),
createCollectServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),
@@ -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,11 +9,13 @@ import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
import {
ensureFfmpegInstalledOnceAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
@@ -40,14 +42,14 @@ 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(),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
spawnDetached: jest.fn(),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
startServeSimWithTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
@@ -58,6 +60,7 @@ const EXPECTED_EVENT_LOG_PATH = path.join(ARGENT_STATE_DIR, 'tool-server-events.
const mockStopAsync = jest.fn();
const mockTunnelStopAsync = jest.fn();
const mockPreviewStopAsync = jest.fn();
describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
beforeEach(async () => {
@@ -68,6 +71,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(pollArgentArtifactsForUploadAsync).mockResolvedValue(undefined);
mockStopAsync.mockResolvedValue(undefined);
mockTunnelStopAsync.mockResolvedValue(undefined);
mockPreviewStopAsync.mockResolvedValue(undefined);
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({
stopAsync: mockStopAsync,
getLastEventObservedAt: () => undefined,
@@ -86,6 +90,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
url: 'https://argent-abc.tunnel.example.com',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.tunnel.example.com',
stopAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
@@ -120,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');
@@ -140,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);
@@ -151,5 +172,17 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://web-preview.tunnel.example.com',
}),
})
);
expect(mockPreviewStopAsync).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,85 @@
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps';
import { type CustomBuildContext } from '../../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
startDeviceWebPreviewWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('../../utils/remoteDeviceRunSession');
const ctx = {} as CustomBuildContext;
const env = {} as BuildStepEnv;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const stopAsync = jest.fn();
async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise<void> {
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
logger,
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: '1.2.3' },
max_duration_seconds: { value: 120 },
},
outputs: {},
env,
} as never
);
}
describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.example.test',
stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
stopAsync.mockResolvedValue(undefined);
});
it.each([
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.LINUX, false],
])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => {
await runAsync(runtimePlatform);
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(ctx, {
runtimePlatform,
baseDomain: 'tunnel.example.com',
env,
logger,
timeoutMs: 60_000,
packageVersion: '1.2.3',
});
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
remoteConfig: { previewUrl: 'https://web-preview.example.test' },
logger,
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
logger,
maxDurationSeconds: 120,
signal: undefined,
});
expect(stopAsync).toHaveBeenCalledTimes(1);
});
});
@@ -23,8 +23,8 @@ import {
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
@@ -107,22 +107,19 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
let eventCollection:
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
| undefined;
try {
// serve-sim is iOS-only — only launch it (and report a webPreviewUrl)
// on Darwin. Android sessions go without a preview URL.
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Web preview URL: ${serveSim.previewUrl}`);
}
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
await uploadRemoteSessionConfigAsync({
ctx,
@@ -130,7 +127,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
webPreviewUrl: webPreview.previewUrl,
},
logger,
});
@@ -163,8 +160,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
: undefined,
});
} finally {
if (serveSim) {
await serveSim.stopAsync();
if (webPreview) {
await webPreview.stopAsync();
}
await agentDeviceTunnel.stopAsync();
if (eventCollection) {
@@ -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,
});
@@ -26,8 +26,8 @@ import {
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -118,7 +118,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
logger,
});
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
try {
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
@@ -128,18 +128,15 @@ export function createStartAppiumRemoteSessionBuildFunction(
logger,
});
switch (runtimePlatform) {
case BuildRuntimePlatform.DARWIN:
serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
});
break;
case BuildRuntimePlatform.LINUX:
break;
}
// expo-device-hub has no serial-selection flag. Device run session workflows must expose
// a single booted Android emulator so the Hub and Appium resolve the same device.
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
});
await uploadRemoteSessionConfigAsync({
ctx,
@@ -151,7 +148,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
'appium:automationName': device.automationName,
'appium:udid': device.udid,
},
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
webPreviewUrl: webPreview.previewUrl,
},
logger,
});
@@ -170,8 +167,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
: undefined,
});
} finally {
if (serveSim) {
await serveSim.stopAsync();
if (webPreview) {
await webPreview.stopAsync();
}
if (appiumTunnel) {
await appiumTunnel.stopAsync();
@@ -20,14 +20,14 @@ 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,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -103,32 +103,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',
@@ -186,7 +193,7 @@ export function createStartArgentRemoteSessionBuildFunction(
});
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
try {
toolsTunnel = await startNgrokTunnelAsync({
port: toolServerPort,
@@ -199,18 +206,14 @@ export function createStartArgentRemoteSessionBuildFunction(
const publicToolsUrl = toolsTunnel.url;
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
// serve-sim is iOS-only — Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = serveSim.previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
}
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
await uploadRemoteSessionConfigAsync({
ctx,
@@ -218,7 +221,7 @@ export function createStartArgentRemoteSessionBuildFunction(
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
...(webPreviewUrl ? { webPreviewUrl } : {}),
webPreviewUrl: webPreview.previewUrl,
},
logger,
});
@@ -238,8 +241,8 @@ export function createStartArgentRemoteSessionBuildFunction(
: undefined,
});
} finally {
if (serveSim) {
await serveSim.stopAsync();
if (webPreview) {
await webPreview.stopAsync();
}
if (toolsTunnel) {
await toolsTunnel.stopAsync();
@@ -297,7 +300,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;
}
@@ -10,22 +10,21 @@ import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
startServeSimWithTunnelAsync,
startDeviceWebPreviewWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartServeSimRemoteSessionBuildFunction(
export function createStartWebPreviewRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_serve_sim_remote_session',
name: 'Start serve-sim remote session',
name: 'Start web preview remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
@@ -38,30 +37,34 @@ export function createStartServeSimRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger }, { inputs, env, signal }) => {
fn: async ({ logger, global }, { inputs, env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
const { runtimePlatform } = global;
logger.info('Starting serve-sim remote session.');
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
await selectXcodeDeveloperDirectoryAsync({ env, logger });
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const serveSim = await startServeSimWithTunnelAsync(ctx, {
const webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
packageVersion,
});
logger.info(`Preview URL: ${serveSim.previewUrl}`);
logger.info(`Preview URL: ${webPreview.previewUrl}`);
try {
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: { previewUrl: serveSim.previewUrl },
remoteConfig: { previewUrl: webPreview.previewUrl },
logger,
});
@@ -73,7 +76,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
signal,
});
} finally {
await serveSim.stopAsync();
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 });
@@ -13,14 +13,17 @@ import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { sleepAsync } from '../../../utils/retry';
import {
createExpoDeviceHubArgs,
createServeSimArgs,
ensureFfmpegInstalledAsync,
fetchServeSimTurnArgsAsync,
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
metricsCorsOriginToServeSimArgs,
startDeviceWebPreviewWithTunnelAsync,
startExpoDeviceHubWithTunnelAsync,
startNgrokTunnelAsync,
turnIceServersToServeSimArgs,
turnIceServersToWebPreviewArgs,
waitForDeviceRunSessionStoppedAsync,
waitForServeSimReadyAsync,
waitForWebPreviewReadyAsync,
} from '../remoteDeviceRunSession';
jest.mock('@ngrok/ngrok');
@@ -173,6 +176,49 @@ describe(createServeSimArgs, () => {
});
});
describe(createExpoDeviceHubArgs, () => {
it('uses the latest Expo package and applies the EAS Android streaming policy', () => {
expect(
createExpoDeviceHubArgs({
port: 4321,
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
})
).toEqual([
'--yes',
'expo-device-hub@latest',
'--port',
'4321',
'--host',
'127.0.0.1',
'--platform',
'android',
'--transport',
'webrtc',
'--webrtc-codec',
'h264',
'--webrtc-ice-policy',
'all',
'--max-dimension',
'960',
'--video-bitrate',
'6000000',
'--video-fps',
'60',
'--hide-sidebar',
'--hide-boot-device',
'--turn-url',
'turns:turn.example.test:443',
]);
});
it('pins the requested package version', () => {
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' }).slice(0, 2)).toEqual([
'--yes',
'[email protected]',
]);
});
});
describe(metricsCorsOriginToServeSimArgs, () => {
it('returns no args when the origin is unset or empty', () => {
expect(metricsCorsOriginToServeSimArgs({} as BuildStepEnv)).toEqual([]);
@@ -200,7 +246,7 @@ describe(metricsCorsOriginToServeSimArgs, () => {
});
});
describe(waitForServeSimReadyAsync, () => {
describe(waitForWebPreviewReadyAsync, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
jest.mocked(sleepAsync).mockReset();
@@ -215,8 +261,9 @@ describe(waitForServeSimReadyAsync, () => {
json: async () => ({ status: 'ready', device: 'DEVICE-A' }),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
await waitForServeSimReadyAsync({
serveSim: { pid: undefined, getOutput: () => '' },
await waitForWebPreviewReadyAsync({
previewServer: { pid: undefined, getOutput: () => '' },
serverName: 'expo-device-hub',
port: 4321,
timeoutMs: 10_000,
});
@@ -261,14 +308,179 @@ describe(startNgrokTunnelAsync, () => {
});
});
describe(turnIceServersToServeSimArgs, () => {
describe(startDeviceWebPreviewWithTunnelAsync, () => {
const baseDomain = 'eas-simulator.ngrok.dev';
const turnArgs = [
'--turn-url',
'turns:turn.example.test:443',
'--turn-username',
'turn-user',
'--turn-credential',
'turn-credential',
];
const metricsCorsArgs = ['--metrics-cors-origin', 'https://metrics.expo.test'];
const env = {
DEVICE_RUN_SESSION_ID: 'drs-id',
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://metrics.expo.test',
NGROK_AUTHTOKEN: 'ngrok-token',
} as unknown as BuildStepEnv;
beforeEach(() => {
jest.mocked(spawn).mockReset();
jest.mocked(ngrok.forward).mockReset();
jest.mocked(turtleFetch).mockReset();
const spawnPromise = Object.assign(Promise.resolve(undefined), {
child: {
pid: undefined,
unref: jest.fn(),
},
});
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
jest.mocked(turtleFetch).mockImplementation(async url => {
if (url.endsWith('/turn-ice-servers')) {
return {
json: async () => ({
data: {
iceServers: [
{
urls: ['turns:turn.example.test:443'],
username: 'turn-user',
credential: 'turn-credential',
},
],
},
}),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
}
return {
json: async () => ({ status: 'ready', device: 'device-id' }),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
});
});
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,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
});
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');
expect(args).toEqual(createExpoDeviceHubArgs({ port, turnArgs, packageVersion }));
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.previewUrl).toBe('https://android-preview.example.test');
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
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);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://ios-preview.example.test',
close,
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
});
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
expect(port).toBeGreaterThan(0);
expect(command).toBe('npx');
expect(args).toEqual(createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }));
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.previewUrl).toBe('https://ios-preview.example.test');
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, () => {
it('returns no args for an empty ICE server list', () => {
expect(turnIceServersToServeSimArgs([])).toEqual([]);
expect(turnIceServersToWebPreviewArgs([])).toEqual([]);
});
it('builds --stun-url and --turn-url flags from Cloudflare ICE servers', () => {
expect(
turnIceServersToServeSimArgs([
turnIceServersToWebPreviewArgs([
{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.cloudflare.com:53'] },
{
urls: [
@@ -293,7 +505,7 @@ describe(turnIceServersToServeSimArgs, () => {
it('emits only --turn-url flags when no credential-less (STUN) entry is present', () => {
expect(
turnIceServersToServeSimArgs([
turnIceServersToWebPreviewArgs([
{
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
username: 'u',
@@ -311,19 +523,19 @@ describe(turnIceServersToServeSimArgs, () => {
});
it('emits only --stun-url when there is no credentialed TURN entry', () => {
expect(turnIceServersToServeSimArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
expect(turnIceServersToWebPreviewArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
'--stun-url',
'stun:stun.cloudflare.com:3478',
]);
});
});
describe(fetchServeSimTurnArgsAsync, () => {
describe(fetchWebPreviewTurnArgsAsync, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it('requests TURN ICE servers from the device run session endpoint and returns serve-sim args', async () => {
it('requests TURN ICE servers from the device run session endpoint and returns web preview args', async () => {
jest.mocked(turtleFetch).mockResolvedValue({
json: async () => ({
data: {
@@ -339,7 +551,7 @@ describe(fetchServeSimTurnArgsAsync, () => {
}),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger: createLoggerMock(),
});
@@ -363,11 +575,11 @@ describe(fetchServeSimTurnArgsAsync, () => {
);
});
it('returns [] and warns when the request fails so serve-sim falls back to P2P/STUN', async () => {
it('returns [] and warns when the request fails so the web preview falls back to P2P/STUN', async () => {
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
const logger = createLoggerMock();
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger,
});
@@ -628,7 +840,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
});
});
describe(ensureFfmpegInstalledAsync, () => {
describe(ensureFfmpegInstalledOnceAsync, () => {
const spawnMock = jest.mocked(spawn);
function spawnResolved(): ReturnType<typeof spawn> {
@@ -647,7 +859,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(),
@@ -660,7 +872,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(),
@@ -681,7 +893,7 @@ describe(ensureFfmpegInstalledAsync, () => {
.mockReturnValueOnce(spawnResolved()) // apt-get update
.mockReturnValueOnce(spawnResolved()); // apt-get install
await ensureFfmpegInstalledAsync({
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: createEnvMock(),
logger: createLoggerMock(),
@@ -703,6 +915,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
@@ -710,7 +949,7 @@ describe(ensureFfmpegInstalledAsync, () => {
.mockReturnValueOnce(spawnResolved()); // apt-get install
const logger = createLoggerMock();
await ensureFfmpegInstalledAsync({
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: createEnvMock(),
logger,
@@ -729,7 +968,7 @@ describe(ensureFfmpegInstalledAsync, () => {
const logger = createLoggerMock();
await expect(
ensureFfmpegInstalledAsync({
ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
logger,
@@ -750,7 +989,7 @@ describe(ensureFfmpegInstalledAsync, () => {
const logger = createLoggerMock();
await expect(
ensureFfmpegInstalledAsync({
ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
logger,
@@ -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'
@@ -19,12 +19,16 @@ import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const WEB_PREVIEW_HOST = '127.0.0.1';
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
const SERVE_SIM_HOST = '127.0.0.1';
const SERVE_SIM_MAX_DIMENSION = '960';
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 = '960';
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
@@ -293,10 +297,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;
}
@@ -329,14 +332,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
@@ -344,7 +348,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,
@@ -363,7 +367,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 });
@@ -373,16 +377,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,
@@ -390,11 +419,12 @@ const TurnIceServersResponseSchema = z.object({
});
/**
* Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the
* Translate Cloudflare ICE servers into web preview CLI flags: `--stun-url` (the
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
* (the entry carrying the short-lived credentials).
* (the entry carrying the short-lived credentials). serve-sim and expo-device-hub
* intentionally expose the same ICE flag contract.
*/
export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string[] {
export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): string[] {
const stunUrls = iceServers
.filter(server => !server.username && !server.credential)
.flatMap(server => server.urls);
@@ -420,14 +450,14 @@ export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string
/**
* Fetch short-lived Cloudflare TURN ICE servers for this job run from www
* (minted on demand, mirroring how the worker fetches project clone URLs) and
* translate them into serve-sim CLI flags.
* translate them into web preview CLI flags.
*
* Best-effort: on any failure we log and return [] so serve-sim falls back to
* its built-in P2P/STUN behavior. The credential is passed to serve-sim as a
* process arg and deliberately not logged (turtle-spawn never logs argv and the
* worker is single-tenant).
* Best-effort: on any failure we log and return [] so the preview server falls
* back to its built-in P2P/STUN behavior. The credential is passed as a process
* arg and deliberately not logged (turtle-spawn never logs argv and the worker
* is single-tenant).
*/
export async function fetchServeSimTurnArgsAsync(
export async function fetchWebPreviewTurnArgsAsync(
ctx: CustomBuildContext,
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
): Promise<string[]> {
@@ -456,9 +486,9 @@ export async function fetchServeSimTurnArgsAsync(
);
const { data } = TurnIceServersResponseSchema.parse(await response.json());
const args = turnIceServersToServeSimArgs(data.iceServers);
const args = turnIceServersToWebPreviewArgs(data.iceServers);
if (args.length > 0) {
logger.info('Configured serve-sim with Cloudflare TURN ICE servers.');
logger.info('Configured the web preview with Cloudflare TURN ICE servers.');
}
return args;
} catch (err) {
@@ -466,7 +496,7 @@ export async function fetchServeSimTurnArgsAsync(
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
logger.warn(
{ err: error },
'Could not fetch Cloudflare TURN ICE servers; serve-sim will fall back to P2P/STUN.'
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
);
return [];
}
@@ -600,6 +630,10 @@ function createServeSimPackageSpec(packageVersion: string | undefined): string {
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
export function createServeSimArgs({
port,
turnArgs = [],
@@ -617,7 +651,7 @@ export function createServeSimArgs({
'--port',
String(port),
'--host',
SERVE_SIM_HOST,
WEB_PREVIEW_HOST,
'--transport',
'webrtc',
'--webrtc-codec',
@@ -635,44 +669,84 @@ export function createServeSimArgs({
];
}
export function createExpoDeviceHubArgs({
port,
turnArgs = [],
packageVersion,
}: {
port: number;
turnArgs?: string[];
packageVersion?: string;
}): string[] {
return [
'--yes',
createExpoDeviceHubPackageSpec(packageVersion),
'--port',
String(port),
'--host',
WEB_PREVIEW_HOST,
'--platform',
'android',
'--transport',
'webrtc',
'--webrtc-codec',
'h264',
'--webrtc-ice-policy',
'all',
'--max-dimension',
EXPO_DEVICE_HUB_MAX_DIMENSION,
'--video-bitrate',
EXPO_DEVICE_HUB_VIDEO_BITRATE,
'--video-fps',
EXPO_DEVICE_HUB_VIDEO_FPS,
'--hide-sidebar',
'--hide-boot-device',
...turnArgs,
];
}
async function findAvailablePortAsync(): Promise<number> {
const server = createServer();
server.unref();
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, SERVE_SIM_HOST, () => resolve());
server.listen(0, WEB_PREVIEW_HOST, () => resolve());
});
const address = server.address();
await new Promise<void>((resolve, reject) => {
server.close(err => (err ? reject(err) : resolve()));
});
if (!address || typeof address === 'string') {
throw new SystemError('Could not allocate a local port for serve-sim.');
throw new SystemError('Could not allocate a local port for the web preview.');
}
return address.port;
}
const ServeSimReadyResponseSchema = z.object({
const WebPreviewReadyResponseSchema = z.object({
status: z.literal('ready'),
device: z.string(),
});
export async function waitForServeSimReadyAsync({
serveSim,
export async function waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
}: {
serveSim: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
serverName: string;
port: number;
timeoutMs: number;
}): Promise<void> {
const readyUrl = `http://${SERVE_SIM_HOST}:${port}/readyz`;
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
if (serveSim.pid !== undefined && !isProcessRunning(serveSim.pid)) {
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
throw new SystemError(
`serve-sim exited before becoming ready. Last output:\n${serveSim.getOutput() || '<empty>'}`
`${serverName} exited before becoming ready. Last output:\n${
previewServer.getOutput() || '<empty>'
}`
);
}
try {
@@ -680,7 +754,7 @@ export async function waitForServeSimReadyAsync({
retries: 0,
timeout: 2_000,
});
ServeSimReadyResponseSchema.parse(await response.json());
WebPreviewReadyResponseSchema.parse(await response.json());
return;
} catch (error) {
lastError = error;
@@ -688,17 +762,75 @@ export async function waitForServeSimReadyAsync({
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for serve-sim readiness at ${readyUrl}${
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
lastError instanceof Error ? `: ${lastError.message}` : ''
}. Last output:\n${serveSim.getOutput() || '<empty>'}`
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
);
}
export type ServeSimPreviewHandle = {
export type DeviceWebPreviewHandle = {
previewUrl: string;
stopAsync: () => Promise<void>;
};
export type ServeSimPreviewHandle = DeviceWebPreviewHandle;
async function startWebPreviewWithTunnelAsync(
ctx: CustomBuildContext,
{
baseDomain,
env,
logger,
timeoutMs,
serverName,
packageSpec,
createArgs,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
serverName: string;
packageSpec: string;
createArgs: (port: number, turnArgs: string[]) => string[];
}
): Promise<DeviceWebPreviewHandle> {
const port = await findAvailablePortAsync();
logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`);
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
const previewServer = spawnDetached({
command: 'npx',
args: createArgs(port, turnArgs),
env,
});
try {
logger.info(`Waiting for ${serverName} to become ready.`);
await waitForWebPreviewReadyAsync({ previewServer, serverName, port, timeoutMs });
const tunnel = await startNgrokTunnelAsync({
port,
subdomainPrefix: 'web-preview',
baseDomain,
authtoken: getNgrokAuthtokenOrThrow(env),
logger,
});
return {
previewUrl: tunnel.url,
stopAsync: async () => {
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
for (const result of results) {
if (result.status === 'rejected') {
logger.warn({ err: result.reason }, `Could not stop a ${serverName} preview resource.`);
}
}
},
};
} catch (error) {
await previewServer.stopAsync();
throw error;
}
}
export async function startServeSimWithTunnelAsync(
ctx: CustomBuildContext,
{
@@ -715,42 +847,70 @@ export async function startServeSimWithTunnelAsync(
packageVersion?: string;
}
): Promise<ServeSimPreviewHandle> {
const port = await findAvailablePortAsync();
logger.info(
`Launching ${createServeSimPackageSpec(packageVersion)} on ${SERVE_SIM_HOST}:${port}.`
);
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
const serveSim = spawnDetached({
command: 'npx',
args: createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
return await startWebPreviewWithTunnelAsync(ctx, {
baseDomain,
env,
logger,
timeoutMs,
serverName: 'serve-sim',
packageSpec: createServeSimPackageSpec(packageVersion),
createArgs: (port, turnArgs) =>
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
});
}
try {
logger.info('Waiting for serve-sim to become ready.');
await waitForServeSimReadyAsync({ serveSim, port, timeoutMs });
const tunnel = await startNgrokTunnelAsync({
port,
subdomainPrefix: 'web-preview',
baseDomain,
authtoken: getNgrokAuthtokenOrThrow(env),
logger,
});
return {
previewUrl: tunnel.url,
stopAsync: async () => {
const results = await Promise.allSettled([tunnel.stopAsync(), serveSim.stopAsync()]);
for (const result of results) {
if (result.status === 'rejected') {
logger.warn({ err: result.reason }, 'Could not stop a serve-sim preview resource.');
}
}
},
};
} catch (error) {
await serveSim.stopAsync();
throw error;
export async function startExpoDeviceHubWithTunnelAsync(
ctx: CustomBuildContext,
{
runtimePlatform,
baseDomain,
env,
logger,
timeoutMs,
packageVersion,
}: {
runtimePlatform: BuildRuntimePlatform;
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
}
): Promise<DeviceWebPreviewHandle> {
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
}
return await startWebPreviewWithTunnelAsync(ctx, {
baseDomain,
env,
logger,
timeoutMs,
serverName: 'expo-device-hub',
packageSpec: createExpoDeviceHubPackageSpec(packageVersion),
createArgs: (port, turnArgs) => createExpoDeviceHubArgs({ port, turnArgs, packageVersion }),
});
}
export async function startDeviceWebPreviewWithTunnelAsync(
ctx: CustomBuildContext,
{
runtimePlatform,
...options
}: {
runtimePlatform: BuildRuntimePlatform;
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
}
): Promise<DeviceWebPreviewHandle> {
switch (runtimePlatform) {
case BuildRuntimePlatform.DARWIN:
return await startServeSimWithTunnelAsync(ctx, options);
case BuildRuntimePlatform.LINUX:
return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform });
}
}
@@ -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.0.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.0.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.0.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"
+424 -226
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: {
+9068 -1696
View File
File diff suppressed because it is too large. Load diff
+9 -6
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "23.1.0",
"version": "24.0.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.0.0",
"@expo/eas-json": "24.0.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.0.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -77,6 +77,7 @@
"ajv-formats": "2.1.1",
"better-opn": "3.0.2",
"bplist-parser": "^0.3.0",
"centrifuge": "5.7.1",
"chalk": "4.1.2",
"cli-progress": "3.12.0",
"dateformat": "4.6.3",
@@ -132,6 +133,7 @@
"untildify": "4.0.0",
"uuid": "11.1.1",
"wrap-ansi": "7.0.0",
"ws": "8.21.1",
"yaml": "2.6.0",
"zod": "^4.1.3"
},
@@ -160,6 +162,7 @@
"@types/tar-stream": "3.1.3",
"@types/tough-cookie": "4.0.2",
"@types/wrap-ansi": "3.0.0",
"@types/ws": "8.5.10",
"axios": "1.18.1",
"eslint-plugin-graphql": "4.0.0",
"jest": "29.7.0",
@@ -8,10 +8,7 @@ import {
mockProjectId,
mockTestCommand,
} from './utils';
import {
fetchRawLogsForBuildJobAsync,
fetchRawLogsForCustomJobAsync,
} from '../../commandUtils/workflow/fetchLogs';
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/logs/fetchLogs';
import WorkflowLogView from '../../commands/workflow/logs';
import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
@@ -38,7 +35,7 @@ jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
jest.mock('../../commandUtils/workflow/fetchLogs');
jest.mock('../../commandUtils/workflow/logs/fetchLogs');
describe(WorkflowLogView, () => {
beforeEach(() => {
@@ -49,6 +46,7 @@ describe(WorkflowLogView, () => {
});
afterEach(() => {
jest.clearAllMocks();
jest.mocked(fetchRawLogsForJobAsync).mockReset();
});
test('view logs, passing in no parameters, no runs found', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
@@ -105,7 +103,7 @@ describe(WorkflowLogView, () => {
return { selectedStep: 'step1' };
}
});
jest.mocked(fetchRawLogsForCustomJobAsync).mockResolvedValue(null);
jest.mocked(fetchRawLogsForJobAsync).mockResolvedValue(null);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
@@ -139,7 +137,7 @@ describe(WorkflowLogView, () => {
}
});
jest
.mocked(fetchRawLogsForCustomJobAsync)
.mocked(fetchRawLogsForJobAsync)
.mockResolvedValue(
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install dependencies","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
);
@@ -209,7 +207,7 @@ describe(WorkflowLogView, () => {
}
});
jest
.mocked(fetchRawLogsForBuildJobAsync)
.mocked(fetchRawLogsForJobAsync)
.mockResolvedValue(
'{"result":"success","marker":"end-step","buildStepDisplayName":"step1","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
);
@@ -255,7 +253,7 @@ describe(WorkflowLogView, () => {
}
});
jest
.mocked(fetchRawLogsForCustomJobAsync)
.mocked(fetchRawLogsForJobAsync)
.mockResolvedValue(
[
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"first"}',
+10
View File
@@ -112,3 +112,13 @@ export function getEASUpdateURL(projectId: string, manifestHostOverride: string
export function getExpoApiWorkflowSchemaURL(): string {
return getExpoApiBaseUrl() + '/v2/workflows/schema';
}
export function getEASLogsWebsocketUrl(): string {
if (process.env.EXPO_STAGING) {
return `wss://staging-logs.expo.dev/connection/websocket`;
} else if (process.env.EXPO_LOCAL) {
return `ws://localhost:4997/connection/websocket`;
} else {
return `wss://logs.expo.dev/connection/websocket`;
}
}
@@ -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);
});
});
@@ -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();
}
},
};
}
@@ -1,35 +1,137 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
import { fetchRawLogsForCustomJobAsync } from '../fetchLogs';
import { infoForActiveWorkflowRunAsync } from '../utils';
import { WorkflowJobStatus } from '../../../graphql/generated';
import { groupLogLinesIntoSteps, parseLogLines } from '../logs/parseLogs';
import { WorkflowLogs, WorkflowRawLogLine } from '../types';
import { formatActiveWorkflowRun, formatFailedWorkflowRun } from '../utils';
jest.mock('../fetchLogs');
function jobWithLogs(
logLines: WorkflowRawLogLine[],
status: WorkflowJobStatus = WorkflowJobStatus.InProgress
): {
job: ReturnType<typeof getMockWorkflowRunWithJobsFragment>['jobs'][number];
logs: WorkflowLogs;
} {
return {
job: { ...getMockWorkflowRunWithJobsFragment().jobs[0], status },
logs: groupLogLinesIntoSteps(logLines),
};
}
describe('workflow utils', () => {
afterEach(() => {
jest.clearAllMocks();
});
function stepLines(buildStepId: string, count: number): WorkflowRawLogLine[] {
return Array.from({ length: count }, (_, index) => ({ buildStepId, msg: `line${index}` }));
}
test('shows the display name for the current step while keying logs by step id', async () => {
const workflowRun = getMockWorkflowRunWithJobsFragment();
workflowRun.jobs = workflowRun.jobs.map(job => ({
...job,
status: WorkflowJobStatus.InProgress,
}));
jest
.mocked(fetchRawLogsForCustomJobAsync)
.mockResolvedValue(
[
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","time":"2022-01-01T00:00:00.000Z","msg":"npm ci"}',
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","marker":"end-step","result":"success","time":"2022-01-01T00:00:01.000Z","msg":"done"}',
].join('\n')
);
const output = await infoForActiveWorkflowRunAsync({} as any, workflowRun);
describe(formatActiveWorkflowRun, () => {
test('shows the display name for the current step while keying logs by step id', () => {
const output = formatActiveWorkflowRun([
jobWithLogs(
parseLogLines(
[
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","time":"2022-01-01T00:00:00.000Z","msg":"npm ci"}',
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","marker":"end-step","result":"success","time":"2022-01-01T00:00:01.000Z","msg":"done"}',
].join('\n')
).logLines
),
]);
expect(output).toContain('Current step');
expect(output).toContain('Install dependencies');
expect(output).not.toContain('step-id-1');
});
test('shows exactly maxLogLines trailing lines of the current step', () => {
const output = formatActiveWorkflowRun([jobWithLogs(stepLines('step-id-1', 10))], 5);
expect(output).toContain('line5');
expect(output).toContain('line9');
expect(output).not.toContain('line4');
});
test('keeps five trailing lines by default', () => {
const output = formatActiveWorkflowRun([jobWithLogs(stepLines('step-id-1', 10))]);
expect(output).toContain('line5');
expect(output).toContain('line9');
expect(output).not.toContain('line4');
});
test('reports the last step as the current one', () => {
const output = formatActiveWorkflowRun([
jobWithLogs([
{ buildStepId: 'install', buildStepDisplayName: 'Install', msg: 'installing' },
{ buildStepId: 'build', buildStepDisplayName: 'Build', msg: 'compiling' },
]),
]);
expect(output).toContain('Build');
expect(output).not.toContain('Install');
});
test('passes over a trailing skipped step', () => {
const output = formatActiveWorkflowRun([
jobWithLogs([
{ buildStepId: 'install', buildStepDisplayName: 'Install', msg: 'installing' },
{
buildStepId: 'build',
buildStepDisplayName: 'Build',
msg: 'skipping',
marker: 'end-step',
result: 'skipped',
},
]),
]);
expect(output).toContain('Install');
expect(output).not.toContain('Build');
});
test('reports a step that has not ended yet', () => {
const output = formatActiveWorkflowRun([
jobWithLogs([
{
buildStepId: 'install',
buildStepDisplayName: 'Install',
msg: 'installed',
marker: 'end-step',
result: 'success',
},
{ buildStepId: 'build', buildStepDisplayName: 'Build', msg: 'compiling' },
]),
]);
expect(output).toContain('Build');
});
test('omits the current step when the job has no steps yet', () => {
const output = formatActiveWorkflowRun([jobWithLogs([])]);
expect(output).not.toContain('Current step');
expect(output).not.toContain('Current logs');
});
test('omits the current step for a job that is not in progress', () => {
const output = formatActiveWorkflowRun([
jobWithLogs(stepLines('step-id-1', 3), WorkflowJobStatus.Success),
]);
expect(output).not.toContain('Current step');
expect(output).not.toContain('line0');
});
});
describe(formatFailedWorkflowRun, () => {
test('prints every line of the failed step when there is no limit', () => {
const output = formatFailedWorkflowRun([
jobWithLogs(
[
...stepLines('build', 8),
{ buildStepId: 'build', msg: 'it failed', marker: 'end-step', result: 'fail' },
],
WorkflowJobStatus.Failure
),
]);
expect(output).toContain('line0');
expect(output).toContain('line7');
});
});
@@ -1,51 +0,0 @@
import { WorkflowJobResult } from './types';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
// This function is in a separate module for testing purposes
export async function fetchRawLogsForCustomJobAsync(
job: WorkflowJobResult
): Promise<string | null> {
const firstLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
if (!firstLogFileUrl) {
return null;
}
const response = await fetch(firstLogFileUrl, {
method: 'GET',
});
const rawLogs = await response.text();
return rawLogs;
}
export async function fetchRawLogsForBuildJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<string | null> {
// Prefer turtleJobRun logs, which contain JSONL-formatted step logs
// for both built-in and custom build steps
const turtleLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
if (turtleLogFileUrl) {
const response = await fetch(turtleLogFileUrl, {
method: 'GET',
});
return await response.text();
}
// Fall back to build logFiles
const buildId = job.outputs?.build_id;
if (!buildId) {
return null;
}
const buildFragment = await BuildQuery.byIdAsync(state.graphqlClient, buildId, {
useCache: false,
});
const firstLogFileUrl = buildFragment.logFiles?.[0];
if (!firstLogFileUrl) {
return null;
}
const response = await fetch(firstLogFileUrl, {
method: 'GET',
});
return await response.text();
}
@@ -0,0 +1,27 @@
import { getMockWorkflowCustomJobFragment } from '../../../../__tests__/commands/utils';
import { BuildQuery } from '../../../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../../../context/contextUtils/createGraphqlClient';
import { fetchRawLogsForJobAsync } from '../fetchLogs';
jest.mock('../../../../graphql/queries/BuildQuery');
describe(fetchRawLogsForJobAsync, () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('reads a custom job from its turtle job run log file', async () => {
const rawLogs = '{"logId":"1","buildStepId":"install","msg":"from the turtle job run"}';
const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(rawLogs));
await expect(
fetchRawLogsForJobAsync(
{ graphqlClient: {} as ExpoGraphqlClient },
getMockWorkflowCustomJobFragment()
)
).resolves.toBe(rawLogs);
expect(fetchSpy).toHaveBeenCalledWith('https://example.com/log1');
expect(BuildQuery.byIdAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,186 @@
import { groupLogLinesIntoSteps, mergeLogLines, parseLogLines } from '../parseLogs';
import { WorkflowRawLogLine } from '../../types';
function logLine(overrides: Partial<WorkflowRawLogLine> = {}): WorkflowRawLogLine {
return { msg: 'a message', ...overrides };
}
describe(parseLogLines, () => {
it('parses a JSONL log file', () => {
const { logLines, errors } = parseLogLines(
[
'{"logId":"1","buildStepId":"install","msg":"npm ci"}',
'{"logId":"2","buildStepId":"install","msg":"done"}',
].join('\n')
);
expect(errors).toEqual([]);
expect(logLines).toEqual([
{ logId: '1', buildStepId: 'install', msg: 'npm ci' },
{ logId: '2', buildStepId: 'install', msg: 'done' },
]);
});
it('skips blank lines, including a trailing newline', () => {
const { logLines, errors } = parseLogLines('{"logId":"1","msg":"one"}\n\n');
expect(errors).toEqual([]);
expect(logLines).toHaveLength(1);
});
it('reports malformed lines as errors while keeping the parsable ones', () => {
const { logLines, errors } = parseLogLines(
['{"logId":"1","msg":"one"}', 'this is not json', '{"logId":"2","msg":"two"}'].join('\n')
);
expect(logLines.map(line => line.msg)).toEqual(['one', 'two']);
expect(errors).toHaveLength(1);
expect(errors[0]).toBeInstanceOf(Error);
});
it('parses a line that carries no message', () => {
const { logLines, errors } = parseLogLines('{"logId":"1"}');
expect(errors).toEqual([]);
expect(logLines).toEqual([{ logId: '1' }]);
});
});
describe(mergeLogLines, () => {
it('keeps the rightmost line for a repeated logId', () => {
const fileLogLines = [
logLine({ logId: '1', msg: 'from the file' }),
logLine({ logId: '2', msg: 'from the file' }),
];
const realtimeLogLines = [
logLine({ logId: '2', msg: 'from realtime' }),
logLine({ logId: '3', msg: 'from realtime' }),
];
expect(mergeLogLines(fileLogLines, realtimeLogLines)).toEqual([
{ logId: '1', msg: 'from the file' },
{ logId: '2', msg: 'from realtime' },
{ logId: '3', msg: 'from realtime' },
]);
});
it('keeps identical messages that have different logIds', () => {
const merged = mergeLogLines(
[logLine({ logId: '1', msg: 'Repeated text' })],
[logLine({ logId: '2', msg: 'Repeated text' })]
);
expect(merged).toEqual([
{ logId: '1', msg: 'Repeated text' },
{ logId: '2', msg: 'Repeated text' },
]);
});
it('keeps identical lines without logId', () => {
const merged = mergeLogLines(
[logLine({ msg: 'same' }), logLine({ msg: 'same' })],
[logLine({ msg: 'same' })]
);
expect(merged).toHaveLength(3);
});
it('deduplicates within a single group', () => {
const merged = mergeLogLines([logLine({ logId: '1' }), logLine({ logId: '1' })]);
expect(merged).toHaveLength(1);
});
});
describe(groupLogLinesIntoSteps, () => {
it('groups lines by step id and labels the step with its display name', () => {
const logs = groupLogLinesIntoSteps([
logLine({ buildStepId: 'step-id-1', msg: 'npm ci' }),
logLine({
buildStepId: 'step-id-1',
buildStepDisplayName: 'Install dependencies',
msg: 'ok',
}),
]);
expect(Array.from(logs.keys())).toEqual(['step-id-1']);
expect(logs.get('step-id-1')).toEqual({
key: 'step-id-1',
label: 'Install dependencies',
logLines: [
{ time: undefined, msg: 'npm ci', result: undefined, marker: undefined, err: undefined },
{ time: undefined, msg: 'ok', result: undefined, marker: undefined, err: undefined },
],
});
});
it('keeps steps in the order they first appear', () => {
const logs = groupLogLinesIntoSteps([
logLine({ buildStepId: 'first' }),
logLine({ buildStepId: 'second' }),
logLine({ buildStepId: 'first' }),
logLine({ buildStepId: 'third' }),
]);
expect(Array.from(logs.keys())).toEqual(['first', 'second', 'third']);
});
it('falls back to the display name, then the phase, when there is no step id', () => {
const logs = groupLogLinesIntoSteps([
logLine({ buildStepDisplayName: 'Run fastlane' }),
logLine({ phase: 'PREPARE_CREDENTIALS' }),
]);
expect(Array.from(logs.keys())).toEqual(['Run fastlane', 'PREPARE_CREDENTIALS']);
expect(logs.get('PREPARE_CREDENTIALS')?.label).toBe('PREPARE_CREDENTIALS');
});
it('drops lines that belong to no step', () => {
const logs = groupLogLinesIntoSteps([logLine({ msg: 'a line with no step' })]);
expect(logs.size).toBe(0);
});
it('drops lines that carry no message, keeping the rest of the step', () => {
const logs = groupLogLinesIntoSteps([
logLine({ buildStepId: 'install', msg: undefined, marker: 'start-step' }),
logLine({ buildStepId: 'install', msg: 'npm ci' }),
]);
expect(logs.get('install')?.logLines).toEqual([
{ time: undefined, msg: 'npm ci', result: undefined, marker: undefined, err: undefined },
]);
});
it('creates no step for a line that carries no message', () => {
const logs = groupLogLinesIntoSteps([
logLine({ buildStepId: 'install', msg: undefined, marker: 'end-step', result: 'success' }),
]);
expect(logs.size).toBe(0);
});
it('records the result of the first end marker and ignores a later one', () => {
const logs = groupLogLinesIntoSteps([
logLine({ buildStepId: 'install', msg: 'skipping', marker: 'end-step', result: 'skipped' }),
logLine({ buildStepId: 'install', msg: 'retried', marker: 'end-step', result: 'success' }),
]);
expect(logs.get('install')?.result).toBe('skipped');
});
it('leaves the result undefined for a step that has not ended', () => {
const logs = groupLogLinesIntoSteps([logLine({ buildStepId: 'install', msg: 'npm ci' })]);
expect(logs.get('install')?.result).toBeUndefined();
});
it('records an end marker that carries no result and does not let a later one replace it', () => {
const logs = groupLogLinesIntoSteps([
logLine({ buildStepId: 'install', msg: 'ended', marker: 'END_PHASE' }),
logLine({ buildStepId: 'install', msg: 'retried', marker: 'END_PHASE', result: 'success' }),
]);
expect(logs.get('install')?.result).toBe('');
});
});
@@ -0,0 +1,308 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../../__tests__/commands/utils';
import { RealtimeLogsTargetType, WorkflowJobStatus } from '../../../../graphql/generated';
import { RealtimeLogsClient } from '../../../../utils/centrifuge';
import { fetchAndParseLogsFromJobAsync } from '../parseLogs';
import { WorkflowJobLogsState, WorkflowRunLogsWatcher, realtimeLogsTargetForJob } from '../watcher';
import { WorkflowJobResult, WorkflowRawLogLine } from '../../types';
jest.mock('../parseLogs', () => ({
...jest.requireActual('../parseLogs'),
fetchAndParseLogsFromJobAsync: jest.fn(),
}));
function createFakeClient(): {
client: RealtimeLogsClient;
publish: (data: unknown) => void;
subscribeCalls: unknown[];
closeCount: () => number;
} {
const listeners: ((data: unknown) => void)[] = [];
const subscribeCalls: unknown[] = [];
let closed = 0;
return {
subscribeCalls,
closeCount: () => closed,
publish: data => {
listeners.forEach(listener => {
listener(data);
});
},
client: {
subscribeAsync: async (args, onPublication) => {
subscribeCalls.push(args);
listeners.push(onPublication);
return { close: () => closed++ };
},
close: () => closed++,
},
};
}
function inProgressJob(): WorkflowJobResult {
const job = getMockWorkflowRunWithJobsFragment().jobs[0];
return { ...job, status: WorkflowJobStatus.InProgress };
}
describe(realtimeLogsTargetForJob, () => {
it('targets the turtle job run when there is one', () => {
const job = { ...inProgressJob(), turtleJobRun: { id: 'job-run-id' } } as WorkflowJobResult;
expect(realtimeLogsTargetForJob(job)).toEqual({
type: RealtimeLogsTargetType.JobRun,
id: 'job-run-id',
});
});
it('falls back to the build when there is no turtle job run', () => {
const job = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: { id: 'build-id' },
} as WorkflowJobResult;
expect(realtimeLogsTargetForJob(job)).toEqual({
type: RealtimeLogsTargetType.Build,
id: 'build-id',
});
});
it('has no target when neither is present', () => {
const job = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: null,
outputs: null,
} as WorkflowJobResult;
expect(realtimeLogsTargetForJob(job)).toBeNull();
});
});
describe(WorkflowRunLogsWatcher, () => {
beforeEach(() => {
jest
.mocked(fetchAndParseLogsFromJobAsync)
.mockResolvedValue([{ logId: '1', buildStepId: 'install', msg: 'from the file' }]);
});
afterEach(() => {
jest.clearAllMocks();
});
it('subscribes once per in-progress job', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
const job = inProgressJob();
await watcher.syncJobsAsync([job]);
await watcher.syncJobsAsync([job]);
expect(fake.subscribeCalls).toHaveLength(1);
});
it('reports realtime logs as they arrive', async () => {
const fake = createFakeClient();
const onRealtimeLogs = jest.fn();
const watcher = new WorkflowRunLogsWatcher({} as any, () => fake.client, onRealtimeLogs);
await watcher.syncJobsAsync([inProgressJob()]);
fake.publish([{ logId: '2', buildStepId: 'install', msg: 'pushed' }]);
expect(onRealtimeLogs).toHaveBeenCalledTimes(1);
});
it('does not report a publication that carries no usable log lines', async () => {
const fake = createFakeClient();
const onRealtimeLogs = jest.fn();
const watcher = new WorkflowRunLogsWatcher({} as any, () => fake.client, onRealtimeLogs);
await watcher.syncJobsAsync([inProgressJob()]);
fake.publish([{ msg: 'no logId' }]);
expect(onRealtimeLogs).not.toHaveBeenCalled();
});
it('still fetches logs when the realtime logs client is unavailable', async () => {
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => null,
() => {}
);
await watcher.syncJobsAsync([inProgressJob()]);
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(1);
});
it('fetches logs on every sync while in progress, and stops once the job completes', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
const job = inProgressJob();
await watcher.syncJobsAsync([job]);
await watcher.syncJobsAsync([job]);
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(2);
await watcher.syncJobsAsync([{ ...job, status: WorkflowJobStatus.Success }]);
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(2);
});
it('does not fetch logs for a job that has not started', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
await watcher.syncJobsAsync([{ ...inProgressJob(), status: WorkflowJobStatus.New }]);
expect(fetchAndParseLogsFromJobAsync).not.toHaveBeenCalled();
});
it('closes the subscription when a job leaves in-progress', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
const job = inProgressJob();
await watcher.syncJobsAsync([job]);
expect(fake.closeCount()).toBe(0);
await watcher.syncJobsAsync([{ ...job, status: WorkflowJobStatus.Failure }]);
expect(fake.closeCount()).toBe(1);
});
it('closes the client and its subscriptions', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
await watcher.syncJobsAsync([inProgressJob()]);
watcher.close();
expect(fake.closeCount()).toBe(2);
});
});
function fileLine(logId: string, msg: string, buildStepId = 'install'): WorkflowRawLogLine {
return { logId, buildStepId, msg };
}
function stepMessages(logsState: WorkflowJobLogsState): string[] {
return Array.from(logsState.getLogs().values()).flatMap(group =>
group.logLines.map(logLine => logLine.msg)
);
}
describe(WorkflowJobLogsState, () => {
it('hides realtime lines until the file logs reach them', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('5', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first']);
});
it('shows realtime lines once a published logId also appears in the file', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
});
it('shows realtime logs when a publication repeats a logId already in the file', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('shows buffered realtime lines once the job is completed', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('9', 'pushed')]);
logsState.markCompleted();
logsState.markCompleted();
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('folds later publications once completion showed them', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('9', 'buffered')]);
logsState.markCompleted();
logsState.ingestRealtimeLogLines([fileLine('10', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'buffered', 'pushed']);
});
it('replaces the file snapshot instead of accumulating it', () => {
const logsState = new WorkflowJobLogsState();
const keyless: WorkflowRawLogLine = { buildStepId: 'install', msg: 'submission log' };
logsState.ingestFileLogLines([keyless]);
logsState.ingestFileLogLines([keyless]);
expect(stepMessages(logsState)).toEqual(['submission log']);
});
it('drops a buffered realtime line once it lands in the file', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('ignores publications that are not arrays of log lines', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
expect(logsState.ingestRealtimeLogLines({ logId: '2', msg: 'not an array' })).toBe(false);
expect(logsState.ingestRealtimeLogLines(['a string', 42, null])).toBe(false);
expect(logsState.ingestRealtimeLogLines([{ msg: 'no logId' }])).toBe(false);
expect(stepMessages(logsState)).toEqual(['first']);
});
it('deduplicates a line republished on the same channel', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('deduplicates a line republished later', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
});
});
@@ -0,0 +1,28 @@
import { WorkflowJobResult } from '../types';
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
export async function fetchRawLogsForJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<string | null> {
const turtleLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
if (turtleLogFileUrl) {
const response = await fetch(turtleLogFileUrl);
return await response.text();
}
const buildId = job.outputs?.build_id;
if (!buildId) {
return null;
}
const buildFragment = await BuildQuery.byIdAsync(state.graphqlClient, buildId, {
useCache: false,
});
const firstLogFileUrl = buildFragment.logFiles?.[0];
if (!firstLogFileUrl) {
return null;
}
const response = await fetch(firstLogFileUrl);
return await response.text();
}
@@ -0,0 +1,83 @@
import { fetchRawLogsForJobAsync } from './fetchLogs';
import { WorkflowJobResult, WorkflowLogs, WorkflowRawLogLine } from '../types';
import Log from '../../../log';
import uniqBy from '../../../utils/expodash/uniqBy';
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
export function parseLogLines(rawLogs: string): {
logLines: WorkflowRawLogLine[];
errors: Error[];
} {
const logLines: WorkflowRawLogLine[] = [];
const errors: Error[] = [];
for (const rawLogLine of rawLogs.split('\n')) {
if (!rawLogLine) {
continue;
}
try {
logLines.push(JSON.parse(rawLogLine));
} catch (err) {
errors.push(err as Error);
}
}
return { logLines, errors };
}
export function mergeLogLines<T extends WorkflowRawLogLine>(...logLineGroups: T[][]): T[] {
return uniqBy(logLineGroups.flat().reverse(), logLine => logLine.logId ?? Symbol()).reverse();
}
export function groupLogLinesIntoSteps(
logLines: WorkflowRawLogLine[],
accumulator: WorkflowLogs = new Map()
): WorkflowLogs {
for (const logLine of logLines) {
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } = logLine;
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
if (!stepKey || !stepLabel || !msg) {
continue;
}
let logGroup = accumulator.get(stepKey);
if (!logGroup) {
logGroup = { key: stepKey, label: stepLabel, logLines: [] };
accumulator.set(stepKey, logGroup);
}
if (buildStepDisplayName) {
logGroup.label = buildStepDisplayName;
}
if (logGroup.result === undefined && (marker === 'end-step' || marker === 'END_PHASE')) {
logGroup.result = result ?? '';
}
logGroup.logLines.push({ time, msg, result, marker, err });
}
return accumulator;
}
export async function fetchAndParseLogsFromJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<WorkflowRawLogLine[] | null> {
const rawLogs = await fetchRawLogsForJobAsync(state, job);
if (!rawLogs) {
return null;
}
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
const { logLines, errors } = parseLogLines(rawLogs);
for (const error of errors) {
Log.debug(`Failed to parse a log line: ${error.message}`);
}
return logLines;
}
export async function fetchAndProcessLogsFromJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<WorkflowLogs | null> {
const logLines = await fetchAndParseLogsFromJobAsync(state, job);
return logLines && groupLogLinesIntoSteps(logLines);
}
@@ -0,0 +1,210 @@
import { fetchAndParseLogsFromJobAsync, groupLogLinesIntoSteps, mergeLogLines } from './parseLogs';
import { WorkflowJobResult, WorkflowLogs, WorkflowRawLogLine } from '../types';
import {
RealtimeLogsTargetInput,
RealtimeLogsTargetType,
WorkflowJobStatus,
} from '../../../graphql/generated';
import Log from '../../../log';
import { RealtimeLogsClient, RealtimeLogsSubscription } from '../../../utils/centrifuge';
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
import nullthrows from 'nullthrows';
type RealtimeLogLine = WorkflowRawLogLine & { logId: string };
function isRealtimeLogLine(entry: unknown): entry is RealtimeLogLine {
return (
typeof entry === 'object' &&
entry !== null &&
typeof (entry as Partial<WorkflowRawLogLine>).logId === 'string'
);
}
export class WorkflowJobLogsState {
private fileLogIds = new Set<string>();
private realtimeLogLines: RealtimeLogLine[] = [];
private realtimeLogIds = new Set<string>();
private haveFileLogsCaughtUp = false;
private realtimeLogsRevealed = false;
private groupedLogs: WorkflowLogs = new Map();
public ingestFileLogLines(logLines: WorkflowRawLogLine[]): void {
this.fileLogIds = new Set(logLines.flatMap(logLine => (logLine.logId ? [logLine.logId] : [])));
if (
!this.haveFileLogsCaughtUp &&
this.realtimeLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
) {
this.haveFileLogsCaughtUp = true;
}
this.realtimeLogLines = this.realtimeLogLines.filter(
logLine => !this.fileLogIds.has(logLine.logId)
);
this.realtimeLogIds = new Set(this.realtimeLogLines.map(logLine => logLine.logId));
if (this.haveFileLogsCaughtUp) {
this.realtimeLogsRevealed = true;
}
this.groupedLogs = groupLogLinesIntoSteps(
mergeLogLines(logLines, this.realtimeLogsRevealed ? this.realtimeLogLines : [])
);
}
public ingestRealtimeLogLines(data: unknown): boolean {
if (!Array.isArray(data)) {
return false;
}
const publishedLogLines = data.filter(isRealtimeLogLine);
if (publishedLogLines.length === 0) {
return false;
}
if (
!this.haveFileLogsCaughtUp &&
publishedLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
) {
this.haveFileLogsCaughtUp = true;
}
const newLogLines = publishedLogLines.filter(
logLine => !this.fileLogIds.has(logLine.logId) && !this.realtimeLogIds.has(logLine.logId)
);
for (const logLine of newLogLines) {
this.realtimeLogLines.push(logLine);
this.realtimeLogIds.add(logLine.logId);
}
if (!this.realtimeLogsRevealed) {
if (this.haveFileLogsCaughtUp) {
this.revealRealtimeLogs();
}
} else if (newLogLines.length > 0) {
groupLogLinesIntoSteps(newLogLines, this.groupedLogs);
}
return true;
}
public markCompleted(): void {
this.revealRealtimeLogs();
}
public getLogs(): WorkflowLogs {
return this.groupedLogs;
}
private revealRealtimeLogs(): void {
if (this.realtimeLogsRevealed) {
return;
}
this.realtimeLogsRevealed = true;
groupLogLinesIntoSteps(this.realtimeLogLines, this.groupedLogs);
}
}
type TrackedJob = {
logsState: WorkflowJobLogsState;
subscription: RealtimeLogsSubscription | null;
};
export function realtimeLogsTargetForJob(job: WorkflowJobResult): RealtimeLogsTargetInput | null {
if (job.turtleJobRun?.id) {
return { type: RealtimeLogsTargetType.JobRun, id: job.turtleJobRun.id };
}
const buildId = job.turtleBuild?.id ?? job.outputs?.build_id;
if (buildId) {
return { type: RealtimeLogsTargetType.Build, id: buildId };
}
return null;
}
export class WorkflowRunLogsWatcher {
private readonly trackedJobs = new Map<string, TrackedJob>();
private realtimeLogsClient?: RealtimeLogsClient | null;
constructor(
private readonly graphqlClient: ExpoGraphqlClient,
private readonly createRealtimeLogsClient: () => RealtimeLogsClient | null,
private readonly onRealtimeLogs: () => void
) {}
public async syncJobsAsync(
jobs: WorkflowJobResult[]
): Promise<Map<string, WorkflowJobLogsState>> {
await Promise.all(jobs.map(job => this.syncJobAsync(job)));
return new Map(
jobs.map(job => [
job.id,
nullthrows(
this.trackedJobs.get(job.id),
'job has to be in tracked jobs after it was synced'
).logsState,
])
);
}
public close(): void {
for (const trackedJob of this.trackedJobs.values()) {
trackedJob.subscription?.close();
trackedJob.subscription = null;
}
this.realtimeLogsClient?.close();
}
private async syncJobAsync(job: WorkflowJobResult): Promise<void> {
let trackedJob = this.trackedJobs.get(job.id);
if (!trackedJob) {
trackedJob = { logsState: new WorkflowJobLogsState(), subscription: null };
this.trackedJobs.set(job.id, trackedJob);
}
const workflowInProgress = job.status === WorkflowJobStatus.InProgress;
if (!workflowInProgress) {
trackedJob.subscription?.close();
trackedJob.subscription = null;
return;
}
await Promise.all([
trackedJob.subscription ? Promise.resolve() : this.subscribeAsync(job, trackedJob),
this.fetchLogsAsync(job, trackedJob),
]);
}
private async fetchLogsAsync(job: WorkflowJobResult, trackedJob: TrackedJob): Promise<void> {
try {
const logLines = await fetchAndParseLogsFromJobAsync(
{ graphqlClient: this.graphqlClient },
job
);
if (logLines) {
trackedJob.logsState.ingestFileLogLines(logLines);
}
} catch (err: any) {
Log.debug(`Failed to fetch logs for job ${job.id}: ${err.message}`);
}
}
private getRealtimeLogsClient(): RealtimeLogsClient | null {
if (this.realtimeLogsClient === undefined) {
this.realtimeLogsClient = this.createRealtimeLogsClient();
}
return this.realtimeLogsClient;
}
private async subscribeAsync(job: WorkflowJobResult, trackedJob: TrackedJob): Promise<void> {
const target = realtimeLogsTargetForJob(job);
if (!target) {
return;
}
const realtimeLogsClient = this.getRealtimeLogsClient();
if (!realtimeLogsClient) {
return;
}
trackedJob.subscription = await realtimeLogsClient.subscribeAsync({ target }, data => {
if (trackedJob.logsState.ingestRealtimeLogLines(data)) {
this.onRealtimeLogs();
}
});
}
}
@@ -1,11 +1,11 @@
import { Choice } from 'prompts';
import { WorkflowJobResult, WorkflowLogs } from './types';
import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs';
import {
choiceFromWorkflowJob,
choiceFromWorkflowRun,
choicesFromWorkflowLogs,
fetchAndProcessLogsFromJobAsync,
processWorkflowRuns,
} from './utils';
import { AppQuery } from '../../graphql/queries/AppQuery';
@@ -42,19 +42,26 @@ export type WorkflowRunWithJobsResult = WorkflowRunResult & {
logs?: string;
};
export type WorkflowLogLine = {
time: string;
msg: string;
export type WorkflowRawLogLine = {
logId?: string;
buildStepId?: string;
buildStepDisplayName?: string;
phase?: string;
time?: string;
msg?: string;
result?: string;
marker?: string;
err?: any;
};
export type WorkflowLogs = Map<
string,
{
key: string;
label: string;
logLines: WorkflowLogLine[];
}
>;
export type WorkflowLogLine = Pick<WorkflowRawLogLine, 'time' | 'result' | 'marker' | 'err'> &
Required<Pick<WorkflowRawLogLine, 'msg'>>;
export type StepLogs = {
key: string;
label: string;
result?: string;
logLines: WorkflowLogLine[];
};
export type WorkflowLogs = Map<string, StepLogs>;
@@ -1,7 +1,7 @@
import chalk from 'chalk';
import * as fs from 'node:fs';
import { fetchRawLogsForBuildJobAsync, fetchRawLogsForCustomJobAsync } from './fetchLogs';
import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs';
import {
WorkflowJobResult,
WorkflowLogLine,
@@ -11,20 +11,22 @@ import {
} from './types';
import {
WorkflowJobStatus,
WorkflowJobType,
WorkflowRunByIdQuery,
WorkflowRunByIdWithJobsQuery,
WorkflowRunFragment,
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
import { WorkflowRunLogsWatcher } from './logs/watcher';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { ora, updateSpinnerText } from '../../ora';
import { Choice } from '../../prompts';
import formatFields from '../../utils/formatFields';
import { createRealtimeLogsClient } from '../../utils/centrifuge';
import { sleepAsync } from '../../utils/promise';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
import nullthrows from 'nullthrows';
export function computeTriggerInfoForWorkflowRun(run: WorkflowRunFragment): {
triggerType: WorkflowTriggerType;
@@ -85,19 +87,13 @@ export function choicesFromWorkflowLogs(
logs: WorkflowLogs
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
return Array.from(logs.values())
.map(({ key, label, logLines }) => {
const stepStatus =
logLines?.filter(
(line: WorkflowLogLine) => line.marker === 'end-step' || line.marker === 'END_PHASE'
)[0]?.result ?? '';
return {
title: `${label} - ${stepStatus}`,
name: label,
status: stepStatus,
value: key,
logLines,
};
})
.map(({ key, label, result, logLines }) => ({
title: `${label} - ${result ?? ''}`,
name: label,
status: result ?? '',
value: key,
logLines,
}))
.filter(step => step.status !== 'skipped');
}
@@ -121,52 +117,6 @@ export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunRes
});
}
export async function fetchAndProcessLogsFromJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<WorkflowLogs | null> {
let rawLogs: string | null;
switch (job.type) {
case WorkflowJobType.Build:
case WorkflowJobType.Repack:
rawLogs = await fetchRawLogsForBuildJobAsync(state, job);
break;
default:
rawLogs = await fetchRawLogsForCustomJobAsync(job);
break;
}
if (!rawLogs) {
return null;
}
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
const logs: WorkflowLogs = new Map();
rawLogs.split('\n').forEach((line, index) => {
Log.debug(`line ${index} = ${JSON.stringify(line, null, 2)}`);
try {
const parsedLine = JSON.parse(line);
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } =
parsedLine;
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
if (stepKey && stepLabel) {
if (!logs.has(stepKey)) {
logs.set(stepKey, {
key: stepKey,
label: stepLabel,
logLines: [],
});
}
const logGroup = logs.get(stepKey)!;
if (buildStepDisplayName) {
logGroup.label = buildStepDisplayName;
}
logGroup.logLines.push({ time, msg, result, marker, err });
}
} catch {}
});
return logs;
}
function descriptionForJobStatus(status: WorkflowJobStatus): string {
switch (status) {
case WorkflowJobStatus.New:
@@ -188,82 +138,106 @@ function descriptionForJobStatus(status: WorkflowJobStatus): string {
}
}
export async function infoForActiveWorkflowRunAsync(
function isJobCompleted(status: WorkflowJobStatus): boolean {
switch (status) {
case WorkflowJobStatus.Success:
case WorkflowJobStatus.Failure:
case WorkflowJobStatus.Canceled:
case WorkflowJobStatus.Skipped:
return true;
case WorkflowJobStatus.New:
case WorkflowJobStatus.InProgress:
case WorkflowJobStatus.ActionRequired:
case WorkflowJobStatus.PendingCancel:
return false;
}
}
type WorkflowRunWithJobs = WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'];
type JobWithLogs = {
job: WorkflowRunWithJobs['jobs'][number];
logs: WorkflowLogs;
};
function stepLogTail(
step: { logLines?: WorkflowLogLine[] },
maxLogLines: number // -1 means no limit
): string[] {
const logLines = step.logLines ?? [];
const tail = maxLogLines === -1 ? logLines : logLines.slice(-maxLogLines);
return tail.map(line => line.msg);
}
export async function logsForFailedWorkflowRunAsync(
graphqlClient: ExpoGraphqlClient,
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
workflowRun: WorkflowRunWithJobs
): Promise<JobWithLogs[]> {
const jobs = workflowRun.jobs.filter(job => job.status === WorkflowJobStatus.Failure);
return await Promise.all(
jobs.map(async job => {
const maybeLogs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
return { job, logs: maybeLogs ?? new Map() };
})
);
}
export function formatActiveWorkflowRun(
jobLogs: JobWithLogs[],
maxLogLines: number = 5 // -1 means no limit
): Promise<string> {
const statusLines = [];
): string {
const statusValues = [];
for (const job of workflowRun.jobs) {
for (const { job, logs } of jobLogs) {
statusValues.push({ label: '', value: '' });
statusValues.push({ label: ' Job', value: job.name });
statusValues.push({ label: ' Status', value: descriptionForJobStatus(job.status) });
if (job.status !== WorkflowJobStatus.InProgress) {
continue;
}
const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
const steps = logs ? choicesFromWorkflowLogs(logs) : [];
if (steps.length > 0) {
const currentStep = steps[steps.length - 1];
statusValues.push({ label: ' Current step', value: currentStep.name });
if (currentStep?.logLines?.length) {
statusValues.push({ label: ' Current logs', value: '' });
const currentLogs =
currentStep.logLines
?.map(line => line.msg)
.filter((_, index) => {
if (maxLogLines === -1) {
return true;
}
return index > (currentStep.logLines?.length ?? 0) - maxLogLines;
}) ?? [];
for (const log of currentLogs) {
statusValues.push({ label: '', value: log });
}
}
}
}
statusValues.push({ label: '', value: '' });
statusLines.push(formatFields(statusValues));
return statusLines.join('\n');
}
export async function infoForFailedWorkflowRunAsync(
graphqlClient: ExpoGraphqlClient,
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
maxLogLines: number = -1 // -1 means no limit
): Promise<string> {
const statusLines = [];
const statusValues = [];
const logLinesToKeep = maxLogLines === -1 ? Infinity : maxLogLines;
for (const job of workflowRun.jobs) {
if (job.status !== WorkflowJobStatus.Failure) {
const steps = choicesFromWorkflowLogs(logs);
if (steps.length === 0) {
continue;
}
const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
const steps = logs ? choicesFromWorkflowLogs(logs) : [];
statusValues.push({ label: '', value: '' });
statusValues.push({ label: ' Failed job', value: job.name });
if (steps.length > 0) {
const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed');
if (failedStep) {
const logs = failedStep.logLines?.map(line => line.msg).slice(-logLinesToKeep) ?? [];
statusValues.push({ label: ' Failed step', value: failedStep.name });
statusValues.push({
label: ' Logs for failed step',
value: '',
});
for (const log of logs) {
statusValues.push({ label: '', value: log });
}
const currentStep = steps[steps.length - 1];
statusValues.push({ label: ' Current step', value: currentStep.name });
if (currentStep.logLines?.length) {
statusValues.push({ label: ' Current logs', value: '' });
for (const log of stepLogTail(currentStep, maxLogLines)) {
statusValues.push({ label: '', value: log });
}
}
}
statusValues.push({ label: '', value: '' });
statusLines.push(formatFields(statusValues));
return statusLines.join('\n');
return formatFields(statusValues);
}
export function formatFailedWorkflowRun(
jobLogs: JobWithLogs[],
maxLogLines: number = -1 // -1 means no limit
): string {
const statusValues = [];
for (const { job, logs } of jobLogs) {
statusValues.push({ label: '', value: '' });
statusValues.push({ label: ' Failed job', value: job.name });
const steps = choicesFromWorkflowLogs(logs);
const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed');
if (!failedStep) {
continue;
}
statusValues.push({ label: ' Failed step', value: failedStep.name });
statusValues.push({ label: ' Logs for failed step', value: '' });
for (const log of stepLogTail(failedStep, maxLogLines)) {
statusValues.push({ label: '', value: log });
}
}
statusValues.push({ label: '', value: '' });
return formatFields(statusValues);
}
export async function fileExistsAsync(filePath: string): Promise<boolean> {
return await fs.promises
.access(filePath, fs.constants.F_OK)
@@ -312,65 +286,111 @@ export async function showWorkflowStatusAsync(
stream: spinnerUsesStdErr ? process.stderr : process.stdout,
text: '',
}).start();
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting to start:}`;
updateSpinnerText(spinner, {
prefixText: chalk`{bold.yellow Workflow run is waiting to start:}`,
});
let failedFetchesCount = 0;
while (true) {
try {
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
useCache: false,
});
failedFetchesCount = 0;
switch (workflowRun.status) {
case WorkflowRunStatus.New:
break;
case WorkflowRunStatus.InProgress: {
spinner.prefixText = chalk`{bold.green Workflow run is in progress:}`;
spinner.text = await infoForActiveWorkflowRunAsync(graphqlClient, workflowRun, 5);
break;
}
case WorkflowRunStatus.ActionRequired:
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting for action:}`;
break;
case WorkflowRunStatus.Canceled:
spinner.prefixText = chalk`{bold.yellow Workflow has been canceled.}`;
spinner.stopAndPersist();
return workflowRun;
case WorkflowRunStatus.Failure: {
spinner.prefixText = chalk`{bold.red Workflow has failed.}`;
const failedInfo = await infoForFailedWorkflowRunAsync(graphqlClient, workflowRun, 30);
spinner.fail(failedInfo);
return workflowRun;
}
case WorkflowRunStatus.Success:
spinner.prefixText = chalk`{bold.green Workflow has completed successfully.}`;
spinner.text = '';
spinner.succeed('');
return workflowRun;
}
if (!waitForCompletion) {
if (spinner.isSpinning) {
spinner.stopAndPersist();
}
return workflowRun;
}
} catch {
spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.';
failedFetchesCount += 1;
if (failedFetchesCount > 6) {
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
process.exit(workflowRunExitCodes.WAIT_ABORTED);
}
const watcher = new WorkflowRunLogsWatcher(
graphqlClient,
() => createRealtimeLogsClient(graphqlClient),
() => {
renderActiveWorkflowRun();
}
);
let failedFetchesCount = 0;
let renderActiveWorkflowRun = (): void => {};
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
try {
while (true) {
try {
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
useCache: false,
});
failedFetchesCount = 0;
switch (workflowRun.status) {
case WorkflowRunStatus.New:
break;
case WorkflowRunStatus.InProgress: {
updateSpinnerText(spinner, {
prefixText: chalk`{bold.green Workflow run is in progress:}`,
});
const logsStates = await watcher.syncJobsAsync(workflowRun.jobs);
for (const job of workflowRun.jobs) {
if (isJobCompleted(job.status)) {
nullthrows(
logsStates.get(job.id),
'syncJobsAsync must have been called before markCompleted'
).markCompleted();
}
}
renderActiveWorkflowRun = () => {
const text = formatActiveWorkflowRun(
workflowRun.jobs.map(job => ({
job,
logs: nullthrows(
logsStates.get(job.id),
'syncJobsAsync must have been called before getLogs'
).getLogs(),
}))
);
updateSpinnerText(spinner, { text });
};
renderActiveWorkflowRun();
break;
}
case WorkflowRunStatus.ActionRequired:
updateSpinnerText(spinner, {
prefixText: chalk`{bold.yellow Workflow run is waiting for action:}`,
});
break;
case WorkflowRunStatus.Canceled:
updateSpinnerText(spinner, {
prefixText: chalk`{bold.yellow Workflow has been canceled.}`,
});
spinner.stopAndPersist();
return workflowRun;
case WorkflowRunStatus.Failure: {
updateSpinnerText(spinner, {
prefixText: chalk`{bold.red Workflow has failed.}`,
});
const jobLogs = await logsForFailedWorkflowRunAsync(graphqlClient, workflowRun);
spinner.fail(formatFailedWorkflowRun(jobLogs, 30));
return workflowRun;
}
case WorkflowRunStatus.Success:
updateSpinnerText(spinner, {
prefixText: chalk`{bold.green Workflow has completed successfully.}`,
});
spinner.succeed('');
return workflowRun;
}
if (!waitForCompletion) {
if (spinner.isSpinning) {
spinner.stopAndPersist();
}
return workflowRun;
}
} catch {
updateSpinnerText(spinner, {
text: '⚠ Failed to fetch the workflow run status. Check your network connection.',
});
failedFetchesCount += 1;
if (failedFetchesCount > 6) {
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
process.exit(workflowRunExitCodes.WAIT_ABORTED);
}
}
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
}
} finally {
watcher.close();
}
}
@@ -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();
});
});
@@ -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();
@@ -52,7 +57,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 +67,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 +75,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 +176,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,162 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
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,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
import {
buildObserveErrorGroupsJson,
buildObserveErrorGroupsTable,
buildObserveErrorOccurrencesJson,
buildObserveErrorOccurrencesTable,
} from '../../observe/formatErrors';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { observePlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
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,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveErrors);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveErrors,
loggedInOnlyContextDefinition: ObserveErrors.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
nonInteractive,
});
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,174 @@
import { Args } from '@oclif/core';
import { validate as isUuid } from 'uuid';
import EasCommand from '../../commandUtils/EasCommand';
import { EasCommandError } from '../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
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 { ObserveProjectIdFlag } from '../../observe/flags';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
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 = {
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveEvent);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveEvent,
loggedInOnlyContextDefinition: ObserveEvent.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
nonInteractive,
});
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);
}
@@ -12,6 +12,8 @@ import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents';
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
@@ -26,7 +28,7 @@ import {
buildObserveCustomEventsTable,
} from '../../observe/formatCustomEvents';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { appObservePlatformFromFlag } from '../../observe/platforms';
import { observePlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -53,7 +55,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.',
@@ -100,7 +104,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 +115,8 @@ export default class ObserveEvents extends EasCommand {
appId: projectId,
startTime,
endTime,
platform,
platforms,
environment: flags.environment,
})
);
@@ -138,10 +143,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 +157,8 @@ export default class ObserveEvents extends EasCommand {
appId: projectId,
startTime,
endTime,
platform,
platforms,
environment: flags.environment,
});
if (json) {
@@ -8,6 +8,7 @@ import {
import Log from '../../log';
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
import {
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
@@ -20,7 +21,7 @@ import {
} from '../../observe/formatMetrics';
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { appPlatformsFromFlag } from '../../observe/platforms';
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -62,6 +63,7 @@ export default class ObserveMetricsSummary extends EasCommand {
options: DEFAULT_STATS_JSON,
})(),
...ObserveTimeRangeFlags,
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -97,7 +99,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 +107,10 @@ export default class ObserveMetricsSummary extends EasCommand {
graphqlClient,
projectId,
metricNames,
platforms,
targets,
startTime,
endTime
endTime,
flags.environment
)
);
@@ -17,6 +17,8 @@ import {
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
@@ -25,7 +27,7 @@ import {
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 { observePlatformTargetsFromFlag, observePlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { selectAsync } from '../../prompts';
@@ -59,7 +61,9 @@ export default class ObserveMetrics extends EasCommand {
}),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveBuildNumberFlag,
...ObserveUpdateIdFlag,
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -108,8 +112,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 +124,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
),
])
);
@@ -11,6 +11,8 @@ import { fetchObserveNavigationRoutesAsync } from '../../observe/fetchNavigation
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
@@ -25,7 +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 { observePlatformTargetsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -61,15 +63,14 @@ 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,
}),
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -112,19 +113,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,
})
);
@@ -16,7 +16,11 @@ import {
fetchSessionMetricCandidatesAsync,
verifyObserveSessionAccessAsync,
} from '../../observe/fetchSessions';
import { ObserveProjectIdFlag, ObserveTimeRangeFlags } from '../../observe/flags';
import {
ObserveEnvironmentFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
} from '../../observe/flags';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import {
buildObserveSessionEventsJson,
@@ -66,6 +70,7 @@ 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,
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -102,10 +107,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 +133,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 +179,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 +217,7 @@ async function pickSessionIdInteractivelyAsync({
startTime,
endTime,
limit: PICKER_CANDIDATE_LIMIT,
environment,
})
).map(event => ({ sessionId: event.sessionId, title: formatMetricCandidateTitle(event) }))
: (
@@ -217,6 +227,7 @@ async function pickSessionIdInteractivelyAsync({
startTime,
endTime,
limit: PICKER_CANDIDATE_LIMIT,
environment,
})
).map(event => ({ sessionId: event.sessionId, title: formatLogCandidateTitle(event) }));
@@ -254,16 +265,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 +287,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) {
@@ -6,12 +6,13 @@ import {
import Log from '../../log';
import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
import {
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
} from '../../observe/flags';
import { buildObserveVersionsJson, buildObserveVersionsTable } from '../../observe/formatVersions';
import { appPlatformsFromFlag } from '../../observe/platforms';
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -22,6 +23,7 @@ export default class ObserveVersions extends EasCommand {
static override flags = {
...ObservePlatformFlag,
...ObserveTimeRangeFlags,
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -53,14 +55,15 @@ export default class ObserveVersions extends EasCommand {
const { startTime, endTime } = resolveTimeRange(flags);
const platforms = appPlatformsFromFlag(flags.platform);
const targets = observePlatformTargetsFromFlag(flags.platform);
const results = await fetchObserveVersionsAsync(
graphqlClient,
projectId,
platforms,
targets,
startTime,
endTime
endTime,
flags.environment
);
if (json) {
@@ -0,0 +1,364 @@
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppFragment,
AppPlatform,
BuildFragment,
BuildStatus,
DistributionType,
SubmissionStatus,
UpdateFragment,
WorkflowRunFragment,
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../../graphql/generated';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
import { ProjectStatusSubmission, SubmissionQuery } from '../../../graphql/queries/SubmissionQuery';
import { UpdateQuery } from '../../../graphql/queries/UpdateQuery';
import Log from '../../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ProjectStatus from '../status';
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../graphql/queries/BuildQuery');
jest.mock('../../../graphql/queries/SubmissionQuery');
jest.mock('../../../graphql/queries/UpdateQuery');
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockAppByIdAsync = jest.mocked(AppQuery.byIdAsync);
const mockWorkflowRunsAsync = jest.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync);
const mockViewBuildsOnAppAsync = jest.mocked(BuildQuery.viewBuildsOnAppAsync);
const mockProjectStatusSubmissionsAsync = jest.mocked(SubmissionQuery.forProjectStatusAsync);
const mockViewUpdateGroupsAsync = jest.mocked(UpdateQuery.viewUpdateGroupsOnAppAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
const projectId = 'test-project-id';
function makeApp(): AppFragment {
return {
id: projectId,
name: 'my-app',
fullName: '@jester/my-app',
slug: 'my-app',
ownerAccount: { id: 'account-1', name: 'jester' },
} as unknown as AppFragment;
}
function makeBuild(overrides: Partial<BuildFragment> = {}): BuildFragment {
return {
id: 'build-1',
platform: AppPlatform.Ios,
status: BuildStatus.Finished,
distribution: DistributionType.Store,
buildProfile: 'production',
appVersion: '1.0.0',
appBuildVersion: '42',
runtime: { id: 'runtime-1', version: '1.0.0' },
updateChannel: { id: 'channel-1', name: 'production' },
gitCommitHash: 'abcdef1234567890',
gitCommitMessage: 'Ship it\nlonger body',
createdAt: '2026-07-01T00:00:00.000Z',
completedAt: '2026-07-01T00:10:00.000Z',
initiatingActor: { id: 'actor-1', displayName: 'jester' },
app: {
id: projectId,
name: 'my-app',
slug: 'my-app',
ownerAccount: { id: 'account-1', name: 'jester' },
},
...overrides,
} as unknown as BuildFragment;
}
function makeWorkflowRun(overrides: Partial<WorkflowRunFragment> = {}): WorkflowRunFragment {
return {
id: 'run-1',
status: WorkflowRunStatus.Success,
gitCommitMessage: 'Automate all the things',
gitCommitHash: 'fedcba0987654321',
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2026-07-02T00:00:00.000Z',
updatedAt: '2026-07-02T00:05:00.000Z',
errors: [],
workflow: { id: 'wf-1', name: 'Publish', fileName: 'publish.yml' },
...overrides,
} as unknown as WorkflowRunFragment;
}
function makeSubmission(overrides: Partial<ProjectStatusSubmission> = {}): ProjectStatusSubmission {
return {
id: 'submission-1',
platform: AppPlatform.Android,
status: SubmissionStatus.Finished,
createdAt: '2026-07-02T12:00:00.000Z',
completedAt: '2026-07-02T12:05:00.000Z',
initiatingActor: { id: 'actor-1', displayName: 'jester' },
submittedBuild: { id: 'build-1' },
androidConfig: {
applicationIdentifier: 'com.example.app',
track: 'production',
releaseStatus: null,
rollout: 50,
},
error: null,
...overrides,
} as unknown as ProjectStatusSubmission;
}
function makeUpdateGroup(): UpdateFragment[] {
return [
{
id: 'update-ios',
group: 'group-1',
message: 'Fix crash on launch',
createdAt: '2026-07-03T00:00:00.000Z',
runtime: { id: 'runtime-1', version: '1.0.0' },
platform: 'ios',
isRollBackToEmbedded: false,
gitCommitHash: '1234567890abcdef',
rolloutPercentage: 25,
environment: 'production',
actor: { __typename: 'User', id: 'actor-1', username: 'jester' },
branch: { id: 'branch-1', name: 'production' },
},
{
id: 'update-android',
group: 'group-1',
message: 'Fix crash on launch',
createdAt: '2026-07-03T00:00:00.000Z',
runtime: { id: 'runtime-1', version: '1.0.0' },
platform: 'android',
isRollBackToEmbedded: false,
gitCommitHash: '1234567890abcdef',
rolloutPercentage: 25,
environment: 'production',
actor: { __typename: 'User', id: 'actor-1', username: 'jester' },
branch: { id: 'branch-1', name: 'production' },
},
] as unknown as UpdateFragment[];
}
describe(ProjectStatus, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
mockAppByIdAsync.mockResolvedValue(makeApp());
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
mockWorkflowRunsAsync.mockResolvedValue([makeWorkflowRun()]);
mockProjectStatusSubmissionsAsync.mockResolvedValue([makeSubmission()]);
mockViewUpdateGroupsAsync.mockResolvedValue([makeUpdateGroup()]);
});
function createCommand(argv: string[]): ProjectStatus {
const command = new ProjectStatus(argv, mockConfig);
// @ts-expect-error getContextAsync is stubbed for the test
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('splits production and development build queries by filter', async () => {
const command = createCommand([]);
await command.runAsync();
expect(mockViewBuildsOnAppAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
limit: 4,
offset: 0,
filter: { developmentClient: false, distribution: DistributionType.Store },
});
expect(mockViewBuildsOnAppAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
limit: 4,
offset: 0,
filter: { developmentClient: true },
});
});
it('respects the --limit flag across all sections', async () => {
const command = createCommand(['--limit', '5']);
await command.runAsync();
expect(mockWorkflowRunsAsync).toHaveBeenCalledWith(graphqlClient, projectId, undefined, 6);
expect(mockProjectStatusSubmissionsAsync).toHaveBeenCalledWith(graphqlClient, projectId, {
limit: 6,
offset: 0,
});
expect(mockViewUpdateGroupsAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
limit: 6,
offset: 0,
});
});
it('renders human-readable text output by default without touching JSON output', async () => {
const command = createCommand([]);
await expect(command.runAsync()).resolves.not.toThrow();
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
it('renders build statuses unknown to this CLI version', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue([
makeBuild({ status: 'WAITING_FOR_CAPACITY' as BuildStatus }),
]);
const command = createCommand([]);
await command.runAsync();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('waiting for capacity'));
});
it('renders workflow run statuses unknown to this CLI version', async () => {
mockWorkflowRunsAsync.mockResolvedValue([
makeWorkflowRun({ status: 'PENDING_CANCEL' as WorkflowRunStatus }),
]);
const command = createCommand([]);
await command.runAsync();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('pending cancel'));
});
it('handles a project with no activity in any section', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue([]);
mockWorkflowRunsAsync.mockResolvedValue([]);
mockProjectStatusSubmissionsAsync.mockResolvedValue([]);
mockViewUpdateGroupsAsync.mockResolvedValue([]);
const command = createCommand([]);
await expect(command.runAsync()).resolves.not.toThrow();
});
it('prints an aggregated JSON snapshot with --json', async () => {
const command = createCommand(['--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledTimes(1);
const output = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(output.generatedAt).toEqual(expect.any(String));
expect(output.limit).toBe(3);
expect(output.hasMore).toEqual({
productionBuilds: false,
developmentBuilds: false,
workflowRuns: false,
submissions: false,
updates: false,
});
expect(output.project).toMatchObject({
id: projectId,
fullName: '@jester/my-app',
account: 'jester',
url: 'https://expo.dev/accounts/jester/projects/my-app',
});
expect(output.productionBuilds).toHaveLength(1);
expect(output.productionBuilds[0]).toMatchObject({
id: 'build-1',
status: BuildStatus.Finished,
gitCommitMessage: 'Ship it',
url: 'https://expo.dev/accounts/jester/projects/my-app/builds/build-1',
});
expect(output.developmentBuilds).toHaveLength(1);
expect(output.workflowRuns[0]).toMatchObject({
id: 'run-1',
workflowName: 'Publish',
errors: [],
url: 'https://expo.dev/accounts/jester/projects/my-app/workflows/run-1',
});
expect(output.submissions[0]).toMatchObject({
id: 'submission-1',
androidTrack: 'production',
appIdentifier: 'com.example.app',
buildId: 'build-1',
rollout: 50,
createdAt: '2026-07-02T12:00:00.000Z',
url: 'https://expo.dev/accounts/jester/projects/my-app/submissions/submission-1',
});
expect(output.updates[0]).toMatchObject({
group: 'group-1',
branch: 'production',
platforms: ['android', 'ios'],
message: 'Fix crash on launch',
rolloutPercentage: 25,
environment: 'production',
initiatingActor: 'jester',
url: 'https://expo.dev/accounts/jester/projects/my-app/updates/group-1',
});
});
it('includes structured activity errors', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue([
makeBuild({
status: BuildStatus.Errored,
error: {
errorCode: 'BUILD_FAILED',
message: 'Gradle failed',
docsUrl: 'https://docs.expo.dev/build-reference/troubleshooting/',
},
}),
]);
mockWorkflowRunsAsync.mockResolvedValue([
makeWorkflowRun({
status: WorkflowRunStatus.Failure,
errors: [{ title: 'Deploy failed', message: 'Deployment did not complete' }],
}),
]);
mockProjectStatusSubmissionsAsync.mockResolvedValue([
makeSubmission({
status: SubmissionStatus.Errored,
error: { errorCode: 'SUBMIT_FAILED', message: 'Store upload failed' },
}),
]);
await createCommand(['--json']).runAsync();
const output = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(output.productionBuilds[0].error).toEqual({
code: 'BUILD_FAILED',
message: 'Gradle failed',
docsUrl: 'https://docs.expo.dev/build-reference/troubleshooting/',
});
expect(output.workflowRuns[0].errors).toEqual([
{ title: 'Deploy failed', message: 'Deployment did not complete' },
]);
expect(output.submissions[0].error).toEqual({
code: 'SUBMIT_FAILED',
message: 'Store upload failed',
});
});
it('reports when more results are available and returns only the requested limit', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue(Array.from({ length: 4 }, () => makeBuild()));
mockWorkflowRunsAsync.mockResolvedValue(Array.from({ length: 4 }, () => makeWorkflowRun()));
mockProjectStatusSubmissionsAsync.mockResolvedValue(
Array.from({ length: 4 }, () => makeSubmission())
);
mockViewUpdateGroupsAsync.mockResolvedValue(Array.from({ length: 4 }, () => makeUpdateGroup()));
await createCommand(['--json']).runAsync();
const output = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(output.hasMore).toEqual({
productionBuilds: true,
developmentBuilds: true,
workflowRuns: true,
submissions: true,
updates: true,
});
expect(output.productionBuilds).toHaveLength(3);
expect(output.developmentBuilds).toHaveLength(3);
expect(output.workflowRuns).toHaveLength(3);
expect(output.submissions).toHaveLength(3);
expect(output.updates).toHaveLength(3);
});
});
@@ -0,0 +1,65 @@
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import {
PROJECT_STATUS_DEFAULT_LIMIT,
getProjectStatusAsync,
printProjectStatusAsText,
} from '../../project/projectStatus';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ProjectStatus extends EasCommand {
static override description =
'show a snapshot of the project: recent builds, dev builds, workflow runs, submissions, and updates';
static override aliases = ['status'];
static override examples = [
'$ eas status \t # Show a concise project snapshot',
'$ eas status --json \t # Output a machine-readable snapshot for agents and automation',
'$ eas status --json --limit 10 \t # Include more activity from each section',
];
static override flags = {
limit: getLimitFlagWithCustomValues({
defaultTo: PROJECT_STATUS_DEFAULT_LIMIT,
limit: 25,
description: `The number of items to show in each section. Defaults to ${PROJECT_STATUS_DEFAULT_LIMIT} and is capped at 25.`,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ProjectStatus);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ProjectStatus, { nonInteractive });
const status = await getProjectStatusAsync(graphqlClient, {
projectId,
limit: flags.limit ?? PROJECT_STATUS_DEFAULT_LIMIT,
});
if (jsonFlag) {
printJsonOnlyOutput(status);
return;
}
printProjectStatusAsText(status);
}
}
@@ -48,6 +48,7 @@ function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): De
return {
id: 'session-123',
name: null,
tags: [],
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.AgentDevice,
platform: AppPlatform.Ios,
@@ -143,6 +144,7 @@ describe(SimulatorGet, () => {
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
id: 'session-123',
name: undefined,
tags: [],
type: 'web-preview-only',
status: DeviceRunSessionStatus.InProgress,
platform: AppPlatform.Ios,
@@ -110,6 +110,7 @@ function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): De
return {
id: 'session-123',
name: null,
tags: [],
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.AgentDevice,
platform: AppPlatform.Ios,
@@ -332,12 +333,12 @@ describe(Simulator, () => {
);
});
it('creates a ServeSim session for --type web-preview-only', async () => {
it('creates a WebPreviewOnly session for --type web-preview-only', async () => {
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({
type: DeviceRunSessionType.ServeSim,
type: DeviceRunSessionType.WebPreviewOnly,
remoteConfig: {
__typename: 'ServeSimRunSessionRemoteConfig',
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
previewUrl: 'https://preview.example.test',
},
})
@@ -354,7 +355,7 @@ describe(Simulator, () => {
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ type: DeviceRunSessionType.ServeSim })
expect.objectContaining({ type: DeviceRunSessionType.WebPreviewOnly })
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('https://preview.example.test'));
});
@@ -508,7 +509,7 @@ describe(Simulator, () => {
);
});
it('forwards --device to the create mutation as deviceIdentifier', async () => {
it('forwards --device in the iOS create options', async () => {
const { command } = createCommand([
'--platform',
'ios',
@@ -520,7 +521,23 @@ describe(Simulator, () => {
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ deviceIdentifier: 'iPhone 16 Pro' })
expect.objectContaining({ ios: { deviceIdentifier: 'iPhone 16 Pro' } })
);
});
it('forwards --device in the Android create options', async () => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
'--device',
'pixel_9',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ android: { deviceIdentifier: 'pixel_9' } })
);
});
@@ -536,7 +553,7 @@ describe(Simulator, () => {
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ deviceIdentifier: 'iPhone 16 Pro' })
expect.objectContaining({ ios: { deviceIdentifier: 'iPhone 16 Pro' } })
);
});
@@ -544,10 +561,7 @@ describe(Simulator, () => {
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--device', ' ']);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ deviceIdentifier: undefined })
);
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1]).not.toHaveProperty('ios');
});
it('omits resourceClass when --resource-class is not set', async () => {
@@ -613,6 +627,19 @@ describe(Simulator, () => {
);
});
it('warns that Android emulator support is still in development before creating a session', async () => {
const { command } = createCommand(['--platform', 'android', '--non-interactive']);
await command.runAsync();
expect(Log.warn).toHaveBeenCalledWith(
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
);
expect(jest.mocked(Log.warn).mock.invocationCallOrder[0]).toBeLessThan(
mockCreateDeviceRunSessionAsync.mock.invocationCallOrder[0]
);
});
it.each([
['ios', AppPlatform.Ios],
['android', AppPlatform.Android],
@@ -696,6 +723,36 @@ describe(Simulator, () => {
);
});
it('forwards repeated tags', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--tag',
'variant:pro',
'--tag',
' nightly ',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ tags: ['variant:pro', 'nightly'] })
);
});
it('omits tags when every tag is blank', async () => {
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--tag', ' ']);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.not.objectContaining({ tags: expect.anything() })
);
});
it.each([
['--launch-arg', '--uitesting'],
['--open-url', 'exp://example.test'],
@@ -42,6 +42,7 @@ function makeSession(overrides: Partial<DeviceRunSessionNode> = {}): DeviceRunSe
return {
id: 'session-123',
name: null,
tags: [],
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.AgentDevice,
platform: AppPlatform.Ios,
@@ -132,6 +133,7 @@ describe(SimulatorList, () => {
{
id: 'session-123',
name: undefined,
tags: [],
type: 'web-preview-only',
status: DeviceRunSessionStatus.InProgress,
platform: AppPlatform.Ios,
@@ -168,6 +170,10 @@ describe(SimulatorList, () => {
'ios',
'--name',
'checkout',
'--tag',
'variant:pro',
'--tag',
'nightly',
'--limit',
'25',
'--after',
@@ -181,13 +187,33 @@ describe(SimulatorList, () => {
after: 'page-cursor',
filter: {
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
types: [DeviceRunSessionType.Appium, DeviceRunSessionType.ServeSim],
types: [DeviceRunSessionType.Appium, DeviceRunSessionType.WebPreviewOnly],
platforms: [AppPlatform.Ios],
name: 'checkout',
tags: ['variant:pro', 'nightly'],
},
});
});
it('prints a tags row for every session, tagged or not', async () => {
mockListByAppIdAsync.mockResolvedValue(
makeConnection([
makeSession({ id: 'session-1', tags: ['variant:pro', 'nightly'] }),
makeSession({ id: 'session-2' }),
])
);
const { command } = createCommand([]);
await command.runAsync();
const printed = jest
.mocked(Log.log)
.mock.calls.map(([entry]) => String(entry))
.join('\n');
expect(printed).toContain('Tags: variant:pro, nightly');
expect(printed).toContain('Tags: none');
});
it('prints a name row for every session, named or not', async () => {
mockListByAppIdAsync.mockResolvedValue(
makeConnection([
@@ -18,6 +18,7 @@ import {
import {
deviceRunSessionTypeToFlagValue,
formatRemoteSessionInstructions,
sanitizeRemoteConfigForJson,
} from '../../simulator/utils';
import { formatBytes } from '../../utils/files';
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
@@ -87,6 +88,7 @@ export default class SimulatorGet extends EasCommand {
printJsonOnlyOutput({
id: session.id,
name: session.name ?? undefined,
tags: session.tags,
type: deviceRunSessionTypeToFlagValue(session.type),
status: session.status,
platform: session.platform,
@@ -95,7 +97,9 @@ export default class SimulatorGet extends EasCommand {
finishedAt: session.finishedAt ?? undefined,
updatedAt: session.updatedAt,
deviceRunSessionUrl,
remoteConfig: session.remoteConfig,
remoteConfig: session.remoteConfig
? sanitizeRemoteConfigForJson(session.remoteConfig)
: session.remoteConfig,
artifacts: session.artifacts,
});
return;
@@ -123,6 +127,7 @@ function formatSessionFields(session: DeviceRunSessionById, deviceRunSessionUrl:
return formatFields([
{ label: 'ID', value: session.id },
{ label: 'Name', value: formatNullable(session.name) },
{ label: 'Tags', value: session.tags.length > 0 ? session.tags.join(', ') : 'none' },
{ label: 'Type', value: session.type },
{ label: 'Status', value: session.status },
{ label: 'Platform', value: session.platform },
@@ -38,6 +38,7 @@ import {
formatRemoteSessionInstructions,
formatSimulatorUnavailableMessage,
getRemoteSessionEnvironmentVariables,
sanitizeRemoteConfigForJson,
} from '../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { sleepAsync } from '../../utils/promise';
@@ -71,6 +72,11 @@ export default class Simulator extends EasCommand {
description:
'Human-readable name for the simulator session, shown in eas simulator:list and on expo.dev. Defaults to unnamed.',
}),
tag: Flags.string({
description:
'Label used to group simulator sessions, for example one per app variant. Repeat for multiple tags. Stored lowercased.',
multiple: true,
}),
device: Flags.string({
description:
'Virtual device to start for the session. On iOS, a Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android, an AVD hardware profile id (e.g. "pixel_7"). Defaults to a device chosen by the runner.',
@@ -179,6 +185,7 @@ export default class Simulator extends EasCommand {
// The server rejects blank names, so trim here and treat a whitespace-only
// --name as if it had been omitted rather than surfacing a validation error.
const name = flags.name?.trim() || undefined;
const tags = flags.tag?.map(tag => tag.trim()).filter(tag => tag.length > 0);
const deviceIdentifier = flags.device?.trim() || undefined;
const buildId = flags['build-id']?.trim() || undefined;
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
@@ -212,6 +219,12 @@ export default class Simulator extends EasCommand {
}
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
if (platform === AppPlatform.Android) {
Log.warn(
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
);
Log.newLine();
}
const expoGoSdkVersion = flags['expo-go']
? await resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion: sdkVersionFromFlag })
: undefined;
@@ -233,10 +246,15 @@ export default class Simulator extends EasCommand {
const session = await DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, {
appId: projectId,
name,
...(tags?.length ? { tags } : {}),
platform,
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
packageVersion: flags['package-version'],
deviceIdentifier,
...(deviceIdentifier
? platform === AppPlatform.Ios
? { ios: { deviceIdentifier } }
: { android: { deviceIdentifier } }
: {}),
...(buildId ? { buildId } : {}),
...(applicationArchiveUrlFromFlag
? { applicationArchiveUrl: applicationArchiveUrlFromFlag }
@@ -368,7 +386,7 @@ export default class Simulator extends EasCommand {
name,
type: flags.type,
deviceRunSessionUrl,
remoteConfig,
remoteConfig: sanitizeRemoteConfigForJson(remoteConfig),
});
return;
}
@@ -74,6 +74,10 @@ export default class SimulatorList extends EasCommand {
name: Flags.string({
description: 'Filter by session name (case-insensitive prefix match)',
}),
tag: Flags.string({
description: 'Filter by tag (repeatable). A session must carry every tag listed.',
multiple: true,
}),
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }),
after: Flags.string({
description:
@@ -115,6 +119,9 @@ export default class SimulatorList extends EasCommand {
if (flags.name) {
filter.name = flags.name;
}
if (flags.tag && flags.tag.length > 0) {
filter.tags = flags.tag;
}
const limit = flags.limit ?? DEFAULT_LIMIT;
@@ -140,6 +147,7 @@ export default class SimulatorList extends EasCommand {
sessions: sessions.map(session => ({
id: session.id,
name: session.name ?? undefined,
tags: session.tags,
type: deviceRunSessionTypeToFlagValue(session.type),
status: session.status,
platform: session.platform,
@@ -173,6 +181,7 @@ export default class SimulatorList extends EasCommand {
const lines = [
`ID: ${session.id}`,
`Name: ${session.name ?? 'null'}`,
`Tags: ${session.tags.length > 0 ? session.tags.join(', ') : 'none'}`,
`Type: ${session.type}`,
`Status: ${session.status}`,
`Platform: ${session.platform}`,
@@ -0,0 +1,171 @@
import spawnAsync from '@expo/spawn-async';
import { Config } from '@oclif/core';
import { fileExistsAsync, maybeReadStdinAsync } from '../../../commandUtils/workflow/utils';
import { WorkflowRevisionMutation } from '../../../graphql/mutations/WorkflowRevisionMutation';
import { WorkflowRunMutation } from '../../../graphql/mutations/WorkflowRunMutation';
import Log from '../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
import { uploadAccountScopedFileAsync } from '../../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../../project/uploadAccountScopedProjectSourceAsync';
import { WorkflowFile } from '../../../utils/workflowFile';
import WorkflowRun from '../run';
jest.mock('@expo/spawn-async', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../graphql/mutations/WorkflowRevisionMutation');
jest.mock('../../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../../project/projectUtils');
jest.mock('../../../project/uploadAccountScopedFileAsync');
jest.mock('../../../project/uploadAccountScopedProjectSourceAsync');
jest.mock('../../../utils/workflowFile');
jest.mock('../../../commandUtils/workflow/utils', () => ({
...jest.requireActual('../../../commandUtils/workflow/utils'),
fileExistsAsync: jest.fn(),
maybeReadStdinAsync: jest.fn(),
}));
jest.mock('../../../log', () => ({
__esModule: true,
default: {
log: jest.fn(),
newLine: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
link: jest.fn((url: string) => url),
}));
const mockSpawn = jest.mocked(spawnAsync);
const mockValidate = jest.mocked(WorkflowRevisionMutation.validateWorkflowYamlConfigAsync);
const mockGetOrCreateRevision = jest.mocked(
WorkflowRevisionMutation.getOrCreateWorkflowRevisionFromGitRefAsync
);
const mockCreateFromGitRef = jest.mocked(WorkflowRunMutation.createWorkflowRunFromGitRefAsync);
const mockCreateRun = jest.mocked(WorkflowRunMutation.createWorkflowRunAsync);
const mockGetOwner = jest.mocked(getOwnerAccountForProjectIdAsync);
const mockUploadProject = jest.mocked(uploadAccountScopedProjectSourceAsync);
const mockUploadFile = jest.mocked(uploadAccountScopedFileAsync);
const mockReadWorkflow = jest.mocked(WorkflowFile.readWorkflowFileContentsAsync);
const mockFileExists = jest.mocked(fileExistsAsync);
const mockReadStdin = jest.mocked(maybeReadStdinAsync);
describe('WorkflowRun --ssh', () => {
let mockConfig: Config;
let exitSpy: jest.SpyInstance;
beforeAll(async () => {
mockConfig = new Config({ root: __dirname });
mockConfig.runHook = async () => ({ failures: [], successes: [] });
});
beforeEach(() => {
jest.clearAllMocks();
exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
mockReadStdin.mockResolvedValue(null);
mockGetOwner.mockResolvedValue({ id: 'acct-1', name: 'testuser' } as never);
mockValidate.mockResolvedValue(undefined as never);
mockReadWorkflow.mockResolvedValue({
filePath: '.eas/workflows/test.yml',
yamlConfig: 'jobs:\n a:\n steps: []\n',
} as never);
mockFileExists.mockResolvedValue(false);
mockUploadProject.mockResolvedValue({ projectArchiveBucketKey: 'archive-key' } as never);
mockUploadFile.mockResolvedValue({ fileBucketKey: 'file-key' } as never);
mockCreateRun.mockResolvedValue({ id: 'run-1' } as never);
mockCreateFromGitRef.mockResolvedValue({ id: 'run-git-1' } as never);
mockSpawn.mockResolvedValue({ output: ['abc123\n'] } as never);
mockGetOrCreateRevision.mockResolvedValue({
id: 'rev-1',
yamlConfig: 'jobs:\n a:\n steps: []\n',
} as never);
});
afterEach(() => {
exitSpy.mockRestore();
});
function createCommand(flags: {
ssh?: boolean;
'ssh-idle-timeout'?: number;
ref?: string;
}): WorkflowRun {
const command = new WorkflowRun(['.eas/workflows/test.yml'], mockConfig);
// @ts-expect-error getContextAsync/parse are protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
getDynamicPrivateProjectConfigAsync: async () => ({
projectId: 'app-1',
exp: { slug: 'testapp' },
}),
loggedIn: { graphqlClient: {} },
vcsClient: { getRootPathAsync: async () => '/test/project' },
projectDir: '/test/project',
});
// @ts-expect-error parse is protected on the oclif base
jest.spyOn(command, 'parse').mockResolvedValue({
flags: {
json: false,
wait: false,
'non-interactive': true,
input: undefined,
ref: flags.ref,
ssh: flags.ssh ?? false,
'ssh-idle-timeout': flags['ssh-idle-timeout'],
},
args: { file: '.eas/workflows/test.yml' },
});
return command;
}
it('passes ssh input into createWorkflowRunAsync and logs that SSH is enabled', async () => {
await expect(createCommand({ ssh: true, 'ssh-idle-timeout': 120 }).runAsync()).rejects.toThrow(
'process.exit:0'
);
expect(mockCreateRun).toHaveBeenCalledWith(
{},
expect.objectContaining({
workflowRunInput: expect.objectContaining({
ssh: { idleTimeoutSeconds: 120 },
}),
})
);
expect(Log.log).toHaveBeenCalledWith(
'SSH enabled. Each VM job logs the `eas workflow:ssh` command to connect.'
);
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('does not log the SSH banner when --ssh is unset', async () => {
await expect(createCommand({ ssh: false }).runAsync()).rejects.toThrow('process.exit:0');
expect(mockCreateRun).toHaveBeenCalledWith(
{},
expect.objectContaining({
workflowRunInput: expect.objectContaining({
ssh: null,
}),
})
);
expect(Log.log).not.toHaveBeenCalledWith(
'SSH enabled. Each VM job logs the `eas workflow:ssh` command to connect.'
);
});
it('passes ssh input into createWorkflowRunFromGitRefAsync when --ref is set', async () => {
await expect(createCommand({ ssh: true, ref: 'main' }).runAsync()).rejects.toThrow(
'process.exit:0'
);
expect(mockCreateFromGitRef).toHaveBeenCalledWith(
{},
expect.objectContaining({
ssh: { idleTimeoutSeconds: undefined },
})
);
expect(Log.log).toHaveBeenCalledWith(
'SSH enabled. Each VM job logs the `eas workflow:ssh` command to connect.'
);
});
});
@@ -3,6 +3,31 @@ import {
parseJsonInputs,
parseWorkflowInputsFromYaml,
} from '../../../commandUtils/workflow/inputs';
import { resolveWorkflowRunSshInput } from '../run';
describe(resolveWorkflowRunSshInput, () => {
it('is null when --ssh is not set', () => {
expect(resolveWorkflowRunSshInput({ ssh: false, idleTimeoutSeconds: undefined })).toBeNull();
});
it('forwards ssh with an undefined timeout when --ssh is set alone', () => {
expect(resolveWorkflowRunSshInput({ ssh: true, idleTimeoutSeconds: undefined })).toEqual({
idleTimeoutSeconds: undefined,
});
});
it('forwards the requested idle timeout when both flags are set', () => {
expect(resolveWorkflowRunSshInput({ ssh: true, idleTimeoutSeconds: 60 })).toEqual({
idleTimeoutSeconds: 60,
});
});
it('rejects --ssh-idle-timeout without --ssh', () => {
expect(() => resolveWorkflowRunSshInput({ ssh: false, idleTimeoutSeconds: 60 })).toThrow(
'--ssh-idle-timeout requires --ssh.'
);
});
});
describe('parseInputs', () => {
it('should parse single key=value pair', () => {
@@ -0,0 +1,387 @@
import spawnAsync from '@expo/spawn-async';
import { Config } from '@oclif/core';
import fs from 'node:fs';
import { WorkflowJobSshQuery } from '../../../graphql/queries/WorkflowJobSshQuery';
import Log from '../../../log';
import { sleepAsync } from '../../../utils/promise';
import WorkflowSsh, {
parseSshArgv,
resolveSshConnectStatus,
splitConnectionHost,
sshHostAliasForResource,
terminalSshStatusMessage,
} from '../ssh';
jest.mock('@expo/spawn-async', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../graphql/queries/WorkflowJobSshQuery');
jest.mock('../../../log', () => ({
__esModule: true,
default: { log: jest.fn(), error: jest.fn(), newLine: jest.fn() },
}));
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
const spinner = { start: jest.fn(), fail: jest.fn(), succeed: jest.fn(), stop: jest.fn() };
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
jest.mock('../../../utils/promise', () => ({ sleepAsync: jest.fn() }));
jest.mock('node:fs', () => {
const actual = jest.requireActual('node:fs');
return {
...actual,
promises: { ...actual.promises, mkdtemp: jest.fn(), writeFile: jest.fn(), rm: jest.fn() },
};
});
describe(parseSshArgv, () => {
it('takes the first token as the resource id and the rest as the passthrough command', () => {
expect(parseSshArgv(['job-id', 'ls', '-la'])).toEqual({
showConnect: false,
resourceId: 'job-id',
command: ['ls', '-la'],
});
});
it('detects --show-connect before the id and strips it', () => {
expect(parseSshArgv(['--show-connect', 'job-id'])).toEqual({
showConnect: true,
resourceId: 'job-id',
command: [],
});
});
it('detects --show-connect=true before the id', () => {
expect(parseSshArgv(['--show-connect=true', 'job-id'])).toEqual({
showConnect: true,
resourceId: 'job-id',
command: [],
});
});
it('leaves --show-connect after the id in the passthrough command', () => {
expect(parseSshArgv(['job-id', '--show-connect'])).toEqual({
showConnect: false,
resourceId: 'job-id',
command: ['--show-connect'],
});
});
it('throws on an unknown flag before the id', () => {
expect(() => parseSshArgv(['--show-conect', 'job-id'])).toThrow('Unknown flag');
});
it('reports an undefined resource id when none is given', () => {
expect(parseSshArgv([])).toEqual({ showConnect: false, resourceId: undefined, command: [] });
expect(parseSshArgv(['--show-connect'])).toEqual({
showConnect: true,
resourceId: undefined,
command: [],
});
});
});
describe(resolveSshConnectStatus, () => {
it('is unknown when the resource did not resolve', () => {
expect(resolveSshConnectStatus(null)).toBe('unknown');
});
it('is not-enabled when the job did not request ssh', () => {
expect(
resolveSshConnectStatus({ sshRequested: false, jobCompleted: false, session: null })
).toBe('not-enabled');
});
it('is ended when ssh was requested, the job finished, and no session remains', () => {
expect(resolveSshConnectStatus({ sshRequested: true, jobCompleted: true, session: null })).toBe(
'ended'
);
});
it('is pending when ssh was requested and the job is still running without a session', () => {
expect(
resolveSshConnectStatus({ sshRequested: true, jobCompleted: false, session: null })
).toBe('pending');
});
it('is pending while the worker is reconnecting', () => {
expect(
resolveSshConnectStatus({
sshRequested: true,
jobCompleted: false,
session: {
connectionConfig: { host: 'uptermd.upterm.dev', secret: 'tok', reconnecting: true },
},
})
).toBe('pending');
});
it('is ready when the session has a connection config', () => {
expect(
resolveSshConnectStatus({
sshRequested: true,
jobCompleted: false,
session: {
connectionConfig: { host: 'uptermd.upterm.dev', secret: 'tok', reconnecting: false },
},
})
).toBe('ready');
});
});
describe(splitConnectionHost, () => {
it('returns the host with no port for a plain hostname', () => {
expect(splitConnectionHost('relay.expo.dev')).toEqual({ hostname: 'relay.expo.dev' });
});
it('splits a host:port into host and numeric port', () => {
expect(splitConnectionHost('relay.expo.dev:8022')).toEqual({
hostname: 'relay.expo.dev',
port: 8022,
});
});
});
describe(terminalSshStatusMessage, () => {
it('has no message while the session can still open', () => {
expect(terminalSshStatusMessage('pending', 'job-1')).toBeNull();
expect(terminalSshStatusMessage('ready', 'job-1')).toBeNull();
});
it('distinguishes not-enabled from ended', () => {
expect(terminalSshStatusMessage('not-enabled', 'job-1')).toMatch(/SSH was not enabled/);
expect(terminalSshStatusMessage('ended', 'job-1')).toMatch(/has ended/);
expect(terminalSshStatusMessage('unknown', 'job-1')).toMatch(/No workflow job found/);
});
});
describe(sshHostAliasForResource, () => {
it('suffixes the alias with the start of the resource id', () => {
expect(sshHostAliasForResource('job-1')).toBe('eas-workflow-ssh-job1');
});
it('drops characters that are not safe in an ssh config host alias', () => {
expect(sshHostAliasForResource('a b\nHost evil')).toBe('eas-workflow-ssh-abHostev');
});
it('falls back to the bare alias when nothing usable remains', () => {
expect(sshHostAliasForResource('---')).toBe('eas-workflow-ssh');
});
});
describe(WorkflowSsh, () => {
const graphqlClient = {} as never;
const mockConnectInfo = jest.mocked(WorkflowJobSshQuery.connectInfoForResourceIdAsync);
const mockSpawn = jest.mocked(spawnAsync);
const mockSleep = jest.mocked(sleepAsync);
const mockMkdtemp = jest.mocked(fs.promises.mkdtemp);
const mockWriteFile = jest.mocked(fs.promises.writeFile);
const mockRm = jest.mocked(fs.promises.rm);
let mockConfig: Config;
let previousExitCode: typeof process.exitCode;
const readyInfo = {
sshRequested: true,
jobCompleted: false,
session: {
id: 'ts-1',
connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false },
},
};
const pendingInfo = { sshRequested: true, jobCompleted: false, session: null };
const endedInfo = { sshRequested: true, jobCompleted: true, session: null };
const notEnabledInfo = { sshRequested: false, jobCompleted: false, session: null };
beforeAll(async () => {
mockConfig = new Config({ root: __dirname });
mockConfig.runHook = async () => ({ failures: [], successes: [] });
});
beforeEach(() => {
jest.clearAllMocks();
previousExitCode = process.exitCode;
process.exitCode = undefined;
mockMkdtemp.mockResolvedValue('/tmp/eas-ssh-1' as never);
mockWriteFile.mockResolvedValue(undefined as never);
mockRm.mockResolvedValue(undefined as never);
mockSpawn.mockResolvedValue({ stdout: '', stderr: '' } as never);
mockSleep.mockResolvedValue(undefined);
});
afterEach(() => {
process.exitCode = previousExitCode;
});
function createCommand(argv: string[]): WorkflowSsh {
const command = new WorkflowSsh(argv, mockConfig);
// @ts-expect-error getContextAsync/parse are protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({ loggedIn: { graphqlClient } });
// @ts-expect-error parse is protected on the oclif base
jest.spyOn(command, 'parse').mockResolvedValue({});
return command;
}
it('throws when no workflow job id is given', async () => {
await expect(createCommand([]).runAsync()).rejects.toThrow('Provide a workflow job id');
});
it('reports unknown and exits 1 when the id does not resolve', async () => {
mockConnectInfo.mockResolvedValue(null);
await createCommand(['job-1']).runAsync();
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('No workflow job found'));
expect(process.exitCode).toBe(1);
});
it('reports not-enabled and exits 1 when the job did not request ssh', async () => {
mockConnectInfo.mockResolvedValue(notEnabledInfo as never);
await createCommand(['job-1']).runAsync();
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('was not enabled'));
expect(process.exitCode).toBe(1);
});
it('reports ended and exits 1 when the job finished without a live session', async () => {
mockConnectInfo.mockResolvedValue(endedInfo as never);
await createCommand(['job-1']).runAsync();
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('has ended'));
expect(process.exitCode).toBe(1);
});
it('prints the connect command for --show-connect without spawning ssh', async () => {
mockConnectInfo.mockResolvedValue(readyInfo as never);
await createCommand(['--show-connect', 'job-1']).runAsync();
expect(Log.log).toHaveBeenCalledWith(
'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null [email protected]'
);
expect(mockSpawn).not.toHaveBeenCalled();
});
it('prints --show-connect with -p and a hostname-only wss proxy for non-default SSH ports', async () => {
mockConnectInfo.mockResolvedValue({
...readyInfo,
session: {
id: 'ts-1',
connectionConfig: {
host: 'relay.expo.dev:8022',
secret: 'TOKENx',
reconnecting: false,
},
},
} as never);
await createCommand(['--show-connect', 'job-1']).runAsync();
expect(Log.log).toHaveBeenCalledWith(
'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 8022 [email protected]'
);
expect(Log.log).toHaveBeenCalledWith(
' ssh -o ProxyCommand="upterm proxy wss://[email protected]" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 8022 [email protected]'
);
});
it('writes a 0600 config and opens ssh, then removes the config dir', async () => {
mockConnectInfo.mockResolvedValue(readyInfo as never);
await createCommand(['job-1', 'ls', '-la']).runAsync();
expect(mockWriteFile).toHaveBeenCalledWith(
'/tmp/eas-ssh-1/config',
expect.stringContaining('HostName relay.expo.dev'),
{ mode: 0o600 }
);
expect(mockWriteFile).toHaveBeenCalledWith(
'/tmp/eas-ssh-1/config',
expect.stringContaining('Host eas-workflow-ssh-job1'),
{ mode: 0o600 }
);
expect(mockSpawn).toHaveBeenCalledWith(
'ssh',
['-F', '/tmp/eas-ssh-1/config', 'eas-workflow-ssh-job1', 'ls', '-la'],
{ stdio: 'inherit' }
);
expect(mockRm).toHaveBeenCalledWith('/tmp/eas-ssh-1', { recursive: true, force: true });
});
it('waits for a pending session to open, then connects', async () => {
mockConnectInfo
.mockResolvedValueOnce(pendingInfo as never) // initial status check
.mockResolvedValueOnce(pendingInfo as never) // first wait poll
.mockResolvedValue(readyInfo as never);
await createCommand(['job-1']).runAsync();
expect(mockSleep).toHaveBeenCalled();
expect(mockSpawn).toHaveBeenCalled();
});
it('exits 1 when a pending session ends before it opens', async () => {
mockConnectInfo
.mockResolvedValueOnce(pendingInfo as never)
.mockResolvedValue(endedInfo as never);
await createCommand(['job-1']).runAsync();
expect(mockSpawn).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it('exits 1 when waiting for the session to open times out', async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
try {
mockConnectInfo.mockResolvedValue(pendingInfo as never);
mockSleep.mockImplementation(async () => {
jest.setSystemTime(Date.now() + 6 * 60 * 1000);
});
await createCommand(['job-1']).runAsync();
expect(mockSpawn).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
} finally {
jest.useRealTimers();
}
});
it('throws on an unexpected connection host', async () => {
mockConnectInfo.mockResolvedValue({
sshRequested: true,
jobCompleted: false,
session: {
connectionConfig: { host: 'bad host', secret: 'TOKENx', reconnecting: false },
},
} as never);
await expect(createCommand(['job-1']).runAsync()).rejects.toThrow('connection host');
});
it('propagates the ssh exit code from the catch handler', async () => {
const command = createCommand(['job-1']);
// @ts-expect-error isRunningSubprocess is private
command.isRunningSubprocess = true;
const err = Object.assign(new Error('ssh exited'), { status: 42 });
// @ts-expect-error catch is protected
await command.catch(err);
expect(process.exitCode).toBe(42);
});
it('reports a missing ssh client (ENOENT) from the catch handler', async () => {
const command = createCommand(['job-1']);
// @ts-expect-error isRunningSubprocess is private
command.isRunningSubprocess = true;
const err = Object.assign(new Error('spawn ssh ENOENT'), { code: 'ENOENT' });
// @ts-expect-error catch is protected
await command.catch(err);
expect(process.exitCode).toBe(1);
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('Install an OpenSSH client'));
});
it('delegates non-subprocess errors to the base handler', async () => {
const command = createCommand(['job-1']);
let threw = false;
try {
// @ts-expect-error catch is protected
await command.catch(new Error('boom'));
} catch {
threw = true;
}
expect(threw).toBe(true);
});
it('stops the spinner and rethrows when polling fails while waiting', async () => {
mockConnectInfo
.mockResolvedValueOnce(pendingInfo as never)
.mockRejectedValue(new Error('network blip'));
await expect(createCommand(['job-1']).runAsync()).rejects.toThrow('network blip');
});
});
@@ -78,6 +78,19 @@ import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccou
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { WorkflowFile } from '../../utils/workflowFile';
export function resolveWorkflowRunSshInput({
ssh,
idleTimeoutSeconds,
}: {
ssh: boolean;
idleTimeoutSeconds: number | undefined;
}): { idleTimeoutSeconds: number | undefined } | null {
if (idleTimeoutSeconds !== undefined && !ssh) {
throw new Error('--ssh-idle-timeout requires --ssh.');
}
return ssh ? { idleTimeoutSeconds } : null;
}
export default class WorkflowRun extends EasCommand {
static override description =
'run an EAS workflow. The entire local project directory will be packaged and uploaded to EAS servers for the workflow run, unless the --ref flag is used.';
@@ -110,6 +123,18 @@ export default class WorkflowRun extends EasCommand {
"The git reference must exist in the project's git repository, and the workflow file must exist at that reference. When this flag is used, the local project is not uploaded; instead, the workflow is run from the exact state of the project at the chosen reference.",
summary: 'Git reference to run the workflow on',
}),
ssh: Flags.boolean({
default: false,
description: 'Open an SSH session on each VM job for live debugging with `eas workflow:ssh`.',
summary: '[EXPERIMENTAL] Enable SSH on the run',
}),
'ssh-idle-timeout': Flags.integer({
description:
'Seconds to keep an SSH session open after its job finishes, while no client is connected. The session is always available for the whole job. Requires --ssh. Must be between 0 and 3600; defaults to 0 (close as soon as the job finishes and nobody is connected).',
summary: '[EXPERIMENTAL] SSH idle timeout in seconds',
min: 0,
max: 3600,
}),
...EasJsonOnlyFlag,
};
@@ -127,6 +152,11 @@ export default class WorkflowRun extends EasCommand {
enableJsonOutput();
}
const sshInput = resolveWorkflowRunSshInput({
ssh: flags.ssh,
idleTimeoutSeconds: flags['ssh-idle-timeout'],
});
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
@@ -272,6 +302,7 @@ export default class WorkflowRun extends EasCommand {
workflowRevisionId: workflowRevisionId ?? '',
gitRef,
inputs,
ssh: sshInput,
});
} catch (err) {
throw new Error(`Failed to create workflow run: ${err}`);
@@ -341,6 +372,7 @@ export default class WorkflowRun extends EasCommand {
packageJsonBucketKey,
projectRootDirectory,
},
ssh: sshInput,
},
}));
} catch (err) {
@@ -353,6 +385,10 @@ export default class WorkflowRun extends EasCommand {
Log.newLine();
Log.log(`See logs: ${link(getWorkflowRunUrl(account.name, projectName, workflowRunId))}`);
if (sshInput) {
Log.log('SSH enabled. Each VM job logs the `eas workflow:ssh` command to connect.');
}
if (!flags.wait) {
if (flags.json) {
printJsonOnlyOutput({
@@ -0,0 +1,252 @@
import spawnAsync from '@expo/spawn-async';
import { Flags } from '@oclif/core';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
WorkflowJobSshConnectInfo,
WorkflowJobSshQuery,
WorkflowJobSshSession,
} from '../../graphql/queries/WorkflowJobSshQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { sleepAsync } from '../../utils/promise';
export function splitConnectionHost(connectionHost: string): { hostname: string; port?: number } {
try {
const { hostname, port: stringPort } = new URL(`ssh://${connectionHost}`);
const port = stringPort === '' ? undefined : Number(stringPort);
return { hostname, port };
} catch (err) {
throw new Error(
'Unexpected connection host reported for this ssh session. Update eas-cli and try again, or contact support if it persists.',
{ cause: err }
);
}
}
const SSH_INSECURE_OPTS = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
const SESSION_OPEN_TIMEOUT_MS = 5 * 60 * 1000;
const SESSION_OPEN_POLL_INTERVAL_MS = 3000;
export type SshConnectStatus = 'unknown' | 'not-enabled' | 'ended' | 'pending' | 'ready';
export function resolveSshConnectStatus(
connectInfo:
| (Pick<WorkflowJobSshConnectInfo, 'sshRequested' | 'jobCompleted'> & {
session?: Pick<WorkflowJobSshSession, 'connectionConfig'> | null;
})
| null
): SshConnectStatus {
if (!connectInfo) {
return 'unknown';
}
const { sshRequested, jobCompleted, session } = connectInfo;
if (session?.connectionConfig && !session.connectionConfig.reconnecting) {
return 'ready';
}
if (session?.connectionConfig?.reconnecting) {
return 'pending';
}
if (!sshRequested) {
return 'not-enabled';
}
return jobCompleted ? 'ended' : 'pending';
}
export function terminalSshStatusMessage(
status: SshConnectStatus,
resourceId: string
): string | null {
switch (status) {
case 'unknown':
return `No workflow job found for "${resourceId}". Pass a workflow job id from a run started with \`eas workflow:run --ssh\`.`;
case 'not-enabled':
return `SSH was not enabled for "${resourceId}". Start the run with \`eas workflow:run --ssh\` to enable it.`;
case 'ended':
return 'This ssh session has ended.';
case 'pending':
case 'ready':
return null;
}
}
export function sshHostAliasForResource(resourceId: string): string {
const suffix = resourceId.replace(/[^A-Za-z0-9]/g, '').slice(0, 8);
return suffix ? `eas-workflow-ssh-${suffix}` : 'eas-workflow-ssh';
}
export function parseSshArgv(rawArgv: readonly string[]): {
showConnect: boolean;
resourceId: string | undefined;
command: string[];
} {
let showConnect = false;
let index = 0;
while (index < rawArgv.length && rawArgv[index].startsWith('-')) {
if (rawArgv[index] === '--show-connect' || rawArgv[index] === '--show-connect=true') {
showConnect = true;
} else {
throw new Error(
`Unknown flag "${rawArgv[index]}" before the id. The only supported flag is --show-connect; everything after the id is passed to the remote shell.`
);
}
index += 1;
}
const [resourceId, ...command] = rawArgv.slice(index);
return { showConnect, resourceId, command };
}
export default class WorkflowSsh extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] open an ssh session on the worker running a workflow job';
static override strict = false;
static override flags = {
'show-connect': Flags.boolean({
description: 'Print the ssh connection command (host and token) instead of opening a session',
default: false,
}),
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
private isRunningSubprocess = false;
async runAsync(): Promise<void> {
const rawArgv = [...this.argv];
await this.parse(WorkflowSsh, []);
const { showConnect, resourceId, command } = parseSshArgv(rawArgv);
if (typeof resourceId !== 'string' || resourceId.length === 0) {
throw new Error('Provide a workflow job id: eas workflow:ssh <workflow-job-id> [command...]');
}
const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkflowSsh, { nonInteractive: true });
const connectInfo = await WorkflowJobSshQuery.connectInfoForResourceIdAsync(
graphqlClient,
resourceId
);
const status = resolveSshConnectStatus(connectInfo);
const statusMessage = terminalSshStatusMessage(status, resourceId);
if (statusMessage) {
Log.error(statusMessage);
process.exitCode = 1;
return;
}
const connectionConfig =
(status === 'ready' ? connectInfo?.session?.connectionConfig : null) ??
(await waitForSessionToOpenAsync(graphqlClient, resourceId));
if (!connectionConfig) {
process.exitCode = 1;
return;
}
const { host: connectionHost, secret } = connectionConfig;
const { hostname, port } = splitConnectionHost(connectionHost);
const portOption = port !== undefined ? ` -p ${port}` : '';
if (showConnect) {
Log.log(`ssh ${SSH_INSECURE_OPTS}${portOption} ${secret}@${hostname}`);
Log.newLine();
Log.log(
'If your network blocks the direct SSH connection, reach the session through the WebSocket relay with upterm (https://upterm.dev):'
);
// WSS terminates on the relay hostname (default 443). Do not paste an SSH
// :port into the wss:// URL — that port is only for the ssh destination.
Log.log(
` ssh -o ProxyCommand="upterm proxy wss://${secret}@${hostname}" ${SSH_INSECURE_OPTS}${portOption} ${secret}@${hostname}`
);
return;
}
const configDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-ssh-'));
try {
const configPath = path.join(configDir, 'config');
const hostAlias = sshHostAliasForResource(resourceId);
await fs.promises.writeFile(
configPath,
[
`Host ${hostAlias}`,
` HostName ${hostname}`,
...(port !== undefined ? [` Port ${port}`] : []),
` User ${secret}`,
' StrictHostKeyChecking no',
' UserKnownHostsFile /dev/null',
'',
].join('\n'),
{ mode: 0o600 }
);
this.isRunningSubprocess = true;
await spawnAsync('ssh', ['-F', configPath, hostAlias, ...command], {
stdio: 'inherit',
});
} finally {
await fs.promises.rm(configDir, { recursive: true, force: true });
}
}
protected override catch(err: Error): Promise<void> {
if (this.isRunningSubprocess) {
if ((err as Error & { code?: string }).code === 'ENOENT') {
Log.error(
'Could not run `ssh`. Install an OpenSSH client and make sure `ssh` is on your PATH, then try again.'
);
process.exitCode = 1;
return Promise.resolve();
}
const status = (err as Error & { status?: number | null }).status;
process.exitCode = process.exitCode ?? status ?? 1;
return Promise.resolve();
}
return super.catch(err);
}
}
async function waitForSessionToOpenAsync(
graphqlClient: ExpoGraphqlClient,
workflowJobId: string
): Promise<NonNullable<WorkflowJobSshSession['connectionConfig']> | null> {
const spinner = ora('Waiting for the worker to open the ssh session').start();
const deadline = Date.now() + SESSION_OPEN_TIMEOUT_MS;
try {
while (Date.now() < deadline) {
const connectInfo = await WorkflowJobSshQuery.connectInfoForResourceIdAsync(
graphqlClient,
workflowJobId
);
const status = resolveSshConnectStatus(connectInfo);
const statusMessage = terminalSshStatusMessage(status, workflowJobId);
if (statusMessage) {
spinner.fail(statusMessage);
return null;
}
if (status === 'ready') {
spinner.succeed('The ssh session is ready.');
return connectInfo?.session?.connectionConfig ?? null;
}
await sleepAsync(SESSION_OPEN_POLL_INTERVAL_MS);
}
spinner.fail(
'Timed out waiting for the ssh session to open. The worker may still be starting up; try again in a moment.'
);
return null;
} catch (err) {
spinner.stop();
throw err;
}
}
@@ -105,7 +105,8 @@ export const distributionCertificateSchema: CredentialSchema<DistributionCertifi
export type MinimalAscApiKey = {
keyP8: string;
keyId: string;
issuerId: string;
// Absent for individual API keys, which are valid only for submissions.
issuerId?: string;
teamId?: string;
teamName?: string;
roles?: UserRole[];
@@ -115,7 +116,7 @@ export type MinimalAscApiKey = {
export interface AscApiKeyPath {
keyP8Path: string;
keyId: string;
issuerId: string;
issuerId?: string;
}
export const ascApiKeyIdSchema: CredentialSchema<Pick<MinimalAscApiKey, 'keyId'>> = {
@@ -129,7 +130,7 @@ export const ascApiKeyIdSchema: CredentialSchema<Pick<MinimalAscApiKey, 'keyId'>
],
};
export const ascApiKeyIssuerIdSchema: CredentialSchema<Pick<MinimalAscApiKey, 'issuerId'>> = {
export const ascApiKeyIssuerIdSchema: CredentialSchema<{ issuerId: string }> = {
name: 'App Store Connect API Key',
questions: [
{
@@ -246,7 +246,11 @@ function displayAscApiKey(
if (name) {
fields.push({ label: 'Name', value: name });
}
fields.push({ label: 'Issuer ID', value: issuerIdentifier });
if (issuerIdentifier) {
fields.push({ label: 'Issuer ID', value: issuerIdentifier });
} else {
fields.push({ label: 'Key Type', value: 'Individual (submissions only)' });
}
if (roles) {
fields.push({ label: 'Roles', value: roles.join(',') });
}
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more