Compare commits

..
Author SHA1 Message Date
szdziedzicandClaude Opus 5.5 b10d44f0ed [eas-cli] Add CHANGELOG entry
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-24 23:49:48 +02:00
szdziedzicandClaude Opus 5.5 1e2a72982f [eas-cli] Stop spinners from repeating their text after a terminal resize
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-24 23:49:23 +02:00
Expo CI 5198a2cc9c bump STAGING and PRODUCTION to 24.8.0 in cli-versions.json 2026-09-24 21:22:09 +00:00
Expo CI ca5f0d2306 update CHANGELOG.md for v24.8.0 2026-09-24 21:17:13 +00:00
Expo CI 9eefd5a5c4 v24.8.0 2026-09-24 21:13:58 +00:00
Tomek Czajecki a797caaa8a [eas-cli] Add --build-fingerprint to eas simulator (#4472)
* [eas-cli] Add --build-fingerprint to eas simulator

* [eas-cli] Add changelog entry
2026-09-25 00:05:35 +03:00
Gabe Debes 11323be04b [build-tools] boot the Android emulator headless from argent (#4465)
* [build-tools] start argent with ARGENT_EMULATOR_NO_WINDOW

* [build-tools] add the PR number to the changelog
2026-09-24 12:44:25 -07:00
David Mokos b1f886ef8d allows interpolated URIs in workflow validation (#4329) 2026-09-24 04:57:53 -07:00
David MokosandDavid Mokos 14e75df9e5 [eas-cli] Stop creating CLAUDE.md in new apps (#4446)
* [eas-cli] Stop creating CLAUDE.md in new apps

* [eas-cli] Link changelog entry to PR

---------

Co-authored-by: David Mokos <[email protected]>
2026-09-24 12:19:18 +02:00
Szymon Świerk dd88335654 [eas-cli] Gate the individual ASC API key prompt (#4464) 2026-09-24 01:01:57 +02:00
Gabe Debes 5183a6581a [build-tools] pass the website origin to serve-sim (#4437)
* [build-tools] pass the website origin to serve-sim

* [build-tools] allow deploy previews to reach staging sessions

* [build-tools] allow the website dev ports on staging sessions

* [build-tools] tidy the website origin helper and its tests

* [build-tools] name only https website dev origins
2026-09-23 13:43:06 -07:00
Gabe Debes fb7c812e50 [build-tools] raise the simulator preview stream to 1600px and 10 Mbps (#4439) 2026-09-23 13:01:19 -07:00
Gabe Debes e690ac0060 [build-tools] hand the iOS application launch to serve-sim (#4324) 2026-09-23 09:38:36 -07:00
Szymon Świerk c2190ca74f [eas-cli] Add individual key type prompt for submission keys (#4250) 2026-09-23 16:34:34 +02:00
Szymon Świerk 157b7cb79f [eas-cli] Accept individual ASC API keys for submissions, guard provisioning (#4249)
* [eas-cli] Allow resolving an ASC API key without an issuer ID

* [eas-cli] Accept individual ASC API keys during authentication

* [eas-cli] Guard credential provisioning operations against individual ASC API keys

* [eas-cli] Allow individual ASC API keys for metadata and TestFlight setup

* [eas-cli] Accept individual ASC API keys in EAS Submit

* [eas-cli] Reject individual ASC API keys in the ASC integration flow

* [eas-cli] Update CHANGELOG for individual ASC API key support
2026-09-23 16:21:16 +02:00
Tomasz Mazur 0d2b1c8d88 [eas-cli] add support for build id in workflow:ssh (#4452) 2026-09-23 10:48:18 +02:00
Szymon Dziedzic 788cb57ff1 [eas-cli] Exclude simulator queue time from startup timeout (#4411)
* [eas-cli] Exclude simulator queue time from startup timeout

* [eas-cli] Add PR attribution to simulator changelog

* [eas-cli] Clarify simulator queue wait message
2026-09-22 23:31:13 +02:00
Krystof WoldrichandClaude Opus 5 1634a50b6d [eas-cli] Make sim a hidden alias of simulator in the help print (#4457)
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-22 20:04:39 +02:00
Szymon DziedzicandClaude Fable 5.1 805b403ae3 [eas-cli] Make eas simulator commands visible (#4456)
* [eas-cli] Make eas simulator commands visible

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

* [eas-cli] Add PR number to changelog entry

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-22 19:28:46 +02:00
Byron Karlen 1672bcdadd [eas-cli] Recognize new Release Manager role (#4300) 2026-09-21 11:32:53 -07:00
Juwan WheatleyandClaude Fable 5.1 30b1eb2dda [eas-cli] add integrations:supabase:advisors command (#4342)
* [eas-cli] add integrations:supabase:advisors command

* [eas-cli] prompt to re-authorize Supabase from integrations:supabase:advisors

* [eas-cli] link each Supabase advisor finding to its dashboard entry

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

* [eas-cli] verify Supabase advisors against deployed API and preserve reauthorization state

* [eas-cli] make Supabase advisor titles clickable

* [eas-cli] remove advisor remediation links from terminal output

* [eas-cli] move Supabase advisor links below issue descriptions

* [eas-cli] print Supabase dashboard URL above advisor findings

* [eas-cli] tighten spacing within Supabase advisor findings

* [eas-cli] address Supabase advisors review feedback

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-21 13:20:28 -04:00
Stanisław Chmiela 99ce876d50 [build-tools] Preserve safe certificate import diagnostics (#4447)
* [build-tools] Preserve safe certificate import diagnostics

* [build-tools] Keep certificate import diagnostics in Keychain

* [build-tools] Simplify import error handling and log diagnostics as errors

* [build-tools] Narrow import failures to Error with optional process fields

* [build-tools] Name import diagnostics and simplify their tests

* [build-tools] Avoid duplicate certificate import diagnostics
2026-09-21 17:40:34 +02:00
Stanisław Chmiela 602502e502 [build-tools] Unify iOS keychain implementations (#4450)
# Why

We had two copies of the Keychain class (from the time period of migrating "legacy build process" to "step-based build process" and copying stuff "not to break anything").

# How

Unified the implementations. The difference between classes was whether the class received `BuildContext` as constructor param or not.

The unified interface requires `logger` being passed in to every command (since we're using the same `Keychain` instance in multiple build phases).

# Test Plan

CI should pass.
2026-09-21 17:14:23 +02:00
Gabe Debes 30a3924f43 [build-tools] pass --share-url so serve-sim copies the expo.dev preview page (#4434)
* [build-tools] pass --share-url so serve-sim copies the expo.dev preview page

* [build-tools] changelog for serve-sim --share-url

* [build-tools] reuse previewPageUrl for serve-sim --share-url
2026-09-17 15:48:10 -07:00
Douglas Lowder d2e6b4c45e [docs] Update RELEASING.md (#4417) 2026-09-17 12:28:30 -07:00
Douglas LowderandClaude Opus 5 419efa485f [ci] Fix and harden release workflow (#4425)
- Rework the workflow so that the changelog is read correctly, and the changelog commit is skipped if the new entry is already present
- Split the npm check into its own release job and raise the timeout
- Serialize release runs with a concurrency group, effectively forcing them to queue

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 12:20:37 -07:00
Will Schurman 🦦 3bb4240cb4 Upgrade typescript to 6.x (#4400)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Follow-up to #4399 to upgrade typescript to latest version. 

# How

Tell claude to upgrade to 6.x.

# Test Plan

CI, tests
2026-09-17 11:51:16 -07:00
Will Schurman 🦦 1677efc00c Upgrade typescript to highest 5.x (#4399)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

This is a bit behind in typescript. 6.x is where most of our repos live.

First step is to get to highest 5.x.

# How

Tell claude to upgrade to highest 5.x.

It required a new version of `https-proxy-agent` to be compatible with new node types.

# Test Plan

`yarn start`, tests.
2026-09-17 10:17:45 -07:00
Douglas Lowder c7b3105f9f [eas-cli] Skip managed-workflow checks for custom builds (#4424) 2026-09-16 19:08:46 -07:00
Expo CI 6fceead233 update CHANGELOG.md for v24.7.0 2026-09-16 22:16:08 +00:00
Expo CI 4a109648a6 v24.7.0 2026-09-16 22:13:30 +00:00
Gabe Debes ea139e207c [eas-cli] point preview links at the expo.dev page and expose the preview api url (#4352) 2026-09-16 15:07:49 -07:00
Gabe Debes 7b3c8cf245 [build-tools] report the expo.dev preview page url and the preview api url (#4353) 2026-09-16 14:31:37 -07:00
Gabe Debes efbe0bbdff [build-tools] Launch simulator packages with the resolved package manager (#4343)
* [build-tools] Launch simulator packages with the resolved package manager

Serve-sim, device-hub, and Argent were hardcoded to npx or bunx. Resolve the manager the same way other steps do so EAS_FALLBACK_PACKAGE_MANAGER can switch them off the npm audit path.

* [build-tools] Override the simulator package manager instead of using the lockfile fallback

EAS_FALLBACK_PACKAGE_MANAGER only applies when no lockfile is found, so a worker yarn.lock would keep npx. EAS_OVERRIDE_PACKAGE_MANAGER wins when set. Preview still defaults to npx and Argent to bunx.

* [build-tools] Honor fallback as well as override for simulator package exec

Override still wins when set. If it is unset, EAS_FALLBACK_PACKAGE_MANAGER selects the runner so serve-sim can use bunx without a lockfile-based resolve.

* [build-tools] Add changelog for simulator package manager env

* [build-tools] Format package manager changes

* [build-tools] Honor the simulator package manager for agent-device and Appium

Install these session packages through the same env override/fallback as serve-sim, instead of hardcoding bun add --global and npm install.

* [build-tools] Sort Appium session test imports for lint

* [build-tools] Keep simulator process comments concrete

* [build-tools] Cover simulator package install and agent-device fallback
2026-09-15 16:19:51 -07:00
Gabe Debes 8f645d8870 [build-tools] Default serve-sim iOS previews to H.264 (#4416)
* fix(build-tools): default serve-sim iOS previews to H.264

* changelog
2026-09-15 13:34:00 -07:00
Stanisław Chmiela 97007d17cc [build-tools] Use maestro-runner --parallel execution for shards (#4407)
`maestro-runner` supports sharding!

Runner parallel execution: https://github.com/devicelab-dev/maestro-runner/blob/main/pkg/executor/parallel.go
2026-09-15 19:09:52 +02:00
Expo CI 0dbaa34a8b bump STAGING and PRODUCTION to 24.6.0 in cli-versions.json 2026-09-15 16:21:45 +00:00
Expo CI 2fc537f9a7 update CHANGELOG.md for v24.6.0 2026-09-15 16:17:00 +00:00
Expo CI 71cf0aea74 v24.6.0 2026-09-15 16:14:36 +00:00
Will Schurman 🦦 f6a3028356 [eas-cli] Remove broad primaryAccount querying to support scoped token access (#4380) 2026-09-15 08:38:14 -07:00
Will Schurman 🦦 3182070f4c [eas-cli] Regenerate graphql and fix types (#4379) 2026-09-15 08:14:01 -07:00
Tomasz Mazur 097b58c7a9 [eas-cli] Display build logs when --wait flag is set (#4303)
* [eas-cli] Display build logs when --wait flag is set

* [eas-cli] use custom fetch

* [eas-cli] add timeouts to builds logs watch
2026-09-15 16:57:22 +02:00
Expo CI 9584d05708 bump STAGING and PRODUCTION to 24.5.0 in cli-versions.json 2026-09-15 09:48:19 +00:00
Expo CI b9ac59b336 update CHANGELOG.md for v24.5.0 2026-09-15 09:44:37 +00:00
177 changed files with 8712 additions and 2123 deletions

No files matched your search

+74 -9
View File
@@ -5,6 +5,14 @@ on:
name: Release workflow
# Releases push commits to main, so they must never overlap. The group is a
# constant rather than per-ref, so two different tags serialize. Queued runs
# are never cancelled, because a half-finished release leaves npm, the git
# history, and cli-versions.json out of sync.
concurrency:
group: release
cancel-in-progress: false
jobs:
release:
name: Create a release
@@ -47,6 +55,10 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
# The changelog is generated on the branch it is committed to, so the
# job never switches branches with a dirty working tree.
ref: main
fetch-depth: 0
- uses: ./.github/actions/setup-mise
- name: Install dependencies
working-directory: ./scripts
@@ -57,23 +69,67 @@ jobs:
run: |
git config user.email "[email protected]"
git config user.name "Expo CI"
- name: Check whether the changelog is already updated
run: |
if grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
echo "CHANGELOG.md already has an entry for v$EAS_CLI_VERSION."
echo "CHANGELOG_ALREADY_UPDATED=true" >> "$GITHUB_ENV"
else
echo "CHANGELOG_ALREADY_UPDATED=false" >> "$GITHUB_ENV"
fi
- name: Update changelog
if: env.CHANGELOG_ALREADY_UPDATED == 'false'
working-directory: ./scripts
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
- name: Reuse the existing changelog entry
if: env.CHANGELOG_ALREADY_UPDATED == 'true'
run: |
{
echo "v$EAS_CLI_VERSION"
echo
awk -v heading="## [$EAS_CLI_VERSION](" '
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md | sed '/./,$!d'
} > /tmp/current-changelog
- name: Publish release
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- 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
if git diff --cached --quiet; then
echo "No changelog changes to commit."
else
git commit -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
git pull --rebase --autostash origin main
git push
fi
bump-cli-versions:
name: Gate on npm and bump cli-versions.json
runs-on: ubuntu-latest
needs: release-changelog
permissions:
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
ref: main
fetch-depth: 0
- uses: ./.github/actions/setup-mise
- 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: Verify eas-cli is available on npm
run: |
availability_deadline_epoch=$(( $(date +%s) + 600 ))
availability_timeout_seconds=1800
availability_deadline_epoch=$(( $(date +%s) + availability_timeout_seconds ))
while true; do
published="$(npm view "eas-cli@$EAS_CLI_VERSION" version --prefer-online 2>/dev/null || true)"
@@ -95,7 +151,7 @@ jobs:
sleep "$retry_delay"
done
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after 10 minutes." >&2
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after ${availability_timeout_seconds}s." >&2
exit 1
- name: Bump STAGING and PRODUCTION
run: |
@@ -109,11 +165,20 @@ jobs:
- 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
if git diff --cached --quiet; then
echo "cli-versions.json already points at $EAS_CLI_VERSION."
else
git commit -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in cli-versions.json"
git pull --rebase --autostash origin main
git push
fi
- name: Prepare changelog for Slack
run: |
tail -n +3 /tmp/current-changelog > /tmp/changelog.md
awk -v heading="## [$EAS_CLI_VERSION](" '
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md | sed '/./,$!d' > /tmp/changelog.md
npx slackmd /tmp/changelog.md
{
echo 'SLACK_CHANGELOG<<EOF'
+61 -1
View File
@@ -8,12 +8,72 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [build-tools] Let connected sandboxes run shell commands through the Expo MCP server. ([#4347](https://github.com/expo/eas-cli/pull/4347) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- [eas-cli] Stop spinners from printing their text over and over after the terminal is resized. ([#4473](https://github.com/expo/eas-cli/pull/4473) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
### 🎉 New features
- [eas-cli] Add `--build-fingerprint` to `eas simulator` to install the newest build with a given fingerprint. ([#4472](https://github.com/expo/eas-cli/pull/4472) by [@tchayen](https://github.com/tchayen))
- [eas-cli] Accept build IDs in `workflow:ssh <id>`. ([#4452](https://github.com/expo/eas-cli/pull/4452) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli] Make `eas simulator:*` commands visible in help output. ([#4456](https://github.com/expo/eas-cli/pull/4456) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw))
- [eas-cli] Accept individual App Store Connect API keys for submissions, TestFlight setup, and metadata. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@sswrk](https://github.com/sswrk))
- [build-tools] Add `launch_app_identifier`, `launch_args`, and `open_url` inputs to the serve-sim, agent-device, argent, and Appium remote sessions, so serve-sim launches an installed app before the stream starts. The inputs are iOS only, and Android sessions keep using `eas/launch_application`. ([#4324](https://github.com/expo/eas-cli/pull/4324) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stream iOS Simulator previews at 1600px and 10 Mbps. ([#4439](https://github.com/expo/eas-cli/pull/4439) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [eas-cli] Exclude time waiting for simulator concurrency from the session startup timeout and show queue progress. ([#4411](https://github.com/expo/eas-cli/pull/4411) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] List `eas simulator` only once in `eas --help`. The `sim` shorthand is now a hidden alias, so it still runs but no longer duplicates the entry. ([#4457](https://github.com/expo/eas-cli/pull/4457) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Start the Argent tool-server with `ARGENT_EMULATOR_NO_WINDOW=1`, so Argent can boot the Android emulator again after it stops. Without it, the windowed emulator fails on the Linux image because `libpulse.so.0` is missing. ([#4465](https://github.com/expo/eas-cli/pull/4465) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix `eas account:view` printing `Role: undefined` for the new Release Manager role. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@byronkarlen](https://github.com/byronkarlen))
### 🧹 Chores
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#XXXX](https://github.com/expo/eas-cli/pull/XXXX) by [@wschurman](https://github.com/wschurman))
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
### 🎉 New features
- [build-tools] Stream iOS Simulator previews with H.264. ([#4416](https://github.com/expo/eas-cli/pull/4416) by [@gwdp](https://github.com/gwdp))
- [build-tools] Report the expo.dev preview page as a simulator session's preview URL, and the preview server behind it as `previewApiUrl`. ([#4353](https://github.com/expo/eas-cli/pull/4353) by [@gwdp](https://github.com/gwdp))
- [eas-cli] `eas simulator:start` and `eas simulator:get` now report the expo.dev preview page instead of the raw tunnel URL, in both the printed instructions and `--json`. ([#4352](https://github.com/expo/eas-cli/pull/4352) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [eas-cli] Skip the managed-workflow Expo SDK and Metro config checks for builds that use a custom build config, since those builds run their own steps instead of the standard managed pipeline. ([#4424](https://github.com/expo/eas-cli/pull/4424) by [@douglowder](https://github.com/douglowder))
- [build-tools] Honor `EAS_OVERRIDE_PACKAGE_MANAGER` and `EAS_FALLBACK_PACKAGE_MANAGER` when installing and launching simulator packages (serve-sim, device-hub, Argent, agent-device, Appium) so they can use bun instead of stalling on npm audit. ([#4343](https://github.com/expo/eas-cli/pull/4343) by [@gwdp](https://github.com/gwdp))
## [24.6.0](https://github.com/expo/eas-cli/releases/tag/v24.6.0) - 2026-09-15
### 🎉 New features
- [eas-cli] Display logs in `eas build --wait`. ([#4303](https://github.com/expo/eas-cli/pull/4303) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [build-tools] Skip project source refresh for jobs with no sources. ([#4389](https://github.com/expo/eas-cli/pull/4389) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Stop fetching the primary account in the login query. Commands that need it now fetch it on demand, so scoped access tokens without primary account access no longer fail at login. ([#4380](https://github.com/expo/eas-cli/pull/4380) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- [eas-cli] Regenerate GraphQL types and update Observe tests for the new `firstSeenAt` and `lastSeenAt` event-name fields. ([#4379](https://github.com/expo/eas-cli/pull/4379) by [@wschurman](https://github.com/wschurman))
## [24.5.0](https://github.com/expo/eas-cli/releases/tag/v24.5.0) - 2026-09-15
### 🎉 New features
- [build-tools] Let connected sandboxes run shell commands through the Expo MCP server. ([#4347](https://github.com/expo/eas-cli/pull/4347) by [@sjchmiela](https://github.com/sjchmiela))
## [24.4.2](https://github.com/expo/eas-cli/releases/tag/v24.4.2) - 2026-09-15
### 🐛 Bug fixes
+2 -2
View File
@@ -3,7 +3,7 @@
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
The release also sets the `STAGING` version in `cli-versions.json`. It does not change the `PRODUCTION` version. See [EAS Build CLI versions](#eas-build-cli-versions).
The release also sets the `STAGING` and `PRODUCTION` versions in `cli-versions.json`. See [EAS Build CLI versions](#eas-build-cli-versions).
## EAS Build CLI versions
@@ -14,7 +14,7 @@ The release also sets the `STAGING` version in `cli-versions.json`. It does not
This file replaces the old `latest-eas-build` and `latest-eas-build-staging` npm dist-tags. The build servers read the file from the `main` branch.
A release updates `STAGING` to the new version automatically. To promote a version to production, invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
A release updates `STAGING` and `PRODUCTION` to the new version automatically. To set a different version to production (to fix a regression, for example), invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
## Choosing the next version
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.4.2",
"PRODUCTION": "24.4.2"
"STAGING": "24.8.0",
"PRODUCTION": "24.8.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.5.0",
"version": "24.8.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+3 -3
View File
@@ -22,7 +22,7 @@
},
"devDependencies": {
"@types/jest": "29.5.11",
"@types/node": "20.14.8",
"@types/node": "20.19.43",
"eslint-plugin-async-protect": "3.1.0",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
@@ -32,8 +32,8 @@
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
"rimraf": "3.0.2",
"ts-jest": "29.1.1",
"typescript": "5.5.4"
"ts-jest": "29.4.12",
"typescript": "6.0.3"
},
"packageManager": "[email protected]"
}
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.5.0",
"version": "24.8.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -41,17 +41,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "24.0.0",
"@expo/eas-build-job": "24.5.0",
"@expo/downloader": "24.8.0",
"@expo/eas-build-job": "24.8.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.0.0",
"@expo/logger": "24.8.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": "24.5.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@expo/steps": "24.8.0",
"@expo/template-file": "24.8.0",
"@expo/turtle-spawn": "24.8.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -61,7 +61,7 @@
"fast-glob": "^3.3.2",
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
"gql.tada": "^1.8.13",
"gql.tada": "^1.11.3",
"joi": "^17.13.1",
"jose": "5.10.0",
"lodash": "^4.18.1",
@@ -88,7 +88,7 @@
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
"@types/node": "20.14.2",
"@types/node": "20.19.43",
"@types/node-fetch": "^2.6.11",
"@types/node-forge": "^1.3.11",
"@types/plist": "^3.0.5",
@@ -101,10 +101,10 @@
"memfs": "^4.17.1",
"nock": "13.4.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"ts-jest": "^29.4.12",
"ts-mockito": "^2.6.1",
"tslib": "^2.6.3",
"typescript": "^5.5.4",
"typescript": "^6.0.3",
"uuid": "^9.0.1"
}
}
@@ -1,11 +1,9 @@
import { Ios } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { v4 as uuid } from 'uuid';
import { BuildContext } from '../../../context';
import { distributionCertificate } from '../__tests__/fixtures';
import Keychain from '../keychain';
@@ -17,11 +15,9 @@ jest.setTimeout(60 * 1000);
// We need the JS code to modify the file system, so we need to mock it.
jest.unmock('fs');
let ctx: BuildContext<Ios.Job>;
describe('Keychain class', () => {
describe('ensureCertificateImported method', () => {
let keychain: Keychain<Ios.Job>;
let keychain: Keychain;
const certificatePath = path.join(os.tmpdir(), `cert-${uuid()}.p12`);
beforeAll(async () => {
@@ -36,37 +32,34 @@ describe('Keychain class', () => {
});
beforeEach(async () => {
ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: mockLogger,
env: {},
uploadArtifact: jest.fn(),
});
keychain = new Keychain(ctx);
await keychain.create();
keychain = new Keychain();
await keychain.create({ logger: mockLogger });
});
afterEach(async () => {
await keychain.destroy();
await keychain.destroy({ logger: mockLogger });
});
it("should throw an error if the certificate hasn't been imported", async () => {
await expect(
keychain.ensureCertificateImported(
distributionCertificate.teamId,
distributionCertificate.fingerprint
)
keychain.ensureCertificateImported({
teamId: distributionCertificate.teamId,
fingerprint: distributionCertificate.fingerprint,
})
).rejects.toThrowError(/hasn't been imported successfully/);
});
it("shouldn't throw any error if the certificate has been imported successfully", async () => {
await keychain.importCertificate(certificatePath, distributionCertificate.password);
await keychain.importCertificate({
logger: mockLogger,
certPath: certificatePath,
certPassword: distributionCertificate.password,
});
await expect(
keychain.ensureCertificateImported(
distributionCertificate.teamId,
distributionCertificate.fingerprint
)
keychain.ensureCertificateImported({
teamId: distributionCertificate.teamId,
fingerprint: distributionCertificate.fingerprint,
})
).resolves.not.toThrow();
});
});
@@ -0,0 +1,133 @@
import { errors } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import { SpawnResult } from '@expo/turtle-spawn';
import { runFastlane } from '../../fastlane';
import Keychain from '../keychain';
jest.mock('../../fastlane');
const runFastlaneMock = jest.mocked(runFastlane);
const logger = createLogger({ name: 'test' });
const logError = jest.spyOn(logger, 'error').mockImplementation();
let keychain: Keychain;
const options = {
logger,
certPath: '/test.p12',
certPassword: 'certificate-password',
};
const result = (stdout: string, stderr = ''): SpawnResult => ({
stdout,
stderr,
output: [stdout, stderr],
pid: 1,
status: 0,
signal: null,
});
beforeEach(async () => {
keychain = new Keychain();
runFastlaneMock.mockResolvedValueOnce(result(''));
await keychain.create({ logger });
runFastlaneMock.mockClear();
});
it('runs the import with the certificate and keychain credentials, without streaming output', async () => {
runFastlaneMock.mockResolvedValueOnce(result('Result: true'));
await keychain.importCertificate(options);
expect(runFastlaneMock).toHaveBeenCalledWith([
'run',
'import_certificate',
'certificate_path:/test.p12',
'certificate_password:certificate-password',
`keychain_path:${keychain.data.path}`,
`keychain_password:${keychain.data.password}`,
]);
expect(logError).not.toHaveBeenCalled();
});
describe.each(['success', 'failure'])('Fastlane %s', outcome => {
it.each([
{
output: 'SecKeychainItemImport: MAC verification failed during PKCS12 import',
codes: ['PKCS12_MAC_VERIFICATION_FAILED'],
},
{
output: 'SecKeychainItemImport: Unknown format in import',
codes: ['PKCS12_UNKNOWN_FORMAT'],
},
{
output: 'SecKeychainItemImport: Some other error',
codes: ['CERTIFICATE_IMPORT_FAILED'],
},
{
output:
'SecKeychainItemImport: Unknown format in import\nSecKeychainItemSetAccessWithPassword: Access denied',
codes: ['PKCS12_UNKNOWN_FORMAT', 'PRIVATE_KEY_ACCESS_FAILED'],
},
{
output:
'seckeychainitemimport: unknown format in import\nSecKeychainItemImport: Some other error',
codes: ['PKCS12_UNKNOWN_FORMAT', 'CERTIFICATE_IMPORT_FAILED'],
},
])('reports only the applicable codes: $codes', async ({ output, codes }) => {
if (outcome === 'success') {
runFastlaneMock.mockResolvedValueOnce(result(output));
await keychain.importCertificate(options);
} else {
runFastlaneMock.mockRejectedValueOnce(
Object.assign(new Error('import failed'), {
stderr: output,
})
);
const failure = await keychain.importCertificate(options).catch(error => error);
expect(failure.metadata.diagnosticCodes).toEqual(codes);
}
expect(logError.mock.calls.map(([fields]) => fields.diagnosticCode)).toEqual(codes);
});
});
it('never forwards output, command arguments, private key data, or the original error', async () => {
const secret = 'very-secret-password';
const error = Object.assign(new Error(`fastlane certificate_password:${secret}`), {
stdout: `private key data ${secret}`,
stderr: `security: SecKeychainItemSetAccessWithPassword: ${secret}`,
});
runFastlaneMock.mockRejectedValueOnce(error);
const failure = await keychain.importCertificate(options).catch(error => error);
expect(failure).toBeInstanceOf(errors.UserError);
expect(failure.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(failure.metadata).toEqual({ diagnosticCodes: ['PRIVATE_KEY_ACCESS_FAILED'] });
expect(failure.toExternalExpoError().message).toContain(
'Fastlane could not complete certificate import'
);
expect(failure.cause).toBeUndefined();
expect(JSON.stringify([failure, logError.mock.calls])).not.toContain(secret);
expect(logError).toHaveBeenCalledWith(
{ diagnosticCode: 'PRIVATE_KEY_ACCESS_FAILED' },
expect.stringContaining('private key')
);
});
it.each(['ENOENT', 'EACCES'])(
'classifies a %s process launch failure as a system error',
async code => {
runFastlaneMock.mockRejectedValueOnce(Object.assign(new Error('secret command'), { code }));
const error = await keychain.importCertificate(options).catch(error => error);
expect(error).toBeInstanceOf(errors.SystemError);
expect(error.trackingCode).toBe('IOS_CERTIFICATE_IMPORT_PROCESS_START_FAILED');
expect(error.cause).toBeUndefined();
}
);
it.each([
null,
new Error('failure'),
{ stderr: 'SecKeychainItemImport: Unknown format in import' },
Object.assign(new Error('failure'), { stdout: 123, stderr: {} }),
])('handles rejected values without usable process output: %p', async value => {
runFastlaneMock.mockRejectedValueOnce(value);
await expect(keychain.importCertificate(options)).rejects.toThrow(
'Fastlane could not complete certificate import'
);
expect(logError).not.toHaveBeenCalled();
});
@@ -18,7 +18,7 @@ jest.unmock('fs');
describe('ProvisioningProfile class', () => {
describe('verifyCertificate method', () => {
let ctx: BuildContext<Ios.Job>;
let keychain: Keychain<Ios.Job>;
let keychain: Keychain;
beforeAll(async () => {
ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, {
@@ -30,12 +30,12 @@ describe('ProvisioningProfile class', () => {
},
uploadArtifact: jest.fn(),
});
keychain = new Keychain(ctx);
await keychain.create();
keychain = new Keychain();
await keychain.create({ logger: mockLogger });
});
afterAll(async () => {
await keychain.destroy();
await keychain.destroy({ logger: mockLogger });
});
it("shouldn't throw any error if the provisioning profile and distribution certificate match", async () => {
@@ -1,19 +1,53 @@
import { Ios } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { errors } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import os from 'os';
import path from 'path';
import { v4 as uuid } from 'uuid';
import { BuildContext } from '../../context';
import { runFastlane } from '../fastlane';
export default class Keychain<TJob extends Ios.Job> {
// Do not log raw fastlane output or spawn errors: they can contain passwords,
// command arguments, and private key attributes from set-key-partition-list.
const IMPORT_CERTIFICATE_DIAGNOSTICS = [
{
code: 'PKCS12_MAC_VERIFICATION_FAILED',
pattern: /SecKeychainItemImport: MAC verification failed during PKCS12 import/i,
message:
'macOS could not verify the PKCS#12 MAC. Check the certificate password and PKCS#12 export format; this error does not prove that the password is wrong.',
},
{
code: 'PKCS12_UNKNOWN_FORMAT',
pattern: /SecKeychainItemImport: Unknown format in import/i,
message: 'macOS did not recognize the certificate import format.',
},
{
code: 'CERTIFICATE_IMPORT_FAILED',
pattern:
/SecKeychainItemImport:(?! MAC verification failed during PKCS12 import| Unknown format in import)/i,
message: 'macOS reported a certificate import error (SecKeychainItemImport).',
},
{
code: 'PRIVATE_KEY_ACCESS_FAILED',
pattern: /SecKeychainItemSetAccessWithPassword:/,
message:
'macOS could not set access to the imported private key (SecKeychainItemSetAccessWithPassword).',
},
{
code: 'KEYCHAIN_ITEM_LOOKUP_FAILED',
pattern: /SecItemCopyMatching:/,
message:
'macOS could not find an item while configuring private key access (SecItemCopyMatching).',
},
];
export default class Keychain {
private readonly keychainPath: string;
private readonly keychainPassword: string;
private created = false;
private destroyed = false;
constructor(private readonly ctx: BuildContext<TJob>) {
constructor() {
this.keychainPath = path.join(os.tmpdir(), `eas-build-${uuid()}.keychain`);
this.keychainPassword = uuid();
}
@@ -25,8 +59,8 @@ export default class Keychain<TJob extends Ios.Job> {
};
}
public async create(): Promise<void> {
this.ctx.logger.debug(`Creating keychain - ${this.keychainPath}`);
public async create({ logger }: { logger: bunyan }): Promise<void> {
logger.debug(`Creating keychain - ${this.keychainPath}`);
await runFastlane([
'run',
'create_keychain',
@@ -38,23 +72,77 @@ export default class Keychain<TJob extends Ios.Job> {
this.created = true;
}
public async importCertificate(certPath: string, certPassword: string): Promise<void> {
public async importCertificate({
logger,
certPath,
certPassword,
}: {
logger: bunyan;
certPath: string;
certPassword: string;
}): Promise<void> {
if (!this.created) {
throw new Error('You must create a keychain first.');
}
this.ctx.logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
await runFastlane([
'run',
'import_certificate',
`certificate_path:${certPath}`,
`certificate_password:${certPassword}`,
`keychain_path:${this.keychainPath}`,
`keychain_password:${this.keychainPassword}`,
]);
logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
try {
const result = await runFastlane([
'run',
'import_certificate',
`certificate_path:${certPath}`,
`certificate_password:${certPassword}`,
`keychain_path:${this.keychainPath}`,
`keychain_password:${this.keychainPassword}`,
]);
const output = [result.stdout, result.stderr].join('\n');
for (const diagnostic of IMPORT_CERTIFICATE_DIAGNOSTICS) {
if (diagnostic.pattern.test(output)) {
logger.error({ diagnosticCode: diagnostic.code }, diagnostic.message);
}
}
} catch (error) {
const processError =
error instanceof Error
? (error as Error &
Partial<Pick<SpawnResult, 'stdout' | 'stderr'> & NodeJS.ErrnoException>)
: undefined;
const output = [processError?.stdout, processError?.stderr]
.filter(value => typeof value === 'string')
.join('\n');
const diagnosticCodes: string[] = [];
for (const diagnostic of IMPORT_CERTIFICATE_DIAGNOSTICS) {
if (diagnostic.pattern.test(output)) {
diagnosticCodes.push(diagnostic.code);
logger.error({ diagnosticCode: diagnostic.code }, diagnostic.message);
}
}
// Never attach the original error: its message includes passwords.
if (processError?.code === 'ENOENT' || processError?.code === 'EACCES') {
throw new errors.SystemError('Fastlane could not be started to import the certificate.', {
trackingCode: 'IOS_CERTIFICATE_IMPORT_PROCESS_START_FAILED',
});
}
throw new errors.UserError(
errors.ErrorCode.UNKNOWN_ERROR,
'Fastlane could not complete certificate import. Check the certificate import diagnostics in the Prepare credentials logs.',
{
trackingCode: 'IOS_CERTIFICATE_IMPORT_FAILED',
metadata: { diagnosticCodes },
}
);
}
}
public async ensureCertificateImported(teamId: string, fingerprint: string): Promise<void> {
public async ensureCertificateImported({
teamId,
fingerprint,
}: {
teamId: string;
fingerprint: string;
}): Promise<void> {
const identities = await this.findIdentitiesByTeamId(teamId);
if (!identities.includes(fingerprint)) {
throw new Error(
@@ -63,27 +151,33 @@ export default class Keychain<TJob extends Ios.Job> {
}
}
public async destroy(keychainPath?: string): Promise<void> {
public async destroy({
logger,
keychainPath,
}: {
logger: bunyan;
keychainPath?: string;
}): Promise<void> {
if (!keychainPath && !this.created) {
this.ctx.logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
return;
}
if (this.destroyed) {
this.ctx.logger.warn('The keychain has been already destroyed');
logger.warn('The keychain has been already destroyed');
return;
}
const keychainToDeletePath = keychainPath ?? this.keychainPath;
this.ctx.logger.info(`Destroying keychain - ${keychainToDeletePath}`);
logger.info(`Destroying keychain - ${keychainToDeletePath}`);
try {
await runFastlane(['run', 'delete_keychain', `keychain_path:${keychainToDeletePath}`]);
this.destroyed = true;
} catch (err) {
this.ctx.logger.error({ err }, 'Failed to delete the keychain\n');
logger.error({ err }, 'Failed to delete the keychain\n');
throw err;
}
}
public async cleanUpKeychains(): Promise<void> {
public async cleanUpKeychains({ logger }: { logger: bunyan }): Promise<void> {
const { stdout } = await spawn('security', ['list-keychains'], { stdio: 'pipe' });
const keychainList = (/"(.*)"/g.exec(stdout) ?? ([] as string[])).map(i =>
i.slice(1, i.length - 1)
@@ -92,7 +186,7 @@ export default class Keychain<TJob extends Ios.Job> {
/eas-build-[\w-]+\.keychain$/.exec(keychain)
);
for (const turtleKeychainPath of turtleKeychainList) {
await this.destroy(turtleKeychainPath);
await this.destroy({ logger, keychainPath: turtleKeychainPath });
}
}
@@ -26,7 +26,7 @@ export interface Credentials {
export type TargetProvisioningProfiles = Record<string, ProvisioningProfileData>;
export default class IosCredentialsManager<TJob extends Ios.Job> {
private keychain?: Keychain<TJob>;
private keychain?: Keychain;
private readonly provisioningProfiles: ProvisioningProfile<TJob>[] = [];
private cleanedUp = false;
@@ -48,8 +48,8 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
this.ctx.logger.info('Preparing credentials');
this.ctx.logger.info('Creating keychain');
this.keychain = new Keychain(this.ctx);
await this.keychain.create();
this.keychain = new Keychain();
await this.keychain.create({ logger: this.ctx.logger });
const targets = Object.keys(buildCredentials);
const targetProvisioningProfiles: TargetProvisioningProfiles = {};
@@ -82,7 +82,7 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
}
if (this.keychain) {
await this.keychain.destroy();
await this.keychain.destroy({ logger: this.ctx.logger });
}
if (this.provisioningProfiles) {
for (const provisioningProfile of this.provisioningProfiles) {
@@ -120,10 +120,11 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
);
this.ctx.logger.info('Importing distribution certificate into the keychain');
await this.keychain.importCertificate(
distCertPath,
targetCredentials.distributionCertificate.password
);
await this.keychain.importCertificate({
logger: this.ctx.logger,
certPath: distCertPath,
certPassword: targetCredentials.distributionCertificate.password,
});
this.ctx.logger.info('Initializing provisioning profile');
const provisioningProfile = new ProvisioningProfile(
@@ -138,10 +139,10 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
this.ctx.logger.info(
'Validating whether the distribution certificate has been imported successfully'
);
await this.keychain.ensureCertificateImported(
provisioningProfile.data.teamId,
certificateFingerprint
);
await this.keychain.ensureCertificateImported({
teamId: provisioningProfile.data.teamId,
fingerprint: certificateFingerprint,
});
this.ctx.logger.info(
'Verifying whether the distribution certificate and provisioning profile match'
@@ -256,7 +256,9 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('rejects sharding with maestro-runner', async () => {
it('passes shards to maestro-runner as parallel devices', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
@@ -264,10 +266,9 @@ describe('createMaestroTestsBuildFunction', () => {
shards: 2,
});
await expect(step.executeAsync()).rejects.toThrow(
'maestro-runner does not support EAS Maestro test sharding'
);
expect(mockedSpawn).not.toHaveBeenCalled();
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
});
it('rejects a non-junit output_format with maestro-runner', async () => {
@@ -45,6 +45,7 @@ jest.mock('../../utils/localEgress', () => ({
monitorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: () => 'session-id',
getNgrokTunnelDomainOrThrow: () => 'example.test',
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
@@ -52,7 +53,11 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
ensureFfmpegInstalledOnceAsync: jest.fn(),
spawnDetached: () => ({ pid: 123, getOutput: () => '', stopAsync: jest.fn() }),
waitForFileAsync: async () => ({ port: 1234, token: 'controller-token' }),
startNgrokTunnelAsync: async () => ({ url: 'https://controller.test', stopAsync: jest.fn() }),
startNgrokTunnelAsync: async () => ({
url: 'https://controller.test',
subdomainId: 'controller-id',
stopAsync: jest.fn(),
}),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
@@ -115,7 +120,8 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://preview.test',
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://preview.test',
previewToken: 'preview-secret',
stopAsync: stopPreview,
});
@@ -0,0 +1,200 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
// The daemon entry path and the state directory are resolved from the home directory when
// the module loads, so point it at a temp home we can populate.
jest.mock('node:os', () => {
const actual = jest.requireActual('node:os');
const actualPath = jest.requireActual('node:path');
return {
...actual,
homedir: () => actualPath.join(actual.tmpdir(), 'eas-agent-device-orchestration-home'),
};
});
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../sentry');
jest.mock('../../utils/agentDeviceArtifacts', () => ({
pollAgentDeviceArtifactsForUploadAsync: jest.fn(),
}));
jest.mock('../../utils/agentDeviceEvents', () => ({
startAgentDeviceEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
getNgrokAuthtokenOrThrow: jest.fn(),
getNgrokTunnelDomainOrThrow: jest.fn(),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
spawnDetached: jest.fn(),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
waitForFileAsync: jest.fn(),
}));
const TEST_HOME = path.join(os.tmpdir(), 'eas-agent-device-orchestration-home');
const DAEMON_ENTRY_PATH = path.join(
TEST_HOME,
'.bun/install/global/node_modules/agent-device/dist/src/internal/daemon.js'
);
const ctx = {} as unknown as CustomBuildContext;
const mockPreviewStopAsync = jest.fn();
const mockTunnelStopAsync = jest.fn();
const mockDaemonStopAsync = jest.fn();
const mockEventCollectionStopAsync = jest.fn();
async function runAsync(
logger: { info: jest.Mock; warn: jest.Mock },
runtimePlatform: BuildRuntimePlatform,
launchInputs: Record<string, { value: unknown }> = {}
): Promise<void> {
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
max_duration_seconds: { value: undefined },
...launchInputs,
},
outputs: {},
env: {},
} as never
);
}
describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () => {
beforeEach(async () => {
jest.clearAllMocks();
jest.mocked(spawn).mockResolvedValue(undefined as never);
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue(undefined);
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue({
stopAsync: mockEventCollectionStopAsync,
getLastEventObservedAt: () => undefined,
});
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
jest.mocked(getNgrokAuthtokenOrThrow).mockReturnValue('ngrok-token');
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => '',
stopAsync: mockDaemonStopAsync,
});
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token: 'daemon-token' });
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
url: 'https://agent-device-abc.tunnel.example.com',
subdomainId: 'agent-device-abc',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
stopAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
await fs.promises.writeFile(DAEMON_ENTRY_PATH, '');
});
afterEach(async () => {
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
});
it('reports the preview URL and tears every resource down', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
await runAsync(logger, BuildRuntimePlatform.LINUX);
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
agentDeviceRemoteSessionUrl: 'https://agent-device-abc.tunnel.example.com',
agentDeviceRemoteSessionToken: 'daemon-token',
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.tunnel.example.com',
}),
})
);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockEventCollectionStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
launch_app_identifier: { value: 'host.exp.Exponent' },
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
open_url: { value: 'exp://127.0.0.1:8081' },
});
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
runtimePlatform: BuildRuntimePlatform.DARWIN,
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
})
);
expect(logger.info).toHaveBeenCalledWith(
'serve-sim will launch host.exp.Exponent with arguments ' +
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
);
});
it('fails before starting the daemon when a launch is asked for on Android', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
await expect(
runAsync(logger, BuildRuntimePlatform.LINUX, {
launch_app_identifier: { value: 'host.exp.Exponent' },
})
).rejects.toThrow('runs on linux');
expect(spawnDetached).not.toHaveBeenCalled();
});
it('declares the launch inputs', () => {
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
});
@@ -1,9 +1,38 @@
import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
import { Sentry } from '../../../sentry';
import { stopAgentDeviceEventCollectionSafelyAsync } from '../startAgentDeviceRemoteSession';
import { spawnDetached } from '../../utils/remoteDeviceRunSession';
import {
startAgentDeviceDaemonAsync,
stopAgentDeviceEventCollectionSafelyAsync,
} from '../startAgentDeviceRemoteSession';
jest.mock('../../../sentry');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
spawnDetached: jest.fn(),
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
async function writeDaemonEntry(cwd: string): Promise<string> {
const daemonPath = path.join(
cwd,
'node_modules',
'agent-device',
'dist',
'src',
'internal',
'daemon.js'
);
await fs.promises.mkdir(path.dirname(daemonPath), { recursive: true });
await fs.promises.writeFile(daemonPath, '');
return daemonPath;
}
describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
beforeEach(() => {
@@ -12,7 +41,6 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
it('reports an unexpected stop failure without rejecting', async () => {
const error = new Error('stop failed');
const logger = { warn: jest.fn() } as unknown as bunyan;
await expect(
stopAgentDeviceEventCollectionSafelyAsync({
@@ -37,3 +65,161 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
);
});
});
describe(startAgentDeviceDaemonAsync, () => {
const stopAsync = jest.fn().mockResolvedValue(undefined);
beforeEach(() => {
jest.clearAllMocks();
stopAsync.mockResolvedValue(undefined);
jest.mocked(spawnDetached).mockReturnValue({
pid: 1234,
getOutput: () => '',
stopAsync,
});
jest.mocked(spawn).mockImplementation((async (
command: string,
args: string[],
options?: { cwd?: string }
) => {
if (command === 'bun' && args[0] === '--version') {
return { stdout: '1.3.11\n' };
}
if (typeof options?.cwd === 'string' && command !== 'git') {
await writeDaemonEntry(options.cwd);
}
return { stdout: '' };
}) as never);
});
it('installs with bun add by default and launches the published daemon', async () => {
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
});
expect(spawn).toHaveBeenCalledWith(
'bun',
['add', '[email protected]'],
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
);
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
expect(spawnDetached).toHaveBeenCalledWith({
command: 'node',
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
});
await handle.stopAsync();
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});
it('installs with npm when EAS_OVERRIDE_PACKAGE_MANAGER is npm', async () => {
await startAgentDeviceDaemonAsync({
packageVersion: undefined,
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
logger,
});
expect(spawn).toHaveBeenCalledWith(
'npm',
['install', '--no-audit', 'agent-device@latest'],
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
);
});
it('falls back to git clone when the published daemon is missing', async () => {
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
if (command === 'bun' && args[0] === '--version') {
return { stdout: '1.3.11\n' };
}
return { stdout: '' };
}) as never);
await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
});
expect(spawn).toHaveBeenCalledWith(
'git',
[
'clone',
'--depth',
'1',
'--branch',
'v1.2.3',
'https://github.com/callstack/agent-device.git',
'/tmp/agent-device-src',
],
expect.objectContaining({ env: {}, logger })
);
expect(spawn).toHaveBeenCalledWith(
'bun',
['install', '--production'],
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
);
expect(spawnDetached).toHaveBeenCalledWith({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: '/tmp/agent-device-src',
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
});
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
expect.any(Error),
expect.objectContaining({
extras: expect.objectContaining({
packageSpec: '[email protected]',
packageManager: 'bun',
bunVersion: '1.3.11',
}),
})
);
});
it('clones latest and reports unknown bun when diagnostics fail', async () => {
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
if (command === 'bun' && args[0] === '--version') {
throw new Error('bun missing');
}
return { stdout: '' };
}) as never);
await startAgentDeviceDaemonAsync({
packageVersion: undefined,
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
logger,
});
expect(spawn).toHaveBeenCalledWith(
'git',
[
'clone',
'--depth',
'1',
'https://github.com/callstack/agent-device.git',
'/tmp/agent-device-src',
],
expect.objectContaining({ logger })
);
expect(spawn).toHaveBeenCalledWith(
'npm',
['install', '--no-audit', '--omit=dev'],
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
expect.any(Error),
expect.objectContaining({
extras: expect.objectContaining({
packageVersion: 'latest',
packageManager: 'npm',
bunVersion: 'unknown',
}),
})
);
});
});
@@ -1,10 +1,19 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { resolveAppium3VersionSpec, resolveAppiumDeviceAsync } from '../startAppiumRemoteSession';
import {
createStartAppiumRemoteSessionBuildFunction,
installAppiumAsync,
resolveAppium3VersionSpec,
resolveAppiumDeviceAsync,
} from '../startAppiumRemoteSession';
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
@@ -13,8 +22,10 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
const logger = { info: jest.fn(), warn: jest.fn() } as never;
@@ -84,3 +95,75 @@ describe(resolveAppiumDeviceAsync, () => {
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
});
});
describe(installAppiumAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
if (args.includes('--json')) {
return { stdout: JSON.stringify({}) };
}
return { stdout: '' };
}) as never);
});
it('installs Appium with npm by default', async () => {
const result = await installAppiumAsync({
versionSpec: '^3',
driverName: 'xcuitest',
env: { EXISTING: 'value' },
logger,
});
try {
expect(spawn).toHaveBeenNthCalledWith(
1,
'npm',
['install', '--no-audit', 'appium@^3'],
expect.objectContaining({
cwd: result.appiumHome,
env: expect.objectContaining({ APPIUM_HOME: result.appiumHome, EXISTING: 'value' }),
})
);
expect(spawn).toHaveBeenCalledWith(
result.appiumBinPath,
['driver', 'install', 'xcuitest'],
expect.objectContaining({ env: result.appiumEnv, logger })
);
} finally {
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
}
});
it('installs Appium with bun add when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
const result = await installAppiumAsync({
versionSpec: '3.5.0',
driverName: 'uiautomator2',
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' },
logger,
});
try {
expect(spawn).toHaveBeenNthCalledWith(
1,
'bun',
['add', '[email protected]'],
expect.objectContaining({ cwd: result.appiumHome })
);
} finally {
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
}
});
});
describe('createStartAppiumRemoteSessionBuildFunction', () => {
it('declares the launch inputs so serve-sim can launch the app', () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
});
@@ -4,6 +4,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
@@ -42,6 +43,7 @@ jest.mock('../../utils/argentEvents', () => ({
startArgentEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
ensureFfmpegInstalledOnceAsync: jest.fn(),
getDeviceRunSessionIdOrThrow: jest.fn(),
getNgrokAuthtokenOrThrow: jest.fn(),
@@ -88,10 +90,12 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
});
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
url: 'https://argent-abc.tunnel.example.com',
subdomainId: 'argent-abc',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.tunnel.example.com',
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
stopAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -155,6 +159,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
// (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.ARGENT_EMULATOR_NO_WINDOW).toBe('1');
expect(serverEnv.EXISTING).toBe('value');
expect(jest.mocked(startArgentEventCollectionAsync).mock.calls[0][0]).toMatchObject({
deviceRunSessionId: 'device-run-session-id',
@@ -179,10 +184,86 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://web-preview.tunnel.example.com',
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.tunnel.example.com',
}),
})
);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
const ctx = {} as unknown as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
max_duration_seconds: { value: undefined },
launch_app_identifier: { value: 'host.exp.Exponent' },
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
open_url: { value: 'exp://127.0.0.1:8081' },
},
outputs: {},
env: { EXISTING: 'value' },
} as never
);
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
runtimePlatform: BuildRuntimePlatform.DARWIN,
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
})
);
expect(logger.info).toHaveBeenCalledWith(
'serve-sim will launch host.exp.Exponent with arguments ' +
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
);
});
it('fails before starting anything when a launch is asked for on Android', async () => {
const ctx = {} as unknown as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
await expect(
buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
max_duration_seconds: { value: undefined },
launch_app_identifier: { value: 'host.exp.Exponent' },
},
outputs: {},
env: {},
} as never
)
).rejects.toThrow('runs on linux');
expect(spawn).not.toHaveBeenCalled();
});
it('declares the launch inputs', () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
});
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting bun x resolve it.')
expect.stringContaining('Continuing and letting the package manager resolve it.')
);
});
});
@@ -32,7 +32,11 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
const tunnel = { url: 'https://egress.example.com', stopAsync: jest.fn() };
const tunnel = {
url: 'https://egress.example.com',
subdomainId: 'egress-id',
stopAsync: jest.fn(),
};
async function start(signal?: AbortSignal): Promise<void> {
await createStartLocalEgressBuildFunction().fn!({ logger } as BuildStepContext, {
@@ -1,6 +1,7 @@
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
@@ -12,14 +13,25 @@ import {
} from '../../utils/remoteDeviceRunSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('../../utils/remoteDeviceRunSession');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
getNgrokTunnelDomainOrThrow: jest.fn(),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
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> {
async function runAsync(
runtimePlatform: BuildRuntimePlatform,
launchInputs: Record<string, { value: unknown }> = {}
): Promise<void> {
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
@@ -30,6 +42,7 @@ async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise<void> {
inputs: {
package_version: { value: '1.2.3' },
max_duration_seconds: { value: 120 },
...launchInputs,
},
outputs: {},
env,
@@ -44,7 +57,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.example.test',
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -54,7 +68,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.example.test',
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
stopAsync,
});
@@ -64,7 +79,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: {
previewUrl: 'https://web-preview.example.test',
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
},
})
@@ -85,11 +101,17 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
logger,
timeoutMs: 60_000,
packageVersion: '1.2.3',
launchAppIdentifier: undefined,
launchArgs: [],
openUrl: undefined,
});
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
remoteConfig: { previewUrl: 'https://web-preview.example.test' },
remoteConfig: {
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
},
logger,
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
@@ -101,4 +123,49 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
});
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it('declares the launch inputs', () => {
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual([
'launch_app_identifier',
'launch_args',
'open_url',
'package_version',
'max_duration_seconds',
]);
});
it('hands the launch inputs to the web preview and announces them', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
launch_app_identifier: { value: 'host.exp.Exponent' },
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
open_url: { value: 'exp://127.0.0.1:8081' },
});
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
})
);
expect(logger.info).toHaveBeenCalledWith(
'serve-sim will launch host.exp.Exponent with arguments ' +
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
);
});
it('fails before starting anything when a launch is asked for on Android', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
launch_app_identifier: { value: 'host.exp.Exponent' },
})
).rejects.toThrow('runs on linux');
expect(startDeviceWebPreviewWithTunnelAsync).not.toHaveBeenCalled();
});
});
@@ -184,7 +184,7 @@ function logApplicationLaunch(
logger.info(`Launching ${applicationIdentifier}${argumentsDescription}.`);
}
function parseNonEmptyStringInput(value: unknown, inputName: string): string {
export function parseNonEmptyStringInput(value: unknown, inputName: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
@@ -194,7 +194,7 @@ function parseNonEmptyStringInput(value: unknown, inputName: string): string {
return value;
}
function parseLaunchArgsInput(value: unknown): string[] {
export function parseLaunchArgsInput(value: unknown): string[] {
if (value === undefined) {
return [];
}
@@ -207,7 +207,7 @@ function parseLaunchArgsInput(value: unknown): string[] {
return value;
}
function parseOpenUrlInput(value: unknown): string {
export function parseOpenUrlInput(value: unknown): string {
const openUrl = parseNonEmptyStringInput(value, 'open_url');
if (!URL.canParse(openUrl)) {
throw new UserError(
@@ -108,6 +108,9 @@ function buildMaestroArgs({
}
case 'maestro-runner': {
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
if (shards !== undefined && shards > 1) {
args.push(`--parallel=${shards}`);
}
if (includeTags) {
args.push(`--include-tags=${includeTags}`);
}
@@ -259,12 +262,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
undefined,
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
);
if (backend === 'maestro-runner' && shards !== undefined && shards > 1) {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
'maestro-runner does not support EAS Maestro test sharding. Remove shards or set it to 1.'
);
}
if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
@@ -18,13 +18,22 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { Sentry } from '../../sentry';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageAdd,
resolvePackageInstall,
} from '../../utils/packageManager';
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
import {
type DetachedProcessHandle,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
@@ -53,6 +62,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
name: 'Start agent device remote session',
__metricsId: 'eas/start_agent_device_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -83,6 +93,14 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const { runtimePlatform } = global;
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value as string | undefined,
},
{ runtimePlatform }
);
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
@@ -115,14 +133,23 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
| undefined;
try {
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
logger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
@@ -132,7 +159,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
webPreviewUrl: webPreview.previewUrl,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
@@ -183,6 +211,74 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
});
}
export async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
await fs.promises.writeFile(
path.join(installDir, 'package.json'),
`${JSON.stringify({ name: 'eas-agent-device', private: true })}\n`
);
try {
const add = resolvePackageAdd(packageManager, packageSpec);
logger.info(`Installing ${packageSpec} with ${add.command}.`);
await spawn(add.command, add.args, { cwd: installDir, env, logger });
const daemonPath = getInstalledAgentDeviceDaemonPath(installDir);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
logger.info(`Launching daemon from ${daemonPath} after ${add.command} install.`);
const daemonProcess = spawnDetached({
command: 'node',
args: [daemonPath],
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
});
return {
...daemonProcess,
stopAsync: async () => {
await daemonProcess.stopAsync();
await fs.promises.rm(installDir, { recursive: true, force: true });
},
};
} catch (err) {
await fs.promises.rm(installDir, { recursive: true, force: true });
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
error,
{
level: 'warning',
tags: {
phase: 'agent-device-daemon-start',
fallback: 'git-clone',
},
extras: {
packageSpec,
packageVersion: packageVersion ?? 'latest',
packageManager,
bunVersion,
},
}
);
logger.warn(
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
}
}
export async function stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
@@ -205,61 +301,6 @@ export async function stopAgentDeviceEventCollectionSafelyAsync({
}
}
async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
try {
logger.info(`Installing ${packageSpec} globally with Bun.`);
await spawn('bun', ['add', '--global', packageSpec], {
env,
logger,
});
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
logger.info(`Launching daemon from ${daemonPath}.`);
return spawnDetached({
command: 'node',
args: [daemonPath],
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
'Failed to start agent-device daemon from global Bun package; falling back to git clone',
error,
{
level: 'warning',
tags: {
phase: 'agent-device-daemon-start',
fallback: 'git-clone',
},
extras: {
packageSpec,
packageVersion: packageVersion ?? 'latest',
bunVersion,
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
},
}
);
logger.warn(
`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
}
}
async function startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
@@ -276,14 +317,17 @@ async function startAgentDeviceDaemonFromGitAsync({
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const install = resolvePackageInstall(packageManager, { production: true });
logger.info(`Installing agent-device dependencies with ${install.command}.`);
await spawn(install.command, install.args, {
cwd: SRC_DIR,
env,
logger,
});
logger.info('Launching daemon from cloned agent-device source.');
// Git fallback is TypeScript source. The published path runs node on dist JS.
return spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
@@ -346,11 +390,9 @@ function createAgentDevicePackageSpec(packageVersion: string | undefined): strin
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
}
function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
function getInstalledAgentDeviceDaemonPath(installDir: string): string {
return path.join(
getBunInstallDirectory(env),
'install',
'global',
installDir,
'node_modules',
AGENT_DEVICE_PACKAGE_NAME,
'dist',
@@ -360,11 +402,6 @@ function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
);
}
function getBunInstallDirectory(env: BuildStepEnv): string {
const bunInstall = env.BUN_INSTALL?.trim();
return bunInstall ? bunInstall : path.join(os.homedir(), '.bun');
}
function parseDaemonInfo(raw: string): { port: number; token: string } {
const parsed = JSON.parse(raw) as unknown;
if (
@@ -21,13 +21,21 @@ import {
} from '../utils/localEgressSession';
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageAdd,
} from '../../utils/packageManager';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
import {
createServeSimLaunchInputProviders,
describeServeSimLaunch,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
@@ -54,6 +62,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
name: 'Start Appium remote session',
__metricsId: 'eas/start_appium_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -72,6 +81,14 @@ export function createStartAppiumRemoteSessionBuildFunction(
const packageVersion = inputs.package_version.value as string | undefined;
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const { runtimePlatform } = global;
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
},
{ runtimePlatform }
);
const versionSpec = resolveAppium3VersionSpec(packageVersion);
logger.info(
@@ -133,12 +150,19 @@ export function createStartAppiumRemoteSessionBuildFunction(
// 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.
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
});
await uploadRemoteSessionConfigWithLocalEgressAsync({
@@ -153,7 +177,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
'appium:automationName': device.automationName,
'appium:udid': device.udid,
},
webPreviewUrl: webPreview.previewUrl,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
@@ -198,7 +223,7 @@ export function resolveAppium3VersionSpec(packageVersion: string | undefined): s
return versionSpec;
}
type AppiumDevice = {
export type AppiumDevice = {
platformName: 'iOS' | 'Android';
automationName: 'XCUITest' | 'UiAutomator2';
driverName: 'xcuitest' | 'uiautomator2';
@@ -247,7 +272,7 @@ export async function resolveAppiumDeviceAsync({
}
}
async function installAppiumAsync({
export async function installAppiumAsync({
versionSpec,
driverName,
env,
@@ -265,9 +290,14 @@ async function installAppiumAsync({
);
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
const add = resolvePackageAdd(
resolveConfiguredPackageManager(env, PackageManager.NPM),
`appium@${versionSpec}`
);
logger.info(`Installing appium@${versionSpec}.`);
await spawn('npm', ['install', '--prefix', appiumHome, `appium@${versionSpec}`], {
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
await spawn(add.command, add.args, {
cwd: appiumHome,
env: appiumEnv,
logger,
});
@@ -19,15 +19,23 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { Sentry } from '../../sentry';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageExec,
} from '../../utils/packageManager';
import { isProcessDescendantOfAsync } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
import {
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
@@ -66,6 +74,7 @@ export function createStartArgentRemoteSessionBuildFunction(
name: 'Start argent remote session',
__metricsId: 'eas/start_argent_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -98,6 +107,14 @@ export function createStartArgentRemoteSessionBuildFunction(
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value as string | undefined,
},
{ runtimePlatform }
);
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
@@ -113,42 +130,45 @@ export function createStartArgentRemoteSessionBuildFunction(
// Never rejects, so `void` is safe.
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const argentExec = (args: string[]): { command: string; args: string[] } =>
resolvePackageExec(packageManager, args);
logger.info('Enabling the Argent artifacts list endpoint flag.');
await spawn(
'bun',
[
'x',
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
],
{ env, logger }
);
const enableArtifacts = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
]);
await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger });
logger.info('Enabling the Argent tool-server event log flag.');
await spawn(
'bun',
['x', `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
{ env, logger }
);
const enableEventLog = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_EVENT_LOG_FLAG,
]);
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
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 startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
]);
logger.info(
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
);
// Keep Argent itself in foreground mode under the detached process. This preserves
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const argentServer = spawnDetached({
command: 'bun',
args: [
'x',
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
],
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH },
command: startServer.command,
args: startServer.args,
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
});
if (argentServer.pid === undefined) {
throw new SystemError(
@@ -209,14 +229,23 @@ export function createStartArgentRemoteSessionBuildFunction(
const publicToolsUrl = toolsTunnel.url;
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
logger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
@@ -226,7 +255,8 @@ export function createStartArgentRemoteSessionBuildFunction(
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
webPreviewUrl: webPreview.previewUrl,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
@@ -306,7 +336,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 bun x resolve it.`
`Continuing and letting the package manager resolve it.`
);
return;
}
@@ -11,8 +11,11 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import {
createServeSimLaunchInputProviders,
describeServeSimLaunch,
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
startDeviceWebPreviewWithTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
@@ -29,6 +32,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
name: 'Start web preview remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -46,8 +50,20 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
const { runtimePlatform } = global;
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value as string | undefined,
},
{ runtimePlatform }
);
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
@@ -60,8 +76,11 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
packageVersion,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
});
logger.info(`Preview URL: ${webPreview.previewUrl}`);
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
try {
await uploadRemoteSessionConfigWithLocalEgressAsync({
@@ -70,7 +89,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
ctx,
deviceRunSessionId,
remoteConfig: {
previewUrl: webPreview.previewUrl,
previewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
},
logger,
@@ -1,5 +1,6 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import {
@@ -8,6 +9,7 @@ import {
} from 'node:timers';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
@@ -16,15 +18,20 @@ import { sleepAsync } from '../../../utils/retry';
import {
createExpoDeviceHubArgs,
createServeSimArgs,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
metricsCorsOriginToServeSimArgs,
parseServeSimLaunchInputs,
simulatorPreviewPageUrl,
startDeviceWebPreviewWithTunnelAsync,
startExpoDeviceHubWithTunnelAsync,
startNgrokTunnelAsync,
turnIceServersToWebPreviewArgs,
waitForDeviceRunSessionStoppedAsync,
waitForWebPreviewReadyAsync,
websiteOrigin,
websiteOriginServeSimArgs,
} from '../remoteDeviceRunSession';
jest.mock('@ngrok/ngrok');
@@ -122,6 +129,161 @@ function createEnvMock(): BuildStepEnv {
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
}
describe(createServeSimLaunchInputProviders, () => {
it('declares the launch inputs as optional', () => {
const globalCtx = createGlobalContextMock();
const inputs = createServeSimLaunchInputProviders().map(provider =>
provider(globalCtx, 'Test step')
);
expect(
inputs.map(({ id, required, allowedValueTypeName }) => ({
id,
required,
allowedValueTypeName,
}))
).toEqual([
{
id: 'launch_app_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
},
{
id: 'launch_args',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
},
{
id: 'open_url',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
},
]);
});
});
describe(parseServeSimLaunchInputs, () => {
const darwin = { runtimePlatform: BuildRuntimePlatform.DARWIN };
it('reads the launch identifier, arguments and URL', () => {
expect(
parseServeSimLaunchInputs(
{
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
},
darwin
)
).toEqual({
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
});
});
it('defaults to no launch when the step declares nothing', () => {
expect(parseServeSimLaunchInputs({}, { runtimePlatform: BuildRuntimePlatform.LINUX })).toEqual({
launchAppIdentifier: undefined,
launchArgs: [],
openUrl: undefined,
});
});
it('rejects launch arguments that are not a list', () => {
expect(() =>
parseServeSimLaunchInputs(
{
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: 'oops',
},
darwin
)
).toThrow('must be an array of strings');
});
it('rejects launch arguments that are not all strings', () => {
expect(() =>
parseServeSimLaunchInputs(
{
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-flag', 1],
},
darwin
)
).toThrow('must be an array of strings');
});
it('rejects launch arguments with no application to launch', () => {
expect(() => parseServeSimLaunchInputs({ launchArgs: ['-flag'] }, darwin)).toThrow(
'Pass "launch_app_identifier"'
);
});
it('rejects a URL with no application to open it in', () => {
expect(() => parseServeSimLaunchInputs({ openUrl: 'exp://127.0.0.1:8081' }, darwin)).toThrow(
'Pass "launch_app_identifier"'
);
});
it('rejects an identifier that resolved to an empty string', () => {
expect(() => parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin)).toThrow(
'must be a non-empty string'
);
});
it('rejects a URL that is not a URL', () => {
expect(() =>
parseServeSimLaunchInputs(
{ launchAppIdentifier: 'host.exp.Exponent', openUrl: 'not a url' },
darwin
)
).toThrow('must be a valid URL');
});
it('raises a user error, not a platform error', () => {
expect(() => parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin)).toThrow(UserError);
try {
parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin);
} catch (error) {
expect((error as UserError).errorCode).toBe('EAS_LAUNCH_APPLICATION_INVALID_INPUT');
}
});
it('rejects a launch on a session that does not run an iOS simulator', () => {
expect(() =>
parseServeSimLaunchInputs(
{ launchAppIdentifier: 'host.exp.Exponent' },
{ runtimePlatform: BuildRuntimePlatform.LINUX }
)
).toThrow('runs on linux');
});
});
describe(describeServeSimLaunch, () => {
it('says nothing when there is no application to launch', () => {
expect(describeServeSimLaunch({ launchArgs: [] })).toBeNull();
});
it('names only the application when there are no arguments or URL', () => {
expect(describeServeSimLaunch({ launchAppIdentifier: 'host.exp.Exponent' })).toBe(
'serve-sim will launch host.exp.Exponent.'
);
});
it('names the application, its arguments and the URL', () => {
expect(
describeServeSimLaunch({
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-flag', '1'],
openUrl: 'exp://127.0.0.1:8081',
})
).toBe(
'serve-sim will launch host.exp.Exponent with arguments ["-flag","1"], then open exp://127.0.0.1:8081.'
);
});
});
describe(createServeSimArgs, () => {
it('uses the latest Expo package and applies the EAS streaming policy', () => {
expect(
@@ -130,7 +292,6 @@ describe(createServeSimArgs, () => {
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
})
).toEqual([
'--yes',
'@expo/serve-sim@latest',
'--port',
'4321',
@@ -140,13 +301,13 @@ describe(createServeSimArgs, () => {
'--transport',
'webrtc',
'--webrtc-codec',
'vp8',
'h264',
'--max-dimension',
'960',
'1600',
'--mjpeg-quality',
'0.55',
'--video-bitrate',
'6000000',
'10000000',
'--video-fps',
'60',
'--turn-url',
@@ -154,33 +315,70 @@ describe(createServeSimArgs, () => {
]);
});
it('appends metrics CORS args after the TURN args when provided', () => {
it('appends the website args after the TURN args when provided', () => {
const args = createServeSimArgs({
port: 4321,
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
metricsCorsArgs: ['--metrics-cors-origin', 'https://expo.dev'],
websiteArgs: ['--cors-origin', 'https://expo.dev'],
});
expect(args.slice(-4)).toEqual([
'--turn-url',
'turns:turn.example.test:443',
'--metrics-cors-origin',
'--cors-origin',
'https://expo.dev',
]);
});
it('pins the requested package version', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' }).slice(0, 2)).toEqual([
'--yes',
'@expo/[email protected]',
]);
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' })[0]).toBe(
'@expo/[email protected]'
);
});
it('pins a dist-tag', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' }).slice(0, 2)).toEqual([
'--yes',
'@expo/serve-sim@next',
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' })[0]).toBe(
'@expo/serve-sim@next'
);
});
it('appends --share-url after the website args', () => {
const args = createServeSimArgs({
port: 4321,
websiteArgs: ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'],
shareUrl: 'https://expo.dev/simulator-preview/abc',
});
expect(args.slice(-4)).toEqual([
'--frame-ancestor',
'https://expo.dev',
'--share-url',
'https://expo.dev/simulator-preview/abc',
]);
});
it('appends the launch flags after the streaming policy', () => {
const args = createServeSimArgs({
port: 4321,
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
});
expect(args.slice(-8)).toEqual([
'--launch-app-identifier',
'host.exp.Exponent',
'--launch-arg',
'-EXDevMenuIsOnboardingFinished',
'--launch-arg',
'1',
'--open-url',
'exp://127.0.0.1:8081',
]);
});
it('omits the launch flags when there is no application to launch', () => {
const args = createServeSimArgs({ port: 4321 });
expect(args.some(argument => argument.startsWith('--launch'))).toBe(false);
expect(args).not.toContain('--open-url');
});
});
describe(createExpoDeviceHubArgs, () => {
@@ -191,7 +389,6 @@ describe(createExpoDeviceHubArgs, () => {
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
})
).toEqual([
'--yes',
'expo-device-hub@latest',
'--port',
'4321',
@@ -219,37 +416,51 @@ describe(createExpoDeviceHubArgs, () => {
});
it('pins the requested package version', () => {
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' }).slice(0, 2)).toEqual([
'--yes',
'[email protected]',
]);
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' })[0]).toBe(
'[email protected]'
);
});
});
describe(metricsCorsOriginToServeSimArgs, () => {
it('returns no args when the origin is unset or empty', () => {
expect(metricsCorsOriginToServeSimArgs({} as BuildStepEnv)).toEqual([]);
expect(
metricsCorsOriginToServeSimArgs({ EAS_SIMULATOR_METRICS_CORS_ORIGIN: '' } as BuildStepEnv)
).toEqual([]);
describe(websiteOriginServeSimArgs, () => {
it('names only the production website by default', () => {
expect(websiteOriginServeSimArgs({} as BuildStepEnv)).toEqual([
'--cors-origin',
'https://expo.dev',
'--frame-ancestor',
'https://expo.dev',
]);
});
it('builds one flag per comma-separated origin', () => {
expect(
metricsCorsOriginToServeSimArgs({
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://expo.dev',
} as BuildStepEnv)
).toEqual(['--metrics-cors-origin', 'https://expo.dev']);
expect(
metricsCorsOriginToServeSimArgs({
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://expo.dev, https://staging.expo.dev',
} as BuildStepEnv)
).toEqual([
'--metrics-cors-origin',
'https://expo.dev',
'--metrics-cors-origin',
it('names staging, its deploy previews and each website dev port on staging', () => {
const args = websiteOriginServeSimArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
expect(args.slice(0, 4)).toEqual([
'--cors-origin',
'https://staging.expo.dev',
'--frame-ancestor',
'https://staging.expo.dev',
]);
expect(args).toContain('https://*.expo.dev');
expect(args).toContain('https://expo.test:13001');
expect(args).toContain('https://expo.test:13215');
expect(args).not.toContain('https://expo.test:13216');
expect(args).not.toContain('https://expo.dev');
});
it('names only https origins', () => {
for (const env of [{}, { EXPO_STAGING: '1' }, { EXPO_LOCAL: '1' }]) {
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
expect(args.filter(value => value.startsWith('http://'))).toEqual([]);
}
});
it('names the website dev ports on local, without the deploy-preview wildcard', () => {
for (const env of [{ EXPO_LOCAL: '1' }, { EXPO_LOCAL: '1', EXPO_STAGING: '1' }]) {
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
expect(args).toContain('https://expo.test:13001');
expect(args).not.toContain('https://*.expo.dev');
expect(args.filter(value => value === 'https://expo.test')).toHaveLength(2);
}
});
});
@@ -309,6 +520,11 @@ describe(startNgrokTunnelAsync, () => {
})
);
expect(tunnel.url).toBe('https://web-preview.example.test');
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({
domain: `web-preview-${tunnel.subdomainId}.eas-simulator.ngrok.dev`,
})
);
await tunnel.stopAsync();
await tunnel.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
@@ -325,10 +541,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
'--turn-credential',
'turn-credential',
];
const metricsCorsArgs = ['--metrics-cors-origin', 'https://metrics.expo.test'];
const websiteArgs = ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'];
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;
@@ -419,9 +634,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
const port = Number(args[args.indexOf('--port') + 1]);
expect(port).toBeGreaterThan(0);
expect(command).toBe('npx');
expect(args).toEqual(createExpoDeviceHubArgs({ port, turnArgs, packageVersion }));
expect(args).toEqual(['--yes', ...createExpoDeviceHubArgs({ port, turnArgs, packageVersion })]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.previewUrl).toBe('https://android-preview.example.test');
expect(preview.apiUrl).toBe('https://android-preview.example.test');
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
@@ -442,7 +657,41 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
});
expect(preview.previewToken).toBe('tok-1');
expect(preview.previewUrl).toBe('https://preview.example.test');
expect(preview.apiUrl).toBe('https://preview.example.test');
});
it.each([
[{}, 'https://expo.dev'],
[{ EXPO_STAGING: '1' }, 'https://staging.expo.dev'],
[{ EXPO_LOCAL: '1' }, 'https://expo.test'],
])('points the preview page at the website for the stage the worker runs in', (stage, origin) => {
expect(websiteOrigin({ ...env, ...stage })).toBe(origin);
expect(simulatorPreviewPageUrl({ ...env, ...stage }, 'abc')).toBe(
`${origin}/simulator-preview/abc`
);
});
it('points the preview URL at the website page for the tunnel', async () => {
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://preview.example.test',
close: jest.fn().mockResolvedValue(undefined),
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env: { ...env, EXPO_STAGING: '1' },
logger: createLoggerMock(),
timeoutMs: 10_000,
});
expect(preview.previewPageUrl).toMatch(
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
);
const previewId = preview.previewPageUrl.split('/').at(-1);
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({ domain: `web-preview-${previewId}.${baseDomain}` })
);
});
// serve-sim is always launched with --require-token, so a missing token means it is running
@@ -505,9 +754,18 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
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(args).toEqual([
'--yes',
...createServeSimArgs({
port,
turnArgs,
websiteArgs,
shareUrl: preview.previewPageUrl,
packageVersion,
}),
]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.previewUrl).toBe('https://ios-preview.example.test');
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
@@ -538,6 +796,117 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('launches serve-sim with bun x when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://ios-preview.example.test',
close,
} as never);
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env: bunEnv,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion: '4.5.6',
});
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
expect(command).toBe('bun');
expect(args).toEqual([
'x',
...createServeSimArgs({
port,
turnArgs,
websiteArgs,
shareUrl: preview.previewPageUrl,
packageVersion: '4.5.6',
}),
]);
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('launches serve-sim with bun x when EAS_FALLBACK_PACKAGE_MANAGER is bun', async () => {
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: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
expect(command).toBe('bun');
expect(args).toEqual([
'x',
...createServeSimArgs({
port,
turnArgs,
websiteArgs,
shareUrl: preview.previewPageUrl,
}),
]);
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('hands the launch options to serve-sim on Darwin', async () => {
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://ios-preview.example.test',
close: jest.fn().mockResolvedValue(undefined),
} as never);
await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
});
const [, args] = jest.mocked(spawn).mock.calls[0];
expect(args.slice(-8)).toEqual([
'--launch-app-identifier',
'host.exp.Exponent',
'--launch-arg',
'-EXDevMenuIsOnboardingFinished',
'--launch-arg',
'1',
'--open-url',
'exp://127.0.0.1:8081',
]);
});
it('refuses to launch an application on Linux, where expo-device-hub cannot', async () => {
await expect(
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.LINUX,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
launchAppIdentifier: 'host.exp.Exponent',
})
).rejects.toThrow('Cannot launch host.exp.Exponent');
expect(spawn).not.toHaveBeenCalled();
});
});
describe(turnIceServersToWebPreviewArgs, () => {
@@ -1,66 +0,0 @@
import { createLogger } from '@expo/logger';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { v4 as uuid } from 'uuid';
import { distributionCertificate } from '../__tests__/fixtures';
import Keychain from '../keychain';
const mockLogger = createLogger({ name: 'mock-logger' });
jest.setTimeout(60 * 1000);
// Those are in fact "real" tests in that they really execute the `fastlane` commands.
// We need the JS code to modify the file system, so we need to mock it.
jest.unmock('fs');
describe('Keychain class', () => {
describe('ensureCertificateImported method', () => {
let keychain: Keychain;
const certificatePath = path.join(os.tmpdir(), `cert-${uuid()}.p12`);
beforeAll(async () => {
await fs.writeFile(
certificatePath,
new Uint8Array(Buffer.from(distributionCertificate.dataBase64, 'base64'))
);
});
afterAll(async () => {
await fs.remove(certificatePath);
});
beforeEach(async () => {
keychain = new Keychain();
await keychain.create(mockLogger);
});
afterEach(async () => {
await keychain.destroy(mockLogger);
});
it("should throw an error if the certificate hasn't been imported", async () => {
await expect(
keychain.ensureCertificateImported(
distributionCertificate.teamId,
distributionCertificate.fingerprint
)
).rejects.toThrowError(/hasn't been imported successfully/);
});
it("shouldn't throw any error if the certificate has been imported successfully", async () => {
await keychain.importCertificate(
mockLogger,
certificatePath,
distributionCertificate.password
);
await expect(
keychain.ensureCertificateImported(
distributionCertificate.teamId,
distributionCertificate.fingerprint
)
).resolves.not.toThrow();
});
});
});
@@ -1,7 +1,7 @@
import { createLogger } from '@expo/logger';
import { provisioningProfile } from './fixtures';
import Keychain from '../keychain';
import Keychain from '../../../../../ios/credentials/keychain';
import ProvisioningProfile from '../provisioningProfile';
const mockLogger = createLogger({ name: 'mock-logger' });
@@ -18,11 +18,11 @@ describe('ProvisioningProfile class', () => {
beforeAll(async () => {
keychain = new Keychain();
await keychain.create(mockLogger);
await keychain.create({ logger: mockLogger });
});
afterAll(async () => {
await keychain.destroy(mockLogger);
await keychain.destroy({ logger: mockLogger });
});
it("shouldn't throw any error if the provisioning profile and distribution certificate match", async () => {
@@ -1,112 +0,0 @@
import { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import os from 'os';
import path from 'path';
import { v4 as uuid } from 'uuid';
import { runFastlane } from '../fastlane';
export default class Keychain {
private readonly keychainPath: string;
private readonly keychainPassword: string;
private created = false;
private destroyed = false;
constructor() {
this.keychainPath = path.join(os.tmpdir(), `eas-build-${uuid()}.keychain`);
this.keychainPassword = uuid();
}
get data(): { path: string; password: string } {
return {
path: this.keychainPath,
password: this.keychainPassword,
};
}
public async create(logger: bunyan): Promise<void> {
logger.debug(`Creating keychain - ${this.keychainPath}`);
await runFastlane([
'run',
'create_keychain',
`path:${this.keychainPath}`,
`password:${this.keychainPassword}`,
'unlock:true',
'timeout:360000',
]);
this.created = true;
}
public async importCertificate(
logger: bunyan,
certPath: string,
certPassword: string
): Promise<void> {
if (!this.created) {
throw new Error('You must create a keychain first.');
}
logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
await runFastlane([
'run',
'import_certificate',
`certificate_path:${certPath}`,
`certificate_password:${certPassword}`,
`keychain_path:${this.keychainPath}`,
`keychain_password:${this.keychainPassword}`,
]);
}
public async ensureCertificateImported(teamId: string, fingerprint: string): Promise<void> {
const identities = await this.findIdentitiesByTeamId(teamId);
if (!identities.includes(fingerprint)) {
throw new Error(
`Distribution certificate with fingerprint ${fingerprint} hasn't been imported successfully`
);
}
}
public async destroy(logger: bunyan, keychainPath?: string): Promise<void> {
if (!keychainPath && !this.created) {
logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
return;
}
if (this.destroyed) {
logger.warn('The keychain has been already destroyed');
return;
}
const keychainToDeletePath = keychainPath ?? this.keychainPath;
logger.info(`Destroying keychain - ${keychainToDeletePath}`);
try {
await runFastlane(['run', 'delete_keychain', `keychain_path:${keychainToDeletePath}`]);
this.destroyed = true;
} catch (err) {
logger.error({ err }, 'Failed to delete the keychain\n');
throw err;
}
}
public async cleanUpKeychains(logger: bunyan): Promise<void> {
const { stdout } = await spawn('security', ['list-keychains'], { stdio: 'pipe' });
const keychainList = (/"(.*)"/g.exec(stdout) ?? ([] as string[])).map(i =>
i.slice(1, i.length - 1)
);
const turtleKeychainList = keychainList.filter(keychain =>
/eas-build-[\w-]+\.keychain$/.exec(keychain)
);
for (const turtleKeychainPath of turtleKeychainList) {
await this.destroy(logger, turtleKeychainPath);
}
}
private async findIdentitiesByTeamId(teamId: string): Promise<string> {
const { output } = await spawn(
'security',
['find-identity', '-v', '-s', `(${teamId})`, this.keychainPath],
{
stdio: 'pipe',
}
);
return output.join('');
}
}
@@ -8,7 +8,7 @@ import path from 'path';
import { v4 as uuid } from 'uuid';
import * as distributionCertificateUtils from './distributionCertificate';
import Keychain from './keychain';
import Keychain from '../../../../ios/credentials/keychain';
import ProvisioningProfile, {
DistributionType,
ProvisioningProfileData,
@@ -36,7 +36,7 @@ export default class IosCredentialsManager {
logger.info('Creating keychain');
this.keychain = new Keychain();
await this.keychain.create(logger);
await this.keychain.create({ logger });
const targets = Object.keys(this.buildCredentials);
const targetProvisioningProfiles: TargetProvisioningProfiles = {};
@@ -70,7 +70,7 @@ export default class IosCredentialsManager {
}
if (this.keychain) {
await this.keychain.destroy(logger);
await this.keychain.destroy({ logger });
}
if (this.provisioningProfiles) {
for (const provisioningProfile of this.provisioningProfiles) {
@@ -109,11 +109,11 @@ export default class IosCredentialsManager {
);
logger.info('Importing distribution certificate into the keychain');
await this.keychain.importCertificate(
await this.keychain.importCertificate({
logger,
distCertPath,
targetCredentials.distributionCertificate.password
);
certPath: distCertPath,
certPassword: targetCredentials.distributionCertificate.password,
});
logger.info('Initializing provisioning profile');
const provisioningProfile = new ProvisioningProfile(
@@ -125,10 +125,10 @@ export default class IosCredentialsManager {
await provisioningProfile.init(logger);
logger.info('Validating whether distribution certificate has been imported successfully');
await this.keychain.ensureCertificateImported(
provisioningProfile.data.teamId,
certificateFingerprint
);
await this.keychain.ensureCertificateImported({
teamId: provisioningProfile.data.teamId,
fingerprint: certificateFingerprint,
});
logger.info('Verifying whether the distribution certificate and provisioning profile match');
provisioningProfile.verifyCertificate(certificateFingerprint);
@@ -1,7 +1,13 @@
import { SystemError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildRuntimePlatform, BuildStepEnv, spawnAsync } from '@expo/steps';
import {
BuildRuntimePlatform,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
spawnAsync,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { graphql } from 'gql.tada';
@@ -14,7 +20,17 @@ import { clearTimeout, setTimeout } from 'node:timers';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { CustomBuildContext } from '../../customBuildContext';
import {
parseLaunchArgsInput,
parseNonEmptyStringInput,
parseOpenUrlInput,
} from '../functions/launchApplication';
import { Sentry } from '../../sentry';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageExec,
} from '../../utils/packageManager';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
@@ -22,9 +38,9 @@ import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetrics
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_MAX_DIMENSION = '960';
const SERVE_SIM_MAX_DIMENSION = '1600';
const SERVE_SIM_MJPEG_QUALITY = '0.55';
const SERVE_SIM_VIDEO_BITRATE = '6000000';
const SERVE_SIM_VIDEO_BITRATE = '10000000';
const SERVE_SIM_VIDEO_FPS = '60';
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
@@ -549,7 +565,7 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
}
try {
// spawnDetached creates a dedicated process group. Signaling the group also
// terminates npx/bunx descendants instead of leaving the actual daemon alive.
// terminates npx/bun descendants instead of leaving the actual daemon alive.
process.kill(-pid, 'SIGTERM');
} catch {
try {
@@ -612,19 +628,36 @@ export function spawnDetached({
};
}
export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] {
const origin = env.EAS_SIMULATOR_METRICS_CORS_ORIGIN;
if (!origin) {
return [];
export function websiteOrigin(env: BuildStepEnv): string {
return env.EXPO_LOCAL
? 'https://expo.test'
: env.EXPO_STAGING
? 'https://staging.expo.dev'
: 'https://expo.dev';
}
export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string): string {
return new URL(`/simulator-preview/${subdomainId}`, websiteOrigin(env)).toString();
}
// Website dev servers on expo.test use staging sessions, and CORS matches ports exactly.
const WEBSITE_DEV_ORIGINS = [
'https://expo.test',
'https://expo.test:13001',
...Array.from({ length: 16 }, (_, index) => `https://expo.test:${13200 + index}`),
];
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
const origins = new Set([websiteOrigin(env)]);
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
origins.add('https://*.expo.dev');
}
const args: string[] = [];
for (const value of origin.split(',')) {
const trimmed = value.trim();
if (trimmed) {
args.push('--metrics-cors-origin', trimmed);
if (env.EXPO_LOCAL || env.EXPO_STAGING) {
for (const origin of WEBSITE_DEV_ORIGINS) {
origins.add(origin);
}
}
return args;
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
}
function createServeSimPackageSpec(packageVersion: string | undefined): string {
@@ -635,19 +668,97 @@ function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): str
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
export interface ServeSimLaunchOptions {
launchAppIdentifier?: string;
launchArgs?: string[];
openUrl?: string;
}
/**
* serve-sim performs the launch so the application starts under its instrumentation.
*/
export function createServeSimLaunchInputProviders(): ReturnType<
typeof BuildStepInput.createProvider
>[] {
return [
BuildStepInput.createProvider({
id: 'launch_app_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'launch_args',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'open_url',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
];
}
export function parseServeSimLaunchInputs(
{
launchAppIdentifier: rawLaunchAppIdentifier,
launchArgs: rawLaunchArgs,
openUrl: rawOpenUrl,
}: { launchAppIdentifier?: unknown; launchArgs?: unknown; openUrl?: unknown },
{ runtimePlatform }: { runtimePlatform: BuildRuntimePlatform }
): ServeSimLaunchOptions {
const launchAppIdentifier =
rawLaunchAppIdentifier === undefined
? undefined
: parseNonEmptyStringInput(rawLaunchAppIdentifier, 'launch_app_identifier');
const launchArgs = parseLaunchArgsInput(rawLaunchArgs);
const openUrl = rawOpenUrl === undefined ? undefined : parseOpenUrlInput(rawOpenUrl);
if (!launchAppIdentifier && (launchArgs.length > 0 || openUrl)) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'Inputs "launch_args" and "open_url" only work with an application launch. Pass "launch_app_identifier", or remove them.'
);
}
if (launchAppIdentifier && runtimePlatform !== BuildRuntimePlatform.DARWIN) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
`Input "launch_app_identifier" launches an application on an iOS simulator, and this session runs on ${runtimePlatform}. Run the session on an iOS simulator, or drop the launch inputs.`
);
}
return { launchAppIdentifier, launchArgs, openUrl };
}
export function describeServeSimLaunch({
launchAppIdentifier,
launchArgs = [],
openUrl,
}: ServeSimLaunchOptions): string | null {
if (!launchAppIdentifier) {
return null;
}
const withArguments =
launchArgs.length > 0 ? ` with arguments ${JSON.stringify(launchArgs)}` : '';
const thenOpen = openUrl ? `, then open ${openUrl}` : '';
return `serve-sim will launch ${launchAppIdentifier}${withArguments}${thenOpen}.`;
}
export function createServeSimArgs({
port,
turnArgs = [],
metricsCorsArgs = [],
websiteArgs = [],
shareUrl,
packageVersion,
launchAppIdentifier,
launchArgs = [],
openUrl,
}: {
port: number;
turnArgs?: string[];
metricsCorsArgs?: string[];
websiteArgs?: string[];
shareUrl?: string;
packageVersion?: string;
}): string[] {
} & ServeSimLaunchOptions): string[] {
return [
'--yes',
createServeSimPackageSpec(packageVersion),
'--port',
String(port),
@@ -657,7 +768,7 @@ export function createServeSimArgs({
'--transport',
'webrtc',
'--webrtc-codec',
'vp8',
'h264',
'--max-dimension',
SERVE_SIM_MAX_DIMENSION,
'--mjpeg-quality',
@@ -667,7 +778,11 @@ export function createServeSimArgs({
'--video-fps',
SERVE_SIM_VIDEO_FPS,
...turnArgs,
...metricsCorsArgs,
...websiteArgs,
...(shareUrl ? ['--share-url', shareUrl] : []),
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
...(openUrl ? ['--open-url', openUrl] : []),
];
}
@@ -681,7 +796,6 @@ export function createExpoDeviceHubArgs({
packageVersion?: string;
}): string[] {
return [
'--yes',
createExpoDeviceHubPackageSpec(packageVersion),
'--port',
String(port),
@@ -771,7 +885,8 @@ export async function waitForWebPreviewReadyAsync({
}
export type DeviceWebPreviewHandle = {
previewUrl: string;
previewPageUrl: string;
apiUrl: string;
/** Session token gating the preview. Only serve-sim mints one. */
previewToken?: string;
stopAsync: () => Promise<void>;
@@ -797,16 +912,24 @@ async function startWebPreviewWithTunnelAsync(
timeoutMs: number;
serverName: string;
packageSpec: string;
createArgs: (port: number, turnArgs: string[]) => string[];
createArgs: (port: number, turnArgs: string[], previewPageUrl: string) => string[];
readPreviewTokenAsync?: (device: string) => Promise<string>;
}
): Promise<DeviceWebPreviewHandle> {
const subdomainId = randomBytes(16).toString('hex');
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
const port = await findAvailablePortAsync();
logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`);
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
const previewExec = resolvePackageExec(
resolveConfiguredPackageManager(env, PackageManager.NPM),
createArgs(port, turnArgs, previewPageUrl)
);
logger.info(
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
);
const previewServer = spawnDetached({
command: 'npx',
args: createArgs(port, turnArgs),
command: previewExec.command,
args: previewExec.args,
env,
});
@@ -822,12 +945,14 @@ async function startWebPreviewWithTunnelAsync(
const tunnel = await startNgrokTunnelAsync({
port,
subdomainPrefix: 'web-preview',
subdomainId,
baseDomain,
authtoken: getNgrokAuthtokenOrThrow(env),
logger,
});
return {
previewUrl: tunnel.url,
previewPageUrl,
apiUrl: tunnel.url,
previewToken,
stopAsync: async () => {
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
@@ -860,15 +985,18 @@ export async function startServeSimWithTunnelAsync(
logger,
timeoutMs,
packageVersion,
launchAppIdentifier,
launchArgs,
openUrl,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
}
} & ServeSimLaunchOptions
): Promise<ServeSimPreviewHandle> {
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
const websiteArgs = websiteOriginServeSimArgs(env);
return await startWebPreviewWithTunnelAsync(ctx, {
baseDomain,
env,
@@ -876,8 +1004,17 @@ export async function startServeSimWithTunnelAsync(
timeoutMs,
serverName: 'serve-sim',
packageSpec: createServeSimPackageSpec(packageVersion),
createArgs: (port, turnArgs) =>
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
createArgs: (port, turnArgs, previewPageUrl) =>
createServeSimArgs({
port,
turnArgs,
websiteArgs,
shareUrl: previewPageUrl,
packageVersion,
launchAppIdentifier,
launchArgs,
openUrl,
}),
readPreviewTokenAsync: async device => {
const previewToken = await readServeSimPreviewTokenAsync(device);
if (!previewToken) {
@@ -931,6 +1068,9 @@ export async function startDeviceWebPreviewWithTunnelAsync(
ctx: CustomBuildContext,
{
runtimePlatform,
launchAppIdentifier,
launchArgs,
openUrl,
...options
}: {
runtimePlatform: BuildRuntimePlatform;
@@ -939,24 +1079,39 @@ export async function startDeviceWebPreviewWithTunnelAsync(
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
}
} & ServeSimLaunchOptions
): Promise<DeviceWebPreviewHandle> {
switch (runtimePlatform) {
case BuildRuntimePlatform.DARWIN:
return await startServeSimWithTunnelAsync(ctx, options);
return await startServeSimWithTunnelAsync(ctx, {
...options,
launchAppIdentifier,
launchArgs,
openUrl,
});
case BuildRuntimePlatform.LINUX:
// Unreachable from the three step functions, which reject a non-Darwin launch while
// parsing. Kept because this function is exported and expo-device-hub cannot launch.
if (launchAppIdentifier) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
);
}
return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform });
}
}
export type NgrokTunnelHandle = {
url: string;
subdomainId: string;
stopAsync: () => Promise<void>;
};
export async function startNgrokTunnelAsync({
port,
subdomainPrefix,
subdomainId: subdomainIdArg,
baseDomain,
authtoken,
rewriteHostHeader,
@@ -964,12 +1119,14 @@ export async function startNgrokTunnelAsync({
}: {
port: number;
subdomainPrefix: string;
subdomainId?: string;
baseDomain: string;
authtoken: string;
rewriteHostHeader?: boolean;
logger: bunyan;
}): Promise<NgrokTunnelHandle> {
const domain = `${subdomainPrefix}-${randomBytes(16).toString('hex')}.${baseDomain}`;
const subdomainId = subdomainIdArg ?? randomBytes(16).toString('hex');
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
// the process exits, and the step blocks forever to hold it open.
@@ -987,6 +1144,7 @@ export async function startNgrokTunnelAsync({
let stopped = false;
return {
url,
subdomainId,
stopAsync: async () => {
if (stopped) {
return;
@@ -9,6 +9,12 @@ import {
PackageManager,
findPackagerRootDir,
getPackageVersionFromPackageJson,
resolveConfiguredPackageManager,
resolveFallbackPackageManager,
resolveOverridePackageManager,
resolvePackageAdd,
resolvePackageExec,
resolvePackageInstall,
resolvePackageManager,
resolvePackageVersionAsync,
shouldUseFrozenLockfile,
@@ -117,6 +123,176 @@ describe(resolvePackageManager, () => {
});
});
describe(resolveFallbackPackageManager, () => {
it('returns undefined when unset or empty', () => {
expect(resolveFallbackPackageManager({})).toBeUndefined();
expect(resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: '' })).toBeUndefined();
});
it('returns bun when set', () => {
expect(resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bun' })).toBe(
PackageManager.BUN
);
});
it('throws a UserError on an unsupported value', () => {
expect(() => resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' })).toThrow(
errors.UserError
);
});
});
describe(resolveOverridePackageManager, () => {
it('returns undefined when unset or empty', () => {
expect(resolveOverridePackageManager({})).toBeUndefined();
expect(resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: '' })).toBeUndefined();
});
it('returns bun when set', () => {
expect(resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' })).toBe(
PackageManager.BUN
);
});
it('throws a UserError on an unsupported value', () => {
expect(() => resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: 'bunn' })).toThrow(
errors.UserError
);
});
});
describe(resolveConfiguredPackageManager, () => {
it('uses the unset default', () => {
expect(resolveConfiguredPackageManager({}, PackageManager.NPM)).toBe(PackageManager.NPM);
});
it('uses EAS_FALLBACK_PACKAGE_MANAGER when no override is set', () => {
expect(
resolveConfiguredPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bun' }, PackageManager.NPM)
).toBe(PackageManager.BUN);
});
it('prefers EAS_OVERRIDE_PACKAGE_MANAGER over the fallback', () => {
expect(
resolveConfiguredPackageManager(
{
EAS_OVERRIDE_PACKAGE_MANAGER: 'npm',
EAS_FALLBACK_PACKAGE_MANAGER: 'bun',
},
PackageManager.BUN
)
).toBe(PackageManager.NPM);
});
});
describe(resolvePackageExec, () => {
const packageArgs = ['@expo/serve-sim@latest', '--port', '1'];
it('maps npm to npx --yes', () => {
expect(resolvePackageExec(PackageManager.NPM, packageArgs)).toEqual({
command: 'npx',
args: ['--yes', ...packageArgs],
});
});
it('maps bun to bun x', () => {
expect(resolvePackageExec(PackageManager.BUN, packageArgs)).toEqual({
command: 'bun',
args: ['x', ...packageArgs],
});
});
it('maps yarn to npx --yes', () => {
expect(resolvePackageExec(PackageManager.YARN, packageArgs)).toEqual({
command: 'npx',
args: ['--yes', ...packageArgs],
});
});
it('maps pnpm to pnpm dlx', () => {
expect(resolvePackageExec(PackageManager.PNPM, packageArgs)).toEqual({
command: 'pnpm',
args: ['dlx', ...packageArgs],
});
});
});
describe(resolvePackageAdd, () => {
it('maps npm to npm install --no-audit', () => {
expect(resolvePackageAdd(PackageManager.NPM, 'appium@^3')).toEqual({
command: 'npm',
args: ['install', '--no-audit', 'appium@^3'],
});
});
it('maps bun to bun add', () => {
expect(resolvePackageAdd(PackageManager.BUN, 'agent-device@latest')).toEqual({
command: 'bun',
args: ['add', 'agent-device@latest'],
});
});
it('maps yarn to yarn add', () => {
expect(resolvePackageAdd(PackageManager.YARN, 'appium@^3')).toEqual({
command: 'yarn',
args: ['add', 'appium@^3'],
});
});
it('maps pnpm to pnpm add', () => {
expect(resolvePackageAdd(PackageManager.PNPM, 'appium@^3')).toEqual({
command: 'pnpm',
args: ['add', 'appium@^3'],
});
});
});
describe(resolvePackageInstall, () => {
it('maps bun production install', () => {
expect(resolvePackageInstall(PackageManager.BUN, { production: true })).toEqual({
command: 'bun',
args: ['install', '--production'],
});
});
it('maps npm production install without audit', () => {
expect(resolvePackageInstall(PackageManager.NPM, { production: true })).toEqual({
command: 'npm',
args: ['install', '--no-audit', '--omit=dev'],
});
});
it('maps yarn and pnpm production installs', () => {
expect(resolvePackageInstall(PackageManager.YARN, { production: true })).toEqual({
command: 'yarn',
args: ['install', '--production'],
});
expect(resolvePackageInstall(PackageManager.PNPM, { production: true })).toEqual({
command: 'pnpm',
args: ['install', '--prod'],
});
});
it('maps non-production installs', () => {
expect(resolvePackageInstall(PackageManager.NPM)).toEqual({
command: 'npm',
args: ['install', '--no-audit'],
});
expect(resolvePackageInstall(PackageManager.BUN)).toEqual({
command: 'bun',
args: ['install'],
});
expect(resolvePackageInstall(PackageManager.YARN)).toEqual({
command: 'yarn',
args: ['install'],
});
expect(resolvePackageInstall(PackageManager.PNPM)).toEqual({
command: 'pnpm',
args: ['install'],
});
});
});
describe(resolvePackageVersionAsync, () => {
const logger = {
debug: jest.fn(),
@@ -30,19 +30,100 @@ export function resolvePackageManager(
}
} catch {}
const fallback = env.EAS_FALLBACK_PACKAGE_MANAGER;
if (fallback) {
const parsed = z.enum(PackageManager).safeParse(fallback);
if (parsed.success) {
return parsed.data;
}
const allowed = Object.values(PackageManager).join(', ');
throw new errors.UserError(
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER',
`Invalid EAS_FALLBACK_PACKAGE_MANAGER value "${fallback}" (expected one of: ${allowed}).`
);
return resolveFallbackPackageManager(env) ?? PackageManager.YARN;
}
function parsePackageManagerEnvValue(
value: string | undefined,
envName: string,
errorCode: string
): PackageManager | undefined {
if (!value) {
return undefined;
}
const parsed = z.enum(PackageManager).safeParse(value);
if (parsed.success) {
return parsed.data;
}
const allowed = Object.values(PackageManager).join(', ');
throw new errors.UserError(
errorCode,
`Invalid ${envName} value "${value}" (expected one of: ${allowed}).`
);
}
export function resolveFallbackPackageManager(env: BuildStepEnv): PackageManager | undefined {
return parsePackageManagerEnvValue(
env.EAS_FALLBACK_PACKAGE_MANAGER,
'EAS_FALLBACK_PACKAGE_MANAGER',
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER'
);
}
export function resolveOverridePackageManager(env: BuildStepEnv): PackageManager | undefined {
return parsePackageManagerEnvValue(
env.EAS_OVERRIDE_PACKAGE_MANAGER,
'EAS_OVERRIDE_PACKAGE_MANAGER',
'EAS_INVALID_OVERRIDE_PACKAGE_MANAGER'
);
}
export function resolveConfiguredPackageManager(
env: BuildStepEnv,
whenUnset: PackageManager
): PackageManager {
return resolveOverridePackageManager(env) ?? resolveFallbackPackageManager(env) ?? whenUnset;
}
export function resolvePackageExec(
manager: PackageManager,
args: string[]
): { command: string; args: string[] } {
switch (manager) {
case PackageManager.NPM:
return { command: 'npx', args: ['--yes', ...args] };
case PackageManager.BUN:
return { command: 'bun', args: ['x', ...args] };
case PackageManager.YARN:
return { command: 'npx', args: ['--yes', ...args] };
case PackageManager.PNPM:
return { command: 'pnpm', args: ['dlx', ...args] };
}
}
export function resolvePackageAdd(
manager: PackageManager,
packageSpec: string
): { command: string; args: string[] } {
switch (manager) {
case PackageManager.NPM:
return { command: 'npm', args: ['install', '--no-audit', packageSpec] };
case PackageManager.BUN:
return { command: 'bun', args: ['add', packageSpec] };
case PackageManager.YARN:
return { command: 'yarn', args: ['add', packageSpec] };
case PackageManager.PNPM:
return { command: 'pnpm', args: ['add', packageSpec] };
}
}
export function resolvePackageInstall(
manager: PackageManager,
{ production }: { production?: boolean } = {}
): { command: string; args: string[] } {
switch (manager) {
case PackageManager.NPM:
return {
command: 'npm',
args: production ? ['install', '--no-audit', '--omit=dev'] : ['install', '--no-audit'],
};
case PackageManager.BUN:
return { command: 'bun', args: production ? ['install', '--production'] : ['install'] };
case PackageManager.YARN:
return { command: 'yarn', args: production ? ['install', '--production'] : ['install'] };
case PackageManager.PNPM:
return { command: 'pnpm', args: production ? ['install', '--prod'] : ['install'] };
}
return PackageManager.YARN;
}
/**
+1
View File
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"target": "es2022",
"module": "commonjs",
"strict": true,
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "24.5.0",
"version": "24.8.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,13 +35,13 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "24.5.0"
"@expo/steps": "24.8.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
"@jest/globals": "^29.7.0",
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"@types/node": "20.19.43",
"@types/prompts": "^2.4.9",
"@vercel/ncc": "^0.38.1",
"arg": "^5.0.2",
@@ -52,7 +52,7 @@
"prompts": "^2.4.2",
"rimraf": "3.0.2",
"ts-node": "^10.9.2",
"typescript": "^5.5.4",
"typescript": "^6.0.3",
"update-check": "^1.5.4"
}
}
@@ -11,8 +11,8 @@
},
"devDependencies": {
"@expo/steps": "^1.0.119",
"@types/node": "20.14.11",
"@types/node": "20.19.43",
"@vercel/ncc": "^0.38.1",
"typescript": "^5.5.3"
"typescript": "^6.0.3"
}
}
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"module": "node16",
"sourceMap": true,
"inlineSources": true,
"strict": true,
@@ -16,7 +16,7 @@
"outDir": "build",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"moduleResolution": "node16",
"rootDir": "src",
"declaration": false,
"composite": false
@@ -2,6 +2,7 @@
"include": ["src/**/*.ts"],
"compilerOptions": {
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"target": "es2022",
"module": "commonjs",
"strict": true,
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "24.0.0",
"version": "24.8.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -31,10 +31,10 @@
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"@types/node": "20.19.43",
"jest": "^29.7.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
"ts-jest": "^29.4.12",
"typescript": "^6.0.3"
}
}
+2
View File
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"target": "es2022",
"module": "commonjs",
"strict": true,
@@ -8,6 +9,7 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"types": ["node", "jest"],
"composite": true,
"rootDir": "./src",
"outDir": "./dist"
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "24.0.0",
"version": "24.8.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",
@@ -51,7 +51,7 @@
"jest": "29.7.0",
"memfs": "3.4.13",
"rimraf": "3.0.2",
"typescript": "5.5.4"
"typescript": "6.0.3"
},
"engines": {
"node": ">=20.0.0"
@@ -5,6 +5,7 @@
"importHelpers": true,
"module": "commonjs",
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "24.5.0",
"version": "24.8.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,19 +25,19 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "24.0.0",
"@expo/logger": "24.8.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "24.0.0",
"@expo/turtle-spawn": "24.8.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"@types/node": "20.19.43",
"jest": "^29.7.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
"ts-jest": "^29.4.12",
"typescript": "^6.0.3"
}
}
+2
View File
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"target": "es2022",
"module": "commonjs",
"strict": true,
@@ -8,6 +9,7 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"types": ["node", "jest"],
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
+455 -246
View File
File diff suppressed because it is too large. Load diff
+1510 -39
View File
File diff suppressed because it is too large. Load diff
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "24.5.0",
"version": "24.8.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,11 +48,11 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "24.5.0",
"@expo/eas-json": "24.5.0",
"@expo/eas-build-job": "24.8.0",
"@expo/eas-json": "24.8.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "24.0.0",
"@expo/logger": "24.8.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": "24.5.0",
"@expo/steps": "24.8.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -95,7 +95,7 @@
"gradle-to-js": "2.0.1",
"graphql": "16.8.1",
"graphql-tag": "2.12.6",
"https-proxy-agent": "5.0.1",
"https-proxy-agent": "7.0.6",
"ignore": "5.3.0",
"indent-string": "4.0.0",
"invariant": "^2.2.2",
@@ -152,6 +152,7 @@
"@types/getenv": "^1.0.0",
"@types/invariant": "^2.2.37",
"@types/mime": "3.0.1",
"@types/node": "20.19.43",
"@types/node-fetch": "2.6.12",
"@types/node-forge": "1.3.1",
"@types/pngjs": "6.0.4",
@@ -172,7 +173,7 @@
"rimraf": "3.0.2",
"ts-mockito": "2.6.1",
"ts-node": "10.9.2",
"typescript": "5.5.4"
"typescript": "6.0.3"
},
"oclif": {
"additionalHelpFlags": [
@@ -3,7 +3,7 @@ const path = require('path');
const LLM_CONFIGS_EXPO_APP_TEMPLATE_BASE_URL =
'https://raw.githubusercontent.com/expo/llm-configs/main/expo-app';
const AGENT_TEMPLATE_FILE_NAMES = ['AGENTS.md', 'CLAUDE.md'];
const AGENT_TEMPLATE_FILE_NAMES = ['AGENTS.md'];
async function fetchTemplateAsync(fileName) {
const url = `${LLM_CONFIGS_EXPO_APP_TEMPLATE_BASE_URL}/${fileName}`;
@@ -11,8 +11,8 @@ import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVers
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { mockTestCommand } from './utils';
import { UserQuery } from '../../graphql/queries/UserQuery';
jest.mock('@expo/config', () => ({
...jest.requireActual('@expo/config'),
@@ -45,8 +45,8 @@ jest.mock('fs-extra', () => ({
writeFile: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../user/actions');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/queries/UserQuery');
jest.mock('../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../project/uploadAccountScopedFileAsync');
jest.mock('../../project/uploadAccountScopedProjectSourceAsync');
@@ -110,7 +110,9 @@ const mockActor = {
describe('Go command', () => {
beforeEach(() => {
jest.mocked(ensureActorHasPrimaryAccount).mockReturnValue(mockAccount as any);
jest
.mocked(UserQuery.requireCurrentUserPrimaryAccountAsync)
.mockReturnValue(mockAccount as any);
jest.mocked(WorkflowRunQuery.expoGoRepackConfigurationAsync).mockResolvedValue({
files: [],
sdkVersion: '55.0.0',
@@ -8,7 +8,7 @@ import {
mockProjectId,
mockTestCommand,
} from './utils';
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/logs/fetchLogs';
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/fetchLogs';
import WorkflowLogView from '../../commands/workflow/logs';
import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
@@ -35,7 +35,7 @@ jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
jest.mock('../../commandUtils/workflow/logs/fetchLogs');
jest.mock('../../commandUtils/workflow/fetchLogs');
describe(WorkflowLogView, () => {
beforeEach(() => {
@@ -0,0 +1,36 @@
jest.unmock('ora');
import { ora } from '../ora';
function createFakeTtyStream(columns: number): NodeJS.WriteStream & { clearedLines: number } {
return {
isTTY: true,
columns,
clearedLines: 0,
write: jest.fn(),
moveCursor: jest.fn(),
cursorTo: jest.fn(),
clearLine(this: { clearedLines: number }) {
this.clearedLines++;
},
} as unknown as NodeJS.WriteStream & { clearedLines: number };
}
describe(ora, () => {
it('clears all wrapped lines after the terminal gets narrower', () => {
const stream = createFakeTtyStream(200);
const spinner = ora({ text: 'x'.repeat(100), stream, isEnabled: true });
spinner.render();
spinner.render();
expect(stream.clearedLines).toBe(1);
stream.columns = 40;
// The frame on screen was written at the old width, so it is cleared with the old line count.
spinner.render();
stream.clearedLines = 0;
spinner.render();
// "- " + 100 characters wraps to 3 lines at 40 columns.
expect(stream.clearedLines).toBe(3);
});
});
@@ -0,0 +1,233 @@
import { BuildPhase } from '@expo/eas-build-job';
import { v4 as uuid } from 'uuid';
import { groupLogLinesIntoSteps } from '../../commandUtils/logs/parseLogs';
import { JobLogs, RawLogLine } from '../../commandUtils/logs/types';
import fetch from '../../fetch';
import {
AppPlatform,
BuildFragment,
BuildPriority,
BuildStatus,
RealtimeLogsTargetType,
} from '../../graphql/generated';
import {
formatActiveBuildText,
formatActiveBuildsText,
isBuildCompleted,
logSourceForBuild,
} from '../logs';
jest.mock('../../fetch');
function createBuildFragment(overrides: Partial<BuildFragment> = {}): BuildFragment {
return {
id: 'build-id',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
platform: AppPlatform.Android,
logFiles: [],
priority: BuildPriority.Normal,
app: {
__typename: 'App',
slug: 'test-project',
id: uuid(),
name: 'test-project',
ownerAccount: {
__typename: 'Account',
id: uuid(),
name: 'test-account',
},
},
status: BuildStatus.InProgress,
isForIosSimulator: false,
...overrides,
};
}
function phaseLines(phase: string, count: number): RawLogLine[] {
return Array.from({ length: count }, (_, index) => ({ phase, msg: `line${index}` }));
}
function logsForPhase(phase: string, count: number): JobLogs {
return groupLogLinesIntoSteps(phaseLines(phase, count));
}
describe(logSourceForBuild, () => {
it('keys the source by the build id', () => {
expect(logSourceForBuild(createBuildFragment({ id: 'some-build-id' })).key).toBe(
'some-build-id'
);
});
it('targets the build itself for realtime logs', () => {
expect(logSourceForBuild(createBuildFragment({ id: 'some-build-id' })).realtimeTarget).toEqual({
type: RealtimeLogsTargetType.Build,
id: 'some-build-id',
});
});
it('is in progress only while the build is in progress', () => {
expect(
logSourceForBuild(createBuildFragment({ status: BuildStatus.InProgress })).isInProgress
).toBe(true);
for (const status of [
BuildStatus.New,
BuildStatus.InQueue,
BuildStatus.PendingCancel,
BuildStatus.Canceled,
BuildStatus.Errored,
BuildStatus.Finished,
]) {
expect(logSourceForBuild(createBuildFragment({ status })).isInProgress).toBe(false);
}
});
describe('fetchRawLogLinesAsync', () => {
afterEach(() => {
jest.mocked(fetch).mockReset();
});
it('parses the first log file of the build', async () => {
jest.mocked(fetch).mockResolvedValueOnce({
text: async () =>
[
'{"logId":"1","phase":"INSTALL_DEPENDENCIES","msg":"npm ci"}',
'{"logId":"2","phase":"INSTALL_DEPENDENCIES","msg":"done"}',
].join('\n'),
} as any);
const source = logSourceForBuild(
createBuildFragment({ logFiles: ['https://logs.test/first', 'https://logs.test/second'] })
);
await expect(source.fetchRawLogLinesAsync()).resolves.toEqual([
{ logId: '1', phase: 'INSTALL_DEPENDENCIES', msg: 'npm ci' },
{ logId: '2', phase: 'INSTALL_DEPENDENCIES', msg: 'done' },
]);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith('https://logs.test/first', {
timeout: expect.any(Number),
});
});
it('returns null when the build has no log file', async () => {
await expect(
logSourceForBuild(createBuildFragment({ logFiles: [] })).fetchRawLogLinesAsync()
).resolves.toBeNull();
expect(fetch).not.toHaveBeenCalled();
});
});
});
describe(isBuildCompleted, () => {
it('reports FINISHED as completed', () => {
expect(isBuildCompleted(BuildStatus.Finished)).toBe(true);
});
it('reports ERRORED as completed', () => {
expect(isBuildCompleted(BuildStatus.Errored)).toBe(true);
});
it('reports CANCELED as completed', () => {
expect(isBuildCompleted(BuildStatus.Canceled)).toBe(true);
});
it('reports NEW as not completed', () => {
expect(isBuildCompleted(BuildStatus.New)).toBe(false);
});
it('reports IN_QUEUE as not completed', () => {
expect(isBuildCompleted(BuildStatus.InQueue)).toBe(false);
});
it('reports IN_PROGRESS as not completed', () => {
expect(isBuildCompleted(BuildStatus.InProgress)).toBe(false);
});
it('reports PENDING_CANCEL as not completed', () => {
expect(isBuildCompleted(BuildStatus.PendingCancel)).toBe(false);
});
});
describe(formatActiveBuildText, () => {
it('returns the base text alone when there are no logs', () => {
expect(formatActiveBuildText('Build in progress...', new Map())).toBe('Build in progress...');
});
it('shows the display name of a raw build phase', () => {
const output = formatActiveBuildText(
'Build in progress...',
logsForPhase(BuildPhase.INSTALL_DEPENDENCIES, 1)
);
expect(output).toContain('Current phase');
expect(output).toContain('Install dependencies');
expect(output).not.toContain('INSTALL_DEPENDENCIES');
});
it('passes a build step display name through untouched', () => {
const output = formatActiveBuildText(
'Build in progress...',
groupLogLinesIntoSteps([
{ buildStepId: 'step-id-1', buildStepDisplayName: 'Run fastlane build', msg: 'line0' },
])
);
expect(output).toContain('Run fastlane build');
expect(output).not.toContain('step-id-1');
});
it('keeps the base text and five trailing log lines', () => {
const output = formatActiveBuildText(
'Build in progress...',
logsForPhase(BuildPhase.RUN_GRADLEW, 10)
);
expect(output.startsWith('Build in progress...\n')).toBe(true);
expect(output).toContain('line5');
expect(output).toContain('line9');
expect(output).not.toContain('line4');
});
});
describe(formatActiveBuildsText, () => {
it('returns the base text alone when no build has logs', () => {
expect(
formatActiveBuildsText('Waiting for builds to complete.', [
{ build: createBuildFragment(), logs: new Map() },
])
).toBe('Waiting for builds to complete.');
});
it('labels each build with its platform emoji and display name', () => {
const output = formatActiveBuildsText('Waiting for builds to complete.', [
{
build: createBuildFragment({ id: 'android-build', platform: AppPlatform.Android }),
logs: logsForPhase(BuildPhase.RUN_GRADLEW, 1),
},
{
build: createBuildFragment({ id: 'ios-build', platform: AppPlatform.Ios }),
logs: logsForPhase(BuildPhase.RUN_FASTLANE, 1),
},
]);
expect(output).toContain('🤖 Android');
expect(output).toContain('Run gradlew');
expect(output).toContain('🍏 iOS');
expect(output).toContain('Run fastlane');
});
it('keeps three trailing log lines per build', () => {
const output = formatActiveBuildsText('Waiting for builds to complete.', [
{
build: createBuildFragment({ id: 'android-build', platform: AppPlatform.Android }),
logs: logsForPhase(BuildPhase.RUN_GRADLEW, 10),
},
]);
expect(output).toContain('line7');
expect(output).toContain('line9');
expect(output).not.toContain('line6');
});
});
@@ -0,0 +1,163 @@
import { BuildPhase } from '@expo/eas-build-job';
import { v4 as uuid } from 'uuid';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { LogsState } from '../../commandUtils/logs/state';
import { groupLogLinesIntoSteps } from '../../commandUtils/logs/parseLogs';
import { JobLogs } from '../../commandUtils/logs/types';
import { LogsWatcher } from '../../commandUtils/logs/watcher';
import {
AppPlatform,
BuildFragment,
BuildPriority,
BuildStatus,
} from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { Ora, isSpinnerEnabled, ora } from '../../ora';
import { createRealtimeLogsClient } from '../../utils/centrifuge';
import { sleepAsync } from '../../utils/promise';
import { waitForBuildEndAsync } from '../build';
jest.mock('../../ora', () => ({
...jest.requireActual('../../ora'),
ora: jest.fn(),
isSpinnerEnabled: jest.fn(),
}));
jest.mock('../../commandUtils/logs/watcher', () => ({
LogsWatcher: jest.fn(),
}));
jest.mock('../../utils/centrifuge', () => ({
createRealtimeLogsClient: jest.fn(),
}));
jest.mock('../../graphql/queries/BuildQuery', () => ({
BuildQuery: {
byIdAsync: jest.fn(),
},
}));
jest.mock('../../utils/promise', () => ({
...jest.requireActual('../../utils/promise'),
sleepAsync: jest.fn(),
}));
const graphqlClient = {} as unknown as ExpoGraphqlClient;
function createBuildFragment(status: BuildStatus): BuildFragment {
return {
id: 'build-id',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
platform: AppPlatform.Android,
logFiles: [],
priority: BuildPriority.Normal,
app: {
__typename: 'App',
slug: 'test-project',
id: uuid(),
name: 'test-project',
ownerAccount: {
__typename: 'Account',
id: uuid(),
name: 'test-account',
},
},
status,
isForIosSimulator: false,
};
}
function createSpinner(): Ora {
const spinner: Record<string, unknown> = {
text: '',
prefixText: '',
isSpinning: true,
start: jest.fn(() => spinner),
stop: jest.fn(() => spinner),
stopAndPersist: jest.fn(() => spinner),
succeed: jest.fn(() => spinner),
fail: jest.fn(() => spinner),
warn: jest.fn(() => spinner),
};
return spinner as unknown as Ora;
}
function setBuildStatuses(statuses: BuildStatus[]): void {
const mocked = jest.mocked(BuildQuery.byIdAsync);
for (const status of statuses) {
mocked.mockResolvedValueOnce(createBuildFragment(status));
}
}
describe(waitForBuildEndAsync, () => {
let spinner: Ora;
let publish: () => void;
let watcherClose: jest.Mock;
let logs: JobLogs;
beforeEach(() => {
jest.clearAllMocks();
spinner = createSpinner();
logs = new Map();
publish = () => {};
watcherClose = jest.fn();
jest.mocked(ora).mockReturnValue(spinner);
jest.mocked(LogsWatcher).mockImplementation(((
_createRealtimeLogsClient: unknown,
onRealtimeLogs: () => void
) => {
publish = onRealtimeLogs;
return {
syncAsync: jest.fn(async () =>
new Map<string, LogsState>([
['build-id', { getLogs: () => logs, markCompleted: jest.fn() } as unknown as LogsState],
])
),
close: watcherClose,
};
}) as unknown as () => LogsWatcher);
jest.mocked(sleepAsync).mockImplementation(async () => {
publish();
});
});
it('does not create a watcher when the spinner is disabled', async () => {
jest.mocked(isSpinnerEnabled).mockReturnValue(false);
setBuildStatuses([BuildStatus.Finished]);
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
expect(LogsWatcher).not.toHaveBeenCalled();
expect(createRealtimeLogsClient).not.toHaveBeenCalled();
});
it('renders the current phase and its log tail on publication while in progress', async () => {
jest.mocked(isSpinnerEnabled).mockReturnValue(true);
setBuildStatuses([BuildStatus.InProgress, BuildStatus.Finished]);
jest.mocked(sleepAsync).mockImplementationOnce(async () => {
logs = groupLogLinesIntoSteps([
{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'npm ci' },
{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'added 1 package' },
]);
publish();
});
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
expect(LogsWatcher).toHaveBeenCalledTimes(1);
expect(spinner.text).toContain('Build in progress...');
expect(spinner.text).toContain('Install dependencies');
expect(spinner.text).toContain('added 1 package');
expect(watcherClose).toHaveBeenCalledTimes(1);
});
it('ignores a publication that arrives while the build is queued', async () => {
jest.mocked(isSpinnerEnabled).mockReturnValue(true);
setBuildStatuses([BuildStatus.InProgress, BuildStatus.InQueue, BuildStatus.Finished]);
logs = groupLogLinesIntoSteps([{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'npm ci' }]);
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
expect(jest.mocked(sleepAsync).mock.calls.length).toBe(2);
expect(spinner.text).toBe('Build queued...');
});
});
+114 -21
View File
@@ -30,6 +30,12 @@ import {
} from './errors';
import { transformMetadata } from './graphql';
import { LocalBuildMode, runLocalBuildAsync } from './local';
import {
formatActiveBuildText,
formatActiveBuildsText,
isBuildCompleted,
logSourceForBuild,
} from './logs';
import { collectMetadataAsync } from './metadata';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import {
@@ -46,6 +52,8 @@ import { getExpoWebsiteBaseUrl } from '../api';
import { formatStarterSubscribeCommand } from '../billing/plans';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
import { LogsState } from '../commandUtils/logs/state';
import { LogsWatcher } from '../commandUtils/logs/watcher';
import { createFingerprintAsync } from '../fingerprint/cli';
import {
AppPlatform,
@@ -60,7 +68,7 @@ import {
import { BuildMutation, BuildResult } from '../graphql/mutations/BuildMutation';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log, { learnMore, link } from '../log';
import { Ora, ora } from '../ora';
import { Ora, isSpinnerEnabled, ora, updateSpinnerText } from '../ora';
import {
RequestedPlatform,
appPlatformDisplayNames,
@@ -70,6 +78,7 @@ import {
import { maybeUploadFingerprintAsync } from '../project/maybeUploadFingerprintAsync';
import { resolveRuntimeVersionAsync } from '../project/resolveRuntimeVersionAsync';
import { uploadFileAtPathToGCSAsync } from '../uploads';
import { createRealtimeLogsClient } from '../utils/centrifuge';
import { formatBytes } from '../utils/files';
import { printJsonOnlyOutput } from '../utils/json';
import { createProgressTracker } from '../utils/progress';
@@ -538,19 +547,64 @@ export async function waitForBuildEndAsync(
originalSpinnerText = 'Waiting for builds to complete. You can press Ctrl+C to exit.';
spinner = ora('Waiting for builds to complete. You can press Ctrl+C to exit.').start();
}
while (true) {
const builds = await getBuildsSafelyAsync(graphqlClient, buildIds);
const { refetch } =
builds.length === 1
? await handleSingleBuildProgressAsync({ build: builds[0], accountName }, { spinner })
: await handleMultipleBuildsProgressAsync({ builds }, { spinner, originalSpinnerText });
if (!refetch) {
return builds;
let render = (): void => {};
const watcher = isSpinnerEnabled()
? new LogsWatcher(
() => createRealtimeLogsClient(graphqlClient),
() => {
render();
}
)
: null;
try {
while (true) {
render = (): void => {};
const builds = await getBuildsSafelyAsync(graphqlClient, buildIds);
const logsStates = await syncBuildLogsAsync(watcher, builds);
const installRender = (nextRender: () => void): void => {
render = nextRender;
};
const { refetch } =
builds.length === 1
? await handleSingleBuildProgressAsync(
{ build: builds[0], accountName },
{ spinner, logsStates, installRender }
)
: await handleMultipleBuildsProgressAsync(
{ builds },
{ spinner, originalSpinnerText, logsStates, installRender }
);
if (!refetch) {
return builds;
}
await sleepAsync(intervalSec * 1000);
}
await sleepAsync(intervalSec * 1000);
} finally {
watcher?.close();
}
}
async function syncBuildLogsAsync(
watcher: LogsWatcher | null,
builds: MaybeBuildFragment[]
): Promise<Map<string, LogsState>> {
if (!watcher) {
return new Map();
}
const existingBuilds = builds.filter(isBuildFragment);
const logsStates = await watcher.syncAsync(existingBuilds.map(build => logSourceForBuild(build)));
for (const build of existingBuilds) {
if (isBuildCompleted(build.status)) {
nullthrows(
logsStates.get(build.id),
'syncAsync must have been called before markCompleted'
).markCompleted();
}
}
return logsStates;
}
async function getBuildsSafelyAsync(
graphqlClient: ExpoGraphqlClient,
buildIds: string[]
@@ -570,6 +624,11 @@ interface BuildProgressResult {
refetch: boolean;
}
type BuildLogsProgressOptions = {
logsStates: Map<string, LogsState>;
installRender: (render: () => void) => void;
};
let queueProgressBarStarted = false;
const queueProgressBar = new cliProgress.SingleBar(
{ format: '|{bar}| {estimatedWaitTime}' },
@@ -587,10 +646,12 @@ async function handleSingleBuildProgressAsync(
build: MaybeBuildFragment;
accountName: string;
},
{ spinner }: { spinner: Ora }
{ spinner, logsStates, installRender }: { spinner: Ora } & BuildLogsProgressOptions
): Promise<BuildProgressResult> {
if (build === null) {
spinner.text = 'Could not fetch the build status. Check your network connection.';
updateSpinnerText(spinner, {
text: 'Could not fetch the build status. Check your network connection.',
});
return { refetch: true };
}
@@ -615,16 +676,18 @@ async function handleSingleBuildProgressAsync(
statusNewSetAt ??= now;
const newStatusDurationMs = now - statusNewSetAt;
if (newStatusDurationMs < NEW_STATUS_GRACE_PERIOD_MS) {
spinner.text = 'Waiting for build to get enqueued…';
updateSpinnerText(spinner, { text: 'Waiting for build to get enqueued…' });
} else {
spinner.text = `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
formatAccountBillingUrl(accountName)
)}.`;
updateSpinnerText(spinner, {
text: `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
formatAccountBillingUrl(accountName)
)}.`,
});
}
break;
}
case BuildStatus.InQueue: {
spinner.text = 'Build queued...';
updateSpinnerText(spinner, { text: 'Build queued...' });
const progressBarPayload =
typeof build.estimatedWaitTimeLeftSeconds === 'number'
? { estimatedWaitTime: formatEstimatedWaitTime(build.estimatedWaitTimeLeftSeconds) }
@@ -661,9 +724,17 @@ async function handleSingleBuildProgressAsync(
case BuildStatus.Canceled:
spinner.fail('Build canceled');
return { refetch: false };
case BuildStatus.InProgress:
spinner.text = 'Build in progress...';
case BuildStatus.InProgress: {
const logsState = logsStates.get(build.id);
const render = (): void => {
updateSpinnerText(spinner, {
text: formatActiveBuildText('Build in progress...', logsState?.getLogs() ?? new Map()),
});
};
installRender(render);
render();
break;
}
case BuildStatus.Errored:
spinner.fail('Build failed');
if (build.error) {
@@ -698,7 +769,12 @@ const platforms = [AppPlatform.Android, AppPlatform.Ios];
async function handleMultipleBuildsProgressAsync(
{ builds: maybeBuilds }: { builds: MaybeBuildFragment[] },
{ spinner, originalSpinnerText }: { spinner: Ora; originalSpinnerText: string }
{
spinner,
originalSpinnerText,
logsStates,
installRender,
}: { spinner: Ora; originalSpinnerText: string } & BuildLogsProgressOptions
): Promise<BuildProgressResult> {
const buildCount = maybeBuilds.length;
const builds = maybeBuilds.filter<BuildFragment>(isBuildFragment);
@@ -732,7 +808,24 @@ async function handleMultipleBuildsProgressAsync(
someNew &&
statusNewSetAt !== null &&
Date.now() - statusNewSetAt >= NEW_STATUS_GRACE_PERIOD_MS;
spinner.text = formatPendingBuildsText(originalSpinnerText, builds, showConcurrencyWarning);
const pendingBuildsText = formatPendingBuildsText(
originalSpinnerText,
builds,
showConcurrencyWarning
);
const inProgressBuilds = builds.filter(build => build.status === BuildStatus.InProgress);
const render = (): void => {
const text = formatActiveBuildsText(
pendingBuildsText,
inProgressBuilds.map(build => ({
build,
logs: logsStates.get(build.id)?.getLogs() ?? new Map(),
}))
);
updateSpinnerText(spinner, { text });
};
installRender(render);
render();
return { refetch: true };
}
}
+1 -2
View File
@@ -87,8 +87,7 @@ export async function createBuildContextAsync<T extends Platform>({
buildProfile.developmentClient ??
(platform === Platform.ANDROID
? (buildProfile as BuildProfile<Platform.ANDROID>)?.gradleCommand === ':app:assembleDebug'
: (buildProfile as BuildProfile<Platform.IOS>)?.buildConfiguration === 'Debug') ??
false;
: (buildProfile as BuildProfile<Platform.IOS>)?.buildConfiguration === 'Debug');
const requiredPackageManager = resolvePackageManager(projectDir);
+113
View File
@@ -0,0 +1,113 @@
import { BuildPhase, buildPhaseDisplayName } from '@expo/eas-build-job';
import { choicesFromJobLogs, stepLogTail } from '../commandUtils/logs/format';
import { parseLogLines } from '../commandUtils/logs/parseLogs';
import { JobLogs, RawLogLine } from '../commandUtils/logs/types';
import { LogSource } from '../commandUtils/logs/watcher';
import fetch from '../fetch';
import { BuildFragment, BuildStatus, RealtimeLogsTargetType } from '../graphql/generated';
import Log from '../log';
import { appPlatformDisplayNames, appPlatformEmojis } from '../platform';
import formatFields, { FormatFieldsItem } from '../utils/formatFields';
const LOGS_FETCH_TIMEOUT_MS = 5_000;
const SINGLE_BUILD_MAX_LOG_LINES = 5;
const MULTIPLE_BUILDS_MAX_LOG_LINES = 3;
async function fetchAndParseLogsForBuildAsync(build: BuildFragment): Promise<RawLogLine[] | null> {
const logFileUrl = build.logFiles[0];
if (!logFileUrl) {
return null;
}
const response = await fetch(logFileUrl, { timeout: LOGS_FETCH_TIMEOUT_MS });
const rawLogs = await response.text();
if (!rawLogs) {
return null;
}
const { logLines, errors } = parseLogLines(rawLogs);
for (const error of errors) {
Log.debug(`Failed to parse a log line: ${error.message}`);
}
return logLines;
}
export function logSourceForBuild(build: BuildFragment): LogSource {
return {
key: build.id,
realtimeTarget: { type: RealtimeLogsTargetType.Build, id: build.id },
isInProgress: build.status === BuildStatus.InProgress,
fetchRawLogLinesAsync: async () => await fetchAndParseLogsForBuildAsync(build),
};
}
export function isBuildCompleted(status: BuildStatus): boolean {
switch (status) {
case BuildStatus.Finished:
case BuildStatus.Errored:
case BuildStatus.Canceled:
return true;
case BuildStatus.New:
case BuildStatus.InQueue:
case BuildStatus.InProgress:
case BuildStatus.PendingCancel:
return false;
}
}
function stepDisplayName(label: string): string {
return Object.hasOwn(buildPhaseDisplayName, label)
? buildPhaseDisplayName[label as BuildPhase]
: label;
}
function activeBuildFields(
logs: JobLogs,
maxLogLines: number,
buildLabel: string | null
): FormatFieldsItem[] {
const steps = choicesFromJobLogs(logs);
if (steps.length === 0) {
return [];
}
const currentStep = steps[steps.length - 1];
const fields: FormatFieldsItem[] = [{ label: '', value: '' }];
if (buildLabel !== null) {
fields.push({ label: ' Build', value: buildLabel });
}
fields.push({ label: ' Current phase', value: stepDisplayName(currentStep.name) });
if (currentStep.logLines?.length) {
fields.push({ label: ' Current logs', value: '' });
for (const logLine of stepLogTail(currentStep, maxLogLines)) {
fields.push({ label: '', value: logLine });
}
}
return fields;
}
function appendFields(baseText: string, fields: FormatFieldsItem[]): string {
if (fields.length === 0) {
return baseText;
}
return `${baseText}\n${formatFields(fields)}`;
}
export function formatActiveBuildText(baseText: string, logs: JobLogs): string {
return appendFields(baseText, activeBuildFields(logs, SINGLE_BUILD_MAX_LOG_LINES, null));
}
export function formatActiveBuildsText(
baseText: string,
buildsWithLogs: { build: BuildFragment; logs: JobLogs }[]
): string {
return appendFields(
baseText,
buildsWithLogs.flatMap(({ build, logs }) =>
activeBuildFields(
logs,
MULTIPLE_BUILDS_MAX_LOG_LINES,
`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]}`
)
)
);
}
@@ -504,7 +504,10 @@ async function prepareAndStartBuildAsync({
if (easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE) {
validateAppConfigForRemoteVersionSource(buildCtx.exp, buildProfile.platform);
}
if (buildCtx.workflow === Workflow.MANAGED) {
// Custom builds run the steps from the profile's config file instead of the
// standard managed pipeline, so the managed-workflow checks do not apply.
const isCustomBuild = !!buildProfile.profile.config;
if (buildCtx.workflow === Workflow.MANAGED && !isCustomBuild) {
if (!sdkVersionChecked) {
await checkExpoSdkIsSupportedAsync(buildCtx);
sdkVersionChecked = true;
@@ -45,11 +45,6 @@ describe(getProjectIdAsync, () => {
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_1',
@@ -420,11 +415,6 @@ describe(getProjectIdAsync, () => {
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_1',
@@ -524,11 +514,6 @@ describe(getProjectIdAsync, () => {
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
accounts: [
{
id: 'account_id_1',
@@ -1,7 +1,7 @@
import { groupLogLinesIntoSteps, mergeLogLines, parseLogLines } from '../parseLogs';
import { WorkflowRawLogLine } from '../../types';
import { RawLogLine } from '../types';
function logLine(overrides: Partial<WorkflowRawLogLine> = {}): WorkflowRawLogLine {
function logLine(overrides: Partial<RawLogLine> = {}): RawLogLine {
return { msg: 'a message', ...overrides };
}
@@ -0,0 +1,110 @@
import { LogsState } from '../state';
import { RawLogLine } from '../types';
function fileLine(logId: string, msg: string, buildStepId = 'install'): RawLogLine {
return { logId, buildStepId, msg };
}
function stepMessages(logsState: LogsState): string[] {
return Array.from(logsState.getLogs().values()).flatMap(group =>
group.logLines.map(logLine => logLine.msg)
);
}
describe(LogsState, () => {
it('hides realtime lines until the file logs reach them', () => {
const logsState = new LogsState();
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 LogsState();
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 LogsState();
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 LogsState();
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 LogsState();
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 LogsState();
const keyless: RawLogLine = { 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 LogsState();
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 LogsState();
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 LogsState();
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 LogsState();
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,153 @@
import { RealtimeLogsTargetType } from '../../../graphql/generated';
import { RealtimeLogsClient } from '../../../utils/centrifuge';
import { LogSource, LogsWatcher } from '../watcher';
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++,
},
};
}
describe(LogsWatcher, () => {
let fetchRawLogLinesAsync: jest.Mock;
beforeEach(() => {
fetchRawLogLinesAsync = jest
.fn()
.mockResolvedValue([{ logId: '1', buildStepId: 'install', msg: 'from the file' }]);
});
function inProgressSource(overrides: Partial<LogSource> = {}): LogSource {
return {
key: 'job1',
realtimeTarget: { type: RealtimeLogsTargetType.JobRun, id: 'job-run-id' },
isInProgress: true,
fetchRawLogLinesAsync,
...overrides,
};
}
it('subscribes once per in-progress source', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
await watcher.syncAsync([inProgressSource()]);
expect(fake.subscribeCalls).toEqual([
{ target: { type: RealtimeLogsTargetType.JobRun, id: 'job-run-id' } },
]);
});
it('reports realtime logs as they arrive', async () => {
const fake = createFakeClient();
const onRealtimeLogs = jest.fn();
const watcher = new LogsWatcher(() => fake.client, onRealtimeLogs);
await watcher.syncAsync([inProgressSource()]);
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 LogsWatcher(() => fake.client, onRealtimeLogs);
await watcher.syncAsync([inProgressSource()]);
fake.publish([{ msg: 'no logId' }]);
expect(onRealtimeLogs).not.toHaveBeenCalled();
});
it('still fetches logs when the realtime logs client is unavailable', async () => {
const watcher = new LogsWatcher(
() => null,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(1);
});
it('fetches logs on every sync while in progress, and stops once the source is not', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
await watcher.syncAsync([inProgressSource()]);
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(2);
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(2);
});
it('does not fetch logs for a source that is not in progress', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
expect(fetchRawLogLinesAsync).not.toHaveBeenCalled();
});
it('closes the subscription when a source leaves in-progress', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
expect(fake.closeCount()).toBe(0);
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
expect(fake.closeCount()).toBe(1);
});
it('closes the client and its subscriptions', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
watcher.close();
expect(fake.closeCount()).toBe(2);
});
});
@@ -0,0 +1,25 @@
import { JobLogs, LogLine } from './types';
import { Choice } from '../../prompts';
export function choicesFromJobLogs(
logs: JobLogs
): (Choice & { name: string; status: string; logLines: LogLine[] | undefined })[] {
return Array.from(logs.values())
.map(({ key, label, result, logLines }) => ({
title: `${label} - ${result ?? ''}`,
name: label,
status: result ?? '',
value: key,
logLines,
}))
.filter(step => step.status !== 'skipped');
}
export function stepLogTail(
step: { logLines?: LogLine[] },
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);
}
@@ -0,0 +1,56 @@
import { JobLogs, RawLogLine } from './types';
import uniqBy from '../../utils/expodash/uniqBy';
export function parseLogLines(rawLogs: string): {
logLines: RawLogLine[];
errors: Error[];
} {
const logLines: RawLogLine[] = [];
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 RawLogLine>(...logLineGroups: T[][]): T[] {
return uniqBy(logLineGroups.flat().reverse(), logLine => logLine.logId ?? Symbol()).reverse();
}
export function groupLogLinesIntoSteps(
logLines: RawLogLine[],
accumulator: JobLogs = new Map()
): JobLogs {
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;
}
@@ -0,0 +1,94 @@
import { groupLogLinesIntoSteps, mergeLogLines } from './parseLogs';
import { JobLogs, RawLogLine } from './types';
type RealtimeLogLine = RawLogLine & { logId: string };
function isRealtimeLogLine(entry: unknown): entry is RealtimeLogLine {
return (
typeof entry === 'object' &&
entry !== null &&
typeof (entry as Partial<RawLogLine>).logId === 'string'
);
}
export class LogsState {
private fileLogIds = new Set<string>();
private realtimeLogLines: RealtimeLogLine[] = [];
private realtimeLogIds = new Set<string>();
private haveFileLogsCaughtUp = false;
private realtimeLogsRevealed = false;
private jobLogs: JobLogs = new Map();
public ingestFileLogLines(logLines: RawLogLine[]): 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.jobLogs = 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.jobLogs);
}
return true;
}
public markCompleted(): void {
this.revealRealtimeLogs();
}
public getLogs(): JobLogs {
return this.jobLogs;
}
private revealRealtimeLogs(): void {
if (this.realtimeLogsRevealed) {
return;
}
this.realtimeLogsRevealed = true;
groupLogLinesIntoSteps(this.realtimeLogLines, this.jobLogs);
}
}
@@ -0,0 +1,23 @@
export type RawLogLine = {
logId?: string;
buildStepId?: string;
buildStepDisplayName?: string;
phase?: string;
time?: string;
msg?: string;
result?: string;
marker?: string;
err?: any;
};
export type LogLine = Pick<RawLogLine, 'time' | 'result' | 'marker' | 'err'> &
Required<Pick<RawLogLine, 'msg'>>;
export type StepLogs = {
key: string;
label: string;
result?: string;
logLines: LogLine[];
};
export type JobLogs = Map<string, StepLogs>;
@@ -0,0 +1,102 @@
import { LogsState } from './state';
import { RawLogLine } from './types';
import { RealtimeLogsTargetInput } from '../../graphql/generated';
import Log from '../../log';
import { RealtimeLogsClient, RealtimeLogsSubscription } from '../../utils/centrifuge';
import nullthrows from 'nullthrows';
export type LogSource = {
key: string;
realtimeTarget: RealtimeLogsTargetInput | null;
isInProgress: boolean;
fetchRawLogLinesAsync: () => Promise<RawLogLine[] | null>;
};
type TrackedSource = {
logsState: LogsState;
subscription: RealtimeLogsSubscription | null;
};
export class LogsWatcher {
private readonly trackedSources = new Map<string, TrackedSource>();
private realtimeLogsClient?: RealtimeLogsClient | null;
constructor(
private readonly createRealtimeLogsClient: () => RealtimeLogsClient | null,
private readonly onRealtimeLogs: () => void
) {}
public async syncAsync(sources: LogSource[]): Promise<Map<string, LogsState>> {
await Promise.all(sources.map(source => this.syncSourceAsync(source)));
return new Map(
sources.map(source => [
source.key,
nullthrows(
this.trackedSources.get(source.key),
'source has to be in tracked sources after it was synced'
).logsState,
])
);
}
public close(): void {
for (const trackedSource of this.trackedSources.values()) {
trackedSource.subscription?.close();
trackedSource.subscription = null;
}
this.realtimeLogsClient?.close();
}
private async syncSourceAsync(source: LogSource): Promise<void> {
let trackedSource = this.trackedSources.get(source.key);
if (!trackedSource) {
trackedSource = { logsState: new LogsState(), subscription: null };
this.trackedSources.set(source.key, trackedSource);
}
if (!source.isInProgress) {
trackedSource.subscription?.close();
trackedSource.subscription = null;
return;
}
await Promise.all([
trackedSource.subscription ? Promise.resolve() : this.subscribeAsync(source, trackedSource),
this.fetchLogsAsync(source, trackedSource),
]);
}
private async fetchLogsAsync(source: LogSource, trackedSource: TrackedSource): Promise<void> {
try {
const logLines = await source.fetchRawLogLinesAsync();
if (logLines) {
trackedSource.logsState.ingestFileLogLines(logLines);
}
} catch (err: any) {
Log.debug(`Failed to fetch logs for job ${source.key}: ${err.message}`);
}
}
private getRealtimeLogsClient(): RealtimeLogsClient | null {
if (this.realtimeLogsClient === undefined) {
this.realtimeLogsClient = this.createRealtimeLogsClient();
}
return this.realtimeLogsClient;
}
private async subscribeAsync(source: LogSource, trackedSource: TrackedSource): Promise<void> {
const target = source.realtimeTarget;
if (!target) {
return;
}
const realtimeLogsClient = this.getRealtimeLogsClient();
if (!realtimeLogsClient) {
return;
}
trackedSource.subscription = await realtimeLogsClient.subscribeAsync({ target }, data => {
if (trackedSource.logsState.ingestRealtimeLogLines(data)) {
this.onRealtimeLogs();
}
});
}
}
@@ -1 +0,0 @@
@AGENTS.md
@@ -1,12 +1,19 @@
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import {
SupabaseAdvisorLintData,
SupabaseAdvisorLintLevel,
SupabaseAdvisorType,
SupabaseConnectionData,
SupabaseOrganizationData,
SupabaseProjectData,
} from '../graphql/types/SupabaseConnection';
import Log, { link } from '../log';
export const SUPABASE_REAUTHORIZATION_REQUIRED_ERROR_CODE =
'SUPABASE_REAUTHORIZATION_REQUIRED_ERROR';
export function getSupabaseProjectDashboardUrl(
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>
): string {
@@ -94,3 +101,72 @@ export function formatSupabaseProject(project: SupabaseProjectData): string {
export function logNoSupabaseProject(projectName: string): void {
Log.warn(`No Supabase project is linked to Expo app ${chalk.bold(projectName)} on EAS.`);
}
export function getSupabaseAdvisorsDashboardUrl(
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>,
type: SupabaseAdvisorType
): string {
return `${getSupabaseProjectDashboardUrl(project)}/advisors/${type.toLowerCase()}`;
}
export function isSupabaseReauthorizationRequiredError(error: unknown): boolean {
return (
error instanceof CombinedError &&
error.graphQLErrors.some(
graphQLError =>
graphQLError.extensions?.errorCode === SUPABASE_REAUTHORIZATION_REQUIRED_ERROR_CODE
)
);
}
const ADVISOR_LINT_LEVEL_LABELS: Record<
SupabaseAdvisorLintLevel,
[singular: string, plural: string]
> = {
[SupabaseAdvisorLintLevel.Error]: ['error', 'errors'],
[SupabaseAdvisorLintLevel.Warn]: ['warning', 'warnings'],
[SupabaseAdvisorLintLevel.Info]: ['info', 'info'],
};
const ADVISOR_LINT_LEVEL_MARKERS: Record<SupabaseAdvisorLintLevel, string> = {
[SupabaseAdvisorLintLevel.Error]: chalk.red('✖'),
[SupabaseAdvisorLintLevel.Warn]: chalk.yellow('▲'),
[SupabaseAdvisorLintLevel.Info]: chalk.blue('●'),
};
export function summarizeSupabaseAdvisorLints(lints: readonly SupabaseAdvisorLintData[]): string {
if (lints.length === 0) {
return 'No unresolved findings';
}
return (Object.keys(ADVISOR_LINT_LEVEL_LABELS) as SupabaseAdvisorLintLevel[])
.map(level => [level, lints.filter(lint => lint.level === level).length] as const)
.filter(([, count]) => count > 0)
.map(([level, count]) => `${count} ${ADVISOR_LINT_LEVEL_LABELS[level][count === 1 ? 0 : 1]}`)
.join(', ');
}
function formatInlineCode(text: string): string {
return text
.replaceAll('\\`', '`')
.split('`')
.map((part, index) => (index % 2 === 1 ? chalk.cyan(part) : part))
.join('');
}
export function formatSupabaseAdvisorLints(
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>,
type: SupabaseAdvisorType,
lints: readonly SupabaseAdvisorLintData[]
): string {
const heading = `${chalk.bold(type === SupabaseAdvisorType.Security ? 'Security' : 'Performance')} · ${summarizeSupabaseAdvisorLints(lints)}`;
const dashboardUrl = getSupabaseAdvisorsDashboardUrl(project, type);
const rows = lints.map(lint =>
[
` ${ADVISOR_LINT_LEVEL_MARKERS[lint.level]} ${lint.level === SupabaseAdvisorLintLevel.Warn ? 'WARNING' : lint.level} ${chalk.bold(lint.title)}`,
...(lint.entity ? [` ${chalk.dim(lint.entity)}`] : []),
` ${formatInlineCode(lint.detail)}`,
` ${link(`${dashboardUrl}?id=${encodeURIComponent(lint.cacheKey)}`, { text: chalk.underline('View in Supabase ↗'), dim: false })}`,
].join('\n')
);
return [heading, ...rows].join('\n\n');
}
@@ -1,9 +1,9 @@
import { getMockWorkflowCustomJobFragment } from '../../../../__tests__/commands/utils';
import { BuildQuery } from '../../../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../../../context/contextUtils/createGraphqlClient';
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');
jest.mock('../../../graphql/queries/BuildQuery');
describe(fetchRawLogsForJobAsync, () => {
afterEach(() => {
@@ -0,0 +1,108 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
import {
RealtimeLogsTargetType,
WorkflowJobStatus,
WorkflowJobType,
} from '../../../graphql/generated';
import { fetchRawLogsForJobAsync } from '../fetchLogs';
import { logSourceForWorkflowJob } from '../logs';
import { WorkflowJobResult } from '../types';
jest.mock('../fetchLogs');
function inProgressJob(): WorkflowJobResult {
const job = getMockWorkflowRunWithJobsFragment().jobs[0];
return { ...job, status: WorkflowJobStatus.InProgress };
}
describe(logSourceForWorkflowJob, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('keys the source by the job id', () => {
expect(logSourceForWorkflowJob({} as any, inProgressJob()).key).toBe('job1');
});
it('is in progress only while the job is in progress', () => {
const job = inProgressJob();
expect(logSourceForWorkflowJob({} as any, job).isInProgress).toBe(true);
for (const status of [
WorkflowJobStatus.New,
WorkflowJobStatus.ActionRequired,
WorkflowJobStatus.PendingCancel,
WorkflowJobStatus.Success,
WorkflowJobStatus.Failure,
WorkflowJobStatus.Canceled,
WorkflowJobStatus.Skipped,
]) {
expect(logSourceForWorkflowJob({} as any, { ...job, status }).isInProgress).toBe(false);
}
});
it('targets the turtle job run when there is one', () => {
const job = { ...inProgressJob(), turtleJobRun: { id: 'job-run-id' } } as WorkflowJobResult;
expect(logSourceForWorkflowJob({} as any, job).realtimeTarget).toEqual({
type: RealtimeLogsTargetType.JobRun,
id: 'job-run-id',
});
});
it('falls back to the build when there is no turtle job run', () => {
const withTurtleBuild = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: { id: 'build-id' },
} as WorkflowJobResult;
const withBuildOutput = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: null,
outputs: { build_id: 'output-build-id' },
} as WorkflowJobResult;
expect(logSourceForWorkflowJob({} as any, withTurtleBuild).realtimeTarget).toEqual({
type: RealtimeLogsTargetType.Build,
id: 'build-id',
});
expect(logSourceForWorkflowJob({} as any, withBuildOutput).realtimeTarget).toEqual({
type: RealtimeLogsTargetType.Build,
id: 'output-build-id',
});
});
it('has no realtime target when neither is present', () => {
const job = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: null,
outputs: null,
} as WorkflowJobResult;
expect(logSourceForWorkflowJob({} as any, job).realtimeTarget).toBeNull();
});
it('fetches and parses the job logs with the graphql client it was given', async () => {
const graphqlClient = {} as any;
const job = { ...inProgressJob(), type: WorkflowJobType.Build } as WorkflowJobResult;
jest
.mocked(fetchRawLogsForJobAsync)
.mockResolvedValue(
[
'{"logId":"1","buildStepId":"install","msg":"npm ci"}',
'{"logId":"2","buildStepId":"install","msg":"done"}',
].join('\n')
);
await expect(
logSourceForWorkflowJob(graphqlClient, job).fetchRawLogLinesAsync()
).resolves.toEqual([
{ logId: '1', buildStepId: 'install', msg: 'npm ci' },
{ logId: '2', buildStepId: 'install', msg: 'done' },
]);
expect(fetchRawLogsForJobAsync).toHaveBeenCalledWith({ graphqlClient }, job);
});
});
@@ -1,15 +1,15 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
import { WorkflowJobStatus } from '../../../graphql/generated';
import { groupLogLinesIntoSteps, parseLogLines } from '../logs/parseLogs';
import { WorkflowLogs, WorkflowRawLogLine } from '../types';
import { groupLogLinesIntoSteps, parseLogLines } from '../../logs/parseLogs';
import { JobLogs, RawLogLine } from '../../logs/types';
import { formatActiveWorkflowRun, formatFailedWorkflowRun } from '../utils';
function jobWithLogs(
logLines: WorkflowRawLogLine[],
logLines: RawLogLine[],
status: WorkflowJobStatus = WorkflowJobStatus.InProgress
): {
job: ReturnType<typeof getMockWorkflowRunWithJobsFragment>['jobs'][number];
logs: WorkflowLogs;
logs: JobLogs;
} {
return {
job: { ...getMockWorkflowRunWithJobsFragment().jobs[0], status },
@@ -17,7 +17,7 @@ function jobWithLogs(
};
}
function stepLines(buildStepId: string, count: number): WorkflowRawLogLine[] {
function stepLines(buildStepId: string, count: number): RawLogLine[] {
return Array.from({ length: count }, (_, index) => ({ buildStepId, msg: `line${index}` }));
}
@@ -0,0 +1,62 @@
import { validateWorkflowStructure } from '../validation';
const workflowSchema = {
type: 'object',
properties: {
jobs: {
type: 'object',
additionalProperties: {
anyOf: [
{
type: 'object',
properties: {
type: { type: 'string', const: 'slack' },
params: {
type: 'object',
properties: {
webhook_url: { type: 'string', format: 'uri' },
message: { type: 'string' },
},
required: ['webhook_url', 'message'],
additionalProperties: false,
},
},
required: ['type', 'params'],
additionalProperties: false,
},
],
},
},
},
required: ['jobs'],
additionalProperties: false,
};
const workflowWithWebhookUrl = (webhookUrl: string): object => ({
jobs: {
notify: {
type: 'slack',
params: {
webhook_url: webhookUrl,
message: 'Build finished',
},
},
},
});
describe(validateWorkflowStructure, () => {
it('allows interpolated values for URI fields', () => {
expect(() => {
validateWorkflowStructure(
workflowWithWebhookUrl('${{ env.SLACK_WEBHOOK_URL }}'),
workflowSchema
);
}).not.toThrow();
});
it('still rejects invalid literal values for URI fields', () => {
expect(() => {
validateWorkflowStructure(workflowWithWebhookUrl('not a URL'), workflowSchema);
}).toThrow('must be a valid URI string');
});
});
@@ -1,6 +1,6 @@
import { WorkflowJobResult } from '../types';
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
import { WorkflowJobResult } from './types';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
export async function fetchRawLogsForJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
@@ -0,0 +1,59 @@
import { fetchRawLogsForJobAsync } from './fetchLogs';
import { WorkflowJobResult } from './types';
import {
RealtimeLogsTargetInput,
RealtimeLogsTargetType,
WorkflowJobStatus,
} from '../../graphql/generated';
import Log from '../../log';
import { groupLogLinesIntoSteps, parseLogLines } from '../logs/parseLogs';
import { JobLogs, RawLogLine } from '../logs/types';
import { LogSource } from '../logs/watcher';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
async function fetchAndParseLogsFromJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<RawLogLine[] | 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<JobLogs | null> {
const logLines = await fetchAndParseLogsFromJobAsync(state, job);
return logLines && groupLogLinesIntoSteps(logLines);
}
export function logSourceForWorkflowJob(
graphqlClient: ExpoGraphqlClient,
job: WorkflowJobResult
): LogSource {
return {
key: job.id,
realtimeTarget: realtimeLogsTargetForJob(job),
isInProgress: job.status === WorkflowJobStatus.InProgress,
fetchRawLogLinesAsync: async () => await fetchAndParseLogsFromJobAsync({ graphqlClient }, job),
};
}
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;
}
@@ -1,308 +0,0 @@
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']);
});
});
@@ -1,83 +0,0 @@
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);
}
@@ -1,210 +0,0 @@
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,13 +1,10 @@
import { Choice } from 'prompts';
import { WorkflowJobResult, WorkflowLogs } from './types';
import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs';
import {
choiceFromWorkflowJob,
choiceFromWorkflowRun,
choicesFromWorkflowLogs,
processWorkflowRuns,
} from './utils';
import { WorkflowJobResult } from './types';
import { fetchAndProcessLogsFromJobAsync } from './logs';
import { choicesFromJobLogs } from '../logs/format';
import { JobLogs } from '../logs/types';
import { choiceFromWorkflowJob, choiceFromWorkflowRun, processWorkflowRuns } from './utils';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
@@ -58,7 +55,7 @@ export type WorkflowCommandSelectionState = {
jobId?: string;
step?: string;
job?: WorkflowJobResult;
logs?: WorkflowLogs | null;
logs?: JobLogs | null;
message?: string;
};
@@ -140,7 +137,7 @@ export function moveToWorkflowSelectionFinishedState(
previousState: WorkflowCommandSelectionState,
params: {
step: string;
logs: WorkflowLogs;
logs: JobLogs;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
@@ -276,7 +273,7 @@ export const workflowStepSelectionAction: WorkflowCommandSelectionAction = async
return moveToWorkflowSelectionFinishedState(prevState, { step: '', logs });
}
const choices: Choice[] = [
...choicesFromWorkflowLogs(logs),
...choicesFromJobLogs(logs),
{
title: 'Go back and select a different workflow job',
value: 'go-back',
@@ -41,27 +41,3 @@ export type WorkflowRunWithJobsResult = WorkflowRunResult & {
jobs: WorkflowJobResult[];
logs?: string;
};
export type WorkflowRawLogLine = {
logId?: string;
buildStepId?: string;
buildStepDisplayName?: string;
phase?: string;
time?: string;
msg?: string;
result?: string;
marker?: string;
err?: any;
};
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,14 +1,8 @@
import chalk from 'chalk';
import * as fs from 'node:fs';
import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs';
import {
WorkflowJobResult,
WorkflowLogLine,
WorkflowLogs,
WorkflowRunResult,
WorkflowTriggerType,
} from './types';
import { fetchAndProcessLogsFromJobAsync, logSourceForWorkflowJob } from './logs';
import { WorkflowJobResult, WorkflowRunResult, WorkflowTriggerType } from './types';
import {
WorkflowJobStatus,
WorkflowRunByIdQuery,
@@ -17,7 +11,9 @@ import {
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
import { WorkflowRunLogsWatcher } from './logs/watcher';
import { choicesFromJobLogs, stepLogTail } from '../logs/format';
import { JobLogs } from '../logs/types';
import { LogsWatcher } from '../logs/watcher';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { ora, updateSpinnerText } from '../../ora';
@@ -83,20 +79,6 @@ export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Ch
};
}
export function choicesFromWorkflowLogs(
logs: WorkflowLogs
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
return Array.from(logs.values())
.map(({ key, label, result, logLines }) => ({
title: `${label} - ${result ?? ''}`,
name: label,
status: result ?? '',
value: key,
logLines,
}))
.filter(step => step.status !== 'skipped');
}
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
return runs.map(run => {
const finishedAt = run.status === WorkflowRunStatus.InProgress ? null : run.updatedAt;
@@ -157,18 +139,9 @@ type WorkflowRunWithJobs = WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'];
type JobWithLogs = {
job: WorkflowRunWithJobs['jobs'][number];
logs: WorkflowLogs;
logs: JobLogs;
};
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: WorkflowRunWithJobs
@@ -195,7 +168,7 @@ export function formatActiveWorkflowRun(
if (job.status !== WorkflowJobStatus.InProgress) {
continue;
}
const steps = choicesFromWorkflowLogs(logs);
const steps = choicesFromJobLogs(logs);
if (steps.length === 0) {
continue;
}
@@ -222,7 +195,7 @@ export function formatFailedWorkflowRun(
for (const { job, logs } of jobLogs) {
statusValues.push({ label: '', value: '' });
statusValues.push({ label: ' Failed job', value: job.name });
const steps = choicesFromWorkflowLogs(logs);
const steps = choicesFromJobLogs(logs);
const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed');
if (!failedStep) {
continue;
@@ -290,8 +263,7 @@ export async function showWorkflowStatusAsync(
prefixText: chalk`{bold.yellow Workflow run is waiting to start:}`,
});
const watcher = new WorkflowRunLogsWatcher(
graphqlClient,
const watcher = new LogsWatcher(
() => createRealtimeLogsClient(graphqlClient),
() => {
renderActiveWorkflowRun();
@@ -316,12 +288,14 @@ export async function showWorkflowStatusAsync(
updateSpinnerText(spinner, {
prefixText: chalk`{bold.green Workflow run is in progress:}`,
});
const logsStates = await watcher.syncJobsAsync(workflowRun.jobs);
const logsStates = await watcher.syncAsync(
workflowRun.jobs.map(job => logSourceForWorkflowJob(graphqlClient, job))
);
for (const job of workflowRun.jobs) {
if (isJobCompleted(job.status)) {
nullthrows(
logsStates.get(job.id),
'syncJobsAsync must have been called before markCompleted'
'syncAsync must have been called before markCompleted'
).markCompleted();
}
}
@@ -331,7 +305,7 @@ export async function showWorkflowStatusAsync(
job,
logs: nullthrows(
logsStates.get(job.id),
'syncJobsAsync must have been called before getLogs'
'syncAsync must have been called before getLogs'
).getLogs(),
}))
);
@@ -2,6 +2,7 @@ import { InvalidEasJsonError, MissingEasJsonError } from '@expo/eas-json/build/e
import { CombinedError } from '@urql/core';
import { promises as fs } from 'fs';
import path from 'path';
import addFormats from 'ajv-formats';
import * as YAML from 'yaml';
import { validateWorkflowLocalCompositeFunctionsAsync } from './compositeFunctions';
@@ -15,9 +16,10 @@ import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
import { parsedYamlFromWorkflowContents } from './parse';
const jobTypesWithBuildProfile = new Set(['build', 'repack']);
const validateUri = addFormats.get('uri') as (value: string) => boolean;
const buildProfileIsInterpolated = (profileName: string): boolean => {
return profileName.includes('${{') && profileName.includes('}}');
const stringIsInterpolated = (value: string): boolean => {
return value.includes('${{') && value.includes('}}');
};
export async function validateWorkflowFileAsync(
@@ -113,7 +115,7 @@ async function validateWorkflowBuildJobsAsync(parsedYaml: any, projectDir: strin
job =>
!buildProfileNames.has(job.value.params.profile) &&
// If a profile name is interpolated, we can't check if it's valid until the workflow actually runs
!buildProfileIsInterpolated(job.value.params.profile)
!stringIsInterpolated(job.value.params.profile)
);
if (invalidBuildJobs.length > 0) {
@@ -139,10 +141,12 @@ function validateWorkflowJobTypes(parsedYaml: any, workflowJsonSchema: any): voi
}
}
function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): void {
export function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): void {
delete workflowJsonSchema['$schema'];
const ajv = createValidator();
// Interpolated values cannot be format-checked until the workflow runs.
ajv.addFormat('uri', value => stringIsInterpolated(value) || validateUri(value));
const validate = ajv.compile(workflowJsonSchema);
const result = validate(parsedYaml);
@@ -62,12 +62,24 @@ export default class AccountView extends EasCommand {
return 'Admin';
case Role.Developer:
return 'Developer';
case Role.ReleaseManager:
return 'Release Manager';
case Role.ViewOnly:
return 'Viewer';
case Role.Custom:
case Role.HasAdmin:
case Role.NotAdmin:
return 'Custom';
// A role added to the API after this version of the CLI was published. TypeScript narrows
// `role` to `never` here, but the server can still send one, so render its name rather than
// returning undefined.
default: {
const unknownRole: string = role;
return unknownRole
.split('_')
.map(word => word.charAt(0) + word.slice(1).toLowerCase())
.join(' ');
}
}
}
}
+22 -18
View File
@@ -19,6 +19,7 @@ import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExist
import { ensureTestFlightGroupExistsAsync } from '../credentials/ios/appstore/ensureTestFlightGroup';
import { Target } from '../credentials/ios/types';
import {
AccountFragment,
WorkflowJobStatus,
WorkflowProjectSourceType,
WorkflowRunStatus,
@@ -34,7 +35,6 @@ import { detectProjectSdkVersionAsync } from '../project/detectProjectSdkVersion
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../user/actions';
import { Actor, getActorDisplayName } from '../user/User';
import { sleepAsync } from '../utils/promise';
import { Client } from '../vcs/vcs';
@@ -43,6 +43,7 @@ import {
INVALID_BUNDLE_IDENTIFIER_MESSAGE,
isBundleIdentifierValid,
} from '../project/ios/bundleIdentifier';
import { UserQuery } from '../graphql/queries/UserQuery';
function deriveBundleIdSlug(bundleId: string): string {
return bundleId.split('.').filter(Boolean).pop()!;
@@ -142,8 +143,11 @@ export default class Go extends EasCommand {
} = await this.getContextAsync(Go, {
nonInteractive: false,
});
Log.withTick(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
const userPrimaryAccount = await UserQuery.requireCurrentUserPrimaryAccountAsync(graphqlClient);
const detectedSdkVersion = await detectProjectSdkVersionAsync(process.cwd());
if (detectedSdkVersion && !flags['sdk-version']) {
Log.log(
@@ -154,7 +158,7 @@ export default class Go extends EasCommand {
if (!sdkVersion) {
({ sdkVersion } = await this.selectSdkVersionAsync(graphqlClient));
}
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
const bundleId = flags['bundle-id'] ?? this.generateBundleId(userPrimaryAccount);
if (!isBundleIdentifierValid(bundleId)) {
throw new Error(
`"${bundleId}" is not a valid iOS bundle identifier. ${INVALID_BUNDLE_IDENTIFIER_MESSAGE} Pass a valid identifier with --bundle-id.`
@@ -167,7 +171,7 @@ export default class Go extends EasCommand {
let projectId: string;
try {
projectId = await withSuppressedOutputAsync(() =>
this.ensureEasProjectAsync(graphqlClient, actor, slug)
this.ensureEasProjectAsync(graphqlClient, userPrimaryAccount, slug)
);
} catch (error) {
setupSpinner.fail();
@@ -202,7 +206,7 @@ export default class Go extends EasCommand {
} = await this.dispatchWorkflowAsync(
graphqlClient,
projectId,
actor,
userPrimaryAccount,
bundleId,
appName,
ascApp.id,
@@ -275,8 +279,8 @@ export default class Go extends EasCommand {
};
}
private generateBundleId(actor: Actor): string {
const username = ensureActorHasPrimaryAccount(actor).name;
private generateBundleId(userPrimaryAccount: AccountFragment): string {
const username = userPrimaryAccount.name;
const sanitizedUsername = username
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
@@ -287,14 +291,12 @@ export default class Go extends EasCommand {
private async ensureEasProjectAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
userPrimaryAccount: AccountFragment,
slug: string
): Promise<string> {
const account = ensureActorHasPrimaryAccount(actor);
const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
account.name,
userPrimaryAccount.name,
slug
);
@@ -303,7 +305,7 @@ export default class Go extends EasCommand {
}
return await AppMutation.createAppAsync(graphqlClient, {
accountId: account.id,
accountId: userPrimaryAccount.id,
projectName: slug,
});
}
@@ -394,7 +396,7 @@ export default class Go extends EasCommand {
private async dispatchWorkflowAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
actor: Actor,
userPrimaryAccount: AccountFragment,
bundleId: string,
appName: string,
ascAppId: string,
@@ -402,8 +404,6 @@ export default class Go extends EasCommand {
tmpDir: string,
vcsClient: Client
): Promise<{ workflowUrl: string; workflowRunId: string; sdkVersion: string }> {
const account = ensureActorHasPrimaryAccount(actor);
const repackConfig = await WorkflowRunQuery.expoGoRepackConfigurationAsync(graphqlClient, {
appId: projectId,
ascAppId,
@@ -421,17 +421,17 @@ export default class Go extends EasCommand {
const { projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
graphqlClient,
vcsClient,
accountId: account.id,
accountId: userPrimaryAccount.id,
});
const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
accountId: userPrimaryAccount.id,
filePath: path.join(tmpDir, 'eas.json'),
maxSizeBytes: 1024 * 1024,
});
const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
accountId: userPrimaryAccount.id,
filePath: path.join(tmpDir, 'package.json'),
maxSizeBytes: 1024 * 1024,
});
@@ -449,7 +449,11 @@ export default class Go extends EasCommand {
},
});
const workflowUrl = getWorkflowRunUrl(account.name, deriveBundleIdSlug(bundleId), result.id);
const workflowUrl = getWorkflowRunUrl(
userPrimaryAccount.name,
deriveBundleIdSlug(bundleId),
result.id
);
return { workflowUrl, workflowRunId: result.id, sdkVersion: repackConfig.sdkVersion };
}
@@ -101,6 +101,7 @@ describe(IntegrationsAscConnect, () => {
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
issuerIdentifier: 'FAKEISSUER',
},
] as any);
@@ -139,6 +140,7 @@ describe(IntegrationsAscConnect, () => {
{
id: 'eas-key-uuid',
keyIdentifier: 'FAKEKEY000',
issuerIdentifier: 'FAKEISSUER',
},
] as any);
@@ -207,6 +209,7 @@ describe(IntegrationsAscConnect, () => {
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
issuerIdentifier: 'FAKEISSUER',
},
] as any);
@@ -234,6 +237,7 @@ describe(IntegrationsAscConnect, () => {
{
id: 'eas-key-uuid',
keyIdentifier: 'FAKEKEY000',
issuerIdentifier: 'FAKEISSUER',
},
] as any);
@@ -255,6 +259,35 @@ describe(IntegrationsAscConnect, () => {
);
});
it('fails when --api-key-id points at an individual key', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
issuerIdentifier: null,
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow('is an individual key');
expect(AscAppLinkQuery.discoverAccessibleAppsAsync).not.toHaveBeenCalled();
});
it('fails when multiple keys match Apple key identifier', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
@@ -263,10 +296,12 @@ describe(IntegrationsAscConnect, () => {
{
id: 'eas-key-uuid-1',
keyIdentifier: 'FAKEKEY000',
issuerIdentifier: 'FAKEISSUER',
},
{
id: 'eas-key-uuid-2',
keyIdentifier: 'FAKEKEY000',
issuerIdentifier: 'FAKEISSUER',
},
] as any);
@@ -297,6 +332,7 @@ describe(IntegrationsAscConnect, () => {
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
issuerIdentifier: 'FAKEISSUER',
},
] as any);
@@ -329,6 +365,7 @@ describe(IntegrationsAscConnect, () => {
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
issuerIdentifier: 'FAKEISSUER',
},
] as any);
@@ -113,6 +113,11 @@ export default class IntegrationsAscConnect extends EasCommand {
`Multiple App Store Connect API keys match Apple key identifier "${apiKeyId}".`
);
} else if (keysByAppleId.length === 1) {
if (!keysByAppleId[0].issuerIdentifier) {
throw new EasCommandError(
`App Store Connect API key "${apiKeyId}" is an individual key (it has no Issuer ID). Individual keys are not supported for the App Store Connect connection. Use a team API key.`
);
}
apiKeyId = keysByAppleId[0].id;
} else {
throw new EasCommandError(
@@ -9,7 +9,6 @@ import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
Role,
} from '../../../../graphql/generated';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
@@ -56,12 +55,6 @@ describe(IntegrationsConvexConnect, () => {
email: '[email protected]',
featureGates: {},
isExpoAdmin: false,
primaryAccount: {
id: testAccountId,
name: testAccountName,
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
accounts: [],
};
@@ -62,12 +62,6 @@ describe(IntegrationsPostHogConnect, () => {
email: '[email protected]',
featureGates: {},
isExpoAdmin: false,
primaryAccount: {
id: testAccountId,
name: testAccountName,
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
accounts: [],
};
@@ -0,0 +1,337 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import stripAnsi from 'strip-ansi';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../../../../commandUtils/errors';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { SupabaseMutation } from '../../../../graphql/mutations/SupabaseMutation';
import { SupabaseQuery } from '../../../../graphql/queries/SupabaseQuery';
import {
SupabaseAdvisorLintData,
SupabaseAdvisorLintLevel,
SupabaseAdvisorLintsData,
SupabaseAdvisorType,
} from '../../../../graphql/types/SupabaseConnection';
import { authorizeViaBrowserAsync } from '../../../../integrations/supabase/provision';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import Log from '../../../../log';
import { ora } from '../../../../ora';
import { confirmAsync } from '../../../../prompts';
import { printJsonOnlyOutput } from '../../../../utils/json';
import IntegrationsSupabaseAdvisors from '../advisors';
jest.mock('supports-hyperlinks', () => ({ stdout: false, stderr: false }));
jest.mock('../../../../graphql/queries/SupabaseQuery');
jest.mock('../../../../graphql/mutations/SupabaseMutation');
jest.mock('../../../../log', () => {
const actual = jest.requireActual('../../../../log');
return {
__esModule: true,
...actual,
default: { ...actual.default, log: jest.fn(), warn: jest.fn(), newLine: jest.fn() },
};
});
jest.mock('../../../../utils/json');
jest.mock('../../../../prompts');
jest.mock('../../../../integrations/supabase/provision');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../ora', () => ({
ora: jest.fn(() => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
stop: jest.fn().mockReturnThis(),
})),
}));
describe(IntegrationsSupabaseAdvisors, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const runCommand = jest.fn();
Object.assign(mockConfig, { runCommand });
const rlsLint: SupabaseAdvisorLintData = {
name: 'rls_disabled_in_public',
title: 'RLS Disabled in Public',
level: SupabaseAdvisorLintLevel.Error,
description: 'Detects cases where row level security (RLS) has not been enabled on a table.',
detail: 'Table \\`public.todos\\` is public, but RLS has not been enabled.',
entity: 'public.todos',
remediation: 'https://supabase.com/docs/guides/database/database-linter?lint=0013',
cacheKey: 'rls_disabled_in_public_public_todos',
};
const unindexedForeignKeyLint: SupabaseAdvisorLintData = {
name: 'unindexed_foreign_keys',
title: 'Unindexed foreign keys',
level: SupabaseAdvisorLintLevel.Info,
description: 'Identifies foreign key constraints without a covering index.',
detail: 'Table `public.todos` has a foreign key without a covering index.',
entity: 'public.todos',
remediation: null,
cacheKey: 'unindexed_foreign_keys_public_todos',
};
const mockResult: SupabaseAdvisorLintsData = {
project: {
id: 'project-1',
supabaseProjectRef: 'abcdefghijklmnop',
supabaseProjectName: 'Test App',
supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co',
supabaseRegion: 'us-east-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
security: [rlsLint],
performance: [unindexedForeignKeyLint],
};
function createCommand(argv: string[]): IntegrationsSupabaseAdvisors {
const command = new IntegrationsSupabaseAdvisors(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { slug: 'testapp' },
},
loggedIn: { graphqlClient },
} as never);
return command;
}
function loggedOutput(): string {
return jest
.mocked(Log.log)
.mock.calls.map(([line]) => stripAnsi(String(line)))
.join('\n');
}
beforeEach(() => {
jest.clearAllMocks();
jest.requireMock('supports-hyperlinks').stdout = false;
jest
.mocked(authorizeViaBrowserAsync)
.mockResolvedValue({ supabaseOrganizationSlug: 'original-org' } as never);
jest
.mocked(getOwnerAccountForProjectIdAsync)
.mockResolvedValue({ id: 'account-1', name: 'test' } as never);
jest.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).mockResolvedValue({
updatedAt: '2024-01-01',
supabaseOrganizationSlug: 'original-org',
} as never);
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(mockResult.project);
jest.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).mockResolvedValue(mockResult);
});
function mockReauthorizationRequiredOnce(): void {
jest.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).mockRejectedValueOnce(
new CombinedError({
graphQLErrors: [
new GraphQLError('Expo is not allowed to read this Supabase project’s advisors.', {
extensions: { errorCode: 'SUPABASE_REAUTHORIZATION_REQUIRED_ERROR', errorType: 'USER' },
}),
],
})
);
}
it('prints both advisors with severity summaries, details, and dashboard links', async () => {
await createCommand([]).runAsync();
expect(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).toHaveBeenCalledWith(
graphqlClient,
testProjectId,
[SupabaseAdvisorType.Security, SupabaseAdvisorType.Performance]
);
const output = loggedOutput();
expect(output).toContain('Security · 1 error');
expect(output).toContain('View in Supabase ↗: https://supabase.com/dashboard/project/');
expect(output).not.toContain('How to fix');
expect(output).toContain('Table public.todos is public, but RLS has not been enabled.');
expect(output).not.toContain(rlsLint.remediation);
expect(output).toContain(
'https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/security?id=rls_disabled_in_public_public_todos'
);
expect(output).toContain('Performance · 1 info');
expect(output).toContain(
'https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/performance'
);
});
it('links below issue descriptions without visible URLs when hyperlinks are supported', async () => {
jest.requireMock('supports-hyperlinks').stdout = true;
await createCommand([]).runAsync();
const rawOutput = jest
.mocked(Log.log)
.mock.calls.map(([line]) => String(line))
.join('\n');
const output = loggedOutput();
expect(rawOutput).toContain(
']8;;https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/security?id=rls_disabled_in_public_public_todos'
);
expect(rawOutput).not.toContain(rlsLint.remediation);
expect(output).toContain('✖ ERROR RLS Disabled in Public\n public.todos');
expect(output).not.toContain('How to fix');
expect(output).toContain(
'✖ ERROR RLS Disabled in Public\n public.todos\n Table public.todos is public, but RLS has not been enabled.\n View in Supabase ↗'
);
expect(output).not.toContain('RLS Disabled in Public ↗');
expect(output.split('\n').filter(line => line.includes('https://'))).toEqual([
'Dashboard: https://supabase.com/dashboard/project/abcdefghijklmnop',
]);
expect(output).toMatch(
/^Dashboard: https:\/\/supabase.com\/dashboard\/project\/abcdefghijklmnop\nSecurity/
);
});
it('separates multiple findings and keeps clean sections compact', async () => {
jest.requireMock('supports-hyperlinks').stdout = true;
jest.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).mockResolvedValue({
...mockResult,
security: [rlsLint, { ...unindexedForeignKeyLint, level: SupabaseAdvisorLintLevel.Warn }],
performance: [],
});
await createCommand([]).runAsync();
const output = loggedOutput();
expect(output).toContain('Security · 1 error, 1 warning');
expect(output).toContain('View in Supabase ↗\n\n ▲ WARNING Unindexed foreign keys');
expect(output).toContain('Performance · No unresolved findings');
expect(output).toMatch(
/^Dashboard: https:\/\/supabase.com\/dashboard\/project\/abcdefghijklmnop\nSecurity/
);
});
it('limits the output to one advisor with --type', async () => {
await createCommand(['--type', 'security']).runAsync();
const output = loggedOutput();
expect(output).toContain('Security · 1 error');
expect(output).not.toContain('Performance');
expect(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).toHaveBeenCalledWith(
graphqlClient,
testProjectId,
[SupabaseAdvisorType.Security]
);
});
it.each(['--json', '--non-interactive'])('does not start a spinner with %s', async flag => {
await createCommand([flag]).runAsync();
expect(ora).not.toHaveBeenCalled();
});
it('rejects an unsupported advisor type before making requests', async () => {
await expect(createCommand(['--type', 'other']).runAsync()).rejects.toThrow();
expect(SupabaseQuery.getSupabaseProjectByAppIdAsync).not.toHaveBeenCalled();
});
it('prints structured findings with --json', async () => {
await createCommand(['--json']).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
project: {
ref: 'abcdefghijklmnop',
name: 'Test App',
dashboardUrls: {
security: 'https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/security',
performance:
'https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/performance',
},
},
security: [rlsLint],
performance: [unindexedForeignKeyLint],
});
expect(Log.log).not.toHaveBeenCalled();
});
it('warns when no Supabase project is linked', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Supabase project'));
});
it('offers to re-authorize, refreshes OAuth in place, and retries when accepted', async () => {
mockReauthorizationRequiredOnce();
jest.mocked(confirmAsync).mockResolvedValue(true);
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('Re-authorize Supabase') })
);
expect(authorizeViaBrowserAsync).toHaveBeenCalledWith(
graphqlClient,
{ id: 'account-1', name: 'test' },
false,
'2024-01-01'
);
expect(runCommand).not.toHaveBeenCalled();
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
expect(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).toHaveBeenCalledTimes(2);
expect(loggedOutput()).toContain('Security · 1 error');
});
it('preserves the selected organization when OAuth defaults to a different one', async () => {
mockReauthorizationRequiredOnce();
jest.mocked(confirmAsync).mockResolvedValue(true);
jest
.mocked(authorizeViaBrowserAsync)
.mockResolvedValue({ id: 'connection-1', supabaseOrganizationSlug: 'other-org' } as never);
await createCommand([]).runAsync();
expect(SupabaseMutation.setSupabaseConnectionOrganizationAsync).toHaveBeenCalledWith(
graphqlClient,
{
supabaseConnectionId: 'connection-1',
organizationSlug: 'original-org',
}
);
});
it('warns and returns when the user declines to re-authorize', async () => {
mockReauthorizationRequiredOnce();
jest.mocked(confirmAsync).mockResolvedValue(false);
await expect(createCommand([]).runAsync()).resolves.toBeUndefined();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('eas integrations:supabase:advisors')
);
expect(authorizeViaBrowserAsync).not.toHaveBeenCalled();
expect(runCommand).not.toHaveBeenCalled();
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
});
it('fails with the reauth command in non-interactive mode', async () => {
mockReauthorizationRequiredOnce();
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(EasCommandError);
expect(confirmAsync).not.toHaveBeenCalled();
expect(runCommand).not.toHaveBeenCalled();
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
});
it('distinguishes unavailable advisors from clean results', async () => {
jest
.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync)
.mockResolvedValue({ ...mockResult, security: null, performance: [] });
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Security advisors are unavailable')
);
expect(loggedOutput()).toContain('Performance · No unresolved findings');
expect(loggedOutput()).not.toContain('Security · No unresolved findings');
});
it('does not reauthorize when the project is unlinked during the request', async () => {
jest.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).mockResolvedValue(null);
await createCommand(['--json']).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
project: null,
security: null,
performance: null,
});
expect(confirmAsync).not.toHaveBeenCalled();
expect(authorizeViaBrowserAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,212 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import {
formatSupabaseAdvisorLints,
formatSupabaseProjectLabel,
getSupabaseAdvisorsDashboardUrl,
getSupabaseProjectDashboardUrl,
isSupabaseReauthorizationRequiredError,
logNoSupabaseProject,
} from '../../../commandUtils/supabase';
import { SupabaseMutation } from '../../../graphql/mutations/SupabaseMutation';
import { SupabaseQuery } from '../../../graphql/queries/SupabaseQuery';
import {
SupabaseAdvisorLintsData,
SupabaseAdvisorType,
} from '../../../graphql/types/SupabaseConnection';
import { authorizeViaBrowserAsync } from '../../../integrations/supabase/provision';
import Log, { link } from '../../../log';
import { ora } from '../../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
import { confirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
const ADVISOR_TYPES = [SupabaseAdvisorType.Security, SupabaseAdvisorType.Performance];
const REAUTH_COMMAND = 'eas integrations:supabase:advisors';
export default class IntegrationsSupabaseAdvisors extends EasCommand {
static override description =
"list unresolved findings from the Supabase Security and Performance Advisors for this app's primary Supabase project";
static override examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --type security',
'<%= config.bin %> <%= command.id %> --json --non-interactive',
];
static override flags = {
type: Flags.option({
description: 'Only show one advisor',
options: ['security', 'performance'] as const,
})(),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsSupabaseAdvisors);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const types =
flags.type === 'security'
? [SupabaseAdvisorType.Security]
: flags.type === 'performance'
? [SupabaseAdvisorType.Performance]
: ADVISOR_TYPES;
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsSupabaseAdvisors, {
nonInteractive,
withServerSideEnvironment: null,
});
const project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId);
if (!project) {
if (jsonFlag) {
printJsonOnlyOutput({ project: null, security: null, performance: null });
} else {
logNoSupabaseProject(exp.slug);
}
return;
}
let result = await this.fetchLintsAsync(graphqlClient, projectId, types, nonInteractive);
if (result === undefined) {
if (nonInteractive) {
throw new EasCommandError(
`Expo cannot read this project's advisors until the Supabase connection grants the database read permission. Run ${chalk.bold(REAUTH_COMMAND)} interactively to re-authorize.`
);
}
Log.warn(
'Expo cannot read the Supabase advisors until the Supabase connection is re-authorized with the database read permission.'
);
const confirmed = await confirmAsync({
message: 'Re-authorize Supabase in your browser now?',
});
if (!confirmed) {
Log.warn(`Run ${chalk.bold(REAUTH_COMMAND)} when you are ready to re-authorize.`);
return;
}
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const connection = await SupabaseQuery.getSupabaseConnectionByAccountIdAsync(
graphqlClient,
account.id,
{ useCache: false }
);
const authorized = await authorizeViaBrowserAsync(
graphqlClient,
account,
false,
connection?.updatedAt
);
if (
connection &&
authorized.supabaseOrganizationSlug !== connection.supabaseOrganizationSlug
) {
await SupabaseMutation.setSupabaseConnectionOrganizationAsync(graphqlClient, {
supabaseConnectionId: authorized.id,
organizationSlug: connection.supabaseOrganizationSlug,
});
}
Log.newLine();
result = await this.fetchLintsAsync(graphqlClient, projectId, types, nonInteractive);
if (result === undefined) {
throw new EasCommandError(
`Supabase still denies access to the advisors after re-authorizing. Check that the Expo integration is allowed to read the database for ${chalk.bold(formatSupabaseProjectLabel(project))}.`
);
}
}
if (result === null) {
if (jsonFlag) {
printJsonOnlyOutput({ project: null, security: null, performance: null });
} else {
logNoSupabaseProject(exp.slug);
}
return;
}
if (jsonFlag) {
printJsonOnlyOutput({
project: {
ref: result.project.supabaseProjectRef,
name: result.project.supabaseProjectName,
dashboardUrls: Object.fromEntries(
types.map(type => [
type.toLowerCase(),
getSupabaseAdvisorsDashboardUrl(result.project, type),
])
),
},
security: types.includes(SupabaseAdvisorType.Security) ? result.security : undefined,
performance: types.includes(SupabaseAdvisorType.Performance)
? result.performance
: undefined,
});
return;
}
Log.newLine();
Log.log(
`${chalk.bold('Dashboard')}: ${link(getSupabaseProjectDashboardUrl(result.project), { dim: false })}`
);
for (const type of types) {
const lints = type === SupabaseAdvisorType.Security ? result.security : result.performance;
Log.newLine();
if (lints === null) {
Log.warn(
`${type === SupabaseAdvisorType.Security ? 'Security' : 'Performance'} advisors are unavailable. Try again or open ${getSupabaseAdvisorsDashboardUrl(result.project, type)}.`
);
} else {
Log.log(formatSupabaseAdvisorLints(result.project, type, lints));
}
}
}
private async fetchLintsAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
types: SupabaseAdvisorType[],
nonInteractive: boolean
): Promise<SupabaseAdvisorLintsData | null | undefined> {
const spinner = nonInteractive ? null : ora('Fetching Supabase advisor findings').start();
try {
const result = await SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync(
graphqlClient,
projectId,
types
);
if (result) {
spinner?.succeed(
`Fetched Supabase advisor findings for ${formatSupabaseProjectLabel(result.project)}`
);
} else {
spinner?.stop();
}
return result;
} catch (error) {
if (isSupabaseReauthorizationRequiredError(error)) {
spinner?.fail('Supabase needs to be re-authorized');
return undefined;
}
spinner?.fail('Failed to fetch Supabase advisor findings');
throw error;
}
}
}
@@ -28,7 +28,6 @@ jest.mock('../../../project/expoConfig', () => ({
createOrModifyExpoConfigAsync: jest.fn(),
}));
jest.mock('../../../prompts');
jest.mock('../../../user/actions');
jest.mock('../../../graphql/mutations/AppMutation');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../ora', () => ({
@@ -27,6 +27,7 @@ import {
resetSimulatorEnvAsync,
} from '../../../simulator/env';
import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo';
import * as promiseUtils from '../../../utils/promise';
import Simulator from '../index';
jest.mock('fs-extra');
@@ -617,6 +618,40 @@ describe(Simulator, () => {
);
});
it('forwards --build-fingerprint to the create mutation', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--build-fingerprint',
' 4b1f2c9e7a3d ',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ buildFingerprint: '4b1f2c9e7a3d' })
);
});
it('accepts launch options with --build-fingerprint', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--build-fingerprint',
'4b1f2c9e7a3d',
'--open-url',
'exp://example.test',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ buildFingerprint: '4b1f2c9e7a3d', openUrl: 'exp://example.test' })
);
});
it('forwards --application-archive-url to the create mutation', async () => {
const { command } = createCommand([
'--platform',
@@ -813,6 +848,25 @@ describe(Simulator, () => {
it.each([
['--build-id', '8d8b713c-1834-4bd3-91e6-46f895422cbc'],
['--application-archive-url', 'https://example.test/builds/app.tar.gz'],
])('rejects passing --build-fingerprint with %s', async (sourceFlag, sourceValue) => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--build-fingerprint',
'4b1f2c9e7a3d',
sourceFlag,
sourceValue,
]);
await expect(command.runAsync()).rejects.toThrow();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it.each([
['--build-id', '8d8b713c-1834-4bd3-91e6-46f895422cbc'],
['--build-fingerprint', '4b1f2c9e7a3d'],
['--application-archive-url', 'https://example.test/builds/app.tar.gz'],
])('rejects passing --expo-go with %s', async (sourceFlag, sourceValue) => {
const { command } = createCommand([
'--platform',
@@ -828,6 +882,100 @@ describe(Simulator, () => {
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
describe('waiting for concurrency', () => {
let elapsedMs: number;
beforeEach(() => {
elapsedMs = 0;
jest.spyOn(Date, 'now').mockImplementation(() => elapsedMs);
jest.spyOn(promiseUtils, 'sleepAsync').mockImplementation(async () => {
elapsedMs += 5 * 60 * 1_000;
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it.each([JobRunStatus.New, JobRunStatus.InQueue])(
'allows more than 15 minutes in %s and then a full startup window',
async status => {
mockByIdAsync.mockImplementation(async () => {
if (elapsedMs < 20 * 60 * 1_000) {
return makeDeviceRunSession({
remoteConfig: null,
turtleJobRun: { id: 'job-123', status },
});
}
expect(mockOra.mock.results[1].value.text).toContain(
elapsedMs === 20 * 60 * 1_000
? '⏳ Simulator session queued or waiting for available concurrency'
: 'session to start'
);
return makeDeviceRunSession({
...(elapsedMs < 30 * 60 * 1_000 ? { remoteConfig: null } : {}),
});
});
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await command.runAsync();
expect(elapsedMs).toBe(30 * 60 * 1_000);
expect(mockEnsureDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
}
);
it('times out and stops a session that does not start within 15 minutes after queuing', async () => {
mockByIdAsync.mockImplementation(async () =>
makeDeviceRunSession({
remoteConfig: null,
turtleJobRun: {
id: 'job-123',
status: elapsedMs < 20 * 60 * 1_000 ? JobRunStatus.InQueue : JobRunStatus.InProgress,
},
})
);
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
'session to start (excluding time in the queue)'
);
expect(elapsedMs).toBe(35 * 60 * 1_000);
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
graphqlClient,
'session-123'
);
});
it.each([JobRunStatus.Canceled, JobRunStatus.Errored, JobRunStatus.Finished])(
'reports %s while queued without waiting for a timeout',
async status => {
mockByIdAsync
.mockResolvedValueOnce(
makeDeviceRunSession({
remoteConfig: null,
turtleJobRun: { id: 'job-123', status: JobRunStatus.InQueue },
})
)
.mockResolvedValueOnce(
makeDeviceRunSession({
remoteConfig: null,
turtleJobRun: { id: 'job-123', status },
})
);
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(status.toLowerCase());
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
graphqlClient,
'session-123'
);
}
);
});
it('stops the simulator session when interrupted before the session is ready', async () => {
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(code => {
throw new Error(`process.exit(${code})`);
@@ -13,7 +13,6 @@ import {
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class SimulatorAvailability extends EasCommand {
static override hidden = true;
static override aliases = ['sim:availability'];
static override description =
'[EXPERIMENTAL] check whether EAS Simulator is enabled for the current project account';
@@ -10,7 +10,6 @@ import {
} from '../../simulator/env';
export default class SimulatorEgress extends EasCommand {
static override hidden = true;
static override aliases = ['sim:egress'];
static override description = `[EXPERIMENTAL] run the local egress client for a simulator session to route proxied HTTP(S) requests through this machine`;
@@ -29,7 +29,6 @@ const POLL_INTERVAL_MS = 5_000;
const POST_STOP_REFRESH_COUNT = 2;
export default class SimulatorEvents extends EasCommand {
static override hidden = true;
static override aliases = ['sim:events'];
static override description =
'[EXPERIMENTAL] show activity events from a remote simulator session';
@@ -4,7 +4,6 @@ import EasCommand from '../../commandUtils/EasCommand';
import { SIMULATOR_DOTENV_FILE_NAME, loadSimulatorEnvAsync } from '../../simulator/env';
export default class SimulatorExec extends EasCommand {
static override hidden = true;
static override aliases = ['sim:exec'];
static override description = `[EXPERIMENTAL] execute a simulator command with ${SIMULATOR_DOTENV_FILE_NAME} environment loaded`;
static override strict = false;
@@ -28,7 +28,6 @@ type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId
type DeviceRunSessionArtifact = DeviceRunSessionById['artifacts'][number];
export default class SimulatorGet extends EasCommand {
static override hidden = true;
static override aliases = ['sim:get'];
static override description =
'[EXPERIMENTAL] get info about a remote simulator session on EAS by its simulator session ID';
@@ -47,7 +47,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { sleepAsync } from '../../utils/promise';
const POLL_INTERVAL_MS = 5_000; // 5 seconds
const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes
const STARTUP_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes, excluding time in the queue
const OUT_CONFIG_TYPE_VALUES = {
Env: 'env',
Dotenv: 'dotenv',
@@ -61,8 +61,8 @@ const APP_PLATFORM_BY_FLAG_VALUE: Record<PlatformFlagValue, AppPlatform> = {
};
export default class Simulator extends EasCommand {
static override hidden = true;
static override aliases = ['simulator:start', 'sim', 'sim:start'];
static override aliases = ['simulator:start', 'sim:start'];
static override hiddenAliases = ['sim'];
static override description =
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it';
@@ -87,17 +87,22 @@ export default class Simulator extends EasCommand {
}),
'build-id': Flags.string({
description: 'EAS Build to install and launch before the simulator session is ready.',
exclusive: ['application-archive-url', 'expo-go'],
exclusive: ['build-fingerprint', 'application-archive-url', 'expo-go'],
}),
'build-fingerprint': Flags.string({
description:
'Fingerprint hash of an EAS Build to install and launch before the simulator session is ready. Uses the most recent finished build with this fingerprint that can be installed on the simulator.',
exclusive: ['build-id', 'application-archive-url', 'expo-go'],
}),
'application-archive-url': Flags.string({
description:
'Application archive URL to download, install, and launch before the simulator session is ready.',
exclusive: ['build-id', 'expo-go'],
exclusive: ['build-id', 'build-fingerprint', 'expo-go'],
}),
'expo-go': Flags.boolean({
description:
"Install and launch Expo Go matching the current project's Expo SDK before the simulator session is ready.",
exclusive: ['build-id', 'application-archive-url'],
exclusive: ['build-id', 'build-fingerprint', 'application-archive-url'],
}),
'sdk-version': Flags.string({
description:
@@ -203,6 +208,7 @@ export default class Simulator extends EasCommand {
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 buildFingerprint = flags['build-fingerprint']?.trim() || undefined;
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
const sdkVersionFromFlag = flags['sdk-version']?.trim() || undefined;
const launchArgs = flags['launch-arg'];
@@ -217,11 +223,12 @@ export default class Simulator extends EasCommand {
if (
(launchArgs?.length || openUrl) &&
!buildId &&
!buildFingerprint &&
!applicationArchiveUrlFromFlag &&
!flags['expo-go']
) {
throw new EasCommandError(
'Launch options require an application source. Pass --build-id, --application-archive-url, or --expo-go.'
'Launch options require an application source. Pass --build-id, --build-fingerprint, --application-archive-url, or --expo-go.'
);
}
@@ -281,6 +288,7 @@ export default class Simulator extends EasCommand {
: { android: { deviceIdentifier } }
: {}),
...(buildId ? { buildId } : {}),
...(buildFingerprint ? { buildFingerprint } : {}),
...(applicationArchiveUrlFromFlag
? { applicationArchiveUrl: applicationArchiveUrlFromFlag }
: {}),
@@ -318,11 +326,11 @@ export default class Simulator extends EasCommand {
}
const pollSpinner = ora(`⏳ Waiting for ${flags.type} session to be ready`).start();
const deadline = Date.now() + POLL_TIMEOUT_MS;
let startupDeadline: number | undefined = Date.now() + STARTUP_TIMEOUT_MS;
let remoteConfig: DeviceRunSessionRemoteConfig | undefined;
try {
while (!sessionInterrupt.signal.aborted && Date.now() < deadline) {
while (!sessionInterrupt.signal.aborted) {
const session = await Promise.race([
DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId),
sessionInterrupt.abortPromise,
@@ -358,6 +366,17 @@ export default class Simulator extends EasCommand {
break;
}
if (jobRunStatus === JobRunStatus.New || jobRunStatus === JobRunStatus.InQueue) {
startupDeadline = undefined;
pollSpinner.text = '⏳ Simulator session queued or waiting for available concurrency';
} else {
startupDeadline ??= Date.now() + STARTUP_TIMEOUT_MS;
pollSpinner.text = `⏳ Waiting for ${flags.type} session to start`;
if (Date.now() >= startupDeadline) {
break;
}
}
await sleepAsync(POLL_INTERVAL_MS, sessionInterrupt.signal);
}
} catch (err) {
@@ -379,11 +398,11 @@ export default class Simulator extends EasCommand {
}
if (!remoteConfig) {
pollSpinner.fail(`Timed out waiting for ${flags.type} session to be ready`);
pollSpinner.fail(`Timed out waiting for ${flags.type} session to start`);
await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId);
sessionInterrupt.dispose();
throw new Error(
`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} session to be ready. ${link(deviceRunSessionUrl)}`
`Timed out after ${Math.round(STARTUP_TIMEOUT_MS / 1000)}s waiting for ${flags.type} session to start (excluding time in the queue). ${link(deviceRunSessionUrl)}`
);
}
@@ -49,7 +49,6 @@ const PLATFORM_BY_FLAG_VALUE = Object.fromEntries(
);
export default class SimulatorList extends EasCommand {
static override hidden = true;
static override aliases = ['sim:list'];
static override description =
'[EXPERIMENTAL] list remote simulator sessions for the current project';
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff. Show more