mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a04ae6342 | ||
|
|
8443581d13 | ||
|
|
aa6e610000 | ||
|
|
3cc58e500f | ||
|
|
2a7cb5e1fe | ||
|
|
289d579869 | ||
|
|
067c0e578f | ||
|
|
520c63fd3e | ||
|
|
221e01568c | ||
|
|
b8cff20089 | ||
|
|
c99ec44b92 | ||
|
|
90501af961 | ||
|
|
4c317292d2 | ||
|
|
16cf32ae73 | ||
|
|
1ec9e83c3b | ||
|
|
3b1b96e71f | ||
|
|
2f081de936 | ||
|
|
954534df42 |
No files matched your search
@@ -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
|
||||
@@ -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'
|
||||
@@ -137,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[@]}"
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -172,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,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
|
||||
|
||||
@@ -66,12 +66,12 @@ 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
|
||||
@@ -90,9 +90,11 @@ jobs:
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,5 +42,5 @@ jobs:
|
||||
yarn release
|
||||
else
|
||||
echo "Releasing with version $INPUT_VERSION"
|
||||
yarn release $INPUT_VERSION
|
||||
yarn release "$INPUT_VERSION"
|
||||
fi
|
||||
@@ -8,10 +8,27 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Upload JavaScript source maps generated by builds. ([#4170](https://github.com/expo/eas-cli/pull/4170) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
- [eas-cli] Set the project icon on the EAS dashboard from the app config when running `eas init`, if the project does not have one yet. ([#4177](https://github.com/expo/eas-cli/pull/4177) by [@tchayen](https://github.com/tchayen))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Make the non-interactive "EAS project not configured" error actionable on every path: list the exact `eas init --id` / `eas init --account` commands and the accounts you can create projects in, instead of suggesting bare `eas init`, which fails the same way. ([#4153](https://github.com/expo/eas-cli/pull/4153) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Set up the internal TestFlight group and invite admin testers when submitting interactively with an existing `ascAppId`, with `--no-auto-testflight-setup` to disable it, and fix the TestFlight group URL printed when adding testers partially fails. ([#4136](https://github.com/expo/eas-cli/pull/4136) by [@tchayen](https://github.com/tchayen))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [21.8.0](https://github.com/expo/eas-cli/releases/tag/v21.8.0) - 2026-08-11
|
||||
|
||||
### 🎉 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))
|
||||
|
||||
## [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
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "21.7.0",
|
||||
"STAGING": "21.8.0",
|
||||
"PRODUCTION": "21.5.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "21.7.1",
|
||||
"version": "21.8.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 .",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "21.7.1",
|
||||
"version": "21.8.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
#include <dlfcn.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/*
|
||||
* Xcode loads one plugin that implements the llcas_* API. This small shim loads
|
||||
* Apple's local CAS plugin and forwards every API call to it. The only exception
|
||||
* is the "remote-service-path" option: Xcode needs that option to schedule C and
|
||||
* Objective-C cache tasks, but Apple's local plugin does not need a remote service.
|
||||
* The shim accepts and discards that option. No cache data leaves the machine.
|
||||
*/
|
||||
|
||||
typedef void *llcas_cas_options_t;
|
||||
typedef void *llcas_cas_t;
|
||||
typedef void *llcas_cancellable_t;
|
||||
typedef uint32_t llcas_lookup_result_t;
|
||||
|
||||
typedef struct {
|
||||
const uint8_t *data;
|
||||
size_t size;
|
||||
} llcas_digest_t;
|
||||
|
||||
typedef struct {
|
||||
const void *data;
|
||||
size_t size;
|
||||
} llcas_data_t;
|
||||
|
||||
typedef struct {
|
||||
uint64_t opaque;
|
||||
} llcas_objectid_t;
|
||||
|
||||
typedef struct {
|
||||
uint64_t opaque;
|
||||
} llcas_loaded_object_t;
|
||||
|
||||
typedef struct {
|
||||
uint64_t opaque_b;
|
||||
uint64_t opaque_e;
|
||||
} llcas_object_refs_t;
|
||||
|
||||
typedef void (*llcas_cas_load_object_cb)(void *, llcas_lookup_result_t,
|
||||
llcas_loaded_object_t, char *);
|
||||
typedef void (*llcas_actioncache_get_cb)(void *, llcas_lookup_result_t,
|
||||
llcas_objectid_t, char *);
|
||||
typedef void (*llcas_actioncache_put_cb)(void *, bool, char *);
|
||||
|
||||
static void *apple_plugin_handle;
|
||||
|
||||
static void *apple_plugin_symbol(const char *name) {
|
||||
if (apple_plugin_handle == NULL) {
|
||||
const char *path = getenv("EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN");
|
||||
if (path == NULL || path[0] == '\0') {
|
||||
path = "/Applications/Xcode.app/Contents/Developer/usr/lib/"
|
||||
"libToolchainCASPlugin.dylib";
|
||||
}
|
||||
apple_plugin_handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
|
||||
if (apple_plugin_handle == NULL) {
|
||||
fprintf(stderr, "EAS local CAS plugin could not load %s: %s\n", path,
|
||||
dlerror());
|
||||
abort();
|
||||
}
|
||||
}
|
||||
|
||||
void *symbol = dlsym(apple_plugin_handle, name);
|
||||
if (symbol == NULL) {
|
||||
fprintf(stderr, "EAS local CAS plugin could not find %s: %s\n", name,
|
||||
dlerror());
|
||||
abort();
|
||||
}
|
||||
return symbol;
|
||||
}
|
||||
|
||||
#define APPLE_PLUGIN(name, type) ((type)apple_plugin_symbol(#name))
|
||||
|
||||
void llcas_get_plugin_version(uint32_t *major, uint32_t *minor) {
|
||||
typedef void (*fn_t)(uint32_t *, uint32_t *);
|
||||
APPLE_PLUGIN(llcas_get_plugin_version, fn_t)(major, minor);
|
||||
}
|
||||
|
||||
void llcas_string_dispose(char *value) {
|
||||
typedef void (*fn_t)(char *);
|
||||
APPLE_PLUGIN(llcas_string_dispose, fn_t)(value);
|
||||
}
|
||||
|
||||
void llcas_cancellable_cancel(llcas_cancellable_t token) {
|
||||
typedef void (*fn_t)(llcas_cancellable_t);
|
||||
APPLE_PLUGIN(llcas_cancellable_cancel, fn_t)(token);
|
||||
}
|
||||
|
||||
void llcas_cancellable_dispose(llcas_cancellable_t token) {
|
||||
typedef void (*fn_t)(llcas_cancellable_t);
|
||||
APPLE_PLUGIN(llcas_cancellable_dispose, fn_t)(token);
|
||||
}
|
||||
|
||||
llcas_cas_options_t llcas_cas_options_create(void) {
|
||||
typedef llcas_cas_options_t (*fn_t)(void);
|
||||
return APPLE_PLUGIN(llcas_cas_options_create, fn_t)();
|
||||
}
|
||||
|
||||
void llcas_cas_options_dispose(llcas_cas_options_t options) {
|
||||
typedef void (*fn_t)(llcas_cas_options_t);
|
||||
APPLE_PLUGIN(llcas_cas_options_dispose, fn_t)(options);
|
||||
}
|
||||
|
||||
void llcas_cas_options_set_client_version(llcas_cas_options_t options,
|
||||
uint32_t major, uint32_t minor) {
|
||||
typedef void (*fn_t)(llcas_cas_options_t, uint32_t, uint32_t);
|
||||
APPLE_PLUGIN(llcas_cas_options_set_client_version, fn_t)(options, major, minor);
|
||||
}
|
||||
|
||||
void llcas_cas_options_set_ondisk_path(llcas_cas_options_t options,
|
||||
const char *path) {
|
||||
typedef void (*fn_t)(llcas_cas_options_t, const char *);
|
||||
APPLE_PLUGIN(llcas_cas_options_set_ondisk_path, fn_t)(options, path);
|
||||
}
|
||||
|
||||
bool llcas_cas_options_set_option(llcas_cas_options_t options, const char *name,
|
||||
const char *value, char **error) {
|
||||
if (name != NULL && strcmp(name, "remote-service-path") == 0) {
|
||||
if (error != NULL) {
|
||||
*error = NULL;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
typedef bool (*fn_t)(llcas_cas_options_t, const char *, const char *,
|
||||
char **);
|
||||
return APPLE_PLUGIN(llcas_cas_options_set_option, fn_t)(options, name, value,
|
||||
error);
|
||||
}
|
||||
|
||||
llcas_cas_t llcas_cas_create(llcas_cas_options_t options, char **error) {
|
||||
typedef llcas_cas_t (*fn_t)(llcas_cas_options_t, char **);
|
||||
return APPLE_PLUGIN(llcas_cas_create, fn_t)(options, error);
|
||||
}
|
||||
|
||||
void llcas_cas_dispose(llcas_cas_t cas) {
|
||||
typedef void (*fn_t)(llcas_cas_t);
|
||||
APPLE_PLUGIN(llcas_cas_dispose, fn_t)(cas);
|
||||
}
|
||||
|
||||
int64_t llcas_cas_get_ondisk_size(llcas_cas_t cas, char **error) {
|
||||
typedef int64_t (*fn_t)(llcas_cas_t, char **);
|
||||
return APPLE_PLUGIN(llcas_cas_get_ondisk_size, fn_t)(cas, error);
|
||||
}
|
||||
|
||||
bool llcas_cas_set_ondisk_size_limit(llcas_cas_t cas, int64_t size,
|
||||
char **error) {
|
||||
typedef bool (*fn_t)(llcas_cas_t, int64_t, char **);
|
||||
return APPLE_PLUGIN(llcas_cas_set_ondisk_size_limit, fn_t)(cas, size, error);
|
||||
}
|
||||
|
||||
bool llcas_cas_prune_ondisk_data(llcas_cas_t cas, char **error) {
|
||||
typedef bool (*fn_t)(llcas_cas_t, char **);
|
||||
return APPLE_PLUGIN(llcas_cas_prune_ondisk_data, fn_t)(cas, error);
|
||||
}
|
||||
|
||||
char *llcas_cas_get_hash_schema_name(llcas_cas_t cas) {
|
||||
typedef char *(*fn_t)(llcas_cas_t);
|
||||
return APPLE_PLUGIN(llcas_cas_get_hash_schema_name, fn_t)(cas);
|
||||
}
|
||||
|
||||
uint32_t llcas_digest_parse(llcas_cas_t cas, const char *printed,
|
||||
uint8_t *bytes, size_t bytes_size, char **error) {
|
||||
typedef uint32_t (*fn_t)(llcas_cas_t, const char *, uint8_t *, size_t,
|
||||
char **);
|
||||
return APPLE_PLUGIN(llcas_digest_parse, fn_t)(cas, printed, bytes,
|
||||
bytes_size, error);
|
||||
}
|
||||
|
||||
bool llcas_digest_print(llcas_cas_t cas, llcas_digest_t digest, char **printed,
|
||||
char **error) {
|
||||
typedef bool (*fn_t)(llcas_cas_t, llcas_digest_t, char **, char **);
|
||||
return APPLE_PLUGIN(llcas_digest_print, fn_t)(cas, digest, printed, error);
|
||||
}
|
||||
|
||||
bool llcas_cas_get_objectid(llcas_cas_t cas, llcas_digest_t digest,
|
||||
llcas_objectid_t *object, char **error) {
|
||||
typedef bool (*fn_t)(llcas_cas_t, llcas_digest_t, llcas_objectid_t *,
|
||||
char **);
|
||||
return APPLE_PLUGIN(llcas_cas_get_objectid, fn_t)(cas, digest, object, error);
|
||||
}
|
||||
|
||||
llcas_digest_t llcas_objectid_get_digest(llcas_cas_t cas,
|
||||
llcas_objectid_t object) {
|
||||
typedef llcas_digest_t (*fn_t)(llcas_cas_t, llcas_objectid_t);
|
||||
return APPLE_PLUGIN(llcas_objectid_get_digest, fn_t)(cas, object);
|
||||
}
|
||||
|
||||
llcas_lookup_result_t llcas_cas_contains_object(llcas_cas_t cas,
|
||||
llcas_objectid_t object,
|
||||
bool globally, char **error) {
|
||||
typedef llcas_lookup_result_t (*fn_t)(llcas_cas_t, llcas_objectid_t, bool,
|
||||
char **);
|
||||
return APPLE_PLUGIN(llcas_cas_contains_object, fn_t)(cas, object, globally,
|
||||
error);
|
||||
}
|
||||
|
||||
llcas_lookup_result_t llcas_cas_load_object(llcas_cas_t cas,
|
||||
llcas_objectid_t object,
|
||||
llcas_loaded_object_t *loaded,
|
||||
char **error) {
|
||||
typedef llcas_lookup_result_t (*fn_t)(llcas_cas_t, llcas_objectid_t,
|
||||
llcas_loaded_object_t *, char **);
|
||||
return APPLE_PLUGIN(llcas_cas_load_object, fn_t)(cas, object, loaded, error);
|
||||
}
|
||||
|
||||
void llcas_cas_load_object_async(llcas_cas_t cas, llcas_objectid_t object,
|
||||
void *context,
|
||||
llcas_cas_load_object_cb callback,
|
||||
llcas_cancellable_t *cancellable) {
|
||||
typedef void (*fn_t)(llcas_cas_t, llcas_objectid_t, void *,
|
||||
llcas_cas_load_object_cb, llcas_cancellable_t *);
|
||||
APPLE_PLUGIN(llcas_cas_load_object_async,
|
||||
fn_t)(cas, object, context, callback, cancellable);
|
||||
}
|
||||
|
||||
bool llcas_cas_store_object(llcas_cas_t cas, llcas_data_t data,
|
||||
const llcas_objectid_t *refs, size_t refs_count,
|
||||
llcas_objectid_t *stored, char **error) {
|
||||
typedef bool (*fn_t)(llcas_cas_t, llcas_data_t, const llcas_objectid_t *,
|
||||
size_t, llcas_objectid_t *, char **);
|
||||
return APPLE_PLUGIN(llcas_cas_store_object,
|
||||
fn_t)(cas, data, refs, refs_count, stored, error);
|
||||
}
|
||||
|
||||
llcas_data_t llcas_loaded_object_get_data(llcas_cas_t cas,
|
||||
llcas_loaded_object_t object) {
|
||||
typedef llcas_data_t (*fn_t)(llcas_cas_t, llcas_loaded_object_t);
|
||||
return APPLE_PLUGIN(llcas_loaded_object_get_data, fn_t)(cas, object);
|
||||
}
|
||||
|
||||
llcas_object_refs_t llcas_loaded_object_get_refs(llcas_cas_t cas,
|
||||
llcas_loaded_object_t object) {
|
||||
typedef llcas_object_refs_t (*fn_t)(llcas_cas_t, llcas_loaded_object_t);
|
||||
return APPLE_PLUGIN(llcas_loaded_object_get_refs, fn_t)(cas, object);
|
||||
}
|
||||
|
||||
size_t llcas_object_refs_get_count(llcas_cas_t cas, llcas_object_refs_t refs) {
|
||||
typedef size_t (*fn_t)(llcas_cas_t, llcas_object_refs_t);
|
||||
return APPLE_PLUGIN(llcas_object_refs_get_count, fn_t)(cas, refs);
|
||||
}
|
||||
|
||||
llcas_objectid_t llcas_object_refs_get_id(llcas_cas_t cas,
|
||||
llcas_object_refs_t refs,
|
||||
size_t index) {
|
||||
typedef llcas_objectid_t (*fn_t)(llcas_cas_t, llcas_object_refs_t, size_t);
|
||||
return APPLE_PLUGIN(llcas_object_refs_get_id, fn_t)(cas, refs, index);
|
||||
}
|
||||
|
||||
llcas_lookup_result_t llcas_actioncache_get_for_digest(llcas_cas_t cas,
|
||||
llcas_digest_t digest,
|
||||
llcas_objectid_t *result,
|
||||
bool globally,
|
||||
char **error) {
|
||||
typedef llcas_lookup_result_t (*fn_t)(llcas_cas_t, llcas_digest_t,
|
||||
llcas_objectid_t *, bool, char **);
|
||||
return APPLE_PLUGIN(llcas_actioncache_get_for_digest,
|
||||
fn_t)(cas, digest, result, globally, error);
|
||||
}
|
||||
|
||||
void llcas_actioncache_get_for_digest_async(llcas_cas_t cas,
|
||||
llcas_digest_t digest,
|
||||
bool globally, void *context,
|
||||
llcas_actioncache_get_cb callback,
|
||||
llcas_cancellable_t *cancellable) {
|
||||
typedef void (*fn_t)(llcas_cas_t, llcas_digest_t, bool, void *,
|
||||
llcas_actioncache_get_cb, llcas_cancellable_t *);
|
||||
APPLE_PLUGIN(llcas_actioncache_get_for_digest_async,
|
||||
fn_t)(cas, digest, globally, context, callback, cancellable);
|
||||
}
|
||||
|
||||
bool llcas_actioncache_put_for_digest(llcas_cas_t cas, llcas_digest_t digest,
|
||||
llcas_objectid_t value, bool globally,
|
||||
char **error) {
|
||||
typedef bool (*fn_t)(llcas_cas_t, llcas_digest_t, llcas_objectid_t, bool,
|
||||
char **);
|
||||
return APPLE_PLUGIN(llcas_actioncache_put_for_digest,
|
||||
fn_t)(cas, digest, value, globally, error);
|
||||
}
|
||||
|
||||
void llcas_actioncache_put_for_digest_async(llcas_cas_t cas,
|
||||
llcas_digest_t digest,
|
||||
llcas_objectid_t value,
|
||||
bool globally, void *context,
|
||||
llcas_actioncache_put_cb callback,
|
||||
llcas_cancellable_t *cancellable) {
|
||||
typedef void (*fn_t)(llcas_cas_t, llcas_digest_t, llcas_objectid_t, bool,
|
||||
void *, llcas_actioncache_put_cb, llcas_cancellable_t *);
|
||||
APPLE_PLUGIN(llcas_actioncache_put_for_digest_async,
|
||||
fn_t)(cas, digest, value, globally, context, callback,
|
||||
cancellable);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Artifacts, BuildContext } from '../context';
|
||||
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
|
||||
import { maybeFindAndUploadBuildArtifacts } from '../utils/artifacts';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { maybeUploadSourceMapAsync } from '../utils/sourceMaps';
|
||||
|
||||
export async function runBuilderWithHooksAsync<T extends BuildJob>(
|
||||
ctx: BuildContext<T>,
|
||||
@@ -38,6 +39,9 @@ export async function runBuilderWithHooksAsync<T extends BuildJob>(
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_BUILD_ARTIFACTS, async () => {
|
||||
if (buildSuccess) {
|
||||
await maybeUploadSourceMapAsync(ctx);
|
||||
}
|
||||
await maybeFindAndUploadBuildArtifacts(ctx, {
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
@@ -18,8 +18,12 @@ import { downloadApplicationArchiveAsync } from '../ios/resign';
|
||||
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
restoreXcodeCompilationCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveXcodeCompilationCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
@@ -29,6 +33,7 @@ import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
import { isSourceMapUploadEnabled, resolveIosSourceMapPathAsync } from '../utils/sourceMaps';
|
||||
|
||||
const INSTALL_PODS_WARN_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
|
||||
const INSTALL_PODS_KILL_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
||||
@@ -99,13 +104,21 @@ async function buildInnerAsync(
|
||||
return;
|
||||
}
|
||||
await ctx.cacheManager?.restoreCache(ctx);
|
||||
await restoreCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await Promise.all([
|
||||
restoreCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
}),
|
||||
restoreXcodeCompilationCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => {
|
||||
@@ -168,20 +181,24 @@ async function buildInnerAsync(
|
||||
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
|
||||
const scheme = resolveScheme(ctx);
|
||||
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
|
||||
const sourceMapPath = isSourceMapUploadEnabled(ctx)
|
||||
? await resolveIosSourceMapPathAsync(ctx)
|
||||
: null;
|
||||
return await runFastlaneGym(ctx, {
|
||||
credentials,
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
entitlements,
|
||||
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
|
||||
? {
|
||||
extraEnv: {
|
||||
extraEnv: {
|
||||
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
|
||||
? {
|
||||
EXPO_UPDATES_FINGERPRINT_OVERRIDE:
|
||||
resolvedExpoUpdatesRuntimeVersion?.runtimeVersion,
|
||||
EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type,
|
||||
},
|
||||
}
|
||||
: null),
|
||||
}
|
||||
: null),
|
||||
...(sourceMapPath ? { SOURCEMAP_FILE: sourceMapPath } : null),
|
||||
},
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
@@ -237,14 +254,22 @@ async function buildInnerAsync(
|
||||
return;
|
||||
}
|
||||
await ctx.cacheManager?.saveCache(ctx);
|
||||
await saveCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
evictUsedBefore,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await Promise.all([
|
||||
saveCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
evictUsedBefore,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
}),
|
||||
saveXcodeCompilationCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import {
|
||||
XCODE_COMPILATION_CACHE_ENV,
|
||||
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
|
||||
compressXcodeCompilationCacheAsync,
|
||||
decompressXcodeCompilationCacheAsync,
|
||||
prepareXcodeCompilationCacheEnvAsync,
|
||||
} from '../compilationCache';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const DEVELOPER_DIRECTORY = '/Applications/Xcode.app/Contents/Developer';
|
||||
const APPLE_PLUGIN_PATH = path.join(DEVELOPER_DIRECTORY, 'usr/lib/libToolchainCASPlugin.dylib');
|
||||
const BUNDLED_SHIM_PATH = path.resolve(
|
||||
__dirname,
|
||||
'../../../bin/libeas_xcode_local_cas_plugin.dylib'
|
||||
);
|
||||
|
||||
describe(prepareXcodeCompilationCacheEnvAsync, () => {
|
||||
test('does nothing without the explicit opt-in', async () => {
|
||||
const result = await prepareXcodeCompilationCacheEnvAsync({
|
||||
derivedDataPath: '/project/ios/build',
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('enables compilation caching after validating the bundled arm64 shim', async () => {
|
||||
vol.fromJSON({
|
||||
[BUNDLED_SHIM_PATH]: 'bundled shim',
|
||||
[APPLE_PLUGIN_PATH]: 'Apple plugin',
|
||||
});
|
||||
vol.mkdirSync(os.tmpdir(), { recursive: true });
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
|
||||
const result = await prepareXcodeCompilationCacheEnvAsync({
|
||||
derivedDataPath: '/project/ios/build',
|
||||
env: {
|
||||
[XCODE_COMPILATION_CACHE_ENV]: '1',
|
||||
DEVELOPER_DIR: DEVELOPER_DIRECTORY,
|
||||
},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
expect.arrayContaining([
|
||||
'llvm-cas',
|
||||
`--fcas-plugin-path=${BUNDLED_SHIM_PATH}`,
|
||||
'--fcas-plugin-option=remote-service-path=/dev/null',
|
||||
'--ingest',
|
||||
BUNDLED_SHIM_PATH,
|
||||
]),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: APPLE_PLUGIN_PATH,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({
|
||||
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: APPLE_PLUGIN_PATH,
|
||||
GYM_XCARGS: `COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES COMPILATION_CACHE_ENABLE_PLUGIN=YES COMPILATION_CACHE_PLUGIN_PATH=${BUNDLED_SHIM_PATH} COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null`,
|
||||
GYM_DERIVED_DATA_PATH: '/project/ios/build',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not enable compilation caching when the bundled shim is missing', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
[APPLE_PLUGIN_PATH]: 'Apple plugin',
|
||||
});
|
||||
|
||||
const result = await prepareXcodeCompilationCacheEnvAsync({
|
||||
derivedDataPath: '/project/ios/build',
|
||||
env: {
|
||||
[XCODE_COMPILATION_CACHE_ENV]: '1',
|
||||
DEVELOPER_DIR: DEVELOPER_DIRECTORY,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
expect.stringContaining('compilation caching is disabled')
|
||||
);
|
||||
});
|
||||
|
||||
test('does not enable compilation caching when the bundled shim is incompatible', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
[BUNDLED_SHIM_PATH]: 'bundled shim',
|
||||
[APPLE_PLUGIN_PATH]: 'Apple plugin',
|
||||
});
|
||||
vol.mkdirSync(os.tmpdir(), { recursive: true });
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('incompatible plugin'));
|
||||
|
||||
const result = await prepareXcodeCompilationCacheEnvAsync({
|
||||
derivedDataPath: '/project/ios/build',
|
||||
env: {
|
||||
[XCODE_COMPILATION_CACHE_ENV]: '1',
|
||||
DEVELOPER_DIR: DEVELOPER_DIRECTORY,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
expect.stringContaining('compilation caching is disabled')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Xcode compilation cache archives', () => {
|
||||
test('uses system tar to preserve sparse files', async () => {
|
||||
const workingDirectory = '/working-directory';
|
||||
vol.fromJSON({
|
||||
[path.join(workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH, 'cache.db')]: 'cache',
|
||||
});
|
||||
vol.mkdirSync(os.tmpdir(), { recursive: true });
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
|
||||
const { archivePath } = await compressXcodeCompilationCacheAsync({
|
||||
workingDirectory,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'tar',
|
||||
['-czf', archivePath, '-C', workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH],
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
await decompressXcodeCompilationCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'tar',
|
||||
['-xzSf', archivePath, '-C', workingDirectory],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import path from 'path';
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import * as CompilationCache from '../compilationCache';
|
||||
import { Credentials } from '../credentials/manager';
|
||||
import { DistributionType } from '../credentials/provisioningProfile';
|
||||
import { runFastlaneGym } from '../fastlane';
|
||||
@@ -23,13 +24,19 @@ const WORKING_DIR = '/workingdir';
|
||||
// BuildContext.getReactNativeProjectDirectory() nests the RN project under `${workingdir}/build`.
|
||||
const IOS_DIR = path.join(WORKING_DIR, 'build', 'ios');
|
||||
|
||||
function makeIosBuildContext({ simulator }: { simulator: boolean }): BuildContext<Ios.Job> {
|
||||
function makeIosBuildContext({
|
||||
simulator,
|
||||
env = {},
|
||||
}: {
|
||||
simulator: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): BuildContext<Ios.Job> {
|
||||
const job: Ios.Job = { ...createTestIosJob(), simulator };
|
||||
return new BuildContext(job, {
|
||||
workingdir: WORKING_DIR,
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test', ...env },
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
}
|
||||
@@ -71,6 +78,10 @@ describe(runFastlaneGym, () => {
|
||||
(spawn as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: 'archive',
|
||||
@@ -125,4 +136,68 @@ describe(runFastlaneGym, () => {
|
||||
workspacePath: IOS_DIR,
|
||||
});
|
||||
});
|
||||
|
||||
it('enables local Swift, C, and Objective-C compilation caching', async () => {
|
||||
jest.spyOn(CompilationCache, 'prepareXcodeCompilationCacheEnvAsync').mockResolvedValue({
|
||||
GYM_XCARGS:
|
||||
'OTHER_SWIFT_FLAGS=-warnings-as-errors COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES COMPILATION_CACHE_ENABLE_PLUGIN=YES COMPILATION_CACHE_PLUGIN_PATH=/workingdir/bin/libeas_xcode_local_cas_plugin.dylib COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null',
|
||||
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN:
|
||||
'/Applications/Xcode.app/Contents/Developer/usr/lib/libToolchainCASPlugin.dylib',
|
||||
GYM_DERIVED_DATA_PATH: path.join(IOS_DIR, 'build'),
|
||||
});
|
||||
const ctx = makeIosBuildContext({
|
||||
simulator: false,
|
||||
env: {
|
||||
EAS_BUILD_XCODE_COMPILATION_CACHE: '1',
|
||||
GYM_XCARGS: 'OTHER_SWIFT_FLAGS=-warnings-as-errors',
|
||||
},
|
||||
});
|
||||
|
||||
await runFastlaneGym(ctx, {
|
||||
scheme: 'App',
|
||||
credentials: ARCHIVE_CREDENTIALS,
|
||||
entitlements: null,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'fastlane',
|
||||
['gym'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
GYM_XCARGS:
|
||||
'OTHER_SWIFT_FLAGS=-warnings-as-errors COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES COMPILATION_CACHE_ENABLE_PLUGIN=YES COMPILATION_CACHE_PLUGIN_PATH=/workingdir/bin/libeas_xcode_local_cas_plugin.dylib COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null',
|
||||
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN:
|
||||
'/Applications/Xcode.app/Contents/Developer/usr/lib/libToolchainCASPlugin.dylib',
|
||||
GYM_DERIVED_DATA_PATH: path.join(IOS_DIR, 'build'),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not enable compilation caching when the local plugin cannot be prepared', async () => {
|
||||
const ctx = makeIosBuildContext({
|
||||
simulator: false,
|
||||
env: {
|
||||
EAS_BUILD_XCODE_COMPILATION_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
await runFastlaneGym(ctx, {
|
||||
scheme: 'App',
|
||||
credentials: ARCHIVE_CREDENTIALS,
|
||||
entitlements: null,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'fastlane',
|
||||
['gym'],
|
||||
expect.objectContaining({
|
||||
env: expect.not.objectContaining({ GYM_XCARGS: expect.any(String) }),
|
||||
})
|
||||
);
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
expect.stringContaining('compilation caching is disabled')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Env, Platform } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { createHash } from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { CACHE_KEY_PREFIX_BY_PLATFORM, generateDefaultBuildCacheKeyAsync } from '../utils/cacheKey';
|
||||
|
||||
export const XCODE_COMPILATION_CACHE_ENV = 'EAS_BUILD_XCODE_COMPILATION_CACHE';
|
||||
export const XCODE_COMPILATION_CACHE_RELATIVE_PATH = 'ios/build/CompilationCache.noindex';
|
||||
|
||||
export function getXcodeCompilationCachePath(derivedDataPath: string): string {
|
||||
return path.join(derivedDataPath, 'CompilationCache.noindex');
|
||||
}
|
||||
|
||||
async function prepareXcodeLocalCompilationCachePluginAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
}): Promise<{ pluginPath: string; applePluginPath: string }> {
|
||||
const bundledPluginPath = path.resolve(
|
||||
__dirname,
|
||||
'../../bin/libeas_xcode_local_cas_plugin.dylib'
|
||||
);
|
||||
const developerDirectory =
|
||||
env.DEVELOPER_DIR ??
|
||||
(
|
||||
await spawn('xcode-select', ['--print-path'], {
|
||||
env,
|
||||
logger,
|
||||
})
|
||||
).stdout.trim();
|
||||
const applePluginPath = path.join(
|
||||
developerDirectory,
|
||||
'usr',
|
||||
'lib',
|
||||
'libToolchainCASPlugin.dylib'
|
||||
);
|
||||
|
||||
if (!(await fs.pathExists(applePluginPath))) {
|
||||
throw new Error(`The Xcode CAS plugin is missing at ${applePluginPath}.`);
|
||||
}
|
||||
if (!(await fs.pathExists(bundledPluginPath))) {
|
||||
throw new Error(`The EAS local CAS plugin is missing at ${bundledPluginPath}.`);
|
||||
}
|
||||
|
||||
const validationDirectory = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-xcode-local-cache-validation-')
|
||||
);
|
||||
try {
|
||||
await spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'llvm-cas',
|
||||
`--fcas-plugin-path=${bundledPluginPath}`,
|
||||
'--fcas-plugin-option=remote-service-path=/dev/null',
|
||||
`--cas=${path.join(validationDirectory, 'cas')}`,
|
||||
'--ingest',
|
||||
bundledPluginPath,
|
||||
],
|
||||
{
|
||||
env: { ...env, EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: applePluginPath },
|
||||
logger,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
await fs.remove(validationDirectory);
|
||||
}
|
||||
|
||||
return { pluginPath: bundledPluginPath, applePluginPath };
|
||||
}
|
||||
|
||||
export async function compressXcodeCompilationCacheAsync({
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
}): Promise<{ archivePath: string }> {
|
||||
const cachePath = path.join(workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH);
|
||||
if (!(await fs.pathExists(cachePath))) {
|
||||
throw new Error(`The Xcode compilation cache does not exist at ${cachePath}.`);
|
||||
}
|
||||
|
||||
const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'xcode-compilation-cache-'));
|
||||
const archivePath = path.join(archiveDirectory, 'cache.tar.gz');
|
||||
await spawn(
|
||||
'tar',
|
||||
['-czf', archivePath, '-C', workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH],
|
||||
{ env, logger }
|
||||
);
|
||||
return { archivePath };
|
||||
}
|
||||
|
||||
export async function decompressXcodeCompilationCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
archivePath: string;
|
||||
workingDirectory: string;
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
await fs.ensureDir(workingDirectory);
|
||||
await spawn('tar', ['-xzSf', archivePath, '-C', workingDirectory], { env, logger });
|
||||
}
|
||||
|
||||
export async function generateXcodeCompilationCacheKeyAsync({
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
}): Promise<{ key: string; keyPrefix: string; xcodeVersion: string }> {
|
||||
const xcodeVersion = (
|
||||
await spawn('xcodebuild', ['-version'], {
|
||||
env,
|
||||
logger,
|
||||
})
|
||||
).stdout.trim();
|
||||
const xcodeVersionHash = createHash('sha256').update(xcodeVersion).digest('hex').slice(0, 16);
|
||||
const ccacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, Platform.IOS);
|
||||
const dependencyHash = ccacheKey.slice(CACHE_KEY_PREFIX_BY_PLATFORM[Platform.IOS].length);
|
||||
const keyPrefix = `ios-xcode-compilation-cache-${xcodeVersionHash}-`;
|
||||
|
||||
return {
|
||||
key: `${keyPrefix}${dependencyHash}`,
|
||||
keyPrefix,
|
||||
xcodeVersion: xcodeVersion.replace(/\s+/g, ' '),
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareXcodeCompilationCacheEnvAsync({
|
||||
derivedDataPath,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
derivedDataPath: string;
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
}): Promise<Env> {
|
||||
if (env[XCODE_COMPILATION_CACHE_ENV] !== '1') {
|
||||
return {};
|
||||
}
|
||||
|
||||
let plugin: { pluginPath: string; applePluginPath: string };
|
||||
try {
|
||||
plugin = await prepareXcodeLocalCompilationCachePluginAsync({
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(`The EAS local Xcode CAS plugin is ready at ${plugin.pluginPath}.`);
|
||||
} catch (error: any) {
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'The EAS local Xcode CAS plugin is missing or incompatible. Xcode compilation caching is disabled.'
|
||||
);
|
||||
return {};
|
||||
}
|
||||
logger.info(
|
||||
`Xcode compilation caching is enabled. The cache directory is ${getXcodeCompilationCachePath(derivedDataPath)}.`
|
||||
);
|
||||
|
||||
const pluginXcargs = [
|
||||
'COMPILATION_CACHE_ENABLE_PLUGIN=YES',
|
||||
`COMPILATION_CACHE_PLUGIN_PATH=${plugin.pluginPath}`,
|
||||
// A non-empty remote service path makes Xcode schedule C, Objective-C,
|
||||
// precompiled-module, and precompiled-header cache tasks. The EAS plugin
|
||||
// consumes this option and never opens the path or performs remote I/O.
|
||||
'COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null',
|
||||
].join(' ');
|
||||
|
||||
return {
|
||||
GYM_XCARGS: [
|
||||
env.GYM_XCARGS,
|
||||
'COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES',
|
||||
pluginXcargs,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
GYM_DERIVED_DATA_PATH: derivedDataPath,
|
||||
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: plugin.applePluginPath,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import type { Credentials } from './credentials/manager';
|
||||
import * as CompilationCache from './compilationCache';
|
||||
import { createFastfileForResigningBuild } from './fastfile';
|
||||
import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile';
|
||||
import { isTVOS } from './tvos';
|
||||
@@ -46,13 +47,18 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
|
||||
if (ctx.skipNativeBuild) {
|
||||
throw new SkipNativeBuildError('Skipping fastlane build');
|
||||
}
|
||||
const compilationCacheEnv = await CompilationCache.prepareXcodeCompilationCacheEnvAsync({
|
||||
derivedDataPath,
|
||||
env: ctx.env,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
const buildLogger = new XcodeBuildLogger(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
|
||||
try {
|
||||
await runFastlane(['gym'], {
|
||||
cwd: workspacePath,
|
||||
logger: ctx.logger,
|
||||
env: { ...ctx.env, ...extraEnv },
|
||||
env: { ...ctx.env, ...extraEnv, ...compilationCacheEnv },
|
||||
});
|
||||
} finally {
|
||||
await buildLogger.flush();
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.41.0');
|
||||
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
|
||||
maestro_version: '1.41.0',
|
||||
maestro_backend: 'maestro',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +71,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
|
||||
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
|
||||
maestro_version: '2.0.10',
|
||||
maestro_backend: 'maestro',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,4 +89,141 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
|
||||
});
|
||||
|
||||
it('checks maestro-runner when selected by input', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout:
|
||||
command === 'maestro-runner'
|
||||
? 'maestro-runner 1.2.3\n Commit: abc123\n Built: 2026-08-13\n Go: go1.26.5\n OS/Arch: darwin/arm64\n'
|
||||
: '',
|
||||
})) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock();
|
||||
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(2);
|
||||
expect(mockedSpawn.mock.calls.map(([command]) => command)).toEqual([
|
||||
'maestro-runner',
|
||||
'maestro-runner',
|
||||
]);
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
|
||||
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
|
||||
maestro_version: '1.2.3',
|
||||
maestro_backend: 'maestro-runner',
|
||||
});
|
||||
});
|
||||
|
||||
it('selects maestro-runner from EAS_MAESTRO_BACKEND', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout: command === 'maestro-runner' ? 'maestro-runner 1.2.3\n' : '',
|
||||
})) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock();
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_MAESTRO_BACKEND: 'maestro-runner',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls.map(([command]) => command)).toEqual([
|
||||
'maestro-runner',
|
||||
'maestro-runner',
|
||||
]);
|
||||
});
|
||||
|
||||
it('installs the latest maestro-runner version when it is missing', async () => {
|
||||
let versionChecks = 0;
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'maestro-runner' && versionChecks++ === 0) {
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
}
|
||||
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.2.3\n' : '' };
|
||||
}) as any);
|
||||
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => '#!/bin/sh\n',
|
||||
} as Response);
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
try {
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock();
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith('https://open.devicelab.dev/install/maestro-runner');
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
|
||||
[],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenLastCalledWith(
|
||||
'maestro-runner',
|
||||
['--version'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
PATH: expect.stringMatching(/:\/home\/expo\/\.maestro-runner\/bin$/),
|
||||
}),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('installs the requested maestro-runner version', async () => {
|
||||
let versionChecks = 0;
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'maestro-runner' && versionChecks++ === 0) {
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
}
|
||||
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.0.9\n' : '' };
|
||||
}) as any);
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
text: async () => '#!/bin/sh\n',
|
||||
} as Response);
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
try {
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock();
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner', maestro_version: '1.0.9' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
|
||||
['--version', '1.0.9'],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
|
||||
);
|
||||
} finally {
|
||||
jest.restoreAllMocks();
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import {
|
||||
XCODE_COMPILATION_CACHE_ENV,
|
||||
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
|
||||
compressXcodeCompilationCacheAsync,
|
||||
decompressXcodeCompilationCacheAsync,
|
||||
generateXcodeCompilationCacheKeyAsync,
|
||||
} from '../../../ios/compilationCache';
|
||||
import { downloadCacheAsync } from '../restoreCache';
|
||||
import { restoreXcodeCompilationCacheAsync } from '../restoreBuildCache';
|
||||
import { uploadCacheAsync } from '../saveCache';
|
||||
import { saveXcodeCompilationCacheAsync } from '../saveBuildCache';
|
||||
|
||||
jest.mock('../restoreCache', () => ({
|
||||
downloadCacheAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../saveCache', () => ({
|
||||
uploadCacheAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../ios/compilationCache', () => ({
|
||||
XCODE_COMPILATION_CACHE_ENV: 'EAS_BUILD_XCODE_COMPILATION_CACHE',
|
||||
XCODE_COMPILATION_CACHE_RELATIVE_PATH: 'ios/build/CompilationCache.noindex',
|
||||
compressXcodeCompilationCacheAsync: jest.fn(),
|
||||
decompressXcodeCompilationCacheAsync: jest.fn(),
|
||||
generateXcodeCompilationCacheKeyAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const WORKING_DIRECTORY = '/working-directory';
|
||||
const ENV = {
|
||||
[XCODE_COMPILATION_CACHE_ENV]: '1',
|
||||
EAS_USE_CACHE: '1',
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
};
|
||||
const SECRETS = { robotAccessToken: 'robot-access-token' };
|
||||
|
||||
describe('Xcode compilation build cache', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(generateXcodeCompilationCacheKeyAsync).mockResolvedValue({
|
||||
key: 'ios-xcode-compilation-cache-version-dependencies',
|
||||
keyPrefix: 'ios-xcode-compilation-cache-version-',
|
||||
xcodeVersion: 'Xcode 26.0 Build version 17A000',
|
||||
});
|
||||
});
|
||||
|
||||
test('restores through the active build cache service', async () => {
|
||||
jest.mocked(downloadCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/cache.tar.gz',
|
||||
matchedKey: 'ios-xcode-compilation-cache-version-dependencies',
|
||||
});
|
||||
|
||||
await restoreXcodeCompilationCacheAsync({
|
||||
logger: createMockLogger(),
|
||||
workingDirectory: WORKING_DIRECTORY,
|
||||
env: ENV,
|
||||
secrets: SECRETS,
|
||||
});
|
||||
|
||||
expect(downloadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
jobId: 'build-id',
|
||||
key: 'ios-xcode-compilation-cache-version-dependencies',
|
||||
keyPrefixes: ['ios-xcode-compilation-cache-version-'],
|
||||
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
|
||||
platform: Platform.IOS,
|
||||
})
|
||||
);
|
||||
expect(decompressXcodeCompilationCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
archivePath: '/tmp/cache.tar.gz',
|
||||
workingDirectory: WORKING_DIRECTORY,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('saves through the active build cache service', async () => {
|
||||
vol.fromJSON({ '/tmp/xcode-cache.tar.gz': 'cache' });
|
||||
jest.mocked(compressXcodeCompilationCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/xcode-cache.tar.gz',
|
||||
});
|
||||
|
||||
await saveXcodeCompilationCacheAsync({
|
||||
logger: createMockLogger(),
|
||||
workingDirectory: WORKING_DIRECTORY,
|
||||
env: ENV,
|
||||
secrets: SECRETS,
|
||||
});
|
||||
|
||||
expect(compressXcodeCompilationCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workingDirectory: WORKING_DIRECTORY,
|
||||
})
|
||||
);
|
||||
|
||||
expect(uploadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
jobId: 'build-id',
|
||||
key: 'ios-xcode-compilation-cache-version-dependencies',
|
||||
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
|
||||
platform: Platform.IOS,
|
||||
size: 5,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
|
||||
import { Datadog } from '../../datadog';
|
||||
|
||||
export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
@@ -29,6 +30,11 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'backend',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
@@ -37,22 +43,28 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, outputs }) => {
|
||||
const requestedMaestroVersion = inputs.maestro_version.value as string | undefined;
|
||||
const { value: currentMaestroVersion } = await asyncResult(getMaestroVersion({ env }));
|
||||
const backend = resolveMaestroBackend({
|
||||
input: inputs.backend.value,
|
||||
env,
|
||||
});
|
||||
const requestedVersion = inputs.maestro_version.value as string | undefined;
|
||||
const { value: currentMaestroVersion } = await asyncResult(
|
||||
getMaestroVersion({ env, backend })
|
||||
);
|
||||
|
||||
// When not running in EAS Build VM, do not modify local environment.
|
||||
if (env.EAS_BUILD_RUNNER !== 'eas-build') {
|
||||
const currentIsJavaInstalled = await isJavaInstalled({ env });
|
||||
const currentIsIdbInstalled = await isIdbInstalled({ env });
|
||||
const needsToInstallJava = backend === 'maestro' && !(await isJavaInstalled({ env }));
|
||||
const needsToInstallIdb = backend === 'maestro' && !(await isIdbInstalled({ env }));
|
||||
|
||||
if (!currentIsJavaInstalled) {
|
||||
if (needsToInstallJava) {
|
||||
logger.warn(
|
||||
'It seems Java is not installed. It is required to run Maestro. If the job fails, this may be the reason.'
|
||||
);
|
||||
logger.info('');
|
||||
}
|
||||
|
||||
if (!currentIsIdbInstalled) {
|
||||
if (needsToInstallIdb) {
|
||||
logger.warn(
|
||||
'It seems IDB is not installed. Maestro requires it to run flows on iOS Simulator. If the job fails, this may be the reason.'
|
||||
);
|
||||
@@ -61,27 +73,32 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
|
||||
if (!currentMaestroVersion) {
|
||||
logger.warn(
|
||||
'It seems Maestro is not installed. Please install Maestro manually and rerun the job.'
|
||||
`It seems ${backend} is not installed. Please install it manually and rerun the job.`
|
||||
);
|
||||
logger.info('');
|
||||
}
|
||||
|
||||
// Guide is helpful in these two cases, it doesn't mention Java.
|
||||
if (!currentIsIdbInstalled || !currentMaestroVersion) {
|
||||
if (backend === 'maestro' && (needsToInstallIdb || !currentMaestroVersion)) {
|
||||
logger.warn(
|
||||
'For more info, check out Maestro installation guide: https://maestro.mobile.dev/getting-started/installing-maestro'
|
||||
);
|
||||
} else if (backend === 'maestro-runner' && !currentMaestroVersion) {
|
||||
logger.warn(
|
||||
'For more info, check out maestro-runner installation guide: https://github.com/devicelab-dev/maestro-runner#install'
|
||||
);
|
||||
}
|
||||
|
||||
if (currentMaestroVersion) {
|
||||
outputs.maestro_version.set(currentMaestroVersion);
|
||||
logger.info(`Maestro ${currentMaestroVersion} is ready.`);
|
||||
logger.info(`${backend} ${currentMaestroVersion} is ready.`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await isJavaInstalled({ env }))) {
|
||||
const needsToInstallJava = backend === 'maestro' && !(await isJavaInstalled({ env }));
|
||||
if (needsToInstallJava) {
|
||||
if (global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
logger.info('Installing Java');
|
||||
await installJavaFromGcs({ logger, env });
|
||||
@@ -94,10 +111,11 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
}
|
||||
|
||||
// IDB is only a requirement on macOS.
|
||||
if (
|
||||
const needsToInstallIdb =
|
||||
backend === 'maestro' &&
|
||||
global.runtimePlatform === BuildRuntimePlatform.DARWIN &&
|
||||
!(await isIdbInstalled({ env }))
|
||||
) {
|
||||
!(await isIdbInstalled({ env }));
|
||||
if (needsToInstallIdb) {
|
||||
logger.info('Installing IDB');
|
||||
await installIdbFromBrew({ logger, env });
|
||||
}
|
||||
@@ -106,43 +124,100 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
// and it is already installed which happens when developing on a local computer.
|
||||
if (
|
||||
!currentMaestroVersion ||
|
||||
(requestedMaestroVersion && requestedMaestroVersion !== currentMaestroVersion)
|
||||
(requestedVersion && requestedVersion !== currentMaestroVersion)
|
||||
) {
|
||||
await installMaestro({
|
||||
version: requestedMaestroVersion,
|
||||
global,
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
switch (backend) {
|
||||
case 'maestro':
|
||||
await installMaestro({ version: requestedVersion, global, logger, env });
|
||||
break;
|
||||
case 'maestro-runner':
|
||||
await installMaestroRunner({ version: requestedVersion, global, logger, env });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const maestroVersionResult = await asyncResult(getMaestroVersion({ env }));
|
||||
const maestroVersionResult = await asyncResult(getMaestroVersion({ env, backend }));
|
||||
if (!maestroVersionResult.ok) {
|
||||
logger.error(maestroVersionResult.reason, 'Failed to get Maestro version.');
|
||||
|
||||
throw new Error('Failed to ensure Maestro is installed.');
|
||||
throw new Error(`Failed to ensure ${backend} is installed.`);
|
||||
}
|
||||
|
||||
logger.info(`Maestro ${maestroVersionResult.value} is ready.`);
|
||||
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
|
||||
outputs.maestro_version.set(maestroVersionResult.value);
|
||||
|
||||
Datadog.distribution('eas.maestro.install', 1, {
|
||||
maestro_version: maestroVersionResult.value,
|
||||
maestro_backend: backend,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getMaestroVersion({ env }: { env: BuildStepEnv }): Promise<string> {
|
||||
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
|
||||
// `maestro --version` can print an analytics notice to stdout before the version,
|
||||
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
|
||||
// Take the last version-looking token: the real version is printed after the notice, so
|
||||
// this stays correct even if the notice itself contains a version-like string. Keeps the
|
||||
// step output and the eas.maestro.install metric tag clean. Best-effort only: fall back to
|
||||
// the raw output if none is found, so we never fail the build over a version string.
|
||||
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
|
||||
return versions?.at(-1) ?? stdout.trim();
|
||||
async function getMaestroVersion({
|
||||
env,
|
||||
backend,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
backend: MaestroBackend;
|
||||
}): Promise<string> {
|
||||
switch (backend) {
|
||||
case 'maestro': {
|
||||
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
|
||||
// `maestro --version` can print an analytics notice to stdout before the version,
|
||||
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
|
||||
// Take the last version-looking token: the real version is printed after the notice.
|
||||
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
|
||||
return versions?.at(-1) ?? stdout.trim();
|
||||
}
|
||||
case 'maestro-runner': {
|
||||
const { stdout } = await spawn('maestro-runner', ['--version'], { stdio: 'pipe', env });
|
||||
// maestro-runner prints build information after its version. The Go runtime version in
|
||||
// that output is also semver-shaped, so read only the prefixed runner version.
|
||||
return /^maestro-runner\s+(\S+)/m.exec(stdout)?.[1] ?? stdout.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function installMaestroRunner({
|
||||
global,
|
||||
version,
|
||||
logger,
|
||||
env,
|
||||
}: {
|
||||
version?: string;
|
||||
logger: bunyan;
|
||||
global: BuildStepGlobalContext;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
logger.info('Fetching maestro-runner install script');
|
||||
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install_maestro_runner'));
|
||||
try {
|
||||
const installMaestroRunnerScriptResponse = await fetch(
|
||||
'https://open.devicelab.dev/install/maestro-runner'
|
||||
);
|
||||
const installMaestroRunnerScript = await installMaestroRunnerScriptResponse.text();
|
||||
const scriptPath = path.join(tempDirectory, 'install_maestro_runner.sh');
|
||||
await fs.promises.writeFile(scriptPath, installMaestroRunnerScript, { mode: 0o777 });
|
||||
logger.info('Installing maestro-runner');
|
||||
assert(
|
||||
env.HOME,
|
||||
'Failed to infer directory to install maestro-runner in: $HOME environment variable is empty.'
|
||||
);
|
||||
await spawn(scriptPath, version && version !== 'latest' ? ['--version', version] : [], {
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
const binDir = path.join(env.HOME, '.maestro-runner', 'bin');
|
||||
global.updateEnv({
|
||||
...global.env,
|
||||
PATH: `${global.env.PATH}:${binDir}`,
|
||||
});
|
||||
env.PATH = `${env.PATH}:${binDir}`;
|
||||
process.env.PATH = `${process.env.PATH}:${binDir}`;
|
||||
} finally {
|
||||
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function installMaestro({
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const MaestroBackendSchema = z.enum(['maestro', 'maestro-runner']).default('maestro');
|
||||
|
||||
export type MaestroBackend = z.output<typeof MaestroBackendSchema>;
|
||||
|
||||
export function resolveMaestroBackend({
|
||||
input,
|
||||
env,
|
||||
}: {
|
||||
input: unknown;
|
||||
env: BuildStepEnv;
|
||||
}): MaestroBackend {
|
||||
const result = MaestroBackendSchema.safeParse(input || env.EAS_MAESTRO_BACKEND || undefined);
|
||||
if (!result.success) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
'backend and EAS_MAESTRO_BACKEND must be either "maestro" or "maestro-runner".',
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import { Datadog } from '../../datadog';
|
||||
import {
|
||||
XCODE_COMPILATION_CACHE_ENV,
|
||||
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
|
||||
decompressXcodeCompilationCacheAsync,
|
||||
generateXcodeCompilationCacheKeyAsync,
|
||||
} from '../../ios/compilationCache';
|
||||
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
@@ -48,26 +54,91 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
);
|
||||
}
|
||||
|
||||
await restoreCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
platform,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
|
||||
if (platform === Platform.ANDROID) {
|
||||
await restoreGradleCacheAsync({
|
||||
await Promise.all([
|
||||
restoreCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
platform,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
}),
|
||||
platform === Platform.IOS
|
||||
? restoreXcodeCompilationCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
})
|
||||
: restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreXcodeCompilationCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
const buildCacheEnabled =
|
||||
env.EAS_RESTORE_CACHE === '1' || (env.EAS_USE_CACHE === '1' && env.EAS_RESTORE_CACHE !== '0');
|
||||
if (!buildCacheEnabled || env[XCODE_COMPILATION_CACHE_ENV] !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const { key, keyPrefix, xcodeVersion } = await generateXcodeCompilationCacheKeyAsync({
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(`Restoring Xcode compilation cache for ${xcodeVersion} with key: ${key}`);
|
||||
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
|
||||
key,
|
||||
keyPrefixes: [keyPrefix],
|
||||
platform: Platform.IOS,
|
||||
});
|
||||
await decompressXcodeCompilationCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(
|
||||
`Xcode compilation cache restored ${matchedKey === key ? '(direct hit)' : '(prefix match)'}`
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof TurtleFetchError && err.response?.status === 404) {
|
||||
logger.info('No Xcode compilation cache found for this key');
|
||||
} else {
|
||||
logger.warn({ err }, 'Failed to restore Xcode compilation cache');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createCacheStatsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
|
||||
@@ -14,6 +14,12 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
|
||||
import {
|
||||
XCODE_COMPILATION_CACHE_ENV,
|
||||
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
|
||||
compressXcodeCompilationCacheAsync,
|
||||
generateXcodeCompilationCacheKeyAsync,
|
||||
} from '../../ios/compilationCache';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
|
||||
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
@@ -43,27 +49,87 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
|
||||
);
|
||||
}
|
||||
|
||||
await saveCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
platform,
|
||||
evictUsedBefore,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
|
||||
if (platform === Platform.ANDROID) {
|
||||
await saveGradleCacheAsync({
|
||||
await Promise.all([
|
||||
saveCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
platform,
|
||||
evictUsedBefore,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
}),
|
||||
platform === Platform.IOS
|
||||
? saveXcodeCompilationCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
})
|
||||
: saveGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveXcodeCompilationCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
const buildCacheEnabled =
|
||||
env.EAS_SAVE_CACHE === '1' || (env.EAS_USE_CACHE === '1' && env.EAS_SAVE_CACHE !== '0');
|
||||
if (!buildCacheEnabled || env[XCODE_COMPILATION_CACHE_ENV] !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { key, xcodeVersion } = await generateXcodeCompilationCacheKeyAsync({
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(`Saving Xcode compilation cache for ${xcodeVersion} with key: ${key}`);
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const { archivePath } = await compressXcodeCompilationCacheAsync({
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
logger.info(`Xcode compilation cache archive size: ${formatBytes(size)}`);
|
||||
|
||||
await uploadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key,
|
||||
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
|
||||
size,
|
||||
platform: Platform.IOS,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save Xcode compilation cache');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'path';
|
||||
|
||||
import { COMMON_FASTLANE_ENV } from '../../../common/fastlane';
|
||||
import { XcodeBuildLogger } from '../../../common/xcpretty';
|
||||
import * as CompilationCache from '../../../ios/compilationCache';
|
||||
|
||||
export async function runFastlaneGym({
|
||||
workingDir,
|
||||
@@ -19,14 +20,23 @@ export async function runFastlaneGym({
|
||||
env: BuildStepEnv;
|
||||
extraEnv?: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
const workspacePath = path.join(workingDir, 'ios');
|
||||
const derivedDataPath = path.join(workspacePath, 'build');
|
||||
// When caching is enabled, this also sets GYM_DERIVED_DATA_PATH so Fastlane
|
||||
// and the cache restore/save operations use this exact directory.
|
||||
const compilationCacheEnv = await CompilationCache.prepareXcodeCompilationCacheEnvAsync({
|
||||
derivedDataPath,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
const buildLogger = new XcodeBuildLogger(logger, workingDir);
|
||||
void buildLogger.watchLogFiles(buildLogsDirectory);
|
||||
try {
|
||||
await runFastlane(['gym'], {
|
||||
cwd: path.join(workingDir, 'ios'),
|
||||
cwd: workspacePath,
|
||||
logger,
|
||||
env,
|
||||
extraEnv,
|
||||
extraEnv: { ...extraEnv, ...compilationCacheEnv },
|
||||
});
|
||||
} finally {
|
||||
await buildLogger.flush();
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { BuildJob, ManagedArtifactType } from '@expo/eas-build-job';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createTestAndroidJob, createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { maybeUploadSourceMapAsync, resolveIosSourceMapPathAsync } from '../sourceMaps';
|
||||
|
||||
function enableSourceMapUpload<TJob extends BuildJob>(job: TJob): TJob {
|
||||
return {
|
||||
...job,
|
||||
experimental: {
|
||||
...job.experimental,
|
||||
uploadSourceMaps: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createContext<TJob extends BuildJob>(
|
||||
job: TJob,
|
||||
env: Record<string, string> = {}
|
||||
): { ctx: BuildContext<TJob>; uploadArtifact: jest.Mock } {
|
||||
const uploadArtifact = jest.fn(async () => ({ filename: null }));
|
||||
const ctx = new BuildContext(job, {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
...env,
|
||||
},
|
||||
uploadArtifact,
|
||||
});
|
||||
return { ctx, uploadArtifact };
|
||||
}
|
||||
|
||||
describe(maybeUploadSourceMapAsync.name, () => {
|
||||
it('uploads the final Android source map without sourcesContent', async () => {
|
||||
const finalSourceMapPath =
|
||||
'/workingdir/build/android/app/build/generated/sourcemaps/react/release/index.android.bundle.map';
|
||||
vol.fromJSON({
|
||||
[finalSourceMapPath]: JSON.stringify({
|
||||
version: 3,
|
||||
sources: ['App.tsx'],
|
||||
sourcesContent: ['customer source'],
|
||||
names: [],
|
||||
mappings: '',
|
||||
sections: [
|
||||
{
|
||||
map: {
|
||||
version: 3,
|
||||
sources: ['nested.ts'],
|
||||
sourcesContent: ['nested customer source'],
|
||||
names: [],
|
||||
mappings: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
[`${finalSourceMapPath.slice(0, -4)}.packager.map`]: JSON.stringify({ version: 3 }),
|
||||
[`${finalSourceMapPath.slice(0, -4)}.compiler.map`]: JSON.stringify({ version: 3 }),
|
||||
});
|
||||
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()));
|
||||
|
||||
await maybeUploadSourceMapAsync(ctx);
|
||||
|
||||
expect(uploadArtifact).toHaveBeenCalledWith({
|
||||
artifact: {
|
||||
type: ManagedArtifactType.SOURCE_MAP,
|
||||
paths: ['/workingdir/observe-source-maps/android.map'],
|
||||
},
|
||||
logger: ctx.logger,
|
||||
});
|
||||
const uploadedSourceMap = await fs.readJson('/workingdir/observe-source-maps/android.map');
|
||||
expect(uploadedSourceMap).not.toHaveProperty('sourcesContent');
|
||||
expect(uploadedSourceMap.sections[0].map).not.toHaveProperty('sourcesContent');
|
||||
});
|
||||
|
||||
it('does not guess when an Android build produces multiple final source maps', async () => {
|
||||
vol.fromJSON({
|
||||
'/workingdir/build/android/app/build/generated/sourcemaps/react/release/one.map':
|
||||
JSON.stringify({ version: 3 }),
|
||||
'/workingdir/build/android/other/build/generated/sourcemaps/react/release/two.map':
|
||||
JSON.stringify({ version: 3 }),
|
||||
});
|
||||
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()));
|
||||
const markBuildPhaseHasWarnings = jest.spyOn(ctx, 'markBuildPhaseHasWarnings');
|
||||
|
||||
await expect(maybeUploadSourceMapAsync(ctx)).resolves.toBeUndefined();
|
||||
|
||||
expect(uploadArtifact).not.toHaveBeenCalled();
|
||||
expect(markBuildPhaseHasWarnings).toHaveBeenCalled();
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to upload source map.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when source-map upload is disabled', async () => {
|
||||
const { ctx, uploadArtifact } = createContext(createTestAndroidJob());
|
||||
|
||||
await maybeUploadSourceMapAsync(ctx);
|
||||
|
||||
expect(uploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not upload source maps from local builds', async () => {
|
||||
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()), {
|
||||
EAS_BUILD_RUNNER: 'local-build-plugin',
|
||||
});
|
||||
|
||||
await maybeUploadSourceMapAsync(ctx);
|
||||
|
||||
expect(uploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveIosSourceMapPathAsync.name, () => {
|
||||
it('uses an absolute worker-owned path by default', async () => {
|
||||
const { ctx } = createContext(createTestIosJob());
|
||||
|
||||
await expect(resolveIosSourceMapPathAsync(ctx)).resolves.toBe(
|
||||
'/workingdir/observe-source-maps/main.jsbundle.map'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a configured path relative to the iOS project', async () => {
|
||||
const { ctx } = createContext(createTestIosJob(), {
|
||||
SOURCEMAP_FILE: 'build/custom/main.jsbundle.map',
|
||||
});
|
||||
|
||||
await expect(resolveIosSourceMapPathAsync(ctx)).resolves.toBe(
|
||||
'/workingdir/build/ios/build/custom/main.jsbundle.map'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { BuildJob, ManagedArtifactType, Platform, SystemError } from '@expo/eas-build-job';
|
||||
import fg from 'fast-glob';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
const ANDROID_SOURCE_MAP_PATTERN = 'android/**/build/generated/sourcemaps/react/**/*.map';
|
||||
const SOURCE_MAP_UPLOAD_DIRECTORY = 'observe-source-maps';
|
||||
|
||||
export function isSourceMapUploadEnabled(ctx: BuildContext<BuildJob>): boolean {
|
||||
return !ctx.isLocal && ctx.job.experimental?.uploadSourceMaps === true;
|
||||
}
|
||||
|
||||
export async function resolveIosSourceMapPathAsync(ctx: BuildContext<BuildJob>): Promise<string> {
|
||||
const configuredPath = ctx.env.SOURCEMAP_FILE;
|
||||
const sourceMapPath = configuredPath
|
||||
? path.resolve(ctx.getReactNativeProjectDirectory(), 'ios', configuredPath)
|
||||
: path.join(ctx.workingdir, SOURCE_MAP_UPLOAD_DIRECTORY, 'main.jsbundle.map');
|
||||
|
||||
await fs.ensureDir(path.dirname(sourceMapPath));
|
||||
return sourceMapPath;
|
||||
}
|
||||
|
||||
export async function maybeUploadSourceMapAsync(ctx: BuildContext<BuildJob>): Promise<void> {
|
||||
if (!isSourceMapUploadEnabled(ctx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceMapPath = await resolveSourceMapPathAsync(ctx);
|
||||
const strippedSourceMapPath = await stripSourcesContentAsync(ctx, sourceMapPath);
|
||||
|
||||
ctx.logger.info(`Uploading source map: ${sourceMapPath}`);
|
||||
await ctx.uploadArtifact({
|
||||
artifact: {
|
||||
type: ManagedArtifactType.SOURCE_MAP,
|
||||
paths: [strippedSourceMapPath],
|
||||
},
|
||||
logger: ctx.logger,
|
||||
});
|
||||
} catch (err: any) {
|
||||
ctx.logger.warn({ err }, 'Failed to upload source map.');
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSourceMapPathAsync(ctx: BuildContext<BuildJob>): Promise<string> {
|
||||
if (ctx.job.platform === Platform.IOS) {
|
||||
const sourceMapPath = await resolveIosSourceMapPathAsync(ctx);
|
||||
if (!(await fs.pathExists(sourceMapPath))) {
|
||||
throw new SystemError(`The iOS source map was not generated at ${sourceMapPath}.`);
|
||||
}
|
||||
return sourceMapPath;
|
||||
}
|
||||
|
||||
if (ctx.job.platform === Platform.ANDROID) {
|
||||
const projectDir = ctx.getReactNativeProjectDirectory();
|
||||
const sourceMapPaths = (
|
||||
await fg(ANDROID_SOURCE_MAP_PATTERN, {
|
||||
absolute: true,
|
||||
cwd: projectDir,
|
||||
onlyFiles: true,
|
||||
})
|
||||
).filter(sourceMapPath => !isIntermediateAndroidSourceMap(sourceMapPath));
|
||||
|
||||
if (sourceMapPaths.length === 0) {
|
||||
throw new SystemError('The Android build did not generate a final composed source map.');
|
||||
}
|
||||
if (sourceMapPaths.length > 1) {
|
||||
throw new SystemError(
|
||||
`Found multiple final Android source maps: ${sourceMapPaths.join(', ')}. ` +
|
||||
'Refusing to upload a source map that may not match the application archive.'
|
||||
);
|
||||
}
|
||||
return sourceMapPaths[0];
|
||||
}
|
||||
|
||||
throw new SystemError('Source-map upload is not supported for this build platform.');
|
||||
}
|
||||
|
||||
function isIntermediateAndroidSourceMap(sourceMapPath: string): boolean {
|
||||
return sourceMapPath.endsWith('.packager.map') || sourceMapPath.endsWith('.compiler.map');
|
||||
}
|
||||
|
||||
async function stripSourcesContentAsync(
|
||||
ctx: BuildContext<BuildJob>,
|
||||
sourceMapPath: string
|
||||
): Promise<string> {
|
||||
const sourceMap = JSON.parse(await fs.readFile(sourceMapPath, 'utf8')) as unknown;
|
||||
if (!isRecord(sourceMap) || sourceMap.version !== 3) {
|
||||
throw new SystemError(`Invalid source map at ${sourceMapPath}.`);
|
||||
}
|
||||
|
||||
removeSourcesContent(sourceMap);
|
||||
|
||||
const uploadDirectory = path.join(ctx.workingdir, SOURCE_MAP_UPLOAD_DIRECTORY);
|
||||
const uploadPath = path.join(uploadDirectory, `${ctx.job.platform}.map`);
|
||||
await fs.ensureDir(uploadDirectory);
|
||||
await fs.writeFile(uploadPath, JSON.stringify(sourceMap), 'utf8');
|
||||
return uploadPath;
|
||||
}
|
||||
|
||||
function removeSourcesContent(value: unknown): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of value) {
|
||||
removeSourcesContent(child);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete value.sourcesContent;
|
||||
for (const child of Object.values(value)) {
|
||||
removeSourcesContent(child);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -47,6 +47,9 @@ describe('Android.JobSchema', () => {
|
||||
},
|
||||
},
|
||||
expoBuildUrl: 'https://expo.dev/fake/build/url',
|
||||
experimental: {
|
||||
uploadSourceMaps: true,
|
||||
},
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
};
|
||||
|
||||
@@ -49,6 +49,9 @@ describe('Ios.JobSchema', () => {
|
||||
},
|
||||
},
|
||||
expoBuildUrl: 'https://expo.dev/fake/build/url',
|
||||
experimental: {
|
||||
uploadSourceMaps: true,
|
||||
},
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
};
|
||||
|
||||
@@ -119,6 +119,7 @@ export interface Job {
|
||||
|
||||
experimental?: {
|
||||
prebuildCommand?: string;
|
||||
uploadSourceMaps?: boolean;
|
||||
};
|
||||
expoBuildUrl?: string;
|
||||
githubTriggerOptions?: {
|
||||
@@ -186,6 +187,7 @@ export const JobSchema = Joi.object({
|
||||
|
||||
experimental: Joi.object({
|
||||
prebuildCommand: Joi.string(),
|
||||
uploadSourceMaps: Joi.boolean(),
|
||||
}),
|
||||
expoBuildUrl: Joi.string().uri().optional(),
|
||||
githubTriggerOptions: Joi.object({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export enum ManagedArtifactType {
|
||||
APPLICATION_ARCHIVE = 'APPLICATION_ARCHIVE',
|
||||
BUILD_ARTIFACTS = 'BUILD_ARTIFACTS',
|
||||
SOURCE_MAP = 'SOURCE_MAP',
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
|
||||
@@ -133,6 +133,7 @@ export interface Job {
|
||||
|
||||
experimental?: {
|
||||
prebuildCommand?: string;
|
||||
uploadSourceMaps?: boolean;
|
||||
};
|
||||
expoBuildUrl?: string;
|
||||
githubTriggerOptions?: {
|
||||
@@ -222,6 +223,7 @@ export const JobSchema = Joi.object({
|
||||
|
||||
experimental: Joi.object({
|
||||
prebuildCommand: Joi.string(),
|
||||
uploadSourceMaps: Joi.boolean(),
|
||||
}),
|
||||
expoBuildUrl: Joi.string().uri().optional(),
|
||||
githubTriggerOptions: Joi.object({
|
||||
|
||||
+216
-121
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "21.7.1",
|
||||
"version": "21.8.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -104,6 +104,7 @@ export async function prepareJobAsync(
|
||||
}),
|
||||
experimental: {
|
||||
prebuildCommand: buildProfile.prebuildCommand,
|
||||
uploadSourceMaps: buildProfile.uploadSourceMaps,
|
||||
},
|
||||
mode: buildMode,
|
||||
triggeredBy: BuildTrigger.EAS_CLI,
|
||||
|
||||
@@ -99,6 +99,7 @@ export async function prepareJobAsync(
|
||||
}),
|
||||
experimental: {
|
||||
prebuildCommand: buildProfile.prebuildCommand,
|
||||
uploadSourceMaps: buildProfile.uploadSourceMaps,
|
||||
},
|
||||
mode: buildMode,
|
||||
triggeredBy: BuildTrigger.EAS_CLI,
|
||||
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
+74
-3
@@ -4,7 +4,7 @@ import { anything, instance, mock, when } from 'ts-mockito';
|
||||
|
||||
import { Role } from '../../../../graphql/generated';
|
||||
import { AppQuery } from '../../../../graphql/queries/AppQuery';
|
||||
import { learnMore } from '../../../../log';
|
||||
import Log, { learnMore } from '../../../../log';
|
||||
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { isExpoInstalled } from '../../../../project/projectUtils';
|
||||
import { promptAsync } from '../../../../prompts';
|
||||
@@ -32,6 +32,8 @@ describe(getProjectIdAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
|
||||
jest
|
||||
.mocked(getConfigFilePaths)
|
||||
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
|
||||
@@ -379,6 +381,7 @@ describe(getProjectIdAsync, () => {
|
||||
).resolves.toEqual('2345');
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
expect(Log.warn).toHaveBeenCalledWith('EAS project not configured.');
|
||||
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ accountName: 'dominik', projectName: 'test' },
|
||||
@@ -478,7 +481,14 @@ describe(getProjectIdAsync, () => {
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
'Project is not configured. When using a robot access token, run "eas init --account <name> --non-interactive" or set the "owner" field in your app config before managing the project. Accounts this token can create projects in: notnotbrent'
|
||||
`EAS project not configured. A robot access token cannot configure it interactively. ` +
|
||||
`Run one of the following, then re-run this command:\n\n` +
|
||||
`To link an existing project:\n\n` +
|
||||
` eas init --id <project-id> --non-interactive\n\n` +
|
||||
`To create a new project:\n\n` +
|
||||
` eas init --account <account-name> --non-interactive\n\n` +
|
||||
`Accounts you can create projects in: notnotbrent\n\n` +
|
||||
`Alternatively, set the "owner" field in your app config.`
|
||||
);
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
@@ -493,12 +503,73 @@ describe(getProjectIdAsync, () => {
|
||||
{ nonInteractive: true }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
|
||||
`EAS project not configured. This command cannot configure it in non-interactive mode. ` +
|
||||
`Run one of the following, then re-run this command:\n\n` +
|
||||
`To link an existing project:\n\n` +
|
||||
` eas init --id <project-id> --non-interactive\n\n` +
|
||||
`To create a new project:\n\n` +
|
||||
` eas init --account <account-name> --non-interactive\n\n` +
|
||||
`Accounts you can create projects in: notnotbrent`
|
||||
);
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defers to the project fetch in non-interactive mode when the account is already known', async () => {
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: 'user_id',
|
||||
email: '[email protected]',
|
||||
username: 'notnotbrent',
|
||||
primaryAccount: {
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
featureGates: {},
|
||||
},
|
||||
authenticationInfo: { accessToken: 'fake', sessionSecret: null },
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
instance(sessionManagerMock),
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
|
||||
{ nonInteractive: true }
|
||||
)
|
||||
).resolves.toEqual('2345');
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ accountName: 'notnotbrent', projectName: 'test' },
|
||||
{ nonInteractive: true },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('does not warn that the project is not configured in non-interactive mode', async () => {
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
|
||||
{ nonInteractive: true }
|
||||
)
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(Log.warn).not.toHaveBeenCalledWith('EAS project not configured.');
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if writing the ID back to the config fails', async () => {
|
||||
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
getPrivateExpoConfigAsync,
|
||||
} from '../../../project/expoConfig';
|
||||
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { getUnconfiguredProjectError } from '../../../project/projectNotConfiguredError';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import SessionManager from '../../../user/SessionManager';
|
||||
import { Actor, getActorUsername, getCreatableAccountNames } from '../../../user/User';
|
||||
import { Actor, getActorUsername } from '../../../user/User';
|
||||
|
||||
/**
|
||||
* Save an EAS project ID to the appropriate field in the app config.
|
||||
@@ -183,7 +184,9 @@ export async function validateOrSetProjectIdAsync({
|
||||
throw new Error('This command must be run inside a project directory.');
|
||||
}
|
||||
|
||||
Log.warn('EAS project not configured.');
|
||||
if (!options.nonInteractive) {
|
||||
Log.warn('EAS project not configured.');
|
||||
}
|
||||
|
||||
const accountName = await chooseAccountNameForEASProjectAsync(exp, actor, options);
|
||||
|
||||
@@ -226,11 +229,11 @@ async function chooseAccountNameForEASProjectAsync(
|
||||
}
|
||||
|
||||
if (actor.__typename === 'Robot') {
|
||||
throw new Error(
|
||||
`Project is not configured. When using a robot access token, run "eas init --account <name> --non-interactive" or set the "owner" field in your app config before managing the project. Accounts this token can create projects in: ${getCreatableAccountNames(
|
||||
actor
|
||||
).join(', ')}`
|
||||
);
|
||||
throw getUnconfiguredProjectError({
|
||||
actor,
|
||||
reason: 'A robot access token cannot configure it interactively.',
|
||||
additionalFix: 'Alternatively, set the "owner" field in your app config.',
|
||||
});
|
||||
}
|
||||
|
||||
const allAccounts = actor.accounts;
|
||||
@@ -239,9 +242,7 @@ async function chooseAccountNameForEASProjectAsync(
|
||||
}
|
||||
|
||||
if (options.nonInteractive) {
|
||||
throw new Error(
|
||||
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
|
||||
);
|
||||
throw getUnconfiguredProjectError({ actor });
|
||||
}
|
||||
|
||||
const choices = getAccountChoices(
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
|
||||
import { App, User, UserRole } from '@expo/apple-utils';
|
||||
import { App } from '@expo/apple-utils';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
@@ -15,6 +15,7 @@ import { SetUpAscApiKey } from '../credentials/ios/actions/SetUpAscApiKey';
|
||||
import { SetUpBuildCredentials } from '../credentials/ios/actions/SetUpBuildCredentials';
|
||||
import { SetUpPushKey } from '../credentials/ios/actions/SetUpPushKey';
|
||||
import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExists';
|
||||
import { ensureTestFlightGroupExistsAsync } from '../credentials/ios/appstore/ensureTestFlightGroup';
|
||||
import { Target } from '../credentials/ios/types';
|
||||
import {
|
||||
WorkflowJobStatus,
|
||||
@@ -60,62 +61,8 @@ export async function detectProjectSdkVersionAsync(
|
||||
}
|
||||
}
|
||||
|
||||
const TESTFLIGHT_GROUP_NAME = 'Team (Expo)';
|
||||
|
||||
async function setupTestFlightAsync(ascApp: App): Promise<void> {
|
||||
let group;
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
const groups = await ascApp.getBetaGroupsAsync({
|
||||
query: { includes: ['betaTesters'] },
|
||||
});
|
||||
|
||||
group = groups.find(
|
||||
g => g.attributes.isInternalGroup && g.attributes.name === TESTFLIGHT_GROUP_NAME
|
||||
);
|
||||
|
||||
if (!group) {
|
||||
group = await ascApp.createBetaGroupAsync({
|
||||
name: TESTFLIGHT_GROUP_NAME,
|
||||
isInternalGroup: true,
|
||||
hasAccessToAllBuilds: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
} catch (error: any) {
|
||||
// Apple returns this error when the app isn't ready yet
|
||||
if (error?.data?.errors?.some((e: any) => e.code === 'ENTITY_ERROR.RELATIONSHIP.INVALID')) {
|
||||
if (attempt < 9) {
|
||||
await sleepAsync(10_000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
throw new Error('Failed to create TestFlight group');
|
||||
}
|
||||
|
||||
const users = await User.getAsync(ascApp.context);
|
||||
const admins = users.filter(u => u.attributes.roles?.includes(UserRole.ADMIN));
|
||||
|
||||
const existingEmails = new Set(
|
||||
group.attributes.betaTesters?.map((t: any) => t.attributes.email?.toLowerCase()) ?? []
|
||||
);
|
||||
|
||||
const newTesters = admins
|
||||
.filter(u => u.attributes.email && !existingEmails.has(u.attributes.email.toLowerCase()))
|
||||
.map(u => ({
|
||||
email: u.attributes.email!,
|
||||
firstName: u.attributes.firstName ?? '',
|
||||
lastName: u.attributes.lastName ?? '',
|
||||
}));
|
||||
|
||||
if (newTesters.length > 0) {
|
||||
await group.createBulkBetaTesterAssignmentsAsync(newTesters);
|
||||
}
|
||||
await ensureTestFlightGroupExistsAsync(ascApp);
|
||||
}
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { AppMutation } from '../../../graphql/mutations/AppMutation';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { maybeSetProjectIconFromAppConfigAsync } from '../../../project/projectIcon';
|
||||
import { isExpoInstalled } from '../../../project/projectUtils';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
@@ -36,6 +37,7 @@ jest.mock('../../../ora', () => ({
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
|
||||
jest.mock('../../../project/projectIcon');
|
||||
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
|
||||
jest.mock('../../../project/projectUtils');
|
||||
jest.mock('../../../utils/json');
|
||||
@@ -887,4 +889,67 @@ describe(ProjectInit.name, () => {
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('project icon', () => {
|
||||
beforeEach(() => {
|
||||
mockTestProject({ configuredProjectId: '1234', configuredOwner: jester.accounts[0].name });
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the icon from the app config', async () => {
|
||||
await new ProjectInit([], commandOptions).run();
|
||||
|
||||
expect(jest.mocked(maybeSetProjectIconFromAppConfigAsync).mock.calls[0][1]).toEqual({
|
||||
projectId: '1234',
|
||||
projectDir: '/test-project',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not set the icon with --no-icon', async () => {
|
||||
await new ProjectInit(['--no-icon'], commandOptions).run();
|
||||
|
||||
expect(maybeSetProjectIconFromAppConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the icon source in JSON output', async () => {
|
||||
jest.mocked(maybeSetProjectIconFromAppConfigAsync).mockResolvedValue({
|
||||
status: 'set',
|
||||
icon: { field: 'icon', path: '/test-project/assets/icon.png' },
|
||||
});
|
||||
|
||||
await new ProjectInit(['--json', '--non-interactive'], commandOptions).run();
|
||||
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
status: 'already-linked',
|
||||
projectId: '1234',
|
||||
owner: 'jester',
|
||||
slug: 'testing-123',
|
||||
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
|
||||
icon: { source: 'icon' },
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the icon from JSON output when it was not set', async () => {
|
||||
jest.mocked(maybeSetProjectIconFromAppConfigAsync).mockResolvedValue({
|
||||
status: 'skipped',
|
||||
reason: 'no-icon-in-app-config',
|
||||
});
|
||||
|
||||
await new ProjectInit(['--json', '--non-interactive'], commandOptions).run();
|
||||
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
status: 'already-linked',
|
||||
projectId: '1234',
|
||||
owner: 'jester',
|
||||
slug: 'testing-123',
|
||||
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,17 @@
|
||||
import { Args } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { getProjectDashboardUrl } from '../../../build/utils/url';
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EASNonInteractiveFlag } from '../../../commandUtils/flags';
|
||||
import { AppUploadSessionType } from '../../../graphql/generated';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import Log, { link } from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { uploadAppScopedFileAtPathToGCSAsync } from '../../../uploads';
|
||||
import { sleepAsync } from '../../../utils/promise';
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
|
||||
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // matches the upload session's GCS limit
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const POLL_TIMEOUT_MS = 90_000;
|
||||
import {
|
||||
pollForProfileImageChangeAsync,
|
||||
uploadProjectIconAsync,
|
||||
validateIconAsync,
|
||||
} from '../../../project/projectIcon';
|
||||
|
||||
export default class ProjectIconSet extends EasCommand {
|
||||
static override description = 'set the project icon displayed on the EAS dashboard';
|
||||
@@ -51,7 +45,7 @@ export default class ProjectIconSet extends EasCommand {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
await validateImageAsync(imagePath);
|
||||
await validateIconAsync(imagePath);
|
||||
|
||||
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
const projectDashboardUrl = getProjectDashboardUrl(app.ownerAccount.name, app.slug);
|
||||
@@ -62,11 +56,7 @@ export default class ProjectIconSet extends EasCommand {
|
||||
|
||||
const spinner = ora('Uploading project icon').start();
|
||||
try {
|
||||
await uploadAppScopedFileAtPathToGCSAsync(graphqlClient, {
|
||||
type: AppUploadSessionType.ProfileImageUpload,
|
||||
appId: projectId,
|
||||
path: imagePath,
|
||||
});
|
||||
await uploadProjectIconAsync(graphqlClient, { projectId, imagePath });
|
||||
|
||||
// The icon is processed asynchronously (resized and assigned to the
|
||||
// project by the server), so poll until the icon URL changes.
|
||||
@@ -84,48 +74,3 @@ export default class ProjectIconSet extends EasCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateImageAsync(imagePath: string): Promise<void> {
|
||||
if (!(await fs.pathExists(imagePath))) {
|
||||
throw new Error(`No file found at ${imagePath}`);
|
||||
}
|
||||
const extension = path.extname(imagePath).toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.includes(extension)) {
|
||||
throw new Error(
|
||||
`Unsupported image format "${extension}". The icon must be a PNG or JPEG file.`
|
||||
);
|
||||
}
|
||||
const { size } = await fs.stat(imagePath);
|
||||
if (size > MAX_IMAGE_SIZE_BYTES) {
|
||||
throw new Error(
|
||||
`The image is ${(size / 1024 / 1024).toFixed(1)} MB, but the maximum allowed size is 10 MB.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollForProfileImageChangeAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId,
|
||||
previousProfileImageUrl,
|
||||
projectDashboardUrl,
|
||||
}: {
|
||||
projectId: string;
|
||||
previousProfileImageUrl: string | null;
|
||||
projectDashboardUrl: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
|
||||
await sleepAsync(POLL_INTERVAL_MS);
|
||||
const profileImageUrl = await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId);
|
||||
if (profileImageUrl && profileImageUrl !== previousProfileImageUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for the icon to be processed. It may still appear on the project page shortly: ${chalk.underline(
|
||||
projectDashboardUrl
|
||||
)}`
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { maybeSetProjectIconFromAppConfigAsync } from '../../project/projectIcon';
|
||||
import {
|
||||
ProjectInitResult,
|
||||
ensureOwnerSlugConsistencyAsync,
|
||||
@@ -37,6 +38,12 @@ export default class ProjectInit extends EasCommand {
|
||||
description:
|
||||
'Whether to create a new project/link an existing project without additional prompts or overwrite any existing project ID when running with --id flag',
|
||||
}),
|
||||
icon: Flags.boolean({
|
||||
description:
|
||||
'Set the icon shown on the EAS dashboard from the app config, when the project does not have one yet',
|
||||
default: true,
|
||||
allowNo: true,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -82,6 +89,13 @@ export default class ProjectInit extends EasCommand {
|
||||
}
|
||||
);
|
||||
|
||||
const iconResult = flags.icon
|
||||
? await maybeSetProjectIconFromAppConfigAsync(graphqlClient, {
|
||||
projectId: result.projectId,
|
||||
projectDir,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
status: result.status,
|
||||
@@ -89,6 +103,7 @@ export default class ProjectInit extends EasCommand {
|
||||
owner,
|
||||
slug,
|
||||
dashboardUrl: getProjectDashboardUrl(owner, slug),
|
||||
...(iconResult?.status === 'set' ? { icon: { source: iconResult.icon.field } } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ interface RawCommandFlags {
|
||||
verbose: boolean;
|
||||
wait: boolean;
|
||||
'non-interactive': boolean;
|
||||
'auto-testflight-setup': boolean;
|
||||
'verbose-fastlane': boolean;
|
||||
groups?: string[];
|
||||
}
|
||||
@@ -46,6 +47,7 @@ interface CommandFlags {
|
||||
verbose: boolean;
|
||||
wait: boolean;
|
||||
nonInteractive: boolean;
|
||||
autoTestFlightSetup: boolean;
|
||||
isVerboseFastlaneEnabled: boolean;
|
||||
groups?: string[];
|
||||
}
|
||||
@@ -106,6 +108,11 @@ export default class Submit extends EasCommand {
|
||||
default: false,
|
||||
description: 'Run command in non-interactive mode',
|
||||
}),
|
||||
'auto-testflight-setup': Flags.boolean({
|
||||
default: true,
|
||||
allowNo: true,
|
||||
description: 'Set up an internal TestFlight group for the app (iOS only)',
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
@@ -152,6 +159,7 @@ export default class Submit extends EasCommand {
|
||||
profile: submissionProfile.profile,
|
||||
archiveFlags: flagsWithPlatform.archiveFlags,
|
||||
nonInteractive: flagsWithPlatform.nonInteractive,
|
||||
autoTestFlightSetup: flagsWithPlatform.autoTestFlightSetup,
|
||||
isVerboseFastlaneEnabled: flagsWithPlatform.isVerboseFastlaneEnabled,
|
||||
groups: flagsWithPlatform.groups,
|
||||
actor,
|
||||
@@ -198,6 +206,7 @@ export default class Submit extends EasCommand {
|
||||
wait,
|
||||
profile,
|
||||
'non-interactive': nonInteractive,
|
||||
'auto-testflight-setup': autoTestFlightSetup,
|
||||
'verbose-fastlane': isVerboseFastlaneEnabled,
|
||||
groups,
|
||||
'what-to-test': whatToTest,
|
||||
@@ -221,6 +230,7 @@ export default class Submit extends EasCommand {
|
||||
wait,
|
||||
profile,
|
||||
nonInteractive,
|
||||
autoTestFlightSetup,
|
||||
whatToTest,
|
||||
isVerboseFastlaneEnabled,
|
||||
groups,
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { App, BetaGroup, User } from '@expo/apple-utils';
|
||||
|
||||
import { ensureTestFlightGroupExistsAsync } from '../ensureTestFlightGroup';
|
||||
import { confirmAsync } from '../../../../prompts';
|
||||
|
||||
jest.mock('../../../../ora');
|
||||
jest.mock('../../../../prompts', () => ({
|
||||
confirmAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/apple-utils', () => ({
|
||||
...jest.requireActual('@expo/apple-utils'),
|
||||
User: { getAsync: jest.fn() },
|
||||
BetaGroup: { deleteAsync: jest.fn() },
|
||||
}));
|
||||
|
||||
function mockApp({
|
||||
groups,
|
||||
createdGroup,
|
||||
}: {
|
||||
groups: Partial<BetaGroup>[];
|
||||
createdGroup?: Partial<BetaGroup>;
|
||||
}): App {
|
||||
return {
|
||||
id: '1234567890',
|
||||
context: {},
|
||||
getBetaGroupsAsync: jest.fn().mockResolvedValue(groups),
|
||||
createBetaGroupAsync: jest.fn().mockResolvedValue(createdGroup),
|
||||
} as unknown as App;
|
||||
}
|
||||
|
||||
function mockGroup({
|
||||
hasAccessToAllBuilds,
|
||||
}: {
|
||||
hasAccessToAllBuilds: boolean;
|
||||
}): Partial<BetaGroup> {
|
||||
return {
|
||||
id: 'group-id',
|
||||
context: {} as BetaGroup['context'],
|
||||
attributes: {
|
||||
name: 'Team (Expo)',
|
||||
isInternalGroup: true,
|
||||
hasAccessToAllBuilds,
|
||||
betaTesters: [],
|
||||
} as unknown as BetaGroup['attributes'],
|
||||
createBulkBetaTesterAssignmentsAsync: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(confirmAsync).mockReset();
|
||||
jest.mocked(User.getAsync).mockReset().mockResolvedValue([]);
|
||||
jest.mocked(BetaGroup.deleteAsync).mockReset();
|
||||
});
|
||||
|
||||
describe(ensureTestFlightGroupExistsAsync, () => {
|
||||
it('skips setup when the app already has beta groups', async () => {
|
||||
const app = mockApp({ groups: [mockGroup({ hasAccessToAllBuilds: true })] });
|
||||
|
||||
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: true });
|
||||
|
||||
expect(app.createBetaGroupAsync).not.toHaveBeenCalled();
|
||||
expect(User.getAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a group and adds admins without prompting in non-interactive mode', async () => {
|
||||
const app = mockApp({
|
||||
groups: [],
|
||||
createdGroup: mockGroup({ hasAccessToAllBuilds: true }),
|
||||
});
|
||||
|
||||
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: true });
|
||||
|
||||
expect(app.createBetaGroupAsync).toHaveBeenCalledWith({
|
||||
name: 'Team (Expo)',
|
||||
isInternalGroup: true,
|
||||
hasAccessToAllBuilds: true,
|
||||
});
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not prompt or delete the group in non-interactive mode when it lacks access to all builds', async () => {
|
||||
const app = mockApp({
|
||||
groups: [],
|
||||
createdGroup: mockGroup({ hasAccessToAllBuilds: false }),
|
||||
});
|
||||
|
||||
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: true });
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(BetaGroup.deleteAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts to regenerate the group in interactive mode when it lacks access to all builds', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
const app = mockApp({
|
||||
groups: [],
|
||||
createdGroup: mockGroup({ hasAccessToAllBuilds: false }),
|
||||
});
|
||||
|
||||
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: false });
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalled();
|
||||
expect(BetaGroup.deleteAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,10 @@ const AUTO_GROUP_NAME = 'Team (Expo)';
|
||||
* Ensure a TestFlight internal group with access to all builds exists for the app and has all admin users invited to it.
|
||||
* This allows users to instantly access their builds from TestFlight after it finishes processing.
|
||||
*/
|
||||
export async function ensureTestFlightGroupExistsAsync(app: App): Promise<void> {
|
||||
if (process.env.EAS_NO_AUTO_TESTFLIGHT_SETUP) {
|
||||
Log.debug('EAS_NO_AUTO_TESTFLIGHT_SETUP is set, skipping TestFlight setup');
|
||||
return;
|
||||
}
|
||||
|
||||
export async function ensureTestFlightGroupExistsAsync(
|
||||
app: App,
|
||||
{ nonInteractive = false }: { nonInteractive?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const groups = await app.getBetaGroupsAsync({
|
||||
query: {
|
||||
includes: ['betaTesters'],
|
||||
@@ -33,19 +31,22 @@ export async function ensureTestFlightGroupExistsAsync(app: App): Promise<void>
|
||||
const group = await ensureInternalGroupAsync({
|
||||
app,
|
||||
groups,
|
||||
nonInteractive,
|
||||
});
|
||||
const users = await User.getAsync(app.context);
|
||||
const admins = users.filter(user => user.attributes.roles?.includes(UserRole.ADMIN));
|
||||
|
||||
await addAllUsersToInternalGroupAsync(group, admins);
|
||||
await addAllUsersToInternalGroupAsync(group, admins, app);
|
||||
}
|
||||
|
||||
async function ensureInternalGroupAsync({
|
||||
groups,
|
||||
app,
|
||||
nonInteractive,
|
||||
}: {
|
||||
groups: BetaGroup[];
|
||||
app: App;
|
||||
nonInteractive: boolean;
|
||||
}): Promise<BetaGroup> {
|
||||
let betaGroup = groups.find(group => group.attributes.name === AUTO_GROUP_NAME);
|
||||
if (!betaGroup) {
|
||||
@@ -88,6 +89,13 @@ async function ensureInternalGroupAsync({
|
||||
|
||||
// `hasAccessToAllBuilds` is a newer feature that allows the group to automatically have access to all builds. This cannot be patched so we need to recreate the group.
|
||||
if (!betaGroup.attributes.hasAccessToAllBuilds) {
|
||||
if (nonInteractive) {
|
||||
// Deleting a group is destructive, so it needs explicit confirmation.
|
||||
Log.warn(
|
||||
`TestFlight group "${AUTO_GROUP_NAME}" does not have automatic access to new builds. Re-run in interactive mode to regenerate it, or recreate it in App Store Connect.`
|
||||
);
|
||||
return betaGroup;
|
||||
}
|
||||
if (
|
||||
await confirmAsync({
|
||||
message: 'Regenerate internal TestFlight group to allow automatic access to all builds?',
|
||||
@@ -101,6 +109,7 @@ async function ensureInternalGroupAsync({
|
||||
includes: ['betaTesters'],
|
||||
},
|
||||
}),
|
||||
nonInteractive,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -108,7 +117,11 @@ async function ensureInternalGroupAsync({
|
||||
return betaGroup;
|
||||
}
|
||||
|
||||
async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]): Promise<void> {
|
||||
async function addAllUsersToInternalGroupAsync(
|
||||
group: BetaGroup,
|
||||
users: User[],
|
||||
app: App
|
||||
): Promise<void> {
|
||||
let emails = users
|
||||
.filter(user => user.attributes.email)
|
||||
.map(user => ({
|
||||
@@ -162,7 +175,7 @@ async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]):
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
const groupUrl = await getTestFlightGroupUrlAsync(group);
|
||||
const groupUrl = await getTestFlightGroupUrlAsync(group, app);
|
||||
|
||||
Log.error(
|
||||
`Unable to add all admins to TestFlight group "${
|
||||
@@ -181,12 +194,12 @@ async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]):
|
||||
}
|
||||
}
|
||||
|
||||
async function getTestFlightGroupUrlAsync(group: BetaGroup): Promise<string | null> {
|
||||
async function getTestFlightGroupUrlAsync(group: BetaGroup, app: App): Promise<string | null> {
|
||||
if (group.context.providerId) {
|
||||
try {
|
||||
const session = await Session.getSessionForProviderIdAsync(group.context.providerId);
|
||||
|
||||
return `https://appstoreconnect.apple.com/teams/${session.provider.publicProviderId}/apps/6741088859/testflight/groups/${group.id}`;
|
||||
return `https://appstoreconnect.apple.com/teams/${session.provider.publicProviderId}/apps/${app.id}/testflight/groups/${group.id}`;
|
||||
} catch (error) {
|
||||
// Avoid crashing if we can't get the session.
|
||||
Log.debug('Failed to get session for provider ID', error);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { createOrModifyExpoConfigAsync } from '../../project/expoConfig';
|
||||
|
||||
const DYNAMIC_CONFIG_MARKER = 'Cannot automatically write to dynamic config';
|
||||
|
||||
export type SdkInstallResult =
|
||||
| { status: 'installed'; dynamicConfigGuidance?: string }
|
||||
| { status: 'failed' };
|
||||
|
||||
export function getSpawnErrorOutput(error: unknown): string {
|
||||
const { stdout, stderr } = (error ?? {}) as { stdout?: string; stderr?: string };
|
||||
return `${stdout ?? ''}${stderr ?? ''}`;
|
||||
}
|
||||
|
||||
export function extractDynamicConfigGuidance(output: string): string | null {
|
||||
const index = output.indexOf(DYNAMIC_CONFIG_MARKER);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
return output.slice(index).trim();
|
||||
}
|
||||
|
||||
// These point eas-cli at a local or staging server; SDK packages always come from production.
|
||||
export function envForExpoInstall(): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env };
|
||||
delete env.EXPO_LOCAL;
|
||||
delete env.EXPO_STAGING;
|
||||
delete env.EXPO_UNIVERSE_DIR;
|
||||
return env;
|
||||
}
|
||||
|
||||
export async function installSdkPackagesAsync(
|
||||
projectDir: string,
|
||||
{ packages, label, jsonFlag }: { packages: string[]; label: string; jsonFlag: boolean }
|
||||
): Promise<SdkInstallResult> {
|
||||
const spinner = jsonFlag ? null : ora(`Installing the ${label} SDK packages`).start();
|
||||
try {
|
||||
await spawnAsync('npx', ['expo', 'install', ...packages], {
|
||||
cwd: projectDir,
|
||||
env: envForExpoInstall(),
|
||||
});
|
||||
spinner?.succeed(`Installed the ${label} SDK packages`);
|
||||
return { status: 'installed' };
|
||||
} catch (error) {
|
||||
const output = getSpawnErrorOutput(error);
|
||||
Log.debug(output || error);
|
||||
const dynamicConfigGuidance = extractDynamicConfigGuidance(output);
|
||||
if (dynamicConfigGuidance) {
|
||||
spinner?.warn(
|
||||
`Installed the ${label} SDK packages — add the config plugin to your app config`
|
||||
);
|
||||
return { status: 'installed', dynamicConfigGuidance };
|
||||
}
|
||||
spinner?.warn(`Could not install the ${label} SDK packages`);
|
||||
return { status: 'failed' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function addConfigPluginAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig,
|
||||
{ plugin }: { plugin: string }
|
||||
): Promise<string | null> {
|
||||
const plugins = exp.plugins ?? [];
|
||||
const alreadyAdded = plugins.some(p => (Array.isArray(p) ? p[0] : p) === plugin);
|
||||
if (alreadyAdded) {
|
||||
Log.withTick(`Config plugin ${chalk.bold(plugin)} is already configured`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const modification = await createOrModifyExpoConfigAsync(
|
||||
projectDir,
|
||||
{ plugins: [...plugins, plugin] },
|
||||
{ skipSDKVersionRequirement: true }
|
||||
);
|
||||
if (modification.type === 'success') {
|
||||
Log.withTick(`Added the ${chalk.bold(plugin)} config plugin`);
|
||||
return null;
|
||||
}
|
||||
if (modification.type === 'warn') {
|
||||
return `${modification.message} Add ${JSON.stringify(plugin)} to the "plugins" array in your app config.`;
|
||||
}
|
||||
return `Add ${JSON.stringify(plugin)} to the "plugins" array in your app config.`;
|
||||
}
|
||||
|
||||
export async function setupSdkAndConfigAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig,
|
||||
{
|
||||
packages,
|
||||
plugin,
|
||||
label,
|
||||
jsonFlag,
|
||||
}: { packages: string[]; plugin: string; label: string; jsonFlag: boolean }
|
||||
): Promise<string[]> {
|
||||
const installResult = await installSdkPackagesAsync(projectDir, { packages, label, jsonFlag });
|
||||
// Adding the plugin for a package that isn't installed leaves the app config unresolvable.
|
||||
if (installResult.status === 'failed') {
|
||||
return [
|
||||
`The ${label} SDK packages didn't install, so the ${plugin} config plugin was not added. Run npx expo install ${packages.join(' ')} from your project directory, then re-run this command.`,
|
||||
];
|
||||
}
|
||||
if (installResult.dynamicConfigGuidance) {
|
||||
return [installResult.dynamicConfigGuidance];
|
||||
}
|
||||
const pluginManualStep = await addConfigPluginAsync(projectDir, exp, { plugin });
|
||||
return pluginManualStep ? [pluginManualStep] : [];
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../../graphql/generated';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import {
|
||||
EAS_SUPABASE_ENVIRONMENTS,
|
||||
EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME,
|
||||
EAS_SUPABASE_URL_ENV_VAR_NAME,
|
||||
confirmOverwriteForAdditionalProjectAsync,
|
||||
createSupabaseEnvVars,
|
||||
supabaseEnvironmentCancelMessage,
|
||||
supabaseMoveConfirmMessage,
|
||||
} from '../env';
|
||||
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../log');
|
||||
|
||||
describe('createSupabaseEnvVars and constants', () => {
|
||||
it('returns public URL and key vars', () => {
|
||||
expect(createSupabaseEnvVars('https://example.supabase.co', 'pk')).toEqual([
|
||||
{
|
||||
name: EAS_SUPABASE_URL_ENV_VAR_NAME,
|
||||
value: 'https://example.supabase.co',
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
},
|
||||
{
|
||||
name: EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME,
|
||||
value: 'pk',
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists production, preview, and development in that order', () => {
|
||||
expect(EAS_SUPABASE_ENVIRONMENTS).toEqual([
|
||||
DefaultEnvironment.Production,
|
||||
DefaultEnvironment.Preview,
|
||||
DefaultEnvironment.Development,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Supabase prompt messages', () => {
|
||||
it('names the additional project when moving a variable', () => {
|
||||
expect(supabaseMoveConfirmMessage(EAS_SUPABASE_URL_ENV_VAR_NAME, 'preview')).toBe(
|
||||
`Move ${EAS_SUPABASE_URL_ENV_VAR_NAME} for preview to the additional Supabase project?`
|
||||
);
|
||||
});
|
||||
|
||||
it('lists known environments when provisioning is canceled', () => {
|
||||
expect(supabaseEnvironmentCancelMessage('production, preview')).toContain(
|
||||
'No additional Supabase project was provisioned'
|
||||
);
|
||||
expect(supabaseEnvironmentCancelMessage('production, preview')).toContain(
|
||||
'known: production, preview'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmOverwriteForAdditionalProjectAsync', () => {
|
||||
const client = {} as ExpoGraphqlClient;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('forces overwrite immediately with overwrite', async () => {
|
||||
await expect(
|
||||
confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], true, true)
|
||||
).resolves.toEqual({ forceOverwrite: true });
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not force overwrite when there is no overlap', async () => {
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]);
|
||||
await expect(
|
||||
confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], true, false)
|
||||
).resolves.toEqual({ forceOverwrite: false });
|
||||
});
|
||||
|
||||
it('treats null environments as no overlap', async () => {
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
|
||||
{
|
||||
id: 'v1',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: null,
|
||||
} as never,
|
||||
]);
|
||||
await expect(
|
||||
confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], true, false)
|
||||
).resolves.toEqual({ forceOverwrite: false });
|
||||
});
|
||||
|
||||
it('throws in non-interactive mode when overlap exists', async () => {
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
|
||||
{
|
||||
id: 'v1',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: ['preview'],
|
||||
} as never,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], true, false)
|
||||
).rejects.toThrow(/Re-run with --overwrite/);
|
||||
});
|
||||
|
||||
it('forces overwrite after interactive confirmation', async () => {
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
|
||||
{
|
||||
id: 'v1',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: ['preview'],
|
||||
} as never,
|
||||
]);
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], false, false)
|
||||
).resolves.toEqual({ forceOverwrite: true });
|
||||
});
|
||||
|
||||
it('throws when interactive confirmation is declined', async () => {
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
|
||||
{
|
||||
id: 'v1',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: ['preview'],
|
||||
} as never,
|
||||
]);
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], false, false)
|
||||
).rejects.toThrow(/Canceled/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,550 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import openBrowserAsync from 'better-opn';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { SupabaseMutation } from '../../../graphql/mutations/SupabaseMutation';
|
||||
import { SupabaseQuery } from '../../../graphql/queries/SupabaseQuery';
|
||||
import {
|
||||
SupabaseConnectionData,
|
||||
SupabaseOrganizationData,
|
||||
SupabaseProjectData,
|
||||
} from '../../../graphql/types/SupabaseConnection';
|
||||
import Log from '../../../log';
|
||||
import { selectAsync } from '../../../prompts';
|
||||
import {
|
||||
BackgroundJobReceiptPollError,
|
||||
BackgroundJobReceiptPollErrorType,
|
||||
pollForBackgroundJobReceiptAsync,
|
||||
} from '../../../utils/pollForBackgroundJobReceiptAsync';
|
||||
import {
|
||||
additionalProvisionFailureHint,
|
||||
authorizeViaBrowserAsync,
|
||||
loadOrganizationsBestEffortAsync,
|
||||
pollForConnectionAsync,
|
||||
pollProvisionReceiptAsync,
|
||||
primaryProvisionFailureHint,
|
||||
projectNameSuffixForEnvironments,
|
||||
resolveOrganizationAsync,
|
||||
resolvePublishableKeyAsync,
|
||||
resolveRegionAsync,
|
||||
toProvisionPollError,
|
||||
} from '../provision';
|
||||
|
||||
jest.mock('better-opn');
|
||||
jest.mock('../../../graphql/mutations/SupabaseMutation');
|
||||
jest.mock('../../../graphql/queries/SupabaseQuery');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/pollForBackgroundJobReceiptAsync', () => ({
|
||||
...jest.requireActual('../../../utils/pollForBackgroundJobReceiptAsync'),
|
||||
pollForBackgroundJobReceiptAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(),
|
||||
}));
|
||||
|
||||
import { ora } from '../../../ora';
|
||||
|
||||
function mockOraSpinner(): {
|
||||
start: jest.Mock;
|
||||
succeed: jest.Mock;
|
||||
fail: jest.Mock;
|
||||
stop: jest.Mock;
|
||||
text: string;
|
||||
} {
|
||||
const spinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
stop: jest.fn().mockReturnThis(),
|
||||
text: '',
|
||||
};
|
||||
jest.mocked(ora).mockReturnValue(spinner as never);
|
||||
return spinner;
|
||||
}
|
||||
|
||||
function userError(message: string): CombinedError {
|
||||
return new CombinedError({
|
||||
graphQLErrors: [new GraphQLError(message, { extensions: { errorType: 'USER' } })],
|
||||
});
|
||||
}
|
||||
|
||||
function systemError(message: string): CombinedError {
|
||||
return new CombinedError({
|
||||
graphQLErrors: [new GraphQLError(message, { extensions: { errorType: 'SYSTEM' } })],
|
||||
});
|
||||
}
|
||||
|
||||
const connection: SupabaseConnectionData = {
|
||||
id: 'conn-1',
|
||||
supabaseOrganizationSlug: 'org-slug',
|
||||
supabaseOrganizationName: 'Org',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const project: SupabaseProjectData = {
|
||||
id: 'project-1',
|
||||
supabaseProjectRef: 'abcdefghijklmnop',
|
||||
supabaseProjectName: 'Demo',
|
||||
supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co',
|
||||
supabaseRegion: 'us-east-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('provision hints and poll errors', () => {
|
||||
it('builds failure hints', () => {
|
||||
expect(primaryProvisionFailureHint()).toContain('--link');
|
||||
const additional = additionalProvisionFailureHint(['preview']);
|
||||
expect(additional).toContain('--environment preview');
|
||||
expect(additional).not.toContain('--link');
|
||||
expect(additional).toContain('EXPO_PUBLIC_SUPABASE_URL');
|
||||
});
|
||||
|
||||
it('toProvisionPollError preserves non-poll errors', () => {
|
||||
const err = new Error('boom');
|
||||
expect(toProvisionPollError(err, { hint: 'hint' })).toBe(err);
|
||||
expect(toProvisionPollError('string-err', { hint: '' }).message).toBe('string-err');
|
||||
});
|
||||
|
||||
it('toProvisionPollError wraps failed job messages with hints', () => {
|
||||
const pollError = new BackgroundJobReceiptPollError({
|
||||
errorType: BackgroundJobReceiptPollErrorType.JOB_FAILED_NO_WILL_RETRY,
|
||||
receiptErrorMessage: 'quota exceeded',
|
||||
});
|
||||
expect(toProvisionPollError(pollError, { hint: 'try link' }).message).toContain(
|
||||
'quota exceeded'
|
||||
);
|
||||
expect(toProvisionPollError(pollError, { hint: 'try link' }).message).toContain('try link');
|
||||
|
||||
const noMessage = new BackgroundJobReceiptPollError({
|
||||
errorType: BackgroundJobReceiptPollErrorType.JOB_FAILED_NO_WILL_RETRY,
|
||||
receiptErrorMessage: null,
|
||||
});
|
||||
expect(toProvisionPollError(noMessage, { hint: '' }).message).toContain(
|
||||
'Background job failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('toProvisionPollError wraps timeout/null receipt', () => {
|
||||
const timeout = new BackgroundJobReceiptPollError({
|
||||
errorType: BackgroundJobReceiptPollErrorType.TIMEOUT,
|
||||
});
|
||||
expect(toProvisionPollError(timeout, { hint: '' }).message).toContain('Timed out');
|
||||
|
||||
const nullReceipt = new BackgroundJobReceiptPollError({
|
||||
errorType: BackgroundJobReceiptPollErrorType.NULL_RECEIPT,
|
||||
});
|
||||
expect(toProvisionPollError(nullReceipt, { hint: 'hint' }).message).toContain('hint');
|
||||
});
|
||||
|
||||
it('toProvisionPollError returns unrecognized poll errors unchanged', () => {
|
||||
const pollError = new BackgroundJobReceiptPollError({
|
||||
errorType: BackgroundJobReceiptPollErrorType.TIMEOUT,
|
||||
});
|
||||
(pollError as { errorData: { errorType: number } }).errorData = { errorType: 999 };
|
||||
expect(toProvisionPollError(pollError, { hint: 'hint' })).toBe(pollError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pollProvisionReceiptAsync', () => {
|
||||
const client = {} as ExpoGraphqlClient;
|
||||
let spinner: ReturnType<typeof mockOraSpinner>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
spinner = mockOraSpinner();
|
||||
});
|
||||
|
||||
it('returns the finalized receipt and stops its own spinner', async () => {
|
||||
const receipt = { id: 'r1' } as never;
|
||||
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue(receipt);
|
||||
|
||||
const result = await pollProvisionReceiptAsync(client, receipt, {
|
||||
startMessage: 'start',
|
||||
waitingMessage: 'wait',
|
||||
failureMessage: 'fail',
|
||||
failureHint: 'hint',
|
||||
});
|
||||
expect(result).toBe(receipt);
|
||||
expect(spinner.stop).toHaveBeenCalled();
|
||||
expect(spinner.succeed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails spinner when poll returns null', async () => {
|
||||
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue(null);
|
||||
await expect(
|
||||
pollProvisionReceiptAsync(client, { id: 'r1' } as never, {
|
||||
startMessage: 'start',
|
||||
waitingMessage: 'wait',
|
||||
failureMessage: 'fail',
|
||||
failureHint: 'hint',
|
||||
})
|
||||
).rejects.toThrow(/without a receipt/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorizeViaBrowserAsync / loadOrganizationsBestEffortAsync / pollForConnectionAsync', () => {
|
||||
const client = {} as ExpoGraphqlClient;
|
||||
const account = { id: 'acct-1', name: 'acct' };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
mockOraSpinner();
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('authorizeViaBrowserAsync rejects non-interactive mode', async () => {
|
||||
await expect(authorizeViaBrowserAsync(client, account, true)).rejects.toThrow(
|
||||
/non-interactive/
|
||||
);
|
||||
});
|
||||
|
||||
it('authorizeViaBrowserAsync polls until connected', async () => {
|
||||
jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({
|
||||
url: 'https://oauth.example',
|
||||
});
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue(true as never);
|
||||
jest
|
||||
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(connection);
|
||||
jest
|
||||
.mocked(SupabaseMutation.listSupabaseOrganizationsAsync)
|
||||
.mockResolvedValue([{ id: '1', slug: 'org-slug', name: 'Org' }]);
|
||||
|
||||
const promise = authorizeViaBrowserAsync(client, account, false);
|
||||
await jest.advanceTimersByTimeAsync(2_000);
|
||||
await expect(promise).resolves.toEqual(connection);
|
||||
});
|
||||
|
||||
it('authorizeViaBrowserAsync shows URL when browser open fails', async () => {
|
||||
jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({
|
||||
url: 'https://oauth.example',
|
||||
});
|
||||
jest.mocked(openBrowserAsync).mockRejectedValue(new Error('no browser'));
|
||||
jest.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).mockResolvedValue(connection);
|
||||
jest.mocked(SupabaseMutation.listSupabaseOrganizationsAsync).mockResolvedValue([]);
|
||||
|
||||
await expect(authorizeViaBrowserAsync(client, account, false)).resolves.toEqual(connection);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('authorize at this link'));
|
||||
});
|
||||
|
||||
it('authorizeViaBrowserAsync succeeds when organization listing fails', async () => {
|
||||
jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({
|
||||
url: 'https://oauth.example',
|
||||
});
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue(true as never);
|
||||
jest.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).mockResolvedValue(connection);
|
||||
jest
|
||||
.mocked(SupabaseMutation.listSupabaseOrganizationsAsync)
|
||||
.mockRejectedValue(new Error('org list unavailable'));
|
||||
|
||||
const spinner = mockOraSpinner();
|
||||
await expect(authorizeViaBrowserAsync(client, account, false)).resolves.toEqual(connection);
|
||||
expect(spinner.succeed).toHaveBeenCalledWith(expect.stringContaining('org-slug'));
|
||||
});
|
||||
|
||||
it('authorizeViaBrowserAsync fails spinner on a non-retriable poll error', async () => {
|
||||
jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({
|
||||
url: 'https://oauth.example',
|
||||
});
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue(true as never);
|
||||
jest
|
||||
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
|
||||
.mockRejectedValue(userError('not authorized'));
|
||||
|
||||
const spinner = mockOraSpinner();
|
||||
await expect(authorizeViaBrowserAsync(client, account, false)).rejects.toThrow(
|
||||
/not authorized/
|
||||
);
|
||||
expect(spinner.fail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('authorizeViaBrowserAsync fails spinner when the authorization never arrives', async () => {
|
||||
jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({
|
||||
url: 'https://oauth.example',
|
||||
});
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue(true as never);
|
||||
jest.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).mockResolvedValue(null);
|
||||
|
||||
const spinner = mockOraSpinner();
|
||||
const promise = authorizeViaBrowserAsync(client, account, false);
|
||||
promise.catch(() => undefined);
|
||||
await jest.advanceTimersByTimeAsync(15 * 60 * 1_000 + 2_000);
|
||||
await expect(promise).rejects.toThrow(/Timed out waiting for the Supabase connection/);
|
||||
expect(spinner.fail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loadOrganizationsBestEffortAsync returns null on failure', async () => {
|
||||
jest
|
||||
.mocked(SupabaseMutation.listSupabaseOrganizationsAsync)
|
||||
.mockRejectedValue(new Error('nope'));
|
||||
await expect(loadOrganizationsBestEffortAsync(client, 'acct-1')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('pollForConnectionAsync retries after retriable query errors', async () => {
|
||||
jest
|
||||
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
|
||||
.mockRejectedValueOnce(new CombinedError({ networkError: new Error('offline') }))
|
||||
.mockResolvedValueOnce(connection);
|
||||
|
||||
const promise = pollForConnectionAsync(client, 'acct-1');
|
||||
await jest.advanceTimersByTimeAsync(2_000);
|
||||
await expect(promise).resolves.toEqual(connection);
|
||||
expect(Log.debug).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pollForConnectionAsync stops on a non-retriable query error', async () => {
|
||||
jest
|
||||
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
|
||||
.mockRejectedValue(userError('not authorized'));
|
||||
|
||||
await expect(pollForConnectionAsync(client, 'acct-1')).rejects.toThrow(/not authorized/);
|
||||
expect(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('pollForConnectionAsync keeps waiting through server-side errors', async () => {
|
||||
jest
|
||||
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
|
||||
.mockRejectedValueOnce(systemError('boom'))
|
||||
.mockResolvedValueOnce(connection);
|
||||
|
||||
const promise = pollForConnectionAsync(client, 'acct-1');
|
||||
await jest.advanceTimersByTimeAsync(2_000);
|
||||
await expect(promise).resolves.toEqual(connection);
|
||||
});
|
||||
|
||||
it('pollForConnectionAsync surfaces an error that keeps repeating', async () => {
|
||||
jest
|
||||
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
|
||||
.mockRejectedValue(new CombinedError({ networkError: new Error('offline') }));
|
||||
|
||||
const promise = pollForConnectionAsync(client, 'acct-1');
|
||||
promise.catch(() => undefined);
|
||||
await jest.advanceTimersByTimeAsync(10 * 2_000);
|
||||
await expect(promise).rejects.toThrow(/offline/);
|
||||
expect(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
it('pollForConnectionAsync forgives errors that stop repeating', async () => {
|
||||
const query = jest.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync);
|
||||
for (let i = 0; i < 9; i++) {
|
||||
query.mockRejectedValueOnce(new CombinedError({ networkError: new Error('offline') }));
|
||||
}
|
||||
query.mockResolvedValueOnce(null);
|
||||
for (let i = 0; i < 9; i++) {
|
||||
query.mockRejectedValueOnce(new CombinedError({ networkError: new Error('offline') }));
|
||||
}
|
||||
query.mockResolvedValueOnce(connection);
|
||||
|
||||
const promise = pollForConnectionAsync(client, 'acct-1');
|
||||
await jest.advanceTimersByTimeAsync(20 * 2_000);
|
||||
await expect(promise).resolves.toEqual(connection);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePublishableKeyAsync', () => {
|
||||
const client = {} as ExpoGraphqlClient;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
mockOraSpinner();
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns key once available', async () => {
|
||||
jest
|
||||
.mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce('pk_live');
|
||||
|
||||
const promise = resolvePublishableKeyAsync(client, 'app-1', project);
|
||||
await jest.advanceTimersByTimeAsync(3_000);
|
||||
await expect(promise).resolves.toBe('pk_live');
|
||||
});
|
||||
|
||||
it('stops on a non-retriable readiness error', async () => {
|
||||
jest
|
||||
.mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync)
|
||||
.mockRejectedValue(userError('revoked'));
|
||||
|
||||
await expect(resolvePublishableKeyAsync(client, 'app-1', project)).rejects.toThrow(/revoked/);
|
||||
expect(SupabaseMutation.fetchSupabasePublishableKeyAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps waiting through retriable readiness errors', async () => {
|
||||
jest
|
||||
.mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync)
|
||||
.mockRejectedValueOnce(new CombinedError({ networkError: new Error('offline') }))
|
||||
.mockResolvedValueOnce('pk_live');
|
||||
|
||||
const promise = resolvePublishableKeyAsync(client, 'app-1', project);
|
||||
await jest.advanceTimersByTimeAsync(3_000);
|
||||
await expect(promise).resolves.toBe('pk_live');
|
||||
});
|
||||
|
||||
it('gives up once a server fault keeps repeating', async () => {
|
||||
jest
|
||||
.mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync)
|
||||
.mockRejectedValue(systemError('upstream 502'));
|
||||
|
||||
const promise = resolvePublishableKeyAsync(client, 'app-1', project);
|
||||
promise.catch(() => undefined);
|
||||
await jest.advanceTimersByTimeAsync(10 * 3_000);
|
||||
await expect(promise).rejects.toThrow(/upstream 502/);
|
||||
expect(SupabaseMutation.fetchSupabasePublishableKeyAsync).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
it('keeps waiting through a server fault that resolves', async () => {
|
||||
jest
|
||||
.mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync)
|
||||
.mockRejectedValueOnce(systemError('upstream 502'))
|
||||
.mockResolvedValueOnce('pk_live');
|
||||
|
||||
const promise = resolvePublishableKeyAsync(client, 'app-1', project);
|
||||
await jest.advanceTimersByTimeAsync(3_000);
|
||||
await expect(promise).resolves.toBe('pk_live');
|
||||
});
|
||||
|
||||
it('times out when key never becomes ready', async () => {
|
||||
jest.mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync).mockResolvedValue(null);
|
||||
|
||||
const promise = resolvePublishableKeyAsync(client, 'app-1', project);
|
||||
promise.catch(() => undefined);
|
||||
await jest.advanceTimersByTimeAsync(5 * 60 * 1_000 + 3_000);
|
||||
await expect(promise).rejects.toThrow(/still provisioning/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRegionAsync', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('returns flag value when provided', async () => {
|
||||
await expect(resolveRegionAsync('emea', true)).resolves.toBe('emea');
|
||||
});
|
||||
|
||||
it('rejects a whitespace-only --region value', async () => {
|
||||
await expect(resolveRegionAsync(' ', true)).rejects.toThrow(/Pass a Supabase region/);
|
||||
});
|
||||
|
||||
it('throws in non-interactive mode without a flag', async () => {
|
||||
await expect(resolveRegionAsync(undefined, true)).rejects.toThrow(/--region/);
|
||||
});
|
||||
|
||||
it('prompts interactively when unset', async () => {
|
||||
jest.mocked(selectAsync).mockResolvedValue('apac');
|
||||
await expect(resolveRegionAsync(undefined, false)).resolves.toBe('apac');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOrganizationAsync', () => {
|
||||
const client = {} as ExpoGraphqlClient;
|
||||
const organizations: SupabaseOrganizationData[] = [
|
||||
{ id: '1', slug: 'org-slug', name: 'Org' },
|
||||
{ id: '2', slug: 'other', name: 'Other' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('returns connection when flag matches current org', async () => {
|
||||
await expect(
|
||||
resolveOrganizationAsync(client, 'acct-1', connection, 'org-slug', true, organizations)
|
||||
).resolves.toEqual(connection);
|
||||
});
|
||||
|
||||
it('sets organization when flag is a different connected org', async () => {
|
||||
const updated = { ...connection, supabaseOrganizationSlug: 'other' };
|
||||
jest.mocked(SupabaseMutation.setSupabaseConnectionOrganizationAsync).mockResolvedValue(updated);
|
||||
|
||||
await expect(
|
||||
resolveOrganizationAsync(client, 'acct-1', connection, 'other', true, organizations)
|
||||
).resolves.toEqual(updated);
|
||||
});
|
||||
|
||||
it('throws when flag org is unknown', async () => {
|
||||
await expect(
|
||||
resolveOrganizationAsync(client, 'acct-1', connection, 'missing', true, organizations)
|
||||
).rejects.toThrow(/isn't one of your connected organizations/);
|
||||
});
|
||||
|
||||
it('returns connection in non-interactive mode without flag', async () => {
|
||||
await expect(
|
||||
resolveOrganizationAsync(client, 'acct-1', connection, undefined, true, organizations)
|
||||
).resolves.toEqual(connection);
|
||||
});
|
||||
|
||||
it('returns connection when only one org exists', async () => {
|
||||
await expect(
|
||||
resolveOrganizationAsync(client, 'acct-1', connection, undefined, false, [organizations[0]])
|
||||
).resolves.toEqual(connection);
|
||||
});
|
||||
|
||||
it('prompts and updates when a different org is chosen', async () => {
|
||||
const updated = { ...connection, supabaseOrganizationSlug: 'other' };
|
||||
jest.mocked(selectAsync).mockResolvedValue('other');
|
||||
jest.mocked(SupabaseMutation.setSupabaseConnectionOrganizationAsync).mockResolvedValue(updated);
|
||||
|
||||
await expect(
|
||||
resolveOrganizationAsync(client, 'acct-1', connection, undefined, false, organizations)
|
||||
).resolves.toEqual(updated);
|
||||
});
|
||||
|
||||
it('prompts and keeps current org when reselected', async () => {
|
||||
jest.mocked(selectAsync).mockResolvedValue('org-slug');
|
||||
await expect(
|
||||
resolveOrganizationAsync(client, 'acct-1', connection, undefined, false, organizations)
|
||||
).resolves.toEqual(connection);
|
||||
expect(SupabaseMutation.setSupabaseConnectionOrganizationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads organizations when not preloaded', async () => {
|
||||
jest.mocked(SupabaseMutation.listSupabaseOrganizationsAsync).mockResolvedValue(organizations);
|
||||
const updated = { ...connection, supabaseOrganizationSlug: 'other' };
|
||||
jest.mocked(SupabaseMutation.setSupabaseConnectionOrganizationAsync).mockResolvedValue(updated);
|
||||
|
||||
await expect(
|
||||
resolveOrganizationAsync(client, 'acct-1', connection, 'other', true, null)
|
||||
).resolves.toEqual(updated);
|
||||
expect(SupabaseMutation.listSupabaseOrganizationsAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads organizations interactively when not preloaded', async () => {
|
||||
jest.mocked(SupabaseMutation.listSupabaseOrganizationsAsync).mockResolvedValue(organizations);
|
||||
jest.mocked(selectAsync).mockResolvedValue('org-slug');
|
||||
|
||||
await expect(
|
||||
resolveOrganizationAsync(client, 'acct-1', connection, undefined, false, null)
|
||||
).resolves.toEqual(connection);
|
||||
expect(SupabaseMutation.listSupabaseOrganizationsAsync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectNameSuffixForEnvironments', () => {
|
||||
it('sorts, joins, and truncates environment names', () => {
|
||||
expect(projectNameSuffixForEnvironments(['preview', 'development'])).toBe(
|
||||
'development-preview'
|
||||
);
|
||||
expect(
|
||||
projectNameSuffixForEnvironments(['a'.repeat(40), 'b'.repeat(40)]).length
|
||||
).toBeLessThanOrEqual(32);
|
||||
});
|
||||
|
||||
it('replaces characters outside [a-zA-Z0-9._-] with a dash', () => {
|
||||
expect(projectNameSuffixForEnvironments(['a/b', 'c d'])).toBe('a-b-c-d');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { DefaultEnvironment } from '../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EnvVar, loadProjectScopedEnvVarsAsync } from '../../environments/variables';
|
||||
import { EnvironmentVariableVisibility } from '../../graphql/generated';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
|
||||
export const EAS_SUPABASE_URL_ENV_VAR_NAME = 'EXPO_PUBLIC_SUPABASE_URL';
|
||||
export const EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME = 'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY';
|
||||
|
||||
/** Label passed into shared env helpers for prompts and log lines. */
|
||||
export const SUPABASE_ENV_LABEL = 'Supabase';
|
||||
|
||||
// Production-first, matching the PostHog and Convex integrations.
|
||||
export const EAS_SUPABASE_ENVIRONMENTS = [
|
||||
DefaultEnvironment.Production,
|
||||
DefaultEnvironment.Preview,
|
||||
DefaultEnvironment.Development,
|
||||
];
|
||||
|
||||
export function supabaseMoveConfirmMessage(
|
||||
variableName: string,
|
||||
overlappingEnvironments: string
|
||||
): string {
|
||||
return `Move ${variableName} for ${overlappingEnvironments} to the additional Supabase project?`;
|
||||
}
|
||||
|
||||
export function supabaseEnvironmentCancelMessage(knownEnvironments: string): string {
|
||||
return `Canceled. No additional Supabase project was provisioned. Create the environment(s) first, or pass only existing ones (known: ${knownEnvironments}).`;
|
||||
}
|
||||
|
||||
export function createSupabaseEnvVars(url: string, publishableKey: string): EnvVar[] {
|
||||
return [
|
||||
{
|
||||
name: EAS_SUPABASE_URL_ENV_VAR_NAME,
|
||||
value: url,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
},
|
||||
{
|
||||
name: EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME,
|
||||
value: publishableKey,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function confirmOverwriteForAdditionalProjectAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
environments: string[],
|
||||
nonInteractive: boolean,
|
||||
overwrite: boolean
|
||||
): Promise<{ forceOverwrite: boolean }> {
|
||||
if (overwrite) {
|
||||
return { forceOverwrite: true };
|
||||
}
|
||||
const names = [EAS_SUPABASE_URL_ENV_VAR_NAME, EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME];
|
||||
const targetSet = new Set(environments);
|
||||
for (const name of names) {
|
||||
const existingVariables = await loadProjectScopedEnvVarsAsync(graphqlClient, projectId, name);
|
||||
const hasOverlap = existingVariables.some(variable =>
|
||||
(variable.environments ?? []).some(environment => targetSet.has(environment))
|
||||
);
|
||||
if (!hasOverlap) {
|
||||
continue;
|
||||
}
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`EAS already has ${name} for ${environments.join(', ')}. Re-run with --overwrite to replace it before provisioning an additional project.`
|
||||
);
|
||||
}
|
||||
const proceed = await confirmAsync({
|
||||
message: `EAS already has ${name} covering ${environments.join(', ')}. Continue and move those values to the additional Supabase project?`,
|
||||
});
|
||||
if (!proceed) {
|
||||
throw new Error(
|
||||
`Canceled. No additional Supabase project was provisioned. Pass --overwrite to replace ${name} for ${environments.join(', ')}, or leave those environments on the primary project.`
|
||||
);
|
||||
}
|
||||
// One confirm covers both vars; stop asking. Force overwrite so the per-var upsert
|
||||
// doesn't prompt again and can't return false after we already billed the project.
|
||||
return { forceOverwrite: true };
|
||||
}
|
||||
return { forceOverwrite: false };
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import openBrowserAsync from 'better-opn';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
formatSupabaseOrganization,
|
||||
getSupabaseProjectDashboardUrl,
|
||||
} from '../../commandUtils/supabase';
|
||||
import { isPermanentGraphqlError } from '../../graphql/client';
|
||||
import { BackgroundJobReceiptDataFragment } from '../../graphql/generated';
|
||||
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 { ora } from '../../ora';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
import {
|
||||
BackgroundJobReceiptPollError,
|
||||
BackgroundJobReceiptPollErrorType,
|
||||
pollForBackgroundJobReceiptAsync,
|
||||
} from '../../utils/pollForBackgroundJobReceiptAsync';
|
||||
|
||||
// The server holds the pending OAuth row for 15 minutes; match that so we don't time out on an
|
||||
// approval the user completes within the window.
|
||||
const CONNECTION_POLL_INTERVAL_MS = 2_000;
|
||||
const CONNECTION_POLL_TIMEOUT_MS = 15 * 60 * 1_000;
|
||||
const MAX_CONSECUTIVE_CONNECTION_ERRORS = 10;
|
||||
|
||||
// A freshly provisioned project takes a minute or two to become healthy; the publishable key only
|
||||
// resolves once it is.
|
||||
const READINESS_POLL_INTERVAL_MS = 3_000;
|
||||
const READINESS_POLL_TIMEOUT_MS = 5 * 60 * 1_000;
|
||||
// A server fault is worth retrying, but not for the full timeout: once it repeats this many
|
||||
// times it isn't a provisioning delay, and the user shouldn't wait five minutes to hear it.
|
||||
const MAX_CONSECUTIVE_READINESS_ERRORS = 10;
|
||||
|
||||
// Background job create + publishable-key polling can take 5+ minutes; allow 7 min at 1s interval.
|
||||
export const PROVISION_RECEIPT_MAX_CHECKS = 420;
|
||||
export const PROVISION_RECEIPT_MAX_CONSECUTIVE_FETCH_ERRORS = 3;
|
||||
|
||||
export const SUPABASE_REGION_CHOICES = [
|
||||
{ title: 'Americas (US)', value: 'americas' },
|
||||
{ title: 'Europe / Middle East / Africa', value: 'emea' },
|
||||
{ title: 'Asia Pacific', value: 'apac' },
|
||||
];
|
||||
|
||||
export function projectNameSuffixForEnvironments(environments: string[]): string {
|
||||
return [...environments]
|
||||
.sort()
|
||||
.join('-')
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
export function toProvisionPollError(error: unknown, { hint }: { hint: string }): Error {
|
||||
if (!(error instanceof BackgroundJobReceiptPollError)) {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
const trimmedHint = hint.trim();
|
||||
const join = (message: string): string =>
|
||||
trimmedHint ? `${message.trimEnd()}\n\n${trimmedHint}` : message;
|
||||
if (error.errorData.errorType === BackgroundJobReceiptPollErrorType.JOB_FAILED_NO_WILL_RETRY) {
|
||||
return new Error(join(error.errorData.receiptErrorMessage ?? error.message));
|
||||
}
|
||||
if (
|
||||
error.errorData.errorType === BackgroundJobReceiptPollErrorType.TIMEOUT ||
|
||||
error.errorData.errorType === BackgroundJobReceiptPollErrorType.NULL_RECEIPT
|
||||
) {
|
||||
return new Error(
|
||||
join('Timed out or lost contact while waiting for Supabase project provision.')
|
||||
);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Fast-forward guidance when additional (--environment) provision fails permanently. */
|
||||
export function additionalProvisionFailureHint(environments: string[]): string {
|
||||
return [
|
||||
'Free a slot on your Supabase plan (delete, pause, or upgrade a project), then re-run:',
|
||||
` eas integrations:supabase:connect --environment ${environments.join(',')}`,
|
||||
'Or set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY on those EAS environments to an existing Supabase project.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Where to find a project for --link (primary connect path). */
|
||||
export function primaryProvisionFailureHint(): string {
|
||||
return [
|
||||
'If a project already exists in your Supabase dashboard, link it instead of provisioning:',
|
||||
' eas integrations:supabase:connect --link <project-url>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function pollProvisionReceiptAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
receipt: BackgroundJobReceiptDataFragment,
|
||||
{
|
||||
startMessage,
|
||||
waitingMessage,
|
||||
failureMessage,
|
||||
failureHint,
|
||||
}: {
|
||||
startMessage: string;
|
||||
waitingMessage: string;
|
||||
failureMessage: string;
|
||||
failureHint: string;
|
||||
}
|
||||
): Promise<BackgroundJobReceiptDataFragment> {
|
||||
const spinner = ora(startMessage).start();
|
||||
try {
|
||||
spinner.text = waitingMessage;
|
||||
const finalized = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt, {
|
||||
maxChecks: PROVISION_RECEIPT_MAX_CHECKS,
|
||||
maxConsecutiveFetchErrors: PROVISION_RECEIPT_MAX_CONSECUTIVE_FETCH_ERRORS,
|
||||
});
|
||||
if (!finalized) {
|
||||
throw new Error('Supabase project provision finished without a receipt.');
|
||||
}
|
||||
// Stopped, not succeeded: the caller resolves what was provisioned and owns that message.
|
||||
spinner.stop();
|
||||
return finalized;
|
||||
} catch (error) {
|
||||
spinner.fail(failureMessage);
|
||||
throw toProvisionPollError(error, { hint: failureHint });
|
||||
}
|
||||
}
|
||||
|
||||
export async function authorizeViaBrowserAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
account: { id: string; name: string },
|
||||
nonInteractive: boolean
|
||||
): Promise<SupabaseConnectionData> {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Connecting Supabase requires approving access in a browser, which isn't possible in non-interactive mode. Re-run \`eas integrations:supabase:connect\` interactively.`
|
||||
);
|
||||
}
|
||||
|
||||
const { url } = await SupabaseMutation.beginSupabaseOAuthAsync(graphqlClient, {
|
||||
accountId: account.id,
|
||||
});
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`Authorize Expo to access your Supabase account in the browser. You'll need an existing Supabase account.`
|
||||
);
|
||||
Log.log(`If your browser doesn't open automatically, authorize at this link: ${link(url)}`);
|
||||
void openBrowserAsync(url).catch(() => false);
|
||||
|
||||
const spinner = ora(
|
||||
'Waiting for you to authorize in Supabase (up to 15 minutes; press Ctrl-C to cancel)'
|
||||
).start();
|
||||
try {
|
||||
const connection = await pollForConnectionAsync(graphqlClient, account.id);
|
||||
const organizations = await loadOrganizationsBestEffortAsync(graphqlClient, account.id);
|
||||
spinner.succeed(
|
||||
`Connected Supabase organization ${chalk.bold(
|
||||
formatSupabaseOrganization(connection, organizations ?? undefined)
|
||||
)}`
|
||||
);
|
||||
return connection;
|
||||
} catch (error) {
|
||||
spinner.fail("Couldn't confirm the Supabase connection");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadOrganizationsBestEffortAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string
|
||||
): Promise<SupabaseOrganizationData[] | null> {
|
||||
try {
|
||||
return await SupabaseMutation.listSupabaseOrganizationsAsync(graphqlClient, accountId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollForConnectionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string
|
||||
): Promise<SupabaseConnectionData> {
|
||||
const deadline = Date.now() + CONNECTION_POLL_TIMEOUT_MS;
|
||||
let consecutiveErrors = 0;
|
||||
for (;;) {
|
||||
let connection: SupabaseConnectionData | null = null;
|
||||
try {
|
||||
connection = await SupabaseQuery.getSupabaseConnectionByAccountIdAsync(
|
||||
graphqlClient,
|
||||
accountId,
|
||||
{ useCache: false }
|
||||
);
|
||||
consecutiveErrors = 0;
|
||||
} catch (error) {
|
||||
consecutiveErrors += 1;
|
||||
Log.debug(`Polling for the Supabase connection failed: ${error}`);
|
||||
if (
|
||||
isPermanentGraphqlError(error) ||
|
||||
consecutiveErrors >= MAX_CONSECUTIVE_CONNECTION_ERRORS
|
||||
) {
|
||||
Log.error(
|
||||
'Gave up checking whether the Supabase authorization finished. Fix the error below, then re-run `eas integrations:supabase:connect` — an authorization you already approved is still picked up.'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (connection) {
|
||||
return connection;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(
|
||||
'Timed out waiting for the Supabase connection. If you authorized it in your browser, re-run `eas integrations:supabase:connect` — it will pick up the connection.'
|
||||
);
|
||||
}
|
||||
await sleepAsync(CONNECTION_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolvePublishableKeyAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
project: SupabaseProjectData
|
||||
): Promise<string> {
|
||||
const spinner = ora('Waiting for the Supabase project to finish provisioning').start();
|
||||
const deadline = Date.now() + READINESS_POLL_TIMEOUT_MS;
|
||||
// The server returns a null key while the project is still provisioning, and throws for a real
|
||||
// problem. A rejected request can't succeed on a retry; a server fault might, but not forever.
|
||||
let consecutiveErrors = 0;
|
||||
for (;;) {
|
||||
let key: string | null = null;
|
||||
try {
|
||||
key = await SupabaseMutation.fetchSupabasePublishableKeyAsync(graphqlClient, appId);
|
||||
consecutiveErrors = 0;
|
||||
} catch (error) {
|
||||
consecutiveErrors += 1;
|
||||
Log.debug(`Polling for the Supabase project readiness failed: ${error}`);
|
||||
if (isPermanentGraphqlError(error) || consecutiveErrors >= MAX_CONSECUTIVE_READINESS_ERRORS) {
|
||||
spinner.fail("Couldn't reach the Supabase project");
|
||||
Log.error(
|
||||
`The project may exist even though EAS can't read its key. Check it at ${getSupabaseProjectDashboardUrl(
|
||||
project
|
||||
)} before you re-run \`eas integrations:supabase:connect\`.`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (key) {
|
||||
spinner.succeed('Supabase project is ready');
|
||||
return key;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
spinner.fail('Supabase project did not finish provisioning in time');
|
||||
throw new Error(
|
||||
`The Supabase project is still provisioning. Once it's healthy (check ${getSupabaseProjectDashboardUrl(
|
||||
project
|
||||
)}), re-run \`eas integrations:supabase:connect\` to finish writing the environment variables.`
|
||||
);
|
||||
}
|
||||
await sleepAsync(READINESS_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveRegionAsync(
|
||||
flagValue: string | undefined,
|
||||
nonInteractive: boolean
|
||||
): Promise<string> {
|
||||
if (flagValue !== undefined) {
|
||||
// The server accepts both the smart-group values (americas | emea | apac) and raw region
|
||||
// codes (e.g. us-east-1), so any non-empty value passes through.
|
||||
const region = flagValue.trim();
|
||||
if (!region) {
|
||||
throw new Error(
|
||||
'Pass a Supabase region to --region (americas, emea, apac, or a raw code like us-east-1).'
|
||||
);
|
||||
}
|
||||
return region;
|
||||
}
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'A Supabase region is required in non-interactive mode. Pass --region (americas, emea, or apac). The region is permanent once the project is created.'
|
||||
);
|
||||
}
|
||||
return await selectAsync(
|
||||
'Select a Supabase region (permanent once the project is created)',
|
||||
SUPABASE_REGION_CHOICES
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveOrganizationAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
connection: SupabaseConnectionData,
|
||||
organizationFlag: string | undefined,
|
||||
nonInteractive: boolean,
|
||||
preloadedOrganizations: SupabaseOrganizationData[] | null
|
||||
): Promise<SupabaseConnectionData> {
|
||||
if (organizationFlag) {
|
||||
if (organizationFlag === connection.supabaseOrganizationSlug) {
|
||||
return connection;
|
||||
}
|
||||
const organizations =
|
||||
preloadedOrganizations ??
|
||||
(await SupabaseMutation.listSupabaseOrganizationsAsync(graphqlClient, accountId));
|
||||
if (!organizations.some(organization => organization.slug === organizationFlag)) {
|
||||
throw new Error(
|
||||
`Supabase organization ${chalk.bold(
|
||||
organizationFlag
|
||||
)} isn't one of your connected organizations (${organizations
|
||||
.map(organization => organization.slug)
|
||||
.join(', ')}).`
|
||||
);
|
||||
}
|
||||
return await SupabaseMutation.setSupabaseConnectionOrganizationAsync(graphqlClient, {
|
||||
supabaseConnectionId: connection.id,
|
||||
organizationSlug: organizationFlag,
|
||||
});
|
||||
}
|
||||
if (nonInteractive) {
|
||||
return connection;
|
||||
}
|
||||
const organizations =
|
||||
preloadedOrganizations ??
|
||||
(await SupabaseMutation.listSupabaseOrganizationsAsync(graphqlClient, accountId));
|
||||
if (organizations.length <= 1) {
|
||||
return connection;
|
||||
}
|
||||
const chosen = await selectAsync(
|
||||
'Select the Supabase organization to use',
|
||||
organizations.map(organization => ({
|
||||
title: `${organization.name} (${organization.slug})`,
|
||||
value: organization.slug,
|
||||
}))
|
||||
);
|
||||
if (chosen === connection.supabaseOrganizationSlug) {
|
||||
return connection;
|
||||
}
|
||||
return await SupabaseMutation.setSupabaseConnectionOrganizationAsync(graphqlClient, {
|
||||
supabaseConnectionId: connection.id,
|
||||
organizationSlug: chosen,
|
||||
});
|
||||
}
|
||||
+7
-1
@@ -15,7 +15,13 @@ describe(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync, () => {
|
||||
jester
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`EAS project not configured. To configure it non-interactively, choose the account that should own the project and run:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: jester\n\nAlternatively, set the "owner" field in your app config, or run "eas init" to configure it interactively.`
|
||||
`EAS project not configured. This command cannot configure it in non-interactive mode. ` +
|
||||
`Run one of the following, then re-run this command:\n\n` +
|
||||
`To link an existing project:\n\n` +
|
||||
` eas init --id <project-id> --non-interactive\n\n` +
|
||||
`To create a new project:\n\n` +
|
||||
` eas init --account fake --non-interactive\n\n` +
|
||||
`Accounts you can create projects in: jester`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { getPrivateExpoConfigAsync } from '../expoConfig';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppUploadSessionType } from '../../graphql/generated';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { uploadAppScopedFileAtPathToGCSAsync } from '../../uploads';
|
||||
import { maybeSetProjectIconFromAppConfigAsync, resolveAppConfigIconAsync } from '../projectIcon';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('../expoConfig');
|
||||
jest.mock('../../graphql/queries/AppQuery');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../uploads');
|
||||
|
||||
const mockGetPrivateExpoConfigAsync = jest.mocked(getPrivateExpoConfigAsync);
|
||||
const mockByIdProfileImageUrlAsync = jest.mocked(AppQuery.byIdProfileImageUrlAsync);
|
||||
const mockUploadAsync = jest.mocked(uploadAppScopedFileAtPathToGCSAsync);
|
||||
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const projectDir = '/app';
|
||||
const projectId = 'project-123';
|
||||
|
||||
function mockAppConfig(exp: Record<string, any>): void {
|
||||
mockGetPrivateExpoConfigAsync.mockResolvedValue({
|
||||
name: 'testing 123',
|
||||
slug: 'testing-123',
|
||||
...exp,
|
||||
} as any);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe(resolveAppConfigIconAsync, () => {
|
||||
it('resolves the icon field relative to the project directory', async () => {
|
||||
vol.fromJSON({ '/app/assets/icon.png': 'fake-png-bytes' });
|
||||
mockAppConfig({ icon: './assets/icon.png' });
|
||||
|
||||
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toEqual({
|
||||
field: 'icon',
|
||||
path: '/app/assets/icon.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to ios.icon when the top-level icon file is missing', async () => {
|
||||
vol.fromJSON({ '/app/assets/ios-icon.png': 'fake-png-bytes' });
|
||||
mockAppConfig({ icon: './assets/missing.png', ios: { icon: './assets/ios-icon.png' } });
|
||||
|
||||
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toEqual({
|
||||
field: 'ios.icon',
|
||||
path: '/app/assets/ios-icon.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the light variant when ios.icon is a per-appearance map', async () => {
|
||||
vol.fromJSON({ '/app/assets/light.png': 'fake-png-bytes' });
|
||||
mockAppConfig({
|
||||
ios: { icon: { light: './assets/light.png', dark: './assets/dark.png' } },
|
||||
});
|
||||
|
||||
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toEqual({
|
||||
field: 'ios.icon',
|
||||
path: '/app/assets/light.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the Android adaptive icon foreground', async () => {
|
||||
vol.fromJSON({ '/app/assets/foreground.png': 'fake-png-bytes' });
|
||||
mockAppConfig({
|
||||
android: { adaptiveIcon: { foregroundImage: './assets/foreground.png' } },
|
||||
});
|
||||
|
||||
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toEqual({
|
||||
field: 'android.adaptiveIcon.foregroundImage',
|
||||
path: '/app/assets/foreground.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a remote icon URL', async () => {
|
||||
mockAppConfig({ icon: 'https://example.com/icon.png' });
|
||||
|
||||
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the app config has no icon', async () => {
|
||||
mockAppConfig({});
|
||||
|
||||
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the app config cannot be read', async () => {
|
||||
mockGetPrivateExpoConfigAsync.mockRejectedValue(new Error('Invalid app config.'));
|
||||
|
||||
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(maybeSetProjectIconFromAppConfigAsync, () => {
|
||||
it('uploads the app config icon when the project has none', async () => {
|
||||
vol.fromJSON({ '/app/assets/icon.png': 'fake-png-bytes' });
|
||||
mockAppConfig({ icon: './assets/icon.png' });
|
||||
mockByIdProfileImageUrlAsync.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
|
||||
).resolves.toEqual({
|
||||
status: 'set',
|
||||
icon: { field: 'icon', path: '/app/assets/icon.png' },
|
||||
});
|
||||
expect(mockUploadAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
type: AppUploadSessionType.ProfileImageUpload,
|
||||
appId: projectId,
|
||||
path: '/app/assets/icon.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not overwrite an icon the project already has', async () => {
|
||||
vol.fromJSON({ '/app/assets/icon.png': 'fake-png-bytes' });
|
||||
mockAppConfig({ icon: './assets/icon.png' });
|
||||
mockByIdProfileImageUrlAsync.mockResolvedValue('https://example.com/existing.png');
|
||||
|
||||
await expect(
|
||||
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
|
||||
).resolves.toEqual({ status: 'skipped', reason: 'icon-already-set' });
|
||||
expect(mockUploadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips without a server round-trip when the app config has no icon', async () => {
|
||||
mockAppConfig({});
|
||||
|
||||
await expect(
|
||||
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
|
||||
).resolves.toEqual({ status: 'skipped', reason: 'no-icon-in-app-config' });
|
||||
expect(mockByIdProfileImageUrlAsync).not.toHaveBeenCalled();
|
||||
expect(mockUploadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips an unsupported image format instead of throwing', async () => {
|
||||
vol.fromJSON({ '/app/assets/icon.gif': 'fake-gif-bytes' });
|
||||
mockAppConfig({ icon: './assets/icon.gif' });
|
||||
|
||||
await expect(
|
||||
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
|
||||
).resolves.toEqual({ status: 'skipped', reason: 'invalid-icon' });
|
||||
expect(mockUploadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips an image over the size limit instead of throwing', async () => {
|
||||
vol.fromJSON({ '/app/assets/icon.png': 'x'.repeat(10 * 1024 * 1024 + 1) });
|
||||
mockAppConfig({ icon: './assets/icon.png' });
|
||||
|
||||
await expect(
|
||||
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
|
||||
).resolves.toEqual({ status: 'skipped', reason: 'invalid-icon' });
|
||||
expect(mockUploadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not propagate an upload failure', async () => {
|
||||
vol.fromJSON({ '/app/assets/icon.png': 'fake-png-bytes' });
|
||||
mockAppConfig({ icon: './assets/icon.png' });
|
||||
mockByIdProfileImageUrlAsync.mockResolvedValue(null);
|
||||
mockUploadAsync.mockRejectedValue(new Error('Upload session expired'));
|
||||
|
||||
await expect(
|
||||
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
|
||||
).resolves.toEqual({ status: 'skipped', reason: 'upload-failed' });
|
||||
});
|
||||
});
|
||||
+3
-8
@@ -1,5 +1,6 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getUnconfiguredProjectError } from './projectNotConfiguredError';
|
||||
import { getProjectDashboardUrl } from '../build/utils/url';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { Role } from '../graphql/generated';
|
||||
@@ -8,7 +9,7 @@ import { AppQuery } from '../graphql/queries/AppQuery';
|
||||
import { link } from '../log';
|
||||
import { ora } from '../ora';
|
||||
import { confirmAsync } from '../prompts';
|
||||
import { Actor, getCreatableAccountNames } from '../user/User';
|
||||
import { Actor } from '../user/User';
|
||||
|
||||
/**
|
||||
* 1. Looks for an existing project on EAS servers. If found, ask the user whether this is the
|
||||
@@ -38,13 +39,7 @@ export async function fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsyn
|
||||
);
|
||||
|
||||
if (options.nonInteractive) {
|
||||
throw new Error(
|
||||
`EAS project not configured. To configure it non-interactively, choose the account that should own the project and run:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: ${getCreatableAccountNames(
|
||||
actor
|
||||
).join(
|
||||
', '
|
||||
)}\n\nAlternatively, set the "owner" field in your app config, or run "eas init" to configure it interactively.`
|
||||
);
|
||||
throw getUnconfiguredProjectError({ actor, accountName });
|
||||
}
|
||||
const account = allAccounts.find(a => a.name === accountName);
|
||||
if (!account) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { getPrivateExpoConfigAsync } from './expoConfig';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppUploadSessionType } from '../graphql/generated';
|
||||
import { AppQuery } from '../graphql/queries/AppQuery';
|
||||
import Log from '../log';
|
||||
import { uploadAppScopedFileAtPathToGCSAsync } from '../uploads';
|
||||
import { sleepAsync } from '../utils/promise';
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
|
||||
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // matches the upload session's GCS limit
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const POLL_TIMEOUT_MS = 90_000;
|
||||
|
||||
/** An icon found in the app config, with the field it came from for display purposes. */
|
||||
export interface AppConfigIcon {
|
||||
field: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type SetProjectIconFromAppConfigResult =
|
||||
| { status: 'set'; icon: AppConfigIcon }
|
||||
| {
|
||||
status: 'skipped';
|
||||
reason: 'no-icon-in-app-config' | 'icon-already-set' | 'invalid-icon' | 'upload-failed';
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the project icon from the app config, but only when the project does not have one yet.
|
||||
* Overwriting an icon picked on the dashboard would be surprising, and the app config is not the
|
||||
* source of truth for it.
|
||||
*
|
||||
* Never throws. The project is already created or linked by the time this runs, so a failed icon
|
||||
* upload must not fail the command that called it.
|
||||
*/
|
||||
export async function maybeSetProjectIconFromAppConfigAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ projectId, projectDir }: { projectId: string; projectDir: string }
|
||||
): Promise<SetProjectIconFromAppConfigResult> {
|
||||
const icon = await resolveAppConfigIconAsync(projectDir);
|
||||
if (!icon) {
|
||||
return { status: 'skipped', reason: 'no-icon-in-app-config' };
|
||||
}
|
||||
|
||||
try {
|
||||
await validateIconAsync(icon.path);
|
||||
} catch (error: any) {
|
||||
Log.warn(`Did not set the project icon from "${icon.field}": ${error.message}`);
|
||||
return { status: 'skipped', reason: 'invalid-icon' };
|
||||
}
|
||||
|
||||
const existingIconUrl = await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId);
|
||||
if (existingIconUrl) {
|
||||
Log.debug('The project already has an icon, skipping the icon from the app config.');
|
||||
return { status: 'skipped', reason: 'icon-already-set' };
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadProjectIconAsync(graphqlClient, { projectId, imagePath: icon.path });
|
||||
} catch (error: any) {
|
||||
Log.warn(`Failed to set the project icon from "${icon.field}": ${error.message}`);
|
||||
return { status: 'skipped', reason: 'upload-failed' };
|
||||
}
|
||||
|
||||
// The server resizes the image and assigns it to the project asynchronously. Callers that need
|
||||
// the icon to be visible before they return should poll with pollForProfileImageChangeAsync.
|
||||
Log.withTick(`Set the project icon from ${chalk.bold(icon.field)} in your app config`);
|
||||
return { status: 'set', icon };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the app icon on disk. Falls through to the platform-specific icons so that a project that
|
||||
* only sets `ios.icon` or an Android adaptive icon still gets one.
|
||||
*/
|
||||
export async function resolveAppConfigIconAsync(projectDir: string): Promise<AppConfigIcon | null> {
|
||||
let exp;
|
||||
try {
|
||||
exp = await getPrivateExpoConfigAsync(projectDir);
|
||||
} catch (error: any) {
|
||||
Log.debug(`Could not read the app config to resolve the project icon: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates: [string, unknown][] = [
|
||||
['icon', exp.icon],
|
||||
['ios.icon', exp.ios?.icon],
|
||||
['android.adaptiveIcon.foregroundImage', exp.android?.adaptiveIcon?.foregroundImage],
|
||||
];
|
||||
|
||||
for (const [field, value] of candidates) {
|
||||
const iconPath = resolveLocalIconPath(projectDir, value);
|
||||
if (iconPath && (await fs.pathExists(iconPath))) {
|
||||
return { field, path: iconPath };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveLocalIconPath(projectDir: string, value: unknown): string | null {
|
||||
// SDK 52 and above allow `ios.icon` to be a per-appearance map. The light icon is the one shown
|
||||
// in most places, so it is the closest match for a single project icon.
|
||||
let rawPath: string | null = null;
|
||||
if (typeof value === 'string') {
|
||||
rawPath = value;
|
||||
} else if (value && typeof value === 'object' && typeof (value as any).light === 'string') {
|
||||
rawPath = (value as any).light;
|
||||
}
|
||||
|
||||
// Remote icons (http://, https://, data:) are not files we can upload. The scheme must be at
|
||||
// least two characters so that a Windows drive letter is still treated as a path.
|
||||
if (!rawPath || /^[a-z][a-z0-9+.-]+:/i.test(rawPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.resolve(projectDir, rawPath);
|
||||
}
|
||||
|
||||
export async function validateIconAsync(imagePath: string): Promise<void> {
|
||||
if (!(await fs.pathExists(imagePath))) {
|
||||
throw new Error(`No file found at ${imagePath}`);
|
||||
}
|
||||
const extension = path.extname(imagePath).toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.includes(extension)) {
|
||||
throw new Error(
|
||||
`Unsupported image format "${extension}". The icon must be a PNG or JPEG file.`
|
||||
);
|
||||
}
|
||||
const { size } = await fs.stat(imagePath);
|
||||
if (size > MAX_IMAGE_SIZE_BYTES) {
|
||||
throw new Error(
|
||||
`The image is ${(size / 1024 / 1024).toFixed(1)} MB, but the maximum allowed size is 10 MB.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadProjectIconAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ projectId, imagePath }: { projectId: string; imagePath: string }
|
||||
): Promise<void> {
|
||||
await uploadAppScopedFileAtPathToGCSAsync(graphqlClient, {
|
||||
type: AppUploadSessionType.ProfileImageUpload,
|
||||
appId: projectId,
|
||||
path: imagePath,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pollForProfileImageChangeAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId,
|
||||
previousProfileImageUrl,
|
||||
projectDashboardUrl,
|
||||
}: {
|
||||
projectId: string;
|
||||
previousProfileImageUrl: string | null;
|
||||
projectDashboardUrl: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
|
||||
await sleepAsync(POLL_INTERVAL_MS);
|
||||
const profileImageUrl = await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId);
|
||||
if (profileImageUrl && profileImageUrl !== previousProfileImageUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for the icon to be processed. It may still appear on the project page shortly: ${chalk.underline(
|
||||
projectDashboardUrl
|
||||
)}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Actor, getCreatableAccountNames } from '../user/User';
|
||||
|
||||
/**
|
||||
* Build the error shown when an EAS project is not configured and the CLI cannot configure it
|
||||
* without more input. Lists the exact commands to run, because bare "eas init" also needs
|
||||
* interaction and fails in the same situation.
|
||||
*
|
||||
* @param actor the logged-in actor
|
||||
* @param accountName the account that should own the project, when it is already known
|
||||
* @param reason why the CLI cannot configure the project here
|
||||
* @param additionalFix an extra way to resolve the error, appended at the end
|
||||
*/
|
||||
export function getUnconfiguredProjectError({
|
||||
actor,
|
||||
accountName,
|
||||
reason = 'This command cannot configure it in non-interactive mode.',
|
||||
additionalFix,
|
||||
}: {
|
||||
actor: Actor;
|
||||
accountName?: string;
|
||||
reason?: string;
|
||||
additionalFix?: string;
|
||||
}): Error {
|
||||
return new Error(
|
||||
`EAS project not configured. ${reason} ` +
|
||||
`Run one of the following, then re-run this command:\n\n` +
|
||||
`To link an existing project:\n\n` +
|
||||
` eas init --id <project-id> --non-interactive\n\n` +
|
||||
`To create a new project:\n\n` +
|
||||
` eas init --account ${accountName ?? '<account-name>'} --non-interactive\n\n` +
|
||||
`Accounts you can create projects in: ${getCreatableAccountNames(actor).join(', ')}` +
|
||||
(additionalFix ? `\n\n${additionalFix}` : '')
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface SubmissionContext<T extends Platform> {
|
||||
analyticsEventProperties: AnalyticsEventProperties;
|
||||
exp: ExpoConfig;
|
||||
nonInteractive: boolean;
|
||||
autoTestFlightSetup: boolean;
|
||||
isVerboseFastlaneEnabled: boolean;
|
||||
groups: T extends Platform.IOS ? string[] : undefined;
|
||||
platform: T;
|
||||
@@ -49,6 +50,7 @@ export async function createSubmissionContextAsync<T extends Platform>(params: {
|
||||
credentialsCtx?: CredentialsContext;
|
||||
env?: Env;
|
||||
nonInteractive: boolean;
|
||||
autoTestFlightSetup?: boolean;
|
||||
isVerboseFastlaneEnabled: boolean;
|
||||
groups: string[] | undefined;
|
||||
platform: T;
|
||||
@@ -116,6 +118,7 @@ export async function createSubmissionContextAsync<T extends Platform>(params: {
|
||||
|
||||
return {
|
||||
...rest,
|
||||
autoTestFlightSetup: params.autoTestFlightSetup ?? true,
|
||||
accountName: account.name,
|
||||
credentialsCtx,
|
||||
groups,
|
||||
|
||||
@@ -94,7 +94,7 @@ async function createAppStoreConnectAppAsync(
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureTestFlightGroupExistsAsync(app);
|
||||
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: ctx.nonInteractive });
|
||||
} catch (error: any) {
|
||||
// This process is not critical to the app submission so we shouldn't let it fail the entire process.
|
||||
Log.error(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './AppSpecificPasswordSource';
|
||||
import { AscApiKeySource, AscApiKeySourceType } from './AscApiKeySource';
|
||||
import IosSubmitter, { IosSubmissionOptions } from './IosSubmitter';
|
||||
import { ensureTestFlightSetupForExistingAppAsync } from './ensureTestFlightSetup';
|
||||
import { MissingCredentialsError } from '../../credentials/errors';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import { ArchiveSource, ArchiveSourceType, getArchiveAsync } from '../ArchiveSource';
|
||||
@@ -171,6 +172,7 @@ export default class IosSubmitCommand {
|
||||
private async resolveAscAppIdentifierAsync(): Promise<Result<string>> {
|
||||
const { ascAppId } = this.ctx.profile;
|
||||
if (ascAppId) {
|
||||
await ensureTestFlightSetupForExistingAppAsync(this.ctx, ascAppId);
|
||||
return result(ascAppId);
|
||||
} else if (this.ctx.nonInteractive) {
|
||||
return result(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { refreshContextSubmitProfileAsync } from '../../commons';
|
||||
import { SubmissionContext, createSubmissionContextAsync } from '../../context';
|
||||
import IosSubmitCommand from '../IosSubmitCommand';
|
||||
import { ensureTestFlightSetupForExistingAppAsync } from '../ensureTestFlightSetup';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('../../../ora');
|
||||
@@ -52,6 +53,9 @@ jest.mock('../../commons', () => {
|
||||
refreshContextSubmitProfileAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../ensureTestFlightSetup', () => ({
|
||||
ensureTestFlightSetupForExistingAppAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const vcsClient = resolveVcsClient();
|
||||
|
||||
@@ -203,6 +207,11 @@ describe(IosSubmitCommand, () => {
|
||||
submittedBuildId: undefined,
|
||||
});
|
||||
|
||||
expect(ensureTestFlightSetupForExistingAppAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'12345678'
|
||||
);
|
||||
|
||||
delete process.env.EXPO_APPLE_APP_SPECIFIC_PASSWORD;
|
||||
});
|
||||
describe('build selected from EAS', () => {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { App } from '@expo/apple-utils';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { resolveAscApiKeyForAppCredentialsAsync } from '../../../credentials/ios/actions/AscApiKeyUtils';
|
||||
import { AuthenticationMode } from '../../../credentials/ios/appstore/authenticateTypes';
|
||||
import { ensureTestFlightGroupExistsAsync } from '../../../credentials/ios/appstore/ensureTestFlightGroup';
|
||||
import { hasAscEnvVars } from '../../../credentials/ios/appstore/resolveCredentials';
|
||||
import { SubmissionContext } from '../../context';
|
||||
import { ensureTestFlightSetupForExistingAppAsync } from '../ensureTestFlightSetup';
|
||||
|
||||
jest.mock('@expo/apple-utils', () => ({
|
||||
...jest.requireActual('@expo/apple-utils'),
|
||||
App: { infoAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../credentials/ios/actions/AscApiKeyUtils', () => ({
|
||||
resolveAscApiKeyForAppCredentialsAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../credentials/ios/appstore/ensureTestFlightGroup', () => ({
|
||||
ensureTestFlightGroupExistsAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../credentials/ios/appstore/resolveCredentials', () => ({
|
||||
...jest.requireActual('../../../credentials/ios/appstore/resolveCredentials'),
|
||||
hasAscEnvVars: jest.fn(),
|
||||
}));
|
||||
|
||||
function createContext({
|
||||
nonInteractive = false,
|
||||
autoTestFlightSetup = true,
|
||||
}: {
|
||||
nonInteractive?: boolean;
|
||||
autoTestFlightSetup?: boolean;
|
||||
}): {
|
||||
ctx: SubmissionContext<Platform.IOS>;
|
||||
ensureAuthenticatedAsync: jest.Mock;
|
||||
} {
|
||||
const authCtx = {
|
||||
team: { id: 'team-id' },
|
||||
ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' },
|
||||
authState: { context: {} },
|
||||
};
|
||||
const appStore: any = { authCtx: undefined };
|
||||
const ensureAuthenticatedAsync = jest.fn(async () => {
|
||||
appStore.authCtx = authCtx;
|
||||
return authCtx;
|
||||
});
|
||||
appStore.ensureAuthenticatedAsync = ensureAuthenticatedAsync;
|
||||
|
||||
return {
|
||||
ctx: {
|
||||
nonInteractive,
|
||||
autoTestFlightSetup,
|
||||
applicationIdentifierOverride: 'com.example.app',
|
||||
profile: {},
|
||||
user: { accounts: [{ name: 'account' }] },
|
||||
accountName: 'account',
|
||||
projectName: 'project',
|
||||
credentialsCtx: { appStore },
|
||||
graphqlClient: {},
|
||||
} as SubmissionContext<Platform.IOS>,
|
||||
ensureAuthenticatedAsync,
|
||||
};
|
||||
}
|
||||
|
||||
describe(ensureTestFlightSetupForExistingAppAsync, () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.EXPO_ASC_API_KEY_PATH;
|
||||
delete process.env.EXPO_ASC_KEY_ID;
|
||||
delete process.env.EXPO_ASC_ISSUER_ID;
|
||||
delete process.env.EXPO_APPLE_TEAM_ID;
|
||||
jest.mocked(hasAscEnvVars).mockReset().mockReturnValue(false);
|
||||
jest.mocked(resolveAscApiKeyForAppCredentialsAsync).mockReset().mockResolvedValue(null);
|
||||
jest
|
||||
.mocked(App.infoAsync)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ id: '12345678' } as App);
|
||||
jest.mocked(ensureTestFlightGroupExistsAsync).mockReset();
|
||||
});
|
||||
|
||||
it('sets up TestFlight in non-interactive mode when stored credentials are complete', async () => {
|
||||
jest.mocked(resolveAscApiKeyForAppCredentialsAsync).mockResolvedValue({
|
||||
ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' },
|
||||
teamId: 'team-id',
|
||||
teamName: 'Team',
|
||||
});
|
||||
const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true });
|
||||
|
||||
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
|
||||
|
||||
expect(ensureAuthenticatedAsync).toHaveBeenCalledWith({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' },
|
||||
teamId: 'team-id',
|
||||
teamName: 'Team',
|
||||
teamType: expect.any(String),
|
||||
});
|
||||
expect(ensureTestFlightGroupExistsAsync).toHaveBeenCalledWith(expect.anything(), {
|
||||
nonInteractive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets up TestFlight in non-interactive mode when environment credentials are complete', async () => {
|
||||
jest.mocked(hasAscEnvVars).mockReturnValue(true);
|
||||
process.env.EXPO_ASC_API_KEY_PATH = '/path/to/key.p8';
|
||||
process.env.EXPO_ASC_KEY_ID = 'key-id';
|
||||
process.env.EXPO_ASC_ISSUER_ID = 'issuer-id';
|
||||
process.env.EXPO_APPLE_TEAM_ID = 'team-id';
|
||||
const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true });
|
||||
|
||||
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
|
||||
|
||||
expect(ensureAuthenticatedAsync).toHaveBeenCalledWith({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
teamId: 'team-id',
|
||||
teamType: expect.any(String),
|
||||
});
|
||||
expect(ensureTestFlightGroupExistsAsync).toHaveBeenCalledWith(expect.anything(), {
|
||||
nonInteractive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips setup when environment credentials are incomplete', async () => {
|
||||
jest.mocked(hasAscEnvVars).mockReturnValue(true);
|
||||
process.env.EXPO_ASC_KEY_ID = 'key-id';
|
||||
const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true });
|
||||
|
||||
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
|
||||
|
||||
expect(ensureAuthenticatedAsync).not.toHaveBeenCalled();
|
||||
expect(resolveAscApiKeyForAppCredentialsAsync).not.toHaveBeenCalled();
|
||||
expect(ensureTestFlightGroupExistsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips setup when stored credentials have no Apple Team ID', async () => {
|
||||
jest.mocked(resolveAscApiKeyForAppCredentialsAsync).mockResolvedValue({
|
||||
ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' },
|
||||
});
|
||||
const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true });
|
||||
|
||||
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
|
||||
|
||||
expect(ensureAuthenticatedAsync).not.toHaveBeenCalled();
|
||||
expect(ensureTestFlightGroupExistsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips setup when automatic setup is disabled', async () => {
|
||||
const { ctx, ensureAuthenticatedAsync } = createContext({ autoTestFlightSetup: false });
|
||||
|
||||
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
|
||||
|
||||
expect(ensureAuthenticatedAsync).not.toHaveBeenCalled();
|
||||
expect(resolveAscApiKeyForAppCredentialsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { App } from '@expo/apple-utils';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import {
|
||||
AppleTeamType,
|
||||
AuthenticationMode,
|
||||
} from '../../credentials/ios/appstore/authenticateTypes';
|
||||
import { getRequestContext } from '../../credentials/ios/appstore/authenticate';
|
||||
import { ensureTestFlightGroupExistsAsync } from '../../credentials/ios/appstore/ensureTestFlightGroup';
|
||||
import {
|
||||
hasAscEnvVars,
|
||||
resolveAppleTeamTypeFromEnvironment,
|
||||
} from '../../credentials/ios/appstore/resolveCredentials';
|
||||
import { resolveAscApiKeyForAppCredentialsAsync } from '../../credentials/ios/actions/AscApiKeyUtils';
|
||||
import Log from '../../log';
|
||||
import { getBundleIdentifierAsync } from '../../project/ios/bundleIdentifier';
|
||||
import { SubmissionContext } from '../context';
|
||||
|
||||
/**
|
||||
* Best-effort TestFlight internal group setup for an App Store Connect app
|
||||
* that already exists with `ascAppId` provided.
|
||||
*/
|
||||
export async function ensureTestFlightSetupForExistingAppAsync(
|
||||
ctx: SubmissionContext<Platform.IOS>,
|
||||
ascAppIdentifier: string
|
||||
): Promise<void> {
|
||||
if (!ctx.autoTestFlightSetup) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const bundleIdentifier =
|
||||
ctx.applicationIdentifierOverride ??
|
||||
ctx.profile.bundleIdentifier ??
|
||||
(await getBundleIdentifierAsync(ctx.projectDir, ctx.exp, ctx.vcsClient));
|
||||
|
||||
const appLookupParams = {
|
||||
account: nullthrows(
|
||||
ctx.user.accounts.find(a => a.name === ctx.accountName),
|
||||
`You do not have access to account: ${ctx.accountName}`
|
||||
),
|
||||
projectName: ctx.projectName,
|
||||
bundleIdentifier,
|
||||
};
|
||||
|
||||
if (!ctx.credentialsCtx.appStore.authCtx) {
|
||||
const teamType =
|
||||
resolveAppleTeamTypeFromEnvironment() ?? AppleTeamType.COMPANY_OR_ORGANIZATION;
|
||||
if (hasAscEnvVars()) {
|
||||
const teamId = process.env.EXPO_APPLE_TEAM_ID;
|
||||
if (
|
||||
!process.env.EXPO_ASC_API_KEY_PATH ||
|
||||
!process.env.EXPO_ASC_KEY_ID ||
|
||||
!process.env.EXPO_ASC_ISSUER_ID ||
|
||||
!teamId
|
||||
) {
|
||||
Log.log('App Store Connect credentials are incomplete, skipping TestFlight setup');
|
||||
return;
|
||||
}
|
||||
await ctx.credentialsCtx.appStore.ensureAuthenticatedAsync({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
teamId,
|
||||
teamType,
|
||||
});
|
||||
} else {
|
||||
const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
app: appLookupParams,
|
||||
});
|
||||
const teamId = resolvedKey?.teamId ?? process.env.EXPO_APPLE_TEAM_ID;
|
||||
if (!resolvedKey || !teamId) {
|
||||
Log.log('No complete App Store Connect credentials, skipping TestFlight setup');
|
||||
return;
|
||||
}
|
||||
Log.log('Using App Store Connect API Key from EAS credentials service.');
|
||||
await ctx.credentialsCtx.appStore.ensureAuthenticatedAsync({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
ascApiKey: resolvedKey.ascApiKey,
|
||||
teamId,
|
||||
teamName: resolvedKey.teamName,
|
||||
teamType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const authCtx = ctx.credentialsCtx.appStore.authCtx;
|
||||
if (!authCtx) {
|
||||
Log.debug('No App Store Connect API key available, skipping TestFlight setup');
|
||||
return;
|
||||
}
|
||||
|
||||
const app = await App.infoAsync(getRequestContext(authCtx), { id: ascAppIdentifier });
|
||||
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: ctx.nonInteractive });
|
||||
} catch (error: any) {
|
||||
// Group setup is a convenience on top of the submission and must never
|
||||
// block it.
|
||||
Log.warn('Skipping TestFlight group setup:', error);
|
||||
}
|
||||
}
|
||||
@@ -161,4 +161,39 @@ describe(pollForBackgroundJobReceiptAsync, () => {
|
||||
})
|
||||
).rejects.toThrow('Background job timed out.');
|
||||
});
|
||||
|
||||
it('tolerates consecutive CombinedError fetches before NULL_RECEIPT', async () => {
|
||||
const { CombinedError } = jest.requireActual('@urql/core') as typeof import('@urql/core');
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
|
||||
const receiptId = '123';
|
||||
const backgroundJobReceiptInProgress: BackgroundJobReceiptDataFragment = {
|
||||
id: receiptId,
|
||||
state: BackgroundJobState.InProgress,
|
||||
willRetry: false,
|
||||
tries: 0,
|
||||
resultType: BackgroundJobResultType.Void,
|
||||
} as any;
|
||||
const backgroundJobReceiptSuccess: BackgroundJobReceiptDataFragment = {
|
||||
id: receiptId,
|
||||
state: BackgroundJobState.Success,
|
||||
willRetry: false,
|
||||
tries: 0,
|
||||
resultType: BackgroundJobResultType.Void,
|
||||
} as any;
|
||||
|
||||
jest
|
||||
.mocked(BackgroundJobReceiptQuery.byIdAsync)
|
||||
.mockRejectedValueOnce(new CombinedError({ networkError: new Error('blip') }))
|
||||
.mockRejectedValueOnce(new CombinedError({ networkError: new Error('blip') }))
|
||||
.mockResolvedValueOnce(backgroundJobReceiptSuccess);
|
||||
|
||||
const result = await pollForBackgroundJobReceiptAsync(
|
||||
graphqlClient,
|
||||
backgroundJobReceiptInProgress,
|
||||
{ pollInterval: 50, maxConsecutiveFetchErrors: 3 }
|
||||
);
|
||||
|
||||
expect(result).toEqual(backgroundJobReceiptSuccess);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,8 @@ export function pollForBackgroundJobReceiptAsync(
|
||||
options?: {
|
||||
onBackgroundJobReceiptPollError?: BackgroundJobPollErrorCondition;
|
||||
pollInterval?: number;
|
||||
maxChecks?: number;
|
||||
maxConsecutiveFetchErrors?: number;
|
||||
}
|
||||
): Promise<BackgroundJobReceiptDataFragment | null>;
|
||||
export async function pollForBackgroundJobReceiptAsync(
|
||||
@@ -79,10 +81,15 @@ export async function pollForBackgroundJobReceiptAsync(
|
||||
options?: {
|
||||
onBackgroundJobReceiptPollError?: BackgroundJobPollErrorCondition;
|
||||
pollInterval?: number;
|
||||
maxChecks?: number;
|
||||
maxConsecutiveFetchErrors?: number;
|
||||
}
|
||||
): Promise<BackgroundJobReceiptDataFragment | null> {
|
||||
const maxChecks = options?.maxChecks ?? 90;
|
||||
const maxConsecutiveFetchErrors = options?.maxConsecutiveFetchErrors ?? 0;
|
||||
return await new Promise<BackgroundJobReceiptDataFragment | null>((resolve, reject) => {
|
||||
let numChecks = 0;
|
||||
let consecutiveFetchErrors = 0;
|
||||
const intervalHandle = setIntervalAsync(async function pollForDeletionFinishedAsync() {
|
||||
function failBackgroundDeletion(error: BackgroundJobReceiptPollError): void {
|
||||
void clearIntervalAsync(intervalHandle);
|
||||
@@ -101,6 +108,11 @@ export async function pollForBackgroundJobReceiptAsync(
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
consecutiveFetchErrors += 1;
|
||||
if (consecutiveFetchErrors <= maxConsecutiveFetchErrors) {
|
||||
numChecks++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
failBackgroundDeletion(
|
||||
new BackgroundJobReceiptPollError({
|
||||
@@ -110,6 +122,8 @@ export async function pollForBackgroundJobReceiptAsync(
|
||||
return;
|
||||
}
|
||||
|
||||
consecutiveFetchErrors = 0;
|
||||
|
||||
// job failed and will not retry
|
||||
if (receipt.state === BackgroundJobState.Failure && !receipt.willRetry) {
|
||||
failBackgroundDeletion(
|
||||
@@ -121,10 +135,10 @@ export async function pollForBackgroundJobReceiptAsync(
|
||||
return;
|
||||
}
|
||||
|
||||
// all else fails, stop polling after 90 checks. This should only happen if there's an
|
||||
// all else fails, stop polling after maxChecks. This should only happen if there's an
|
||||
// issue with receipts not setting `willRetry` to false when they fail within a reasonable
|
||||
// amount of time.
|
||||
if (numChecks > 90) {
|
||||
if (numChecks > maxChecks) {
|
||||
failBackgroundDeletion(
|
||||
new BackgroundJobReceiptPollError({
|
||||
errorType: BackgroundJobReceiptPollErrorType.TIMEOUT,
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { DefaultEnvironment } from '../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { DEFAULT_ENVIRONMENTS } from '../environments/defaults';
|
||||
import { getProjectEnvironmentVariableEnvironmentsAsync } from '../environments/variables';
|
||||
import { EnvironmentSecretType, EnvironmentVariableVisibility } from '../graphql/generated';
|
||||
import { EnvironmentVariablesQuery } from '../graphql/queries/EnvironmentVariablesQuery';
|
||||
import { RequestedPlatform } from '../platform';
|
||||
import { promptAsync, selectAsync } from '../prompts';
|
||||
|
||||
const DEFAULT_ENVIRONMENTS = Object.values(DefaultEnvironment);
|
||||
|
||||
export async function getProjectEnvironmentVariableEnvironmentsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const environments = await EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync(
|
||||
graphqlClient,
|
||||
projectId
|
||||
);
|
||||
return environments;
|
||||
} catch {
|
||||
throw new Error('Failed to fetch available environments');
|
||||
}
|
||||
}
|
||||
|
||||
const CUSTOM_ENVIRONMENT_VALUE = '~~CUSTOM~~';
|
||||
|
||||
export async function promptVariableTypeAsync(
|
||||
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user