Compare commits

..
37 Commits
Author SHA1 Message Date
Expo CI b8cff20089 v21.8.0 2026-08-11 15:59:00 +00:00
Gabe Debes c99ec44b92 [eas-cli] Add integrations:supabase:disconnect command (#4108) 2026-08-11 08:49:29 -07:00
Gabe Debes 90501af961 [eas-cli] Add integrations:supabase:dashboard command (#4107) 2026-08-11 08:22:55 -07:00
Gabe Debes 4c317292d2 [eas-cli] Add integrations:supabase:connect (#4106)
* [eas-cli] Add integrations:supabase:connect command

* [eas-cli] Update connect test for provision failure hint

* [eas-cli] Avoid re-run advice after additional Supabase provision

Env-write and missing-credential recovery now print URL/key for manual setup
instead of suggesting another --environment run that would bill a second project.

* [eas-cli] Cover primary provision receipt parse failure in connect

* [eas-cli] Prompt link vs provision after supabase --reauth

Cascade delete clears the EAS project link on reauth; default to
linking an existing project instead of provisioning a new billed one.

* [eas-cli] Cover post-reauth link prompt validation paths

* [eas-cli] Format supabase connect reauth changes

* [eas-cli] Drop redundant URL polyfill and match docs client path

* [eas-cli] Print the Supabase authorize URL before opening the browser

* [eas-cli] Keep the browser open call from rejecting unhandled
2026-08-11 07:49:23 -07:00
Stanisław Chmiela 16cf32ae73 [build-tools] Stream logcat to a file (#3627)
# Why

Workflow Maestro tests currently collect Android logs with `adb logcat` after the tests. This depends on the ADB server and can lose logs when ADB becomes unavailable or the device shuts down.

# How

- Start each Android Emulator with native `-logcat '*:v'` and `-logcat-output` arguments.
- Write logs to a unique temporary directory that lasts for the job.
- Expose that directory as the `logcat_directory` output of `eas/start_android_emulator`.

In Maestro test job we're going to copy files from that `logcat_directory` to Maestro bundle and thus get logs for all devices.

# Test Plan

- `yarn workspace @expo/build-tools jest-unit src/steps/functions/__tests__/startAndroidEmulator.test.ts src/utils/__tests__/AndroidEmulatorUtils.test.ts --runInBand`
- `yarn workspace @expo/build-tools typecheck`
- `yarn fmt:check`
- Real-emulator integration test confirms native logcat capture continues across an ADB server restart.
2026-08-11 15:40:59 +02:00
Gabe Debes 1ec9e83c3b [eas-cli] Supabase integration base (#4130)
* [eas-cli] Add Supabase integration foundation (GraphQL, shared helpers, provisioning)

* [eas-cli] Fix additional Supabase provision failure hint

Stop suggesting --environment with --link; that combo is rejected by connect.

* [eas-cli] Reach 100% coverage on Supabase foundation helpers

* [eas-cli] Regenerate GraphQL after rebase onto main

* [eas-cli] Drop thin Supabase env wrappers and re-exports

Call shared writeEnvLocal / env upsert / resolveTargetEnvironments
with the Supabase label at the callsite instead of one-arg wrappers.

* [eas-cli] Slim supabase/sdk.ts to package constants only

Call shared setupSdkAndConfigAsync with packages/plugin/label at
the connect callsite instead of thin binder wrappers.

* [eas-cli] Consolidate supabase helpers; move writeEnvVarsAsync

Drop constant-only sdk.ts/environments.ts into env.ts. Move the
generic writeEnvVarsAsync loop into environments/variables.

* [eas-cli] Address Supabase foundation review feedback

* [eas-cli] Stop polling when a Supabase request fails for good

* [eas-cli] Move Supabase changelog entry under unreleased main
2026-08-10 16:51:06 -07:00
Stanisław Chmiela 3b1b96e71f [build-tools] Add direct DADB mode for Maestro tests (#4163)
## Why

ADB server is sometimes flaky.

## How

Allow opting in to [DADB](https://github.com/mobile-dev-inc/dadb) for Maestro-Android emulator connections.

DADB mode can be enabled with either:

- the `android_connection_mode: dadb` step input
- the `EAS_MAESTRO_ANDROID_CONNECTION_MODE=dadb` environment variable

The explicit step input has priority over the environment variable. The default remains `adb`. On iOS, `dadb` is accepted and ignored, so the environment variable can be set for the whole project.

## Test plan

I tested this before with a client. I want to allow another client to easily opt in. This is a no-op unless it is enabled on Android.

Unit tests cover input opt-in, environment opt-in, input precedence, invalid values, iOS behavior, retries, and failure behavior.
2026-08-10 21:59:53 +02:00
Douglas Lowder 2f081de936 [ci] Fix actionlint findings across workflows and internal actions (#4167)
# Why

Working on the repo locally, I found warnings and errors when linting workflows with actionlint.

# How

- Fix the warnings and errors, including updating a workflow using an outdated Node 20 runner.
- Add actionlint to the mise configuration, an actionlint script to the top level package.json, and an actionlint step in test.yml to ensure no regressions occur on future PRs.

# Test Plan

CI should pass, including the new actionlint check in test.yml.
2026-08-10 11:08:58 -07:00
Expo CI 954534df42 update CHANGELOG.md and bump STAGING in cli-versions.json 2026-08-10 15:47:56 +00:00
Expo CI 60032049ec v21.7.1 2026-08-10 15:44:17 +00:00
Gabriel Donadel Dall'AgnolandBrent Vatne 7a63315be5 [build-tools] Make configure-version gradle script compatible with AGP 9 (#4155)
* [build-tools] Make configure-version gradle script compatible with AGP 9

* [build-tools] Update gradleConfig snapshots for AGP 9 template change

* update CHANGELOG.md

---------

Co-authored-by: Brent Vatne <[email protected]>
2026-08-10 14:42:56 +02:00
David Mokos 295d49681b [cli] add sandbox detection to telemetry (#4038) 2026-08-10 05:33:33 -07:00
Pieter Willekens c93614ec55 [steps][create-eas-build-function] Declare missing dependencies (#3798)
`fast-glob` and `arg` were imported but not declared in their packages,
resolving only via workspace hoisting. Declare them explicitly so the
packages keep working when installed standalone.
2026-08-10 13:15:28 +02:00
Stanisław Chmiela e8e135431c [build-tools][worker] Enable production EAS Logs (#4168)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

I want to see how the architecture works under full logs pressure.

# How

Enabled pushing logs to logs.expo.dev in production too.

# Test Plan

Staging works, let's see it in production.
2026-08-10 12:59:25 +02:00
Stanisław Chmiela 42a487e695 [build-tools] Enable real time sessions in production (#4160)
# Why

We want to stream session events in real-time.

# How

Added necessary config.

# Test Plan

Land after https://github.com/expo/universe/pull/29610 and verify in production. Staging works good.
2026-08-07 19:19:46 +02:00
Expo CI 2a062dba96 update CHANGELOG.md and bump STAGING in cli-versions.json 2026-08-07 03:13:21 +00:00
Expo CI f923181857 v21.7.0 2026-08-07 03:10:31 +00:00
Gabe Debes 412366f2f6 [eas-cli] Fail when --private-key-path is set without codeSigningCertificate (#4161)
* [eas-cli] Fail when --private-key-path is set without codeSigningCertificate

* [eas-cli] Improve error when --private-key-path is set without a certificate

* [eas-cli] Add changelog entry for private-key-path signing guard
2026-08-06 18:17:03 -07:00
Gabe Debes 6c57bbd57b [build-tools] label serve-sim metrics artifacts with the device name (#4127)
* [build-tools] label serve-sim metrics artifacts with the device name

* [build-tools] add CHANGELOG entry for device name label
2026-08-06 17:48:03 -07:00
Szymon DziedzicandClaude Fable 5 42cf2cb997 [build-tools] Stop remote simulator sessions after max_idle_time_minutes without activity (#4157)
Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-06 17:35:16 +02:00
Szymon Dziedzic e383931af6 [build-tools] Classify Argent session artifacts by MIME type (#4154)
* [build-tools] Classify Argent session artifacts by MIME type

The Argent tool server reports a MIME type for every artifact, but the uploader discarded it and sent kind: undefined. Map PNG/JPEG to `screenshot` and MP4/QuickTime to `screen-recording` so the dashboard groups and labels them instead of showing a flat, unclassified file list.

* [build-tools] Add changelog entry
2026-08-06 10:17:16 +02:00
Expo CI 9827171bf7 update CHANGELOG.md and bump STAGING in cli-versions.json 2026-08-05 23:05:40 +00:00
Expo CI 866febb1ba v21.6.0 2026-08-05 23:02:14 +00:00
Brent Vatne d98d26d962 [reviewer] Bump AI code review to @expo/code-review-cli 0.10.0 (#4158) 2026-08-05 15:51:35 -07:00
Douglas Lowder d78adfeb0f [eas-build-job][build-tools] Replace eas-cli npm dist-tags with cli-versions.json (#4147)
# Why

Add `cli-versions.json` as the source of truth for the staging/production eas-cli versions, to remove the dependency on NPM tags to determine the correct version used in workflow jobs.

# How

Adding new function `fetchEasCliVersionsAsync` in the eas-build-job package. This reads the file from main on GitHub; build-tools uses it to build the eas-cli@<version> npx argument and falls back to the `latest-eas-build*` dist-tags on any failure.  The release workflow now bumps cli-versions.json in the release commit and no longer posts the manual npm dist-tag reminder.

# Test Plan

New unit tests added.
2026-08-05 15:31:36 -07:00
Szymon DziedzicandClaude Opus 5 54f0b5368c [eas-cli] Point gated accounts at the EAS Simulator waitlist (#4151)
`simulator:availability` now prints the waitlist link when the
`device-run-sessions` gate is off, and `--json` carries a `waitlistUrl`
field. `eas simulator` checks the gate before creating a session and
fails with the same message instead of a generic permission error.
Expo admins skip the check so the server stays the authority for them.

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-05 19:41:52 +02:00
Szymon DziedzicandClaude Opus 5 f30267f05f [eas-cli] Add eas sim as a shortcut for eas simulator commands (#4150)
* [eas-cli] Add `eas sim` as a shortcut for `eas simulator` commands

Alias every `eas simulator:*` command as `eas sim:*`, and alias the bare
command so `eas sim` starts a session just like `eas simulator`.

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

* update CHANGELOG.md

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-05 19:35:29 +02:00
Szymon Świerk 86fc9ebb80 [eas-cli][build-tools][steps] Validate local composite functions referenced from workflow hooks; extract the shared loader into steps (#4064)
* [eas-cli] Validate local composite functions referenced from workflow hooks

* extract the local composite function loader into @expo/steps

* skip validation when project_root_directory is set

* Validate defaults.hooks and remove project_root_directory skip
2026-08-05 14:29:44 +02:00
Szymon Dziedzic 94f473b1f3 [eas-cli] Make simulator the index command (#4112)
* [eas-cli] Make simulator the index command

* Update changelog

* [eas-cli] Keep simulator commands hidden

* [eas-cli] Add simulator command topic
2026-08-05 13:14:57 +02:00
Brent Vatne a3a1d84ebd [reviewer] Migrate reviews from GPT to Claude (sonnet 5, opus for security) (#4148) 2026-08-04 15:40:52 -07:00
Szymon Dziedzic eab32ecb9f [eas-cli] Add --name filter to simulator:list (#4118)
* [eas-cli] Add --name filter to simulator:list

* [eas-cli] Add changelog entry
2026-08-04 11:27:45 +02:00
Gabe Debes cdf4e811d5 Revert "[build-tools] Pin the default agent-device version for remote sessions (#4142)" (#4144)
This reverts commit f56c054be4.
2026-08-03 14:01:18 -07:00
Expo CI d003bffa29 update CHANGELOG.md 2026-08-03 20:09:34 +00:00
Expo CI 7d7ebb27f6 v21.5.1 2026-08-03 20:06:47 +00:00
Brent Vatne 9276e22ff2 Temporarily disable adding npm dist-tags in CI, post Slack reminder instead 2026-08-03 13:03:57 -07:00
Brent Vatne f41312cf38 update CHANGELOG.md 2026-08-03 13:00:19 -07:00
Gabe Debes f56c054be4 [build-tools] Pin the default agent-device version for remote sessions (#4142) 2026-08-03 12:59:51 -07:00
120 changed files with 8923 additions and 922 deletions

No files matched your search

+3 -4
View File
@@ -1,10 +1,9 @@
---
description: Security and secrets. Injection, credential or secret leakage, unsafe shell/child-process use, missing validation at trust boundaries.
alwaysRun: true
# No model override: security runs on the config default (gpt-5.5, on the
# subscription). To give it a stronger model, use the metered openai-api alias
# from the mixed auth.providers setup (see the @expo/code-review-cli README) —
# pro-tier models are excluded from the subscription.
# Security is the highest-stakes agent, so it runs on the Opus tier (mirrors
# the expo/code-review-cli repo's own roster).
model: anthropic/claude-opus-5
---
# Security & secrets
+17 -16
View File
@@ -1,9 +1,11 @@
{
// Default model for every pass — served by the ChatGPT/Codex subscription (see
// auth below), so reviews have no marginal cost. Per-agent frontmatter can
// override this. Override everything at runtime with REVIEWER_MODEL
// (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini for a cheaper local run).
"model": "openai/gpt-5.5",
// Default model for every pass. Anthropic models run through the Claude Code
// CLI engine (`claude -p`), inferred per agent from the model id — see the
// auth entry below for the credential. Per-agent frontmatter can override
// this (security runs Opus). Override everything at runtime with
// REVIEWER_MODEL (e.g. REVIEWER_MODEL=anthropic/claude-haiku-4-5 for a
// cheaper local run).
"model": "anthropic/claude-sonnet-5",
// Agents are every markdown file in agents/. shared.md (prepended to all) and
// coordinator.md are reserved. Per-agent overrides go in each file's frontmatter.
@@ -30,17 +32,16 @@
"breakGlass": { "marker": "/skip-review" },
"commentTag": "expo-ai-code-reviewer",
// ChatGPT/Codex subscription (OAuth): tokenEnv holds the ACCESS token from an
// `opencode auth login` ChatGPT sign-in (`ecr setup-auth` extracts it) — a
// plain bearer with no rotation involvement; re-mint before its ~10-day expiry
// (doctor + the run preflight warn first). NEVER use the refresh token here:
// it is single-use and dies on first rotation. All models above are on the
// subscription's allowlist, so reviews draw no metered spend. (To add pro-tier
// models later, see the mixed auth.providers setup in the @expo/code-review-cli
// README.)
// Anthropic models always run through the Claude Code CLI — the engine is
// inferred per agent from the `anthropic/…` model id, so this entry only
// supplies the credential. The tokenEnv holds the shared review credential
// (an `sk-ant-oat…` token from `claude setup-token`, or an `sk-ant-api…`
// Console key — the CLI reads either; the engine classifies by shape).
// ECR_EXPECTED_TOKEN_ENV (repo variable / workflow fallback) must equal this
// tokenEnv. Locally, an active `claude` login is enough.
"auth": {
"mode": "oauth",
"provider": "openai",
"tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN"
"providers": {
"anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" }
}
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
---
# No model override: the coordinator runs on the config default (gpt-5.5, on the
# subscription). It only consolidates text (no repo tools), so the default is
# plenty; override here if consolidation quality ever needs a stronger model.
# No model override: the coordinator runs on the config default
# (claude-sonnet-5). It only consolidates text (no repo tools), so the default
# is plenty; override here if consolidation quality ever needs a stronger model.
---
# Coordinator — consolidation & decision
@@ -1,12 +1,13 @@
name: notify-slack-on-fail-or-recover
description: Notify a Slack channel when a workflow fails, or when it recovers after a previous failure.
inputs:
channel:
description: Slack channel to send the notification to.
required: true
type: string
slack_webhook:
description: Slack incoming webhook URL used to post the notification.
required: true
type: string
runs:
using: 'composite'
@@ -1,4 +1,5 @@
name: setup-gcloud
description: Authenticate to Google Cloud via workload identity federation and set up the gcloud CLI.
runs:
using: "composite"
@@ -38,7 +38,7 @@ jobs:
for platform in android ios; do
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
if ! gsutil stat gs://eas-build-worker-tarballs/$GCP_TARBALL; then
if ! gsutil stat "gs://eas-build-worker-tarballs/$GCP_TARBALL"; then
echo "worker_exists=false" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -191,8 +191,8 @@ jobs:
run: |
IOS_GCP_TARBALL="worker-ios-sha1-$GITHUB_SHA.tar.gz"
ANDROID_GCP_TARBALL="worker-android-sha1-$GITHUB_SHA.tar.gz"
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - "gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL"
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - "gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL"
- name: Promote worker to ${{ inputs.environment }}
if: inputs.environment != ''
@@ -201,5 +201,5 @@ jobs:
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
WORKER_STAGING_TARBALL="worker-$platform-${{ inputs.environment }}.tar.gz"
gsutil cp gs://eas-build-worker-tarballs/$GCP_TARBALL gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL
gsutil cp "gs://eas-build-worker-tarballs/$GCP_TARBALL" "gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL"
done
+19 -10
View File
@@ -19,7 +19,7 @@ permissions:
env:
# Published reviewer run via npx (override with repo variable ECR_VERSION).
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.6.0' }}
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.10.0' }}
concurrency:
group: ai-code-review-cmd-${{ github.event.issue.number }}
@@ -60,9 +60,11 @@ jobs:
elif [ "$rest" != "all" ]; then
agents=$(printf '%s' "$rest" | tr ' ' ',' | tr -cd 'a-zA-Z0-9,_-')
fi
echo "run=true" >> "$GITHUB_OUTPUT"
echo "agents=$agents" >> "$GITHUB_OUTPUT"
echo "route=$route" >> "$GITHUB_OUTPUT"
{
echo "run=true"
echo "agents=$agents"
echo "route=$route"
} >> "$GITHUB_OUTPUT"
- name: Acknowledge
if: steps.cmd.outputs.run == 'true'
@@ -116,13 +118,20 @@ jobs:
curl -fsSL https://getunblocked.com/install.sh | bash
echo "$HOME/.unblocked/bin" >> "$GITHUB_PATH"
# The anthropic/… models run through the Claude Code CLI (claude-code
# engine). Pinned exactly — see expo-code-review.yml.
- name: Install Claude Code CLI
if: steps.cmd.outputs.run == 'true'
run: npm install -g @anthropic-ai/[email protected]
- name: Run AI review
if: steps.cmd.outputs.run == 'true'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CODEX_OAUTH_ACCESS_TOKEN: ${{ secrets.CODEX_OAUTH_ACCESS_TOKEN }}
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN' }}
# Shared Anthropic review credential — see expo-code-review.yml.
CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
# Unblocked CLI headless auth (PAT or team access token). Absent =>
# the CLI stays unauthenticated; the review still runs.
UNBLOCKED_API_TOKEN: ${{ secrets.UNBLOCKED_API_TOKEN }}
@@ -130,10 +139,10 @@ jobs:
AGENTS: ${{ steps.cmd.outputs.agents }}
ROUTE: ${{ steps.cmd.outputs.route }}
run: |
ARGS=""
ARGS=()
if [ -n "$AGENTS" ]; then
ARGS="--agents $AGENTS"
ARGS=(--agents "$AGENTS")
elif [ "$ROUTE" = "true" ]; then
ARGS="--route"
ARGS=(--route)
fi
npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr ci $ARGS
npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr ci "${ARGS[@]}"
@@ -18,7 +18,7 @@ permissions:
env:
# Published reviewer run via npx (override with repo variable ECR_VERSION).
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.6.0' }}
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.10.0' }}
concurrency:
group: ai-code-review-dismiss-${{ github.event.issue.number }}
@@ -61,10 +61,12 @@ jobs:
if [ -z "$(printf '%s' "$ids" | tr -d ' ')" ]; then
echo "run=false" >> "$GITHUB_OUTPUT"; exit 0
fi
echo "run=true" >> "$GITHUB_OUTPUT"
echo "sub=$sub" >> "$GITHUB_OUTPUT"
echo "ids=$ids" >> "$GITHUB_OUTPUT"
echo "reason=$reason" >> "$GITHUB_OUTPUT"
{
echo "run=true"
echo "sub=$sub"
echo "ids=$ids"
echo "reason=$reason"
} >> "$GITHUB_OUTPUT"
- name: Acknowledge
if: steps.cmd.outputs.run == 'true'
+27 -17
View File
@@ -13,9 +13,9 @@ permissions:
env:
# The reviewer is the published @expo/code-review-cli package, run via npx — the
# repo only carries the `.expo-code-review/` config, not the engine source.
# Override with a repo variable ECR_VERSION to pin/bump; defaults to 0.6.x
# (0.6.0 = trusted base-commit configuration + PR-head runtime-config scrub).
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.6.0' }}
# Override with a repo variable ECR_VERSION to pin/bump; defaults to 0.10.x
# (the claude-code engine the anthropic/… models below run on).
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.10.0' }}
concurrency:
group: ai-code-review-${{ github.event.pull_request.number }}
@@ -74,9 +74,11 @@ jobs:
else
route=true # bare `ai-review` => let the router pick
fi
echo "run=true" >> "$GITHUB_OUTPUT"
echo "agents=$agents" >> "$GITHUB_OUTPUT"
echo "route=$route" >> "$GITHUB_OUTPUT"
{
echo "run=true"
echo "agents=$agents"
echo "route=$route"
} >> "$GITHUB_OUTPUT"
# SECURITY: check out the PR's immutable BASE commit, never the PR head or
# merge ref — everything on this runner's disk comes from a commit that
@@ -125,6 +127,14 @@ jobs:
curl -fsSL https://getunblocked.com/install.sh | bash
echo "$HOME/.unblocked/bin" >> "$GITHUB_PATH"
# The anthropic/… models in .expo-code-review/config.jsonc run through the
# Claude Code CLI (the claude-code engine). Pinned exactly — an unpinned
# CLI would silently drift on every CI run; bump it deliberately (mirrors
# expo/code-review-cli's own workflow).
- name: Install Claude Code CLI
if: steps.resolve.outputs.run == 'true'
run: npm install -g @anthropic-ai/[email protected]
# SECURITY: the TRUSTED BASE checkout above includes .expo-code-review/
# config.jsonc, whose auth.tokenEnv names the env var the CLI forwards as the
# model credential. `ecr verify-config` is the canonical guard (ships with the
@@ -136,7 +146,7 @@ jobs:
- name: Guard config tokenEnv (root + routing + all scopes)
if: steps.resolve.outputs.run == 'true'
env:
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN' }}
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
run: npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr verify-config
- name: Run AI review
@@ -148,14 +158,14 @@ jobs:
env:
# GitHub token scoped by the permissions block above (PR comments only).
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ChatGPT/Codex subscription ACCESS token (auth.mode "oauth" in
# .expo-code-review/config.jsonc reads this env var) — used as-is, no
# rotation involvement; re-mint before its ~10-day expiry. All reviews
# run on the subscription.
CODEX_OAUTH_ACCESS_TOKEN: ${{ secrets.CODEX_OAUTH_ACCESS_TOKEN }}
# Shared Anthropic review credential (auth.providers.anthropic in
# .expo-code-review/config.jsonc reads this env var). The claude-code
# engine forwards it to the Claude Code CLI as the only credential —
# an `sk-ant-oat…` setup-token or an `sk-ant-api…` Console key both work.
CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
# Layer-1 runtime auth lock (mirrors the guard step): the CLI refuses to
# run when the tokenEnv the loaded config honors differs from this.
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN' }}
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
# Unblocked CLI headless auth (PAT or team access token). Absent =>
# the CLI stays unauthenticated; the review still runs.
UNBLOCKED_API_TOKEN: ${{ secrets.UNBLOCKED_API_TOKEN }}
@@ -164,13 +174,13 @@ jobs:
AGENTS: ${{ steps.resolve.outputs.agents }}
ROUTE: ${{ steps.resolve.outputs.route }}
run: |
ARGS=""
ARGS=()
if [ -n "$AGENTS" ]; then
ARGS="--agents $AGENTS"
ARGS=(--agents "$AGENTS")
elif [ "$ROUTE" = "true" ]; then
ARGS="--route"
ARGS=(--route)
fi
npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr ci $ARGS
npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr ci "${ARGS[@]}"
# Persist the run log (decision, per-agent cost, duration, token/cache
# metrics) so cost/latency/cache reuse are observable over time — CI is
+5 -5
View File
@@ -5,7 +5,7 @@ on:
jobs:
needs-repro:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
if: "${{ contains(github.event.label.name, 'incomplete issue: missing or invalid repro') }}"
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
@@ -49,7 +49,7 @@ jobs:
})
needs-info:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
if: "${{ contains(github.event.label.name, 'incomplete issue: missing info') }}"
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
@@ -78,7 +78,7 @@ jobs:
})
issue-accepted:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
if: github.event.label.name == 'issue accepted'
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
@@ -95,7 +95,7 @@ jobs:
`})
question:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
if: "${{ contains(github.event.label.name, 'invalid issue: question') }}"
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
@@ -123,7 +123,7 @@ jobs:
})
feature-request:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
if: "${{ contains(github.event.label.name, 'invalid issue: feature request') }}"
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
-64
View File
@@ -1,64 +0,0 @@
on:
workflow_dispatch:
inputs:
staging_only:
type: boolean
required: true
default: false
description: "Only move the latest-eas-build-staging tag, not the latest-eas-build tag"
version:
type: string
required: true
description: "Version number to move the tag to"
dry_run:
type: boolean
required: true
default: true
description: "Run the workflow in dry run mode, without making any NPM changes"
name: Move NPM tags used by EAS builds and workflows
jobs:
move-tag:
name: Move NPM tags used by EAS builds and workflows
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
steps:
- uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2
with:
registry-url: "https://registry.npmjs.org/"
scope: "expo"
node-version: 22
- name: Resolve version (fails if the version does not exist)
run: |
set -o pipefail
npm pack --dry-run eas-cli@$INPUT_VERSION
- name: Add latest-eas-build-staging tag
run: |
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build-staging"
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
echo "Running in dry run mode, skipping version check"
echo "cmd = $cmd"
exit 0
fi
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
$cmd
- name: Add latest-eas-build tag
run: |
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build"
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
echo "Running in dry run mode, skipping version check"
echo "cmd = $cmd"
exit 0
fi
if [[ "$INPUT_STAGING_ONLY" == "true" ]]; then
echo "Only moving the staging tag, skipping the production tag"
exit 0
fi
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
$cmd
@@ -0,0 +1,58 @@
on:
workflow_dispatch:
inputs:
version:
type: string
required: false
description: "eas-cli version to set as PRODUCTION in cli-versions.json. Defaults to the current STAGING value."
name: Promote eas-cli to production
jobs:
promote-production:
name: Bump PRODUCTION in cli-versions.json
runs-on: ubuntu-latest
permissions:
contents: write
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
- name: Ensure main is checked out
run: |
git fetch
git checkout main
- name: Resolve version
run: |
version="$INPUT_VERSION"
if [ -z "$version" ]; then
version="$(jq -r .STAGING cli-versions.json)"
fi
echo "PRODUCTION_VERSION=$version" >> "$GITHUB_ENV"
- name: Verify version is an exact, published eas-cli version
run: |
resolved="$(npm view "eas-cli@$PRODUCTION_VERSION" version 2>/dev/null || true)"
if [ "$resolved" != "$PRODUCTION_VERSION" ]; then
echo "'$PRODUCTION_VERSION' is not an exact published eas-cli version." >&2
echo "npm resolved it to: '${resolved:-<nothing>}'." >&2
echo "Provide an exact version such as 21.5.0, not a range or dist-tag." >&2
exit 1
fi
- name: Bump PRODUCTION in cli-versions.json
run: |
jq --arg version "$PRODUCTION_VERSION" '.PRODUCTION = $version' \
cli-versions.json > cli-versions.json.tmp
mv cli-versions.json.tmp cli-versions.json
- name: Commit and push
run: |
if git diff --quiet cli-versions.json; then
echo "PRODUCTION is already $PRODUCTION_VERSION; nothing to do."
exit 0
fi
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
git add cli-versions.json
git commit -m "bump PRODUCTION to $PRODUCTION_VERSION in cli-versions.json"
git push
+26 -21
View File
@@ -37,20 +37,20 @@ jobs:
run: yarn build
- name: Publish packages to npm
run: yarn run -T lerna publish from-package --yes --no-verify-access
- name: Add latest-eas-build-staging tag
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
- name: Verify eas-cli was published as latest on npm
run: |
VERSION=$(cat lerna.json | jq -r .version)
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
npm dist-tag add eas-cli@$VERSION latest-eas-build-staging
- name: Add latest-eas-build tag
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
run: |
VERSION=$(cat lerna.json | jq -r .version)
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
npm dist-tag add eas-cli@$VERSION latest-eas-build
expected="$(jq -r .version lerna.json)"
for attempt in $(seq 1 10); do
published="$(npm view eas-cli@latest version 2>/dev/null || true)"
if [ "$published" = "$expected" ]; then
echo "Verified eas-cli@$expected is latest on npm."
exit 0
fi
echo "Attempt $attempt: npm latest is '$published', expected '$expected'. Retrying in 15s..."
sleep 15
done
echo "eas-cli@$expected was not published as latest on npm; the publish step failed." >&2
exit 1
release-changelog:
name: Update changelog and publish release
runs-on: ubuntu-latest
@@ -66,30 +66,35 @@ jobs:
working-directory: ./scripts
run: yarn install --immutable
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
- name: Update changelog
working-directory: ./scripts
run: yarn release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
- name: Publish release
run: gh release edit v$EAS_CLI_VERSION --draft=false -F /tmp/current-changelog
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit and push
run: |
git fetch
git checkout main
jq --arg version "$EAS_CLI_VERSION" '.STAGING = $version' \
cli-versions.json > cli-versions.json.tmp
mv cli-versions.json.tmp cli-versions.json
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
git add CHANGELOG.md
git commit --allow-empty -m "update CHANGELOG.md"
git add cli-versions.json CHANGELOG.md
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING in cli-versions.json"
git push
- name: Prepare changelog for Slack
run: |
tail -n +3 /tmp/current-changelog > /tmp/changelog.md
npx slackmd /tmp/changelog.md
echo 'SLACK_CHANGELOG<<EOF' >> $GITHUB_ENV
cat /tmp/changelog.slack.md >> $GITHUB_ENV
echo 'EOF' >> $GITHUB_ENV
{
echo 'SLACK_CHANGELOG<<EOF'
cat /tmp/changelog.slack.md
echo 'EOF'
} >> "$GITHUB_ENV"
- name: Slack Notification
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
env:
+1
View File
@@ -60,6 +60,7 @@ jobs:
NODE_OPTIONS: --max-old-space-size=8192
- run: yarn lint
- run: yarn fmt:check
- run: yarn actionlint
- name: Install scripts dependencies
if: ${{ matrix.coverage }}
run: yarn install --immutable
+1 -1
View File
@@ -42,5 +42,5 @@ jobs:
yarn release
else
echo "Releasing with version $INPUT_VERSION"
yarn release $INPUT_VERSION
yarn release "$INPUT_VERSION"
fi
+50
View File
@@ -8,6 +8,54 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add Supabase integration foundation: GraphQL client, shared integration helpers, and provisioning utilities. ([#4130](https://github.com/expo/eas-cli/pull/4130) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:connect` command. ([#4106](https://github.com/expo/eas-cli/pull/4106) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:dashboard` command. ([#4107](https://github.com/expo/eas-cli/pull/4107) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:disconnect` command. ([#4108](https://github.com/expo/eas-cli/pull/4108) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
### 🧹 Chores
## [21.7.1](https://github.com/expo/eas-cli/releases/tag/v21.7.1) - 2026-08-10
## [21.7.0](https://github.com/expo/eas-cli/releases/tag/v21.7.0) - 2026-08-07
### 🎉 New features
- [build-tools] Label serve-sim metrics artifacts with the sim's device name (from the metrics meta) instead of the raw udid. ([#4127](https://github.com/expo/eas-cli/pull/4127) by [@gwdp](https://github.com/gwdp))
- [build-tools] Set the artifact kind for Argent session artifacts from the MIME type reported by the tool server, so screenshots and screen recordings are grouped and labelled on the simulator session page instead of appearing in an unclassified file list. ([#4154](https://github.com/expo/eas-cli/pull/4154) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [eas-cli] Fail when `--private-key-path` is passed without `updates.codeSigningCertificate` in the resolved app config, instead of ignoring the flag and publishing an unsigned update. ([#4161](https://github.com/expo/eas-cli/pull/4161) by [@gwdp](https://github.com/gwdp))
## [21.6.0](https://github.com/expo/eas-cli/releases/tag/v21.6.0) - 2026-08-05
### 🎉 New features
- [eas-build-job] Replace eas-cli npm dist-tags with cli-versions.json. ([#4147](https://github.com/expo/eas-cli/pull/4147) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Make `eas simulator` the canonical command and keep `eas simulator:start` as an alias. ([#4112](https://github.com/expo/eas-cli/pull/4112) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Validate local composite functions referenced from workflow job hooks during `eas workflow:validate`. ([#4064](https://github.com/expo/eas-cli/pull/4064) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Add `eas sim` as a shortcut for `eas simulator` commands, e.g. `eas sim:list` runs `eas simulator:list`. ([#4150](https://github.com/expo/eas-cli/pull/4150) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Support an optional `max_idle_time_minutes` input on `eas/start_argent_remote_session` and `eas/start_agent_device_remote_session`; the step stops the device run session after that many minutes without observed session events. ([#4157](https://github.com/expo/eas-cli/pull/4157) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [eas-cli] Point accounts without EAS Simulator access at the waitlist: `eas simulator:availability` includes the link (and a `waitlistUrl` field in `--json`), and `eas simulator` now fails early with the same message instead of a generic permission error. ([#4151](https://github.com/expo/eas-cli/pull/4151) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Revert "Pin the default `agent-device` version for remote sessions" now that `agent-device` 0.20.5 fixes the broken release. ([#4144](https://github.com/expo/eas-cli/pull/4144) by [@gwdp](https://github.com/gwdp))
- [build-tools] Make the configure-version gradle script compatible with AGP 9, which removes the `applicationVariants` API; per-output version overrides now fall back to the `androidComponents` variant API. ([#4155](https://github.com/expo/eas-cli/pull/4155) by [@gabrieldonadel](https://github.com/gabrieldonadel))
### 🧹 Chores
- [reviewer] Migrate AI code reviews from GPT to Claude models on the claude-code engine (Sonnet 5 by default, Opus 5 for the security agent). ([#4148](https://github.com/expo/eas-cli/pull/4148) by [@brentvatne](https://github.com/brentvatne))
## [21.5.1](https://github.com/expo/eas-cli/releases/tag/v21.5.1) - 2026-08-03
## [21.5.0](https://github.com/expo/eas-cli/releases/tag/v21.5.0) - 2026-08-03
### 🎉 New features
- [eas-cli] Add `eas submit:list`, `eas submit:view`, `eas submit:retry`, `eas submit:cancel`, and `eas submit:status` commands; list and view include the runtime version and fingerprint of the submitted build, and status shows the live App Store version and TestFlight build states cross-referenced with EAS submissions (Google Play app status is not available through EAS yet). ([#4134](https://github.com/expo/eas-cli/pull/4134) by [@brentvatne](https://github.com/brentvatne))
- [eas-build-job] Add optional `ssh` field on build/job payloads. ([#4083](https://github.com/expo/eas-cli/pull/4083) by [@gwdp](https://github.com/gwdp))
- [eas-build-job] Add an `SSH_SESSION` build phase for upcoming worker SSH support. ([#4029](https://github.com/expo/eas-cli/pull/4029) by [@gwdp](https://github.com/gwdp))
@@ -17,12 +65,14 @@ This is the log of notable changes to EAS CLI and related packages.
- [build-tools] Pass a metrics CORS origin to serve-sim so the dashboard can read a session's live CPU/memory stream cross-origin. ([#3990](https://github.com/expo/eas-cli/pull/3990) by [@gwdp](https://github.com/gwdp))
- [build-tools] Collect each iOS session's serve-sim CPU/memory `/metrics` to a file and upload it as a device run session artifact at teardown. ([#3995](https://github.com/expo/eas-cli/pull/3995) by [@gwdp](https://github.com/gwdp))
- [build-tools] Load local composite function catalogs for hook steps, in both steps-based and native builds. ([#4063](https://github.com/expo/eas-cli/pull/4063) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Add `--name` filter to `eas simulator:list`. ([#4118](https://github.com/expo/eas-cli/pull/4118) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [eas-cli] clean up error handling in local builds. ([#4105](https://github.com/expo/eas-cli/pull/4105) by [@douglowder](https://github.com/douglowder))
- [build-tools] Install ffmpeg when it is missing so Argent screen recording works in EAS Simulator sessions. ([#4110](https://github.com/expo/eas-cli/pull/4110) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Stop simulator job runs when `eas simulator:start` is canceled before the session is ready. ([#4113](https://github.com/expo/eas-cli/pull/4113) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Pin the default `agent-device` version for remote sessions instead of installing `latest`. ([#4142](https://github.com/expo/eas-cli/pull/4142) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
+13
View File
@@ -3,6 +3,19 @@
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).
## EAS Build CLI versions
`cli-versions.json` at the repository root sets which `eas-cli` version EAS Build installs. It has two fields:
- `STAGING`: the version used on the staging build servers.
- `PRODUCTION`: the version used on the production build servers.
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`.
## Choosing the next version
As stated above, the next EAS CLI version is chosen automatically based on the existing changelog entries under the unreleased section in **CHANGELOG.md**.
+4
View File
@@ -0,0 +1,4 @@
{
"STAGING": "21.7.1",
"PRODUCTION": "21.5.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "21.5.0",
"version": "21.8.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1
View File
@@ -3,6 +3,7 @@ _.path = "./tools/bin"
MISE_NODE_COREPACK = "true"
[tools]
actionlint = "1.7.12"
gcloud = "570.0.0"
jq = "1.8.1"
node = "20.19.4"
+1
View File
@@ -11,6 +11,7 @@
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
"watch": "yarn start",
"eas": "packages/eas-cli/bin/run",
"actionlint": "actionlint",
"lint": "oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json .",
"fmt": "oxfmt .",
"fmt:check": "oxfmt --check .",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "21.5.0",
"version": "21.8.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -39,14 +39,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.5.0",
"@expo/eas-build-job": "21.6.0",
"@expo/env": "^0.4.0",
"@expo/logger": "21.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "21.5.0",
"@expo/steps": "21.7.1",
"@expo/template-file": "21.0.2",
"@expo/turtle-spawn": "21.0.0",
"@expo/xcpretty": "^4.3.1",
+7 -7
View File
@@ -4,6 +4,8 @@ import {
BuildStepGlobalContext,
BuildWorkflow,
StepsConfigParser,
buildLocalCompositeFunctionCatalogAsync,
createLocalCompositeFunctionLoader,
errors,
} from '@expo/steps';
import assert from 'assert';
@@ -17,10 +19,6 @@ import { Artifacts, BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { Datadog } from '../datadog';
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
import {
buildCompositeFunctionCatalogAsync,
createCompositeFunctionLoader,
} from '../steps/compositeFunctions';
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
import { getEasFunctions } from '../steps/easFunctions';
import { retryAsync } from '../utils/retry';
@@ -72,11 +70,13 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
steps: ctx.job.steps,
hooks: ctx.job.hooks,
// Eager for job steps (always run), lazy loader for hooks (running anchors only).
compositeFunctionCatalog: await buildCompositeFunctionCatalogAsync(projectRoot, {
steps: ctx.job.steps,
compositeFunctionCatalog: await buildLocalCompositeFunctionCatalogAsync(projectRoot, {
rootSteps: ctx.job.steps,
logger: ctx.logger,
}),
loadCompositeFunction: createLocalCompositeFunctionLoader(projectRoot, {
logger: ctx.logger,
}),
loadCompositeFunction: createCompositeFunctionLoader(projectRoot, ctx.logger),
})
: new BuildConfigParser(globalContext, {
externalFunctions: easFunctions,
+3 -3
View File
@@ -13,13 +13,13 @@ import {
BuildStepGlobalContext,
HookEntry,
constructHookEntriesAsync,
createLocalCompositeFunctionLoader,
extendCompositeFunctionCatalogFromStepsAsync,
validateHookStepsAsync,
} from '@expo/steps';
import { BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { createCompositeFunctionLoader } from '../steps/compositeFunctions';
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
import { getEasFunctions } from '../steps/easFunctions';
@@ -99,9 +99,9 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
const hookEntriesByKey: Partial<Record<HookKey, HookEntry[]>> = {};
const orderedSteps: BuildStep[] = [];
const compositeFunctionCatalog: CompositeFunctionCatalog = {};
const loadCompositeFunction = createCompositeFunctionLoader(
const loadCompositeFunction = createLocalCompositeFunctionLoader(
ctx.getReactNativeProjectDirectory(),
ctx.logger
{ logger: ctx.logger }
);
for (const anchor of wrappedAnchors) {
for (const side of ['before', 'after'] as const) {
+13 -8
View File
@@ -1,16 +1,19 @@
import { BuildPhase, Generic } from '@expo/eas-build-job';
import { Result, asyncResult } from '@expo/results';
import { BuildStepGlobalContext, BuildWorkflow, StepsConfigParser, errors } from '@expo/steps';
import {
BuildStepGlobalContext,
BuildWorkflow,
StepsConfigParser,
buildLocalCompositeFunctionCatalogAsync,
createLocalCompositeFunctionLoader,
errors,
} from '@expo/steps';
import fs from 'fs/promises';
import nullthrows from 'nullthrows';
import { prepareProjectSourcesAsync } from './common/projectSources';
import { BuildContext } from './context';
import { CustomBuildContext } from './customBuildContext';
import {
buildCompositeFunctionCatalogAsync,
createCompositeFunctionLoader,
} from './steps/compositeFunctions';
import { getEasFunctionGroups } from './steps/easFunctionGroups';
import { getEasFunctions } from './steps/easFunctions';
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
@@ -51,8 +54,8 @@ export async function runGenericJobAsync(
try {
const projectRoot = ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory);
// Eager for job steps (always run), lazy loader for hooks (running anchors only).
const compositeFunctionCatalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
steps: ctx.job.steps,
const compositeFunctionCatalog = await buildLocalCompositeFunctionCatalogAsync(projectRoot, {
rootSteps: ctx.job.steps,
logger: ctx.logger,
});
@@ -62,7 +65,9 @@ export async function runGenericJobAsync(
steps: ctx.job.steps,
hooks: ctx.job.hooks,
compositeFunctionCatalog,
loadCompositeFunction: createCompositeFunctionLoader(projectRoot, ctx.logger),
loadCompositeFunction: createLocalCompositeFunctionLoader(projectRoot, {
logger: ctx.logger,
}),
});
return await parser.parseAsync();
@@ -6,6 +6,7 @@ import { Writable } from 'stream';
import { retryAsync } from '../utils/retry';
const MAX_BATCH_BYTES = 200_000;
const REQUEST_TIMEOUT_MS = 10_000;
interface BufferedLogEntry {
enqueuedAt: number;
@@ -161,6 +162,7 @@ export default class HttpLogStream extends Writable {
'Content-Type': 'application/x-ndjson',
},
body: logs.map(log => log.serialized).join('\n'),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
@@ -186,4 +186,21 @@ describe(HttpLogStream.name, () => {
const [firstAttemptLogs, secondAttemptLogs] = parseRequestLogs();
expect(secondAttemptLogs).toEqual(firstAttemptLogs);
});
it('sets a ten-second timeout on HTTP uploads', async () => {
fetchMock.mockResolvedValue(createResponse());
const abortSignalTimeoutSpy = jest.spyOn(AbortSignal, 'timeout');
const stream = new HttpLogStream({
url: 'https://logs.expo.test/build-id',
headers: { Authorization: 'Bearer token' },
logger: createLogger({ name: 'test' }),
});
stream.write({ logId: 'stable-id', msg: 'Do not hang' });
await stream.cleanUp();
expect(abortSignalTimeoutSpy).toHaveBeenCalledWith(10_000);
expect(fetchMock.mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal);
abortSignalTimeoutSpy.mockRestore();
});
});
@@ -1,201 +0,0 @@
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import {
buildCompositeFunctionCatalogAsync,
createCompositeFunctionLoader,
} from '../compositeFunctions';
async function makeProjectWithCompositeFunctionAsync(
functionName: string,
contents: string
): Promise<string> {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-test-'));
const functionDir = path.join(projectRoot, '.eas', 'functions', functionName);
await fs.mkdir(functionDir, { recursive: true });
await fs.writeFile(path.join(functionDir, 'function.yml'), contents, 'utf-8');
return projectRoot;
}
const setupCompositeFunctionContents = [
'name: Setup',
'inputs:',
' - name: greeting',
' type: string',
' default_value: hello',
'outputs:',
' version:',
' value: ${{ steps.read.outputs.version }}',
'runs:',
' steps:',
' - id: read',
' run: set-output version "1.0.0"',
].join('\n');
describe(buildCompositeFunctionCatalogAsync, () => {
it('discovers, reads and validates referenced local composite functions keyed by normalized ref', async () => {
const projectRoot = await makeProjectWithCompositeFunctionAsync(
'setup',
setupCompositeFunctionContents
);
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ uses: './.eas/functions/setup', id: 'setup' }],
});
expect(Object.keys(catalog)).toEqual(['./.eas/functions/setup']);
const action = catalog['./.eas/functions/setup'];
expect(action.name).toBe('Setup');
expect(action.runs.steps).toHaveLength(1);
expect(action.outputs?.version.value).toBe('${{ steps.read.outputs.version }}');
});
it('loads nested composite functions transitively referenced by other composite functions', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-nested-'));
const innerDir = path.join(projectRoot, '.eas', 'functions', 'inner');
const outerDir = path.join(projectRoot, '.eas', 'functions', 'outer');
await fs.mkdir(innerDir, { recursive: true });
await fs.mkdir(outerDir, { recursive: true });
await fs.writeFile(
path.join(innerDir, 'function.yml'),
['runs:', ' steps:', ' - run: echo inner'].join('\n'),
'utf-8'
);
await fs.writeFile(
path.join(outerDir, 'function.yml'),
['runs:', ' steps:', ' - uses: ./.eas/functions/inner'].join('\n'),
'utf-8'
);
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ uses: './.eas/functions/outer', id: 'outer' }],
});
expect(Object.keys(catalog).sort()).toEqual([
'./.eas/functions/inner',
'./.eas/functions/outer',
]);
});
it('returns an empty catalog when there are no referenced composite functions', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-empty-'));
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ run: 'echo hi' }],
});
expect(catalog).toEqual({});
});
it('ignores unreferenced malformed composite functions on disk', async () => {
const projectRoot = await makeProjectWithCompositeFunctionAsync(
'broken',
['name: Broken', 'runs:', ' steps: []'].join('\n')
);
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ run: 'echo hi' }],
});
expect(catalog).toEqual({});
});
it('resolves composite functions referenced by an arbitrary path (arbitrary path style)', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-arbitrary-'));
const functionDir = path.join(projectRoot, 'internal-functions', 'deploy');
await fs.mkdir(functionDir, { recursive: true });
await fs.writeFile(
path.join(functionDir, 'function.yml'),
['runs:', ' steps:', ' - run: echo deploy'].join('\n'),
'utf-8'
);
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ uses: './internal-functions/deploy', id: 'deploy' }],
});
expect(Object.keys(catalog)).toEqual(['./internal-functions/deploy']);
});
it('resolves composite functions defined with a function.yaml extension', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-yaml-'));
const functionDir = path.join(projectRoot, '.eas', 'functions', 'setup');
await fs.mkdir(functionDir, { recursive: true });
await fs.writeFile(
path.join(functionDir, 'function.yaml'),
['runs:', ' steps:', ' - run: echo hi'].join('\n'),
'utf-8'
);
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ uses: './.eas/functions/setup', id: 'setup' }],
});
expect(Object.keys(catalog)).toEqual(['./.eas/functions/setup']);
});
it('loads a shared composite function above the EAS project root', async () => {
const sourceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-monorepo-'));
const projectRoot = path.join(sourceRoot, 'apps', 'my-app');
const functionDir = path.join(sourceRoot, 'shared', 'functions', 'setup');
await fs.mkdir(projectRoot, { recursive: true });
await fs.mkdir(functionDir, { recursive: true });
await fs.writeFile(
path.join(functionDir, 'function.yml'),
['runs:', ' steps:', ' - run: echo shared'].join('\n'),
'utf-8'
);
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ uses: '../../shared/functions/setup', id: 'setup' }],
});
expect(Object.keys(catalog)).toEqual(['../../shared/functions/setup']);
});
it('throws a clear error for a referenced composite function that does not exist', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-missing-'));
await expect(
buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ uses: './.eas/functions/missing', id: 'missing' }],
})
).rejects.toThrow(
/Local composite function "\.\/\.eas\/functions\/missing" was referenced by a step but no such composite function exists/
);
});
it('throws a clear error for a malformed referenced composite function config', async () => {
const projectRoot = await makeProjectWithCompositeFunctionAsync(
'broken',
['name: Broken', 'runs:', ' steps: []'].join('\n')
);
await expect(
buildCompositeFunctionCatalogAsync(projectRoot, {
steps: [{ uses: './.eas/functions/broken', id: 'broken' }],
})
).rejects.toThrow(/must declare at least one step under "runs.steps"/);
});
});
describe(createCompositeFunctionLoader, () => {
it('loads function.yml from disk for a normalized path', async () => {
const projectRoot = await makeProjectWithCompositeFunctionAsync(
'setup',
setupCompositeFunctionContents
);
const loader = createCompositeFunctionLoader(projectRoot);
const config = await loader('./.eas/functions/setup');
expect(config.name).toBe('Setup');
expect(config.runs.steps).toHaveLength(1);
});
it('rejects for a path with no composite function on disk', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-loader-'));
const loader = createCompositeFunctionLoader(projectRoot);
await expect(loader('./.eas/functions/missing')).rejects.toThrow(
/no such composite function exists/
);
});
});
@@ -1,93 +0,0 @@
/**
* Loads local function.yml files from the project and builds the catalog consumed by
* {@link StepsConfigParser}. Keeps filesystem I/O in build-tools; expansion logic lives in @expo/steps.
*/
import { bunyan } from '@expo/logger';
import {
CompositeFunctionCatalog,
CompositeFunctionConfig,
CompositeFunctionConfigZ,
Step,
} from '@expo/eas-build-job';
import {
buildCompositeFunctionCatalogFromStepsAsync,
resolveLocalCompositeFunctionPath,
} from '@expo/steps';
import fs from 'fs/promises';
import path from 'path';
import YAML from 'yaml';
import { ZodError, z } from 'zod';
async function loadLocalCompositeFunctionConfigAsync(
projectRoot: string,
compositeFunctionPath: string,
{ logger }: { logger?: bunyan } = {}
): Promise<CompositeFunctionConfig> {
const resolvedPath = resolveLocalCompositeFunctionPath(projectRoot, compositeFunctionPath);
for (const ext of ['yml', 'yaml'] as const) {
const absolutePath = path.join(resolvedPath, `function.${ext}`);
let rawContents: string;
try {
rawContents = await fs.readFile(absolutePath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
continue;
}
throw new Error(
`Failed to read local composite function "${compositeFunctionPath}" from ${absolutePath}`,
{
cause: err as Error,
}
);
}
let parsed: unknown;
try {
parsed = YAML.parse(rawContents);
} catch (err) {
throw new Error(
`Failed to parse local composite function "${compositeFunctionPath}" YAML at ${absolutePath}`,
{
cause: err as Error,
}
);
}
let config: CompositeFunctionConfig;
try {
config = CompositeFunctionConfigZ.parse(parsed);
} catch (err) {
if (err instanceof ZodError) {
throw new Error(
`Invalid composite function "${compositeFunctionPath}": ${z.prettifyError(err)}`
);
}
throw err;
}
logger?.debug(
`Loaded local composite function "${compositeFunctionPath}" from ${path.relative(projectRoot, absolutePath)}`
);
return config;
}
throw new Error(
`Local composite function "${compositeFunctionPath}" was referenced by a step but no such composite function exists. A local composite function is resolved from a "function.yml" (or "function.yaml") file at the referenced path relative to the EAS project root (e.g. "uses: ${compositeFunctionPath}" resolves "${compositeFunctionPath}/function.yml"). The recommended convention is to keep composite functions under ".eas/functions/<name>".`
);
}
export function createCompositeFunctionLoader(
projectRoot: string,
logger?: bunyan
): (compositeFunctionPath: string) => Promise<CompositeFunctionConfig> {
return compositeFunctionPath =>
loadLocalCompositeFunctionConfigAsync(projectRoot, compositeFunctionPath, { logger });
}
export async function buildCompositeFunctionCatalogAsync(
projectRoot: string,
{ steps, logger }: { steps: readonly Step[]; logger?: bunyan }
): Promise<CompositeFunctionCatalog> {
return buildCompositeFunctionCatalogFromStepsAsync({
rootSteps: steps,
loadCompositeFunction: createCompositeFunctionLoader(projectRoot, logger),
});
}
@@ -63,11 +63,14 @@ const rejectExit1 = (): Error => {
function createStep(
callInputs?: Record<string, unknown>,
options: { env?: Record<string, string | undefined> } = {}
options: {
env?: Record<string, string | undefined>;
logger?: ReturnType<typeof createMockLogger>;
} = {}
): ReturnType<
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
> {
const logger = createMockLogger();
const logger = options.logger ?? createMockLogger();
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
} as unknown as CustomBuildContext;
@@ -134,6 +137,253 @@ describe('createMaestroTestsBuildFunction', () => {
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
});
it('uses direct DADB when android_connection_mode is dadb without cleanup', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const writeFileSpy = jest.spyOn(fs, 'writeFile');
const rmSpy = jest.spyOn(fs, 'rm');
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'android',
android_connection_mode: 'dadb',
},
{ env: { HOME: '/home/expo', PATH: '/usr/bin' }, logger }
);
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(2);
expect(mockedSpawn.mock.calls[0].slice(0, 2)).toEqual(['adb', ['kill-server']]);
expect(mockedSpawn.mock.calls[1][0]).toBe('maestro');
expect(mockedSpawn.mock.calls[0][2]?.signal).toBe(mockedSpawn.mock.calls[1][2]?.signal);
const maestroEnv = mockedSpawn.mock.calls[1][2]?.env;
expect(maestroEnv?.PATH).toMatch(
new RegExp(`^${os.tmpdir()}/maestro-tests-adb-override-.+${path.delimiter}/usr/bin$`)
);
expect(mockedSpawn.mock.calls[0][2]?.env?.PATH).not.toContain('maestro-tests-adb-override-');
expect(logger.info).toHaveBeenCalledWith(
'Using a direct DADB connection for Android Maestro tests after stopping the ADB server.'
);
expect(writeFileSpy).toHaveBeenCalledWith(
expect.stringMatching(/maestro-tests-adb-override-.+\/adb$/),
'#!/bin/sh\nexit 1\n',
{ mode: 0o755 }
);
expect(rmSpy).not.toHaveBeenCalledWith(
expect.stringMatching(/maestro-tests-adb-override-/),
expect.anything()
);
});
it('continues with direct DADB and warns when adb kill-server fails', async () => {
const killServerError = new Error('ADB server is unavailable');
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'adb') {
throw killServerError;
}
return SPAWN_SUCCESS;
}) as any);
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'android',
android_connection_mode: 'dadb',
},
{ env: { HOME: '/home/expo', PATH: '/usr/bin' }, logger }
);
await step.executeAsync();
expect(mockedSpawn.mock.calls.map(([command]) => command)).toEqual(['adb', 'maestro']);
expect(logger.warn).toHaveBeenCalledWith(
{ err: killServerError },
'Using a direct DADB connection for Android Maestro tests, but failed to stop the ADB server.'
);
});
it.each([
{
source: 'omitted',
callInputs: { flow_path: ['flows/a.yaml'], platform: 'android' },
env: { HOME: '/home/expo' },
},
{
source: 'empty input',
callInputs: { flow_path: ['flows/a.yaml'], platform: 'android', android_connection_mode: '' },
env: { HOME: '/home/expo' },
},
{
source: 'empty environment variable',
callInputs: { flow_path: ['flows/a.yaml'], platform: 'android' },
env: { HOME: '/home/expo', EAS_MAESTRO_ANDROID_CONNECTION_MODE: '' },
},
])('treats an $source connection mode value as adb', async ({ callInputs, env }) => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(callInputs, { env });
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
});
it('uses direct DADB when EAS_MAESTRO_ANDROID_CONNECTION_MODE is dadb', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'android',
},
{
env: {
HOME: '/home/expo',
PATH: '/usr/bin',
EAS_MAESTRO_ANDROID_CONNECTION_MODE: 'dadb',
},
}
);
await step.executeAsync();
expect(mockedSpawn.mock.calls[0].slice(0, 2)).toEqual(['adb', ['kill-server']]);
expect(mockedSpawn.mock.calls[1][0]).toBe('maestro');
});
it('prefers android_connection_mode over EAS_MAESTRO_ANDROID_CONNECTION_MODE', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'android',
android_connection_mode: 'adb',
},
{
env: {
HOME: '/home/expo',
PATH: '/usr/bin',
EAS_MAESTRO_ANDROID_CONNECTION_MODE: 'dadb',
},
}
);
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
});
it('uses the same direct DADB environment for all retries', async () => {
let maestroAttempt = 0;
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'maestro' && maestroAttempt++ === 0) {
throw rejectExit1();
}
return SPAWN_SUCCESS;
}) as any);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(null);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
retries: 1,
platform: 'android',
android_connection_mode: 'dadb',
},
{ env: { HOME: '/home/expo', PATH: '/usr/bin' } }
);
await step.executeAsync();
const maestroCalls = mockedSpawn.mock.calls.filter(([command]) => command === 'maestro');
expect(maestroCalls).toHaveLength(2);
expect(maestroCalls[0][2]?.env?.PATH).toBe(maestroCalls[1][2]?.env?.PATH);
expect(mockedSpawn.mock.calls.filter(([command]) => command === 'adb')).toHaveLength(1);
});
it('rejects invalid android_connection_mode values', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
android_connection_mode: 'automatic',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('rejects invalid EAS_MAESTRO_ANDROID_CONNECTION_MODE values', async () => {
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'android',
},
{ env: { HOME: '/home/expo', EAS_MAESTRO_ANDROID_CONNECTION_MODE: 'automatic' } }
);
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('ignores direct DADB input mode on iOS', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'ios',
android_connection_mode: 'dadb',
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
});
it('ignores direct DADB environment mode on iOS', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'ios',
},
{ env: { HOME: '/home/expo', EAS_MAESTRO_ANDROID_CONNECTION_MODE: 'dadb' } }
);
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
});
it('does not clean up direct DADB mode when Maestro fails', async () => {
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'maestro') {
throw new Error('maestro crashed');
}
return SPAWN_SUCCESS;
}) as any);
const rmSpy = jest.spyOn(fs, 'rm');
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'android',
android_connection_mode: 'dadb',
},
{ env: { HOME: '/home/expo', PATH: '/usr/bin' } }
);
await expect(step.executeAsync()).rejects.toThrow('Unexpected spawn failure invoking maestro');
expect(mockedSpawn.mock.calls.filter(([command]) => command === 'adb')).toHaveLength(1);
expect(rmSpy).not.toHaveBeenCalledWith(
expect.stringMatching(/maestro-tests-adb-override-/),
expect.anything()
);
});
it('throws SystemError when spawn fails with ENOENT (binary missing)', async () => {
mockedSpawn.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
@@ -1,4 +1,5 @@
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
@@ -34,6 +35,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedMkdtemp = jest.spyOn(fs.promises, 'mkdtemp');
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
const logger = createMockLogger();
const fn = createStartAndroidEmulatorBuildFunction();
@@ -54,6 +56,7 @@ function createStartResult(serialId: string) {
describe(createStartAndroidEmulatorBuildFunction, () => {
beforeEach(() => {
mockedMkdtemp.mockResolvedValue('/tmp/logcat-directory');
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
@@ -76,6 +79,10 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
});
});
afterAll(() => {
mockedMkdtemp.mockRestore();
});
it('retries base emulator startup with increasing readiness timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
@@ -106,6 +113,20 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
serialId: 'emulator-2222',
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-1111',
@@ -174,6 +195,24 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
serialId: 'emulator-clone-2-attempt-1',
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceName: 'eas-simulator-1',
logcatDirectory: '/tmp/logcat-directory',
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceName: 'eas-simulator-2',
logcatDirectory: '/tmp/logcat-directory',
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -200,6 +239,24 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(3);
});
it('outputs and uses a temporary logcat directory', async () => {
const step = createStep();
await step.executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
logcatDirectory: '/tmp/logcat-directory',
})
);
expect(step.getOutputValueByName('logcat_directory')).toBe('/tmp/logcat-directory');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-default',
})
);
});
it('skips animation scale adjustments when opt out env var is disabled', async () => {
@@ -68,7 +68,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(pollArgentArtifactsForUploadAsync).mockResolvedValue(undefined);
mockStopAsync.mockResolvedValue(undefined);
mockTunnelStopAsync.mockResolvedValue(undefined);
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({ stopAsync: mockStopAsync });
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({
stopAsync: mockStopAsync,
getLastEventObservedAt: () => undefined,
});
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
@@ -108,7 +111,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: { package_version: { value: undefined } },
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: { EXISTING: 'value' },
} as never
@@ -35,6 +35,7 @@ import { sleepAsync } from '../../utils/retry';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
const RetriesSchema = z.number().int().min(0).default(0);
const ShardsSchema = z.number().int().min(1).optional();
const AndroidConnectionModeSchema = z.enum(['adb', 'dadb']).default('adb');
function parseInput<S extends z.ZodTypeAny>(
schema: S,
@@ -143,6 +144,11 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'android_connection_mode',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
@@ -180,7 +186,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
// Public docs (EAS workflows pre-packaged-jobs) document
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
// the uploaded dir.
const spawnEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
const spawnEnv: NodeJS.ProcessEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
// Outputs are published BEFORE any throw below so downstream
// `if: always()` upload steps still see populated values when this
@@ -206,6 +212,13 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
inputs.shards.value,
'shards must be a positive integer.'
);
const androidConnectionMode = parseInput(
AndroidConnectionModeSchema,
inputs.android_connection_mode.value ||
env.EAS_MAESTRO_ANDROID_CONNECTION_MODE ||
undefined,
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
);
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
try {
@@ -232,6 +245,40 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
const harvested: HarvestedScreenshot[] = [];
const totalAttempts = retries + 1;
if (platform === 'android' && androidConnectionMode === 'dadb') {
try {
const adbOverrideDirectoryPath = await fs.mkdtemp(
path.join(os.tmpdir(), 'maestro-tests-adb-override-')
);
await fs.writeFile(path.join(adbOverrideDirectoryPath, 'adb'), '#!/bin/sh\nexit 1\n', {
mode: 0o755,
});
// DADB starts an ADB server when it can find an adb binary. Stop the existing
// server first, then make only the Maestro process find the failing shim.
try {
await spawn('adb', ['kill-server'], { env: { ...spawnEnv }, logger, signal });
logger.info(
'Using a direct DADB connection for Android Maestro tests after stopping the ADB server.'
);
} catch (err) {
logger.warn(
{ err },
'Using a direct DADB connection for Android Maestro tests, but failed to stop the ADB server.'
);
}
spawnEnv.PATH = spawnEnv.PATH
? `${adbOverrideDirectoryPath}${path.delimiter}${spawnEnv.PATH}`
: adbOverrideDirectoryPath;
} catch (err) {
// Intentionally skip cleanup because the worker is disposable.
throw new SystemError('Failed to enable direct DADB connection for Maestro', {
cause: err,
});
}
// Do not restart ADB or remove the override. This keeps Maestro in direct DADB mode.
}
for (let attempt = 0; attempt <= retries; attempt++) {
const outputPath =
outputFormat === 'junit'
@@ -55,6 +55,11 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env
@@ -66,6 +71,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
// A missing or non-positive value disables the idle timeout (opt-in feature).
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const { runtimePlatform } = global;
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
@@ -140,6 +147,13 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
deviceRunSessionId,
logger,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
}
: undefined,
});
} finally {
if (serveSim) {
@@ -5,8 +5,12 @@ import {
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} 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 {
AndroidDeviceName,
@@ -49,7 +53,18 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger }, { inputs, env }) => {
outputProviders: [
BuildStepOutput.createProvider({
id: 'logcat_directory',
required: true,
}),
],
fn: async ({ logger }, { inputs, outputs, env }) => {
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
outputs.logcat_directory.set(logcatDirectory);
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
await assertAndroidEmulatorHostSupportAsync({ env });
} else {
@@ -122,6 +137,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
logcatDirectory,
});
attemptSerialId = startResult.serialId;
await AndroidEmulatorUtils.waitForReadyAsync({
@@ -211,6 +227,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: cloneIdentifier,
env,
logcatDirectory,
});
cloneSerialId = startResult.serialId;
@@ -68,6 +68,11 @@ export function createStartArgentRemoteSessionBuildFunction(
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env
@@ -79,6 +84,8 @@ export function createStartArgentRemoteSessionBuildFunction(
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
// A missing or non-positive value disables the idle timeout (opt-in feature).
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
@@ -215,6 +222,13 @@ export function createStartArgentRemoteSessionBuildFunction(
deviceRunSessionId,
logger,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
}
: undefined,
});
} finally {
if (serveSim) {
@@ -159,6 +159,37 @@ describe(startAgentDeviceEventCollectionAsync, () => {
);
});
it('tracks the local arrival time of the most recent event', async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-device-events-'));
const eventsDir = path.join(stateDir, 'sessions', 'default');
await fs.promises.mkdir(eventsDir, { recursive: true });
const collection = await startAgentDeviceEventCollectionAsync({
ctx: createContext(),
deviceRunSessionId: 'session-id',
stateDir,
logger: createLogger(),
pollIntervalMs: 10,
});
try {
expect(collection.getLastEventObservedAt()).toBeUndefined();
const before = new Date();
await fs.promises.writeFile(
path.join(eventsDir, 'events.ndjson'),
`${JSON.stringify(createAgentDeviceEvent({ requestId: 'request-1', command: 'tap' }))}\n`
);
await waitForAsync(() => expect(mockEventLogStream.write).toHaveBeenCalledTimes(1));
const observedAt = collection.getLastEventObservedAt();
expect(observedAt).toBeInstanceOf(Date);
expect(observedAt!.getTime()).toBeGreaterThanOrEqual(before.getTime());
} finally {
await collection.stopAsync();
await fs.promises.rm(stateDir, { recursive: true, force: true });
}
});
it('preserves unknown kinds and tolerates optional upstream field changes', async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-device-events-'));
const eventsDir = path.join(stateDir, 'sessions', 'default');
@@ -547,6 +578,9 @@ describe(startAgentDeviceEventCollectionAsync, () => {
);
expect(mockEventLogStream.init).not.toHaveBeenCalled();
expect(HttpLogStream).not.toHaveBeenCalled();
// Without collection, activity is invisible: the collection reports fresh
// activity so an enabled idle timeout never stops the session.
expect(collection.getLastEventObservedAt()).toBeInstanceOf(Date);
});
it('continues persisting the artifact when real-time log setup fails', async () => {
@@ -135,10 +135,69 @@ describe(uploadArgentArtifactAsync, () => {
artifactId: 'artifact-id',
name: 'report.json (artifact-id)',
filename: 'report.json',
kind: undefined,
size: data.length,
stream: expect.anything(),
});
});
async function uploadAsync(artifact: {
id: string;
filename: string;
mimeType: string;
isDirectory?: boolean;
}): Promise<CustomBuildContext> {
const ctx = {} as unknown as CustomBuildContext;
jest
.mocked(fetch)
.mockResolvedValueOnce(new Response(Readable.from([Buffer.from('artifact-data')])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadArgentArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
toolsUrl: 'http://127.0.0.1:1234',
logger: createLoggerMock(),
artifact,
});
return ctx;
}
it.each([
{ mimeType: 'image/png', filename: 'screen.png', kind: 'screenshot' },
{ mimeType: 'image/jpeg', filename: 'screen.jpg', kind: 'screenshot' },
// Media types are case-insensitive and may carry parameters.
{ mimeType: 'IMAGE/PNG; charset=binary', filename: 'screen.png', kind: 'screenshot' },
{ mimeType: 'video/mp4', filename: 'session.mp4', kind: 'screen-recording' },
{ mimeType: 'video/quicktime', filename: 'session.mov', kind: 'screen-recording' },
{ mimeType: 'application/json', filename: 'report.json', kind: undefined },
{ mimeType: 'text/plain', filename: 'log.txt', kind: undefined },
])('uploads $mimeType with kind $kind', async ({ mimeType, filename, kind }) => {
const ctx = await uploadAsync({ id: 'artifact-id', filename, mimeType });
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ filename, kind })
);
});
it('leaves directory artifacts unclassified because they are uploaded as a tarball', async () => {
const ctx = await uploadAsync({
id: 'artifact-id',
filename: 'screenshots',
mimeType: 'image/png',
isDirectory: true,
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ filename: 'screenshots.tar.gz', kind: undefined })
);
});
});
describe(pollArgentArtifactsForUploadAsync, () => {
@@ -51,7 +51,8 @@ function createStatusCtxMock(
| { status: 'NEW' | 'IN_PROGRESS' | 'STOPPED' | 'ERRORED' }
| { error: Error }
| { data: unknown }
)[]
)[],
{ ensureStoppedError }: { ensureStoppedError?: Error } = {}
): CustomBuildContext {
const query = jest.fn(() => {
const result = results.shift();
@@ -80,9 +81,25 @@ function createStatusCtxMock(
};
});
const mutation = jest.fn(() => ({
toPromise: async () => {
if (ensureStoppedError) {
return { error: ensureStoppedError };
}
return {
data: {
deviceRunSession: {
ensureDeviceRunSessionStopped: { id: 'drs-id', status: 'STOPPED' },
},
},
};
},
}));
return {
graphqlClient: {
query,
mutation,
},
} as unknown as CustomBuildContext;
}
@@ -423,6 +440,97 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
{ level: 'warning' }
);
});
describe('with an idle timeout', () => {
beforeEach(() => {
// Fake timers make Date.now() advance by exactly the poll interval per
// loop iteration, so idle time accumulates deterministically.
jest.useFakeTimers();
jest.mocked(setTimeoutAsync).mockImplementation(async delayMs => {
jest.advanceTimersByTime(delayMs ?? 0);
return undefined;
});
});
afterEach(() => {
jest.useRealTimers();
});
function manyInProgressStatuses(): { status: 'IN_PROGRESS' }[] {
return Array.from({ length: 20 }, () => ({ status: 'IN_PROGRESS' as const }));
}
it('stops the session when no activity is observed within the max idle time', async () => {
const ctx = createStatusCtxMock(manyInProgressStatuses());
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
idleTimeout: {
maxIdleTimeMinutes: 1,
getLastEventObservedAt: () => undefined,
},
});
// One minute at the 5-second poll interval is 12 status polls.
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(12);
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id had no activity for 1 minute(s) (max idle time). Stopping the session.'
);
});
it('keeps the session alive while events keep arriving', async () => {
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'STOPPED' }]);
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
idleTimeout: {
maxIdleTimeMinutes: 1,
// Fresh activity on every check; 20 polls exceed one minute, so the
// session would have been stopped without these events.
getLastEventObservedAt: () => new Date(),
},
});
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(21);
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
});
it('still returns when the session cannot be marked stopped', async () => {
const ctx = createStatusCtxMock(manyInProgressStatuses(), {
ensureStoppedError: new Error('forbidden'),
});
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
idleTimeout: {
maxIdleTimeMinutes: 1,
getLastEventObservedAt: () => undefined,
},
});
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Could not mark device run session drs-id as stopped. The session job ends anyway.'
);
expect(jest.mocked(Sentry).capture).toHaveBeenCalledWith(
'Could not mark idle device run session as stopped',
expect.any(Error),
{ level: 'warning', extras: { deviceRunSessionId: 'drs-id' } }
);
});
});
});
describe(ensureFfmpegInstalledAsync, () => {
@@ -83,6 +83,36 @@ describe(uploadServeSimMetricsFileAsync, () => {
);
});
it('labels the artifact with the device name when the meta carries one', async () => {
await writeFile(filePath, NDJSON);
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementation(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadServeSimMetricsFileAsync(ctx, {
deviceRunSessionId: 'session-id',
udid: 'AAAA',
filePath,
metadata: {
deviceName: 'iPhone 15 Pro',
hostCores: 8,
sampleIntervalMs: 1000,
schemaVersion: 1,
},
logger: createLoggerMock(),
});
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
name: 'Performance metrics (iPhone 15 Pro)',
metadata: expect.objectContaining({ deviceName: 'iPhone 15 Pro', udid: 'AAAA' }),
})
);
});
it('skips the upload when the file is missing', async () => {
await uploadServeSimMetricsFileAsync(ctx, {
deviceRunSessionId: 'session-id',
@@ -6,6 +6,7 @@ import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import {
type DeviceRunSessionEvent,
type DeviceRunSessionEventCollection,
type DeviceRunSessionEventParseFailure,
startDeviceRunSessionEventCollectionAsync,
} from './deviceRunSessionEvents';
@@ -46,7 +47,7 @@ export async function startAgentDeviceEventCollectionAsync({
stateDir: string;
logger: bunyan;
pollIntervalMs?: number;
}): Promise<{ stopAsync: () => Promise<void> }> {
}): Promise<DeviceRunSessionEventCollection> {
return startDeviceRunSessionEventCollectionAsync({
ctx,
deviceRunSessionId,
@@ -23,7 +23,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -33,6 +38,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -56,7 +73,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -66,6 +88,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -91,7 +125,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -101,6 +140,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -126,7 +177,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -136,6 +192,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -163,7 +231,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -173,6 +246,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -200,7 +285,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -210,6 +300,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -237,7 +339,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -247,6 +354,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -274,7 +393,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -284,6 +408,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -18,6 +18,18 @@ const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
const ARGENT_ARTIFACT_UPLOAD_CLEANUP_TIMEOUT_MS = 30_000;
const ARGENT_ARTIFACT_FETCH_TIMEOUT_MS = 10_000;
// Kinds the EAS dashboard groups device run session artifacts by. Only media types the dashboard
// renders specially are mapped; anything else stays unclassified and lands in its "Other" group.
// Note that these kinds only affect grouping and labelling. The dashboard gates its inline video
// player on the `__eas_screen_recording` metadata flag, which Argent artifacts deliberately do not
// set, so a `screen-recording` kind here does not add a player to the session page.
const ARGENT_ARTIFACT_KIND_BY_MIME_TYPE = new Map<string, string>([
['image/png', 'screenshot'],
['image/jpeg', 'screenshot'],
['video/mp4', 'screen-recording'],
['video/quicktime', 'screen-recording'],
]);
const ArgentArtifactSchema = z.object({
id: z.string(),
filename: z.string(),
@@ -30,6 +42,17 @@ const ArgentArtifactsListResponseSchema = z.object({
type ArgentArtifact = z.infer<typeof ArgentArtifactSchema>;
function getArgentArtifactKind(artifact: ArgentArtifact): string | undefined {
// Directories are repackaged as a tarball before upload, so the reported media type describes the
// contents rather than the file we actually store.
if (artifact.isDirectory) {
return undefined;
}
// Media types are case-insensitive and may carry parameters, e.g. `image/png; charset=binary`.
const mediaType = artifact.mimeType.split(';')[0].trim().toLowerCase();
return ARGENT_ARTIFACT_KIND_BY_MIME_TYPE.get(mediaType);
}
export async function pollArgentArtifactsForUploadAsync(
ctx: CustomBuildContext,
{
@@ -184,7 +207,7 @@ export async function uploadArgentArtifactAsync(
artifactId: artifact.id,
name: `${filename} (${artifact.id})`,
filename,
kind: undefined,
kind: getArgentArtifactKind(artifact),
size,
stream: createReadStream(temporaryArtifactPath),
});
@@ -6,6 +6,7 @@ import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import {
type DeviceRunSessionEvent,
type DeviceRunSessionEventCollection,
type DeviceRunSessionEventParseFailure,
startDeviceRunSessionEventCollectionAsync,
} from './deviceRunSessionEvents';
@@ -75,7 +76,7 @@ export async function startArgentEventCollectionAsync({
eventLogPath: string;
logger: bunyan;
pollIntervalMs?: number;
}): Promise<{ stopAsync: () => Promise<void> }> {
}): Promise<DeviceRunSessionEventCollection> {
return startDeviceRunSessionEventCollectionAsync({
ctx,
deviceRunSessionId,
@@ -94,6 +94,17 @@ type EventFileState = {
decoder: StringDecoder;
};
export type DeviceRunSessionEventCollection = {
stopAsync: () => Promise<void>;
/**
* Local arrival time of the most recently collected event, or `undefined`
* when no event has been observed yet. Uses the collector's clock rather
* than the event's embedded timestamp so producer clock skew cannot affect
* idle detection.
*/
getLastEventObservedAt: () => Date | undefined;
};
export async function startDeviceRunSessionEventCollectionAsync({
ctx,
deviceRunSessionId,
@@ -106,7 +117,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
source: DeviceRunSessionEventSource;
logger: bunyan;
pollIntervalMs?: number;
}): Promise<{ stopAsync: () => Promise<void> }> {
}): Promise<DeviceRunSessionEventCollection> {
const { producer } = source;
let didReportEventLogFailure = false;
const reportEventLogFailure = (error: Error, operation: 'setup' | 'cleanup'): void => {
@@ -136,7 +147,13 @@ export async function startDeviceRunSessionEventCollectionAsync({
const error = err instanceof Error ? err : new Error(String(err));
logger.warn({ err: error }, 'Could not persist device run session events to the artifact.');
reportEventLogFailure(error, 'setup');
return { stopAsync: async () => {} };
return {
stopAsync: async () => {},
// Collection is disabled, so session activity is invisible here. Report
// fresh activity so an enabled idle timeout never stops a session it
// cannot observe.
getLastEventObservedAt: () => new Date(),
};
}
let didReportRealtimeLogFailure = false;
@@ -166,6 +183,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
const states = new Map<string, EventFileState>();
const controller = new AbortController();
let lastEventObservedAt: Date | undefined;
let parseFailureCount = 0;
const parseFailureCounts: Record<DeviceRunSessionEventParseFailure, number> = {
'invalid-json': 0,
@@ -190,6 +208,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
source,
deviceRunSessionId,
writeEvent: event => {
lastEventObservedAt = new Date();
eventLogStream.write(event);
realtimeLogStream?.write({ ...event, logId: event.eventId });
},
@@ -259,6 +278,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
});
return {
getLastEventObservedAt: () => lastEventObservedAt,
stopAsync: async () => {
controller.abort();
await pollingPromise;
@@ -310,7 +330,7 @@ function createRealtimeLogStream(
? 'http://localhost:4999/logs/'
: ctx.env.EXPO_STAGING
? 'https://staging-logs.expo.dev/logs/'
: undefined;
: 'https://logs.expo.dev/logs/';
const jobRunId = ctx.env.EAS_BUILD_ID;
const robotAccessToken = ctx.job.secrets?.robotAccessToken;
if (!baseUrl || !jobRunId || !robotAccessToken) {
@@ -50,6 +50,17 @@ const DEVICE_RUN_SESSION_STATUS_QUERY = graphql(`
}
`);
const ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION = graphql(`
mutation EnsureDeviceRunSessionStopped($deviceRunSessionId: ID!) {
deviceRunSession {
ensureDeviceRunSessionStopped(deviceRunSessionId: $deviceRunSessionId) {
id
status
}
}
}
`);
const DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS = 5_000;
export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string {
@@ -118,23 +129,56 @@ export async function selectXcodeDeveloperDirectoryAsync({
});
}
export type DeviceRunSessionIdleTimeout = {
/** Stop the session after this many minutes without observed activity. */
maxIdleTimeMinutes: number;
/**
* Local arrival time of the most recent session event, or `undefined` when
* no event has been observed yet. The idle clock starts when the wait
* begins, so a session nobody ever connects to still times out.
*/
getLastEventObservedAt: () => Date | undefined;
};
export async function waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
idleTimeout,
}: {
ctx: CustomBuildContext;
deviceRunSessionId: string;
logger: bunyan;
signal?: AbortSignal;
idleTimeout?: DeviceRunSessionIdleTimeout;
}): Promise<void> {
logger.info(
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
);
if (idleTimeout) {
logger.info(
`The session stops automatically after ${idleTimeout.maxIdleTimeMinutes} minute(s) without activity.`
);
}
let pollErrorCount = 0;
let lastActivityAt = new Date();
while (!signal?.aborted) {
if (idleTimeout) {
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
lastActivityAt = lastEventObservedAt;
}
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
logger.info(
`Device run session ${deviceRunSessionId} had no activity for ` +
`${idleTimeout.maxIdleTimeMinutes} minute(s) (max idle time). Stopping the session.`
);
await ensureDeviceRunSessionStoppedSafelyAsync({ ctx, deviceRunSessionId, logger });
return;
}
}
try {
const result = await ctx.graphqlClient
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
@@ -174,6 +218,37 @@ export async function waitForDeviceRunSessionStoppedAsync({
}
}
// Best effort: when this fails, the caller still tears the session down and the
// job run finishes, which clients also treat as the session ending.
async function ensureDeviceRunSessionStoppedSafelyAsync({
ctx,
deviceRunSessionId,
logger,
}: {
ctx: CustomBuildContext;
deviceRunSessionId: string;
logger: bunyan;
}): Promise<void> {
try {
const result = await ctx.graphqlClient
.mutation(ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION, { deviceRunSessionId })
.toPromise();
if (result.error) {
throw result.error;
}
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not mark idle device run session as stopped', error, {
level: 'warning',
extras: { deviceRunSessionId },
});
logger.warn(
{ err: error },
`Could not mark device run session ${deviceRunSessionId} as stopped. The session job ends anyway.`
);
}
}
async function sleepUntilAbortedAsync(
timeoutMs: number,
signal: AbortSignal | undefined
@@ -36,17 +36,19 @@ export async function uploadServeSimMetricsFileAsync(
logger.info(`serve-sim metrics file for ${udid} is empty; skipping upload.`);
return;
}
const deviceName = typeof metadata?.deviceName === 'string' ? metadata.deviceName : undefined;
try {
logger.info(`Uploading serve-sim metrics for ${udid} (${formatBytes(size)}).`);
logger.info(`Uploading serve-sim metrics for ${deviceName ?? udid} (${formatBytes(size)}).`);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: `metrics-${udid}`,
name: `Performance metrics (${udid.slice(0, 8)})`,
name: `Performance metrics (${deviceName ?? udid.slice(0, 8)})`,
filename: METRICS_ARTIFACT_FILENAME,
kind: 'performance-metrics',
metadata: {
__eas_type: 'performance-metrics',
udid,
...(deviceName && { deviceName }),
...(metadata && {
hostCores: metadata.hostCores,
sampleIntervalMs: metadata.sampleIntervalMs,
@@ -20,7 +20,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -30,5 +35,17 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
`;
@@ -1,8 +1,10 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import assert from 'assert';
import FastGlob from 'fast-glob';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -294,10 +296,34 @@ export namespace AndroidEmulatorUtils {
export async function startAsync({
deviceName,
env,
logcatDirectory,
}: {
deviceName: AndroidVirtualDeviceName;
env: NodeJS.ProcessEnv;
}): Promise<{ emulatorPromise: SpawnPromise<SpawnResult>; serialId: AndroidDeviceSerialId }> {
logcatDirectory: string;
}): Promise<{
emulatorPromise: SpawnPromise<SpawnResult>;
serialId: AndroidDeviceSerialId;
logcatOutputPath: string;
}> {
let logcatOutputPath: string;
try {
await fs.promises.mkdir(logcatDirectory, { recursive: true });
const safeDeviceName = deviceName.replace(/[^a-zA-Z0-9_.-]/g, '_');
const timestamp = Math.floor(Date.now() / 1000)
.toString(16)
.padStart(8, '0');
logcatOutputPath = path.join(
logcatDirectory,
`${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}.log`
);
await fs.promises.writeFile(logcatOutputPath, '');
} catch (err) {
throw new SystemError(`Failed to prepare Android emulator logcat output for ${deviceName}.`, {
cause: err,
});
}
const emulatorPromise = spawn(
`${process.env.ANDROID_HOME}/emulator/emulator`,
[
@@ -306,6 +332,10 @@ export namespace AndroidEmulatorUtils {
'-writable-system',
'-noaudio',
'-no-snapshot-save',
'-logcat',
'*:v',
'-logcat-output',
logcatOutputPath,
'-avd',
deviceName,
'-accel',
@@ -352,7 +382,7 @@ export namespace AndroidEmulatorUtils {
// We don't want to await the SpawnPromise here.
// eslint-disable-next-line @typescript-eslint/return-await
return { emulatorPromise, serialId };
return { emulatorPromise, serialId, logcatOutputPath };
}
export async function waitForReadyAsync({
@@ -1,6 +1,9 @@
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout } from 'timers/promises';
import { createMockLogger } from '../../__tests__/utils/logger';
@@ -16,6 +19,8 @@ jest.unmock('fs');
jest.unmock('node:fs');
describe('AndroidEmulatorUtils', () => {
const logcatDirectory = path.join(os.tmpdir(), 'android-emulator-utils-integration-logs');
beforeEach(async () => {
const devices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env: process.env });
for (const { serialId } of devices) {
@@ -42,6 +47,7 @@ describe('AndroidEmulatorUtils', () => {
console.error('Failed to delete emulator', error);
}
}
await fs.promises.rm(logcatDirectory, { force: true, recursive: true });
});
describe('getAvailableDevicesAsync', () => {
@@ -71,6 +77,7 @@ describe('AndroidEmulatorUtils', () => {
({ serialId } = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
}));
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
@@ -94,6 +101,7 @@ describe('AndroidEmulatorUtils', () => {
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
});
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
@@ -123,6 +131,7 @@ describe('AndroidEmulatorUtils', () => {
await AndroidEmulatorUtils.startAsync({
deviceName: cloneDeviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
});
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: serialIdClone,
@@ -156,6 +165,7 @@ describe('AndroidEmulatorUtils', () => {
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
});
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
@@ -206,6 +216,7 @@ describe('AndroidEmulatorUtils', () => {
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env: envForAttempt,
logcatDirectory,
});
serialId = startResult.serialId;
emulatorPromise = asyncResult(startResult.emulatorPromise);
@@ -262,6 +273,73 @@ describe('AndroidEmulatorUtils', () => {
expect(attemptCounter).toBe(2);
}, 360_000);
it('captures native logcat output across an ADB server restart', async () => {
const testLogcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'android-emulator-logcat-e2e-')
);
const deviceName =
`android-emulator-logcat-e2e-${randomUUID().slice(0, 8)}` as AndroidVirtualDeviceName;
let emulatorPromise: Promise<unknown> | null = null;
let serialId: AndroidDeviceSerialId | null = null;
try {
const logger = createMockLogger({ logToConsole: true });
const marker = `ENG-20762-${randomUUID()}`;
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
env: process.env,
logger,
});
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory: testLogcatDirectory,
});
serialId = startResult.serialId;
emulatorPromise = asyncResult(startResult.emulatorPromise);
await AndroidEmulatorUtils.waitForReadyAsync({ serialId, env: process.env });
await spawn('adb', ['kill-server'], { env: process.env });
await spawn('adb', ['start-server'], { env: process.env });
await spawn('adb', ['-s', serialId, 'shell', 'log', '-t', 'EAS_CLI_TEST', marker], {
env: process.env,
});
await retryAsync(
async () => {
const contents = await fs.promises.readFile(startResult.logcatOutputPath, 'utf-8');
if (!contents.includes(marker)) {
throw new Error(`Did not find marker ${marker} in logcat output yet.`);
}
},
{
logger,
retryOptions: {
retries: 10,
retryIntervalMs: 1_000,
},
}
);
} finally {
try {
await AndroidEmulatorUtils.deleteAsync({
...(serialId ? { serialId } : {}),
deviceName,
env: process.env,
});
} catch (error) {
console.warn('Failed to clean up emulator during logcat integration test', error);
}
if (emulatorPromise) {
await emulatorPromise;
}
await fs.promises.rm(testLogcatDirectory, { recursive: true, force: true });
}
}, 180_000);
it('should work with screen recording', async () => {
const deviceName = 'android-emulator-screen-recording-test' as AndroidVirtualDeviceName;
await AndroidEmulatorUtils.createAsync({
@@ -275,6 +353,7 @@ describe('AndroidEmulatorUtils', () => {
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
});
await AndroidEmulatorUtils.waitForReadyAsync({
@@ -1,7 +1,11 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createMockLogger } from '../../__tests__/utils/logger';
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
import { AndroidEmulatorUtils, AndroidVirtualDeviceName } from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
jest.mock('@expo/turtle-spawn', () => ({
@@ -17,11 +21,96 @@ const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
describe('AndroidEmulatorUtils', () => {
let temporaryDirectories: string[] = [];
beforeEach(() => {
temporaryDirectories = [];
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedRetryAsync.mockImplementation(async fn => await fn(0));
});
afterEach(async () => {
await Promise.all(
temporaryDirectories.map(async temporaryDirectory => {
await fs.promises.rm(temporaryDirectory, { force: true, recursive: true });
})
);
});
describe(AndroidEmulatorUtils.startAsync, () => {
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
const child = { pid: 1234, unref: jest.fn() };
const spawnPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
spawnPromise.child = child;
mockedSpawn.mockImplementation(((command: string, args: string[]) => {
if (command.endsWith('/emulator/emulator')) {
return spawnPromise;
}
if (command === 'adb' && args[0] === 'devices') {
return Promise.resolve({ stdout: 'emulator-5554\tdevice\n', stderr: '' });
}
if (command === 'adb' && args[0] === '-s' && args[2] === 'emu') {
return Promise.resolve({ stdout: `${deviceName}\nOK\n`, stderr: '' });
}
throw new Error(`Unexpected command: ${command} ${args.join(' ')}`);
}) as any);
return { child };
}
it('captures logcat through the emulator process', async () => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
temporaryDirectories.push(outputDir);
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const result = await AndroidEmulatorUtils.startAsync({
deviceName,
env: process.env,
logcatDirectory: outputDir,
});
expect(result.logcatOutputPath).toMatch(
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}\\.log$`)
);
await expect(fs.promises.access(result.logcatOutputPath)).resolves.toBeUndefined();
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/\/emulator\/emulator$/),
expect.arrayContaining(['-logcat', '*:v', '-logcat-output', result.logcatOutputPath]),
expect.objectContaining({ detached: true, stdio: 'inherit' })
);
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
expect.arrayContaining(['logcat']),
expect.anything()
);
expect(child.unref).toHaveBeenCalled();
});
it('throws a SystemError when the staging directory cannot be prepared', async () => {
const outputDir = '/unwritable/logcat-staging';
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
const mkdirError = new Error('mkdir failed');
const mkdirSpy = jest.spyOn(fs.promises, 'mkdir').mockRejectedValueOnce(mkdirError);
try {
await expect(
AndroidEmulatorUtils.startAsync({
deviceName,
env: process.env,
logcatDirectory: outputDir,
})
).rejects.toEqual(
new SystemError('Failed to prepare Android emulator logcat output for eas-simulator.', {
cause: mkdirError,
})
);
expect(mockedSpawn).not.toHaveBeenCalled();
} finally {
mkdirSpy.mockRestore();
}
});
});
describe(AndroidEmulatorUtils.waitForReadyAsync, () => {
it('checks boot completion and verifies network with netcat to 1.1.1.1:443', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
@@ -1,8 +1,17 @@
import { EasCliNpmTags } from '@expo/eas-build-job';
import {
EasCliNpmTags,
EasCliVersionsFetchTimeoutError,
fetchEasCliVersionsAsync,
} from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEasCommandPrefixAndEnvAsync, runEasCliCommand } from '../easCli';
import {
resetCachedEasCliVersionsForTest,
resolveEasCommandPrefixAndEnvAsync,
runEasCliCommand,
} from '../easCli';
import { isAtLeastNpm7Async } from '../packageManager';
import { Sentry } from '../../sentry';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
@@ -12,12 +21,29 @@ jest.mock('../packageManager', () => ({
...jest.requireActual('../packageManager'),
isAtLeastNpm7Async: jest.fn(async () => true),
}));
jest.mock('@expo/eas-build-job', () => ({
...jest.requireActual('@expo/eas-build-job'),
fetchEasCliVersionsAsync: jest.fn(),
}));
jest.mock('../../sentry', () => ({
Sentry: { capture: jest.fn() },
}));
const STAGING_VERSION = 'staging-version';
const PRODUCTION_VERSION = 'production-version';
describe(resolveEasCommandPrefixAndEnvAsync, () => {
const originalEnvironment = process.env.ENVIRONMENT;
beforeEach(() => {
resetCachedEasCliVersionsForTest();
jest.mocked(spawn).mockReset();
jest.mocked(Sentry.capture).mockReset();
jest.mocked(fetchEasCliVersionsAsync).mockReset();
jest.mocked(fetchEasCliVersionsAsync).mockResolvedValue({
STAGING: STAGING_VERSION,
PRODUCTION: PRODUCTION_VERSION,
});
});
afterEach(() => {
@@ -40,9 +66,10 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
['--help'],
expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] })
);
expect(fetchEasCliVersionsAsync).not.toHaveBeenCalled();
});
it('resolves staging tag in development when easd --help fails', async () => {
it('resolves the staging version in development when easd --help fails', async () => {
process.env.ENVIRONMENT = 'development';
jest.mocked(spawn).mockResolvedValueOnce({ status: 1 } as any);
@@ -50,12 +77,12 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
args: ['-y', `eas-cli@${STAGING_VERSION}`],
extraEnv: {},
});
});
it('resolves staging tag in development when easd probe throws', async () => {
it('resolves the staging version in development when easd probe throws', async () => {
process.env.ENVIRONMENT = 'development';
jest.mocked(spawn).mockRejectedValueOnce(new Error('easd not installed') as any);
@@ -63,31 +90,80 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
args: ['-y', `eas-cli@${STAGING_VERSION}`],
extraEnv: {},
});
});
it('resolves staging tag and EXPO_STAGING env in staging environment', async () => {
it('resolves the staging version and EXPO_STAGING env in staging environment', async () => {
process.env.ENVIRONMENT = 'staging';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
args: ['-y', `eas-cli@${STAGING_VERSION}`],
extraEnv: { EXPO_STAGING: '1' },
});
expect(spawn).not.toHaveBeenCalled();
});
it('resolves production tag by default', async () => {
it('resolves the production version by default', async () => {
process.env.ENVIRONMENT = 'production';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
args: ['-y', `eas-cli@${PRODUCTION_VERSION}`],
extraEnv: {},
});
expect(spawn).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('fetches cli-versions.json only once across multiple calls', async () => {
process.env.ENVIRONMENT = 'production';
await resolveEasCommandPrefixAndEnvAsync();
await resolveEasCommandPrefixAndEnvAsync();
expect(fetchEasCliVersionsAsync).toHaveBeenCalledTimes(1);
});
it('falls back to the npm dist-tags and reports to Sentry when fetching cli-versions.json fails', async () => {
const fetchError = new Error('not found');
jest.mocked(fetchEasCliVersionsAsync).mockRejectedValue(fetchError);
process.env.ENVIRONMENT = 'staging';
const stagingResult = await resolveEasCommandPrefixAndEnvAsync();
expect(stagingResult).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
});
process.env.ENVIRONMENT = 'production';
const productionResult = await resolveEasCommandPrefixAndEnvAsync();
expect(productionResult).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
});
expect(Sentry.capture).toHaveBeenCalledWith(
expect.stringContaining('Failed to fetch cli-versions.json'),
fetchError,
expect.objectContaining({ level: 'warning' })
);
});
it('reports a timeout-specific Sentry error when the fetch times out', async () => {
const timeoutError = new EasCliVersionsFetchTimeoutError('https://example.com', 10_000);
jest.mocked(fetchEasCliVersionsAsync).mockRejectedValue(timeoutError);
process.env.ENVIRONMENT = 'production';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result.args).toContain(`eas-cli@${EasCliNpmTags.PRODUCTION}`);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.stringContaining('Timed out fetching cli-versions.json'),
timeoutError,
expect.objectContaining({ level: 'warning' })
);
});
it('omits -y when npm is older than v7', async () => {
@@ -96,7 +172,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: [`eas-cli@${EasCliNpmTags.PRODUCTION}`],
args: [`eas-cli@${PRODUCTION_VERSION}`],
extraEnv: {},
});
});
@@ -106,7 +182,13 @@ describe(runEasCliCommand, () => {
const originalEnvironment = process.env.ENVIRONMENT;
beforeEach(() => {
resetCachedEasCliVersionsForTest();
jest.mocked(spawn).mockReset();
jest.mocked(fetchEasCliVersionsAsync).mockReset();
jest.mocked(fetchEasCliVersionsAsync).mockResolvedValue({
STAGING: STAGING_VERSION,
PRODUCTION: PRODUCTION_VERSION,
});
});
afterEach(() => {
@@ -126,7 +208,7 @@ describe(runEasCliCommand, () => {
expect(spawn).toHaveBeenCalledWith(
'npx',
expect.arrayContaining([`eas-cli@${EasCliNpmTags.STAGING}`, 'deploy', '--json']),
expect.arrayContaining([`eas-cli@${STAGING_VERSION}`, 'deploy', '--json']),
expect.objectContaining({
cwd: '/tmp/project',
env: expect.objectContaining({
+53 -4
View File
@@ -1,7 +1,53 @@
import spawn, { SpawnOptions, SpawnResult } from '@expo/turtle-spawn';
import { EasCliNpmTags, Env } from '@expo/eas-build-job';
import {
EasCliNpmTags,
EasCliVersions,
EasCliVersionsFetchTimeoutError,
Env,
fetchEasCliVersionsAsync,
} from '@expo/eas-build-job';
import { isAtLeastNpm7Async } from './packageManager';
import { Sentry } from '../sentry';
let cachedEasCliVersionsPromise: Promise<EasCliVersions> | undefined;
/**
* Resolves the `eas-cli` versions to install for staging/production. Prefers the
* versions committed to `cli-versions.json` (fetched from expo/eas-cli); on any
* failure (e.g. GitHub is unreachable or slow) reports to Sentry and falls back
* to the `latest-eas-build*` npm dist-tags so the build can still proceed.
*
* Memoized: `resolveEasCommandPrefixAndEnvAsync` runs more than once per build,
* so the (never-rejecting) result promise is cached to fetch GitHub at most once
* per process.
*/
async function resolveEasCliVersionsAsync(): Promise<EasCliVersions> {
if (!cachedEasCliVersionsPromise) {
cachedEasCliVersionsPromise = fetchEasCliVersionsWithFallbackAsync();
}
return await cachedEasCliVersionsPromise;
}
async function fetchEasCliVersionsWithFallbackAsync(): Promise<EasCliVersions> {
try {
return await fetchEasCliVersionsAsync();
} catch (error) {
const message =
error instanceof EasCliVersionsFetchTimeoutError
? 'Timed out fetching cli-versions.json; falling back to npm dist-tags'
: 'Failed to fetch cli-versions.json; falling back to npm dist-tags';
Sentry.capture(message, error instanceof Error ? error : new Error(String(error)), {
level: 'warning',
});
return { STAGING: EasCliNpmTags.STAGING, PRODUCTION: EasCliNpmTags.PRODUCTION };
}
}
/** Test-only: clears the memoized versions promise so each test fetches fresh. */
export function resetCachedEasCliVersionsForTest(): void {
cachedEasCliVersionsPromise = undefined;
}
async function probeEasdAsync(): Promise<boolean> {
try {
@@ -24,21 +70,24 @@ export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
if (await probeEasdAsync()) {
return { cmd: 'easd', args: [], extraEnv: {} };
}
const versions = await resolveEasCliVersionsAsync();
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
extraEnv: {},
};
} else if (process.env.ENVIRONMENT === 'staging') {
const versions = await resolveEasCliVersionsAsync();
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
};
} else {
const versions = await resolveEasCliVersionsAsync();
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
args: [...npxArgsPrefix, `eas-cli@${versions.PRODUCTION}`],
extraEnv: {},
};
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "21.5.0",
"version": "21.7.1",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "21.5.0"
"@expo/steps": "21.7.1"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
@@ -44,6 +44,7 @@
"@types/node": "20.14.2",
"@types/prompts": "^2.4.9",
"@vercel/ncc": "^0.38.1",
"arg": "^5.0.2",
"chalk": "4.1.2",
"fs-extra": "^11.2.0",
"ora": "^6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "21.5.0",
"version": "21.6.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -1,4 +1,10 @@
import { EnvSchema, SshSettingsZ, StaticWorkflowInterpolationContextZ } from '../common';
import {
EasCliVersionsFetchTimeoutError,
EnvSchema,
SshSettingsZ,
StaticWorkflowInterpolationContextZ,
fetchEasCliVersionsAsync,
} from '../common';
describe('EnvSchema', () => {
it('accepts explicit undefined values', () => {
@@ -594,3 +600,78 @@ describe('GitHub context event payload passthrough', () => {
expect(parsed.github?.event).toMatchObject(context.github.event);
});
});
describe('fetchEasCliVersionsAsync', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('fetches and parses cli-versions.json', async () => {
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ STAGING: '21.5.1', PRODUCTION: '21.5.0' }),
} as Response);
await expect(fetchEasCliVersionsAsync()).resolves.toEqual({
STAGING: '21.5.1',
PRODUCTION: '21.5.0',
});
expect(fetchSpy).toHaveBeenCalledWith(
'https://raw.githubusercontent.com/expo/eas-cli/main/cli-versions.json',
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
});
it('throws on a non-OK response', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 404,
json: async () => ({}),
} as Response);
await expect(fetchEasCliVersionsAsync()).rejects.toThrow(/HTTP 404/);
});
it('throws when the payload is missing a required property', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ STAGING: '21.5.1' }),
} as Response);
await expect(fetchEasCliVersionsAsync()).rejects.toThrow();
});
it('throws when a version is not valid semver', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ STAGING: 'latest-eas-build-staging', PRODUCTION: '21.5.0' }),
} as Response);
await expect(fetchEasCliVersionsAsync()).rejects.toThrow();
});
it('throws a timeout error when the request exceeds the timeout', async () => {
jest.useFakeTimers();
try {
jest.spyOn(global, 'fetch').mockImplementation(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
(init?.signal as AbortSignal | undefined)?.addEventListener('abort', () => {
reject(new Error('The operation was aborted.'));
});
})
);
const assertion = expect(fetchEasCliVersionsAsync()).rejects.toBeInstanceOf(
EasCliVersionsFetchTimeoutError
);
await jest.advanceTimersByTimeAsync(30_000);
await assertion;
} finally {
jest.useRealTimers();
}
});
});
+62
View File
@@ -1,4 +1,5 @@
import Joi from 'joi';
import semver from 'semver';
import { z } from 'zod';
import { BuildPhase, BuildPhaseResult } from './logs';
@@ -399,3 +400,64 @@ export enum EasCliNpmTags {
STAGING = 'latest-eas-build-staging',
PRODUCTION = 'latest-eas-build',
}
/**
* The `eas-cli` versions used by EAS Build for the staging and production
* environments. Replaces the `latest-eas-build*` npm dist-tags: instead of
* moving dist-tags, the versions are committed to `cli-versions.json` at the
* root of the expo/eas-cli repository.
*/
export interface EasCliVersions {
STAGING: string;
PRODUCTION: string;
}
const SemverStringZ = z
.string()
.refine(value => semver.valid(value) !== null, { message: 'Expected a valid semver version.' });
const EasCliVersionsZ = z.object({
STAGING: SemverStringZ,
PRODUCTION: SemverStringZ,
});
const CLI_VERSIONS_URL = 'https://raw.githubusercontent.com/expo/eas-cli/main/cli-versions.json';
const CLI_VERSIONS_FETCH_TIMEOUT_MS = 10_000;
/**
* Thrown by {@link fetchEasCliVersionsAsync} when the request does not complete
* within {@link CLI_VERSIONS_FETCH_TIMEOUT_MS}. Callers can distinguish this
* from other fetch failures (e.g. to report it separately).
*/
export class EasCliVersionsFetchTimeoutError extends Error {
constructor(url: string, timeoutMs: number) {
super(`Timed out after ${timeoutMs}ms fetching ${url}.`);
this.name = 'EasCliVersionsFetchTimeoutError';
}
}
/**
* Fetches and parses `cli-versions.json` from the `main` branch of
* expo/eas-cli. Throws {@link EasCliVersionsFetchTimeoutError} if the request
* exceeds {@link CLI_VERSIONS_FETCH_TIMEOUT_MS}, or a generic error if the file
* cannot be fetched or does not match the expected shape; callers should fall
* back to {@link EasCliNpmTags}.
*/
export async function fetchEasCliVersionsAsync(): Promise<EasCliVersions> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CLI_VERSIONS_FETCH_TIMEOUT_MS);
try {
const response = await fetch(CLI_VERSIONS_URL, { signal: controller.signal });
if (!response.ok) {
throw new Error(`Failed to fetch ${CLI_VERSIONS_URL} (HTTP ${response.status}).`);
}
return EasCliVersionsZ.parse(await response.json());
} catch (error) {
if (controller.signal.aborted) {
throw new EasCliVersionsFetchTimeoutError(CLI_VERSIONS_URL, CLI_VERSIONS_FETCH_TIMEOUT_MS);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
+3
View File
@@ -7,6 +7,9 @@ export {
BuildMode,
BuildTrigger,
EasCliNpmTags,
EasCliVersions,
EasCliVersionsFetchTimeoutError,
fetchEasCliVersionsAsync,
Env,
EnvironmentSecret,
EnvironmentSecretType,
+432 -125
View File
File diff suppressed because it is too large. Load diff
+8 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "21.5.0",
"version": "21.8.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,8 +48,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "21.5.0",
"@expo/eas-json": "21.5.0",
"@expo/eas-build-job": "21.6.0",
"@expo/eas-json": "21.6.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "21.0.0",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "21.5.0",
"@expo/steps": "21.7.1",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -118,6 +118,7 @@
"prompts": "2.4.2",
"qrcode-terminal": "0.12.0",
"resolve-from": "5.0.0",
"sandbox-cli-detector": "0.2.0",
"semver": "7.5.4",
"set-interval-async": "3.0.3",
"slash": "3.0.0",
@@ -221,6 +222,9 @@
"secret": {
"description": "manage project and account secrets"
},
"simulator": {
"description": "manage remote simulator sessions"
},
"testflight": {
"description": "read TestFlight feedback and crashes"
},
@@ -7,6 +7,7 @@ import { Actor, getActorDisplayName } from '../user/User';
import UserSettings from '../user/UserSettings';
import { easCliVersion } from '../utils/easCli';
import { getAgentTelemetryContext } from './agent';
import { getSandboxTelemetryContext } from './sandbox';
const PLATFORM_TO_ANALYTICS_PLATFORM: Partial<Record<NodeJS.Platform, string>> = {
darwin: 'Mac',
@@ -227,10 +228,12 @@ class RudderstackAnalytics implements AnalyticsWithOrchestration {
private getRudderStackContext(): Record<string, any> {
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
const agent = getAgentTelemetryContext();
const sandboxProvider = getSandboxTelemetryContext();
return {
os: { name: platform, version: os.release() },
device: { type: platform, model: platform },
app: { name: 'eas cli', version: easCliVersion ?? undefined },
...(sandboxProvider ? { sandbox_provider: sandboxProvider } : {}),
...(agent ? { agent } : {}),
};
}
@@ -3,6 +3,7 @@ import RudderAnalytics from '@expo/rudder-sdk-node';
import UserSettings from '../../user/UserSettings';
import { CommandEvent, createAnalyticsAsync } from '../AnalyticsManager';
import { getAgentTelemetryContext } from '../agent';
import { getSandboxTelemetryContext } from '../sandbox';
const mockIdentify = jest.fn();
const mockTrack = jest.fn();
@@ -28,8 +29,12 @@ jest.mock('../../user/UserSettings', () => ({
jest.mock('../agent', () => ({
getAgentTelemetryContext: jest.fn(),
}));
jest.mock('../sandbox', () => ({
getSandboxTelemetryContext: jest.fn(),
}));
const getAgentTelemetryContextMock = jest.mocked(getAgentTelemetryContext);
const getSandboxTelemetryContextMock = jest.mocked(getSandboxTelemetryContext);
const userSettingsMock = jest.mocked(UserSettings);
const originalHttpsProxy = process.env.https_proxy;
@@ -42,6 +47,8 @@ beforeEach(() => {
jest.mocked(RudderAnalytics).mockClear();
getAgentTelemetryContextMock.mockReset();
getAgentTelemetryContextMock.mockReturnValue(null);
getSandboxTelemetryContextMock.mockReset();
getSandboxTelemetryContextMock.mockReturnValue(null);
userSettingsMock.getAsync.mockImplementation(async (key, defaultValue) => {
if (key === 'analyticsDeviceId') {
return 'persistent-device-id';
@@ -68,14 +75,17 @@ afterAll(() => {
}
});
it('omits agent context when no agent is detected', async () => {
it('omits agent and sandbox context when neither is detected', async () => {
const analytics = await createAnalyticsAsync();
analytics.logEvent(CommandEvent.ACTION, { action: 'eas build' });
expect(mockTrack).toHaveBeenCalledWith(
expect.objectContaining({
context: expect.not.objectContaining({ agent: expect.anything() }),
context: expect.not.objectContaining({
agent: expect.anything(),
sandbox_provider: expect.anything(),
}),
})
);
});
@@ -97,3 +107,18 @@ it('adds detected agent context to analytics events', async () => {
})
);
});
it('adds detected sandbox context to analytics events', async () => {
getSandboxTelemetryContextMock.mockReturnValue('e2b');
const analytics = await createAnalyticsAsync();
analytics.logEvent(CommandEvent.ACTION, { action: 'eas build' });
expect(mockTrack).toHaveBeenCalledWith(
expect.objectContaining({
context: expect.objectContaining({
sandbox_provider: 'e2b',
}),
})
);
});
@@ -0,0 +1,60 @@
export {};
const detectSandboxMock = jest.fn();
const logDebugMock = jest.fn();
jest.mock('sandbox-cli-detector', () => ({
detectSandbox: detectSandboxMock,
}));
jest.mock('../../log', () => ({
__esModule: true,
default: {
debug: logDebugMock,
},
}));
beforeEach(() => {
jest.resetModules();
detectSandboxMock.mockReset();
logDebugMock.mockReset();
});
it('returns detected sandbox telemetry context', async () => {
detectSandboxMock.mockReturnValue({
detected: true,
sandbox: { id: 'e2b', name: 'E2B' },
});
const { getSandboxTelemetryContext } = await import('../sandbox');
expect(getSandboxTelemetryContext()).toBe('e2b');
});
it('returns null when no known sandbox is detected', async () => {
detectSandboxMock.mockReturnValue({ detected: false, sandbox: null });
const { getSandboxTelemetryContext } = await import('../sandbox');
expect(getSandboxTelemetryContext()).toBeNull();
});
it('caches the detected sandbox telemetry context', async () => {
detectSandboxMock.mockReturnValue({
detected: true,
sandbox: { id: 'e2b', name: 'E2B' },
});
const { getSandboxTelemetryContext } = await import('../sandbox');
expect(getSandboxTelemetryContext()).toBe('e2b');
expect(getSandboxTelemetryContext()).toBe('e2b');
expect(detectSandboxMock).toHaveBeenCalledTimes(1);
});
it('returns null and logs debug details when detection throws', async () => {
detectSandboxMock.mockImplementation(() => {
throw new Error('boom');
});
const { getSandboxTelemetryContext } = await import('../sandbox');
expect(getSandboxTelemetryContext()).toBeNull();
expect(logDebugMock).toHaveBeenCalledWith('Failed to detect sandbox:', 'boom');
});
+29
View File
@@ -0,0 +1,29 @@
import { detectSandbox } from 'sandbox-cli-detector';
import Log from '../log';
type SandboxTelemetryContext = string | null;
let sandboxTelemetryContext: SandboxTelemetryContext | undefined;
export function getSandboxTelemetryContext(): SandboxTelemetryContext {
if (sandboxTelemetryContext === undefined) {
sandboxTelemetryContext = resolveSandboxTelemetryContext();
}
return sandboxTelemetryContext;
}
function resolveSandboxTelemetryContext(): SandboxTelemetryContext {
try {
const { detected, sandbox } = detectSandbox();
if (!detected || sandbox == null) {
return null;
}
return sandbox.id;
} catch (error: any) {
Log.debug('Failed to detect sandbox:', error?.message ?? error);
return null;
}
}
@@ -0,0 +1,164 @@
import {
SupabaseConnectionData,
SupabaseOrganizationData,
SupabaseProjectData,
} from '../../graphql/types/SupabaseConnection';
import Log from '../../log';
import {
formatSupabaseOrganization,
formatSupabaseProject,
formatSupabaseProjectLabel,
getSupabaseProjectDashboardUrl,
logNoSupabaseProject,
parseSupabaseProjectRef,
} from '../supabase';
jest.mock('../../log', () => ({
__esModule: true,
default: {
warn: jest.fn(),
log: jest.fn(),
},
link: jest.fn((url: string) => url),
}));
describe('commandUtils/supabase', () => {
const project: SupabaseProjectData = {
id: 'project-1',
supabaseProjectRef: 'abcdefghijklmnop',
supabaseProjectName: 'Demo App',
supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co',
supabaseRegion: 'us-east-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
it('getSupabaseProjectDashboardUrl encodes the project ref', () => {
expect(getSupabaseProjectDashboardUrl({ supabaseProjectRef: 'abc/def' })).toBe(
'https://supabase.com/dashboard/project/abc%2Fdef'
);
});
it('formatSupabaseOrganization prefers a distinct name', () => {
const connection: Pick<
SupabaseConnectionData,
'supabaseOrganizationSlug' | 'supabaseOrganizationName'
> = {
supabaseOrganizationSlug: 'org-slug',
supabaseOrganizationName: 'Org Name',
};
expect(formatSupabaseOrganization(connection)).toBe('Org Name (org-slug)');
});
it('formatSupabaseOrganization falls back to live org name', () => {
const connection = {
supabaseOrganizationSlug: 'org-slug',
supabaseOrganizationName: null as unknown as string,
};
const organizations: SupabaseOrganizationData[] = [
{ id: '1', slug: 'org-slug', name: 'Live Name' },
];
expect(formatSupabaseOrganization(connection, organizations)).toBe('Live Name (org-slug)');
});
it('formatSupabaseOrganization returns slug when name matches or is missing', () => {
expect(
formatSupabaseOrganization({
supabaseOrganizationSlug: 'same',
supabaseOrganizationName: 'same',
})
).toBe('same');
expect(
formatSupabaseOrganization({
supabaseOrganizationSlug: 'only-slug',
supabaseOrganizationName: '',
})
).toBe('only-slug');
});
it('formatSupabaseProjectLabel prefers a distinct name', () => {
expect(formatSupabaseProjectLabel(project)).toBe('Demo App (abcdefghijklmnop)');
});
it('formatSupabaseProjectLabel returns ref when name matches or is missing', () => {
expect(
formatSupabaseProjectLabel({
supabaseProjectRef: 'ref',
supabaseProjectName: 'ref',
})
).toBe('ref');
expect(
formatSupabaseProjectLabel({
supabaseProjectRef: 'ref',
supabaseProjectName: '',
})
).toBe('ref');
});
it('formatSupabaseProject includes key fields', () => {
const formatted = formatSupabaseProject(project);
expect(formatted).toContain('Demo App');
expect(formatted).toContain('abcdefghijklmnop');
expect(formatted).toContain('https://abcdefghijklmnop.supabase.co');
expect(formatted).toContain('us-east-1');
expect(formatted).toContain('https://supabase.com/dashboard/project/abcdefghijklmnop');
});
it('logNoSupabaseProject warns with the project name', () => {
logNoSupabaseProject('my-app');
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('my-app'));
});
});
describe(parseSupabaseProjectRef, () => {
it('accepts a bare reference ID', () => {
expect(parseSupabaseProjectRef('jfurmbuioogljwsqwnpd')).toBe('jfurmbuioogljwsqwnpd');
expect(parseSupabaseProjectRef(' jfurmbuioogljwsqwnpd ')).toBe('jfurmbuioogljwsqwnpd');
});
it('accepts a dashboard URL', () => {
expect(
parseSupabaseProjectRef('https://supabase.com/dashboard/project/kwdfdxdzurxigtbtwddj')
).toBe('kwdfdxdzurxigtbtwddj');
expect(
parseSupabaseProjectRef(
'https://supabase.com/dashboard/project/kwdfdxdzurxigtbtwddj/settings/general'
)
).toBe('kwdfdxdzurxigtbtwddj');
});
it('accepts a project API URL', () => {
expect(parseSupabaseProjectRef('https://kwdfdxdzurxigtbtwddj.supabase.co')).toBe(
'kwdfdxdzurxigtbtwddj'
);
});
it('lowercases the reference ID, which the server matches exactly', () => {
expect(parseSupabaseProjectRef('JFURMBUIOOGLJWSQWNPD')).toBe('jfurmbuioogljwsqwnpd');
expect(
parseSupabaseProjectRef('https://supabase.com/dashboard/project/KWDFDXDZURXIGTBTWDDJ')
).toBe('kwdfdxdzurxigtbtwddj');
});
it('rejects a project name with guidance', () => {
expect(() => parseSupabaseProjectRef('@testuser/test-app-personal-661c52f1')).toThrow(
/not a Supabase project reference ID/
);
expect(() => parseSupabaseProjectRef('@testuser/test-app-personal-661c52f1')).toThrow(
/Project Settings/
);
});
it('rejects an unrelated URL and an empty value', () => {
expect(() => parseSupabaseProjectRef('https://example.com/foo')).toThrow(
/not a Supabase project reference ID/
);
expect(() => parseSupabaseProjectRef(' ')).toThrow(/No Supabase project given/);
});
it('treats a malformed URL as a non-ref and rejects it', () => {
expect(() => parseSupabaseProjectRef('https://[')).toThrow(
/not a Supabase project reference ID/
);
});
});
@@ -0,0 +1,96 @@
import chalk from 'chalk';
import {
SupabaseConnectionData,
SupabaseOrganizationData,
SupabaseProjectData,
} from '../graphql/types/SupabaseConnection';
import Log, { link } from '../log';
export function getSupabaseProjectDashboardUrl(
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>
): string {
return `https://supabase.com/dashboard/project/${encodeURIComponent(project.supabaseProjectRef)}`;
}
function extractProjectRefFromUrl(value: string): string | null {
if (!/^https?:\/\//i.test(value)) {
return null;
}
let url: URL;
try {
url = new URL(value);
} catch {
return null;
}
const projectHost = url.hostname.match(/^([a-z0-9]+)\.supabase\./i);
if (projectHost) {
return projectHost[1];
}
const dashboardPath = url.pathname.match(/\/dashboard\/project\/([^/]+)/);
if (dashboardPath) {
return decodeURIComponent(dashboardPath[1]);
}
return null;
}
/**
* Accepts whatever a user is likely to have on hand: the bare reference ID, the dashboard URL, or
* the project API URL. Supabase labels the ref "Reference ID" under Project Settings → General.
*/
export function parseSupabaseProjectRef(input: string): string {
const value = input.trim();
if (!value) {
throw new Error(
'No Supabase project given. Pass the project reference ID or its dashboard URL, for example --link jfurmbuioogljwsqwnpd.'
);
}
const ref = extractProjectRefFromUrl(value) ?? value;
if (!/^[a-z0-9]+$/i.test(ref)) {
throw new Error(
`"${input}" is not a Supabase project reference ID. Supabase shows it as "Reference ID" under Project Settings → General; you can also paste the dashboard URL (https://supabase.com/dashboard/project/<ref>). Note that a project name is not a reference ID.`
);
}
// Refs are lowercase, and the server matches them exactly.
return ref.toLowerCase();
}
/** Prefer human-readable org name; Supabase slugs are often opaque ids like `ycjmzsygzfkryaazoitp`. */
export function formatSupabaseOrganization(
connection: Pick<SupabaseConnectionData, 'supabaseOrganizationSlug' | 'supabaseOrganizationName'>,
organizations: readonly SupabaseOrganizationData[] = []
): string {
const slug = connection.supabaseOrganizationSlug;
const storedName = connection.supabaseOrganizationName;
const liveName = organizations.find(organization => organization.slug === slug)?.name;
const name = storedName || liveName;
if (name && name !== slug) {
return `${name} (${slug})`;
}
return slug;
}
/** Prefer human-readable project name over the opaque project ref. */
export function formatSupabaseProjectLabel(
project: Pick<SupabaseProjectData, 'supabaseProjectRef' | 'supabaseProjectName'>
): string {
const { supabaseProjectRef: ref, supabaseProjectName: name } = project;
if (name && name !== ref) {
return `${name} (${ref})`;
}
return ref;
}
export function formatSupabaseProject(project: SupabaseProjectData): string {
return [
`${chalk.bold('Name')}: ${project.supabaseProjectName}`,
`${chalk.bold('Ref')}: ${project.supabaseProjectRef}`,
`${chalk.bold('URL')}: ${project.supabaseProjectUrl}`,
`${chalk.bold('Region')}: ${project.supabaseRegion}`,
`${chalk.bold('Dashboard')}: ${link(getSupabaseProjectDashboardUrl(project), { dim: false })}`,
].join('\n');
}
export function logNoSupabaseProject(projectName: string): void {
Log.warn(`No Supabase project is linked to Expo app ${chalk.bold(projectName)} on EAS.`);
}
@@ -35,18 +35,8 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => {
).resolves.toBeUndefined();
});
it('resolves local composite functions from the EAS project directory in monorepos', async () => {
const repositoryRoot = await fs.mkdtemp(
path.join(os.tmpdir(), 'eas-workflow-functions-monorepo-')
);
const projectDir = path.join(repositoryRoot, 'apps', 'mobile');
await fs.mkdir(projectDir, { recursive: true });
await makeProjectWithCompositeFunctionAsync(
projectDir,
'setup',
['runs:', ' steps:', ' - run: echo setup'].join('\n')
);
it('throws when a local composite function referenced from job steps does not exist', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-missing-'));
const workflow = {
jobs: {
job: {
@@ -56,81 +46,12 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => {
};
await expect(
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir)
).resolves.toBeUndefined();
});
it('does not treat repository-root composite functions as valid when the EAS project is in a subdirectory', async () => {
const repositoryRoot = await fs.mkdtemp(
path.join(os.tmpdir(), 'eas-workflow-functions-monorepo-')
);
const projectDir = path.join(repositoryRoot, 'apps', 'mobile');
await fs.mkdir(projectDir, { recursive: true });
await makeProjectWithCompositeFunctionAsync(
repositoryRoot,
'setup',
['runs:', ' steps:', ' - run: echo setup'].join('\n')
);
const workflow = {
jobs: {
job: {
steps: [{ uses: './.eas/functions/setup' }],
},
},
};
await expect(
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir)
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
).rejects.toThrow(
/Local composite function "\.\/\.eas\/functions\/setup" was referenced by a step but no such composite function exists/
);
});
it('ignores unreferenced malformed composite functions on disk', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-test-'));
await makeProjectWithCompositeFunctionAsync(
projectRoot,
'broken',
['name: Broken', 'runs:', ' steps: []'].join('\n')
);
const workflow = {
jobs: {
job: {
steps: [{ run: 'echo hi' }],
},
},
};
await expect(
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
).resolves.toBeUndefined();
});
it('validates composite functions referenced by an arbitrary path (arbitrary path style)', async () => {
const projectRoot = await fs.mkdtemp(
path.join(os.tmpdir(), 'eas-workflow-functions-arbitrary-')
);
const functionDir = path.join(projectRoot, 'internal-functions', 'deploy');
await fs.mkdir(functionDir, { recursive: true });
await fs.writeFile(
path.join(functionDir, 'function.yml'),
['runs:', ' steps:', ' - run: echo deploy'].join('\n'),
'utf-8'
);
const workflow = {
jobs: {
job: {
steps: [{ uses: './internal-functions/deploy' }],
},
},
};
await expect(
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
).resolves.toBeUndefined();
});
it('rejects interpolated local composite function references', async () => {
const projectRoot = await fs.mkdtemp(
path.join(os.tmpdir(), 'eas-workflow-functions-interpolated-')
@@ -148,12 +69,38 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => {
).rejects.toThrow(/must not contain interpolation/);
});
it('throws when a referenced local composite function does not exist', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-missing-'));
it('validates local composite functions referenced from job hooks', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-hooks-'));
await makeProjectWithCompositeFunctionAsync(
projectRoot,
'setup',
['runs:', ' steps:', ' - run: echo setup'].join('\n')
);
const workflow = {
jobs: {
job: {
steps: [{ uses: './.eas/functions/setup' }],
steps: [{ run: 'echo hi' }],
hooks: {
before_install_node_modules: [{ uses: './.eas/functions/setup' }],
},
},
},
};
await expect(
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
).resolves.toBeUndefined();
});
it('throws when a composite function referenced from a job hook does not exist', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-hooks-'));
const workflow = {
jobs: {
job: {
steps: [{ run: 'echo hi' }],
hooks: {
before_install_node_modules: [{ uses: './.eas/functions/setup' }],
},
},
},
};
@@ -165,17 +112,20 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => {
);
});
it('validates cyclic local composite functions (cycles are reported at expansion time)', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-cycle-'));
await makeProjectWithCompositeFunctionAsync(
projectRoot,
'loop',
['runs:', ' steps:', ' - uses: ./.eas/functions/loop'].join('\n')
);
it('ignores non-object hooks and non-array hook values', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-hooks-'));
const workflow = {
jobs: {
job: {
steps: [{ uses: './.eas/functions/loop' }],
garbageHooks: {
steps: [{ run: 'echo hi' }],
hooks: 'not an object',
},
garbageHookValue: {
steps: [{ run: 'echo hi' }],
hooks: {
before_install_node_modules: 'not an array',
after_install_node_modules: { uses: './.eas/functions/missing' },
},
},
},
};
@@ -185,22 +135,24 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => {
).resolves.toBeUndefined();
});
it('validates indirectly cyclic local composite functions (cycles are reported at expansion time)', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-cycle-'));
await makeProjectWithCompositeFunctionAsync(
projectRoot,
'a',
['runs:', ' steps:', ' - uses: ./.eas/functions/b'].join('\n')
it('validates local composite functions referenced from defaults.hooks', async () => {
const projectRoot = await fs.mkdtemp(
path.join(os.tmpdir(), 'eas-workflow-functions-default-hooks-')
);
await makeProjectWithCompositeFunctionAsync(
projectRoot,
'b',
['runs:', ' steps:', ' - uses: ./.eas/functions/a'].join('\n')
'setup',
['runs:', ' steps:', ' - run: echo setup'].join('\n')
);
const workflow = {
defaults: {
hooks: {
before_install_node_modules: [{ uses: './.eas/functions/setup' }],
},
},
jobs: {
job: {
steps: [{ uses: './.eas/functions/a' }],
steps: [{ run: 'echo hi' }],
},
},
};
@@ -210,53 +162,124 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => {
).resolves.toBeUndefined();
});
it('validates a shared local composite function referenced along multiple paths', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-diamond-'));
await makeProjectWithCompositeFunctionAsync(
projectRoot,
'shared',
['runs:', ' steps:', ' - run: echo shared'].join('\n')
);
await makeProjectWithCompositeFunctionAsync(
projectRoot,
'left',
['runs:', ' steps:', ' - uses: ./.eas/functions/shared'].join('\n')
);
await makeProjectWithCompositeFunctionAsync(
projectRoot,
'right',
['runs:', ' steps:', ' - uses: ./.eas/functions/shared'].join('\n')
it('throws when a composite function referenced from defaults.hooks does not exist', async () => {
const projectRoot = await fs.mkdtemp(
path.join(os.tmpdir(), 'eas-workflow-functions-default-hooks-missing-')
);
const workflow = {
defaults: {
hooks: {
before_install_node_modules: [{ uses: './.eas/functions/setup' }],
},
},
jobs: {
job: {
steps: [{ uses: './.eas/functions/left' }, { uses: './.eas/functions/right' }],
steps: [{ run: 'echo hi' }],
},
},
};
await expect(
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
).rejects.toThrow(
/Local composite function "\.\/\.eas\/functions\/setup" was referenced by a step but no such composite function exists/
);
});
it('ignores non-object defaults and non-object defaults.hooks', async () => {
const projectRoot = await fs.mkdtemp(
path.join(os.tmpdir(), 'eas-workflow-functions-default-hooks-garbage-')
);
const jobs = {
job: {
steps: [{ run: 'echo hi' }],
},
};
await expect(
validateWorkflowLocalCompositeFunctionsAsync({ defaults: 'garbage', jobs }, projectRoot)
).resolves.toBeUndefined();
await expect(
validateWorkflowLocalCompositeFunctionsAsync(
{ defaults: { hooks: 'garbage' }, jobs },
projectRoot
)
).resolves.toBeUndefined();
});
it('throws when a referenced local composite function is malformed', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-test-'));
it('resolves "./" paths against the project directory, not the repository root', async () => {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-monorepo-'));
const projectDir = path.join(repoRoot, 'apps', 'mobile');
await makeProjectWithCompositeFunctionAsync(
projectRoot,
'broken',
['name: Broken', 'runs:', ' steps: []'].join('\n')
repoRoot,
'notify',
['runs:', ' steps:', ' - run: echo repo root'].join('\n')
);
await makeProjectWithCompositeFunctionAsync(
projectDir,
'notify',
['runs:', ' steps:', ' - run: echo app'].join('\n')
);
const workflow = {
jobs: {
job: {
steps: [{ uses: './.eas/functions/broken' }],
steps: [{ uses: './.eas/functions/notify' }],
},
},
};
await expect(
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
).rejects.toThrow(/must declare at least one step under "runs.steps"/);
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir)
).resolves.toBeUndefined();
});
it('does not fall back to the repository root when the function is missing under the project directory', async () => {
const repoRoot = await fs.mkdtemp(
path.join(os.tmpdir(), 'eas-workflow-functions-monorepo-no-fallback-')
);
const projectDir = path.join(repoRoot, 'apps', 'mobile');
await fs.mkdir(projectDir, { recursive: true });
await makeProjectWithCompositeFunctionAsync(
repoRoot,
'notify',
['runs:', ' steps:', ' - run: echo repo root'].join('\n')
);
const workflow = {
jobs: {
job: {
steps: [{ uses: './.eas/functions/notify' }],
},
},
};
await expect(
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir)
).rejects.toThrow(
/Local composite function "\.\/\.eas\/functions\/notify" was referenced by a step but no such composite function exists/
);
});
it('validates functions referenced with an explicit path above the project directory', async () => {
const repoRoot = await fs.mkdtemp(
path.join(os.tmpdir(), 'eas-workflow-functions-monorepo-upward-')
);
const projectDir = path.join(repoRoot, 'apps', 'mobile');
await fs.mkdir(projectDir, { recursive: true });
await makeProjectWithCompositeFunctionAsync(
repoRoot,
'notify',
['runs:', ' steps:', ' - run: echo repo root'].join('\n')
);
const workflow = {
jobs: {
job: {
steps: [{ uses: '../../.eas/functions/notify' }],
},
},
};
await expect(
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir)
).resolves.toBeUndefined();
});
});
@@ -1,95 +1,39 @@
import { CompositeFunctionConfig, CompositeFunctionConfigZ } from '@expo/eas-build-job';
import {
buildCompositeFunctionCatalogFromStepsAsync,
resolveLocalCompositeFunctionPath,
} from '@expo/steps';
import { promises as fs } from 'fs';
import path from 'path';
import * as YAML from 'yaml';
import { z } from 'zod';
import { buildLocalCompositeFunctionCatalogAsync } from '@expo/steps';
import Log from '../../log';
const COMPOSITE_FUNCTION_FILE_EXTENSIONS = ['yml', 'yaml'] as const;
export async function validateWorkflowLocalCompositeFunctionsAsync(
parsedYaml: any,
projectDir: string
): Promise<void> {
await buildCompositeFunctionCatalogFromStepsAsync({
await buildLocalCompositeFunctionCatalogAsync(projectDir, {
rootSteps: stepsFromWorkflow(parsedYaml),
loadCompositeFunction: compositeFunctionPath =>
loadLocalCompositeFunctionConfigAsync(projectDir, compositeFunctionPath),
logger: {
debug: message => {
Log.debug(message);
},
},
});
}
async function loadLocalCompositeFunctionConfigAsync(
projectDir: string,
compositeFunctionPath: string
): Promise<CompositeFunctionConfig> {
const resolvedPath = resolveLocalCompositeFunctionPath(projectDir, compositeFunctionPath);
for (const ext of COMPOSITE_FUNCTION_FILE_EXTENSIONS) {
const config = await readCompositeFunctionConfigFileAsync(
compositeFunctionPath,
path.join(resolvedPath, `function.${ext}`)
);
if (config) {
Log.debug(`Validated local composite function "${compositeFunctionPath}"`);
return config;
}
}
throw new Error(
`Local composite function "${compositeFunctionPath}" was referenced by a step but no such composite function exists. A local composite function is resolved from a "function.yml" (or "function.yaml") file at the referenced path relative to the EAS project root (e.g. "uses: ${compositeFunctionPath}" resolves "${compositeFunctionPath}/function.yml"). The recommended convention is to keep composite functions under ".eas/functions/<name>".`
);
}
async function readCompositeFunctionConfigFileAsync(
compositeFunctionPath: string,
absolutePath: string
): Promise<CompositeFunctionConfig | null> {
let rawContents: string;
try {
rawContents = await fs.readFile(absolutePath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
return null;
}
throw new Error(
`Failed to read local composite function "${compositeFunctionPath}" from ${absolutePath}`,
{
cause: err as Error,
}
);
}
let parsed: unknown;
try {
parsed = YAML.parse(rawContents);
} catch (err) {
throw new Error(
`Failed to parse local composite function "${compositeFunctionPath}" YAML at ${absolutePath}`,
{
cause: err as Error,
}
);
}
const result = CompositeFunctionConfigZ.safeParse(parsed);
if (!result.success) {
throw new Error(
`Invalid composite function "${compositeFunctionPath}": ${z.prettifyError(result.error)}`
);
}
return result.data;
}
function stepsFromWorkflow(parsedYaml: any): any[] {
const jobs = parsedYaml?.jobs;
if (!jobs || typeof jobs !== 'object') {
return [];
}
return Object.values(jobs).flatMap((job: any) => (Array.isArray(job?.steps) ? job.steps : []));
return [
...Object.values(jobs).flatMap((job: any) => [
...(Array.isArray(job?.steps) ? job.steps : []),
...hookStepsFrom(job),
]),
...hookStepsFrom(parsedYaml?.defaults),
];
}
function hookStepsFrom(jobOrDefaults: any): any[] {
const hooks = jobOrDefaults?.hooks;
if (!hooks || typeof hooks !== 'object') {
return [];
}
return Object.values(hooks).flatMap((steps: any) => (Array.isArray(steps) ? steps : []));
}
@@ -0,0 +1,961 @@
import spawnAsync from '@expo/spawn-async';
import openBrowserAsync from 'better-opn';
import * as fs from 'fs-extra';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import * as supabaseCommandUtils from '../../../../commandUtils/supabase';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import {
EnvironmentVariableScope,
EnvironmentVariableVisibility,
Role,
} from '../../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
import { SupabaseMutation } from '../../../../graphql/mutations/SupabaseMutation';
import { EnvironmentVariablesQuery } from '../../../../graphql/queries/EnvironmentVariablesQuery';
import { SupabaseQuery } from '../../../../graphql/queries/SupabaseQuery';
import {
SupabaseConnectionData,
SupabaseOrganizationData,
SupabaseProjectData,
} from '../../../../graphql/types/SupabaseConnection';
import Log from '../../../../log';
import { createOrModifyExpoConfigAsync } from '../../../../project/expoConfig';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { confirmAsync, promptAsync, selectAsync } from '../../../../prompts';
import { printJsonOnlyOutput } from '../../../../utils/json';
import {
BackgroundJobReceiptPollError,
BackgroundJobReceiptPollErrorType,
pollForBackgroundJobReceiptAsync,
} from '../../../../utils/pollForBackgroundJobReceiptAsync';
import IntegrationsSupabaseConnect from '../connect';
jest.mock('@expo/spawn-async');
jest.mock('better-opn');
jest.mock('fs-extra');
jest.mock('../../../../project/expoConfig');
jest.mock('../../../../graphql/queries/SupabaseQuery');
jest.mock('../../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../../graphql/mutations/SupabaseMutation');
jest.mock('../../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../utils/pollForBackgroundJobReceiptAsync', () => ({
...jest.requireActual('../../../../utils/pollForBackgroundJobReceiptAsync'),
pollForBackgroundJobReceiptAsync: jest.fn(),
}));
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
warn: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
stop: jest.fn().mockReturnThis(),
text: '',
}),
}));
describe(IntegrationsSupabaseConnect, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testAccountId = 'test-account-id';
const testAccountName = 'testuser';
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: Role.Owner, actor: { id: 'test-user-id' } }],
};
const mockConnection: SupabaseConnectionData = {
id: 'connection-1',
supabaseOrganizationSlug: 'org-slug',
supabaseOrganizationName: 'Primary Org',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
const mockOrganizations: SupabaseOrganizationData[] = [
{ id: 'org-1', slug: 'org-slug', name: 'Primary Org' },
{ id: 'org-2', slug: 'other-org', name: 'Other Org' },
];
const mockProject: SupabaseProjectData = {
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',
};
function createCommand(argv: string[]): IntegrationsSupabaseConnect {
const command = new IntegrationsSupabaseConnect(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { name: 'testapp', slug: 'testapp', plugins: [] },
projectDir: '/test/project',
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'error').mockImplementation(() => {});
jest.spyOn(Log, 'withTick').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.spyOn(Log, 'debug').mockImplementation(() => {});
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
jest.mocked(selectAsync).mockResolvedValue('americas');
jest.mocked(confirmAsync).mockResolvedValue(true);
jest.mocked(promptAsync).mockResolvedValue({ linkValue: mockProject.supabaseProjectRef });
jest.mocked(spawnAsync).mockResolvedValue({} as any);
jest.mocked(fs.pathExists).mockResolvedValue(false as never);
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'success' } as any);
jest.mocked(openBrowserAsync).mockResolvedValue(true as never);
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]);
jest
.mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync)
.mockResolvedValue(['production', 'preview', 'development']);
jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({
id: 'env-var-1',
scope: EnvironmentVariableScope.Project,
} as any);
jest
.mocked(EnvironmentVariableMutation.updateAsync)
.mockResolvedValue({ id: 'env-var-1' } as any);
jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValue({ id: 'env-var-extra' });
jest
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
.mockResolvedValue(mockConnection);
jest
.mocked(SupabaseMutation.listSupabaseOrganizationsAsync)
.mockResolvedValue(mockOrganizations);
jest
.mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync)
.mockResolvedValue('sb_publishable_test');
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(SupabaseMutation.linkSupabaseProjectAsync).mockResolvedValue(mockProject);
jest.mocked(SupabaseMutation.provisionSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-1',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-1',
resultData: { supabaseProjectRef: mockProject.supabaseProjectRef },
} as any);
});
it('reuses an existing connection and project and writes public env vars', async () => {
await createCommand([]).runAsync();
expect(SupabaseMutation.beginSupabaseOAuthAsync).not.toHaveBeenCalled();
expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled();
expect(spawnAsync).toHaveBeenCalledWith(
'npx',
expect.arrayContaining(['expo', 'install', '@supabase/supabase-js', 'expo-sqlite']),
expect.objectContaining({
cwd: '/test/project',
})
);
expect(createOrModifyExpoConfigAsync).toHaveBeenCalled();
const createdNames = jest
.mocked(EnvironmentVariableMutation.createForAppAsync)
.mock.calls.map(call => call[1].name);
expect(createdNames).toEqual([
'EXPO_PUBLIC_SUPABASE_URL',
'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY',
]);
expect(
jest.mocked(EnvironmentVariableMutation.createForAppAsync).mock.calls[0][1].visibility
).toBe(EnvironmentVariableVisibility.Public);
});
it('loads organizations once when formatting the existing connection label', async () => {
await createCommand([]).runAsync();
expect(SupabaseMutation.listSupabaseOrganizationsAsync).toHaveBeenCalledTimes(1);
});
it('formats the connection when organization listing fails', async () => {
jest
.mocked(SupabaseMutation.listSupabaseOrganizationsAsync)
.mockRejectedValue(new Error('skip'));
await createCommand(['--overwrite']).runAsync();
expect(Log.withTick).toHaveBeenCalledWith(expect.stringContaining('Primary Org'));
});
it('strips EXPO_LOCAL / EXPO_STAGING / EXPO_UNIVERSE_DIR from the expo install env', async () => {
const previous = {
EXPO_LOCAL: process.env.EXPO_LOCAL,
EXPO_STAGING: process.env.EXPO_STAGING,
EXPO_UNIVERSE_DIR: process.env.EXPO_UNIVERSE_DIR,
};
process.env.EXPO_LOCAL = '1';
process.env.EXPO_STAGING = '1';
process.env.EXPO_UNIVERSE_DIR = '/tmp/universe';
try {
await createCommand([]).runAsync();
const spawnOptions = jest.mocked(spawnAsync).mock.calls[0]?.[2] as {
env?: NodeJS.ProcessEnv;
};
expect(spawnOptions.env?.EXPO_LOCAL).toBeUndefined();
expect(spawnOptions.env?.EXPO_STAGING).toBeUndefined();
expect(spawnOptions.env?.EXPO_UNIVERSE_DIR).toBeUndefined();
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
});
it('links an existing project and skips provisioning', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(SupabaseMutation.linkSupabaseProjectAsync).mockResolvedValue(mockProject);
await createCommand(['--link', mockProject.supabaseProjectRef]).runAsync();
expect(SupabaseMutation.linkSupabaseProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
supabaseProjectRef: mockProject.supabaseProjectRef,
});
expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled();
expect(SupabaseMutation.fetchSupabasePublishableKeyAsync).toHaveBeenCalled();
expect(SupabaseQuery.getSupabaseProjectByAppIdAsync).toHaveBeenCalledTimes(1);
});
it('provisions via background receipt when no project is linked', async () => {
jest
.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(mockProject);
await createCommand(['--region', 'americas', '--organization', 'org-slug']).runAsync();
expect(SupabaseMutation.provisionSupabaseProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
region: 'americas',
});
expect(pollForBackgroundJobReceiptAsync).toHaveBeenCalledWith(
graphqlClient,
expect.anything(),
expect.objectContaining({
maxChecks: 420,
maxConsecutiveFetchErrors: 3,
})
);
});
it('surfaces a permanent provision failure with a --link hint', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(pollForBackgroundJobReceiptAsync).mockRejectedValue(
new BackgroundJobReceiptPollError({
errorType: BackgroundJobReceiptPollErrorType.JOB_FAILED_NO_WILL_RETRY,
receiptErrorMessage: 'name already taken',
})
);
await expect(
createCommand(['--region', 'americas', '--organization', 'org-slug']).runAsync()
).rejects.toThrow(/name already taken[\s\S]*--link/);
});
it('maps NULL_RECEIPT poll errors to a recoverable timeout hint', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(pollForBackgroundJobReceiptAsync).mockRejectedValue(
new BackgroundJobReceiptPollError({
errorType: BackgroundJobReceiptPollErrorType.NULL_RECEIPT,
})
);
await expect(
createCommand(['--region', 'americas', '--organization', 'org-slug']).runAsync()
).rejects.toThrow(/Timed out or lost contact[\s\S]*--link/);
});
it('switches organization when --organization differs from the connection', async () => {
jest
.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(mockProject);
jest.mocked(SupabaseMutation.setSupabaseConnectionOrganizationAsync).mockResolvedValue({
...mockConnection,
supabaseOrganizationSlug: 'other-org',
supabaseOrganizationName: 'Other Org',
});
await createCommand(['--region', 'americas', '--organization', 'other-org']).runAsync();
expect(SupabaseMutation.setSupabaseConnectionOrganizationAsync).toHaveBeenCalledWith(
graphqlClient,
{
supabaseConnectionId: mockConnection.id,
organizationSlug: 'other-org',
}
);
});
it('resets the connection with --reauth, then links an existing project by default', async () => {
jest
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
.mockResolvedValueOnce(mockConnection)
.mockResolvedValueOnce(null)
.mockResolvedValue(mockConnection);
// Cascade delete removes the EAS project link with the connection.
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({
url: 'https://api.supabase.com/v1/oauth/authorize',
});
jest.mocked(SupabaseMutation.disconnectSupabaseAsync).mockResolvedValue(mockConnection.id);
jest.mocked(selectAsync).mockResolvedValueOnce('link').mockResolvedValue('americas');
jest.mocked(promptAsync).mockResolvedValue({ linkValue: mockProject.supabaseProjectRef });
jest.mocked(SupabaseMutation.linkSupabaseProjectAsync).mockResolvedValue(mockProject);
await createCommand(['--reauth']).runAsync();
expect(SupabaseMutation.disconnectSupabaseAsync).toHaveBeenCalledWith(
graphqlClient,
mockConnection.id
);
expect(SupabaseMutation.beginSupabaseOAuthAsync).toHaveBeenCalled();
expect(openBrowserAsync).toHaveBeenCalled();
expect(selectAsync).toHaveBeenCalledWith(
expect.stringContaining('previous EAS project link was removed'),
expect.arrayContaining([
expect.objectContaining({ value: 'link' }),
expect.objectContaining({ value: 'provision' }),
]),
expect.objectContaining({ initial: 'link' })
);
const promptOpts = jest.mocked(promptAsync).mock.calls[0]?.[0] as {
validate?: (value: string) => true | string;
};
expect(promptOpts.validate?.(mockProject.supabaseProjectRef)).toBe(true);
expect(promptOpts.validate?.('not a ref')).toEqual(expect.any(String));
const parseSpy = jest
.spyOn(supabaseCommandUtils, 'parseSupabaseProjectRef')
.mockImplementationOnce(() => {
throw 'nope';
});
expect(promptOpts.validate?.('x')).toBe('Invalid project ref or URL');
parseSpy.mockRestore();
expect(SupabaseMutation.linkSupabaseProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
supabaseProjectRef: mockProject.supabaseProjectRef,
});
expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled();
});
it('provisions after --reauth when the user chooses provision', async () => {
jest
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
.mockResolvedValueOnce(mockConnection)
.mockResolvedValueOnce(null)
.mockResolvedValue(mockConnection);
jest
.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync)
.mockResolvedValueOnce(null)
.mockResolvedValue(mockProject);
jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({
url: 'https://api.supabase.com/v1/oauth/authorize',
});
jest.mocked(SupabaseMutation.disconnectSupabaseAsync).mockResolvedValue(mockConnection.id);
jest
.mocked(selectAsync)
.mockResolvedValueOnce('provision')
.mockResolvedValueOnce('org-slug')
.mockResolvedValue('americas');
await createCommand(['--reauth']).runAsync();
expect(SupabaseMutation.provisionSupabaseProjectAsync).toHaveBeenCalled();
expect(SupabaseMutation.linkSupabaseProjectAsync).not.toHaveBeenCalled();
});
it('skips the post-reauth prompt when --link is passed', async () => {
jest
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
.mockResolvedValueOnce(mockConnection)
.mockResolvedValueOnce(null)
.mockResolvedValue(mockConnection);
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({
url: 'https://api.supabase.com/v1/oauth/authorize',
});
jest.mocked(SupabaseMutation.disconnectSupabaseAsync).mockResolvedValue(mockConnection.id);
jest.mocked(SupabaseMutation.linkSupabaseProjectAsync).mockResolvedValue(mockProject);
await createCommand(['--reauth', '--link', mockProject.supabaseProjectRef]).runAsync();
expect(SupabaseMutation.linkSupabaseProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
supabaseProjectRef: mockProject.supabaseProjectRef,
});
expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled();
expect(selectAsync).not.toHaveBeenCalledWith(
expect.stringContaining('previous EAS project link was removed'),
expect.anything(),
expect.anything()
);
});
it('rejects --reauth in non-interactive mode when a connection exists', async () => {
await expect(createCommand(['--reauth', '--non-interactive']).runAsync()).rejects.toThrow(
/non-interactive/
);
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
});
it('ignores --reauth when there is no connection', async () => {
jest
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
.mockResolvedValueOnce(null)
.mockResolvedValue(mockConnection);
jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({
url: 'https://api.supabase.com/v1/oauth/authorize',
});
await createCommand(['--reauth']).runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('--reauth ignored'));
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
});
it('prints json output for an already-connected project', async () => {
await createCommand(['--json']).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
project: expect.objectContaining({ ref: mockProject.supabaseProjectRef }),
envLocalWritten: true,
manualSteps: [],
})
);
});
it('requires --region in non-interactive mode when provisioning', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(/--region/);
});
it('provisions an additional project for --environment preview', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: {
supabaseProjectRef: 'previewref123456',
supabaseProjectUrl: 'https://previewref123456.supabase.co',
supabaseRegion: 'us-east-1',
publishableKey: 'sb_publishable_preview',
},
} as any);
await createCommand([
'--environment',
'preview',
'--region',
'americas',
'--overwrite',
]).runAsync();
expect(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
appId: testProjectId,
region: 'americas',
projectNameSuffix: 'preview',
})
);
expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
name: 'EXPO_PUBLIC_SUPABASE_URL',
value: 'https://previewref123456.supabase.co',
environments: ['preview'],
}),
testProjectId
);
expect(spawnAsync).not.toHaveBeenCalled();
});
it('surfaces additional provision failures with --link guidance', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockRejectedValue(
new BackgroundJobReceiptPollError({
errorType: BackgroundJobReceiptPollErrorType.JOB_FAILED_NO_WILL_RETRY,
receiptErrorMessage: 'maximum limits for the number of active free projects',
})
);
await expect(
createCommand(['--environment', 'preview', '--region', 'americas', '--overwrite']).runAsync()
).rejects.toThrow(
/maximum limits for the number of active free projects[\s\S]*--environment preview(?!.*--link)/
);
});
it('peels preview off the primary env var when adding --environment preview', async () => {
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
{
id: 'primary-url',
name: 'EXPO_PUBLIC_SUPABASE_URL',
value: 'https://primary.supabase.co',
scope: EnvironmentVariableScope.Project,
environments: ['production', 'preview', 'development'],
},
{
id: 'primary-key',
name: 'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY',
value: 'sb_publishable_primary',
scope: EnvironmentVariableScope.Project,
environments: ['production', 'preview', 'development'],
},
] as any);
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: {
supabaseProjectRef: 'previewref123456',
supabaseProjectUrl: 'https://previewref123456.supabase.co',
supabaseRegion: 'us-east-1',
publishableKey: 'sb_publishable_preview',
},
} as any);
await createCommand([
'--environment',
'preview',
'--region',
'americas',
'--overwrite',
]).runAsync();
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
id: 'primary-url',
environments: ['production', 'development'],
})
);
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
name: 'EXPO_PUBLIC_SUPABASE_URL',
value: 'https://previewref123456.supabase.co',
environments: ['preview'],
}),
testProjectId
);
});
it('rejects empty --environment', async () => {
await expect(createCommand(['--environment', ' ']).runAsync()).rejects.toThrow(
/at least one EAS environment/
);
});
it('rejects unknown --environment in non-interactive mode', async () => {
await expect(
createCommand([
'--environment',
'prevew',
'--region',
'americas',
'--non-interactive',
]).runAsync()
).rejects.toThrow(/EAS environment\(s\) not found/);
});
it('proceeds with unknown --environment after confirmation', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: {
supabaseProjectRef: 'stagingref123456',
supabaseProjectUrl: 'https://stagingref123456.supabase.co',
supabaseRegion: 'us-east-1',
publishableKey: 'sb_publishable_staging',
},
} as any);
jest.mocked(confirmAsync).mockResolvedValue(true);
await createCommand(['--environment', 'staging', '--region', 'americas']).runAsync();
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringMatching(
/EAS environment "staging" is not used on this project yet.*Continue provisioning\?/i
),
})
);
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
name: 'EXPO_PUBLIC_SUPABASE_URL',
environments: ['staging'],
}),
testProjectId
);
});
it('aborts when declining to create an unknown --environment', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await expect(
createCommand(['--environment', 'staging', '--region', 'americas']).runAsync()
).rejects.toThrow(/Canceled\. No additional Supabase project was provisioned/);
expect(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).not.toHaveBeenCalled();
});
it('rejects --environment with --link', async () => {
await expect(
createCommand(['--environment', 'preview', '--link', 'abcdefghijklmnop']).runAsync()
).rejects.toThrow(/Cannot combine --environment with --link/);
});
it('reconciles split env vars on a plain reconnect', async () => {
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
{
id: 'primary-url',
name: 'EXPO_PUBLIC_SUPABASE_URL',
value: 'https://primary.supabase.co',
scope: EnvironmentVariableScope.Project,
environments: ['production', 'development'],
},
{
id: 'preview-url',
name: 'EXPO_PUBLIC_SUPABASE_URL',
value: 'https://preview.supabase.co',
scope: EnvironmentVariableScope.Project,
environments: ['preview'],
},
] as any);
await createCommand(['--overwrite']).runAsync();
expect(EnvironmentVariableMutation.deleteAsync).toHaveBeenCalledWith(
graphqlClient,
'preview-url'
);
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
id: 'primary-url',
value: mockProject.supabaseProjectUrl,
environments: ['production', 'preview', 'development'],
})
);
});
it('rejects --environment in non-interactive mode when env vars would be skipped', async () => {
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
{
id: 'primary-url',
name: 'EXPO_PUBLIC_SUPABASE_URL',
value: 'https://primary.supabase.co',
scope: EnvironmentVariableScope.Project,
environments: ['production', 'preview', 'development'],
},
] as any);
await expect(
createCommand([
'--environment',
'preview',
'--region',
'americas',
'--non-interactive',
]).runAsync()
).rejects.toThrow(/--overwrite/);
expect(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).not.toHaveBeenCalled();
});
it('rejects --environment without a primary project', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
await expect(
createCommand(['--environment', 'preview', '--region', 'americas']).runAsync()
).rejects.toThrow(/without --environment first/);
});
it('continues and still writes env vars when SDK installation fails', async () => {
jest.mocked(spawnAsync).mockRejectedValue(new Error('npm exploded'));
await createCommand(['--overwrite']).runAsync();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('npx expo install'));
});
it('on a dynamic config, shows Expo install guidance and skips a second plugin rewrite', async () => {
const expoGuidance = [
'Cannot automatically write to dynamic config at: app.config.js',
'Add the following to your Expo config',
'',
'{',
' "plugins": [',
' "expo-sqlite"',
' ]',
'}',
].join('\n');
jest.mocked(spawnAsync).mockRejectedValue(
Object.assign(new Error('Process exited with non-zero code: 1'), {
stdout: `${expoGuidance}\n`,
stderr: '',
})
);
await createCommand(['--overwrite']).runAsync();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Cannot automatically write to dynamic config at: app.config.js')
);
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('"expo-sqlite"'));
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining("didn't install"));
});
it('rejects --environment with --reauth', async () => {
await expect(
createCommand(['--environment', 'preview', '--reauth']).runAsync()
).rejects.toThrow(/Cannot combine --environment with --reauth/);
});
it('warns when --link differs from an already linked project', async () => {
await createCommand(['--link', 'differentref12345', '--overwrite']).runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('Ignoring --link'));
expect(SupabaseMutation.linkSupabaseProjectAsync).not.toHaveBeenCalled();
});
it('warns when --region is passed for an already linked project', async () => {
await createCommand(['--region', 'emea', '--overwrite']).runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Ignoring --region/--organization')
);
});
it('throws when primary provision succeeds without a project link', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-1',
resultData: { supabaseProjectRef: 'newref1234567890' },
} as any);
await expect(createCommand(['--region', 'americas', '--overwrite']).runAsync()).rejects.toThrow(
/link with --link newref1234567890/
);
});
it('throws when primary provision succeeds without a project ref in the receipt', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-1',
resultData: {},
} as any);
await expect(createCommand(['--region', 'americas', '--overwrite']).runAsync()).rejects.toThrow(
/Provision succeeded but the Expo project link was not found/
);
});
it('throws when primary provision receipt data is not an object', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-1',
resultData: 'not-an-object',
} as any);
await expect(createCommand(['--region', 'americas', '--overwrite']).runAsync()).rejects.toThrow(
/Provision succeeded but the Expo project link was not found/
);
});
it('rejects --environment without a connection', async () => {
jest.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).mockResolvedValue(null);
await expect(
createCommand(['--environment', 'preview', '--region', 'americas']).runAsync()
).rejects.toThrow(/No Supabase connection found/);
});
it('rejects --organization with --environment', async () => {
await expect(
createCommand([
'--environment',
'preview',
'--region',
'americas',
'--organization',
'other-org',
'--overwrite',
]).runAsync()
).rejects.toThrow(/Cannot use --organization with --environment/);
});
it('throws when additional provision omits credentials', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: { supabaseProjectRef: 'previewref123456' },
} as any);
await expect(
createCommand(['--environment', 'preview', '--region', 'americas', '--overwrite']).runAsync()
).rejects.toThrow(
/did not return project credentials[\s\S]*Do not re-run connect --environment/
);
});
it('throws when additional env writes partially skip', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: {
supabaseProjectRef: 'previewref123456',
supabaseProjectUrl: 'https://previewref123456.supabase.co',
supabaseRegion: 'us-east-1',
publishableKey: 'sb_publishable_preview',
},
} as any);
jest
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
.mockResolvedValueOnce([]) // ensure: URL
.mockResolvedValueOnce([]) // ensure: KEY
.mockResolvedValueOnce([]) // upsert URL: create
.mockResolvedValueOnce([
{
id: 'existing-key',
name: 'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY',
value: 'different',
scope: EnvironmentVariableScope.Project,
environments: ['preview'],
},
] as any); // upsert KEY: exact match
jest.mocked(confirmAsync).mockResolvedValue(false);
await expect(
createCommand(['--environment', 'preview', '--region', 'americas']).runAsync()
).rejects.toThrow(
/did not write all EAS environment variables[\s\S]*Do not re-run connect --environment[\s\S]*sb_publishable_preview/
);
});
it('prints json for additional environment setup', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: {
supabaseProjectRef: 'previewref123456',
supabaseProjectUrl: 'https://previewref123456.supabase.co',
publishableKey: 'sb_publishable_preview',
},
} as any);
jest
.mocked(SupabaseMutation.listSupabaseOrganizationsAsync)
.mockRejectedValue(new Error('skip'));
await createCommand([
'--environment',
'preview',
'--region',
'americas',
'--overwrite',
'--json',
]).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
additionalProject: expect.objectContaining({
ref: 'previewref123456',
region: 'americas',
}),
environments: ['preview'],
})
);
});
it('wraps env write failures after additional provision', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: {
supabaseProjectRef: 'previewref123456',
supabaseProjectUrl: 'https://previewref123456.supabase.co',
supabaseRegion: 'us-east-1',
publishableKey: 'sb_publishable_preview',
},
} as any);
jest
.mocked(EnvironmentVariableMutation.createForAppAsync)
.mockRejectedValue(new Error('graphql down'));
await expect(
createCommand(['--environment', 'preview', '--region', 'americas', '--overwrite']).runAsync()
).rejects.toThrow(
/could not write EAS environment variables.*graphql down[\s\S]*Do not re-run connect --environment[\s\S]*EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_preview/
);
});
it('wraps non-Error env write failures after additional provision', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: {
supabaseProjectRef: 'previewref123456',
supabaseProjectUrl: 'https://previewref123456.supabase.co',
supabaseRegion: 'us-east-1',
publishableKey: 'sb_publishable_preview',
},
} as any);
jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockRejectedValue('graphql down');
await expect(
createCommand(['--environment', 'preview', '--region', 'americas', '--overwrite']).runAsync()
).rejects.toThrow(
/could not write EAS environment variables.*graphql down[\s\S]*Do not re-run connect --environment/
);
});
});
@@ -0,0 +1,130 @@
import openBrowserAsync from 'better-opn';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { SupabaseQuery } from '../../../../graphql/queries/SupabaseQuery';
import { SupabaseProjectData } from '../../../../graphql/types/SupabaseConnection';
import Log from '../../../../log';
import { printJsonOnlyOutput } from '../../../../utils/json';
import IntegrationsSupabaseDashboard from '../dashboard';
jest.mock('better-opn');
jest.mock('../../../../graphql/queries/SupabaseQuery');
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../ora', () => ({
ora: jest.fn(() => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
})),
}));
describe(IntegrationsSupabaseDashboard, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockProject: SupabaseProjectData = {
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',
};
function createCommand(argv: string[]): IntegrationsSupabaseDashboard {
const command = new IntegrationsSupabaseDashboard(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { slug: 'testapp' },
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(openBrowserAsync).mockResolvedValue(true as never);
});
it('opens the Supabase project dashboard', async () => {
await createCommand([]).runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith(
'https://supabase.com/dashboard/project/abcdefghijklmnop'
);
});
it('prints the dashboard URL as JSON', async () => {
await createCommand(['--json']).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
dashboardUrl: 'https://supabase.com/dashboard/project/abcdefghijklmnop',
});
expect(openBrowserAsync).not.toHaveBeenCalled();
});
it('warns when no project is linked', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Supabase project'));
expect(openBrowserAsync).not.toHaveBeenCalled();
});
it('emits a null dashboardUrl as JSON when no project is linked', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
await createCommand(['--json']).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({ dashboardUrl: null });
expect(openBrowserAsync).not.toHaveBeenCalled();
});
it('prints the dashboard URL in non-interactive mode', async () => {
await createCommand(['--non-interactive']).runAsync();
expect(openBrowserAsync).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith('https://supabase.com/dashboard/project/abcdefghijklmnop');
});
it('fails the spinner when the browser cannot be opened', async () => {
jest.mocked(openBrowserAsync).mockResolvedValue(false as never);
const { ora } = jest.requireMock('../../../../ora') as { ora: jest.Mock };
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
ora.mockReturnValue(spinner);
await createCommand([]).runAsync();
expect(spinner.fail).toHaveBeenCalledWith(
expect.stringContaining('Unable to open a web browser')
);
});
it('fails the spinner and rethrows when opening the browser throws', async () => {
jest.mocked(openBrowserAsync).mockRejectedValue(new Error('no browser'));
const { ora } = jest.requireMock('../../../../ora') as { ora: jest.Mock };
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
ora.mockReturnValue(spinner);
await expect(createCommand([]).runAsync()).rejects.toThrow('no browser');
expect(spinner.fail).toHaveBeenCalled();
});
});
@@ -0,0 +1,135 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { SupabaseMutation } from '../../../../graphql/mutations/SupabaseMutation';
import { SupabaseQuery } from '../../../../graphql/queries/SupabaseQuery';
import { SupabaseProjectData } from '../../../../graphql/types/SupabaseConnection';
import Log from '../../../../log';
import { confirmAsync } from '../../../../prompts';
import { printJsonOnlyOutput } from '../../../../utils/json';
import IntegrationsSupabaseDisconnect from '../disconnect';
jest.mock('../../../../graphql/queries/SupabaseQuery');
jest.mock('../../../../graphql/mutations/SupabaseMutation');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../ora', () => ({
ora: jest.fn(),
}));
import { ora } from '../../../../ora';
function mockOraSpinner(): {
start: jest.Mock;
succeed: jest.Mock;
fail: jest.Mock;
} {
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
jest.mocked(ora).mockReturnValue(spinner as never);
return spinner;
}
describe(IntegrationsSupabaseDisconnect, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockProject: SupabaseProjectData = {
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',
};
function createCommand(argv: string[]): IntegrationsSupabaseDisconnect {
const command = new IntegrationsSupabaseDisconnect(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { slug: 'testapp' },
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
mockOraSpinner();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'error').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(SupabaseMutation.deleteSupabaseProjectAsync).mockResolvedValue(mockProject.id);
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('removes the project link after confirmation', async () => {
await createCommand([]).runAsync();
expect(SupabaseMutation.deleteSupabaseProjectAsync).toHaveBeenCalledWith(
graphqlClient,
mockProject.id
);
});
it('skips confirmation with --yes', async () => {
await createCommand(['--yes']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(SupabaseMutation.deleteSupabaseProjectAsync).toHaveBeenCalled();
});
it('cancels when confirmation is declined', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await createCommand([]).runAsync();
expect(SupabaseMutation.deleteSupabaseProjectAsync).not.toHaveBeenCalled();
});
it('prints json when no project is linked', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
await createCommand(['--json']).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({ id: null });
});
it('warns when no project is linked', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Supabase project'));
expect(SupabaseMutation.deleteSupabaseProjectAsync).not.toHaveBeenCalled();
});
it('prints json with id and ref on success', async () => {
await createCommand(['--json', '--yes']).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
id: mockProject.id,
ref: mockProject.supabaseProjectRef,
});
});
it('fails the spinner and rethrows when delete fails', async () => {
jest
.mocked(SupabaseMutation.deleteSupabaseProjectAsync)
.mockRejectedValue(new Error('delete failed'));
const spinner = mockOraSpinner();
await expect(createCommand(['--yes']).runAsync()).rejects.toThrow('delete failed');
expect(spinner.fail).toHaveBeenCalledWith('Failed to remove Supabase project link');
});
});
@@ -0,0 +1,663 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { z } from 'zod';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import {
formatSupabaseOrganization,
formatSupabaseProjectLabel,
getSupabaseProjectDashboardUrl,
parseSupabaseProjectRef,
} from '../../../commandUtils/supabase';
import { SupabaseMutation } from '../../../graphql/mutations/SupabaseMutation';
import { SupabaseQuery } from '../../../graphql/queries/SupabaseQuery';
import {
SupabaseConnectionData,
SupabaseOrganizationData,
SupabaseProjectData,
} from '../../../graphql/types/SupabaseConnection';
import Log, { link } from '../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
import { promptAsync, selectAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import {
parseEnvironmentFlag,
resolveTargetEnvironmentsAsync,
} from '../../../environments/resolve';
import { upsertEnvVarAsync, upsertEnvVarsSequentiallyAsync } from '../../../environments/variables';
import { writeEnvLocalAsync } from '../../../integrations/shared/envFile';
import { setupSdkAndConfigAsync } from '../../../integrations/shared/sdk';
import {
EAS_SUPABASE_ENVIRONMENTS,
EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME,
EAS_SUPABASE_URL_ENV_VAR_NAME,
SUPABASE_ENV_LABEL,
confirmOverwriteForAdditionalProjectAsync,
createSupabaseEnvVars,
supabaseEnvironmentCancelMessage,
supabaseMoveConfirmMessage,
} from '../../../integrations/supabase/env';
import {
additionalProvisionFailureHint,
authorizeViaBrowserAsync,
loadOrganizationsBestEffortAsync,
pollProvisionReceiptAsync,
primaryProvisionFailureHint,
projectNameSuffixForEnvironments,
resolveOrganizationAsync,
resolvePublishableKeyAsync,
resolveRegionAsync,
} from '../../../integrations/supabase/provision';
const SDK_PACKAGES = ['@supabase/supabase-js', 'expo-sqlite'];
const CONFIG_PLUGIN = 'expo-sqlite';
const PrimaryProvisionResultSchema = z.object({
supabaseProjectId: z.string().optional(),
supabaseProjectRef: z.string().optional(),
});
const AdditionalProvisionResultSchema = z.object({
supabaseProjectRef: z.string(),
supabaseProjectName: z.string().optional(),
supabaseProjectUrl: z.string(),
supabaseRegion: z.string().optional(),
publishableKey: z.string(),
});
type AdditionalProvisionResult = {
supabaseProjectRef: string;
supabaseProjectName: string;
supabaseProjectUrl: string;
supabaseRegion: string;
publishableKey: string;
};
export default class IntegrationsSupabaseConnect extends EasCommand {
static override description =
'authorize Supabase, link or provision a project, install the SDK, and write EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY';
static override examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --link <project-url>',
'<%= config.bin %> <%= command.id %> --environment preview',
'<%= config.bin %> <%= command.id %> --environment preview --region americas --non-interactive --overwrite',
'<%= config.bin %> <%= command.id %> --reauth',
];
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
region: Flags.string({
description:
'Region when provisioning (americas | emea | apac, or e.g. us-east-1). Required with --non-interactive if provisioning. Ignored with --link or if already linked',
}),
organization: Flags.string({
description:
'Supabase org slug for a new primary project. Ignored with --link; incompatible with --environment',
}),
link: Flags.string({
description:
'Existing project URL or Reference ID (Project Settings → General). Sets the app primary instead of provisioning. Incompatible with --environment',
}),
reauth: Flags.boolean({
description:
'Disconnect the existing Supabase OAuth connection, then re-authorize in the browser. Removes the EAS connection and primary project link (Supabase projects are kept). Then prompts to link an existing project (default) or provision a new one; pass --link to skip the prompt. Interactive only; incompatible with --environment',
default: false,
}),
overwrite: Flags.boolean({
description:
'Replace existing EXPO_PUBLIC_SUPABASE_* in .env.local and EAS without prompting',
default: false,
}),
environment: Flags.string({
description:
'EAS environments for a separate hosted project (e.g. preview). Requires prior primary connect; writes URL/key only there. Incompatible with --link and --reauth',
}),
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsSupabaseConnect);
const {
region: regionFlag,
organization: organizationFlag,
link: linkRefFlag,
reauth,
overwrite,
environment: environmentFlag,
} = flags;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const requestedEnvironments = parseEnvironmentFlag(environmentFlag);
const linkProjectRef = linkRefFlag ? parseSupabaseProjectRef(linkRefFlag) : undefined;
if (jsonFlag) {
enableJsonOutput();
}
const {
privateProjectConfig: { projectId, projectDir, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsSupabaseConnect, {
nonInteractive,
withServerSideEnvironment: null,
});
if (requestedEnvironments) {
if (linkRefFlag) {
throw new Error(
'Cannot combine --environment with --link. --link sets the primary project; --environment provisions an additional one for the listed EAS environments.'
);
}
if (reauth) {
throw new Error(
'Cannot combine --environment with --reauth. Re-authorize first, then re-run with --environment.'
);
}
if (organizationFlag) {
throw new Error(
'Cannot use --organization with --environment. The additional project uses the organization from the existing connection.'
);
}
const targetEnvironments = await resolveTargetEnvironmentsAsync(
graphqlClient,
projectId,
requestedEnvironments,
nonInteractive,
{
defaultEnvironments: EAS_SUPABASE_ENVIRONMENTS,
cancelMessage: supabaseEnvironmentCancelMessage,
}
);
await this.runAdditionalEnvironmentSetupAsync({
graphqlClient,
projectId,
targetEnvironments,
regionFlag,
nonInteractive,
overwrite,
jsonFlag,
});
return;
}
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
let connection = await SupabaseQuery.getSupabaseConnectionByAccountIdAsync(
graphqlClient,
account.id
);
let didReauth = false;
if (reauth) {
if (!connection) {
if (!nonInteractive) {
Log.warn('--reauth ignored: no existing Supabase connection to reset.');
}
} else if (nonInteractive) {
throw new Error(
"--reauth re-authorizes in the browser, which isn't possible in non-interactive mode. Re-run `eas integrations:supabase:connect --reauth` interactively."
);
} else {
Log.warn(
'Resetting the Supabase connection. This removes the EAS connection and its project link (your Supabase projects are preserved). After authorizing, you will be asked to link an existing project (recommended) or provision a new one.'
);
await SupabaseMutation.disconnectSupabaseAsync(graphqlClient, connection.id);
Log.withTick('Reset the existing Supabase connection');
connection = null;
didReauth = true;
}
}
let organizations: SupabaseOrganizationData[] | null = null;
let authorizedConnection: SupabaseConnectionData;
if (connection) {
organizations = await loadOrganizationsBestEffortAsync(graphqlClient, account.id);
Log.withTick(
`Using existing Supabase organization ${chalk.bold(
formatSupabaseOrganization(connection, organizations ?? undefined)
)}`
);
authorizedConnection = connection;
} else {
authorizedConnection = await authorizeViaBrowserAsync(graphqlClient, account, nonInteractive);
}
connection = authorizedConnection;
let project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId);
if (project) {
Log.withTick(
`Using existing Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}`
);
if (linkProjectRef && linkProjectRef !== project.supabaseProjectRef) {
Log.warn(
`Ignoring --link ${chalk.bold(linkProjectRef)}: this app is already linked to ${chalk.bold(
project.supabaseProjectRef
)}. Run \`eas integrations:supabase:disconnect\` first to link a different project.`
);
} else if (regionFlag || organizationFlag) {
Log.warn(
'Ignoring --region/--organization: this app is already linked to a Supabase project.'
);
}
} else if (linkProjectRef) {
// No org picker here: the project ref determines its organization, and the server validates
// that it matches the connection's organization.
project = await SupabaseMutation.linkSupabaseProjectAsync(graphqlClient, {
appId: projectId,
supabaseProjectRef: linkProjectRef,
});
Log.withTick(`Linked Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}`);
} else if (didReauth) {
const afterReauth = await this.resolveProjectAfterReauthAsync({
graphqlClient,
projectId,
accountId: account.id,
connection: authorizedConnection,
organizationFlag,
regionFlag,
nonInteractive,
organizations,
});
project = afterReauth.project;
authorizedConnection = afterReauth.connection;
connection = authorizedConnection;
} else {
authorizedConnection = await resolveOrganizationAsync(
graphqlClient,
account.id,
authorizedConnection,
organizationFlag,
nonInteractive,
organizations
);
connection = authorizedConnection;
const region = await resolveRegionAsync(regionFlag, nonInteractive);
project = await this.provisionAndPollPrimaryProjectAsync(graphqlClient, projectId, region);
}
const publishableKey = await resolvePublishableKeyAsync(graphqlClient, projectId, project);
const envVars = createSupabaseEnvVars(project.supabaseProjectUrl, publishableKey);
const manualSteps = await setupSdkAndConfigAsync(projectDir, exp, {
packages: SDK_PACKAGES,
plugin: CONFIG_PLUGIN,
label: SUPABASE_ENV_LABEL,
jsonFlag,
});
const envLocalWritten = await writeEnvLocalAsync(projectDir, envVars, {
label: SUPABASE_ENV_LABEL,
nonInteractive,
overwrite,
});
const easWritten = await upsertEnvVarsSequentiallyAsync(envVars, envVar =>
upsertEnvVarAsync(
graphqlClient,
projectId,
envVar,
EAS_SUPABASE_ENVIRONMENTS,
nonInteractive,
overwrite,
{ mode: 'replaceOtherEnvironments' }
)
);
if (jsonFlag) {
printJsonOnlyOutput({
organizationConnection: {
id: connection.id,
organizationSlug: connection.supabaseOrganizationSlug,
},
project: {
id: project.id,
ref: project.supabaseProjectRef,
url: project.supabaseProjectUrl,
region: project.supabaseRegion,
},
dashboardUrl: getSupabaseProjectDashboardUrl(project),
envLocalWritten,
environmentVariables: envVars.map((v, i) => ({ name: v.name, easWritten: easWritten[i] })),
manualSteps,
});
return;
}
this.printNextSteps(project, manualSteps);
}
private async resolveProjectAfterReauthAsync({
graphqlClient,
projectId,
accountId,
connection,
organizationFlag,
regionFlag,
nonInteractive,
organizations,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
accountId: string;
connection: SupabaseConnectionData;
organizationFlag: string | undefined;
regionFlag: string | undefined;
nonInteractive: boolean;
organizations: SupabaseOrganizationData[] | null;
}): Promise<{ project: SupabaseProjectData; connection: SupabaseConnectionData }> {
// --reauth already required interactive mode when a connection existed.
const choice = await selectAsync<'link' | 'provision'>(
'The previous EAS project link was removed. What next?',
[
{ title: 'Link an existing Supabase project (recommended)', value: 'link' },
{ title: 'Provision a new Supabase project', value: 'provision' },
],
{ initial: 'link' }
);
if (choice === 'link') {
const { linkValue } = await promptAsync({
type: 'text',
name: 'linkValue',
message: 'Supabase project ref or URL',
validate: (value: string) => {
try {
parseSupabaseProjectRef(value);
return true;
} catch (error) {
return error instanceof Error ? error.message : 'Invalid project ref or URL';
}
},
});
const project = await SupabaseMutation.linkSupabaseProjectAsync(graphqlClient, {
appId: projectId,
supabaseProjectRef: parseSupabaseProjectRef(linkValue),
});
Log.withTick(`Linked Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}`);
return { project, connection };
}
const nextConnection = await resolveOrganizationAsync(
graphqlClient,
accountId,
connection,
organizationFlag,
nonInteractive,
organizations
);
const region = await resolveRegionAsync(regionFlag, nonInteractive);
const project = await this.provisionAndPollPrimaryProjectAsync(
graphqlClient,
projectId,
region
);
return { project, connection: nextConnection };
}
private async provisionAndPollPrimaryProjectAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
region: string
): Promise<SupabaseProjectData> {
const receipt = await SupabaseMutation.provisionSupabaseProjectAsync(graphqlClient, {
appId: projectId,
region,
});
const finalized = await pollProvisionReceiptAsync(graphqlClient, receipt, {
startMessage: 'Provisioning Supabase project (this can take a minute)…',
waitingMessage: 'Waiting for Expo to finish Supabase setup…',
failureMessage: 'Failed to provision the Supabase project',
failureHint: primaryProvisionFailureHint(),
});
const parsedResult = PrimaryProvisionResultSchema.safeParse(finalized.resultData);
const resultData = parsedResult.success ? parsedResult.data : undefined;
const project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId, {
useCache: false,
});
if (!project) {
throw new Error(
resultData?.supabaseProjectRef
? `Provision succeeded for project ${resultData.supabaseProjectRef}, but the Expo project link was not found. Try again, or link with --link ${resultData.supabaseProjectRef}.`
: 'Provision succeeded but the Expo project link was not found. Try again, or link an existing project with --link.'
);
}
Log.withTick(`Provisioned Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}`);
return project;
}
private async runAdditionalEnvironmentSetupAsync({
graphqlClient,
projectId,
targetEnvironments,
regionFlag,
nonInteractive,
overwrite,
jsonFlag,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
targetEnvironments: string[];
regionFlag: string | undefined;
nonInteractive: boolean;
overwrite: boolean;
jsonFlag: boolean;
}): Promise<void> {
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
let connection = await SupabaseQuery.getSupabaseConnectionByAccountIdAsync(
graphqlClient,
account.id
);
if (!connection) {
throw new Error(
'No Supabase connection found. Run `eas integrations:supabase:connect` first, then re-run with --environment.'
);
}
const primaryProject = await SupabaseQuery.getSupabaseProjectByAppIdAsync(
graphqlClient,
projectId
);
if (!primaryProject) {
throw new Error(
'No primary Supabase project is linked yet. Run `eas integrations:supabase:connect` without --environment first.'
);
}
const organizations = await loadOrganizationsBestEffortAsync(graphqlClient, account.id);
Log.withTick(
`Using existing Supabase organization ${chalk.bold(
formatSupabaseOrganization(connection, organizations ?? undefined)
)}`
);
Log.withTick(
`Primary project remains ${chalk.bold(formatSupabaseProjectLabel(primaryProject))}; provisioning an additional project for ${targetEnvironments.join(', ')}`
);
const region = await resolveRegionAsync(regionFlag, nonInteractive);
const projectNameSuffix = projectNameSuffixForEnvironments(targetEnvironments);
// Fail closed before billing: refuse to provision if we already know env writes will be skipped.
const { forceOverwrite } = await confirmOverwriteForAdditionalProjectAsync(
graphqlClient,
projectId,
targetEnvironments,
nonInteractive,
overwrite
);
const effectiveOverwrite = overwrite || forceOverwrite;
const additional = await this.provisionAndPollAdditionalProjectAsync(graphqlClient, projectId, {
region,
projectNameSuffix,
targetEnvironments,
});
const envVars = createSupabaseEnvVars(additional.supabaseProjectUrl, additional.publishableKey);
const dashboardUrl = getSupabaseProjectDashboardUrl({
supabaseProjectRef: additional.supabaseProjectRef,
});
let easWritten: boolean[] = [];
try {
easWritten = await upsertEnvVarsSequentiallyAsync(envVars, envVar =>
upsertEnvVarAsync(
graphqlClient,
projectId,
envVar,
targetEnvironments,
nonInteractive,
effectiveOverwrite,
{
mode: 'keepOtherEnvironments',
moveConfirmMessage: supabaseMoveConfirmMessage,
}
)
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw this.additionalEnvWriteFailureError({
additional,
targetEnvironments,
dashboardUrl,
headline: `Provisioned additional Supabase project ${additional.supabaseProjectRef}, but could not write EAS environment variables for ${targetEnvironments.join(', ')}: ${message}`,
});
}
if (easWritten.some(written => !written)) {
throw this.additionalEnvWriteFailureError({
additional,
targetEnvironments,
dashboardUrl,
headline: `Provisioned additional Supabase project ${additional.supabaseProjectRef}, but did not write all EAS environment variables for ${targetEnvironments.join(', ')}.`,
});
}
if (jsonFlag) {
printJsonOnlyOutput({
additionalProject: {
ref: additional.supabaseProjectRef,
url: additional.supabaseProjectUrl,
region: additional.supabaseRegion,
},
environments: targetEnvironments,
dashboardUrl,
environmentVariables: envVars.map((v, i) => ({ name: v.name, easWritten: easWritten[i] })),
});
return;
}
Log.addNewLineIfNone();
Log.log(chalk.green('Additional Supabase project is ready!'));
Log.newLine();
Log.log(`${chalk.bold('Dashboard')}: ${link(dashboardUrl, { dim: false })}`);
Log.log(
`EAS environment variables updated for: ${targetEnvironments.map(env => chalk.bold(env)).join(', ')}`
);
Log.log(
`Primary linked project is unchanged (${chalk.bold(primaryProject.supabaseProjectRef)}).`
);
}
private async provisionAndPollAdditionalProjectAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
{
region,
projectNameSuffix,
targetEnvironments,
}: { region: string; projectNameSuffix: string; targetEnvironments: string[] }
): Promise<AdditionalProvisionResult> {
const receipt = await SupabaseMutation.provisionAdditionalSupabaseProjectAsync(graphqlClient, {
appId: projectId,
region,
projectNameSuffix,
});
const finalized = await pollProvisionReceiptAsync(graphqlClient, receipt, {
startMessage: `Provisioning additional Supabase project for ${targetEnvironments.join(', ')}…`,
waitingMessage: 'Waiting for Expo to finish additional Supabase setup…',
failureMessage: 'Failed to provision the additional Supabase project',
failureHint: additionalProvisionFailureHint(targetEnvironments),
});
const parsedResult = AdditionalProvisionResultSchema.safeParse(finalized.resultData);
if (!parsedResult.success) {
throw new Error(
'Additional provision succeeded but did not return project credentials. Open your Supabase dashboard, copy the project URL and publishable key, and set EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY on the target EAS environments. Do not re-run connect --environment — that would create another project.'
);
}
const resultData = parsedResult.data;
const additional: AdditionalProvisionResult = {
supabaseProjectRef: resultData.supabaseProjectRef,
supabaseProjectName: resultData.supabaseProjectName ?? resultData.supabaseProjectRef,
supabaseProjectUrl: resultData.supabaseProjectUrl,
supabaseRegion: resultData.supabaseRegion ?? region,
publishableKey: resultData.publishableKey,
};
Log.withTick(
`Provisioned additional Supabase project ${chalk.bold(formatSupabaseProjectLabel(additional))}`
);
return additional;
}
private additionalEnvWriteFailureError({
additional,
targetEnvironments,
dashboardUrl,
headline,
}: {
additional: Pick<AdditionalProvisionResult, 'supabaseProjectUrl' | 'publishableKey'>;
targetEnvironments: string[];
dashboardUrl: string;
headline: string;
}): Error {
return new Error(
[
headline,
`Do not re-run connect --environment — the Supabase project already exists. Set these on ${targetEnvironments.join(', ')}:`,
` EXPO_PUBLIC_SUPABASE_URL=${additional.supabaseProjectUrl}`,
` EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=${additional.publishableKey}`,
`Dashboard: ${dashboardUrl}`,
].join('\n')
);
}
private printNextSteps(project: SupabaseProjectData, manualSteps: string[]): void {
Log.addNewLineIfNone();
Log.log(chalk.green('Supabase is connected!'));
Log.newLine();
Log.log(
`${chalk.bold('Dashboard')}: ${link(getSupabaseProjectDashboardUrl(project), { dim: false })}`
);
Log.newLine();
Log.log('Next steps:');
Log.log(
` 1. Create a Supabase client (e.g. ${chalk.bold('src/lib/supabase.ts')}) following our guide (${chalk.cyan('https://docs.expo.dev/guides/using-supabase')}). It reads ${chalk.bold(EAS_SUPABASE_URL_ENV_VAR_NAME)} and ${chalk.bold(EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME)}, and runs in Expo Go.`
);
Log.log(
` 2. For local development, run ${chalk.cyan('supabase start')} and point ${chalk.bold(EAS_SUPABASE_URL_ENV_VAR_NAME)} at the local stack (see the guide).`
);
Log.log(
` 3. For a separate hosted project for Preview (or other EAS environments), re-run ${chalk.cyan('eas integrations:supabase:connect --environment preview')}.`
);
if (manualSteps.length > 0) {
Log.newLine();
Log.warn('Finish setup manually:');
for (const step of manualSteps) {
if (step.includes('\n')) {
for (const line of step.split('\n')) {
Log.warn(` ${line}`);
}
} else {
Log.warn(` • ${step}`);
}
}
}
}
}
@@ -0,0 +1,82 @@
import openBrowserAsync from 'better-opn';
import EasCommand from '../../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import {
getSupabaseProjectDashboardUrl,
logNoSupabaseProject,
} from '../../../commandUtils/supabase';
import { SupabaseQuery } from '../../../graphql/queries/SupabaseQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class IntegrationsSupabaseDashboard extends EasCommand {
static override description =
"open this app's primary Supabase project in the dashboard (or print the URL with --non-interactive)";
static override examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --non-interactive',
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsSupabaseDashboard);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsSupabaseDashboard, {
nonInteractive,
withServerSideEnvironment: null,
});
const project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId);
if (!project) {
if (jsonFlag) {
printJsonOnlyOutput({ dashboardUrl: null });
} else {
logNoSupabaseProject(exp.slug);
}
return;
}
const dashboardUrl = getSupabaseProjectDashboardUrl(project);
if (jsonFlag) {
printJsonOnlyOutput({ dashboardUrl });
return;
}
if (nonInteractive) {
Log.log(dashboardUrl);
return;
}
const spinner = ora('Opening your Supabase dashboard').start();
try {
const opened = await openBrowserAsync(dashboardUrl);
if (opened) {
spinner.succeed('Opened your Supabase dashboard');
} else {
spinner.fail(`Unable to open a web browser. Supabase dashboard: ${dashboardUrl}`);
}
} catch (error) {
spinner.fail(`Unable to open a web browser. Supabase dashboard: ${dashboardUrl}`);
throw error;
}
}
}
@@ -0,0 +1,113 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import {
formatSupabaseProject,
formatSupabaseProjectLabel,
logNoSupabaseProject,
} from '../../../commandUtils/supabase';
import { SupabaseMutation } from '../../../graphql/mutations/SupabaseMutation';
import { SupabaseQuery } from '../../../graphql/queries/SupabaseQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { confirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class IntegrationsSupabaseDisconnect extends EasCommand {
static override description =
"remove this app's Supabase project link from EAS (keeps the Supabase project; does not delete EXPO_PUBLIC_SUPABASE_* env vars)";
static override examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --yes',
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
yes: Flags.boolean({
char: 'y',
description: 'Skip confirmation prompt',
default: false,
}),
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsSupabaseDisconnect);
const { yes } = flags;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsSupabaseDisconnect, {
nonInteractive,
withServerSideEnvironment: null,
});
const project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId);
if (!project) {
if (jsonFlag) {
printJsonOnlyOutput({ id: null });
} else {
logNoSupabaseProject(exp.slug);
}
return;
}
if (!jsonFlag) {
Log.addNewLineIfNone();
Log.log(formatSupabaseProject(project));
Log.newLine();
}
if (!nonInteractive && !yes) {
const confirmed = await confirmAsync({
message:
'Remove this Supabase project link from EAS servers? This does not delete the project on Supabase.',
});
if (!confirmed) {
Log.warn('Canceled removal of the Supabase project link.');
return;
}
} else if (!jsonFlag) {
Log.warn(
'Removing the Supabase project link from EAS servers. This does not delete the project on Supabase.'
);
}
const spinner = jsonFlag ? null : ora('Removing Supabase project link').start();
try {
await SupabaseMutation.deleteSupabaseProjectAsync(graphqlClient, project.id);
spinner?.succeed(
`Removed Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))} from EAS servers`
);
} catch (error) {
spinner?.fail('Failed to remove Supabase project link');
throw error;
}
if (jsonFlag) {
printJsonOnlyOutput({ id: project.id, ref: project.supabaseProjectRef });
return;
}
Log.newLine();
Log.log(
`The ${chalk.bold('EXPO_PUBLIC_SUPABASE_URL')} and ${chalk.bold(
'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY'
)} values remain in ${chalk.bold('.env.local')} and in your EAS environment variables. Remove them if you no longer need them.`
);
}
}
@@ -3,11 +3,18 @@ import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { DeviceRunSessionAvailabilityQuery } from '../../../graphql/queries/DeviceRunSessionAvailabilityQuery';
import Log from '../../../log';
import { EAS_SIMULATOR_WAITLIST_URL } from '../../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorAvailability from '../availability';
jest.mock('../../../graphql/queries/DeviceRunSessionAvailabilityQuery');
jest.mock('../../../log');
jest.mock('../../../log', () => ({
__esModule: true,
default: {
log: jest.fn(),
},
link: jest.fn((url: string) => url),
}));
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
@@ -69,7 +76,7 @@ describe(SimulatorAvailability, () => {
});
});
it('emits JSON with available false when not enabled', async () => {
it('emits JSON with available false and the waitlist URL when not enabled', async () => {
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
const command = createCommand(['--json']);
@@ -78,10 +85,23 @@ describe(SimulatorAvailability, () => {
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
available: false,
accountName: 'testuser',
waitlistUrl: EAS_SIMULATOR_WAITLIST_URL,
});
});
it('logs a graceful message when not enabled', async () => {
it('omits the waitlist URL from JSON when enabled', async () => {
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
const command = createCommand(['--json']);
await command.runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
available: true,
accountName: 'testuser',
});
});
it('logs a graceful message pointing at the waitlist when not enabled', async () => {
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
const command = createCommand([]);
@@ -89,7 +109,8 @@ describe(SimulatorAvailability, () => {
expect(mockByAppIdAsync).toHaveBeenCalledWith(graphqlClient, projectId);
expect(mockLog).toHaveBeenCalledWith(
"EAS Simulator isn't available on testuser yet — it's coming soon."
"EAS Simulator isn't available on testuser yet — it's coming soon.\n" +
`Join the waitlist to get access: ${EAS_SIMULATOR_WAITLIST_URL}`
);
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
@@ -11,6 +11,7 @@ import {
JobRunStatus,
} from '../../../graphql/generated';
import { DeviceRunSessionMutation } from '../../../graphql/mutations/DeviceRunSessionMutation';
import { DeviceRunSessionAvailabilityQuery } from '../../../graphql/queries/DeviceRunSessionAvailabilityQuery';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
@@ -22,10 +23,11 @@ import {
loadSimulatorEnvAsync,
resetSimulatorEnvAsync,
} from '../../../simulator/env';
import SimulatorStart from '../start';
import Simulator from '../index';
jest.mock('fs-extra');
jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
jest.mock('../../../graphql/queries/DeviceRunSessionAvailabilityQuery');
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../log', () => ({
__esModule: true,
@@ -72,6 +74,7 @@ const mockCreateDeviceRunSessionAsync = jest.mocked(
const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked(
DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync
);
const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQuery.byAppIdAsync);
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
@@ -142,13 +145,14 @@ function getMockOclifConfig(): Config {
return config;
}
describe(SimulatorStart, () => {
describe(Simulator, () => {
const mockConfig = getMockOclifConfig();
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
beforeEach(() => {
jest.clearAllMocks();
delete process.env[EAS_SIMULATOR_SESSION_ID];
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
mockCreateDeviceRunSessionAsync.mockResolvedValue(makeCreatedDeviceRunSession());
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue({
id: 'session-123',
@@ -168,20 +172,49 @@ describe(SimulatorStart, () => {
}
});
function createCommand(argv: string[]): {
command: SimulatorStart;
function createCommand(
argv: string[],
{ isExpoAdmin = false }: { isExpoAdmin?: boolean } = {}
): {
command: Simulator;
getContextAsync: jest.SpyInstance;
} {
const command = new SimulatorStart(argv, mockConfig);
const command = new Simulator(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
loggedIn: { actor: { isExpoAdmin }, graphqlClient },
projectDir,
projectId: 'project-123',
});
return { command, getContextAsync };
}
it('fails with the waitlist link without creating a session when the account is gated', async () => {
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
"EAS Simulator isn't available on testuser yet — it's coming soon.\n" +
'Join the waitlist to get access: https://expo.dev/services/simulators'
);
expect(mockAvailabilityByAppIdAsync).toHaveBeenCalledWith(graphqlClient, 'project-123');
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
expect(mockPromptAsync).not.toHaveBeenCalled();
});
it('skips the gate check for Expo admins on a gated account', async () => {
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
const { command } = createCommand(['--platform', 'ios', '--non-interactive'], {
isExpoAdmin: true,
});
await command.runAsync();
expect(mockAvailabilityByAppIdAsync).not.toHaveBeenCalled();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalled();
});
it('prints environment variables without saving when outputting env', async () => {
const { command, getContextAsync } = createCommand([
'--platform',
@@ -192,7 +225,7 @@ describe(SimulatorStart, () => {
]);
await command.runAsync();
expect(getContextAsync).toHaveBeenCalledWith(SimulatorStart, {
expect(getContextAsync).toHaveBeenCalledWith(Simulator, {
nonInteractive: true,
});
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
@@ -164,6 +164,8 @@ describe(SimulatorList, () => {
'argent',
'--platform',
'ios',
'--name',
'checkout',
'--limit',
'25',
'--after',
@@ -179,6 +181,7 @@ describe(SimulatorList, () => {
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
types: [DeviceRunSessionType.Argent],
platforms: [AppPlatform.Ios],
name: 'checkout',
},
});
});
@@ -6,10 +6,15 @@ import {
import { DeviceRunSessionAvailabilityQuery } from '../../graphql/queries/DeviceRunSessionAvailabilityQuery';
import Log from '../../log';
import { ora } from '../../ora';
import {
EAS_SIMULATOR_WAITLIST_URL,
formatSimulatorUnavailableMessage,
} from '../../simulator/utils';
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';
@@ -52,7 +57,12 @@ export default class SimulatorAvailability extends EasCommand {
}
if (jsonFlag) {
printJsonOnlyOutput({ available, accountName });
printJsonOnlyOutput({
available,
accountName,
// Only present when gated, so scripts and agents can pass the waitlist link on.
...(available ? {} : { waitlistUrl: EAS_SIMULATOR_WAITLIST_URL }),
});
return;
}
@@ -61,6 +71,6 @@ export default class SimulatorAvailability extends EasCommand {
return;
}
Log.log(`EAS Simulator isn't available on ${accountName} yet — it's coming soon.`);
Log.log(formatSimulatorUnavailableMessage(accountName));
}
}
@@ -30,6 +30,7 @@ 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';
@@ -5,6 +5,7 @@ import { SIMULATOR_DOTENV_FILE_NAME, loadSimulatorEnvAsync } from '../../simulat
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,6 +28,7 @@ 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';
@@ -4,6 +4,7 @@ import nullthrows from 'nullthrows';
import { getDeviceRunSessionUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
@@ -15,6 +16,7 @@ import {
JobRunStatus,
} from '../../graphql/generated';
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
import { DeviceRunSessionAvailabilityQuery } from '../../graphql/queries/DeviceRunSessionAvailabilityQuery';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
@@ -31,6 +33,7 @@ import {
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
DeviceRunSessionRemoteConfig,
formatRemoteSessionInstructions,
formatSimulatorUnavailableMessage,
getRemoteSessionEnvironmentVariables,
} from '../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -49,9 +52,9 @@ const APP_PLATFORM_BY_FLAG_VALUE: Record<PlatformFlagValue, AppPlatform> = {
ios: AppPlatform.Ios,
};
export default class SimulatorStart extends EasCommand {
export default class Simulator extends EasCommand {
static override hidden = true;
static override aliases = ['simulator'];
static override aliases = ['simulator:start', 'sim', 'sim:start'];
static override description =
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it';
@@ -100,7 +103,7 @@ export default class SimulatorStart extends EasCommand {
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(SimulatorStart);
const { flags } = await this.parse(Simulator);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
@@ -110,11 +113,24 @@ export default class SimulatorStart extends EasCommand {
const {
projectId,
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorStart, {
loggedIn: { actor, graphqlClient },
} = await this.getContextAsync(Simulator, {
nonInteractive,
});
// The server would reject the session anyway, but check the gate first so gated
// accounts get the waitlist link instead of a generic permission error. Expo admins
// skip the check so they can work on any account and let the server decide.
if (!actor.isExpoAdmin) {
const { accountName, available } = await DeviceRunSessionAvailabilityQuery.byAppIdAsync(
graphqlClient,
projectId
);
if (!available) {
throw new EasCommandError(formatSimulatorUnavailableMessage(accountName));
}
}
// The server rejects blank names, so trim here and treat a whitespace-only
// --name as if it had been omitted rather than surfacing a validation error.
const name = flags.name?.trim() || undefined;
@@ -50,6 +50,7 @@ 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';
@@ -69,6 +70,9 @@ export default class SimulatorList extends EasCommand {
options: Object.values(PLATFORM_FLAG_VALUES),
multiple: true,
})(),
name: Flags.string({
description: 'Filter by session name (case-insensitive prefix match)',
}),
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }),
after: Flags.string({
description:
@@ -107,6 +111,9 @@ export default class SimulatorList extends EasCommand {
if (flags.platform && flags.platform.length > 0) {
filter.platforms = flags.platform.map(value => PLATFORM_BY_FLAG_VALUE[value]);
}
if (flags.name) {
filter.name = flags.name;
}
const limit = flags.limit ?? DEFAULT_LIMIT;
@@ -16,6 +16,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class SimulatorStop extends EasCommand {
static override hidden = true;
static override aliases = ['sim:stop'];
static override description =
'[EXPERIMENTAL] stop a remote simulator session on EAS by its simulator session ID';
@@ -0,0 +1,154 @@
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import { confirmAsync } from '../../prompts';
import { DEFAULT_ENVIRONMENTS } from '../defaults';
import { parseEnvironmentFlag, resolveTargetEnvironmentsAsync } from '../resolve';
jest.mock('../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../prompts');
describe('parseEnvironmentFlag', () => {
it('returns null when undefined', () => {
expect(parseEnvironmentFlag(undefined)).toBeNull();
});
it('throws on empty or whitespace-only values', () => {
expect(() => parseEnvironmentFlag('')).toThrow(/Pass at least one EAS environment/);
expect(() => parseEnvironmentFlag(' ')).toThrow(/Pass at least one EAS environment/);
expect(() => parseEnvironmentFlag(',,,')).toThrow(/Pass at least one EAS environment/);
});
it('dedupes and normalizes values', () => {
expect(parseEnvironmentFlag(' Preview ,production, preview ')).toEqual([
'preview',
'production',
]);
});
it('throws on invalid environment names', () => {
expect(() => parseEnvironmentFlag('ab')).toThrow(/Invalid EAS environment/);
expect(() => parseEnvironmentFlag('Bad Env')).toThrow(/Invalid EAS environment/);
});
});
describe('resolveTargetEnvironmentsAsync', () => {
const client = {} as ExpoGraphqlClient;
const options = {
defaultEnvironments: DEFAULT_ENVIRONMENTS,
cancelMessage: (knownEnvironments: string) => `Canceled. Known: ${knownEnvironments}.`,
};
beforeEach(() => {
jest.resetAllMocks();
});
it('wraps environments query failures in a generic error that keeps the cause', async () => {
const cause = new Error('network down');
jest
.mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync)
.mockRejectedValue(cause);
await expect(
resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], true, options)
).rejects.toThrow(
expect.objectContaining({
message: expect.stringMatching(/Failed to fetch available environments/),
cause,
})
);
});
it('keeps non-Error query failures as the cause', async () => {
jest
.mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync)
.mockRejectedValue('network down');
await expect(
resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], true, options)
).rejects.toThrow(
expect.objectContaining({
message: expect.stringMatching(/Failed to fetch available environments/),
cause: 'network down',
})
);
});
it('falls back to default environments when none are known', async () => {
jest
.mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync)
.mockResolvedValue([]);
await expect(
resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], true, options)
).resolves.toEqual(['preview']);
});
it('returns requested environments when all are known', async () => {
jest
.mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync)
.mockResolvedValue(['production', 'preview']);
await expect(
resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], true, options)
).resolves.toEqual(['preview']);
});
it('throws in non-interactive mode for unknown default environments', async () => {
jest
.mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync)
.mockResolvedValue(['production']);
await expect(
resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], true, options)
).rejects.toThrow(/EAS environment\(s\) not found/);
});
it('throws in non-interactive mode for unknown custom environments', async () => {
jest
.mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync)
.mockResolvedValue(['production']);
await expect(
resolveTargetEnvironmentsAsync(client, 'app-1', ['staging'], true, options)
).rejects.toThrow(/Custom environments require an Enterprise plan/);
});
it('confirms unknown environments interactively', async () => {
jest
.mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync)
.mockResolvedValue(['production']);
jest.mocked(confirmAsync).mockResolvedValue(true);
await expect(
resolveTargetEnvironmentsAsync(client, 'app-1', ['preview', 'development'], false, options)
).resolves.toEqual(['preview', 'development']);
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('environments') })
);
});
it('cancels when interactive confirmation is declined', async () => {
jest
.mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync)
.mockResolvedValue(['production']);
jest.mocked(confirmAsync).mockResolvedValue(false);
await expect(
resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], false, options)
).rejects.toThrow('Canceled. Known: production.');
});
it('mentions enterprise plan for custom environments interactively', async () => {
jest
.mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync)
.mockResolvedValue(DEFAULT_ENVIRONMENTS);
jest.mocked(confirmAsync).mockResolvedValue(true);
await resolveTargetEnvironmentsAsync(client, 'app-1', ['enterprise-env'], false, options);
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('Custom environments require an Enterprise plan'),
})
);
});
});
@@ -0,0 +1,426 @@
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EnvironmentVariableScope, EnvironmentVariableVisibility } from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { EnvVar, upsertEnvVarAsync, upsertEnvVarsSequentiallyAsync } from '../variables';
jest.mock('../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../prompts');
jest.mock('../../log');
const client = {} as ExpoGraphqlClient;
const envVar: EnvVar = {
name: 'EXPO_PUBLIC_EXAMPLE_URL',
value: 'https://new.example.test',
visibility: EnvironmentVariableVisibility.Public,
};
const REPLACE = { mode: 'replaceOtherEnvironments' } as const;
const KEEP = {
mode: 'keepOtherEnvironments',
moveConfirmMessage: (name: string, environments: string) => `Move ${name} for ${environments}?`,
} as const;
function mockRows(
rows: {
id: string;
environments: string[] | null;
value?: string;
visibility?: EnvironmentVariableVisibility;
}[],
{ includeAccountScoped = false }: { includeAccountScoped?: boolean } = {}
): void {
const projectRows = rows.map(row => ({
id: row.id,
scope: EnvironmentVariableScope.Project,
environments: row.environments,
value: row.value ?? 'old',
visibility: row.visibility ?? EnvironmentVariableVisibility.Public,
}));
const all = includeAccountScoped
? [
...projectRows,
{
id: 'account-row',
scope: EnvironmentVariableScope.Shared,
environments: ['production'],
value: 'shared',
},
]
: projectRows;
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue(all as never);
}
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({} as never);
jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValue({} as never);
jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValue({} as never);
});
describe('upsertEnvVarAsync, no existing variable', () => {
it.each([
['replaceOtherEnvironments', REPLACE],
['keepOtherEnvironments', KEEP],
])('creates without prompting in %s mode', async (_name, options) => {
mockRows([]);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, false, options)
).resolves.toBe(true);
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
client,
expect.objectContaining({ value: envVar.value, environments: ['production'] }),
'app-1'
);
expect(confirmAsync).not.toHaveBeenCalled();
});
it('ignores account-scoped variables of the same name', async () => {
mockRows([], { includeAccountScoped: true });
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, false, REPLACE)
).resolves.toBe(true);
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.deleteAsync).not.toHaveBeenCalled();
});
});
describe('upsertEnvVarAsync in replaceOtherEnvironments mode', () => {
it('reuses the first row and deletes the rest', async () => {
mockRows([
{ id: 'keeper', environments: ['production'] },
{ id: 'extra', environments: ['preview'] },
]);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production', 'preview'], true, true, REPLACE)
).resolves.toBe(true);
expect(EnvironmentVariableMutation.deleteAsync).toHaveBeenCalledWith(client, 'extra');
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(
client,
expect.objectContaining({
id: 'keeper',
value: envVar.value,
environments: ['production', 'preview'],
})
);
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
});
it('takes over environments it was not asked to write', async () => {
mockRows([{ id: 'wide', environments: ['production', 'preview', 'development'] }]);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, true, REPLACE)
).resolves.toBe(true);
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(
client,
expect.objectContaining({ id: 'wide', environments: ['production'] })
);
});
it('deletes a row whose environment list is null', async () => {
mockRows([
{ id: 'keeper', environments: ['production'] },
{ id: 'no-envs', environments: null },
]);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, true, REPLACE)
).resolves.toBe(true);
expect(EnvironmentVariableMutation.deleteAsync).toHaveBeenCalledWith(client, 'no-envs');
});
it('prompts with the multiple-variables message when extra rows exist', async () => {
mockRows([
{ id: 'keeper', environments: ['production'] },
{ id: 'extra', environments: ['preview'] },
]);
jest.mocked(confirmAsync).mockResolvedValue(true);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], false, false, REPLACE)
).resolves.toBe(true);
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('multiple') })
);
});
it('falls back to other-environments wording when extras have no environments', async () => {
mockRows([
{ id: 'keeper', environments: null },
{ id: 'extra', environments: null },
]);
jest.mocked(confirmAsync).mockResolvedValue(true);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], false, false, REPLACE)
).resolves.toBe(true);
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('other environments') })
);
});
it('prompts with the single-variable message when only one row exists', async () => {
mockRows([{ id: 'only', environments: ['production'] }]);
jest.mocked(confirmAsync).mockResolvedValue(true);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], false, false, REPLACE)
).resolves.toBe(true);
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('Overwrite it?') })
);
});
});
describe('upsertEnvVarAsync in keepOtherEnvironments mode', () => {
it('shrinks an overlapping row and creates a new one', async () => {
mockRows([{ id: 'shared', environments: ['production', 'preview'] }]);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['preview'], true, true, KEEP)
).resolves.toBe(true);
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(client, {
id: 'shared',
environments: ['production'],
});
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
client,
expect.objectContaining({ value: envVar.value, environments: ['preview'] }),
'app-1'
);
});
it('reuses a row the target environments fully cover', async () => {
mockRows([{ id: 'preview-only', environments: ['preview'] }]);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['preview', 'development'], true, true, KEEP)
).resolves.toBe(true);
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(
client,
expect.objectContaining({ id: 'preview-only', environments: ['preview', 'development'] })
);
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
});
it('leaves non-overlapping rows completely alone', async () => {
mockRows([
{ id: 'production-only', environments: ['production'] },
{ id: 'no-envs', environments: null },
]);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['preview'], true, false, KEEP)
).resolves.toBe(true);
expect(EnvironmentVariableMutation.deleteAsync).not.toHaveBeenCalled();
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
expect(confirmAsync).not.toHaveBeenCalled();
});
it('uses the caller move message when a row loses only some environments', async () => {
mockRows([{ id: 'shared', environments: ['production', 'preview'] }]);
jest.mocked(confirmAsync).mockResolvedValue(true);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['preview'], false, false, KEEP)
).resolves.toBe(true);
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({ message: `Move ${envVar.name} for preview?` })
);
});
it('uses the generic message when no row loses environments', async () => {
mockRows([{ id: 'exact', environments: ['preview'] }]);
jest.mocked(confirmAsync).mockResolvedValue(true);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['preview'], false, false, KEEP)
).resolves.toBe(true);
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({
message: `EAS already has ${envVar.name} for preview. Overwrite it?`,
})
);
});
it('falls back to the generic message when no move message is supplied', async () => {
mockRows([{ id: 'shared', environments: ['production', 'preview'] }]);
jest.mocked(confirmAsync).mockResolvedValue(true);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['preview'], false, false, {
mode: 'keepOtherEnvironments',
})
).resolves.toBe(true);
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('Overwrite it?') })
);
});
});
describe('upsertEnvVarAsync overwrite gate', () => {
it.each([
['replaceOtherEnvironments', REPLACE],
['keepOtherEnvironments', KEEP],
])('skips and names --overwrite in non-interactive %s mode', async (_name, options) => {
mockRows([{ id: 'existing', environments: ['production'] }]);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, false, options)
).resolves.toBe(false);
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('pass --overwrite'));
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
expect(EnvironmentVariableMutation.deleteAsync).not.toHaveBeenCalled();
});
it.each([
['replaceOtherEnvironments', REPLACE],
['keepOtherEnvironments', KEEP],
])(
'skips without naming --overwrite when declined interactively in %s mode',
async (_name, options) => {
mockRows([{ id: 'existing', environments: ['production'] }]);
jest.mocked(confirmAsync).mockResolvedValue(false);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], false, false, options)
).resolves.toBe(false);
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('Skipped updating'));
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('--overwrite'));
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
}
);
it('still requires consent when only the visibility differs', async () => {
mockRows([
{
id: 'sensitive',
environments: ['production'],
value: envVar.value,
visibility: EnvironmentVariableVisibility.Sensitive,
},
]);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, false, REPLACE)
).resolves.toBe(false);
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
});
it('names the environments in the prompt even when the row has none', async () => {
mockRows([{ id: 'no-envs', environments: null }]);
jest.mocked(confirmAsync).mockResolvedValue(true);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], false, false, REPLACE)
).resolves.toBe(true);
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({
message: `EAS already has ${envVar.name} for other environments. Overwrite it?`,
})
);
});
it('does not prompt when the value and environments are already correct', async () => {
mockRows([{ id: 'exact', environments: ['production'], value: envVar.value }]);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, false, KEEP)
).resolves.toBe(true);
expect(confirmAsync).not.toHaveBeenCalled();
});
it('still requires consent when the value matches but the environments differ', async () => {
mockRows([{ id: 'exact', environments: ['production'], value: envVar.value }]);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production', 'preview'], true, false, REPLACE)
).resolves.toBe(false);
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('pass --overwrite'));
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
});
it('still requires consent when the existing row has no readable value', async () => {
mockRows([{ id: 'secret', environments: ['production'] }]);
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
{
id: 'secret',
scope: EnvironmentVariableScope.Project,
environments: ['production'],
value: null,
},
] as never);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, false, KEEP)
).resolves.toBe(false);
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
});
it('still requires consent when the environments match but the value differs', async () => {
mockRows([{ id: 'exact', environments: ['production'], value: 'stale' }]);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, false, KEEP)
).resolves.toBe(false);
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
});
it('writes without prompting when --overwrite is passed', async () => {
mockRows([{ id: 'existing', environments: ['production'] }]);
await expect(
upsertEnvVarAsync(client, 'app-1', envVar, ['production'], false, true, REPLACE)
).resolves.toBe(true);
expect(confirmAsync).not.toHaveBeenCalled();
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalled();
});
});
describe('upsertEnvVarsSequentiallyAsync', () => {
it('runs upsert for each var and collects the results in order', async () => {
const upsert = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false);
await expect(
upsertEnvVarsSequentiallyAsync(
[
{ name: 'A', value: '1', visibility: EnvironmentVariableVisibility.Public },
{ name: 'B', value: '2', visibility: EnvironmentVariableVisibility.Public },
],
upsert
)
).resolves.toEqual([true, false]);
});
it('does not start the next upsert before the previous one settles', async () => {
const active: string[] = [];
const maxConcurrent = { value: 0 };
const upsert = jest.fn(async (variable: EnvVar) => {
active.push(variable.name);
maxConcurrent.value = Math.max(maxConcurrent.value, active.length);
await Promise.resolve();
active.pop();
return true;
});
await upsertEnvVarsSequentiallyAsync(
[
{ name: 'A', value: '1', visibility: EnvironmentVariableVisibility.Public },
{ name: 'B', value: '2', visibility: EnvironmentVariableVisibility.Public },
],
upsert
);
expect(maxConcurrent.value).toBe(1);
});
});
@@ -0,0 +1,3 @@
import { DefaultEnvironment } from '../build/utils/environment';
export const DEFAULT_ENVIRONMENTS = Object.values(DefaultEnvironment);
@@ -0,0 +1,89 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { confirmAsync } from '../prompts';
import { getProjectEnvironmentVariableEnvironmentsAsync } from './variables';
export function parseEnvironmentFlag(value: string | undefined): string[] | null {
if (value === undefined) {
return null;
}
if (!value.trim()) {
throw new Error(
'Pass at least one EAS environment to --environment (e.g. --environment preview).'
);
}
const environments = [
...new Set(
value
.split(',')
.map(part => part.trim().toLowerCase())
.filter(Boolean)
),
];
if (environments.length === 0) {
throw new Error(
'Pass at least one EAS environment to --environment (e.g. --environment preview).'
);
}
for (const environment of environments) {
if (environment.length < 3 || environment.length > 100 || !/^[a-z0-9_-]+$/.test(environment)) {
throw new Error(
`Invalid EAS environment "${environment}". Use 3–100 lowercase letters, numbers, dashes, or underscores.`
);
}
}
return environments;
}
/**
* Resolve requested EAS environment names against what the project already uses.
* Shared across integrations; pass integration-specific defaults and messages at each callsite.
*/
export async function resolveTargetEnvironmentsAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
requested: string[],
nonInteractive: boolean,
{
defaultEnvironments,
cancelMessage,
}: {
defaultEnvironments: string[];
cancelMessage: (knownEnvironments: string) => string;
}
): Promise<string[]> {
let known = await getProjectEnvironmentVariableEnvironmentsAsync(graphqlClient, projectId);
if (known.length === 0) {
known = [...defaultEnvironments];
}
const knownSet = new Set(known);
const unknown = requested.filter(environment => !knownSet.has(environment));
if (unknown.length === 0) {
return requested;
}
const defaultEnvironmentSet = new Set<string>(defaultEnvironments);
const hasCustom = unknown.some(environment => !defaultEnvironmentSet.has(environment));
if (nonInteractive) {
const hint = hasCustom
? 'Custom environments require an Enterprise plan; pass only default environments, or upgrade.'
: 'Re-run interactively to create them, or pass only existing environments.';
throw new Error(
`EAS environment(s) not found on this project: ${unknown.join(', ')}. Known: ${known.join(', ')}. ${hint}`
);
}
const listed = unknown.map(environment => `"${environment}"`).join(', ');
const isPlural = unknown.length > 1;
const noun = isPlural ? 'environments' : 'environment';
const verb = isPlural ? 'are' : 'is';
const customHint = hasCustom
? ' Custom environments require an Enterprise plan; values will be written if your account supports them.'
: '';
const create = await confirmAsync({
message: `EAS ${noun} ${listed} ${verb} not used on this project yet.${customHint} Continue provisioning?`,
});
if (!create) {
throw new Error(cancelMessage(known.join(', ')));
}
// Custom environments are created lazily when env vars are written (createForAppAsync).
return requested;
}
@@ -0,0 +1,186 @@
import chalk from 'chalk';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../graphql/generated';
import { EnvironmentVariableMutation } from '../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../graphql/queries/EnvironmentVariablesQuery';
import Log from '../log';
import { confirmAsync } from '../prompts';
export type EnvVar = { name: string; value: string; visibility: EnvironmentVariableVisibility };
type ProjectScopedEnvVar = Awaited<
ReturnType<typeof EnvironmentVariablesQuery.byAppIdAsync>
>[number];
// What happens to environments that already hold a value for this name but are not being written:
// drop them so one value covers everything, or leave them on their existing value.
export type EnvVarWriteMode = 'replaceOtherEnvironments' | 'keepOtherEnvironments';
function coversExactly(environments: string[], target: Set<string>): boolean {
return environments.length === target.size && environments.every(e => target.has(e));
}
export async function getProjectEnvironmentVariableEnvironmentsAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string
): Promise<string[]> {
try {
const environments = await EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync(
graphqlClient,
projectId
);
return environments;
} catch (error) {
throw new Error('Failed to fetch available environments', { cause: error });
}
}
export async function loadProjectScopedEnvVarsAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
name: string
): Promise<ProjectScopedEnvVar[]> {
return (
await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
appId: projectId,
filterNames: [name],
})
).filter(variable => variable.scope === EnvironmentVariableScope.Project);
}
export async function upsertEnvVarAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
envVar: EnvVar,
environments: string[],
nonInteractive: boolean,
overwrite: boolean,
{
mode,
moveConfirmMessage,
}: {
mode: EnvVarWriteMode;
// Only reachable in keepOtherEnvironments mode, where a row keeps some environments and loses others.
moveConfirmMessage?: (variableName: string, overlappingEnvironments: string) => string;
}
): Promise<boolean> {
const target = new Set(environments);
const replacingAll = mode === 'replaceOtherEnvironments';
const existingRows = await loadProjectScopedEnvVarsAsync(graphqlClient, projectId, envVar.name);
// 1. Work out which rows this write touches. Replacing takes over every row; keeping only touches
// rows that share an environment with the target.
type CoveredRow = {
id: string;
value: string;
environments: string[];
visibility: EnvironmentVariableVisibility | null;
};
const coveredRows: CoveredRow[] = [];
const rowsToShrink: { id: string; keptEnvironments: string[] }[] = [];
const takenOverEnvironments = new Set<string>();
for (const row of existingRows) {
const rowEnvironments = row.environments ?? [];
const handedOver = replacingAll
? rowEnvironments
: rowEnvironments.filter(environment => target.has(environment));
const kept = replacingAll
? []
: rowEnvironments.filter(environment => !target.has(environment));
if (!replacingAll && handedOver.length === 0) {
continue;
}
handedOver.forEach(environment => takenOverEnvironments.add(environment));
if (kept.length === 0) {
coveredRows.push({
id: row.id,
value: row.value ?? '',
environments: rowEnvironments,
visibility: row.visibility ?? null,
});
} else {
rowsToShrink.push({ id: row.id, keptEnvironments: kept });
}
}
// Reuse the first fully covered row so the variable keeps its id; the rest are redundant. Typed
// explicitly because tsconfig has no noUncheckedIndexedAccess, so index 0 looks always-present.
const rowToReuse: CoveredRow | undefined = coveredRows[0];
const redundantRows = coveredRows.slice(1);
const takenOver = [...takenOverEnvironments].join(', ') || 'other environments';
// 2. Get consent. Overwriting is always explicit — --overwrite, or a yes — and only a write that
// changes nothing at all is exempt. Visibility counts: writing the same value as Public over a
// Sensitive row would expose it, so that is a change like any other.
const reusedRowIsIdentical =
rowToReuse?.value === envVar.value &&
rowToReuse.visibility === envVar.visibility &&
coversExactly(rowToReuse.environments, target);
const touchesExistingRows = coveredRows.length > 0 || rowsToShrink.length > 0;
const changesNothing =
reusedRowIsIdentical && redundantRows.length === 0 && rowsToShrink.length === 0;
if (touchesExistingRows && !overwrite && !changesNothing) {
let message: string;
if (rowsToShrink.length > 0 && moveConfirmMessage) {
message = moveConfirmMessage(envVar.name, takenOver);
} else if (redundantRows.length > 0) {
message = `EAS has multiple ${envVar.name} variables for this project (including ${takenOver}). Replace them with one value for ${environments.join(', ')}?`;
} else {
message = `EAS already has ${envVar.name} for ${takenOver}. Overwrite it?`;
}
if (nonInteractive || !(await confirmAsync({ message }))) {
Log.warn(
`Skipped updating EAS environment variable ${chalk.bold(envVar.name)}${
nonInteractive ? ' (pass --overwrite to replace it)' : ''
}.`
);
return false;
}
}
// 3. Apply: hand environments over, drop redundant rows, then land the value.
for (const { id, keptEnvironments } of rowsToShrink) {
await EnvironmentVariableMutation.updateAsync(graphqlClient, {
id,
environments: keptEnvironments,
});
}
for (const { id } of redundantRows) {
await EnvironmentVariableMutation.deleteAsync(graphqlClient, id);
}
const fields = {
name: envVar.name,
value: envVar.value,
environments,
visibility: envVar.visibility,
type: EnvironmentSecretType.String,
};
if (rowToReuse) {
await EnvironmentVariableMutation.updateAsync(graphqlClient, { id: rowToReuse.id, ...fields });
} else {
await EnvironmentVariableMutation.createForAppAsync(graphqlClient, fields, projectId);
}
Log.withTick(
`${rowToReuse ? 'Updated' : 'Created'} EAS environment variable ${chalk.bold(
envVar.name
)} for ${environments.join(', ')}`
);
return true;
}
export async function upsertEnvVarsSequentiallyAsync(
envVars: EnvVar[],
upsert: (envVar: EnvVar) => Promise<boolean>
): Promise<boolean[]> {
const written: boolean[] = [];
for (const envVar of envVars) {
written.push(await upsert(envVar));
}
return written;
}
@@ -1,8 +1,10 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import Log from '../../log';
import {
EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE,
isPermanentGraphqlError,
withErrorHandlingAsync,
withUpgradeRequiredErrorHandlingAsync,
} from '../client';
@@ -41,6 +43,46 @@ describe(withErrorHandlingAsync, () => {
});
});
describe(isPermanentGraphqlError, () => {
it('is true for user errors', () => {
expect(isPermanentGraphqlError(makeError('Not authorized', { errorType: 'USER' }))).toBe(true);
});
it('is false for network errors', () => {
expect(isPermanentGraphqlError(new CombinedError({ networkError: new Error('offline') }))).toBe(
false
);
});
it('is false for server faults', () => {
expect(isPermanentGraphqlError(makeError('Unexpected', { errorType: 'SYSTEM' }))).toBe(false);
});
it('is false for user errors the server marks transient', () => {
expect(
isPermanentGraphqlError(makeError('Locked', { errorType: 'USER', isTransient: true }))
).toBe(false);
});
it('is false when any error in the response is not a user error', () => {
const mixed = new CombinedError({
graphQLErrors: [
new GraphQLError('Not authorized', { extensions: { errorType: 'USER' } }),
new GraphQLError('Unexpected', { extensions: { errorType: 'SYSTEM' } }),
],
});
expect(isPermanentGraphqlError(mixed)).toBe(false);
});
it('is false for unclassified GraphQL errors', () => {
expect(isPermanentGraphqlError(makeError('Something'))).toBe(false);
});
it('is false for errors that are not GraphQL errors', () => {
expect(isPermanentGraphqlError(new Error('boom'))).toBe(false);
});
});
describe(withUpgradeRequiredErrorHandlingAsync, () => {
it('returns data when the promise resolves successfully', async () => {
const result = await withUpgradeRequiredErrorHandlingAsync(
+19
View File
@@ -60,6 +60,25 @@ export async function withUpgradeRequiredErrorHandlingAsync<T>(
}
}
/**
* Whether a failed request will keep failing the same way. True only when the server attributes
* every error to the caller (`errorType: USER`): a rejected input, a missing permission, a
* resource this account can't see. Server faults, network errors, and anything we can't classify
* stay retriable, since giving up on those turns a blip into a failed command. Polling loops use
* this to stop early instead of waiting out their deadline.
*/
export function isPermanentGraphqlError(error: unknown): boolean {
if (!(error instanceof GraphqlError) || error.networkError) {
return false;
}
return (
error.graphQLErrors.length > 0 &&
error.graphQLErrors.every(
e => e?.extensions?.errorType === 'USER' && !e?.extensions?.isTransient
)
);
}
function isUpgradeRequiredError(error: unknown): boolean {
if (!(error instanceof GraphqlError)) {
return false;
+81
View File
@@ -14340,6 +14340,69 @@ export type RetrySubmissionMutationVariables = Exact<{
export type RetrySubmissionMutation = { __typename?: 'RootMutation', submission: { __typename?: 'SubmissionMutation', retrySubmission: { __typename?: 'CreateSubmissionResult', submission: { __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logFiles: Array<string>, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: string, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null } } } };
export type BeginSupabaseOAuthMutationVariables = Exact<{
input: BeginSupabaseOAuthInput;
}>;
export type BeginSupabaseOAuthMutation = { __typename?: 'RootMutation', supabaseConnection: { __typename?: 'SupabaseConnectionMutation', beginSupabaseOAuth: { __typename?: 'SupabaseOAuthStart', url: string } } };
export type SetSupabaseConnectionOrganizationMutationVariables = Exact<{
input: SetSupabaseConnectionOrganizationInput;
}>;
export type SetSupabaseConnectionOrganizationMutation = { __typename?: 'RootMutation', supabaseConnection: { __typename?: 'SupabaseConnectionMutation', setSupabaseConnectionOrganization: { __typename?: 'SupabaseConnection', id: string, supabaseOrganizationSlug: string, supabaseOrganizationName: string, createdAt: any, updatedAt: any } } };
export type DisconnectSupabaseMutationVariables = Exact<{
id: Scalars['ID']['input'];
}>;
export type DisconnectSupabaseMutation = { __typename?: 'RootMutation', supabaseConnection: { __typename?: 'SupabaseConnectionMutation', disconnectSupabase: string } };
export type ProvisionSupabaseProjectMutationVariables = Exact<{
input: ProvisionSupabaseProjectInput;
}>;
export type ProvisionSupabaseProjectMutation = { __typename?: 'RootMutation', supabaseProject: { __typename?: 'SupabaseProjectMutation', provisionSupabaseProject: { __typename?: 'BackgroundJobReceipt', id: string, state: BackgroundJobState, tries: number, willRetry: boolean, resultId?: string | null, resultType: BackgroundJobResultType, resultData?: any | null, errorCode?: string | null, errorMessage?: string | null, createdAt: any, updatedAt: any } } };
export type ProvisionAdditionalSupabaseProjectMutationVariables = Exact<{
input: ProvisionAdditionalSupabaseProjectInput;
}>;
export type ProvisionAdditionalSupabaseProjectMutation = { __typename?: 'RootMutation', supabaseProject: { __typename?: 'SupabaseProjectMutation', provisionAdditionalSupabaseProject: { __typename?: 'BackgroundJobReceipt', id: string, state: BackgroundJobState, tries: number, willRetry: boolean, resultId?: string | null, resultType: BackgroundJobResultType, resultData?: any | null, errorCode?: string | null, errorMessage?: string | null, createdAt: any, updatedAt: any } } };
export type LinkSupabaseProjectMutationVariables = Exact<{
input: LinkSupabaseProjectInput;
}>;
export type LinkSupabaseProjectMutation = { __typename?: 'RootMutation', supabaseProject: { __typename?: 'SupabaseProjectMutation', linkSupabaseProject: { __typename?: 'SupabaseProject', id: string, supabaseProjectRef: string, supabaseProjectName: string, supabaseProjectUrl: string, supabaseRegion: string, createdAt: any, updatedAt: any } } };
export type DeleteSupabaseProjectMutationVariables = Exact<{
id: Scalars['ID']['input'];
}>;
export type DeleteSupabaseProjectMutation = { __typename?: 'RootMutation', supabaseProject: { __typename?: 'SupabaseProjectMutation', deleteSupabaseProject: string } };
export type ListSupabaseOrganizationsMutationVariables = Exact<{
accountId: Scalars['ID']['input'];
}>;
export type ListSupabaseOrganizationsMutation = { __typename?: 'RootMutation', supabaseConnection: { __typename?: 'SupabaseConnectionMutation', listSupabaseOrganizations: Array<{ __typename?: 'SupabaseOrganization', id: string, slug: string, name: string }> } };
export type FetchSupabasePublishableKeyMutationVariables = Exact<{
appId: Scalars['ID']['input'];
}>;
export type FetchSupabasePublishableKeyMutation = { __typename?: 'RootMutation', supabaseProject: { __typename?: 'SupabaseProjectMutation', fetchSupabasePublishableKey?: string | null } };
export type CreateUploadSessionMutationVariables = Exact<{
type: UploadSessionType;
filename?: InputMaybe<Scalars['String']['input']>;
@@ -15069,6 +15132,20 @@ export type GetAllSubmissionsForAppQuery = { __typename?: 'RootQuery', app: { __
| { __typename: 'Snack', id: string, name: string, slug: string }
, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: string, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }> } } };
export type SupabaseConnectionByAccountIdQueryVariables = Exact<{
accountId: Scalars['String']['input'];
}>;
export type SupabaseConnectionByAccountIdQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, supabaseConnection?: { __typename?: 'SupabaseConnection', id: string, supabaseOrganizationSlug: string, supabaseOrganizationName: string, createdAt: any, updatedAt: any } | null } } };
export type SupabaseProjectByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
export type SupabaseProjectByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, supabaseProject?: { __typename?: 'SupabaseProject', id: string, supabaseProjectRef: string, supabaseProjectName: string, supabaseProjectUrl: string, supabaseRegion: string, createdAt: any, updatedAt: any } | null } } };
export type ViewUpdateGroupInsightsQueryVariables = Exact<{
groupId: Scalars['ID']['input'];
timespan: InsightsTimespan;
@@ -15415,6 +15492,10 @@ export type SubmissionWithSubmittedBuildFragment = { __typename?: 'Submission',
| { __typename: 'Snack', id: string, name: string, slug: string }
, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: string, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null };
export type SupabaseConnectionFragment = { __typename?: 'SupabaseConnection', id: string, supabaseOrganizationSlug: string, supabaseOrganizationName: string, createdAt: any, updatedAt: any };
export type SupabaseProjectFragment = { __typename?: 'SupabaseProject', id: string, supabaseProjectRef: string, supabaseProjectName: string, supabaseProjectUrl: string, supabaseRegion: string, createdAt: any, updatedAt: any };
export type UpdateFragment = { __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, isGitWorkingTreeDirty: boolean, environment?: any | null, rolloutPercentage?: number | null, manifestHostOverride?: string | null, assetHostOverride?: string | null, actor?:
| { __typename: 'PartnerActor', username: string, id: string }
| { __typename: 'Robot', firstName?: string | null, id: string }
@@ -0,0 +1,268 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import { BackgroundJobReceiptDataFragment } from '../generated';
import { BackgroundJobReceiptNode } from '../types/BackgroundJobReceipt';
import {
BeginSupabaseOAuthInput,
LinkSupabaseProjectInput,
ProvisionAdditionalSupabaseProjectInput,
ProvisionSupabaseProjectInput,
SetSupabaseConnectionOrganizationInput,
SupabaseConnectionData,
SupabaseConnectionFragmentNode,
SupabaseOAuthStartData,
SupabaseOrganizationData,
SupabaseProjectData,
SupabaseProjectFragmentNode,
} from '../types/SupabaseConnection';
export const SupabaseMutation = {
async beginSupabaseOAuthAsync(
graphqlClient: ExpoGraphqlClient,
input: BeginSupabaseOAuthInput
): Promise<SupabaseOAuthStartData> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ supabaseConnection: { beginSupabaseOAuth: SupabaseOAuthStartData } },
{ input: BeginSupabaseOAuthInput }
>(
gql`
mutation BeginSupabaseOAuth($input: BeginSupabaseOAuthInput!) {
supabaseConnection {
beginSupabaseOAuth(input: $input) {
url
}
}
}
`,
{ input }
)
.toPromise()
);
return data.supabaseConnection.beginSupabaseOAuth;
},
async setSupabaseConnectionOrganizationAsync(
graphqlClient: ExpoGraphqlClient,
input: SetSupabaseConnectionOrganizationInput
): Promise<SupabaseConnectionData> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{
supabaseConnection: { setSupabaseConnectionOrganization: SupabaseConnectionData };
},
{ input: SetSupabaseConnectionOrganizationInput }
>(
gql`
mutation SetSupabaseConnectionOrganization(
$input: SetSupabaseConnectionOrganizationInput!
) {
supabaseConnection {
setSupabaseConnectionOrganization(input: $input) {
id
...SupabaseConnectionFragment
}
}
}
${print(SupabaseConnectionFragmentNode)}
`,
{ input },
{ additionalTypenames: ['SupabaseConnection'] }
)
.toPromise()
);
return data.supabaseConnection.setSupabaseConnectionOrganization;
},
async disconnectSupabaseAsync(graphqlClient: ExpoGraphqlClient, id: string): Promise<string> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<{ supabaseConnection: { disconnectSupabase: string } }, { id: string }>(
gql`
mutation DisconnectSupabase($id: ID!) {
supabaseConnection {
disconnectSupabase(id: $id)
}
}
`,
{ id },
{ additionalTypenames: ['Account', 'SupabaseConnection', 'SupabaseProject'] }
)
.toPromise()
);
return data.supabaseConnection.disconnectSupabase;
},
async provisionSupabaseProjectAsync(
graphqlClient: ExpoGraphqlClient,
input: ProvisionSupabaseProjectInput
): Promise<BackgroundJobReceiptDataFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ supabaseProject: { provisionSupabaseProject: BackgroundJobReceiptDataFragment } },
{ input: ProvisionSupabaseProjectInput }
>(
gql`
mutation ProvisionSupabaseProject($input: ProvisionSupabaseProjectInput!) {
supabaseProject {
provisionSupabaseProject(input: $input) {
id
...BackgroundJobReceiptData
}
}
}
${print(BackgroundJobReceiptNode)}
`,
{ input },
{ additionalTypenames: ['App', 'SupabaseProject', 'BackgroundJobReceipt'] }
)
.toPromise()
);
return data.supabaseProject.provisionSupabaseProject;
},
async provisionAdditionalSupabaseProjectAsync(
graphqlClient: ExpoGraphqlClient,
input: ProvisionAdditionalSupabaseProjectInput
): Promise<BackgroundJobReceiptDataFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{
supabaseProject: {
provisionAdditionalSupabaseProject: BackgroundJobReceiptDataFragment;
};
},
{ input: ProvisionAdditionalSupabaseProjectInput }
>(
gql`
mutation ProvisionAdditionalSupabaseProject(
$input: ProvisionAdditionalSupabaseProjectInput!
) {
supabaseProject {
provisionAdditionalSupabaseProject(input: $input) {
id
...BackgroundJobReceiptData
}
}
}
${print(BackgroundJobReceiptNode)}
`,
{ input },
{ additionalTypenames: ['App', 'SupabaseProject', 'BackgroundJobReceipt'] }
)
.toPromise()
);
return data.supabaseProject.provisionAdditionalSupabaseProject;
},
async linkSupabaseProjectAsync(
graphqlClient: ExpoGraphqlClient,
input: LinkSupabaseProjectInput
): Promise<SupabaseProjectData> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ supabaseProject: { linkSupabaseProject: SupabaseProjectData } },
{ input: LinkSupabaseProjectInput }
>(
gql`
mutation LinkSupabaseProject($input: LinkSupabaseProjectInput!) {
supabaseProject {
linkSupabaseProject(input: $input) {
id
...SupabaseProjectFragment
}
}
}
${print(SupabaseProjectFragmentNode)}
`,
{ input },
{ additionalTypenames: ['App', 'SupabaseProject'] }
)
.toPromise()
);
return data.supabaseProject.linkSupabaseProject;
},
async deleteSupabaseProjectAsync(graphqlClient: ExpoGraphqlClient, id: string): Promise<string> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<{ supabaseProject: { deleteSupabaseProject: string } }, { id: string }>(
gql`
mutation DeleteSupabaseProject($id: ID!) {
supabaseProject {
deleteSupabaseProject(id: $id)
}
}
`,
{ id },
{ additionalTypenames: ['App', 'SupabaseProject'] }
)
.toPromise()
);
return data.supabaseProject.deleteSupabaseProject;
},
async listSupabaseOrganizationsAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string
): Promise<SupabaseOrganizationData[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{
supabaseConnection: {
listSupabaseOrganizations: SupabaseOrganizationData[];
};
},
{ accountId: string }
>(
gql`
mutation ListSupabaseOrganizations($accountId: ID!) {
supabaseConnection {
listSupabaseOrganizations(accountId: $accountId) {
id
slug
name
}
}
}
`,
{ accountId }
)
.toPromise()
);
return data.supabaseConnection.listSupabaseOrganizations;
},
async fetchSupabasePublishableKeyAsync(
graphqlClient: ExpoGraphqlClient,
appId: string
): Promise<string | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ supabaseProject: { fetchSupabasePublishableKey: string | null } },
{ appId: string }
>(
gql`
mutation FetchSupabasePublishableKey($appId: ID!) {
supabaseProject {
fetchSupabasePublishableKey(appId: $appId)
}
}
`,
{ appId }
)
.toPromise()
);
return data.supabaseProject.fetchSupabasePublishableKey;
},
};
@@ -0,0 +1,141 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { SupabaseMutation } from '../SupabaseMutation';
function makeMutationClient(data: unknown): ExpoGraphqlClient {
return {
mutation: jest.fn().mockReturnValue({
toPromise: jest.fn().mockResolvedValue({ data }),
}),
} as unknown as ExpoGraphqlClient;
}
describe('SupabaseMutation', () => {
it('beginSupabaseOAuthAsync returns oauth start data', async () => {
const oauth = { state: 'state-1', url: 'https://supabase.example/oauth' };
const client = makeMutationClient({
supabaseConnection: { beginSupabaseOAuth: oauth },
});
await expect(
SupabaseMutation.beginSupabaseOAuthAsync(client, { accountId: 'acct-1' })
).resolves.toEqual(oauth);
});
it('setSupabaseConnectionOrganizationAsync returns connection', async () => {
const connection = {
id: 'conn-1',
supabaseOrganizationSlug: 'org',
supabaseOrganizationName: 'Org',
};
const client = makeMutationClient({
supabaseConnection: { setSupabaseConnectionOrganization: connection },
});
await expect(
SupabaseMutation.setSupabaseConnectionOrganizationAsync(client, {
supabaseConnectionId: 'conn-1',
organizationSlug: 'org',
})
).resolves.toEqual(connection);
});
it('disconnectSupabaseAsync returns id', async () => {
const client = makeMutationClient({
supabaseConnection: { disconnectSupabase: 'conn-1' },
});
await expect(SupabaseMutation.disconnectSupabaseAsync(client, 'conn-1')).resolves.toBe(
'conn-1'
);
});
it('provisionSupabaseProjectAsync returns receipt', async () => {
const receipt = { id: 'receipt-1' };
const client = makeMutationClient({
supabaseProject: { provisionSupabaseProject: receipt },
});
await expect(
SupabaseMutation.provisionSupabaseProjectAsync(client, {
appId: 'app-1',
region: 'americas',
})
).resolves.toEqual(receipt);
});
it('provisionAdditionalSupabaseProjectAsync returns receipt', async () => {
const receipt = { id: 'receipt-2' };
const client = makeMutationClient({
supabaseProject: { provisionAdditionalSupabaseProject: receipt },
});
await expect(
SupabaseMutation.provisionAdditionalSupabaseProjectAsync(client, {
appId: 'app-1',
region: 'americas',
projectNameSuffix: 'preview',
})
).resolves.toEqual(receipt);
expect(client.mutation).toHaveBeenCalledWith(
expect.anything(),
{ input: { appId: 'app-1', region: 'americas', projectNameSuffix: 'preview' } },
expect.anything()
);
});
it('linkSupabaseProjectAsync returns project', async () => {
const project = { id: 'project-1', supabaseProjectRef: 'ref' };
const client = makeMutationClient({
supabaseProject: { linkSupabaseProject: project },
});
await expect(
SupabaseMutation.linkSupabaseProjectAsync(client, {
appId: 'app-1',
supabaseProjectRef: 'ref',
})
).resolves.toEqual(project);
expect(client.mutation).toHaveBeenCalledWith(
expect.anything(),
{ input: { appId: 'app-1', supabaseProjectRef: 'ref' } },
expect.anything()
);
});
it('deleteSupabaseProjectAsync returns id', async () => {
const client = makeMutationClient({
supabaseProject: { deleteSupabaseProject: 'project-1' },
});
await expect(SupabaseMutation.deleteSupabaseProjectAsync(client, 'project-1')).resolves.toBe(
'project-1'
);
});
it('listSupabaseOrganizationsAsync returns organizations', async () => {
const organizations = [{ id: '1', slug: 'org', name: 'Org' }];
const client = makeMutationClient({
supabaseConnection: { listSupabaseOrganizations: organizations },
});
await expect(
SupabaseMutation.listSupabaseOrganizationsAsync(client, 'acct-1')
).resolves.toEqual(organizations);
});
it('fetchSupabasePublishableKeyAsync returns key or null', async () => {
const withKey = makeMutationClient({
supabaseProject: { fetchSupabasePublishableKey: 'pk_test' },
});
await expect(SupabaseMutation.fetchSupabasePublishableKeyAsync(withKey, 'app-1')).resolves.toBe(
'pk_test'
);
const withoutKey = makeMutationClient({
supabaseProject: { fetchSupabasePublishableKey: null },
});
await expect(
SupabaseMutation.fetchSupabasePublishableKeyAsync(withoutKey, 'app-1')
).resolves.toBeNull();
});
});
@@ -0,0 +1,97 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
SupabaseConnectionData,
SupabaseConnectionFragmentNode,
SupabaseProjectData,
SupabaseProjectFragmentNode,
} from '../types/SupabaseConnection';
type SupabaseConnectionByAccountIdQuery = {
account: {
byId: {
id: string;
supabaseConnection?: SupabaseConnectionData | null;
};
};
};
type SupabaseProjectByAppIdQuery = {
app: {
byId: {
id: string;
supabaseProject?: SupabaseProjectData | null;
};
};
};
export const SupabaseQuery = {
async getSupabaseConnectionByAccountIdAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string,
{ useCache = true }: { useCache?: boolean } = {}
): Promise<SupabaseConnectionData | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<SupabaseConnectionByAccountIdQuery, { accountId: string }>(
gql`
query SupabaseConnectionByAccountId($accountId: String!) {
account {
byId(accountId: $accountId) {
id
supabaseConnection {
id
...SupabaseConnectionFragment
}
}
}
}
${print(SupabaseConnectionFragmentNode)}
`,
{ accountId },
{
additionalTypenames: ['SupabaseConnection'],
requestPolicy: useCache ? 'cache-first' : 'network-only',
}
)
.toPromise()
);
return data.account.byId.supabaseConnection ?? null;
},
async getSupabaseProjectByAppIdAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
{ useCache = true }: { useCache?: boolean } = {}
): Promise<SupabaseProjectData | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<SupabaseProjectByAppIdQuery, { appId: string }>(
gql`
query SupabaseProjectByAppId($appId: String!) {
app {
byId(appId: $appId) {
id
supabaseProject {
id
...SupabaseProjectFragment
}
}
}
}
${print(SupabaseProjectFragmentNode)}
`,
{ appId },
{
additionalTypenames: ['App', 'SupabaseProject'],
requestPolicy: useCache ? 'cache-first' : 'network-only',
}
)
.toPromise()
);
return data.app.byId.supabaseProject ?? null;
},
};
@@ -0,0 +1,101 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { SupabaseQuery } from '../SupabaseQuery';
function makeQueryClient(data: unknown): ExpoGraphqlClient {
return {
query: jest.fn().mockReturnValue({
toPromise: jest.fn().mockResolvedValue({ data }),
}),
} as unknown as ExpoGraphqlClient;
}
describe('SupabaseQuery', () => {
const connection = {
id: 'conn-1',
supabaseOrganizationSlug: 'org',
supabaseOrganizationName: 'Org',
};
const project = {
id: 'project-1',
supabaseProjectRef: 'abcdefghijklmnop',
supabaseProjectName: 'Demo',
supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co',
supabaseRegion: 'us-east-1',
};
it('getSupabaseConnectionByAccountIdAsync returns connection', async () => {
const client = makeQueryClient({
account: { byId: { id: 'acct-1', supabaseConnection: connection } },
});
await expect(
SupabaseQuery.getSupabaseConnectionByAccountIdAsync(client, 'acct-1')
).resolves.toEqual(connection);
expect(client.query).toHaveBeenCalledWith(
expect.anything(),
{ accountId: 'acct-1' },
expect.objectContaining({ requestPolicy: 'cache-first' })
);
});
it('getSupabaseConnectionByAccountIdAsync returns null when missing', async () => {
const client = makeQueryClient({
account: { byId: { id: 'acct-1' } },
});
await expect(
SupabaseQuery.getSupabaseConnectionByAccountIdAsync(client, 'acct-1')
).resolves.toBeNull();
});
it('getSupabaseConnectionByAccountIdAsync uses network-only when useCache is false', async () => {
const client = makeQueryClient({
account: { byId: { id: 'acct-1', supabaseConnection: connection } },
});
await SupabaseQuery.getSupabaseConnectionByAccountIdAsync(client, 'acct-1', {
useCache: false,
});
expect(client.query).toHaveBeenCalledWith(
expect.anything(),
{ accountId: 'acct-1' },
expect.objectContaining({ requestPolicy: 'network-only' })
);
});
it('getSupabaseProjectByAppIdAsync returns project', async () => {
const client = makeQueryClient({
app: { byId: { id: 'app-1', supabaseProject: project } },
});
await expect(SupabaseQuery.getSupabaseProjectByAppIdAsync(client, 'app-1')).resolves.toEqual(
project
);
expect(client.query).toHaveBeenCalledWith(
expect.anything(),
{ appId: 'app-1' },
expect.objectContaining({ requestPolicy: 'cache-first' })
);
});
it('getSupabaseProjectByAppIdAsync returns null when missing', async () => {
const client = makeQueryClient({
app: { byId: { id: 'app-1', supabaseProject: null } },
});
await expect(SupabaseQuery.getSupabaseProjectByAppIdAsync(client, 'app-1')).resolves.toBeNull();
});
it('getSupabaseProjectByAppIdAsync uses network-only when useCache is false', async () => {
const client = makeQueryClient({
app: { byId: { id: 'app-1', supabaseProject: project } },
});
await SupabaseQuery.getSupabaseProjectByAppIdAsync(client, 'app-1', { useCache: false });
expect(client.query).toHaveBeenCalledWith(
expect.anything(),
{ appId: 'app-1' },
expect.objectContaining({ requestPolicy: 'network-only' })
);
});
});
@@ -0,0 +1,58 @@
import gql from 'graphql-tag';
import {
SupabaseConnection,
SupabaseOAuthStart,
SupabaseOrganization,
SupabaseProject,
} from '../generated';
export {
BeginSupabaseOAuthInput,
LinkSupabaseProjectInput,
ProvisionAdditionalSupabaseProjectInput,
ProvisionSupabaseProjectInput,
SetSupabaseConnectionOrganizationInput,
} from '../generated';
export type SupabaseOrganizationData = Pick<SupabaseOrganization, 'id' | 'slug' | 'name'>;
export type SupabaseConnectionData = Pick<
SupabaseConnection,
'id' | 'supabaseOrganizationSlug' | 'supabaseOrganizationName' | 'createdAt' | 'updatedAt'
>;
export type SupabaseProjectData = Pick<
SupabaseProject,
| 'id'
| 'supabaseProjectRef'
| 'supabaseProjectName'
| 'supabaseProjectUrl'
| 'supabaseRegion'
| 'createdAt'
| 'updatedAt'
>;
export type SupabaseOAuthStartData = Pick<SupabaseOAuthStart, 'url'>;
export const SupabaseConnectionFragmentNode = gql`
fragment SupabaseConnectionFragment on SupabaseConnection {
id
supabaseOrganizationSlug
supabaseOrganizationName
createdAt
updatedAt
}
`;
export const SupabaseProjectFragmentNode = gql`
fragment SupabaseProjectFragment on SupabaseProject {
id
supabaseProjectRef
supabaseProjectName
supabaseProjectUrl
supabaseRegion
createdAt
updatedAt
}
`;
@@ -0,0 +1,198 @@
import dotenv from 'dotenv';
import * as fs from 'fs-extra';
import { EnvironmentVariableVisibility } from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { mergeEnvContent, writeEnvLocalAsync } from '../envFile';
jest.mock('fs-extra');
jest.mock('../../../prompts');
jest.mock('../../../log');
const LABEL = 'Example';
const VAR_NAME = 'EXPO_PUBLIC_EXAMPLE_URL';
const envVars = [
{
name: VAR_NAME,
value: 'https://new.example.test',
visibility: EnvironmentVariableVisibility.Public,
},
];
describe('mergeEnvContent', () => {
it('updates existing keys and appends new ones', () => {
expect(mergeEnvContent('FOO=1\n', { FOO: '2', BAR: '3' })).toBe('FOO=2\nBAR=3\n');
expect(mergeEnvContent('FOO=1', { BAR: '3' })).toBe('FOO=1\nBAR=3\n');
});
it('replaces rather than duplicates keys written with spaces around the equals sign', () => {
expect(mergeEnvContent('FOO = 1\n', { FOO: '2' })).toBe('FOO=2\n');
});
it('replaces rather than duplicates keys written with an export prefix, keeping the prefix', () => {
expect(mergeEnvContent('export FOO=1\n', { FOO: '2' })).toBe('export FOO=2\n');
});
it('replaces indented keys, keeping the existing indentation', () => {
expect(mergeEnvContent(' FOO=1\n', { FOO: '2' })).toBe(' FOO=2\n');
});
it('writes values containing regex replacement patterns verbatim', () => {
expect(mergeEnvContent('FOO=1\n', { FOO: 'a$&b$1c' })).toBe('FOO=a$&b$1c\n');
});
it('does not treat a commented-out key as present', () => {
expect(mergeEnvContent('#FOO=1\n', { FOO: '2' })).toBe('#FOO=1\nFOO=2\n');
});
it('replaces keys written in the colon form dotenv accepts', () => {
expect(mergeEnvContent('FOO: 1\n', { FOO: '2' })).toBe('FOO=2\n');
});
it('collapses duplicate definitions so a later stale one cannot win', () => {
expect(mergeEnvContent('export FOO=1\nFOO=stale\n', { FOO: '2' })).toBe('export FOO=2\n');
expect(mergeEnvContent('FOO=a\nFOO=b\nFOO=c\n', { FOO: '2' })).toBe('FOO=2\n');
expect(mergeEnvContent('A=1\nFOO=old\nB=2\nFOO=stale\nC=3\n', { FOO: '2' })).toBe(
'A=1\nFOO=2\nB=2\nC=3\n'
);
});
it('keeps comments that follow, or trail, a replaced definition', () => {
expect(mergeEnvContent('FOO=1\n# keep\n', { FOO: '2' })).toBe('FOO=2\n# keep\n');
expect(mergeEnvContent('FOO=1\n\n\n# keep\nBAR=2\n', { FOO: '2' })).toBe(
'FOO=2\n\n\n# keep\nBAR=2\n'
);
expect(mergeEnvContent('FOO=1 # keep\n', { FOO: '2' })).toBe('FOO=2 # keep\n');
});
it('keeps CRLF line endings intact when collapsing duplicates', () => {
expect(mergeEnvContent('FOO=1\r\nBAR=2\r\nFOO=3\r\nBAZ=4\r\n', { FOO: 'new' })).toBe(
'FOO=new\r\nBAR=2\r\nBAZ=4\r\n'
);
expect(mergeEnvContent('FOO=1\r\nFOO=2\r\nFOO=3\r\n', { FOO: 'new' })).toBe('FOO=new\r\n');
});
it('quotes values that would not otherwise read back unchanged', () => {
expect(mergeEnvContent('', { FOO: 'secret#123' })).toBe('FOO="secret#123"\n');
expect(mergeEnvContent('', { FOO: ' padded' })).toBe('FOO=" padded"\n');
expect(mergeEnvContent('', { FOO: "'sq'" })).toBe('FOO="\'sq\'"\n');
expect(mergeEnvContent('', { FOO: 'line\nbreak' })).toBe('FOO="line\\nbreak"\n');
});
it('leaves plain values unquoted', () => {
expect(mergeEnvContent('', { FOO: 'https://x.supabase.co' })).toBe(
'FOO=https://x.supabase.co\n'
);
});
it('cannot be used to inject another variable through a value', () => {
const merged = mergeEnvContent('BAR=keep\n', { FOO: 'x\nBAR=hijacked' });
expect(dotenv.parse(merged).BAR).toBe('keep');
expect(dotenv.parse(merged).FOO).toBe('x\nBAR=hijacked');
});
it('does not treat a key inside another quoted value as a definition', () => {
const raw = 'PRIVATE_KEY="-----BEGIN-----\nFOO=notavar\n-----END-----"\nOTHER=1\n';
expect(mergeEnvContent(raw, { FOO: '2' })).toBe(`${raw}FOO=2\n`);
});
it('replaces a multi-line quoted value without orphaning its remaining lines', () => {
expect(mergeEnvContent('FOO="header\nBAR=leaked"\nOTHER=1\n', { FOO: '2' })).toBe(
'FOO=2\nOTHER=1\n'
);
});
});
describe('writeEnvLocalAsync', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(fs.pathExists).mockResolvedValue(false as never);
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
});
it('writes a new .env.local file', async () => {
await expect(
writeEnvLocalAsync('/project', envVars, {
label: LABEL,
nonInteractive: true,
overwrite: false,
})
).resolves.toBe(true);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('.env.local'),
expect.stringContaining(VAR_NAME)
);
expect(Log.withTick).toHaveBeenCalled();
});
it('skips conflicts in non-interactive mode without overwrite', async () => {
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
jest.mocked(fs.readFile).mockResolvedValue(`${VAR_NAME}=old\n` as never);
await expect(
writeEnvLocalAsync('/project', envVars, {
label: LABEL,
nonInteractive: true,
overwrite: false,
})
).resolves.toBe(false);
expect(fs.writeFile).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('skipped'));
});
it('prompts on conflicts interactively and skips when declined', async () => {
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
jest.mocked(fs.readFile).mockResolvedValue(`${VAR_NAME}=old\n` as never);
jest.mocked(confirmAsync).mockResolvedValue(false);
await expect(
writeEnvLocalAsync('/project', envVars, {
label: LABEL,
nonInteractive: false,
overwrite: false,
})
).resolves.toBe(false);
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('overwrites conflicts when confirmed or --overwrite', async () => {
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
jest.mocked(fs.readFile).mockResolvedValue(`${VAR_NAME}=old\n` as never);
jest.mocked(confirmAsync).mockResolvedValue(true);
await expect(
writeEnvLocalAsync('/project', envVars, {
label: LABEL,
nonInteractive: false,
overwrite: false,
})
).resolves.toBe(true);
await expect(
writeEnvLocalAsync('/project', envVars, {
label: LABEL,
nonInteractive: true,
overwrite: true,
})
).resolves.toBe(true);
expect(fs.writeFile).toHaveBeenCalled();
});
it('replaces a conflicting export-prefixed key instead of leaving a stale line', async () => {
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
jest.mocked(fs.readFile).mockResolvedValue(`export ${VAR_NAME}=old\n` as never);
await expect(
writeEnvLocalAsync('/project', envVars, {
label: LABEL,
nonInteractive: true,
overwrite: true,
})
).resolves.toBe(true);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('.env.local'),
`export ${VAR_NAME}=https://new.example.test\n`
);
});
});
@@ -0,0 +1,221 @@
import spawnAsync from '@expo/spawn-async';
import { ExpoConfig } from '@expo/config';
import Log from '../../../log';
import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig';
import {
addConfigPluginAsync,
envForExpoInstall,
extractDynamicConfigGuidance,
getSpawnErrorOutput,
installSdkPackagesAsync,
setupSdkAndConfigAsync,
} from '../sdk';
jest.mock('@expo/spawn-async');
jest.mock('../../../project/expoConfig');
jest.mock('../../../log');
jest.mock('../../../ora', () => ({
ora: jest.fn(() => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
warn: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
})),
}));
const packages = ['example-sdk', 'example-plugin-pkg'];
const plugin = 'example-plugin-pkg';
const label = 'Example';
describe('shared sdk helpers', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('getSpawnErrorOutput concatenates stdout and stderr', () => {
expect(getSpawnErrorOutput({ stdout: 'out', stderr: 'err' })).toBe('outerr');
expect(getSpawnErrorOutput(null)).toBe('');
});
it('extractDynamicConfigGuidance returns guidance after the marker', () => {
expect(extractDynamicConfigGuidance('no marker here')).toBeNull();
expect(
extractDynamicConfigGuidance('prefix Cannot automatically write to dynamic config: do this')
).toBe('Cannot automatically write to dynamic config: do this');
});
it('envForExpoInstall strips Expo local env vars', () => {
const original = process.env;
process.env = {
...original,
EXPO_LOCAL: '1',
EXPO_STAGING: '1',
EXPO_UNIVERSE_DIR: '/tmp',
KEEP: 'yes',
};
try {
const env = envForExpoInstall();
expect(env.KEEP).toBe('yes');
expect(env.EXPO_LOCAL).toBeUndefined();
expect(env.EXPO_STAGING).toBeUndefined();
expect(env.EXPO_UNIVERSE_DIR).toBeUndefined();
} finally {
process.env = original;
}
});
});
describe('installSdkPackagesAsync', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns installed on success', async () => {
jest.mocked(spawnAsync).mockResolvedValue({} as never);
await expect(
installSdkPackagesAsync('/project', { packages, label, jsonFlag: false })
).resolves.toEqual({
status: 'installed',
});
expect(spawnAsync).toHaveBeenCalledWith(
'npx',
['expo', 'install', ...packages],
expect.objectContaining({ cwd: '/project' })
);
});
it('returns dynamic config guidance when install output includes it', async () => {
jest.mocked(spawnAsync).mockRejectedValue({
stdout: '',
stderr: 'Cannot automatically write to dynamic config\nAdd plugin manually',
});
await expect(
installSdkPackagesAsync('/project', { packages, label, jsonFlag: true })
).resolves.toEqual({
status: 'installed',
dynamicConfigGuidance: expect.stringContaining(
'Cannot automatically write to dynamic config'
),
});
});
it('returns failed when install fails without guidance', async () => {
jest.mocked(spawnAsync).mockRejectedValue(new Error('boom'));
await expect(
installSdkPackagesAsync('/project', { packages, label, jsonFlag: true })
).resolves.toEqual({
status: 'failed',
});
});
});
describe('addConfigPluginAsync', () => {
const exp = { name: 'app', slug: 'app' } as ExpoConfig;
beforeEach(() => {
jest.clearAllMocks();
});
it('skips when plugin is already present as a string or tuple', async () => {
await expect(
addConfigPluginAsync(
'/project',
{
...exp,
plugins: [[plugin, {}], 'other'],
},
{ plugin }
)
).resolves.toBeNull();
await expect(
addConfigPluginAsync(
'/project',
{
...exp,
plugins: [plugin],
},
{ plugin }
)
).resolves.toBeNull();
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
expect(Log.withTick).toHaveBeenCalled();
});
it('returns null on successful modification', async () => {
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'success' } as never);
await expect(addConfigPluginAsync('/project', exp, { plugin })).resolves.toBeNull();
});
it('returns warn message when modification warns', async () => {
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'warn', message: 'dynamic config' } as never);
await expect(addConfigPluginAsync('/project', exp, { plugin })).resolves.toContain(
'dynamic config'
);
});
it('returns fallback message for other modification results', async () => {
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'fail' } as never);
await expect(addConfigPluginAsync('/project', exp, { plugin })).resolves.toContain(
JSON.stringify(plugin)
);
});
});
describe('setupSdkAndConfigAsync', () => {
const exp = { name: 'app', slug: 'app' } as ExpoConfig;
beforeEach(() => {
jest.clearAllMocks();
});
it('adds install failure guidance and does not add the plugin', async () => {
jest.mocked(spawnAsync).mockRejectedValue(new Error('nope'));
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'success' } as never);
const steps = await setupSdkAndConfigAsync('/project', exp, {
packages,
plugin,
label,
jsonFlag: true,
});
expect(steps).toEqual([expect.stringContaining('npx expo install')]);
expect(steps[0]).toContain(plugin);
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
});
it('prefers dynamic config guidance over adding the plugin', async () => {
jest.mocked(spawnAsync).mockRejectedValue({
stderr: 'Cannot automatically write to dynamic config\nmanual',
});
const steps = await setupSdkAndConfigAsync('/project', exp, {
packages,
plugin,
label,
jsonFlag: true,
});
expect(steps).toEqual([
expect.stringContaining('Cannot automatically write to dynamic config'),
]);
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
});
it('includes plugin manual steps when needed', async () => {
jest.mocked(spawnAsync).mockResolvedValue({} as never);
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'warn', message: 'edit app.config.js' } as never);
const steps = await setupSdkAndConfigAsync('/project', exp, {
packages,
plugin,
label,
jsonFlag: true,
});
expect(steps[0]).toContain('edit app.config.js');
});
});
@@ -0,0 +1,113 @@
import chalk from 'chalk';
import dotenv from 'dotenv';
import * as fs from 'fs-extra';
import path from 'path';
import { EnvVar } from '../../environments/variables';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
export async function writeEnvLocalAsync(
projectDir: string,
envVars: EnvVar[],
{
label,
nonInteractive,
overwrite,
}: { label: string; nonInteractive: boolean; overwrite: boolean }
): Promise<boolean> {
const envPath = path.join(projectDir, '.env.local');
let rawContent = '';
if (await fs.pathExists(envPath)) {
rawContent = await fs.readFile(envPath, 'utf8');
const existing = dotenv.parse(rawContent);
const conflicts = envVars.filter(v => existing[v.name] !== undefined);
if (conflicts.length > 0 && !overwrite) {
if (nonInteractive) {
Log.warn(
`.env.local already defines ${conflicts.map(v => v.name).join(', ')}; skipped (pass --overwrite to replace).`
);
return false;
}
const confirmed = await confirmAsync({
message: `.env.local already defines ${conflicts
.map(v => v.name)
.join(', ')}. Overwrite with the ${label} values?`,
});
if (!confirmed) {
Log.warn(`Skipped updating ${chalk.bold('.env.local')}.`);
return false;
}
}
}
const updatedContent = mergeEnvContent(
rawContent,
Object.fromEntries(envVars.map(v => [v.name, v.value]))
);
await fs.writeFile(envPath, updatedContent);
Log.withTick(`Wrote ${label} config to ${chalk.bold('.env.local')}`);
return true;
}
// Copied from dotenv (lib/main.js) so this finds exactly the keys dotenv.parse reports above.
const DOTENV_LINE =
/^\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?$/gm;
// dotenv trims unquoted values, cuts them at `#`, and strips one layer of quotes. Inside double
// quotes it unescapes only `\n` and `\r`, so `"` and `\` stay literal.
function formatEnvValue(value: string): string {
if (value === value.trim() && !/[#\r\n'"`]/.test(value.charAt(0) + value)) {
return value;
}
return `"${value.replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"`;
}
export function mergeEnvContent(rawContent: string, newVars: Record<string, string>): string {
const edits: { start: number; end: number; text: string }[] = [];
const rewritten = new Set<string>();
const regex = new RegExp(DOTENV_LINE.source, `${DOTENV_LINE.flags}d`);
let match: RegExpExecArray | null;
while ((match = regex.exec(rawContent)) !== null) {
const key = match[1];
if (!Object.hasOwn(newVars, key) || !match.indices) {
continue;
}
// match[0] runs past the value onto a following comment line, so spans come from the groups.
const [keyStart, keyEnd] = match.indices[1]!;
const valueRange = match.indices[2];
const definitionEnd = valueRange
? valueRange[1] - (/\s+$/.exec(match[2]!)?.[0].length ?? 0)
: keyEnd + (/^[ \t]*(?:=[ \t]*|:[ \t]+)/.exec(rawContent.slice(keyEnd))?.[0].length ?? 0);
if (!rewritten.has(key)) {
rewritten.add(key);
edits.push({
start: keyStart,
end: definitionEnd,
text: `${key}=${formatEnvValue(newVars[key])}`,
});
continue;
}
// dotenv lets a later definition win, so an extra one left behind would keep the stale value.
const nextNewline = rawContent.indexOf('\n', definitionEnd);
edits.push({
start: rawContent.lastIndexOf('\n', keyStart - 1) + 1,
end: nextNewline === -1 ? rawContent.length : nextNewline + 1,
text: '',
});
}
let content = rawContent;
for (const { start, end, text } of edits.reverse()) {
content = content.slice(0, start) + text + content.slice(end);
}
for (const [key, value] of Object.entries(newVars).filter(([key]) => !rewritten.has(key))) {
if (content.length > 0 && !content.endsWith('\n')) {
content += '\n';
}
content += `${key}=${formatEnvValue(value)}\n`;
}
return content;
}
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more