mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48c47bcc0c | ||
|
|
6ad59488b0 | ||
|
|
c476f07818 | ||
|
|
329a02d06f | ||
|
|
f46b469267 | ||
|
|
d708aaefab | ||
|
|
0d1c91aad2 | ||
|
|
4f670f90b6 | ||
|
|
53d294330d | ||
|
|
ed4b39c55e | ||
|
|
a04d336921 | ||
|
|
f83d3b8ee0 | ||
|
|
9b5f237bf2 | ||
|
|
66cc6d2cf9 | ||
|
|
d9ff560dab | ||
|
|
1f53177ecf | ||
|
|
b1f606979a | ||
|
|
71f968e971 | ||
|
|
687e8897c1 | ||
|
|
0d5ef2ee89 | ||
|
|
f856d4022f | ||
|
|
5585ef99c8 | ||
|
|
4eb0753a06 | ||
|
|
bfaff08894 | ||
|
|
64874f7dfe | ||
|
|
daa4c60e9b | ||
|
|
ba4e4eddbc | ||
|
|
6bda9c7fa0 | ||
|
|
4dec5b3765 | ||
|
|
0e3216e681 | ||
|
|
2a191700f7 | ||
|
|
4da5b40520 | ||
|
|
d23248e4ac | ||
|
|
3318392620 | ||
|
|
b5fac2a066 | ||
|
|
138f7973ae | ||
|
|
51311f1813 | ||
|
|
a85b60c11e | ||
|
|
cacd48bf66 | ||
|
|
bc213a51b9 | ||
|
|
ddd8bb7fdd | ||
|
|
ed251cb0d1 | ||
|
|
5176495e9e | ||
|
|
32211614eb | ||
|
|
ce5b48244c | ||
|
|
ece1d00ca7 | ||
|
|
82dd218a2b | ||
|
|
9f763c6957 | ||
|
|
e74c93d80c | ||
|
|
3d691d602c | ||
|
|
7fde621858 | ||
|
|
91c9331e43 | ||
|
|
02ba5880c4 | ||
|
|
8229dc8265 | ||
|
|
15a456970b | ||
|
|
e74751c26c | ||
|
|
0e2a1756ce | ||
|
|
766e278aec | ||
|
|
77e22c740a | ||
|
|
8fdf47aa7d | ||
|
|
2279b650d5 | ||
|
|
c6a36e2e47 | ||
|
|
27cb8c12d6 | ||
|
|
1122a7f374 | ||
|
|
c06d18138c | ||
|
|
3f4ff1dc3e | ||
|
|
e852ab1cb7 | ||
|
|
d941398ce6 | ||
|
|
bae7684996 | ||
|
|
714aec69c7 | ||
|
|
bec6e670de |
No files matched your search
@@ -0,0 +1,16 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Keep SHA-pinned GitHub Actions fresh. Without this, pins never receive
|
||||
# upstream security patches because tag movements no longer reach us.
|
||||
- package-ecosystem: github-actions
|
||||
directories:
|
||||
- /
|
||||
- /.github/actions/setup-mise
|
||||
- /.github/internal-actions/setup-gcloud
|
||||
- /.github/internal-actions/notify-slack-on-fail-or-recover
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- '*'
|
||||
@@ -12,7 +12,7 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Get previous workflow run status
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
id: run-status
|
||||
with:
|
||||
script: |
|
||||
@@ -28,7 +28,7 @@ runs:
|
||||
|
||||
- name: Send Slack Success Notification
|
||||
if: fromJSON(steps.run-status.outputs.result).previous == 'failure' && fromJSON(steps.run-status.outputs.result).current == 'success'
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
|
||||
env:
|
||||
SLACK_CHANNEL: ${{ inputs.channel }}
|
||||
SLACK_COLOR: good
|
||||
@@ -41,7 +41,7 @@ runs:
|
||||
|
||||
- name: Send Slack Failure Notification
|
||||
if: fromJSON(steps.run-status.outputs.result).current == 'failure'
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
|
||||
env:
|
||||
SLACK_CHANNEL: ${{ inputs.channel }}
|
||||
SLACK_COLOR: danger
|
||||
|
||||
@@ -4,7 +4,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Auth gcloud
|
||||
uses: google-github-actions/auth@v2
|
||||
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
|
||||
with:
|
||||
workload_identity_provider: 'projects/321830142373/locations/global/workloadIdentityPools/github/providers/expo'
|
||||
project_id: exponentjs
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
name: Check CHANGELOG.md updated
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
|
||||
- name: Check if CHANGELOG.md was updated
|
||||
id: changelog-updated
|
||||
uses: tj-actions/changed-files@b1ba699b304f2083b602164e06a89b868c84f076
|
||||
@@ -26,11 +26,11 @@ jobs:
|
||||
files: CHANGELOG.md
|
||||
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
|
||||
if: steps.changelog-updated.outputs.any_changed == 'false' && !contains(github.event.pull_request.labels.*.name, 'no changelog')
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.setFailed('Please add a changelog entry!')
|
||||
- uses: mshick/[email protected]
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
if: always()
|
||||
with:
|
||||
message-id: changelog-entry-check
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
name: codemention
|
||||
on:
|
||||
# pull_request_target is required so fork PRs get a write token to post the
|
||||
# mention comment. Safe because there is no checkout — the action only reads
|
||||
# PR metadata via the GitHub API; no fork code is fetched or executed.
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, ready_for_review]
|
||||
jobs:
|
||||
@@ -9,6 +12,9 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: tobyhs/[email protected]
|
||||
# Pinned to a commit SHA (not the v1.4.0 tag) because this runs under
|
||||
# pull_request_target with a write-scoped token — a re-pointed tag from
|
||||
# a compromised upstream account would give the attacker repo write access.
|
||||
- uses: tobyhs/codemention@bb6bfb2c3ff1e6fee7ee37006bbee6d114057225 # v1.4.0
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
if: "${{ contains(github.event.label.name, 'incomplete issue: missing or invalid repro') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
if: "${{ contains(github.event.label.name, 'incomplete issue: missing info') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
if: github.event.label.name == 'issue accepted'
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
if: "${{ contains(github.event.label.name, 'invalid issue: question') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
if: "${{ contains(github.event.label.name, 'invalid issue: feature request') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
|
||||
steps:
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2
|
||||
with:
|
||||
registry-url: "https://registry.npmjs.org/"
|
||||
scope: "expo"
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
steps:
|
||||
- id: create_release
|
||||
uses: actions/create-release@v1
|
||||
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: ./.github/actions/setup-mise
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
cat /tmp/changelog.slack.md >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
- name: Slack Notification
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
|
||||
env:
|
||||
SLACK_CHANNEL: eas-cli
|
||||
SLACK_COLOR: ${{ job.status }}
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v4
|
||||
- uses: actions/stale@a20b814fb01b71def3bd6f56e7494d667ddf28da # v4
|
||||
with:
|
||||
ascending: false
|
||||
operations-per-run: 300
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
name: Ensure GraphQL schema and generated code is up-to-date
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
- run: yarn install --immutable
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
- run: yarn install --immutable
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
coverage: true
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
env:
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
working-directory: ./scripts
|
||||
env:
|
||||
YARN_ENABLE_HARDENED_MODE: ${{ matrix.coverage == true && '1' || '0' }}
|
||||
- uses: codecov/codecov-action@v1
|
||||
- uses: codecov/codecov-action@29386c70ef20e286228c72b668a06fd0e8399192 # v1
|
||||
if: ${{ matrix.coverage }}
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
name: Notify Slack
|
||||
if: ${{ github.ref == 'refs/heads/main' && always() }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
|
||||
- name: Notify Slack
|
||||
uses: ./.github/internal-actions/notify-slack-on-fail-or-recover
|
||||
with:
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
defaultSemverRangePrefix: ''
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 2d
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- '@expo/*'
|
||||
- 'eslint-plugin-universe'
|
||||
- 'oxlint-config-universe'
|
||||
@@ -12,6 +12,62 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [ci] Audit GitHub Actions workflows: harden `pull_request_target` usage, pin all external actions to commit SHAs, and add Dependabot to refresh them. ([#3718](https://github.com/expo/eas-cli/pull/3718) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [18.12.1](https://github.com/expo/eas-cli/releases/tag/v18.12.1) - 2026-05-12
|
||||
|
||||
## [18.12.0](https://github.com/expo/eas-cli/releases/tag/v18.12.0) - 2026-05-12
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Automatically save the Convex deployment URL as an EAS environment variable when connecting a Convex project. ([#3685](https://github.com/expo/eas-cli/pull/3685) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix `eas go` to use server-side project setup and support multiple Expo Go SDK versions. ([#3657](https://github.com/expo/eas-cli/pull/3657) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Update the minimum Expo version required for iOS precompiled modules. ([#3677](https://github.com/expo/eas-cli/pull/3677) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] recommend that users read the docs after connecting Convex ([#3683](https://github.com/expo/eas-cli/pull/3683) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
## [18.11.0](https://github.com/expo/eas-cli/releases/tag/v18.11.0) - 2026-05-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--simulator` to `eas build:dev` and `eas build:run` to select which iOS simulator to install and run builds on. ([#3637](https://github.com/expo/eas-cli/pull/3637) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix Convex team invite output after skipped or unnecessary invitations. ([#3672](https://github.com/expo/eas-cli/pull/3672) by [@fiberjw](https://github.com/fiberjw))
|
||||
- Make directories marked as read-only writable when creating a tar, to prevent the cross-filesystem issues when extracting the tar. ([#3489](https://github.com/expo/eas-cli/pull/3489) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## [18.10.0](https://github.com/expo/eas-cli/releases/tag/v18.10.0) - 2026-05-04
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas integrations:convex` commands to manage Convex integrations for EAS projects. ([#3575](https://github.com/expo/eas-cli/pull/3575) by [@fiberjw](https://github.com/fiberjw))
|
||||
- [eas-cli] New command `observe:logs` for custom events. ([#3638](https://github.com/expo/eas-cli/pull/3638) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Create portable project archives on all platforms to normalize cross-platform tar metadata and permissions. ([#3234](https://github.com/expo/eas-cli/pull/3234) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Remove hardcoded `builderEnvironment.image` override in `eas build:resign`. ([#3661](https://github.com/expo/eas-cli/pull/3661) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Fix `eas update --json` intermittently failing with JSON parse errors during "Computing project fingerprints" by passing `silent: true` to `@expo/fingerprint` to suppress subprocess stdout pollution. ([#3659](https://github.com/expo/eas-cli/pull/3659) by [@Mookiies](https://github.com/Mookiies))
|
||||
|
||||
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
|
||||
|
||||
## [18.9.0](https://github.com/expo/eas-cli/releases/tag/v18.9.0) - 2026-04-30
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas integrations:asc` commands to manage App Store Connect integrations for EAS projects. ([#3558](https://github.com/expo/eas-cli/pull/3558) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Allow `eas build:download` to accept a build ID. ([#3655](https://github.com/expo/eas-cli/pull/3655) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `--id` alias for `--build-id` flag in `eas build:download`. ([#3656](https://github.com/expo/eas-cli/pull/3656) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [steps] Fix workflow job context interpolation when a string contains multiple `${{ }}` expressions on the same line (for example a changelog built from two context values). ([#3644](https://github.com/expo/eas-cli/pull/3644) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [18.8.1](https://github.com/expo/eas-cli/releases/tag/v18.8.1) - 2026-04-23
|
||||
|
||||
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "18.8.0",
|
||||
"version": "18.12.2",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.8.0",
|
||||
"version": "18.12.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -38,14 +38,14 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.8.0",
|
||||
"@expo/eas-build-job": "18.12.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.8.0",
|
||||
"@expo/steps": "18.12.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { fs } from 'memfs';
|
||||
|
||||
const fsRealpath = fs.realpath;
|
||||
(fsRealpath as any).native = fsRealpath;
|
||||
|
||||
const fsRm = (path: string, options: object): Promise<void> => {
|
||||
return fs.promises.rm(path, options);
|
||||
};
|
||||
|
||||
module.exports = { ...fs.promises, realpath: fsRealpath, rm: fsRm };
|
||||
module.exports = { ...fs.promises, rm: fsRm };
|
||||
@@ -42,38 +42,42 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
|
||||
autoSubmitArgs.push('--auto-submit');
|
||||
}
|
||||
|
||||
const result = await spawn(
|
||||
cmd,
|
||||
[
|
||||
...args,
|
||||
'build:internal',
|
||||
'--platform',
|
||||
job.platform,
|
||||
'--profile',
|
||||
buildProfile,
|
||||
...autoSubmitArgs,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...env,
|
||||
EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds')
|
||||
.robotAccessToken,
|
||||
...extraEnv,
|
||||
EAS_PROJECT_ROOT: projectRootOverride,
|
||||
},
|
||||
logger,
|
||||
// This prevents printing stdout with job secrets and credentials to logs.
|
||||
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
|
||||
}
|
||||
);
|
||||
try {
|
||||
const result = await spawn(
|
||||
cmd,
|
||||
[
|
||||
...args,
|
||||
'build:internal',
|
||||
'--platform',
|
||||
job.platform,
|
||||
'--profile',
|
||||
buildProfile,
|
||||
...autoSubmitArgs,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...env,
|
||||
EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds')
|
||||
.robotAccessToken,
|
||||
...extraEnv,
|
||||
EAS_PROJECT_ROOT: projectRootOverride,
|
||||
},
|
||||
logger,
|
||||
// This prevents printing stdout with job secrets and credentials to logs.
|
||||
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
|
||||
}
|
||||
);
|
||||
|
||||
const stdout = result.stdout.toString();
|
||||
const parsed = JSON.parse(stdout);
|
||||
return validateEasBuildInternalResult({
|
||||
result: parsed,
|
||||
oldJob: job,
|
||||
});
|
||||
const stdout = result.stdout.toString();
|
||||
const parsed = JSON.parse(stdout);
|
||||
return validateEasBuildInternalResult({
|
||||
result: parsed,
|
||||
oldJob: job,
|
||||
});
|
||||
} catch (err: any) {
|
||||
throw new Error('Failed to run eas build:internal');
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { installPods } from '../pod';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const WORKING_DIR = '/workingdir';
|
||||
const PROJECT_DIR = path.join(WORKING_DIR, 'build');
|
||||
const IOS_DIR = path.join(PROJECT_DIR, 'ios');
|
||||
|
||||
function makeIosBuildContext({
|
||||
expoVersion,
|
||||
cocoapodsCacheUrl,
|
||||
usePrecompiledModules = true,
|
||||
}: {
|
||||
expoVersion?: string;
|
||||
cocoapodsCacheUrl?: string;
|
||||
usePrecompiledModules?: boolean;
|
||||
}): BuildContext<Ios.Job> {
|
||||
vol.fromJSON({
|
||||
[path.join(IOS_DIR, '.keep')]: '',
|
||||
...(expoVersion
|
||||
? {
|
||||
[path.join(PROJECT_DIR, 'node_modules/expo/package.json')]: JSON.stringify({
|
||||
version: expoVersion,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const job: Ios.Job = {
|
||||
...createTestIosJob(),
|
||||
builderEnvironment: {
|
||||
env: {
|
||||
...(usePrecompiledModules ? { EAS_USE_PRECOMPILED_MODULES: '1' } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return new BuildContext(job, {
|
||||
workingdir: WORKING_DIR,
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
...(cocoapodsCacheUrl ? { EAS_BUILD_COCOAPODS_CACHE_URL: cocoapodsCacheUrl } : {}),
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe(installPods, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
(spawn as jest.Mock).mockImplementation(command => {
|
||||
if (command === 'node') {
|
||||
return Promise.resolve({
|
||||
stdout: Buffer.from(path.join(PROJECT_DIR, 'node_modules/expo/package.json')),
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: '999.0.0' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'node',
|
||||
['--print', "require.resolve('expo/package.json')"],
|
||||
expect.objectContaining({
|
||||
cwd: PROJECT_DIR,
|
||||
env: expect.objectContaining({
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
}),
|
||||
stdio: 'pipe',
|
||||
})
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: IOS_DIR,
|
||||
env: expect.objectContaining({
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL:
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules/',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
|
||||
);
|
||||
});
|
||||
|
||||
it('proxies precompiled modules through CocoaPods cache when enabled', async () => {
|
||||
const ctx = makeIosBuildContext({
|
||||
expoVersion: '999.0.0',
|
||||
cocoapodsCacheUrl: 'https://cocoapods-cache.expo.test/',
|
||||
});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: IOS_DIR,
|
||||
env: expect.objectContaining({
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL:
|
||||
'https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: '55.0.17' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/^Detected expo=55\.0\.17; not enabling precompiled modules use because precompiled modules require expo>=/
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars when Expo version is invalid', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: 'invalid-version' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars when Expo version detection fails', async () => {
|
||||
const ctx = makeIosBuildContext({});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
|
||||
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars without the builder flag', async () => {
|
||||
const ctx = makeIosBuildContext({
|
||||
expoVersion: '999.0.0',
|
||||
usePrecompiledModules: false,
|
||||
});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(
|
||||
(spawn as jest.Mock).mock.calls[0][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
|
||||
).toBeUndefined();
|
||||
expect(ctx.logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { Env, Ios } from '@expo/eas-build-job';
|
||||
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.21';
|
||||
const PRECOMPILED_MODULES_BASE_URL =
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules/';
|
||||
|
||||
export async function installPods<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>,
|
||||
{ infoCallbackFn }: SpawnOptions
|
||||
@@ -12,6 +18,7 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
|
||||
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
||||
const cocoapodsDeploymentFlag = ctx.env['POD_INSTALL_DEPLOYMENT'] === '1' ? ['--deployment'] : [];
|
||||
const precompiledModulesEnv = await resolvePrecompiledModulesPodInstallEnvAsync(ctx);
|
||||
|
||||
return {
|
||||
spawnPromise: spawn('pod', ['install', ...verboseFlag, ...cocoapodsDeploymentFlag], {
|
||||
@@ -20,6 +27,7 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
env: {
|
||||
...ctx.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
...precompiledModulesEnv,
|
||||
},
|
||||
lineTransformer: (line?: string) => {
|
||||
if (
|
||||
@@ -35,3 +43,76 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>
|
||||
): Promise<Env> {
|
||||
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
|
||||
return {};
|
||||
}
|
||||
|
||||
let expoPackageVersion: string;
|
||||
try {
|
||||
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
|
||||
} catch (err) {
|
||||
ctx.logger.info(
|
||||
{ err },
|
||||
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
const validExpoPackageVersion = semver.valid(expoPackageVersion);
|
||||
if (!validExpoPackageVersion) {
|
||||
ctx.logger.info(
|
||||
`Detected expo=${expoPackageVersion}; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
if (semver.lt(validExpoPackageVersion, MIN_PRECOMPILED_MODULES_EXPO_VERSION)) {
|
||||
ctx.logger.info(
|
||||
`Detected expo=${validExpoPackageVersion}; not enabling precompiled modules use because precompiled modules require expo>=${MIN_PRECOMPILED_MODULES_EXPO_VERSION}.`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
const env: Env = {
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(ctx),
|
||||
};
|
||||
|
||||
ctx.logger.info(
|
||||
`Detected expo=${validExpoPackageVersion}; enabling precompiled modules use. Installing pods with additional environment variables.\n${Object.entries(
|
||||
env
|
||||
)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')}\nPrecompiled modules pod install environment is configured.`
|
||||
);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
async function getInstalledExpoPackageVersionAsync<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>
|
||||
): Promise<string> {
|
||||
const { stdout } = await spawn('node', ['--print', "require.resolve('expo/package.json')"], {
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
env: ctx.env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const expoPackageJsonPath = stdout.toString().trim();
|
||||
return (await fs.readJson(expoPackageJsonPath)).version;
|
||||
}
|
||||
|
||||
function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
|
||||
if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
|
||||
return PRECOMPILED_MODULES_BASE_URL;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
|
||||
return PRECOMPILED_MODULES_BASE_URL.replace(
|
||||
`${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
`${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
|
||||
);
|
||||
}
|
||||
@@ -35,12 +35,15 @@ import { createRestoreCacheFunction } from './functions/restoreCache';
|
||||
import { parseXcactivitylogFunction } from './functions/parseXcactivitylog';
|
||||
import { runFastlaneFunction } from './functions/runFastlane';
|
||||
import { runGradleFunction } from './functions/runGradle';
|
||||
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
|
||||
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
|
||||
import { createSaveCacheFunction } from './functions/saveCache';
|
||||
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
|
||||
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
|
||||
@@ -77,9 +80,11 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
generateGymfileFromTemplateFunction(),
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
createStartServeSimRemoteSessionBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
|
||||
createInstallPodsBuildFunction(),
|
||||
@@ -91,6 +96,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createUploadToAscBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(),
|
||||
];
|
||||
|
||||
if (ctx.hasBuildJob()) {
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { buildFlowNameToPathMap, readFlowName, walkDir } from '../maestroFlowDiscovery';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
|
||||
describe(readFlowName, () => {
|
||||
beforeEach(() => vol.reset());
|
||||
|
||||
it('returns top-level `name` when present', async () => {
|
||||
vol.fromJSON({
|
||||
'/p/login.yml': `appId: com.example\nname: Login Flow\n---\n- launchApp\n`,
|
||||
});
|
||||
expect(await readFlowName('/p/login.yml')).toBe('Login Flow');
|
||||
});
|
||||
|
||||
it('falls back to basename without extension when name absent', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `appId: com.example\n---\n- launchApp\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when name is empty string', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `name: ""\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when name is non-string', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `name: 42\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when YAML is malformed', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `appId: [unclosed\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when file is empty', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': '' });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when file does not exist', async () => {
|
||||
expect(await readFlowName('/p/missing.yml')).toBe('missing');
|
||||
});
|
||||
|
||||
it('only parses the first YAML document (commands document errors are ignored)', async () => {
|
||||
vol.fromJSON({
|
||||
'/p/login.yml': `name: Login\n---\n- this is: { malformed yaml\n`,
|
||||
});
|
||||
expect(await readFlowName('/p/login.yml')).toBe('Login');
|
||||
});
|
||||
|
||||
it('handles .yaml extension', async () => {
|
||||
vol.fromJSON({ '/p/checkout.yaml': `name: Checkout\n` });
|
||||
expect(await readFlowName('/p/checkout.yaml')).toBe('Checkout');
|
||||
});
|
||||
|
||||
it('handles a leading `---` document marker', async () => {
|
||||
vol.fromJSON({
|
||||
'/p/login.yml': `---\nappId: com.example\nname: Login\n---\n- launchApp\n`,
|
||||
});
|
||||
expect(await readFlowName('/p/login.yml')).toBe('Login');
|
||||
});
|
||||
});
|
||||
|
||||
describe(walkDir, () => {
|
||||
beforeEach(() => vol.reset());
|
||||
|
||||
it('collects all .yml/.yaml files recursively, sorted', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/p/flows/a.yml': '',
|
||||
'/p/flows/b.yaml': '',
|
||||
'/p/flows/sub/c.yml': '',
|
||||
'/p/flows/sub/d.YAML': '',
|
||||
'/p/flows/readme.md': '',
|
||||
});
|
||||
const out: string[] = [];
|
||||
await walkDir('/p/flows', new Set(), out, logger);
|
||||
expect(out.sort()).toEqual([
|
||||
'/p/flows/a.yml',
|
||||
'/p/flows/b.yaml',
|
||||
'/p/flows/sub/c.yml',
|
||||
'/p/flows/sub/d.YAML',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips files named config.yaml (case-insensitive)', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/p/flows/config.yaml': '',
|
||||
'/p/flows/CONFIG.yaml': '',
|
||||
'/p/flows/login.yml': '',
|
||||
});
|
||||
const out: string[] = [];
|
||||
await walkDir('/p/flows', new Set(), out, logger);
|
||||
expect(out.sort()).toEqual(['/p/flows/login.yml']);
|
||||
});
|
||||
|
||||
it('returns silently when realpath fails (e.g. missing dir)', async () => {
|
||||
const logger = createMockLogger();
|
||||
const out: string[] = [];
|
||||
await walkDir('/p/missing', new Set(), out, logger);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildFlowNameToPathMap, () => {
|
||||
beforeEach(() => vol.reset());
|
||||
|
||||
it('builds a map from a single file input (basename name)', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/flows/login.yml': 'appId: x\n' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['flows/login.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['login', 'flows/login.yml']]));
|
||||
});
|
||||
|
||||
it('uses top-level name when present', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/flows/a.yml': 'name: Login Flow\n' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['flows/a.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['Login Flow', 'flows/a.yml']]));
|
||||
});
|
||||
|
||||
it('walks a directory input and adds each flow', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/login.yml': 'name: Login\n',
|
||||
'/proj/.maestro/checkout.yml': '',
|
||||
'/proj/.maestro/sub/profile.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(
|
||||
new Map([
|
||||
['Login', '.maestro/login.yml'],
|
||||
['checkout', '.maestro/checkout.yml'],
|
||||
['profile', '.maestro/sub/profile.yml'],
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('merges file + directory inputs', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/a.yml': '',
|
||||
'/proj/extra/b.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro', 'extra/b.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(
|
||||
new Map([
|
||||
['a', '.maestro/a.yml'],
|
||||
['b', 'extra/b.yml'],
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('skips non-existent inputs with warn', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/flows/a.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['flows/missing.yml', 'flows/a.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['a', 'flows/a.yml']]));
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('flows/missing.yml'));
|
||||
});
|
||||
|
||||
it('skips workspace config.yaml inside directory inputs', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/config.yaml': 'flows:\n - "*.yml"\n',
|
||||
'/proj/.maestro/login.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
|
||||
});
|
||||
|
||||
it('does NOT skip config.yaml when passed as an explicit file input', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/config.yaml': 'name: Workspace Defaults\nflows:\n - "*.yml"\n',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro/config.yaml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['Workspace Defaults', '.maestro/config.yaml']]));
|
||||
});
|
||||
|
||||
it('returns null when two files share top-level name', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/a.yml': 'name: Login\n',
|
||||
'/proj/b.yml': 'name: Login\n',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['a.yml', 'b.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('"Login"'));
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('a.yml'));
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('b.yml'));
|
||||
});
|
||||
|
||||
it('returns null when two files share basename across directories', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/login/index.yml': '',
|
||||
'/proj/checkout/index.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['login', 'checkout'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when name collides with another file basename', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/login.yml': 'name: Other\n',
|
||||
'/proj/Other.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['login.yml', 'Other.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
});
|
||||
|
||||
it('dedupes the same physical file passed via overlapping inputs', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/.maestro/login.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro', '.maestro/login.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses absolute fallback when input file lives outside projectRoot', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/elsewhere/a.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['/elsewhere/a.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['a', '/elsewhere/a.yml']]));
|
||||
});
|
||||
|
||||
it('absolute directory input under projectRoot yields project-relative children', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/.maestro/a.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['/proj/.maestro'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['a', '.maestro/a.yml']]));
|
||||
});
|
||||
|
||||
it('returns null and warns on unexpected helper failure', async () => {
|
||||
const logger = createMockLogger();
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: [Symbol('bad') as unknown as string],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('buildFlowNameToPathMap failed unexpectedly')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +1,18 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import {
|
||||
parseFlowMetadata,
|
||||
copyLatestAttemptXml,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
} from '../maestroResultParser';
|
||||
|
||||
describe(parseFlowMetadata, () => {
|
||||
it('parses valid ai-*.json', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toEqual({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when flow_name is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when flow_file_path is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid JSON', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': 'not json',
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMaestroResults, () => {
|
||||
it('parses JUnit results and enriches with ai-*.json metadata', async () => {
|
||||
it('parses JUnit results and enriches with name→path map', async () => {
|
||||
vol.fromJSON({
|
||||
// JUnit XML (primary data)
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
@@ -69,18 +24,15 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Debug output (for flow_file_path)
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
const results = await parseMaestroResults(
|
||||
'/junit',
|
||||
new Map([
|
||||
['home', '.maestro/home.yml'],
|
||||
['login', '.maestro/login.yml'],
|
||||
])
|
||||
);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -108,7 +60,7 @@ describe(parseMaestroResults, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('calculates retryCount from timestamp directory occurrences', async () => {
|
||||
it('uses flow name as fallback path when nameToPath is null', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
@@ -118,49 +70,33 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Two timestamp dirs = 1 retry
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results[0].retryCount).toBe(1);
|
||||
});
|
||||
|
||||
it('uses flow name as fallback path when ai-*.json not found', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// No ai-*.json files
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
const results = await parseMaestroResults('/junit', null);
|
||||
expect(results[0].path).toBe('home');
|
||||
expect(results[0].retryCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty array when no JUnit files found', async () => {
|
||||
it('uses flow name as fallback path when name not in map', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/.gitkeep': '',
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
const results = await parseMaestroResults('/junit', new Map());
|
||||
expect(results[0].path).toBe('home');
|
||||
});
|
||||
|
||||
it('returns empty array when no JUnit files found', async () => {
|
||||
vol.fromJSON({ '/junit/.gitkeep': '' });
|
||||
|
||||
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -179,44 +115,13 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
|
||||
expect(results[0].tags).toEqual(['e2e', 'smoke']);
|
||||
expect(results[0].properties).toEqual({ env: 'staging' });
|
||||
});
|
||||
|
||||
it('handles reuse_devices=true (junit_report_directory == tests_directory)', async () => {
|
||||
vol.fromJSON({
|
||||
// Same directory for both JUnit and debug output
|
||||
'/maestro-tests/android-maestro-junit.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'home',
|
||||
path: '.maestro/home.yml',
|
||||
status: 'passed',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns per-attempt results when multiple JUnit files exist for the same flow', async () => {
|
||||
vol.fromJSON({
|
||||
// Attempt 0: login FAILED
|
||||
@@ -239,20 +144,11 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// ai-*.json metadata (2 timestamp dirs = 2 attempts)
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
const results = await parseMaestroResults('/junit', new Map([['login', '.maestro/login.yml']]));
|
||||
|
||||
// Should return 2 results — one per attempt
|
||||
// Should return 2 results — one per attempt; retryCount from filename
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -298,25 +194,15 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
|
||||
const results = await parseMaestroResults(
|
||||
'/junit-reports',
|
||||
new Map([
|
||||
['home', '.maestro/home.yml'],
|
||||
['login', '.maestro/login.yml'],
|
||||
])
|
||||
);
|
||||
|
||||
// 4 results: 2 flows × 2 attempts
|
||||
expect(results).toHaveLength(4);
|
||||
@@ -330,7 +216,6 @@ describe(parseMaestroResults, () => {
|
||||
|
||||
it('returns per-attempt results with sharding (multiple testsuites per attempt file)', async () => {
|
||||
vol.fromJSON({
|
||||
// Attempt 0: shard 1 has home (FAILED), shard 2 has login (PASSED)
|
||||
'/junit-reports/android-maestro-junit-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
@@ -344,7 +229,6 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Attempt 1: all passed across shards
|
||||
'/junit-reports/android-maestro-junit-attempt-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
@@ -356,25 +240,15 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
|
||||
const results = await parseMaestroResults(
|
||||
'/junit-reports',
|
||||
new Map([
|
||||
['home', '.maestro/home.yml'],
|
||||
['login', '.maestro/login.yml'],
|
||||
])
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(4);
|
||||
expect(results).toEqual([
|
||||
@@ -385,52 +259,8 @@ describe(parseMaestroResults, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('backward compat: reuse_devices=true with retries but old single JUnit file', async () => {
|
||||
it('handles reuse_devices=false (separate junit_report_directory, per-flow files)', async () => {
|
||||
vol.fromJSON({
|
||||
// Single overwritten JUnit (only has final attempt's results)
|
||||
'/maestro-tests/android-maestro-junit.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="2" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
|
||||
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// 2 timestamp dirs — both flows appear in both (entire suite retried)
|
||||
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/maestro-tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
'/maestro-tests/2026-01-28_055420/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/maestro-tests/2026-01-28_055420/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
|
||||
|
||||
// Both flows have 2 occurrences → retryCount = 1 for both
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
|
||||
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('handles reuse_devices=false (separate junit_report_directory)', async () => {
|
||||
vol.fromJSON({
|
||||
// JUnit in temp dir (per-flow files)
|
||||
'/tmp/maestro-reports-abc123/junit-report-flow-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
@@ -449,21 +279,14 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Debug output in default location
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults(
|
||||
'/tmp/maestro-reports-abc123',
|
||||
'/tests',
|
||||
'/root/project'
|
||||
new Map([
|
||||
['home', '.maestro/home.yml'],
|
||||
['login', '.maestro/login.yml'],
|
||||
])
|
||||
);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual(
|
||||
@@ -473,51 +296,6 @@ describe(parseMaestroResults, () => {
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('uses raw path when flow_file_path is outside project_root', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/somewhere/else/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results[0].path).toBe('/somewhere/else/.maestro/home.yml');
|
||||
});
|
||||
|
||||
it('filters out non-timestamp directories when scanning debug output', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/not-a-timestamp/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
// Only 1 occurrence (not-a-timestamp dir should be ignored)
|
||||
expect(results[0].retryCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseJUnitTestCases, () => {
|
||||
@@ -803,3 +581,310 @@ describe(parseJUnitTestCases, () => {
|
||||
expect(results[0].properties).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFailedFlowsFromJUnit', () => {
|
||||
it('returns the subset of input flow paths whose testcases failed', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites>
|
||||
<testsuite>
|
||||
<testcase name="Login" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Search" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Checkout" time="1.0"><failure>something</failure></testcase>
|
||||
</testsuite>
|
||||
</testsuites>`,
|
||||
});
|
||||
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
|
||||
nameToPath: new Map([
|
||||
['Login', 'flows/login.yaml'],
|
||||
['Search', 'flows/search.yaml'],
|
||||
['Checkout', 'flows/checkout.yaml'],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result).toEqual(['flows/checkout.yaml']);
|
||||
});
|
||||
|
||||
it('returns null when a failing testcase has no entry in nameToPath', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="Login" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Unknown" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/attempt-0.xml',
|
||||
nameToPath: new Map([['Login', 'flows/login.yaml']]),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when two failing testcases share the same name (collision)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="Duplicate" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="Duplicate" time="1.0"><failure>y</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
|
||||
nameToPath: new Map([['Duplicate', 'flows/a.yaml']]),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when a failing testcase shares a name with any other testcase (pass or fail)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="Shared" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Shared" time="2.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
|
||||
nameToPath: new Map([['Shared', 'flows/a.yaml']]),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when junit file does not exist', async () => {
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/missing.xml',
|
||||
nameToPath: new Map([['A', 'flows/a.yaml']]),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when junit file is malformed', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/bad.xml': 'this is not xml',
|
||||
});
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/bad.xml',
|
||||
nameToPath: new Map([['A', 'flows/a.yaml']]),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
|
||||
// fast-xml-parser can produce a partial parse from truncated XML — without
|
||||
// strict validation, retry-failed-only would only retry the visible failures and
|
||||
// silently skip flows that were cut off.
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="B"`, // intentionally truncated mid-tag
|
||||
});
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/attempt-0.xml',
|
||||
nameToPath: new Map([
|
||||
['A', 'flows/a.yaml'],
|
||||
['B', 'flows/b.yaml'],
|
||||
]),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when junit XML has unclosed tags (partial parse risk)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite>`, // missing </testsuites>
|
||||
});
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/attempt-0.xml',
|
||||
nameToPath: new Map([['A', 'flows/a.yaml']]),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeJUnitReports', () => {
|
||||
it('identity-copies a single attempt (preserves suite-level metadata)', async () => {
|
||||
// Single-attempt runs should land in final_report_path with the same
|
||||
// suite-level attributes (tests/failures/time) and non-testcase children
|
||||
// (e.g. <system-out>) that the legacy bash `cp` upload preserved. The
|
||||
// rebuild path used for multi-attempt merges drops these, so the single
|
||||
// case must short-circuit to a byte-equivalent copy.
|
||||
const input = `<?xml version="1.0"?>
|
||||
<testsuites>
|
||||
<testsuite name="Maestro Flows" tests="2" failures="0" time="3.0" device="Pixel 7">
|
||||
<testcase name="A" status="SUCCESS" time="1.0" />
|
||||
<testcase name="B" status="SUCCESS" time="2.0" />
|
||||
<system-out>boot complete</system-out>
|
||||
</testsuite>
|
||||
</testsuites>`;
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': input,
|
||||
});
|
||||
|
||||
await mergeJUnitReports({
|
||||
sourceDir: '/tmp/junit-reports',
|
||||
outputPath: '/tmp/final.xml',
|
||||
});
|
||||
|
||||
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
|
||||
expect(out).toBe(input);
|
||||
});
|
||||
|
||||
it('keeps the latest attempt per flow name across multiple files', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" status="SUCCESS" time="1.0" />
|
||||
<testcase name="B" time="2.0"><failure>bad</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/tmp/r/android-maestro-junit-attempt-1.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="B" status="SUCCESS" time="3.0" />
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
|
||||
|
||||
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
|
||||
const parsed = new XMLParser({ ignoreAttributes: false }).parse(out);
|
||||
const testcases = parsed.testsuites.testsuite.testcase;
|
||||
const a = testcases.find((t: any) => t['@_name'] === 'A');
|
||||
const b = testcases.find((t: any) => t['@_name'] === 'B');
|
||||
expect(a['@_status']).toBe('SUCCESS'); // from attempt 0
|
||||
expect(b['@_status']).toBe('SUCCESS'); // from attempt 1 (latest)
|
||||
expect(b.failure).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when source directory contains no *.xml files', async () => {
|
||||
// No per-attempt JUnit files were ever written (maestro crashed before
|
||||
// producing output). Phase 3 in runMaestroTests treats this as a SystemError
|
||||
// — without a throw here, mergeJUnitReports would silently write an empty
|
||||
// <testsuite> and the caller would upload a misleading empty report.
|
||||
vol.fromJSON({
|
||||
'/tmp/r/.gitkeep': '',
|
||||
});
|
||||
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow(/no \*\.xml files/);
|
||||
});
|
||||
|
||||
it('throws on invalid XML so caller can fall back to copyLatestAttemptXml', async () => {
|
||||
// Without a throw, mergeJUnitReports would silently emit an empty merged
|
||||
// document and the copy-latest fallback (which only triggers on throw)
|
||||
// would never run.
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': 'not xml at all',
|
||||
});
|
||||
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow(/invalid XML/);
|
||||
});
|
||||
|
||||
it('throws when every input has <testsuites> but no <testcase> elements', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite></testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow(/no parseable testcases/);
|
||||
});
|
||||
|
||||
it('throws when any input XML is malformed (even if others parse)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?><testsuites><testsuite><testcase name="A" status="SUCCESS" time="1.0"/></testsuite></testsuites>`,
|
||||
'/tmp/r/attempt-1.xml': 'garbage not xml',
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when an input XML is truncated mid-tag (would otherwise partial-parse)', async () => {
|
||||
// fast-xml-parser is lenient and can return partial results from truncated
|
||||
// XML. Without strict validation, mergeJUnitReports would emit a merged
|
||||
// report missing the cut-off flows and Phase 3's copy-latest fallback
|
||||
// would never run. Validation must reject the file.
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" status="SUCCESS" time="1.0"/>
|
||||
<testcase name="B"`, // truncated mid-tag
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when an input XML has unclosed tags (would otherwise partial-parse)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" status="SUCCESS" time="1.0"/>
|
||||
</testsuite>`, // missing </testsuites>
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when any input XML parses but has no testcases (even if others have testcases)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?><testsuites><testsuite><testcase name="A" status="SUCCESS" time="1.0"/></testsuite></testsuites>`,
|
||||
'/tmp/r/attempt-1.xml': `<?xml version="1.0"?><testsuites></testsuites>`,
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('preserves duplicate names within the same attempt', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="Dup" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Dup" status="SUCCESS" time="2.0" />
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
|
||||
|
||||
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
|
||||
const parsed = new XMLParser({ ignoreAttributes: false, isArray: n => n === 'testcase' }).parse(
|
||||
out
|
||||
);
|
||||
expect(parsed.testsuites.testsuite.testcase).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyLatestAttemptXml', () => {
|
||||
it('picks the highest attempt number', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': '<a/>',
|
||||
'/tmp/r/android-maestro-junit-attempt-1.xml': '<b/>',
|
||||
'/tmp/r/android-maestro-junit-attempt-2.xml': '<c/>',
|
||||
});
|
||||
await copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' });
|
||||
expect(await fs.readFile('/tmp/out.xml', 'utf-8')).toBe('<c/>');
|
||||
});
|
||||
|
||||
it('throws when source has no xml files', async () => {
|
||||
vol.fromJSON({ '/tmp/r/.keep': '' });
|
||||
await expect(
|
||||
copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,642 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import * as discovery from '../maestroFlowDiscovery';
|
||||
import * as parser from '../maestroResultParser';
|
||||
import { createMaestroTestsBuildFunction } from '../maestroTests';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/retry', () => ({
|
||||
...jest.requireActual('../../../utils/retry'),
|
||||
sleepAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
const SPAWN_SUCCESS = {
|
||||
status: 0,
|
||||
pid: 1,
|
||||
output: [''],
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
signal: null,
|
||||
} as any;
|
||||
|
||||
const rejectExit1 = (): Error => {
|
||||
const e: any = new Error('exit 1');
|
||||
e.status = 1;
|
||||
return e;
|
||||
};
|
||||
|
||||
function createStep(
|
||||
callInputs?: Record<string, unknown>,
|
||||
options: { env?: Record<string, string | undefined> } = {}
|
||||
): ReturnType<
|
||||
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
|
||||
> {
|
||||
const logger = createMockLogger();
|
||||
const fn = createMaestroTestsBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({ logger });
|
||||
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
|
||||
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
|
||||
}
|
||||
|
||||
describe('createMaestroTestsBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
// Default-stub the merge helper so tests that don't care about it don't
|
||||
// hit the real disk. Individual tests override this.
|
||||
jest.restoreAllMocks();
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
});
|
||||
|
||||
it('exports a factory that returns a BuildFunction instance', () => {
|
||||
expect(createMaestroTestsBuildFunction()).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets all outputs before running any flows', async () => {
|
||||
// Make spawn reject so the step still throws; we're locking in that outputs
|
||||
// are set BEFORE the spawn call (downstream `upload_artifact` fails if
|
||||
// `final_report_path` is empty).
|
||||
mockedSpawn.mockRejectedValue(new Error('stop'));
|
||||
|
||||
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow();
|
||||
|
||||
expect(step.getOutputValueByName('junit_report_directory')).toMatch(
|
||||
/\.maestro\/tests\/junit-reports$/
|
||||
);
|
||||
expect(step.getOutputValueByName('tests_directory')).toMatch(/\.maestro\/tests$/);
|
||||
expect(step.getOutputValueByName('final_report_path')).toMatch(
|
||||
/\.maestro\/tests\/android-maestro-junit\.xml$/
|
||||
);
|
||||
});
|
||||
|
||||
it('succeeds when maestro exits 0 on first attempt', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
const [cmd, args] = mockedSpawn.mock.calls[0];
|
||||
expect(cmd).toBe('maestro');
|
||||
expect(args).toEqual(expect.arrayContaining(['test', 'flows/a.yaml', 'flows/b.yaml']));
|
||||
expect(args).toEqual(expect.arrayContaining(['--format=JUNIT']));
|
||||
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
|
||||
});
|
||||
|
||||
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' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws SystemError for unknown-shape spawn rejections', async () => {
|
||||
mockedSpawn.mockRejectedValue(new Error('mystery'));
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws SystemError on signal-only spawn rejections (OOM / SIGTERM)', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('killed'), {
|
||||
status: null,
|
||||
signal: 'SIGKILL',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('treats reject-with-status as a retryable test failure', async () => {
|
||||
const rejectWithExit = (status: number): Error =>
|
||||
Object.assign(new Error(`maestro exited with non-zero code: ${status}`), {
|
||||
status,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
});
|
||||
mockedSpawn.mockRejectedValueOnce(rejectWithExit(1)).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
// Default `retries` is 0, so even though the first attempt rejects with
|
||||
// `status: 1`, no retry occurs and the post-loop check throws UserError.
|
||||
// Spawn ran once.
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries only the failed flows on the next attempt (junit mode)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Attempt 0 saw all 3
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
// Attempt 1 saw only the failed one
|
||||
const a1Args = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1Args).toContain('flows/b.yaml');
|
||||
expect(a1Args).not.toContain('flows/a.yaml');
|
||||
expect(a1Args).not.toContain('flows/c.yaml');
|
||||
});
|
||||
|
||||
it('retries all flows when parseFailedFlowsFromJUnit returns null', async () => {
|
||||
const spawnMock = mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(null);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Both attempts saw all 3 flows
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
expect(spawnMock.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('always retries all flows when output_format is not junit', async () => {
|
||||
const spawnMock = mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
expect(spawnMock.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('runs all original flows on every retry when retry_failed_only=false', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest
|
||||
.spyOn(parser, 'parseFailedFlowsFromJUnit')
|
||||
.mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
retry_failed_only: false,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Both attempts must see both flows in the original input order. Tail-slice
|
||||
// equality locks order, not just membership — guards against any future
|
||||
// failed-first reshuffling of the dumb-retry list.
|
||||
const a0 = mockedSpawn.mock.calls[0][1]!;
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a0.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
|
||||
expect(a1.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('subsets to failing flows on retry when retry_failed_only=true (default behaviour)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
retry_failed_only: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1).toContain('flows/b.yaml');
|
||||
expect(a1).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('falls back to all-flows retry when retry_failed_only=true but output_format!=junit', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'html',
|
||||
retry_failed_only: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to all-flows retry when nameToPath is null (duplicate flow names)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(discovery, 'buildFlowNameToPathMap').mockResolvedValue(null);
|
||||
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
retry_failed_only: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults retry_failed_only to true when omitted from inputs', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1).toContain('flows/b.yaml');
|
||||
expect(a1).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('writes merged junit on success', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mergeSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceDir: expect.stringContaining('junit-reports'),
|
||||
outputPath: expect.stringContaining('android-maestro-junit.xml'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to copyLatestAttemptXml when mergeJUnitReports throws a data error', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
|
||||
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(copySpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows merge and copy-latest failures when maestro succeeded', async () => {
|
||||
// Throwing on copy failure would mask the real reason for early-failing
|
||||
// maestro runs (bad YAML writes no *.xml) and would also wrap a user-side
|
||||
// failure as SystemError, which cancels the build's billing.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
|
||||
jest
|
||||
.spyOn(parser, 'copyLatestAttemptXml')
|
||||
.mockRejectedValue(Object.assign(new Error('no space'), { code: 'ENOSPC' }));
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces ERR_MAESTRO_TESTS_FAILED (not copy error) when maestro fails and copy fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('exit 1'), {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
jest.spyOn(parser, 'copyLatestAttemptXml').mockRejectedValue(new Error('No *.xml files found'));
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries are exhausted with a non-zero exit', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('exit 1'), {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/a.yaml']);
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: 2,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError early on empty flow_path', async () => {
|
||||
const step = createStep({ flow_path: [], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
|
||||
// whole-directory upload picks it up.
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
|
||||
expect(outputArg).not.toMatch(/junit-reports/);
|
||||
});
|
||||
|
||||
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'HTML',
|
||||
platform: 'ios',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/\.maestro\/tests\/ios-maestro-html\.html$/);
|
||||
});
|
||||
|
||||
it('does not include --output when output_format is undefined', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: '',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
expect(args.some(a => a.startsWith('--output'))).toBe(false);
|
||||
expect(args).not.toContain('--output');
|
||||
expect(args).not.toContain('--output=undefined');
|
||||
expect(args).not.toContain('--output=null');
|
||||
expect(args).not.toContain('--output=');
|
||||
});
|
||||
|
||||
it('accepts output_format casing variations (e.g. JUNIT) case-insensitively', async () => {
|
||||
// output_format.toLowerCase() must propagate end-to-end: final_report_path
|
||||
// populated, per-attempt XMLs written into junit-reports/, merge invoked.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'JUNIT',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Phase 1: final_report_path populated
|
||||
expect(step.getOutputValueByName('final_report_path')).toMatch(
|
||||
/\.maestro\/tests\/android-maestro-junit\.xml$/
|
||||
);
|
||||
// Retry loop: --output written into junit-reports/ (not legacy path)
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/junit-reports\/android-maestro-junit-attempt-0\.xml$/);
|
||||
// Phase 3: merge invoked
|
||||
expect(mergeSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceDir: expect.stringContaining('junit-reports'),
|
||||
outputPath: expect.stringContaining('android-maestro-junit.xml'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes include_tags, exclude_tags, and shards through to maestro argv', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
shards: 3,
|
||||
include_tags: 'smoke,critical',
|
||||
exclude_tags: 'slow',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('--shard-split=3');
|
||||
expect(args).toContain('--include-tags=smoke,critical');
|
||||
expect(args).toContain('--exclude-tags=slow');
|
||||
});
|
||||
|
||||
it('passes cwd and env to spawn so relative flow paths resolve correctly', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
|
||||
cwd?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
expect(spawnOptions).toBeDefined();
|
||||
expect(typeof spawnOptions.cwd).toBe('string');
|
||||
expect(spawnOptions.cwd!.length).toBeGreaterThan(0);
|
||||
expect(spawnOptions.env).toBeDefined();
|
||||
expect(spawnOptions.env!.HOME).toBe('/home/expo');
|
||||
});
|
||||
|
||||
it('exports MAESTRO_TESTS_DIR to maestro so flows can reference ${MAESTRO_TESTS_DIR}', async () => {
|
||||
// Public docs (EAS workflows pre-packaged-jobs) instruct users to write
|
||||
// screenshots/recordings into ${MAESTRO_TESTS_DIR}. The step must export
|
||||
// it in the spawn env.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
expect(spawnOptions.env!.MAESTRO_TESTS_DIR).toBe('/home/expo/.maestro/tests');
|
||||
});
|
||||
|
||||
it('uses env.HOME (not os.homedir()) for maestro tests output paths', async () => {
|
||||
// Even when os.homedir() differs, the step should derive paths from env.HOME
|
||||
// so maestro (which receives env) and the step (which inspects those paths)
|
||||
// agree on where reports land.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
},
|
||||
{ env: { HOME: '/custom/home' } }
|
||||
);
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('tests_directory')).toBe('/custom/home/.maestro/tests');
|
||||
expect(step.getOutputValueByName('junit_report_directory')).toBe(
|
||||
'/custom/home/.maestro/tests/junit-reports'
|
||||
);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
'/custom/home/.maestro/tests/android-maestro-junit.xml'
|
||||
);
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(
|
||||
/^--output=\/custom\/home\/\.maestro\/tests\/junit-reports\/android-maestro-junit-attempt-0\.xml$/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws SystemError when env.HOME is unset', async () => {
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws UserError on non-string entries in flow_path', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 2 as unknown as string],
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries is negative', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: -1,
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries is a non-integer number', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: 1.5,
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is zero', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 0 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is negative', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: -1 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is a non-integer', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 1.5 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('accepts shards: undefined (optional input)', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
|
||||
await step.executeAsync(); // should succeed, not throw
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,7 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
it('parses JUnit results and calls GraphQL mutation', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': JUNIT_PASS,
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
'/root/project/.maestro/home.yml': '',
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
@@ -83,7 +83,11 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
},
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
await createStep({
|
||||
callInputs: {
|
||||
flow_path: ['.maestro/home.yml'],
|
||||
},
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import yaml from 'yaml';
|
||||
|
||||
const YAML_EXT = /\.ya?ml$/i;
|
||||
const WORKSPACE_CONFIG_BASENAME = 'config.yaml';
|
||||
|
||||
export interface BuildFlowNameToPathMapArgs {
|
||||
inputFlowPaths: string[];
|
||||
projectRoot: string;
|
||||
logger: bunyan;
|
||||
}
|
||||
|
||||
interface FlowEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
realpath: string;
|
||||
}
|
||||
|
||||
export async function buildFlowNameToPathMap(
|
||||
args: BuildFlowNameToPathMapArgs
|
||||
): Promise<Map<string, string> | null> {
|
||||
try {
|
||||
const flows = await discoverFlows(args);
|
||||
return dedupAndDetectDuplicates(flows, args.logger);
|
||||
} catch (err: any) {
|
||||
args.logger.warn(`buildFlowNameToPathMap failed unexpectedly: ${err?.message ?? String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverFlows({
|
||||
inputFlowPaths,
|
||||
projectRoot,
|
||||
logger,
|
||||
}: BuildFlowNameToPathMapArgs): Promise<FlowEntry[]> {
|
||||
const realRootResult = await asyncResult(fs.realpath(projectRoot));
|
||||
const realRoot = realRootResult.ok ? realRootResult.value : projectRoot;
|
||||
const fileLists = await Promise.all(
|
||||
inputFlowPaths.map(async input => {
|
||||
const abs = path.resolve(projectRoot, input);
|
||||
const statResult = await asyncResult(fs.stat(abs));
|
||||
if (!statResult.ok) {
|
||||
logger.warn(`flow_path entry "${input}" not found, skipping`);
|
||||
return [];
|
||||
}
|
||||
const stat = statResult.value;
|
||||
if (stat.isFile() && YAML_EXT.test(abs)) {
|
||||
return [abs];
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
const out: string[] = [];
|
||||
await walkDir(abs, new Set(), out, logger);
|
||||
return out;
|
||||
}
|
||||
return [];
|
||||
})
|
||||
);
|
||||
return Promise.all(fileLists.flat().map(absFile => makeEntry(absFile, realRoot)));
|
||||
}
|
||||
|
||||
async function makeEntry(absFile: string, realRoot: string): Promise<FlowEntry> {
|
||||
const [name, realFileResult] = await Promise.all([
|
||||
readFlowName(absFile),
|
||||
asyncResult(fs.realpath(absFile)),
|
||||
]);
|
||||
const realFile = realFileResult.ok ? realFileResult.value : absFile;
|
||||
const rel = path.relative(realRoot, realFile);
|
||||
const value = rel.startsWith('..') || path.isAbsolute(rel) ? absFile : rel;
|
||||
return { name, path: value, realpath: realFile };
|
||||
}
|
||||
|
||||
function dedupAndDetectDuplicates(flows: FlowEntry[], logger: bunyan): Map<string, string> | null {
|
||||
const byKey = new Map<string, FlowEntry>();
|
||||
for (const f of flows) {
|
||||
if (!byKey.has(f.realpath)) {
|
||||
byKey.set(f.realpath, f);
|
||||
}
|
||||
}
|
||||
const unique = [...byKey.values()];
|
||||
|
||||
const nameToPath = new Map<string, string>();
|
||||
const duplicates = new Map<string, string[]>();
|
||||
for (const f of unique) {
|
||||
if (nameToPath.has(f.name)) {
|
||||
const existing = nameToPath.get(f.name)!;
|
||||
const list = duplicates.get(f.name) ?? [existing];
|
||||
list.push(f.path);
|
||||
duplicates.set(f.name, list);
|
||||
}
|
||||
nameToPath.set(f.name, f.path);
|
||||
}
|
||||
if (duplicates.size > 0) {
|
||||
for (const [name, paths] of duplicates) {
|
||||
logger.warn(`Duplicate Maestro flow name "${name}" across paths: ${paths.join(', ')}.`);
|
||||
}
|
||||
logger.warn(
|
||||
'Retry-failed-only is disabled for this run; will retry all flows on failure. ' +
|
||||
'Give each Maestro flow a unique name (file basename or top-level name) to enable retry-failed-only.'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return nameToPath;
|
||||
}
|
||||
|
||||
export async function walkDir(
|
||||
dir: string,
|
||||
visited: Set<string>,
|
||||
out: string[],
|
||||
logger: bunyan
|
||||
): Promise<void> {
|
||||
// Cycle protection: dedup on realpath (fall back to the original path when
|
||||
// realpath fails so we still make progress on missing or restricted dirs).
|
||||
const realResult = await asyncResult(fs.realpath(dir));
|
||||
const real = realResult.ok ? realResult.value : dir;
|
||||
if (visited.has(real)) {
|
||||
return;
|
||||
}
|
||||
visited.add(real);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch (err: any) {
|
||||
logger.warn(`readdir failed for ${dir}: ${err?.message ?? String(err)}`);
|
||||
return;
|
||||
}
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
const tgtResult = await asyncResult(fs.stat(full));
|
||||
if (!tgtResult.ok) {
|
||||
continue;
|
||||
}
|
||||
const tgt = tgtResult.value;
|
||||
if (tgt.isDirectory()) {
|
||||
await walkDir(full, visited, out, logger);
|
||||
} else if (tgt.isFile() && isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
} else if (entry.isDirectory()) {
|
||||
await walkDir(full, visited, out, logger);
|
||||
} else if (entry.isFile()) {
|
||||
if (isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFlowName(absFile: string): Promise<string> {
|
||||
const ext = path.extname(absFile);
|
||||
const fallback = path.basename(absFile, ext);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absFile, 'utf-8');
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let firstDoc;
|
||||
try {
|
||||
const docs = yaml.parseAllDocuments(content);
|
||||
firstDoc = docs[0];
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
if (!firstDoc || firstDoc.errors.length > 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = firstDoc.toJS();
|
||||
const name = parsed?.name;
|
||||
if (typeof name === 'string' && name.length > 0) {
|
||||
return name;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function isYamlFile(name: string): boolean {
|
||||
return YAML_EXT.test(name);
|
||||
}
|
||||
|
||||
function isWorkspaceConfig(name: string): boolean {
|
||||
return name.toLowerCase() === WORKSPACE_CONFIG_BASENAME;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface MaestroFlowResult {
|
||||
name: string;
|
||||
@@ -14,13 +13,8 @@ export interface MaestroFlowResult {
|
||||
properties: Record<string, string>;
|
||||
}
|
||||
|
||||
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
|
||||
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
|
||||
|
||||
export function extractFlowKey(filename: string, prefix: string): string | null {
|
||||
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
|
||||
const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
||||
|
||||
export interface JUnitTestCaseResult {
|
||||
name: string;
|
||||
@@ -38,11 +32,9 @@ const xmlParser = new XMLParser({
|
||||
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
||||
});
|
||||
|
||||
// Internal helper — not exported. Parses a single JUnit XML file.
|
||||
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
|
||||
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
const results: JUnitTestCaseResult[] = [];
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const parsed = xmlParser.parse(content);
|
||||
|
||||
const testsuites = parsed?.testsuites?.testsuite;
|
||||
@@ -106,11 +98,20 @@ async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]>
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed XML files
|
||||
// Malformed XML — return whatever we collected before the parser bailed.
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
return parseJUnitContent(content);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
@@ -124,49 +125,16 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: JUnitTestCaseResult[] = [];
|
||||
for (const xmlFile of xmlFiles) {
|
||||
results.push(...(await parseJUnitFile(path.join(junitDirectory, xmlFile))));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const FlowMetadataFileSchema = z.object({
|
||||
flow_name: z.string(),
|
||||
flow_file_path: z.string(),
|
||||
});
|
||||
|
||||
type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
|
||||
|
||||
/**
|
||||
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
|
||||
*
|
||||
* The file contains:
|
||||
* - `flow_name`: derived from the YAML `config.name` field if present, otherwise
|
||||
* the flow filename without extension.
|
||||
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
|
||||
* - `flow_file_path`: absolute path to the original flow YAML file.
|
||||
* - `outputs`: screenshot defect data (unused here).
|
||||
*
|
||||
* Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
|
||||
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
|
||||
*/
|
||||
export async function parseFlowMetadata(filePath: string): Promise<FlowMetadata | null> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
return FlowMetadataFileSchema.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const perFile = await Promise.all(
|
||||
xmlFiles.map(f => parseJUnitFile(path.join(junitDirectory, f)))
|
||||
);
|
||||
return perFile.flat();
|
||||
}
|
||||
|
||||
export async function parseMaestroResults(
|
||||
junitDirectory: string,
|
||||
testsDirectory: string,
|
||||
projectRoot: string
|
||||
nameToPath: Map<string, string> | null
|
||||
): Promise<MaestroFlowResult[]> {
|
||||
// 1. Parse JUnit XML files, tracking which file each result came from
|
||||
let junitEntries: string[];
|
||||
try {
|
||||
junitEntries = await fs.readdir(junitDirectory);
|
||||
@@ -195,54 +163,8 @@ export async function parseMaestroResults(
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. Parse ai-*.json from debug output for flow_file_path + retryCount
|
||||
const flowPathMap = new Map<string, string>(); // flowName → flowFilePath
|
||||
const flowOccurrences = new Map<string, number>(); // flowName → count
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(testsDirectory);
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
|
||||
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
|
||||
|
||||
for (const dir of timestampDirs) {
|
||||
const dirPath = path.join(testsDirectory, dir);
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fs.readdir(dirPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const flowKey = extractFlowKey(file, 'ai');
|
||||
if (!flowKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = await parseFlowMetadata(path.join(dirPath, file));
|
||||
if (!metadata) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track latest path (last timestamp dir wins)
|
||||
flowPathMap.set(metadata.flow_name, metadata.flow_file_path);
|
||||
|
||||
// Count occurrences for retryCount
|
||||
flowOccurrences.set(metadata.flow_name, (flowOccurrences.get(metadata.flow_name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Merge: JUnit results + ai-*.json metadata
|
||||
const results: MaestroFlowResult[] = [];
|
||||
|
||||
// Parse attempt index from filename pattern: *-attempt-N.*
|
||||
const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
||||
|
||||
// Group results by flow name
|
||||
const resultsByName = new Map<string, JUnitResultWithSource[]>();
|
||||
for (const entry of junitResultsWithSource) {
|
||||
const group = resultsByName.get(entry.result.name) ?? [];
|
||||
@@ -251,74 +173,259 @@ export async function parseMaestroResults(
|
||||
}
|
||||
|
||||
for (const [flowName, flowEntries] of resultsByName) {
|
||||
const flowFilePath = flowPathMap.get(flowName);
|
||||
const relativePath = flowFilePath
|
||||
? await relativizePathAsync(flowFilePath, projectRoot)
|
||||
: flowName;
|
||||
|
||||
if (flowEntries.length === 1) {
|
||||
// Single result for this flow — use ai-*.json occurrence count for retryCount
|
||||
// (backward compat with old-style single JUnit file that gets overwritten)
|
||||
const { result } = flowEntries[0];
|
||||
const occurrences = flowOccurrences.get(flowName) ?? 0;
|
||||
const retryCount = Math.max(0, occurrences - 1);
|
||||
const flowPath = nameToPath?.get(flowName) ?? flowName;
|
||||
// retryCount is derived from each entry's source filename (`attempt-N`);
|
||||
// a single entry from `report.xml` (no marker) collapses to attempt 0.
|
||||
const sorted = flowEntries
|
||||
.map(entry => {
|
||||
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
return { ...entry, attemptIndex };
|
||||
})
|
||||
.sort((a, b) => a.attemptIndex - b.attemptIndex);
|
||||
|
||||
for (const { result, attemptIndex } of sorted) {
|
||||
results.push({
|
||||
name: flowName,
|
||||
path: relativePath,
|
||||
path: flowPath,
|
||||
status: result.status,
|
||||
errorMessage: result.errorMessage,
|
||||
duration: result.duration,
|
||||
retryCount,
|
||||
retryCount: attemptIndex,
|
||||
tags: result.tags,
|
||||
properties: result.properties,
|
||||
});
|
||||
} else {
|
||||
// Multiple results — per-attempt JUnit files. Sort by attempt index from filename.
|
||||
const sorted = flowEntries
|
||||
.map(entry => {
|
||||
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
return { ...entry, attemptIndex };
|
||||
})
|
||||
.sort((a, b) => a.attemptIndex - b.attemptIndex);
|
||||
|
||||
for (const { result, attemptIndex } of sorted) {
|
||||
results.push({
|
||||
name: flowName,
|
||||
path: relativePath,
|
||||
status: result.status,
|
||||
errorMessage: result.errorMessage,
|
||||
duration: result.duration,
|
||||
retryCount: attemptIndex,
|
||||
tags: result.tags,
|
||||
properties: result.properties,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function relativizePathAsync(flowFilePath: string, projectRoot: string): Promise<string> {
|
||||
if (!path.isAbsolute(flowFilePath)) {
|
||||
return flowFilePath;
|
||||
/**
|
||||
* Returns the subset of `inputFlowPaths` whose testcases failed in the given
|
||||
* attempt's JUnit file, or `null` when the result cannot be trusted (caller
|
||||
* then falls back to dumb retry — re-run everything).
|
||||
*
|
||||
* Caller passes a precomputed `nameToPath` map built from the user's
|
||||
* `inputFlowPaths`. We translate failed testcase names to flow paths via
|
||||
* that map; unknown names → null.
|
||||
*/
|
||||
export async function parseFailedFlowsFromJUnit(args: {
|
||||
junitFile: string;
|
||||
nameToPath: Map<string, string>;
|
||||
}): Promise<string[] | null> {
|
||||
// fast-xml-parser is lenient — truncated XML can produce a partial parse
|
||||
// with some testcases dropped. Trusting that subset for retry-failed-only would
|
||||
// skip retries for cut-off flows; reject any malformed XML and fall back
|
||||
// to dumb retry.
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(args.junitFile, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (XMLValidator.validate(content) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
|
||||
let resolvedRoot = projectRoot;
|
||||
let resolvedFlow = flowFilePath;
|
||||
try {
|
||||
resolvedRoot = await fs.realpath(projectRoot);
|
||||
} catch {}
|
||||
try {
|
||||
resolvedFlow = await fs.realpath(flowFilePath);
|
||||
} catch {}
|
||||
|
||||
const relative = path.relative(resolvedRoot, resolvedFlow);
|
||||
if (relative.startsWith('..')) {
|
||||
return flowFilePath;
|
||||
const testcases = parseJUnitContent(content);
|
||||
if (testcases.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return relative;
|
||||
|
||||
const failing = testcases.filter(tc => tc.status === 'failed');
|
||||
if (failing.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Two testcases with the same name (pass+fail or fail+fail) make it
|
||||
// impossible to map back to a single input flow_path. Signal "unknown".
|
||||
// Complementary to the map-build duplicate check (which catches the case
|
||||
// where tag filter hides duplication from JUnit).
|
||||
const allNameCounts = new Map<string, number>();
|
||||
for (const tc of testcases) {
|
||||
allNameCounts.set(tc.name, (allNameCounts.get(tc.name) ?? 0) + 1);
|
||||
}
|
||||
for (const count of allNameCounts.values()) {
|
||||
if (count > 1) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const matched: string[] = [];
|
||||
for (const tc of failing) {
|
||||
const p = args.nameToPath.get(tc.name);
|
||||
if (p === undefined) {
|
||||
return null;
|
||||
}
|
||||
matched.push(p);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
|
||||
* attempt's <testcase> per unique flow name (latest determined from the
|
||||
* filename's `attempt-(\d+)` marker; files without the marker = attempt 0),
|
||||
* and writes a merged document to outputPath.
|
||||
*
|
||||
* Throws on empty/malformed/no-testcase input so the caller can fall back to
|
||||
* copyLatestAttemptXml — silently dropping bad attempts could keep stale
|
||||
* failure rows around and produce a misleading merged report.
|
||||
*/
|
||||
export async function mergeJUnitReports(args: {
|
||||
sourceDir: string;
|
||||
outputPath: string;
|
||||
}): Promise<void> {
|
||||
const entries = await fs.readdir(args.sourceDir);
|
||||
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
|
||||
|
||||
if (xmlFiles.length === 0) {
|
||||
throw new Error(`mergeJUnitReports: no *.xml files found in ${args.sourceDir}`);
|
||||
}
|
||||
|
||||
interface FileGroup {
|
||||
attemptIndex: number;
|
||||
filename: string;
|
||||
content: string;
|
||||
testcasesByName: Map<string, unknown[]>;
|
||||
}
|
||||
const contents = await Promise.all(
|
||||
xmlFiles.map(async f => ({
|
||||
filename: f,
|
||||
content: await fs.readFile(path.join(args.sourceDir, f), 'utf-8'),
|
||||
}))
|
||||
);
|
||||
const fileGroups: FileGroup[] = [];
|
||||
for (const { filename, content } of contents) {
|
||||
if (XMLValidator.validate(content) !== true) {
|
||||
throw new Error(`mergeJUnitReports: invalid XML in ${filename}`);
|
||||
}
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = xmlParser.parse(content);
|
||||
} catch (err) {
|
||||
throw new Error(`mergeJUnitReports: failed to parse ${filename}`, { cause: err });
|
||||
}
|
||||
const testsuites = parsed?.testsuites?.testsuite;
|
||||
if (!Array.isArray(testsuites)) {
|
||||
throw new Error(`mergeJUnitReports: no <testsuite> array in ${filename}`);
|
||||
}
|
||||
const match = filename.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
const testcasesByName = new Map<string, unknown[]>();
|
||||
for (const suite of testsuites) {
|
||||
const cases = suite?.testcase;
|
||||
if (!Array.isArray(cases)) {
|
||||
continue;
|
||||
}
|
||||
for (const tc of cases) {
|
||||
const name = tc?.['@_name'];
|
||||
if (typeof name !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const group = testcasesByName.get(name) ?? [];
|
||||
group.push(tc);
|
||||
testcasesByName.set(name, group);
|
||||
}
|
||||
}
|
||||
if (testcasesByName.size === 0) {
|
||||
throw new Error(`mergeJUnitReports: no parseable testcases in ${filename}`);
|
||||
}
|
||||
fileGroups.push({ attemptIndex, filename, content, testcasesByName });
|
||||
}
|
||||
|
||||
// Single attempt: copy the original XML so suite-level metadata (testsuite
|
||||
// attributes, <system-out>, etc.) survives. The rebuild path below would
|
||||
// collapse those to a single attribute-less <testsuite>.
|
||||
if (fileGroups.length === 1) {
|
||||
await fs.writeFile(args.outputPath, fileGroups[0].content);
|
||||
return;
|
||||
}
|
||||
|
||||
// For each unique name, pick the file with the highest attempt index that
|
||||
// contains it (ties broken by sorted filename — later wins). Preserve every
|
||||
// <testcase> element from the winning file for that name, so same-attempt
|
||||
// duplicates survive.
|
||||
const nameToWinningFile = new Map<string, FileGroup>();
|
||||
for (const group of fileGroups) {
|
||||
for (const name of group.testcasesByName.keys()) {
|
||||
const current = nameToWinningFile.get(name);
|
||||
if (!current || group.attemptIndex >= current.attemptIndex) {
|
||||
nameToWinningFile.set(name, group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit in first-seen order (iteration over `fileGroups` yields stable order
|
||||
// matching the sorted filename list).
|
||||
const testcases: unknown[] = [];
|
||||
const emitted = new Set<string>();
|
||||
for (const group of fileGroups) {
|
||||
for (const [name, cases] of group.testcasesByName) {
|
||||
if (emitted.has(name)) {
|
||||
continue;
|
||||
}
|
||||
const winner = nameToWinningFile.get(name);
|
||||
if (winner === group) {
|
||||
for (const tc of cases) {
|
||||
testcases.push(tc);
|
||||
}
|
||||
emitted.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const builder = new XMLBuilder({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
format: true,
|
||||
suppressEmptyNode: true,
|
||||
});
|
||||
const xml = builder.build({
|
||||
testsuites: {
|
||||
testsuite: {
|
||||
testcase: testcases,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fs.writeFile(args.outputPath, xml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
|
||||
* Used as a fallback when mergeJUnitReports fails due to data issues but the
|
||||
* step still needs to produce final_report_path.
|
||||
*
|
||||
* Throws if sourceDir contains no *.xml files or if the copy fails.
|
||||
*/
|
||||
export async function copyLatestAttemptXml(args: {
|
||||
sourceDir: string;
|
||||
outputPath: string;
|
||||
}): Promise<void> {
|
||||
const entries = await fs.readdir(args.sourceDir);
|
||||
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
|
||||
if (xmlFiles.length === 0) {
|
||||
throw new Error(`No *.xml files found in ${args.sourceDir}`);
|
||||
}
|
||||
|
||||
// Pick the file with the highest attempt index. Files without the marker are
|
||||
// treated as attempt 0. Ties are broken by sorted filename — later wins
|
||||
// (same rule as mergeJUnitReports).
|
||||
let winner = xmlFiles[0];
|
||||
let winnerAttempt = (() => {
|
||||
const m = winner.match(ATTEMPT_PATTERN);
|
||||
return m ? parseInt(m[1], 10) : 0;
|
||||
})();
|
||||
for (let i = 1; i < xmlFiles.length; i++) {
|
||||
const candidate = xmlFiles[i];
|
||||
const match = candidate.match(ATTEMPT_PATTERN);
|
||||
const attempt = match ? parseInt(match[1], 10) : 0;
|
||||
if (attempt >= winnerAttempt) {
|
||||
winner = candidate;
|
||||
winnerAttempt = attempt;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.copyFile(path.join(args.sourceDir, winner), args.outputPath);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowsFromJUnit,
|
||||
} from './maestroResultParser';
|
||||
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();
|
||||
|
||||
function parseInput<S extends z.ZodTypeAny>(
|
||||
schema: S,
|
||||
value: unknown,
|
||||
message: string
|
||||
): z.output<S> {
|
||||
const result = schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new UserError('ERR_MAESTRO_INVALID_INPUT', message, { cause: result.error });
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// ENOENT is excluded — "input XML missing" is a data issue, not a storage
|
||||
// fault, so the post-loop merge should fall through to copy-latest instead
|
||||
// of throwing.
|
||||
function isFilesystemError(err: any): boolean {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const code = err.code;
|
||||
return (
|
||||
code === 'ENOSPC' || code === 'EACCES' || code === 'EROFS' || code === 'EIO' || code === 'EPERM'
|
||||
);
|
||||
}
|
||||
|
||||
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
|
||||
// other declared formats pass an explicit path so downstream upload steps
|
||||
// know where to find the result.
|
||||
function buildMaestroArgs(args: {
|
||||
flow_path: string[];
|
||||
outputPath: string | null;
|
||||
output_format: string | undefined;
|
||||
shards: number | undefined;
|
||||
include_tags: string | undefined;
|
||||
exclude_tags: string | undefined;
|
||||
}): string[] {
|
||||
const out: string[] = ['test'];
|
||||
if (args.output_format) {
|
||||
out.push(`--format=${args.output_format.toUpperCase()}`);
|
||||
}
|
||||
if (args.outputPath) {
|
||||
out.push(`--output=${args.outputPath}`);
|
||||
}
|
||||
if (args.shards !== undefined) {
|
||||
out.push(`--shard-split=${args.shards}`);
|
||||
}
|
||||
if (args.include_tags) {
|
||||
out.push(`--include-tags=${args.include_tags}`);
|
||||
}
|
||||
if (args.exclude_tags) {
|
||||
out.push(`--exclude-tags=${args.exclude_tags}`);
|
||||
}
|
||||
out.push(...args.flow_path);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'maestro_tests',
|
||||
name: 'Run Maestro Tests',
|
||||
__metricsId: 'eas/maestro_tests',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'flow_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'retries',
|
||||
required: false,
|
||||
defaultValue: 0,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'retry_failed_only',
|
||||
required: false,
|
||||
defaultValue: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'shards',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'include_tags',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'exclude_tags',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'output_format',
|
||||
required: false,
|
||||
defaultValue: 'junit',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'platform',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
|
||||
BuildStepOutput.createProvider({ id: 'final_report_path', required: false }),
|
||||
BuildStepOutput.createProvider({ id: 'tests_directory', required: true }),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, outputs, env, signal }) => {
|
||||
const { logger, global } = stepCtx;
|
||||
const platformInput = inputs.platform.value as string | undefined;
|
||||
const outputFormat = (inputs.output_format.value as string | undefined)?.toLowerCase();
|
||||
const includeTags = inputs.include_tags.value as string | undefined;
|
||||
const excludeTags = inputs.exclude_tags.value as string | undefined;
|
||||
|
||||
const platform: 'ios' | 'android' =
|
||||
platformInput === 'ios' || platformInput === 'android'
|
||||
? platformInput
|
||||
: global.runtimePlatform === BuildRuntimePlatform.DARWIN
|
||||
? 'ios'
|
||||
: 'android';
|
||||
|
||||
// Paths derive from env.HOME (not os.homedir()). Maestro is spawned with
|
||||
// this env and writes debug output under $HOME/.maestro/tests; the step
|
||||
// must read from the same place or stale files leak across runs.
|
||||
const home = env.HOME;
|
||||
if (!home) {
|
||||
throw new SystemError('HOME env var is not set');
|
||||
}
|
||||
const testsDirectory = path.join(home, '.maestro', 'tests');
|
||||
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
|
||||
const finalReportPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
|
||||
: undefined;
|
||||
|
||||
// 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 };
|
||||
|
||||
// Outputs are published BEFORE any throw below so downstream
|
||||
// `if: always()` upload steps still see populated values when this
|
||||
// step fails early.
|
||||
outputs.tests_directory.set(testsDirectory);
|
||||
outputs.junit_report_directory.set(junitReportDirectory);
|
||||
if (finalReportPath !== undefined) {
|
||||
outputs.final_report_path.set(finalReportPath);
|
||||
}
|
||||
|
||||
const flowPaths = parseInput(
|
||||
FlowPathSchema,
|
||||
inputs.flow_path.value,
|
||||
'flow_path must be a non-empty array of non-empty strings.'
|
||||
);
|
||||
const retries = parseInput(
|
||||
RetriesSchema,
|
||||
inputs.retries.value,
|
||||
'retries must be a non-negative integer.'
|
||||
);
|
||||
const shards = parseInput(
|
||||
ShardsSchema,
|
||||
inputs.shards.value,
|
||||
'shards must be a positive integer.'
|
||||
);
|
||||
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
|
||||
|
||||
try {
|
||||
await fs.mkdir(junitReportDirectory, { recursive: true });
|
||||
} catch (err) {
|
||||
throw new SystemError('Failed to create JUnit report directory', { cause: err });
|
||||
}
|
||||
|
||||
// null → duplicate flow names; retry-failed-only disabled, fall through to
|
||||
// dumb retry (re-run everything) on failure.
|
||||
const nameToPath = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: flowPaths,
|
||||
projectRoot: stepCtx.workingDirectory,
|
||||
logger,
|
||||
});
|
||||
|
||||
// Retry loop. spawn-async error shapes:
|
||||
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
|
||||
// numeric err.status → maestro exited non-zero → retry.
|
||||
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
|
||||
// downgraded to "tests failed".
|
||||
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
|
||||
// flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
|
||||
// trusted; we then fall through to dumb retry (re-run everything).
|
||||
let flowsToRun: string[] = flowPaths;
|
||||
let lastAttemptExitCode: number | null = null;
|
||||
|
||||
const totalAttempts = retries + 1;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
const outputPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
|
||||
: outputFormat
|
||||
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
|
||||
: null;
|
||||
|
||||
const maestroArgs = buildMaestroArgs({
|
||||
flow_path: flowsToRun,
|
||||
outputPath,
|
||||
output_format: outputFormat,
|
||||
shards,
|
||||
include_tags: includeTags,
|
||||
exclude_tags: excludeTags,
|
||||
});
|
||||
logger.info(
|
||||
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
|
||||
);
|
||||
|
||||
try {
|
||||
await spawn('maestro', maestroArgs, {
|
||||
cwd: stepCtx.workingDirectory,
|
||||
env: spawnEnv,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
lastAttemptExitCode = 0;
|
||||
} catch (err: any) {
|
||||
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
|
||||
throw new SystemError('Failed to invoke maestro', { cause: err });
|
||||
}
|
||||
if (err && typeof err.status === 'number') {
|
||||
lastAttemptExitCode = err.status;
|
||||
} else {
|
||||
throw new SystemError('Unexpected spawn failure invoking maestro', { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
if (lastAttemptExitCode === 0 || attempt === retries) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (retryFailedOnly && outputFormat === 'junit' && outputPath && nameToPath) {
|
||||
const failed = await parseFailedFlowsFromJUnit({
|
||||
junitFile: outputPath,
|
||||
nameToPath,
|
||||
});
|
||||
if (failed !== null && failed.length > 0) {
|
||||
flowsToRun = failed;
|
||||
logger.info(
|
||||
`Test failed; retrying ${failed.length} failed flow(s): ${failed.join(', ')}`
|
||||
);
|
||||
} else {
|
||||
flowsToRun = flowPaths;
|
||||
logger.info('Test failed; could not determine failed subset, retrying all flows');
|
||||
}
|
||||
} else {
|
||||
flowsToRun = flowPaths;
|
||||
logger.info('Test failed, retrying all flows');
|
||||
}
|
||||
|
||||
await sleepAsync(2000);
|
||||
}
|
||||
|
||||
// Smart merge first; on data errors (bad XML, missing input) fall back
|
||||
// to copy-latest so the caller still gets a single JUnit file.
|
||||
// Filesystem errors short-circuit straight to SystemError.
|
||||
if (finalReportPath !== undefined) {
|
||||
try {
|
||||
await mergeJUnitReports({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
});
|
||||
} catch (mergeErr: any) {
|
||||
if (isFilesystemError(mergeErr)) {
|
||||
throw new SystemError('Failed to write final_report_path', { cause: mergeErr });
|
||||
}
|
||||
logger.warn({ err: mergeErr }, 'Smart merge failed; falling back to copy-latest.');
|
||||
try {
|
||||
await copyLatestAttemptXml({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
});
|
||||
} catch (copyErr: any) {
|
||||
// Swallow: a copy failure here usually means maestro itself failed
|
||||
// early (bad YAML wrote no *.xml). Throwing SystemError would mask
|
||||
// the real reason and cancel billing for a user-side failure — let
|
||||
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
|
||||
logger.warn(
|
||||
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The retry loop exits via success (0), numeric status (retryable),
|
||||
// or throw (infra). A non-null non-zero status means the user's tests
|
||||
// failed every attempt.
|
||||
if (lastAttemptExitCode !== 0) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_TESTS_FAILED',
|
||||
`Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
const FlowPathSchema = z.array(z.string().min(1)).min(1);
|
||||
|
||||
const CREATE_MUTATION = graphql(`
|
||||
mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
|
||||
workflowDeviceTestCaseResult {
|
||||
@@ -38,6 +42,11 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
defaultValue: '${{ env.HOME }}/.maestro/tests',
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'flow_path',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs }) => {
|
||||
const { logger } = stepsCtx;
|
||||
@@ -51,14 +60,26 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
|
||||
logger.info('No JUnit directory provided, skipping test results report');
|
||||
return;
|
||||
}
|
||||
const testsDirectory = inputs.tests_directory.value as string;
|
||||
|
||||
const flowPathRaw = inputs.flow_path.value;
|
||||
let nameToPath: Map<string, string> | null = null;
|
||||
if (flowPathRaw !== undefined) {
|
||||
const parsed = FlowPathSchema.safeParse(flowPathRaw);
|
||||
if (parsed.success) {
|
||||
nameToPath = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: parsed.data,
|
||||
projectRoot: stepsCtx.workingDirectory,
|
||||
logger,
|
||||
});
|
||||
} else {
|
||||
logger.warn(
|
||||
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const flowResults = await parseMaestroResults(
|
||||
junitDirectory,
|
||||
testsDirectory,
|
||||
stepsCtx.workingDirectory
|
||||
);
|
||||
const flowResults = await parseMaestroResults(junitDirectory, nameToPath);
|
||||
if (flowResults.length === 0) {
|
||||
logger.info('No maestro test results found, skipping report');
|
||||
return;
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
import { TurtleFetchError } from '../../utils/turtleFetch';
|
||||
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -277,9 +277,27 @@ export async function restoreGradleCacheAsync({
|
||||
logger,
|
||||
});
|
||||
|
||||
const hitType = matchedKey === cacheKey ? 'direct_hit' : 'prefix_match';
|
||||
logger.info(
|
||||
`Gradle cache restored to ${gradleCachesPath} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`
|
||||
`Gradle cache restored to ${gradleCachesPath} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
|
||||
);
|
||||
|
||||
try {
|
||||
await turtleFetch(new URL('v2/turtle-builds/logs', expoApiServerURL).toString(), 'POST', {
|
||||
json: {
|
||||
buildId: jobId,
|
||||
message: `Gradle cache restored (${hitType})`,
|
||||
tags: {
|
||||
event: 'gradle_cache_restored',
|
||||
cache_hit_type: hitType,
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
shouldThrowOnNotOk: false,
|
||||
});
|
||||
} catch {}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof TurtleFetchError && err.response?.status === 404) {
|
||||
logger.info('No Gradle cache found for this key');
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} 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 { CustomBuildContext } from '../../customBuildContext';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import {
|
||||
DetachedProcessHandle,
|
||||
ensureBrewPackageInstalledAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
spawnDetached,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
|
||||
const SRC_DIR = '/tmp/agent-device-src';
|
||||
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_agent_device_remote_session',
|
||||
name: 'Start agent device remote session',
|
||||
__metricsId: 'eas/start_agent_device_remote_session',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env }) => {
|
||||
// Fail fast before any expensive setup if the orchestrator-injected
|
||||
// DEVICE_RUN_SESSION_ID env var is missing — without it we cannot
|
||||
// report the remote config back to the API server.
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
}
|
||||
|
||||
logger.info('Ensuring cloudflared is installed.');
|
||||
const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
packageVersion
|
||||
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
|
||||
: `Cloning agent-device (latest) into ${SRC_DIR}.`
|
||||
);
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
||||
|
||||
logger.info('Installing agent-device dependencies.');
|
||||
await spawn('bun', ['install', '--production'], {
|
||||
cwd: SRC_DIR,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Launching agent-device daemon.');
|
||||
spawnDetached({
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: SRC_DIR,
|
||||
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
|
||||
});
|
||||
|
||||
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
|
||||
await waitForFileAsync({
|
||||
filePath: DAEMON_JSON_PATH,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'agent-device daemon',
|
||||
});
|
||||
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
|
||||
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
|
||||
|
||||
logger.info(`Starting cloudflared tunnel to http://localhost:${daemonPort}.`);
|
||||
const cloudflared = spawnDetached({
|
||||
command: cloudflaredCommand,
|
||||
args: ['tunnel', '--url', `http://localhost:${daemonPort}`],
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info('Waiting for a public tunnel URL.');
|
||||
const tunnelUrl = await waitForMatchInOutputAsync({
|
||||
process: cloudflared,
|
||||
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'cloudflared tunnel',
|
||||
});
|
||||
logger.info(`Tunnel is ready at ${tunnelUrl}.`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: { url: tunnelUrl, token: daemonToken },
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
|
||||
// Keep the turtle job alive so the daemon and tunnel stay reachable
|
||||
// until stopDeviceRunSession cancels the run.
|
||||
await new Promise<never>(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureCloudflaredInstalledAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<string> {
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
|
||||
return 'cloudflared';
|
||||
}
|
||||
if (await isCommandAvailableAsync({ command: 'cloudflared', env })) {
|
||||
return 'cloudflared';
|
||||
}
|
||||
const cloudflaredArch = cloudflaredLinuxArchForNodeArch(os.arch());
|
||||
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflaredArch}`;
|
||||
logger.info(`Downloading cloudflared from ${downloadUrl} to ${CLOUDFLARED_LINUX_INSTALL_PATH}.`);
|
||||
await spawn('sudo', ['curl', '-fsSL', '-o', CLOUDFLARED_LINUX_INSTALL_PATH, downloadUrl], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
await spawn('sudo', ['chmod', '+x', CLOUDFLARED_LINUX_INSTALL_PATH], { env, logger });
|
||||
// Return the absolute install path so the tunnel command works even when
|
||||
// /usr/local/bin is not on the step's PATH.
|
||||
return CLOUDFLARED_LINUX_INSTALL_PATH;
|
||||
}
|
||||
|
||||
function cloudflaredLinuxArchForNodeArch(arch: string): 'amd64' | 'arm64' {
|
||||
if (arch === 'x64') {
|
||||
return 'amd64';
|
||||
}
|
||||
if (arch === 'arm64') {
|
||||
return 'arm64';
|
||||
}
|
||||
throw new SystemError(
|
||||
`Unsupported architecture for cloudflared on Linux: "${arch}". Expected "x64" or "arm64".`
|
||||
);
|
||||
}
|
||||
|
||||
async function isCommandAvailableAsync({
|
||||
command,
|
||||
env,
|
||||
}: {
|
||||
command: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
await spawn('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneAgentDeviceAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
|
||||
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForMatchInOutputAsync({
|
||||
process,
|
||||
pattern,
|
||||
timeoutMs,
|
||||
description,
|
||||
}: {
|
||||
process: DetachedProcessHandle;
|
||||
pattern: RegExp;
|
||||
timeoutMs: number;
|
||||
description: string;
|
||||
}): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const match = pattern.exec(process.getOutput());
|
||||
if (match) {
|
||||
return match[1] ?? match[0];
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${description} to start. Last output:\n${process.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForFileAsync({
|
||||
filePath,
|
||||
timeoutMs,
|
||||
description,
|
||||
}: {
|
||||
filePath: string;
|
||||
timeoutMs: number;
|
||||
description: string;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
return;
|
||||
} catch {
|
||||
// not yet; keep polling
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(`Timed out waiting for ${description} to write ${filePath}.`);
|
||||
}
|
||||
|
||||
function readDaemonInfo(filePath: string): { port: number; token: string } {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
typeof (parsed as { httpPort: unknown }).httpPort !== 'number' ||
|
||||
typeof (parsed as { token: unknown }).token !== 'string'
|
||||
) {
|
||||
throw new SystemError(
|
||||
`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`
|
||||
);
|
||||
}
|
||||
const { httpPort, token } = parsed as { httpPort: number; token: string };
|
||||
return { port: httpPort, token };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import {
|
||||
DetachedProcessHandle,
|
||||
ensureBrewPackageInstalledAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
spawnDetached,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
const TRYCLOUDFLARE_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
||||
|
||||
export function createStartServeSimRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_serve_sim_remote_session',
|
||||
name: 'Start serve-sim remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
|
||||
logger.info('Starting serve-sim remote session.');
|
||||
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
|
||||
logger.info('Ensuring cloudflared is installed.');
|
||||
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
|
||||
|
||||
logger.info('Launching serve-sim with tunnel.');
|
||||
const serveSim = spawnDetached({
|
||||
command: 'npx',
|
||||
args: ['serve-sim-szdziedzic@latest', '--tunnel'],
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
|
||||
const { previewUrl, streamUrl } = await waitForServeSimUrlsAsync({
|
||||
serveSim,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Preview URL: ${previewUrl}`);
|
||||
logger.info(`Stream URL: ${streamUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: { previewUrl, streamUrl },
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
|
||||
// Keep the turtle job alive so the serve-sim tunnel stays reachable
|
||||
// until stopDeviceRunSession cancels the run.
|
||||
await new Promise<never>(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServeSimUrlsAsync({
|
||||
serveSim,
|
||||
timeoutMs,
|
||||
}: {
|
||||
serveSim: DetachedProcessHandle;
|
||||
timeoutMs: number;
|
||||
}): Promise<{ previewUrl: string; streamUrl: string }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const output = serveSim.getOutput();
|
||||
const previewUrl = matchLabeledUrl(output, 'Tunnel');
|
||||
const streamUrl = matchLabeledUrl(output, 'Stream');
|
||||
if (previewUrl && streamUrl) {
|
||||
return { previewUrl, streamUrl };
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for serve-sim to report Tunnel and Stream URLs. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
function matchLabeledUrl(content: string, label: string): string | null {
|
||||
const labelPattern = new RegExp(`${label}:\\s*(${TRYCLOUDFLARE_URL_PATTERN.source})`);
|
||||
const match = labelPattern.exec(content);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
@@ -146,6 +146,44 @@ const FIXTURE_NESTED = {
|
||||
],
|
||||
};
|
||||
|
||||
// Overlapping + gapped intervals with pairwise-distinct totals
|
||||
// (taskSeconds=11, wallSpan=12, activeWallTime=8). Distinct values catch a
|
||||
// regression that leaks wallSpan or taskSeconds into the rendered Active cell.
|
||||
const FIXTURE_OVERLAP_WITH_GAP = {
|
||||
schema: { name: 'OverlapWithGap' },
|
||||
subSteps: [
|
||||
{
|
||||
title: 'Build target GappedModule',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'cCompilation',
|
||||
duration: 5.0,
|
||||
startTimestamp: 100.0,
|
||||
endTimestamp: 105.0,
|
||||
signature: 'CompileC a.c',
|
||||
subSteps: [],
|
||||
},
|
||||
{
|
||||
detailStepType: 'cCompilation',
|
||||
duration: 4.0,
|
||||
startTimestamp: 102.0,
|
||||
endTimestamp: 106.0,
|
||||
signature: 'CompileC b.c',
|
||||
subSteps: [],
|
||||
},
|
||||
{
|
||||
detailStepType: 'cCompilation',
|
||||
duration: 2.0,
|
||||
startTimestamp: 110.0,
|
||||
endTimestamp: 112.0,
|
||||
signature: 'CompileC c.c',
|
||||
subSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockedDownloadFile = jest.mocked(downloadFile);
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
@@ -191,14 +229,14 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'),
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'unzip',
|
||||
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'), '-d', tempDir],
|
||||
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'), '-d', tempDir],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
@@ -244,14 +282,14 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
@@ -280,14 +318,14 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
@@ -478,17 +516,38 @@ describe('buildTargetMetrics', () => {
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod!.taskSeconds).toBeCloseTo(3.0);
|
||||
expect(mod!.wallSpan).toBe(0); // no valid intervals
|
||||
expect(mod!.activeWallTime).toBe(0); // no valid intervals
|
||||
});
|
||||
|
||||
it('computes activeWallTime by merging overlapping intervals and excluding gaps', () => {
|
||||
const { results } = buildTargetMetrics(FIXTURE_OVERLAP_WITH_GAP);
|
||||
const mod = results.find(r => r.moduleName === 'GappedModule');
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod!.taskSeconds).toBeCloseTo(11.0); // 5 + 4 + 2
|
||||
expect(mod!.wallSpan).toBeCloseTo(12.0); // 112 - 100
|
||||
expect(mod!.activeWallTime).toBeCloseTo(8.0); // (106-100) + (112-110)
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatReport', () => {
|
||||
it('produces table with header and module rows', () => {
|
||||
it('produces table with header, legend, and module rows', () => {
|
||||
const report = formatReport(FIXTURE_TWO_MODULES);
|
||||
expect(report).toContain('Xcode Build — Compile Metrics by Module');
|
||||
expect(report).toContain('ModuleA');
|
||||
expect(report).toContain('ModuleB');
|
||||
expect(report).toContain('TOTAL');
|
||||
expect(report).toContain('% Task');
|
||||
expect(report).toContain('% Sum');
|
||||
expect(report).toContain('Sum = sum of compile-step wall durations');
|
||||
expect(report).toContain('Active = merged compile-step wall time');
|
||||
expect(report).not.toContain('% Task');
|
||||
expect(report).not.toContain('Wall = ');
|
||||
});
|
||||
|
||||
it('renders Active using activeWallTime, not wallSpan', () => {
|
||||
const report = formatReport(FIXTURE_OVERLAP_WITH_GAP);
|
||||
expect(report).toContain('11.0s');
|
||||
expect(report).toContain('8.0s');
|
||||
expect(report).not.toContain('12.0s');
|
||||
});
|
||||
|
||||
it('does not include modules below threshold', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.46';
|
||||
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.47';
|
||||
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
|
||||
const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
|
||||
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
|
||||
@@ -183,6 +183,7 @@ type XcactivitylogData = z.infer<typeof XcactivitylogDataSchemaZ>;
|
||||
interface TargetMetric {
|
||||
moduleName: string;
|
||||
taskSeconds: number;
|
||||
// Not rendered; retained as secondary sort tie-breaker in buildTargetMetrics.
|
||||
wallSpan: number;
|
||||
activeWallTime: number;
|
||||
}
|
||||
@@ -358,19 +359,22 @@ export function formatReport(data: XcactivitylogData): string {
|
||||
lines.push(
|
||||
`Schema: ${typeof data.schema === 'string' ? data.schema : (data.schema?.name ?? 'unknown')}`
|
||||
);
|
||||
lines.push('% Task = share of total Compile Task Seconds');
|
||||
lines.push('Wall = first compile start to last compile end');
|
||||
lines.push(
|
||||
'Sum = sum of compile-step wall durations within the target; overlapping steps are counted separately'
|
||||
);
|
||||
lines.push('% Sum = share of total Sum');
|
||||
lines.push('Active = merged compile-step wall time, excluding idle gaps between compile steps');
|
||||
lines.push('');
|
||||
lines.push(header);
|
||||
lines.push(
|
||||
'│ ' +
|
||||
'Module'.padEnd(nameWidth) +
|
||||
' │ ' +
|
||||
'Task'.padStart(taskWidth) +
|
||||
'Sum'.padStart(taskWidth) +
|
||||
' │ ' +
|
||||
'% Task'.padStart(pctWidth) +
|
||||
'% Sum'.padStart(pctWidth) +
|
||||
' │ ' +
|
||||
'Wall'.padStart(wallWidth) +
|
||||
'Active'.padStart(wallWidth) +
|
||||
' │ ' +
|
||||
' '.repeat(barMaxWidth) +
|
||||
' │'
|
||||
@@ -390,7 +394,7 @@ export function formatReport(data: XcactivitylogData): string {
|
||||
' │ ' +
|
||||
`${pct.toFixed(1)}%`.padStart(pctWidth) +
|
||||
' │ ' +
|
||||
formatSeconds(result.wallSpan).padStart(wallWidth) +
|
||||
formatSeconds(result.activeWallTime).padStart(wallWidth) +
|
||||
' │ ' +
|
||||
bar +
|
||||
' │'
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
||||
deviceRunSession {
|
||||
startDeviceRunSession(
|
||||
deviceRunSessionId: $deviceRunSessionId
|
||||
remoteConfig: $remoteConfig
|
||||
) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string {
|
||||
const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID;
|
||||
if (!deviceRunSessionId) {
|
||||
throw new SystemError(
|
||||
'DEVICE_RUN_SESSION_ID is not set. ' +
|
||||
'This step must run as part of a device run session created by the API server, ' +
|
||||
'which injects DEVICE_RUN_SESSION_ID into the job environment.'
|
||||
);
|
||||
}
|
||||
return deviceRunSessionId;
|
||||
}
|
||||
|
||||
export async function uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig,
|
||||
logger,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
remoteConfig: Record<string, unknown>;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
logger.info(
|
||||
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
|
||||
);
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(START_DEVICE_RUN_SESSION_MUTATION, { deviceRunSessionId, remoteConfig })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw new SystemError(
|
||||
`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureBrewPackageInstalledAsync({
|
||||
name,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
name: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
await spawn(
|
||||
'bash',
|
||||
['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`],
|
||||
{ env, logger }
|
||||
);
|
||||
}
|
||||
|
||||
export type DetachedProcessHandle = {
|
||||
getOutput: () => string;
|
||||
};
|
||||
|
||||
export function spawnDetached({
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
}: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env: BuildStepEnv;
|
||||
}): DetachedProcessHandle {
|
||||
const promise = spawn(command, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
// We don't await the process — it should outlive this step. Failures show
|
||||
// up in the captured output; suppress unhandled rejections here.
|
||||
promise.catch(() => {});
|
||||
promise.child.unref();
|
||||
|
||||
let output = '';
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
output += chunk.toString();
|
||||
};
|
||||
promise.child.stdout?.on('data', appendChunk);
|
||||
promise.child.stderr?.on('data', appendChunk);
|
||||
|
||||
return { getOutput: () => output };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "18.8.0",
|
||||
"version": "18.12.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "18.8.0"
|
||||
"@expo/steps": "18.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "18.8.0",
|
||||
"version": "18.12.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -266,6 +266,9 @@ describe('Android.JobSchema', () => {
|
||||
customBuildConfig: {
|
||||
path: 'production.android.yml',
|
||||
},
|
||||
outputs: {
|
||||
android_build_type: 'apk',
|
||||
},
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
};
|
||||
@@ -340,6 +343,28 @@ describe('Android.JobSchema', () => {
|
||||
expect(error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('preserves outputs for build jobs', () => {
|
||||
const job = {
|
||||
mode: BuildMode.BUILD,
|
||||
type: Workflow.GENERIC,
|
||||
platform: Platform.ANDROID,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.URL,
|
||||
url: 'https://expo.dev/builds/123',
|
||||
},
|
||||
projectRootDirectory: '.',
|
||||
outputs: {
|
||||
android_build_type: 'apk',
|
||||
},
|
||||
secrets,
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
};
|
||||
const { value, error } = Android.JobSchema.validate(job, joiOptions);
|
||||
expect(value).toMatchObject(job);
|
||||
expect(error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('can set hooks with arbitrary names', () => {
|
||||
const job = {
|
||||
mode: BuildMode.BUILD,
|
||||
|
||||
@@ -77,6 +77,9 @@ describe('Ios.JobSchema', () => {
|
||||
},
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
outputs: {
|
||||
resign_job: 'true',
|
||||
},
|
||||
};
|
||||
|
||||
const { value, error } = Ios.JobSchema.validate(genericJob, joiOptions);
|
||||
@@ -180,6 +183,9 @@ describe('Ios.JobSchema', () => {
|
||||
customBuildConfig: {
|
||||
path: 'production.ios.yml',
|
||||
},
|
||||
outputs: {
|
||||
ios_build_type: 'simulator',
|
||||
},
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
};
|
||||
@@ -254,6 +260,30 @@ describe('Ios.JobSchema', () => {
|
||||
expect(error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('preserves outputs for build jobs', () => {
|
||||
const job = {
|
||||
mode: BuildMode.BUILD,
|
||||
type: Workflow.UNKNOWN,
|
||||
platform: Platform.IOS,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.URL,
|
||||
url: 'https://expo.dev/builds/123',
|
||||
},
|
||||
projectRootDirectory: '.',
|
||||
outputs: {
|
||||
ios_build_type: 'simulator',
|
||||
},
|
||||
secrets: {
|
||||
buildCredentials,
|
||||
},
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
};
|
||||
const { value, error } = Ios.JobSchema.validate(job, joiOptions);
|
||||
expect(value).toMatchObject(job);
|
||||
expect(error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('invalid generic job', () => {
|
||||
const genericJob = {
|
||||
secrets: {
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
EnvironmentSecretsSchema,
|
||||
Hooks,
|
||||
HooksSchema,
|
||||
JobOutputs,
|
||||
JobOutputsSchema,
|
||||
Platform,
|
||||
StaticWorkflowInterpolationContext,
|
||||
StaticWorkflowInterpolationContextZ,
|
||||
@@ -110,7 +112,7 @@ export interface Job {
|
||||
};
|
||||
hooks?: Hooks;
|
||||
steps?: Step[];
|
||||
outputs?: Record<string, string>;
|
||||
outputs?: JobOutputs;
|
||||
|
||||
experimental?: {
|
||||
prebuildCommand?: string;
|
||||
@@ -176,6 +178,7 @@ export const JobSchema = Joi.object({
|
||||
buildType: Joi.string().valid(...Object.values(BuildType)),
|
||||
username: Joi.string(),
|
||||
hooks: HooksSchema,
|
||||
outputs: JobOutputsSchema,
|
||||
|
||||
experimental: Joi.object({
|
||||
prebuildCommand: Joi.string(),
|
||||
|
||||
@@ -352,6 +352,10 @@ export type DynamicInterpolationContext = {
|
||||
export type WorkflowInterpolationContext = StaticWorkflowInterpolationContext &
|
||||
DynamicInterpolationContext;
|
||||
|
||||
export type JobOutputs = Record<string, string>;
|
||||
export const JobOutputsSchema = Joi.object<JobOutputs>().pattern(Joi.string(), Joi.string());
|
||||
export const JobOutputsSchemaZ = z.record(z.string(), z.string());
|
||||
|
||||
export const CustomBuildConfigSchema = Joi.object().when('.mode', {
|
||||
is: [BuildMode.CUSTOM, BuildMode.REPACK],
|
||||
then: Joi.object().when('.customBuildConfig.path', {
|
||||
@@ -361,7 +365,6 @@ export const CustomBuildConfigSchema = Joi.object().when('.mode', {
|
||||
path: Joi.string().required(),
|
||||
}).required(),
|
||||
steps: Joi.any().strip(),
|
||||
outputs: Joi.any().strip(),
|
||||
}),
|
||||
otherwise: Joi.object({
|
||||
customBuildConfig: Joi.any().strip(),
|
||||
@@ -369,13 +372,11 @@ export const CustomBuildConfigSchema = Joi.object().when('.mode', {
|
||||
.items(Joi.any())
|
||||
.required()
|
||||
.custom(steps => validateSteps(steps), 'steps validation'),
|
||||
outputs: Joi.object().pattern(Joi.string(), Joi.string()).required(),
|
||||
}),
|
||||
}),
|
||||
otherwise: Joi.object({
|
||||
customBuildConfig: Joi.any().strip(),
|
||||
steps: Joi.any().strip(),
|
||||
outputs: Joi.any().strip(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
BuildTrigger,
|
||||
EnvironmentSecretZ,
|
||||
HooksZ,
|
||||
JobOutputsSchemaZ,
|
||||
StaticWorkflowInterpolationContextZ,
|
||||
} from './common';
|
||||
import { StepZ } from './step';
|
||||
@@ -48,7 +49,7 @@ export namespace Generic {
|
||||
|
||||
hooks: HooksZ.optional(),
|
||||
steps: z.array(StepZ).min(1),
|
||||
outputs: z.record(z.string(), z.string()).optional(),
|
||||
outputs: JobOutputsSchemaZ.optional(),
|
||||
});
|
||||
|
||||
export type PartialJob = z.infer<typeof PartialJobZ>;
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
EnvironmentSecretsSchema,
|
||||
Hooks,
|
||||
HooksSchema,
|
||||
JobOutputs,
|
||||
JobOutputsSchema,
|
||||
Platform,
|
||||
StaticWorkflowInterpolationContext,
|
||||
StaticWorkflowInterpolationContextZ,
|
||||
@@ -124,7 +126,7 @@ export interface Job {
|
||||
};
|
||||
hooks?: Hooks;
|
||||
steps?: Step[];
|
||||
outputs?: Record<string, string>;
|
||||
outputs?: JobOutputs;
|
||||
|
||||
experimental?: {
|
||||
prebuildCommand?: string;
|
||||
@@ -210,6 +212,7 @@ export const JobSchema = Joi.object({
|
||||
|
||||
username: Joi.string(),
|
||||
hooks: HooksSchema,
|
||||
outputs: JobOutputsSchema,
|
||||
|
||||
experimental: Joi.object({
|
||||
prebuildCommand: Joi.string(),
|
||||
|
||||
@@ -50,7 +50,7 @@ yarn generate-graphql-code # Generate TypeScript types from GraphQL schema
|
||||
yarn verify-graphql-code # Verify GraphQL code is up to date (CI check)
|
||||
```
|
||||
|
||||
The GraphQL schema is fetched from `https://staging-api.expo.dev/graphql` and generates:
|
||||
The GraphQL schema is fetched from the API URL resolved by `src/api.ts` and generates:
|
||||
|
||||
- `src/graphql/generated.ts` - 499KB+ of TypeScript types
|
||||
- Types for all queries, mutations, and fragments
|
||||
|
||||
+299
-96
@@ -117,6 +117,16 @@ eas --help COMMAND
|
||||
* [`eas help [COMMAND]`](#eas-help-command)
|
||||
* [`eas init`](#eas-init)
|
||||
* [`eas init:onboarding [TARGET_PROJECT_DIRECTORY]`](#eas-initonboarding-target_project_directory)
|
||||
* [`eas integrations:asc:connect`](#eas-integrationsascconnect)
|
||||
* [`eas integrations:asc:disconnect`](#eas-integrationsascdisconnect)
|
||||
* [`eas integrations:asc:status`](#eas-integrationsascstatus)
|
||||
* [`eas integrations:convex:connect`](#eas-integrationsconvexconnect)
|
||||
* [`eas integrations:convex:dashboard`](#eas-integrationsconvexdashboard)
|
||||
* [`eas integrations:convex:project`](#eas-integrationsconvexproject)
|
||||
* [`eas integrations:convex:project:delete`](#eas-integrationsconvexprojectdelete)
|
||||
* [`eas integrations:convex:team`](#eas-integrationsconvexteam)
|
||||
* [`eas integrations:convex:team:delete [CONVEX_TEAM]`](#eas-integrationsconvexteamdelete-convex_team)
|
||||
* [`eas integrations:convex:team:invite [CONVEX_TEAM]`](#eas-integrationsconvexteaminvite-convex_team)
|
||||
* [`eas login`](#eas-login)
|
||||
* [`eas logout`](#eas-logout)
|
||||
* [`eas metadata:lint`](#eas-metadatalint)
|
||||
@@ -180,7 +190,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -197,7 +207,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -219,7 +229,7 @@ DESCRIPTION
|
||||
view account usage and billing for the current cycle
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -236,7 +246,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -250,7 +260,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -302,7 +312,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -323,7 +333,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -343,7 +353,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -363,7 +373,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -386,7 +396,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -424,7 +434,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -444,7 +454,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -462,7 +472,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -482,7 +492,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -490,13 +500,16 @@ run dev client simulator/emulator build with matching fingerprint or create a ne
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found] [--skip-bundler]
|
||||
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found] [--skip-bundler] [--simulator
|
||||
<value>]
|
||||
|
||||
FLAGS
|
||||
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. It must be a profile allowing to create
|
||||
emulator/simulator internal distribution dev client builds. The "development-simulator"
|
||||
build profile will be selected by default.
|
||||
-p, --platform=<option> <options: ios|android>
|
||||
--simulator=<value> iOS simulator name or UDID to install and run the development build on. If no value is
|
||||
provided, you will be prompted to select a simulator.
|
||||
--skip-build-if-not-found Skip build if no successful build with matching fingerprint is found.
|
||||
--skip-bundler Install and run the development build without starting the bundler server.
|
||||
|
||||
@@ -504,28 +517,34 @@ DESCRIPTION
|
||||
run dev client simulator/emulator build with matching fingerprint or create a new one
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
download simulator/emulator builds for a given fingerprint hash
|
||||
download a simulator/emulator build by build ID or fingerprint hash
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas build:download --fingerprint <value> [-p ios|android] [--dev-client] [--json] [--non-interactive]
|
||||
$ eas build:download [--build-id <value> | --fingerprint <value> | -p ios|android | --dev-client]
|
||||
[--all-artifacts] [--json] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
-p, --platform=<option> <options: ios|android>
|
||||
--all-artifacts Download all available build artifacts (build artifacts archive, Xcode logs, etc.) in
|
||||
addition to the application archive. Without this flag, only the application archive is
|
||||
downloaded and the command errors if it is missing.
|
||||
--build-id=<value> ID of the build to download. Mutually exclusive with --fingerprint, --platform, and
|
||||
--dev-client; the platform is derived from the build itself.
|
||||
--[no-]dev-client Filter only dev-client builds.
|
||||
--fingerprint=<value> (required) Fingerprint hash of the build to download
|
||||
--fingerprint=<value> Fingerprint hash of the build to download
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
download simulator/emulator builds for a given fingerprint hash
|
||||
download a simulator/emulator build by build ID or fingerprint hash
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -563,7 +582,7 @@ DESCRIPTION
|
||||
inspect the state of the project at specific build stages, useful for troubleshooting
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -603,7 +622,7 @@ DESCRIPTION
|
||||
list all builds for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -633,7 +652,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -642,7 +661,7 @@ run simulator/emulator builds from eas-cli
|
||||
```
|
||||
USAGE
|
||||
$ eas build:run [--latest | --id <value> | --path <value> | --url <value>] [-p android|ios] [-e PROFILE_NAME]
|
||||
[--offset <value>] [--limit <value>]
|
||||
[--simulator <value>] [--offset <value>] [--limit <value>]
|
||||
|
||||
FLAGS
|
||||
-e, --profile=PROFILE_NAME Name of the build profile used to create the build to run. When specified, only builds
|
||||
@@ -653,13 +672,15 @@ FLAGS
|
||||
--limit=<value> The number of items to fetch each query. Defaults to 50 and is capped at 100.
|
||||
--offset=<value> Start queries from specified index. Use for paginating results. Defaults to 0.
|
||||
--path=<value> Path to the simulator/emulator build archive or app
|
||||
--simulator=<value> iOS simulator name or UDID to install and run the build on. If no value is provided, you
|
||||
will be prompted to select a simulator.
|
||||
--url=<value> Simulator/Emulator build archive url
|
||||
|
||||
DESCRIPTION
|
||||
run simulator/emulator builds from eas-cli
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -711,7 +732,7 @@ DESCRIPTION
|
||||
get the latest version from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -729,7 +750,7 @@ DESCRIPTION
|
||||
update version of an app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -747,7 +768,7 @@ DESCRIPTION
|
||||
update a version in native code with a value stored on EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -764,7 +785,7 @@ DESCRIPTION
|
||||
view a build for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -785,7 +806,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -806,7 +827,7 @@ DESCRIPTION
|
||||
Delete a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -828,7 +849,7 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:insights`
|
||||
|
||||
@@ -852,7 +873,7 @@ DESCRIPTION
|
||||
display adoption, crash, and unique-user insights for a channel + runtime version
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/insights.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/channel/insights.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -872,7 +893,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -894,7 +915,7 @@ DESCRIPTION
|
||||
pause a channel to stop it from sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -916,7 +937,7 @@ DESCRIPTION
|
||||
resume a channel to start sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -951,7 +972,7 @@ DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -974,7 +995,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -995,7 +1016,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -1012,7 +1033,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1030,7 +1051,7 @@ DESCRIPTION
|
||||
Set up credentials for building your project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1059,7 +1080,7 @@ ALIASES
|
||||
$ eas worker:deploy
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1084,7 +1105,7 @@ ALIASES
|
||||
$ eas deploy:promote
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1105,7 +1126,7 @@ ALIASES
|
||||
$ eas worker:alias:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1126,7 +1147,7 @@ ALIASES
|
||||
$ eas worker:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1163,7 +1184,7 @@ DESCRIPTION
|
||||
register new Apple Devices to use for internal distribution
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1183,7 +1204,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1204,7 +1225,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1225,7 +1246,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1239,7 +1260,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1253,7 +1274,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1285,7 +1306,7 @@ DESCRIPTION
|
||||
create an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1311,7 +1332,7 @@ DESCRIPTION
|
||||
delete an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1333,7 +1354,7 @@ DESCRIPTION
|
||||
execute a command with environment variables from the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1361,7 +1382,7 @@ DESCRIPTION
|
||||
view an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1389,7 +1410,7 @@ DESCRIPTION
|
||||
list environment variables for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1412,7 +1433,7 @@ DESCRIPTION
|
||||
pull environment variables for the selected environment to .env file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1434,7 +1455,7 @@ DESCRIPTION
|
||||
push environment variables from .env file to the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1468,7 +1489,7 @@ DESCRIPTION
|
||||
update an environment variable on the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1512,7 +1533,7 @@ EXAMPLES
|
||||
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1543,7 +1564,7 @@ EXAMPLES
|
||||
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1602,6 +1623,188 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
## `eas integrations:asc:connect`
|
||||
|
||||
connect a project to an App Store Connect app
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:asc:connect [--api-key-id <value>] [--asc-app-id <value>] [--bundle-id <value>] [--json]
|
||||
[--non-interactive]
|
||||
|
||||
FLAGS
|
||||
--api-key-id=<value> Apple App Store Connect API Key ID
|
||||
--asc-app-id=<value> App Store Connect app identifier
|
||||
--bundle-id=<value> Filter discovered apps by bundle identifier
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
connect a project to an App Store Connect app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
|
||||
|
||||
## `eas integrations:asc:disconnect`
|
||||
|
||||
disconnect the current project from its App Store Connect app
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:asc:disconnect [--yes] [--json] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--yes Skip confirmation prompt
|
||||
|
||||
DESCRIPTION
|
||||
disconnect the current project from its App Store Connect app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
|
||||
|
||||
## `eas integrations:asc:status`
|
||||
|
||||
show the App Store Connect app link status for the current project
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:asc:status [--json] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
show the App Store Connect app link status for the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/integrations/asc/status.ts)_
|
||||
|
||||
## `eas integrations:convex:connect`
|
||||
|
||||
connect Convex to your Expo project
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:convex:connect [--non-interactive] [--region aws-us-east-1|aws-eu-west-1] [--team-name <value>]
|
||||
[--project-name <value>]
|
||||
|
||||
FLAGS
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--project-name=<value> Name for the Convex project (defaults to app slug)
|
||||
--region=<option> Convex deployment region (e.g. aws-us-east-1, aws-eu-west-1)
|
||||
<options: aws-us-east-1|aws-eu-west-1>
|
||||
--team-name=<value> Name for the new Convex team (defaults to EAS account name)
|
||||
|
||||
DESCRIPTION
|
||||
connect Convex to your Expo project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
|
||||
|
||||
## `eas integrations:convex:dashboard`
|
||||
|
||||
open the Convex dashboard for the linked Convex project
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:convex:dashboard
|
||||
|
||||
DESCRIPTION
|
||||
open the Convex dashboard for the linked Convex project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
|
||||
|
||||
## `eas integrations:convex:project`
|
||||
|
||||
display the Convex project linked to the current Expo app
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:convex:project
|
||||
|
||||
DESCRIPTION
|
||||
display the Convex project linked to the current Expo app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/integrations/convex/project.ts)_
|
||||
|
||||
## `eas integrations:convex:project:delete`
|
||||
|
||||
remove the Convex project link for the current Expo app from EAS servers
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:convex:project:delete [--non-interactive] [-y]
|
||||
|
||||
FLAGS
|
||||
-y, --yes Skip confirmation prompt
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
remove the Convex project link for the current Expo app from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
|
||||
|
||||
## `eas integrations:convex:team`
|
||||
|
||||
display Convex teams linked to the current Expo app's owner account
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:convex:team
|
||||
|
||||
DESCRIPTION
|
||||
display Convex teams linked to the current Expo app's owner account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/integrations/convex/team.ts)_
|
||||
|
||||
## `eas integrations:convex:team:delete [CONVEX_TEAM]`
|
||||
|
||||
remove a Convex team link from the current Expo app owner account's EAS servers
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:convex:team:delete [CONVEX_TEAM] [--non-interactive] [-y]
|
||||
|
||||
ARGUMENTS
|
||||
[CONVEX_TEAM] Slug of the Convex team to remove
|
||||
|
||||
FLAGS
|
||||
-y, --yes Skip confirmation prompt
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
remove a Convex team link from the current Expo app owner account's EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
|
||||
|
||||
## `eas integrations:convex:team:invite [CONVEX_TEAM]`
|
||||
|
||||
send a Convex team invitation to your verified email address
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:convex:team:invite [CONVEX_TEAM] [--non-interactive]
|
||||
|
||||
ARGUMENTS
|
||||
[CONVEX_TEAM] Slug of the Convex team to invite yourself to
|
||||
|
||||
FLAGS
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
send a Convex team invitation to your verified email address
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
|
||||
|
||||
## `eas login`
|
||||
|
||||
log in with your Expo account
|
||||
@@ -1652,7 +1855,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1670,7 +1873,7 @@ DESCRIPTION
|
||||
generate the local store configuration from the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1688,7 +1891,7 @@ DESCRIPTION
|
||||
sync the local store configuration to the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1740,7 +1943,7 @@ DESCRIPTION
|
||||
open the project page in a web browser
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1754,7 +1957,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1777,7 +1980,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -1801,7 +2004,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -1819,7 +2022,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1852,7 +2055,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1895,7 +2098,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1916,7 +2119,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1937,7 +2140,7 @@ DESCRIPTION
|
||||
delete all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -1962,7 +2165,7 @@ DESCRIPTION
|
||||
edit all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:insights GROUPID`
|
||||
|
||||
@@ -1989,7 +2192,7 @@ DESCRIPTION
|
||||
display launch, crash, unique-user, and size insights for an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/insights.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/update/insights.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -2016,7 +2219,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -2054,7 +2257,7 @@ DESCRIPTION
|
||||
roll back to an existing update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2082,7 +2285,7 @@ DESCRIPTION
|
||||
revert a rollout update for a project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2112,7 +2315,7 @@ DESCRIPTION
|
||||
roll back to the embedded update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2133,7 +2336,7 @@ DESCRIPTION
|
||||
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2157,7 +2360,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2178,7 +2381,7 @@ DESCRIPTION
|
||||
upload a local build and generate a sharable link
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2200,7 +2403,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2220,7 +2423,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2239,7 +2442,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2262,7 +2465,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2279,7 +2482,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2400,7 +2603,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2420,7 +2623,7 @@ DESCRIPTION
|
||||
create a new workflow configuration YAML file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2444,7 +2647,7 @@ DESCRIPTION
|
||||
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run FILE`
|
||||
|
||||
@@ -2484,7 +2687,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2506,7 +2709,7 @@ DESCRIPTION
|
||||
list recent workflow runs for this project, with their IDs, statuses, and timestamps
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2534,7 +2737,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2554,7 +2757,7 @@ DESCRIPTION
|
||||
validate a workflow configuration yaml file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2576,5 +2779,5 @@ DESCRIPTION
|
||||
workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { CodegenConfig } from '@graphql-codegen/cli';
|
||||
|
||||
import { getExpoApiBaseUrl } from './src/api';
|
||||
|
||||
const schema =
|
||||
process.env.GRAPHQL_SCHEMA_URL ?? new URL('/graphql', getExpoApiBaseUrl()).toString();
|
||||
|
||||
const config: CodegenConfig = {
|
||||
overwrite: true,
|
||||
schema,
|
||||
documents: ['src/**/!(*.d).ts'],
|
||||
generates: {
|
||||
'src/graphql/generated.ts': {
|
||||
plugins: ['typescript', 'typescript-operations'],
|
||||
config: {
|
||||
dedupeOperationSuffix: true,
|
||||
},
|
||||
hooks: {
|
||||
afterOneFileWrite: ['node ./scripts/annotate-graphql-codegen.js'],
|
||||
},
|
||||
},
|
||||
'./graphql.schema.json': {
|
||||
plugins: ['introspection'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,17 +0,0 @@
|
||||
overwrite: true
|
||||
schema: 'https://staging-api.expo.dev/graphql'
|
||||
documents:
|
||||
- 'src/**/!(*.d).ts'
|
||||
generates:
|
||||
src/graphql/generated.ts:
|
||||
plugins:
|
||||
- 'typescript'
|
||||
- 'typescript-operations'
|
||||
config:
|
||||
dedupeOperationSuffix: true
|
||||
hooks:
|
||||
afterOneFileWrite:
|
||||
- 'node ./scripts/annotate-graphql-codegen.js'
|
||||
./graphql.schema.json:
|
||||
plugins:
|
||||
- 'introspection'
|
||||
Generated
+3916
-8
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "18.8.0",
|
||||
"version": "18.12.2",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -37,7 +37,7 @@
|
||||
"typecheck-for-build": "tsc --project tsconfig.build.json",
|
||||
"test": "jest",
|
||||
"version": "yarn run -T oclif readme && node scripts/patch-readme && git add README.md",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.ts",
|
||||
"verify-graphql-code": "./scripts/verify-graphql.sh",
|
||||
"clean": "rimraf dist build tmp node_modules yarn-error.log \"*.tsbuildinfo\"",
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
@@ -47,8 +47,8 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "18.8.0",
|
||||
"@expo/eas-json": "18.8.0",
|
||||
"@expo/eas-build-job": "18.12.0",
|
||||
"@expo/eas-json": "18.12.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "18.5.0",
|
||||
@@ -63,7 +63,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.8.0",
|
||||
"@expo/steps": "18.12.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -201,6 +201,9 @@
|
||||
"env": {
|
||||
"description": "manage project and account environment variables"
|
||||
},
|
||||
"integrations": {
|
||||
"description": "manage third-party service integrations"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "manage store configuration"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
/*
|
||||
* Manual Convex integration E2E runner.
|
||||
*
|
||||
* Run from packages/eas-cli with:
|
||||
* yarn ts-node scripts/convex-e2e.ts
|
||||
*
|
||||
* This uses the deployed EAS API and real authenticated EAS/Convex-backed
|
||||
* resources. Cleanup removes EAS-side links when possible, but it does not
|
||||
* destroy Convex-side resources.
|
||||
*
|
||||
* Options:
|
||||
* CONVEX_E2E_KEEP_PROJECT=1 keeps the temp Expo project and skips cleanup.
|
||||
* CONVEX_E2E_SKIP_DASHBOARD=1 skips opening the Convex dashboard.
|
||||
* CONVEX_E2E_ACCOUNT=<account-name> sets the Expo app owner before eas init.
|
||||
*/
|
||||
|
||||
type RunOptions = {
|
||||
cwd?: string;
|
||||
allowFailure?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
type RunResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
};
|
||||
|
||||
const KEEP_PROJECT = process.env.CONVEX_E2E_KEEP_PROJECT === '1';
|
||||
const SKIP_DASHBOARD = process.env.CONVEX_E2E_SKIP_DASHBOARD === '1';
|
||||
const ACCOUNT = process.env.CONVEX_E2E_ACCOUNT;
|
||||
const SCRIPT_DIR = __dirname;
|
||||
const EAS_CLI_ROOT = path.resolve(SCRIPT_DIR, '..');
|
||||
const LOCAL_EAS_BIN = path.join(EAS_CLI_ROOT, 'bin', 'run');
|
||||
const TEMP_ROOT_PREFIX = path.join(os.tmpdir(), 'eas-cli-convex-e2e-');
|
||||
|
||||
let tempRoot: string | null = null;
|
||||
let projectDir: string | null = null;
|
||||
let initializedProject = false;
|
||||
let initialConvexTeamSlugs: string[] = [];
|
||||
let createdConvexTeamSlug: string | null = null;
|
||||
|
||||
async function mainAsync(): Promise<void> {
|
||||
try {
|
||||
await assertFileExistsAsync(
|
||||
LOCAL_EAS_BIN,
|
||||
`Local EAS CLI binary not found at ${LOCAL_EAS_BIN}`
|
||||
);
|
||||
await runEasAsync(['whoami']);
|
||||
|
||||
const testName = `convex-e2e-${Date.now().toString(36)}`;
|
||||
tempRoot = await fs.mkdtemp(TEMP_ROOT_PREFIX);
|
||||
projectDir = path.join(tempRoot, testName);
|
||||
|
||||
logStep(`Creating fresh Expo project ${testName}`);
|
||||
await runAsync('npx', ['--yes', 'create-expo-app@latest', projectDir, '--template', 'blank'], {
|
||||
cwd: tempRoot,
|
||||
env: getNpxEnvironment(),
|
||||
});
|
||||
|
||||
await updateAppJsonAsync(projectDir, testName);
|
||||
|
||||
logStep('Creating/linking EAS project');
|
||||
await runEasAsync(['init', '--non-interactive', '--force'], projectDir);
|
||||
initializedProject = true;
|
||||
|
||||
initialConvexTeamSlugs = parseConvexTeamSlugs(
|
||||
(await runEasAsync(['integrations:convex:team'], projectDir)).stdout
|
||||
);
|
||||
|
||||
logStep('Connecting Convex');
|
||||
await runEasAsync(
|
||||
['integrations:convex:connect', '--non-interactive', '--project-name', testName],
|
||||
projectDir
|
||||
);
|
||||
|
||||
await assertEnvLocalAsync(projectDir);
|
||||
await assertPackageJsonDependencyAsync(projectDir, 'convex');
|
||||
|
||||
const teamOutput = await runEasAsync(['integrations:convex:team'], projectDir);
|
||||
const projectOutput = await runEasAsync(['integrations:convex:project'], projectDir);
|
||||
assertIncludes(
|
||||
projectOutput.stdout,
|
||||
'Dashboard',
|
||||
'Expected project output to include dashboard'
|
||||
);
|
||||
|
||||
const teamSlugsAfterConnect = parseConvexTeamSlugs(teamOutput.stdout);
|
||||
const newTeamSlugs = teamSlugsAfterConnect.filter(
|
||||
slug => !initialConvexTeamSlugs.includes(slug)
|
||||
);
|
||||
createdConvexTeamSlug = newTeamSlugs[0] ?? null;
|
||||
const convexTeamSlug = createdConvexTeamSlug ?? teamSlugsAfterConnect[0];
|
||||
|
||||
if (!convexTeamSlug) {
|
||||
throw new Error('Could not determine a Convex team slug from team output.');
|
||||
}
|
||||
|
||||
await runEasAsync(
|
||||
['integrations:convex:team:invite', convexTeamSlug, '--non-interactive'],
|
||||
projectDir
|
||||
);
|
||||
|
||||
if (SKIP_DASHBOARD) {
|
||||
logStep('Skipping dashboard command because CONVEX_E2E_SKIP_DASHBOARD=1');
|
||||
} else {
|
||||
await runEasAsync(['integrations:convex:dashboard'], projectDir);
|
||||
}
|
||||
|
||||
logStep('Convex E2E flow completed successfully');
|
||||
} finally {
|
||||
await cleanupAsync();
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupAsync(): Promise<void> {
|
||||
if (!projectDir || !tempRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (KEEP_PROJECT) {
|
||||
logStep('Skipping cleanup because CONVEX_E2E_KEEP_PROJECT=1');
|
||||
log(`Temp project kept at: ${projectDir}`);
|
||||
log('EAS-side links and Convex-side resources were left intact.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (initializedProject) {
|
||||
logStep('Cleaning up EAS-side Convex links');
|
||||
const convexTeamSlugToDelete =
|
||||
createdConvexTeamSlug ?? (await getCreatedConvexTeamSlugForCleanupAsync());
|
||||
|
||||
await runCleanupCommandAsync([
|
||||
'integrations:convex:project:delete',
|
||||
'--non-interactive',
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
if (convexTeamSlugToDelete) {
|
||||
await runCleanupCommandAsync([
|
||||
'integrations:convex:team:delete',
|
||||
convexTeamSlugToDelete,
|
||||
'--non-interactive',
|
||||
'--yes',
|
||||
]);
|
||||
} else {
|
||||
warn(
|
||||
'No newly-created Convex team link was detected. Skipping team link deletion to avoid removing a pre-existing EAS-side link.'
|
||||
);
|
||||
}
|
||||
|
||||
warn('Convex-side resources are not destroyed by this cleanup.');
|
||||
}
|
||||
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
log(`Removed temp project directory: ${tempRoot}`);
|
||||
}
|
||||
|
||||
async function getCreatedConvexTeamSlugForCleanupAsync(): Promise<string | null> {
|
||||
if (!projectDir) {
|
||||
return null;
|
||||
}
|
||||
const result = await runEasAsync(['integrations:convex:team'], projectDir, {
|
||||
allowFailure: true,
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
warn('Could not inspect Convex team links during cleanup.');
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
parseConvexTeamSlugs(result.stdout).find(slug => !initialConvexTeamSlugs.includes(slug)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async function runCleanupCommandAsync(args: string[]): Promise<void> {
|
||||
try {
|
||||
const result = await runEasAsync(args, projectDir ?? undefined, { allowFailure: true });
|
||||
if (result.exitCode !== 0) {
|
||||
warn(`Cleanup command exited with ${result.exitCode}: ${args.join(' ')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
warn(`Cleanup command failed: ${args.join(' ')}`);
|
||||
warn(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function runEasAsync(
|
||||
args: string[],
|
||||
cwd?: string,
|
||||
options: RunOptions = {}
|
||||
): Promise<RunResult> {
|
||||
return await runAsync(LOCAL_EAS_BIN, args, { cwd, ...options });
|
||||
}
|
||||
|
||||
async function runAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
{ cwd, allowFailure = false, env = {} }: RunOptions = {}
|
||||
): Promise<RunResult> {
|
||||
const printableCommand = [command, ...args].join(' ');
|
||||
log(`\n$ ${printableCommand}`);
|
||||
if (cwd) {
|
||||
log(`cwd: ${cwd}`);
|
||||
}
|
||||
|
||||
return await new Promise<RunResult>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
CI: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', data => {
|
||||
const chunk = data.toString();
|
||||
stdout += chunk;
|
||||
process.stdout.write(redact(chunk));
|
||||
});
|
||||
|
||||
child.stderr.on('data', data => {
|
||||
const chunk = data.toString();
|
||||
stderr += chunk;
|
||||
process.stderr.write(redact(chunk));
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', exitCode => {
|
||||
const result = { stdout, stderr, exitCode };
|
||||
if (!allowFailure && exitCode !== 0) {
|
||||
reject(new Error(`Command failed with exit code ${exitCode}: ${printableCommand}`));
|
||||
return;
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function updateAppJsonAsync(appDir: string, testName: string): Promise<void> {
|
||||
const appJsonPath = path.join(appDir, 'app.json');
|
||||
const rawAppJson = await fs.readFile(appJsonPath, 'utf8');
|
||||
const appJson = JSON.parse(rawAppJson);
|
||||
appJson.expo = {
|
||||
...appJson.expo,
|
||||
name: testName,
|
||||
slug: testName,
|
||||
...(ACCOUNT ? { owner: ACCOUNT } : {}),
|
||||
};
|
||||
await fs.writeFile(appJsonPath, `${JSON.stringify(appJson, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function assertEnvLocalAsync(appDir: string): Promise<void> {
|
||||
const envLocalPath = path.join(appDir, '.env.local');
|
||||
const envLocal = await fs.readFile(envLocalPath, 'utf8');
|
||||
assertIncludes(
|
||||
envLocal,
|
||||
'CONVEX_DEPLOY_KEY=',
|
||||
'Expected .env.local to contain CONVEX_DEPLOY_KEY'
|
||||
);
|
||||
assertIncludes(
|
||||
envLocal,
|
||||
'EXPO_PUBLIC_CONVEX_URL=',
|
||||
'Expected .env.local to contain EXPO_PUBLIC_CONVEX_URL'
|
||||
);
|
||||
log('\nVerified .env.local contains Convex environment variables:');
|
||||
log(redact(envLocal));
|
||||
}
|
||||
|
||||
async function assertPackageJsonDependencyAsync(
|
||||
appDir: string,
|
||||
packageName: string
|
||||
): Promise<void> {
|
||||
const packageJsonPath = path.join(appDir, 'package.json');
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
||||
const dependencies = {
|
||||
...packageJson.dependencies,
|
||||
...packageJson.devDependencies,
|
||||
};
|
||||
if (!dependencies[packageName]) {
|
||||
throw new Error(`Expected package.json to include ${packageName}`);
|
||||
}
|
||||
log(`Verified package.json includes ${packageName}@${dependencies[packageName]}`);
|
||||
}
|
||||
|
||||
function parseConvexTeamSlugs(output: string): string[] {
|
||||
const strippedOutput = stripAnsi(output);
|
||||
return Array.from(strippedOutput.matchAll(/^Team:\s+.+?\s\/\s(.+)$/gm), match => match[1].trim());
|
||||
}
|
||||
|
||||
function assertIncludes(value: string, expected: string, message: string): void {
|
||||
if (!value.includes(expected)) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertFileExistsAsync(filePath: string, message: string): Promise<void> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function logStep(message: string): void {
|
||||
log(`\n==> ${message}`);
|
||||
}
|
||||
|
||||
function log(message: string): void {
|
||||
process.stdout.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function warn(message: string): void {
|
||||
process.stderr.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function redact(value: string): string {
|
||||
return value
|
||||
.replace(/^CONVEX_DEPLOY_KEY=.*$/gm, 'CONVEX_DEPLOY_KEY=<redacted>')
|
||||
.replace(/dev:[^\s|]+\|[^\s]+/g, 'dev:<redacted>');
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
function getNpxEnvironment(): NodeJS.ProcessEnv {
|
||||
return {
|
||||
npm_config_user_agent: `npm/10 node/${process.versions.node} ${process.platform} ${process.arch}`,
|
||||
npm_execpath: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
mainAsync().catch(error => {
|
||||
warn(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { ensureLockfileExistsAsync } from '../validateLockfile';
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
const mockResolveWorkspaceRoot = jest.fn();
|
||||
jest.mock('@expo/package-manager', () => {
|
||||
const actual = jest.requireActual('@expo/package-manager');
|
||||
return {
|
||||
...actual,
|
||||
resolveWorkspaceRoot: (...args: unknown[]) => mockResolveWorkspaceRoot(...args),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
mockResolveWorkspaceRoot.mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe(ensureLockfileExistsAsync, () => {
|
||||
it('passes when package-lock.json exists', async () => {
|
||||
vol.fromJSON({ './package-lock.json': '' }, '/app');
|
||||
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('passes when yarn.lock exists', async () => {
|
||||
vol.fromJSON({ './yarn.lock': '' }, '/app');
|
||||
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('passes when pnpm-lock.yaml exists', async () => {
|
||||
vol.fromJSON({ './pnpm-lock.yaml': '' }, '/app');
|
||||
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('passes when bun.lockb exists', async () => {
|
||||
vol.fromJSON({ './bun.lockb': '' }, '/app');
|
||||
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('passes when bun.lock exists', async () => {
|
||||
vol.fromJSON({ './bun.lock': '' }, '/app');
|
||||
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('throws when no lockfile exists', async () => {
|
||||
vol.fromJSON({ './package.json': '{}' }, '/app');
|
||||
await expect(ensureLockfileExistsAsync('/app')).rejects.toThrow(
|
||||
'No lockfile found in the project directory.'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes when lockfile exists in workspace root', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./packages/my-app/package.json': '{}',
|
||||
'./yarn.lock': '',
|
||||
},
|
||||
'/monorepo'
|
||||
);
|
||||
mockResolveWorkspaceRoot.mockReturnValue('/monorepo');
|
||||
await expect(
|
||||
ensureLockfileExistsAsync('/monorepo/packages/my-app')
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('throws when no lockfile in project dir or workspace root', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./packages/my-app/package.json': '{}',
|
||||
'./package.json': '{}',
|
||||
},
|
||||
'/monorepo'
|
||||
);
|
||||
mockResolveWorkspaceRoot.mockReturnValue('/monorepo');
|
||||
await expect(ensureLockfileExistsAsync('/monorepo/packages/my-app')).rejects.toThrow(
|
||||
'No lockfile found in the project directory.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { createBuildContextAsync } from './createContext';
|
||||
import { evaluateConfigWithEnvVarsAsync } from './evaluateConfigWithEnvVarsAsync';
|
||||
import { prepareIosBuildAsync } from './ios/build';
|
||||
import { LocalBuildMode, LocalBuildOptions } from './local';
|
||||
import { ensureLockfileExistsAsync } from './validateLockfile';
|
||||
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
|
||||
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
|
||||
import { ensureRepoIsCleanAsync } from './utils/repository';
|
||||
@@ -65,6 +66,7 @@ import {
|
||||
} from '../project/remoteVersionSource';
|
||||
import { confirmAsync } from '../prompts';
|
||||
import { getEasBuildRunCachedAppPath, runAsync } from '../run/run';
|
||||
import { SimulatorRunTarget } from '../run/ios/run';
|
||||
import { isRunnableOnSimulatorOrEmulator } from '../run/utils';
|
||||
import { createSubmissionContextAsync } from '../submit/context';
|
||||
import {
|
||||
@@ -84,6 +86,7 @@ import { Client } from '../vcs/vcs';
|
||||
|
||||
let metroConfigValidated = false;
|
||||
let sdkVersionChecked = false;
|
||||
let lockfileChecked = false;
|
||||
let hasWarnedAboutUsageOverages = false;
|
||||
|
||||
export interface BuildFlags {
|
||||
@@ -102,6 +105,7 @@ export interface BuildFlags {
|
||||
freezeCredentials: boolean;
|
||||
isVerboseLoggingEnabled?: boolean;
|
||||
whatToTest?: string;
|
||||
simulator?: SimulatorRunTarget;
|
||||
}
|
||||
|
||||
export async function runBuildAndSubmitAsync({
|
||||
@@ -414,6 +418,13 @@ async function prepareAndStartBuildAsync({
|
||||
env,
|
||||
});
|
||||
|
||||
if (!lockfileChecked && !flags.localBuildOptions.localBuildMode) {
|
||||
if (!process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK) {
|
||||
await ensureLockfileExistsAsync(projectDir);
|
||||
}
|
||||
lockfileChecked = true;
|
||||
}
|
||||
|
||||
if (!hasWarnedAboutUsageOverages && !flags.localBuildOptions.localBuildMode) {
|
||||
hasWarnedAboutUsageOverages = true;
|
||||
Log.newLine();
|
||||
@@ -579,14 +590,17 @@ function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | nul
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadAndRunAsync(build: BuildFragment): Promise<void> {
|
||||
export async function downloadAndRunAsync(
|
||||
build: BuildFragment,
|
||||
simulator?: SimulatorRunTarget
|
||||
): Promise<void> {
|
||||
assert(build.artifacts?.applicationArchiveUrl);
|
||||
const cachedAppPath = getEasBuildRunCachedAppPath(build.project.id, build.id, build.platform);
|
||||
|
||||
if (await pathExists(cachedAppPath)) {
|
||||
Log.newLine();
|
||||
Log.log(`Using cached app...`);
|
||||
await runAsync(cachedAppPath, build.platform);
|
||||
await runAsync(cachedAppPath, build.platform, simulator);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -595,7 +609,7 @@ export async function downloadAndRunAsync(build: BuildFragment): Promise<void> {
|
||||
build.platform,
|
||||
cachedAppPath
|
||||
);
|
||||
await runAsync(buildPath, build.platform);
|
||||
await runAsync(buildPath, build.platform, simulator);
|
||||
}
|
||||
|
||||
async function maybeDownloadAndRunSimulatorBuildsAsync(
|
||||
@@ -617,9 +631,9 @@ async function maybeDownloadAndRunSimulatorBuildsAsync(
|
||||
} build on ${
|
||||
simBuild.platform === AppPlatform.Android ? 'an emulator' : 'a simulator'
|
||||
}?`,
|
||||
}));
|
||||
}));
|
||||
if (confirm) {
|
||||
await downloadAndRunAsync(simBuild);
|
||||
await downloadAndRunAsync(simBuild, flags.simulator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import * as tar from 'tar';
|
||||
|
||||
import { Client } from '../../../vcs/vcs';
|
||||
import { makeProjectTarballAsync } from '../repository';
|
||||
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => ({
|
||||
fail: jest.fn(),
|
||||
isSpinning: false,
|
||||
start: jest.fn(),
|
||||
succeed: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
class FakeVcsClient extends Client {
|
||||
public async makeShallowCopyAsync(destinationPath: string): Promise<void> {
|
||||
await fs.mkdirp(path.join(destinationPath, 'bin'));
|
||||
await fs.mkdirp(path.join(destinationPath, 'read-only-dir'));
|
||||
|
||||
await fs.writeFile(path.join(destinationPath, 'bin', 'postcheckout.sh'), '#!/bin/sh\necho hi\n');
|
||||
await fs.writeFile(path.join(destinationPath, 'regular-file.txt'), 'regular file\n');
|
||||
await fs.writeFile(path.join(destinationPath, 'read-only-dir', 'child.txt'), 'child file\n');
|
||||
|
||||
await fs.chmod(path.join(destinationPath, 'bin', 'postcheckout.sh'), 0o755);
|
||||
await fs.chmod(path.join(destinationPath, 'regular-file.txt'), 0o644);
|
||||
await fs.chmod(path.join(destinationPath, 'read-only-dir'), 0o555);
|
||||
}
|
||||
|
||||
public async getRootPathAsync(): Promise<string> {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
public canGetLastCommitMessage(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe(makeProjectTarballAsync, () => {
|
||||
it('creates portable project archives while preserving executable files', async () => {
|
||||
const removeAsync = fs.remove.bind(fs);
|
||||
const removeSpy = jest.spyOn(fs, 'remove').mockImplementation(async targetPath => {
|
||||
await fs.chmod(path.join(targetPath.toString(), 'read-only-dir'), 0o755).catch(() => {});
|
||||
await removeAsync(targetPath);
|
||||
});
|
||||
const projectTarball = await makeProjectTarballAsync(new FakeVcsClient());
|
||||
const entries = new Map<string, tar.ReadEntry>();
|
||||
|
||||
try {
|
||||
await tar.list({
|
||||
file: projectTarball.path,
|
||||
onentry: entry => {
|
||||
entries.set(entry.path, entry);
|
||||
},
|
||||
});
|
||||
|
||||
expect(entries.get('project/bin/postcheckout.sh')?.mode).toBe(0o755);
|
||||
expect(entries.get('project/regular-file.txt')?.mode).toBe(0o644);
|
||||
expect(entries.get('project/read-only-dir/')?.mode).toBe(0o755);
|
||||
expect(entries.has('project/read-only-dir/child.txt')).toBe(true);
|
||||
|
||||
for (const entry of entries.values()) {
|
||||
expect(entry.path.startsWith('project/')).toBe(true);
|
||||
expect(entry.uid).toBeUndefined();
|
||||
expect(entry.gid).toBeUndefined();
|
||||
expect(entry.uname).toBe('');
|
||||
expect(entry.gname).toBe('');
|
||||
}
|
||||
} finally {
|
||||
await fs.remove(projectTarball.path);
|
||||
removeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -143,9 +143,24 @@ export async function makeProjectTarballAsync(vcsClient: Client): Promise<LocalF
|
||||
|
||||
try {
|
||||
await vcsClient.makeShallowCopyAsync(shallowClonePath);
|
||||
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
|
||||
'.',
|
||||
]);
|
||||
await tar.create(
|
||||
{
|
||||
cwd: shallowClonePath,
|
||||
file: tarPath,
|
||||
prefix: 'project',
|
||||
gzip: true,
|
||||
portable: true,
|
||||
onWriteEntry(entry) {
|
||||
// Read-only directories may have files inside them. This causes trouble on tar extraction.
|
||||
// Hence, we're forcing the owner write bit on directories in Windows
|
||||
// to avoid this issue.
|
||||
if (entry.type === 'Directory' && entry.stat) {
|
||||
entry.stat.mode |= 0o200;
|
||||
}
|
||||
},
|
||||
},
|
||||
['.']
|
||||
);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
if (spinner.isSpinning) {
|
||||
|
||||
@@ -64,6 +64,24 @@ export function getWorkflowRunUrl(
|
||||
).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Links to the raw job-run page; prefer a higher-level URL (e.g. the workflow run
|
||||
* or the feature-specific dashboard) that gives users more context. Use this only for internal
|
||||
* tooling where no richer URL exists.
|
||||
*/
|
||||
export function getBareJobRunUrl(
|
||||
accountName: string,
|
||||
projectName: string,
|
||||
jobRunId: string
|
||||
): string {
|
||||
return new URL(
|
||||
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
|
||||
projectName
|
||||
)}/job-runs/${encodeURIComponent(jobRunId)}`,
|
||||
getExpoWebsiteBaseUrl()
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function getProjectGitHubSettingsUrl(accountName: string, projectName: string): string {
|
||||
return new URL(
|
||||
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
BUN_LOCK_FILE,
|
||||
BUN_TEXT_LOCK_FILE,
|
||||
NPM_LOCK_FILE,
|
||||
PNPM_LOCK_FILE,
|
||||
YARN_LOCK_FILE,
|
||||
resolveWorkspaceRoot,
|
||||
} from '@expo/package-manager';
|
||||
import { pathExists } from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
const LOCKFILE_NAMES = [
|
||||
NPM_LOCK_FILE,
|
||||
YARN_LOCK_FILE,
|
||||
PNPM_LOCK_FILE,
|
||||
BUN_LOCK_FILE,
|
||||
BUN_TEXT_LOCK_FILE,
|
||||
];
|
||||
|
||||
async function hasLockfileAsync(dir: string): Promise<boolean> {
|
||||
for (const lockfile of LOCKFILE_NAMES) {
|
||||
if (await pathExists(path.join(dir, lockfile))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function ensureLockfileExistsAsync(projectDir: string): Promise<void> {
|
||||
if (await hasLockfileAsync(projectDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceRoot = resolveWorkspaceRoot(projectDir);
|
||||
if (workspaceRoot && workspaceRoot !== projectDir) {
|
||||
if (await hasLockfileAsync(workspaceRoot)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No lockfile found in the project directory.\n` +
|
||||
`A lockfile is required to ensure deterministic dependency installation in EAS.\n` +
|
||||
`Run your package manager's install command (e.g. "npm install") to generate one.\n` +
|
||||
`To skip this check, run this command with EAS_BUILD_SKIP_LOCKFILE_CHECK=1.`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import dateFormat from 'dateformat';
|
||||
|
||||
import {
|
||||
confirmRecentConvexInviteAsync,
|
||||
formatConvexProject,
|
||||
formatConvexTeam,
|
||||
formatConvexTeamConnection,
|
||||
getConvexProjectDashboardUrl,
|
||||
getConvexTeamDashboardUrl,
|
||||
} from '../convex';
|
||||
import {
|
||||
ConvexProjectData,
|
||||
ConvexTeamConnectionData,
|
||||
} from '../../graphql/types/ConvexTeamConnection';
|
||||
import Log from '../../log';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../prompts');
|
||||
|
||||
describe('Convex command utilities', () => {
|
||||
const mockConnection: ConvexTeamConnectionData = {
|
||||
id: 'connection-1',
|
||||
convexTeamIdentifier: 'team-123',
|
||||
convexTeamName: 'Test Team',
|
||||
convexTeamSlug: 'test team',
|
||||
hasBeenClaimed: true,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
invitedAt: '2024-01-02T00:00:00.000Z',
|
||||
invitedEmail: '[email protected]',
|
||||
};
|
||||
|
||||
const mockProject: ConvexProjectData = {
|
||||
id: 'convex-project-1',
|
||||
convexProjectIdentifier: 'project-123',
|
||||
convexProjectName: 'Test Project',
|
||||
convexProjectSlug: 'test project',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
convexTeamConnection: mockConnection,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('builds encoded Convex dashboard URLs', () => {
|
||||
expect(getConvexTeamDashboardUrl(mockConnection)).toBe(
|
||||
'https://dashboard.convex.dev/t/test%20team'
|
||||
);
|
||||
expect(getConvexProjectDashboardUrl(mockProject)).toBe(
|
||||
'https://dashboard.convex.dev/t/test%20team/test%20project'
|
||||
);
|
||||
});
|
||||
|
||||
it('formats team and project metadata with invite details', () => {
|
||||
expect(formatConvexTeam(mockConnection)).toBe('Test Team / test team');
|
||||
expect(formatConvexTeamConnection(mockConnection)).toContain('Test Team / test team');
|
||||
expect(formatConvexTeamConnection(mockConnection)).not.toContain('team-123');
|
||||
expect(formatConvexTeamConnection(mockConnection)).not.toContain('connection-1');
|
||||
expect(formatConvexTeamConnection(mockConnection)).toContain('Claimed');
|
||||
expect(formatConvexTeamConnection(mockConnection)).toContain('Yes');
|
||||
expect(formatConvexTeamConnection(mockConnection)).toContain('[email protected]');
|
||||
expect(formatConvexTeamConnection(mockConnection)).toContain(
|
||||
dateFormat(mockConnection.invitedAt, 'mmm dd HH:MM:ss')
|
||||
);
|
||||
expect(formatConvexProject(mockProject)).toContain('project-123');
|
||||
expect(formatConvexProject(mockProject)).not.toContain('convex-project-1');
|
||||
expect(formatConvexProject(mockProject)).toContain('Test Team / test team');
|
||||
expect(formatConvexProject(mockProject)).not.toContain('team-123');
|
||||
});
|
||||
|
||||
it('does not prompt when the previous invite timestamp is invalid', async () => {
|
||||
await expect(
|
||||
confirmRecentConvexInviteAsync(
|
||||
{ ...mockConnection, invitedAt: 'not-a-date' },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not prompt when the previous invite is old', async () => {
|
||||
await expect(
|
||||
confirmRecentConvexInviteAsync(
|
||||
{
|
||||
...mockConnection,
|
||||
invitedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts before resending a recent invite in interactive mode', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
const recentInviteTimestamp = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
|
||||
await expect(
|
||||
confirmRecentConvexInviteAsync(
|
||||
{
|
||||
...mockConnection,
|
||||
invitedAt: recentInviteTimestamp,
|
||||
},
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('Are you sure you want to send another invite?'),
|
||||
});
|
||||
expect(confirmAsync).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining(dateFormat(recentInviteTimestamp, 'mmm dd HH:MM:ss')),
|
||||
});
|
||||
});
|
||||
|
||||
it('warns and proceeds for recent invites in non-interactive mode', async () => {
|
||||
await expect(
|
||||
confirmRecentConvexInviteAsync(
|
||||
{
|
||||
...mockConnection,
|
||||
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{ nonInteractive: true }
|
||||
)
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('non-interactive mode'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export function preprocessBuildCommandArgs(argv: string[]): string[] {
|
||||
return withOptionalSimulatorValue(argv);
|
||||
}
|
||||
|
||||
// oclif does not support string flags with optional values. We want "--simulator"
|
||||
// to prompt for a simulator, while still supporting "--simulator <name-or-udid>".
|
||||
// Insert an empty string value for the bare flag before oclif parses argv.
|
||||
function withOptionalSimulatorValue(argv: string[]): string[] {
|
||||
const simulatorFlagIndex = argv.indexOf('--simulator');
|
||||
const simulatorValue = argv[simulatorFlagIndex + 1];
|
||||
|
||||
if (
|
||||
simulatorFlagIndex === -1 ||
|
||||
(simulatorValue !== undefined && !simulatorValue.startsWith('-'))
|
||||
) {
|
||||
return argv;
|
||||
}
|
||||
|
||||
return [...argv.slice(0, simulatorFlagIndex + 1), '', ...argv.slice(simulatorFlagIndex + 1)];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import chalk from 'chalk';
|
||||
import dateFormat from 'dateformat';
|
||||
|
||||
import { ConvexProjectData, ConvexTeamConnectionData } from '../graphql/types/ConvexTeamConnection';
|
||||
import Log, { link } from '../log';
|
||||
import { confirmAsync } from '../prompts';
|
||||
|
||||
const CONVEX_DASHBOARD_HOST = 'https://dashboard.convex.dev';
|
||||
const RECENT_INVITE_THRESHOLD_MS = 60 * 60 * 1000;
|
||||
|
||||
export function formatConvexInviteTimestamp(timestamp: string): string {
|
||||
return dateFormat(timestamp, 'mmm dd HH:MM:ss');
|
||||
}
|
||||
|
||||
export function getConvexTeamDashboardUrl(connection: ConvexTeamConnectionData): string {
|
||||
return `${CONVEX_DASHBOARD_HOST}/t/${encodeURIComponent(connection.convexTeamSlug)}`;
|
||||
}
|
||||
|
||||
export function getConvexProjectDashboardUrl(project: ConvexProjectData): string {
|
||||
return `${getConvexTeamDashboardUrl(project.convexTeamConnection)}/${encodeURIComponent(
|
||||
project.convexProjectSlug
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function formatConvexTeam(connection: ConvexTeamConnectionData): string {
|
||||
return `${connection.convexTeamName} / ${connection.convexTeamSlug}`;
|
||||
}
|
||||
|
||||
export function formatConvexTeamConnection(connection: ConvexTeamConnectionData): string {
|
||||
const lines = [
|
||||
`${chalk.bold('Team')}: ${formatConvexTeam(connection)}`,
|
||||
`${chalk.bold('Dashboard')}: ${link(getConvexTeamDashboardUrl(connection), { dim: false })}`,
|
||||
`${chalk.bold('Claimed')}: ${connection.hasBeenClaimed ? 'Yes' : 'No'}`,
|
||||
];
|
||||
|
||||
if (connection.invitedEmail) {
|
||||
lines.push(`${chalk.bold('Invited email')}: ${connection.invitedEmail}`);
|
||||
}
|
||||
if (connection.invitedAt) {
|
||||
lines.push(`${chalk.bold('Invited at')}: ${formatConvexInviteTimestamp(connection.invitedAt)}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function formatConvexProject(project: ConvexProjectData): string {
|
||||
return [
|
||||
`${chalk.bold('Name')}: ${project.convexProjectName}`,
|
||||
`${chalk.bold('Slug')}: ${project.convexProjectSlug}`,
|
||||
`${chalk.bold('Identifier')}: ${project.convexProjectIdentifier}`,
|
||||
`${chalk.bold('Team')}: ${formatConvexTeam(project.convexTeamConnection)}`,
|
||||
`${chalk.bold('Dashboard')}: ${link(getConvexProjectDashboardUrl(project), { dim: false })}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function logNoConvexTeams(accountName: string): void {
|
||||
Log.warn(`No Convex team is linked to account ${chalk.bold(accountName)} on EAS.`);
|
||||
}
|
||||
|
||||
export function logNoConvexProject(projectName: string): void {
|
||||
Log.warn(`No Convex project is linked to Expo app ${chalk.bold(projectName)} on EAS.`);
|
||||
}
|
||||
|
||||
export async function confirmRecentConvexInviteAsync(
|
||||
connection: ConvexTeamConnectionData,
|
||||
{ nonInteractive }: { nonInteractive: boolean }
|
||||
): Promise<boolean> {
|
||||
const invitedAt = connection.invitedAt ? new Date(connection.invitedAt) : null;
|
||||
if (!invitedAt || Number.isNaN(invitedAt.getTime())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const timeSinceInviteMs = Date.now() - invitedAt.getTime();
|
||||
if (timeSinceInviteMs >= RECENT_INVITE_THRESHOLD_MS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousInvite = `A Convex team invite was already sent${connection.invitedEmail ? ` to ${connection.invitedEmail}` : ''} at ${formatConvexInviteTimestamp(connection.invitedAt)}.`;
|
||||
if (nonInteractive) {
|
||||
Log.warn(
|
||||
`${previousInvite} Sending another invite because this command is running in non-interactive mode.`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return await confirmAsync({
|
||||
message: `${previousInvite} Are you sure you want to send another invite?`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform, BuildFragment } from '../../../graphql/generated';
|
||||
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import Download from '../download';
|
||||
|
||||
jest.mock('../../../graphql/queries/BuildQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockByIdAsync = jest.mocked(BuildQuery.byIdAsync);
|
||||
const mockViewBuildsOnAppAsync = jest.mocked(BuildQuery.viewBuildsOnAppAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeBuild(overrides: Partial<BuildFragment> = {}): BuildFragment {
|
||||
return {
|
||||
id: 'build-123',
|
||||
platform: AppPlatform.Ios,
|
||||
project: { id: 'project-1' },
|
||||
artifacts: {
|
||||
applicationArchiveUrl: 'https://example.com/app.tar.gz',
|
||||
buildArtifactsUrl: null,
|
||||
xcodeBuildLogsUrl: null,
|
||||
buildUrl: null,
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as BuildFragment;
|
||||
}
|
||||
|
||||
describe(Download, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): Download {
|
||||
const command = new Download(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
// Stub the file-system-touching helpers so we don't hit disk.
|
||||
jest
|
||||
.spyOn(command as any, 'getPathToBuildArtifactAsync')
|
||||
.mockResolvedValue('/cache/path/to/build.app');
|
||||
jest.spyOn(command as any, 'downloadExtraArtifactsAsync').mockResolvedValue({});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('errors when neither --build-id nor --fingerprint is provided', async () => {
|
||||
const command = createCommand([]);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'Either --build-id or --fingerprint is required.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches build by ID when --build-id is provided', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeBuild());
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'build-123');
|
||||
expect(mockViewBuildsOnAppAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches build by ID when --id alias is provided', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeBuild());
|
||||
|
||||
const command = createCommand(['--id', 'build-123']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'build-123');
|
||||
expect(mockViewBuildsOnAppAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches builds by fingerprint when --fingerprint is provided', async () => {
|
||||
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
|
||||
|
||||
const command = createCommand(['--fingerprint', 'fp-abc', '--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockViewBuildsOnAppAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockViewBuildsOnAppAsync.mock.calls[0][1].filter?.fingerprintHash).toBe('fp-abc');
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses platform from the build itself when --build-id is provided', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeBuild({ platform: AppPlatform.Android }));
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123']);
|
||||
await command.runAsync();
|
||||
|
||||
const getPathSpy = jest.spyOn(command as any, 'getPathToBuildArtifactAsync');
|
||||
// The spy was set up after instantiation; just ensure runAsync used the build's platform
|
||||
// by checking that no platform prompt was needed.
|
||||
expect(getPathSpy).toBeDefined();
|
||||
});
|
||||
|
||||
it('errors with a helpful message when applicationArchiveUrl is missing and other artifacts exist', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeBuild({
|
||||
artifacts: {
|
||||
applicationArchiveUrl: null,
|
||||
buildArtifactsUrl: 'https://example.com/build-artifacts.tar.gz',
|
||||
xcodeBuildLogsUrl: 'https://example.com/xcode-logs.tar.gz',
|
||||
buildUrl: null,
|
||||
} as any,
|
||||
})
|
||||
);
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123']);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
/Other artifacts are available \(buildArtifacts, xcodeBuildLogs\); re-run with --all-artifacts/
|
||||
);
|
||||
});
|
||||
|
||||
it('errors with the basic message when applicationArchiveUrl is missing and no other artifacts exist', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeBuild({
|
||||
artifacts: {
|
||||
applicationArchiveUrl: null,
|
||||
buildArtifactsUrl: null,
|
||||
xcodeBuildLogsUrl: null,
|
||||
buildUrl: null,
|
||||
} as any,
|
||||
})
|
||||
);
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123']);
|
||||
await expect(command.runAsync()).rejects.toThrow('Build does not have an application archive url');
|
||||
});
|
||||
|
||||
it('does not throw when applicationArchiveUrl is missing but --all-artifacts is set', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeBuild({
|
||||
artifacts: {
|
||||
applicationArchiveUrl: null,
|
||||
buildArtifactsUrl: 'https://example.com/build-artifacts.tar.gz',
|
||||
xcodeBuildLogsUrl: null,
|
||||
buildUrl: null,
|
||||
} as any,
|
||||
})
|
||||
);
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123', '--all-artifacts']);
|
||||
const downloadExtrasSpy = jest
|
||||
.spyOn(command as any, 'downloadExtraArtifactsAsync')
|
||||
.mockResolvedValue({ buildArtifacts: '/cache/build-123-artifacts/build-artifacts.tar.gz' });
|
||||
|
||||
await expect(command.runAsync()).resolves.not.toThrow();
|
||||
expect(downloadExtrasSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads extras only when --all-artifacts is set, regardless of --build-id vs --fingerprint', async () => {
|
||||
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
|
||||
|
||||
const commandWithoutFlag = createCommand(['--fingerprint', 'fp-abc', '--platform', 'ios']);
|
||||
const downloadExtrasSpyWithout = jest
|
||||
.spyOn(commandWithoutFlag as any, 'downloadExtraArtifactsAsync')
|
||||
.mockResolvedValue({});
|
||||
await commandWithoutFlag.runAsync();
|
||||
expect(downloadExtrasSpyWithout).not.toHaveBeenCalled();
|
||||
|
||||
const commandWithFlag = createCommand([
|
||||
'--fingerprint',
|
||||
'fp-abc',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--all-artifacts',
|
||||
]);
|
||||
const downloadExtrasSpyWith = jest
|
||||
.spyOn(commandWithFlag as any, 'downloadExtraArtifactsAsync')
|
||||
.mockResolvedValue({ buildArtifacts: '/path/to/extras' });
|
||||
await commandWithFlag.runAsync();
|
||||
expect(downloadExtrasSpyWith).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON with path and extras when --json --all-artifacts are set', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeBuild());
|
||||
|
||||
const command = createCommand([
|
||||
'--build-id',
|
||||
'build-123',
|
||||
'--all-artifacts',
|
||||
'--json',
|
||||
'--non-interactive',
|
||||
]);
|
||||
jest
|
||||
.spyOn(command as any, 'downloadExtraArtifactsAsync')
|
||||
.mockResolvedValue({ buildArtifacts: '/cache/extras/build-artifacts.tar.gz' });
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
path: '/cache/path/to/build.app',
|
||||
buildArtifacts: '/cache/extras/build-artifacts.tar.gz',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { evaluateConfigWithEnvVarsAsync } from '../../build/evaluateConfigWithEn
|
||||
import { downloadAndRunAsync, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
|
||||
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { preprocessBuildCommandArgs } from '../../commandUtils/buildFlags';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { createFingerprintAsync } from '../../fingerprint/cli';
|
||||
import { BuildFragment, BuildStatus, DistributionType } from '../../graphql/generated';
|
||||
@@ -51,6 +52,11 @@ export default class BuildDev extends EasCommand {
|
||||
description: 'Install and run the development build without starting the bundler server.',
|
||||
default: false,
|
||||
}),
|
||||
simulator: Flags.string({
|
||||
description:
|
||||
'iOS simulator name or UDID to install and run the development build on. If no value is provided, you will be prompted to select a simulator.',
|
||||
multiple: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
@@ -63,7 +69,7 @@ export default class BuildDev extends EasCommand {
|
||||
};
|
||||
|
||||
protected override async runAsync(): Promise<any> {
|
||||
const { flags } = await this.parse(BuildDev);
|
||||
const { flags } = await this.parse(BuildDev, preprocessBuildCommandArgs(this.argv));
|
||||
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
@@ -81,6 +87,10 @@ export default class BuildDev extends EasCommand {
|
||||
if (process.platform !== 'darwin' && platform === Platform.IOS) {
|
||||
Errors.error('Running iOS builds in simulator is only supported on macOS.', { exit: 1 });
|
||||
}
|
||||
if (flags.simulator && platform !== Platform.IOS) {
|
||||
Errors.error('The --simulator flag can only be used with --platform ios.', { exit: 1 });
|
||||
}
|
||||
const simulator = flags.simulator === '' ? true : flags.simulator;
|
||||
|
||||
await vcsClient.ensureRepoExistsAsync();
|
||||
await ensureRepoIsCleanAsync(vcsClient, flags.nonInteractive);
|
||||
@@ -132,7 +142,7 @@ export default class BuildDev extends EasCommand {
|
||||
);
|
||||
|
||||
if (build.artifacts?.applicationArchiveUrl) {
|
||||
await downloadAndRunAsync(build);
|
||||
await downloadAndRunAsync(build, simulator);
|
||||
if (!flags['skip-bundler']) {
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
}
|
||||
@@ -182,6 +192,7 @@ export default class BuildDev extends EasCommand {
|
||||
autoSubmit: false,
|
||||
localBuildOptions: {},
|
||||
profile: flags.profile ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
|
||||
simulator,
|
||||
},
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { Errors, Flags } from '@oclif/core';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import { pathExists } from 'fs-extra';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
@@ -15,24 +16,44 @@ import { toAppPlatform } from '../../graphql/types/AppPlatform';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { getEasBuildRunCachedAppPath } from '../../run/run';
|
||||
import { downloadAndMaybeExtractAppAsync } from '../../utils/download';
|
||||
import {
|
||||
downloadAndMaybeExtractAppAsync,
|
||||
downloadFileWithProgressTrackerAsync,
|
||||
} from '../../utils/download';
|
||||
import { formatBytes } from '../../utils/files';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { getEasBuildRunCacheDirectoryPath } from '../../utils/paths';
|
||||
|
||||
export default class Download extends EasCommand {
|
||||
static override description = 'download simulator/emulator builds for a given fingerprint hash';
|
||||
static override description =
|
||||
'download a simulator/emulator build by build ID or fingerprint hash';
|
||||
|
||||
|
||||
static override flags = {
|
||||
'build-id': Flags.string({
|
||||
aliases: ['id'],
|
||||
description:
|
||||
'ID of the build to download. Mutually exclusive with --fingerprint, --platform, and --dev-client; the platform is derived from the build itself.',
|
||||
exclusive: ['fingerprint', 'platform', 'dev-client'],
|
||||
}),
|
||||
fingerprint: Flags.string({
|
||||
description: 'Fingerprint hash of the build to download',
|
||||
required: true,
|
||||
exclusive: ['build-id'],
|
||||
}),
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
options: [Platform.IOS, Platform.ANDROID] as const,
|
||||
exclusive: ['build-id'],
|
||||
})(),
|
||||
'dev-client': Flags.boolean({
|
||||
description: 'Filter only dev-client builds.',
|
||||
allowNo: true,
|
||||
exclusive: ['build-id'],
|
||||
}),
|
||||
'all-artifacts': Flags.boolean({
|
||||
description:
|
||||
'Download all available build artifacts (build artifacts archive, Xcode logs, etc.) in addition to the application archive. Without this flag, only the application archive is downloaded and the command errors if it is missing.',
|
||||
default: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -45,14 +66,20 @@ export default class Download extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: {
|
||||
'build-id': buildId,
|
||||
platform,
|
||||
fingerprint,
|
||||
'dev-client': developmentClient,
|
||||
'all-artifacts': allArtifacts,
|
||||
...rawFlags
|
||||
},
|
||||
} = await this.parse(Download);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
|
||||
|
||||
if (!buildId && !fingerprint) {
|
||||
throw new Error('Either --build-id or --fingerprint is required.');
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
projectId,
|
||||
@@ -64,24 +91,113 @@ export default class Download extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
|
||||
const build = await this.getBuildAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform: selectedPlatform,
|
||||
fingerprintHash: fingerprint,
|
||||
developmentClient,
|
||||
});
|
||||
const buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
|
||||
let build: BuildFragment;
|
||||
let selectedPlatform: AppPlatform;
|
||||
if (buildId) {
|
||||
build = await this.getBuildByIdAsync({ graphqlClient, buildId });
|
||||
selectedPlatform = build.platform;
|
||||
Log.succeed(`🎯 Found build ${chalk.bold(buildId)} on EAS servers.`);
|
||||
} else {
|
||||
selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
|
||||
build = await this.getBuildByFingerprintAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform: selectedPlatform,
|
||||
fingerprintHash: fingerprint!,
|
||||
developmentClient,
|
||||
});
|
||||
}
|
||||
|
||||
let buildArtifactPath: string | null = null;
|
||||
if (build.artifacts?.applicationArchiveUrl) {
|
||||
buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
|
||||
} else if (!allArtifacts) {
|
||||
const availableArtifacts = listAvailableExtraArtifactNames(build);
|
||||
if (availableArtifacts.length > 0) {
|
||||
throw new Error(
|
||||
`Build does not have an application archive url. Other artifacts are available (${availableArtifacts.join(', ')}); re-run with --all-artifacts to download them.`
|
||||
);
|
||||
}
|
||||
throw new Error('Build does not have an application archive url');
|
||||
}
|
||||
|
||||
let extraArtifactPaths: Record<string, string> = {};
|
||||
if (allArtifacts) {
|
||||
extraArtifactPaths = await this.downloadExtraArtifactsAsync(build);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
const jsonResults = { path: buildArtifactPath };
|
||||
const jsonResults = {
|
||||
...(buildArtifactPath != null && { path: buildArtifactPath }),
|
||||
...extraArtifactPaths,
|
||||
};
|
||||
printJsonOnlyOutput(jsonResults);
|
||||
} else {
|
||||
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
|
||||
if (buildArtifactPath != null) {
|
||||
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
|
||||
}
|
||||
for (const [name, filePath] of Object.entries(extraArtifactPaths)) {
|
||||
Log.log(`${name} downloaded to ${chalk.bold(filePath)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getBuildAsync({
|
||||
private async downloadExtraArtifactsAsync(build: BuildFragment): Promise<Record<string, string>> {
|
||||
const artifacts = build.artifacts;
|
||||
if (!artifacts) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const extraArtifacts: Array<{ name: string; url: string }> = [];
|
||||
if (artifacts.buildArtifactsUrl) {
|
||||
extraArtifacts.push({ name: 'buildArtifacts', url: artifacts.buildArtifactsUrl });
|
||||
}
|
||||
if (artifacts.xcodeBuildLogsUrl) {
|
||||
extraArtifacts.push({ name: 'xcodeBuildLogs', url: artifacts.xcodeBuildLogsUrl });
|
||||
}
|
||||
if (artifacts.buildUrl && artifacts.buildUrl !== artifacts.applicationArchiveUrl) {
|
||||
extraArtifacts.push({ name: 'build', url: artifacts.buildUrl });
|
||||
}
|
||||
|
||||
if (extraArtifacts.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const outputDir = path.join(getEasBuildRunCacheDirectoryPath(), `${build.id}-artifacts`);
|
||||
await fs.ensureDir(outputDir);
|
||||
|
||||
const downloaded: Record<string, string> = {};
|
||||
for (const { name, url } of extraArtifacts) {
|
||||
const fileName = getFileNameFromUrl(url, name);
|
||||
const outputPath = path.join(outputDir, fileName);
|
||||
await downloadFileWithProgressTrackerAsync(
|
||||
url,
|
||||
outputPath,
|
||||
(ratio, total) =>
|
||||
`Downloading ${name} (${formatBytes(total * ratio)} / ${formatBytes(total)})`,
|
||||
`Successfully downloaded ${name}`
|
||||
);
|
||||
downloaded[name] = outputPath;
|
||||
}
|
||||
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
private async getBuildByIdAsync({
|
||||
graphqlClient,
|
||||
buildId,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
buildId: string;
|
||||
}): Promise<BuildFragment> {
|
||||
try {
|
||||
return await BuildQuery.byIdAsync(graphqlClient, buildId);
|
||||
} catch (error: any) {
|
||||
throw new Error(`Could not find build with ID ${buildId}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBuildByFingerprintAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform,
|
||||
@@ -108,7 +224,7 @@ export default class Download extends EasCommand {
|
||||
limit: 1,
|
||||
});
|
||||
if (builds.length === 0) {
|
||||
throw new Errors.CLIError(
|
||||
throw new Error(
|
||||
`No builds available for ${platform} with fingerprint hash ${fingerprintHash}`
|
||||
);
|
||||
}
|
||||
@@ -123,7 +239,7 @@ export default class Download extends EasCommand {
|
||||
build.id,
|
||||
platform
|
||||
);
|
||||
if (await pathExists(cachedBuildArtifactPath)) {
|
||||
if (await fs.pathExists(cachedBuildArtifactPath)) {
|
||||
Log.newLine();
|
||||
Log.log(`Using cached build...`);
|
||||
return cachedBuildArtifactPath;
|
||||
@@ -167,3 +283,33 @@ async function resolvePlatformAsync({
|
||||
});
|
||||
return selectedPlatform;
|
||||
}
|
||||
|
||||
function listAvailableExtraArtifactNames(build: BuildFragment): string[] {
|
||||
const names: string[] = [];
|
||||
if (build.artifacts?.buildArtifactsUrl) {
|
||||
names.push('buildArtifacts');
|
||||
}
|
||||
if (build.artifacts?.xcodeBuildLogsUrl) {
|
||||
names.push('xcodeBuildLogs');
|
||||
}
|
||||
if (
|
||||
build.artifacts?.buildUrl &&
|
||||
build.artifacts.buildUrl !== build.artifacts.applicationArchiveUrl
|
||||
) {
|
||||
names.push('build');
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function getFileNameFromUrl(url: string, fallbackName: string): string {
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
const basename = path.basename(pathname);
|
||||
if (basename) {
|
||||
return basename;
|
||||
}
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
return fallbackName;
|
||||
}
|
||||
@@ -215,7 +215,6 @@ export default class BuildResign extends EasCommand {
|
||||
},
|
||||
},
|
||||
secrets: prepareCredentialsToResign(credentialsResult.credentials),
|
||||
builderEnvironment: { image: 'default' },
|
||||
};
|
||||
const newBuild = await BuildMutation.retryIosBuildAsync(graphqlClient, {
|
||||
buildId: build.id,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { pathExists } from 'fs-extra';
|
||||
|
||||
import { getLatestBuildAsync, listAndSelectBuildOnAppAsync } from '../../build/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { preprocessBuildCommandArgs } from '../../commandUtils/buildFlags';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EasPaginatedQueryFlags,
|
||||
@@ -17,6 +18,7 @@ import { appPlatformDisplayNames } from '../../platform';
|
||||
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { RunArchiveFlags, getEasBuildRunCachedAppPath, runAsync } from '../../run/run';
|
||||
import { SimulatorRunTarget } from '../../run/ios/run';
|
||||
import { isRunnableOnSimulatorOrEmulator } from '../../run/utils';
|
||||
import {
|
||||
downloadAndMaybeExtractAppAsync,
|
||||
@@ -32,6 +34,7 @@ interface RawRunFlags {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
profile?: string;
|
||||
simulator?: string;
|
||||
}
|
||||
|
||||
interface RunCommandFlags {
|
||||
@@ -40,6 +43,7 @@ interface RunCommandFlags {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
profile?: string;
|
||||
simulator?: SimulatorRunTarget;
|
||||
}
|
||||
|
||||
export default class Run extends EasCommand {
|
||||
@@ -72,6 +76,11 @@ export default class Run extends EasCommand {
|
||||
'Name of the build profile used to create the build to run. When specified, only builds created with the specified build profile will be queried.',
|
||||
helpValue: 'PROFILE_NAME',
|
||||
}),
|
||||
simulator: Flags.string({
|
||||
description:
|
||||
'iOS simulator name or UDID to install and run the build on. If no value is provided, you will be prompted to select a simulator.',
|
||||
multiple: false,
|
||||
}),
|
||||
...EasPaginatedQueryFlags,
|
||||
};
|
||||
|
||||
@@ -82,7 +91,7 @@ export default class Run extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags: rawFlags } = await this.parse(Run);
|
||||
const { flags: rawFlags } = await this.parse(Run, preprocessBuildCommandArgs(this.argv));
|
||||
const flags = await this.sanitizeFlagsAsync(rawFlags);
|
||||
const queryOptions = getPaginatedQueryOptions(flags);
|
||||
const {
|
||||
@@ -99,11 +108,11 @@ export default class Run extends EasCommand {
|
||||
queryOptions
|
||||
);
|
||||
|
||||
await runAsync(simulatorBuildPath, flags.selectedPlatform);
|
||||
await runAsync(simulatorBuildPath, flags.selectedPlatform, flags.simulator);
|
||||
}
|
||||
|
||||
private async sanitizeFlagsAsync(flags: RawRunFlags): Promise<RunCommandFlags> {
|
||||
const { platform, limit, offset, profile, ...runArchiveFlags } = flags;
|
||||
const { platform, limit, offset, profile, simulator, ...runArchiveFlags } = flags;
|
||||
|
||||
const selectedPlatform = await resolvePlatformAsync(platform);
|
||||
|
||||
@@ -112,6 +121,9 @@ export default class Run extends EasCommand {
|
||||
exit: 1,
|
||||
});
|
||||
}
|
||||
if (simulator && selectedPlatform !== AppPlatform.Ios) {
|
||||
Errors.error('The --simulator flag can only be used with --platform ios.', { exit: 1 });
|
||||
}
|
||||
|
||||
if (
|
||||
runArchiveFlags.path &&
|
||||
@@ -137,6 +149,7 @@ export default class Run extends EasCommand {
|
||||
limit,
|
||||
offset,
|
||||
profile,
|
||||
simulator: simulator === '' ? true : simulator,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+126
-218
@@ -1,5 +1,5 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { App, User, UserRole } from '@expo/apple-utils';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
@@ -8,7 +8,6 @@ import * as path from 'path';
|
||||
import { Analytics } from '../analytics/AnalyticsManager';
|
||||
import EasCommand from '../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { saveProjectIdToAppConfigAsync } from '../commandUtils/context/contextUtils/getProjectIdAsync';
|
||||
import { CredentialsContext } from '../credentials/context';
|
||||
import { AppStoreApiKeyPurpose } from '../credentials/ios/actions/AscApiKeyUtils';
|
||||
import { getAppFromContextAsync } from '../credentials/ios/actions/BuildCredentialsUtils';
|
||||
@@ -22,31 +21,33 @@ import {
|
||||
WorkflowProjectSourceType,
|
||||
WorkflowRunStatus,
|
||||
} from '../graphql/generated';
|
||||
import { getWorkflowRunUrl } from '../build/utils/url';
|
||||
import { AppMutation } from '../graphql/mutations/AppMutation';
|
||||
import { WorkflowRunMutation } from '../graphql/mutations/WorkflowRunMutation';
|
||||
import { AppQuery } from '../graphql/queries/AppQuery';
|
||||
import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
import { confirmAsync } from '../prompts';
|
||||
import { ora } from '../ora';
|
||||
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
|
||||
import { ensureActorHasPrimaryAccount } from '../user/actions';
|
||||
import { Actor, getActorDisplayName } from '../user/User';
|
||||
import { sleepAsync } from '../utils/promise';
|
||||
import { resolveVcsClient } from '../vcs';
|
||||
import { Client as VcsClient } from '../vcs/vcs';
|
||||
import { Client } from '../vcs/vcs';
|
||||
import NoVcsClient from '../vcs/clients/noVcs';
|
||||
import {
|
||||
INVALID_BUNDLE_IDENTIFIER_MESSAGE,
|
||||
isBundleIdentifierValid,
|
||||
} from '../project/ios/bundleIdentifier';
|
||||
|
||||
// Expo Go release info - update when releasing a new version
|
||||
const EXPO_GO_SDK_VERSION = '55';
|
||||
const EXPO_GO_APP_VERSION = '55.0.11';
|
||||
const EXPO_GO_BUILD_NUMBER = '1017799';
|
||||
function deriveBundleIdSlug(bundleId: string): string {
|
||||
return bundleId.split('.').filter(Boolean).pop()!;
|
||||
}
|
||||
|
||||
const TESTFLIGHT_GROUP_NAME = 'Team (Expo)';
|
||||
|
||||
async function setupTestFlightAsync(ascApp: App): Promise<void> {
|
||||
// Create or get TestFlight group
|
||||
let group;
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
@@ -70,7 +71,7 @@ async function setupTestFlightAsync(ascApp: App): Promise<void> {
|
||||
// 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 new Promise(resolve => setTimeout(resolve, 10000));
|
||||
await sleepAsync(10_000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -118,30 +119,27 @@ async function withSuppressedOutputAsync<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return true;
|
||||
};
|
||||
|
||||
// Only suppress stdout, not stderr — ora writes spinner frames to stderr and
|
||||
// patching it would freeze the spinner animation during suppressed async work.
|
||||
// Suppress stdout and console output during credential setup so its verbose
|
||||
// log lines don't interleave with our progress spinners.
|
||||
process.stdout.write = capture as any;
|
||||
console.log = () => {};
|
||||
console.error = () => {};
|
||||
console.warn = () => {};
|
||||
|
||||
let didThrow = false;
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
console.log = originalConsoleLog;
|
||||
console.error = originalConsoleError;
|
||||
console.warn = originalConsoleWarn;
|
||||
|
||||
if (capturedOutput) {
|
||||
originalConsoleLog(capturedOutput);
|
||||
}
|
||||
didThrow = true;
|
||||
throw error;
|
||||
} finally {
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
console.log = originalConsoleLog;
|
||||
console.error = originalConsoleError;
|
||||
console.warn = originalConsoleWarn;
|
||||
if (didThrow && capturedOutput) {
|
||||
originalConsoleLog(capturedOutput);
|
||||
}
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
@@ -159,6 +157,10 @@ export default class Go extends EasCommand {
|
||||
description: 'App name',
|
||||
default: 'My Expo Go',
|
||||
}),
|
||||
'sdk-version': Flags.string({
|
||||
description: 'Expo Go SDK version to prepare (default: latest)',
|
||||
required: false,
|
||||
}),
|
||||
credentials: Flags.boolean({
|
||||
description: 'Interactively select credentials (default: auto-select)',
|
||||
default: false,
|
||||
@@ -187,34 +189,34 @@ export default class Go extends EasCommand {
|
||||
});
|
||||
Log.withTick(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
|
||||
|
||||
const sdkVersion = flags['sdk-version'];
|
||||
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
|
||||
const appName = flags.name ?? 'My Expo Go';
|
||||
const slug = bundleId.split('.').pop() || 'my-expo-go';
|
||||
|
||||
const projectDir = path.join(os.tmpdir(), `eas-go-${slug}`);
|
||||
await fs.emptyDir(projectDir);
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(projectDir);
|
||||
|
||||
const setupSpinner = ora('Creating project...').start();
|
||||
|
||||
// Step 1: Create project files and initialize git (silently)
|
||||
try {
|
||||
await withSuppressedOutputAsync(async () => {
|
||||
await this.createProjectFilesAsync(projectDir, bundleId, appName);
|
||||
await this.initGitRepoAsync(projectDir);
|
||||
});
|
||||
const vcsClient = resolveVcsClient();
|
||||
|
||||
// Step 2: Create/link EAS project (silently)
|
||||
const projectId = await withSuppressedOutputAsync(() =>
|
||||
this.ensureEasProjectAsync(graphqlClient, actor, projectDir, bundleId)
|
||||
if (!isBundleIdentifierValid(bundleId)) {
|
||||
throw new Error(
|
||||
`"${bundleId}" is not a valid iOS bundle identifier. ${INVALID_BUNDLE_IDENTIFIER_MESSAGE} Pass a valid identifier with --bundle-id.`
|
||||
);
|
||||
}
|
||||
const appName = flags.name;
|
||||
const slug = deriveBundleIdSlug(bundleId);
|
||||
|
||||
// Step 3: Set up iOS credentials and create App Store Connect app
|
||||
const setupSpinner = ora('Setting up project...').start();
|
||||
let projectId: string;
|
||||
try {
|
||||
projectId = await withSuppressedOutputAsync(() =>
|
||||
this.ensureEasProjectAsync(graphqlClient, actor, slug)
|
||||
);
|
||||
} catch (error) {
|
||||
setupSpinner.fail();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const tmpDir = path.join(os.tmpdir(), `eas-go-${Date.now()}`);
|
||||
await fs.ensureDir(tmpDir);
|
||||
const vcsClient = new NoVcsClient({ cwdOverride: tmpDir });
|
||||
|
||||
try {
|
||||
const ascApp = await this.setupCredentialsAsync(
|
||||
projectDir,
|
||||
slug,
|
||||
projectId,
|
||||
bundleId,
|
||||
appName,
|
||||
@@ -229,28 +231,30 @@ export default class Go extends EasCommand {
|
||||
}
|
||||
);
|
||||
|
||||
// Step 4: Start workflow and monitor progress
|
||||
const { workflowUrl, workflowRunId } = await this.runWorkflowAsync(
|
||||
const { workflowUrl, workflowRunId } = await this.dispatchWorkflowAsync(
|
||||
graphqlClient,
|
||||
projectDir,
|
||||
projectId,
|
||||
actor,
|
||||
bundleId,
|
||||
appName,
|
||||
ascApp.id,
|
||||
sdkVersion,
|
||||
tmpDir,
|
||||
vcsClient
|
||||
);
|
||||
Log.withTick(`Workflow started: ${chalk.cyan(workflowUrl)}`);
|
||||
Log.withTick(`Build started: ${chalk.cyan(workflowUrl)}`);
|
||||
|
||||
const status = await this.monitorWorkflowJobsAsync(graphqlClient, workflowRunId);
|
||||
if (status === WorkflowRunStatus.Failure) {
|
||||
throw new Error('Workflow failed');
|
||||
throw new Error('Build failed');
|
||||
} else if (status === WorkflowRunStatus.Canceled) {
|
||||
throw new Error('Workflow was canceled');
|
||||
throw new Error('Build was canceled');
|
||||
}
|
||||
|
||||
// Step 5: Set up TestFlight group (silently)
|
||||
try {
|
||||
await setupTestFlightAsync(ascApp);
|
||||
} catch {
|
||||
// Non-fatal: TestFlight group setup failure shouldn't block the user
|
||||
} catch (e) {
|
||||
Log.debug('TestFlight group setup failed:', e);
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
@@ -266,112 +270,27 @@ export default class Go extends EasCommand {
|
||||
{ learnMoreMessage: 'Learn more about Expo Go on TestFlight' }
|
||||
)}`
|
||||
);
|
||||
|
||||
await fs.remove(projectDir);
|
||||
} catch (error) {
|
||||
Log.gray(`Project files preserved for debugging: ${projectDir}`);
|
||||
throw error;
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
await fs.remove(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
private generateBundleId(actor: Actor): string {
|
||||
const username = actor.accounts[0].name;
|
||||
// Sanitize username for bundle ID: only alphanumeric and hyphens allowed
|
||||
const username = ensureActorHasPrimaryAccount(actor).name;
|
||||
const sanitizedUsername = username
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
||||
// Deterministic bundle ID per user + SDK version (reuses same ASC app)
|
||||
return `com.${sanitizedUsername || 'app'}.expogo${EXPO_GO_SDK_VERSION}`;
|
||||
}
|
||||
|
||||
private async createProjectFilesAsync(
|
||||
projectDir: string,
|
||||
bundleId: string,
|
||||
appName: string
|
||||
): Promise<void> {
|
||||
const slug = bundleId.split('.').pop() || 'custom-expo-go';
|
||||
const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`;
|
||||
|
||||
const appJson = {
|
||||
expo: {
|
||||
name: appName,
|
||||
slug,
|
||||
version: EXPO_GO_APP_VERSION,
|
||||
ios: {
|
||||
bundleIdentifier: bundleId,
|
||||
buildNumber: EXPO_GO_BUILD_NUMBER,
|
||||
config: {
|
||||
usesNonExemptEncryption: false,
|
||||
},
|
||||
},
|
||||
extra: {
|
||||
eas: {
|
||||
build: {
|
||||
experimental: {
|
||||
ios: {
|
||||
appExtensions: [
|
||||
{
|
||||
targetName: 'ExpoNotificationServiceExtension',
|
||||
bundleIdentifier: extensionBundleId,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const easJson = {
|
||||
cli: {
|
||||
version: '>= 5.0.0',
|
||||
},
|
||||
build: {
|
||||
production: {
|
||||
distribution: 'store',
|
||||
credentialsSource: 'remote',
|
||||
},
|
||||
},
|
||||
submit: {
|
||||
production: {
|
||||
ios: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const packageJson = {
|
||||
name: slug,
|
||||
version: '1.0.0',
|
||||
dependencies: {
|
||||
expo: '~54.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeJson(path.join(projectDir, 'app.json'), appJson, { spaces: 2 });
|
||||
await fs.writeJson(path.join(projectDir, 'eas.json'), easJson, { spaces: 2 });
|
||||
await fs.writeJson(path.join(projectDir, 'package.json'), packageJson, { spaces: 2 });
|
||||
|
||||
await spawnAsync('npm', ['install'], { cwd: projectDir });
|
||||
}
|
||||
|
||||
private async initGitRepoAsync(projectDir: string): Promise<void> {
|
||||
await spawnAsync('git', ['init'], { cwd: projectDir });
|
||||
await spawnAsync('git', ['add', '.'], { cwd: projectDir });
|
||||
await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectDir });
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return `com.${sanitizedUsername}.expogo`;
|
||||
}
|
||||
|
||||
private async ensureEasProjectAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
actor: Actor,
|
||||
projectDir: string,
|
||||
bundleId: string
|
||||
slug: string
|
||||
): Promise<string> {
|
||||
const slug = bundleId.split('.').pop() || 'custom-expo-go';
|
||||
const account = actor.accounts[0];
|
||||
const account = ensureActorHasPrimaryAccount(actor);
|
||||
|
||||
const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync(
|
||||
graphqlClient,
|
||||
@@ -380,38 +299,36 @@ export default class Go extends EasCommand {
|
||||
);
|
||||
|
||||
if (existingProjectId) {
|
||||
await saveProjectIdToAppConfigAsync(projectDir, existingProjectId);
|
||||
return existingProjectId;
|
||||
}
|
||||
|
||||
const projectId = await AppMutation.createAppAsync(graphqlClient, {
|
||||
return await AppMutation.createAppAsync(graphqlClient, {
|
||||
accountId: account.id,
|
||||
projectName: slug,
|
||||
});
|
||||
await saveProjectIdToAppConfigAsync(projectDir, projectId);
|
||||
return projectId;
|
||||
}
|
||||
|
||||
private async setupCredentialsAsync(
|
||||
projectDir: string,
|
||||
slug: string,
|
||||
projectId: string,
|
||||
bundleId: string,
|
||||
appName: string,
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
actor: Actor,
|
||||
analytics: Analytics,
|
||||
vcsClient: VcsClient,
|
||||
vcsClient: Client,
|
||||
customizeCreds: boolean,
|
||||
onBeforeAppleAuth?: () => void
|
||||
): Promise<App> {
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir);
|
||||
const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`;
|
||||
|
||||
const exp: ExpoConfig = { name: appName, slug, ios: { bundleIdentifier: bundleId } };
|
||||
|
||||
const credentialsCtx = new CredentialsContext({
|
||||
projectInfo: { exp, projectId },
|
||||
nonInteractive: false,
|
||||
autoAcceptCredentialReuse: !customizeCreds,
|
||||
projectDir,
|
||||
projectDir: process.cwd(),
|
||||
user: actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
@@ -425,7 +342,7 @@ export default class Go extends EasCommand {
|
||||
|
||||
const targets: Target[] = [
|
||||
{
|
||||
targetName: exp.slug,
|
||||
targetName: slug,
|
||||
bundleIdentifier: bundleId,
|
||||
entitlements: {},
|
||||
},
|
||||
@@ -437,43 +354,25 @@ export default class Go extends EasCommand {
|
||||
},
|
||||
];
|
||||
|
||||
const ascApp = await withSuppressedOutputAsync(async () => {
|
||||
await new SetUpBuildCredentials({
|
||||
app,
|
||||
targets,
|
||||
distribution: 'store',
|
||||
}).runAsync(credentialsCtx);
|
||||
await new SetUpBuildCredentials({
|
||||
app,
|
||||
targets,
|
||||
distribution: 'store',
|
||||
}).runAsync(credentialsCtx);
|
||||
|
||||
const appLookupParams = {
|
||||
...app,
|
||||
bundleIdentifier: bundleId,
|
||||
};
|
||||
await new SetUpAscApiKey(appLookupParams, AppStoreApiKeyPurpose.SUBMISSION_SERVICE).runAsync(
|
||||
credentialsCtx
|
||||
);
|
||||
const appLookupParams = {
|
||||
...app,
|
||||
bundleIdentifier: bundleId,
|
||||
};
|
||||
await new SetUpAscApiKey(appLookupParams, AppStoreApiKeyPurpose.SUBMISSION_SERVICE).runAsync(
|
||||
credentialsCtx
|
||||
);
|
||||
|
||||
const ascAppResult = await ensureAppExistsAsync(userAuthCtx, {
|
||||
name: appName,
|
||||
bundleIdentifier: bundleId,
|
||||
});
|
||||
|
||||
const easJsonPath = path.join(projectDir, 'eas.json');
|
||||
const easJson = await fs.readJson(easJsonPath);
|
||||
easJson.submit = easJson.submit || {};
|
||||
easJson.submit.production = easJson.submit.production || {};
|
||||
easJson.submit.production.ios = easJson.submit.production.ios || {};
|
||||
easJson.submit.production.ios.ascAppId = ascAppResult.id;
|
||||
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
|
||||
|
||||
await spawnAsync('git', ['add', 'eas.json'], { cwd: projectDir });
|
||||
await spawnAsync('git', ['commit', '-m', 'Add ascAppId to eas.json'], { cwd: projectDir });
|
||||
|
||||
return ascAppResult;
|
||||
const ascApp = await ensureAppExistsAsync(userAuthCtx, {
|
||||
name: appName,
|
||||
bundleIdentifier: bundleId,
|
||||
});
|
||||
|
||||
// Set up push notifications (outside suppressed block so prompts are visible)
|
||||
const appLookupParamsForPushKey = { ...app, bundleIdentifier: bundleId };
|
||||
const setupPushKeyAction = new SetUpPushKey(appLookupParamsForPushKey);
|
||||
const setupPushKeyAction = new SetUpPushKey(appLookupParams);
|
||||
const isPushKeySetup = await setupPushKeyAction.isPushKeySetupAsync(credentialsCtx);
|
||||
if (!isPushKeySetup) {
|
||||
if (customizeCreds) {
|
||||
@@ -492,14 +391,30 @@ export default class Go extends EasCommand {
|
||||
return ascApp;
|
||||
}
|
||||
|
||||
private async runWorkflowAsync(
|
||||
private async dispatchWorkflowAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectDir: string,
|
||||
projectId: string,
|
||||
actor: Actor,
|
||||
vcsClient: VcsClient
|
||||
bundleId: string,
|
||||
appName: string,
|
||||
ascAppId: string,
|
||||
sdkVersion: string | undefined,
|
||||
tmpDir: string,
|
||||
vcsClient: Client
|
||||
): Promise<{ workflowUrl: string; workflowRunId: string }> {
|
||||
const account = actor.accounts[0];
|
||||
const account = ensureActorHasPrimaryAccount(actor);
|
||||
|
||||
const repackConfig = await WorkflowRunQuery.expoGoRepackConfigurationAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
ascAppId,
|
||||
appName,
|
||||
bundleId,
|
||||
sdkVersion,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
repackConfig.files.map(f => fs.writeFile(path.join(tmpDir, f.fileName), f.fileContents))
|
||||
);
|
||||
|
||||
const { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey } =
|
||||
await withSuppressedOutputAsync(async () => {
|
||||
@@ -508,42 +423,35 @@ export default class Go extends EasCommand {
|
||||
vcsClient,
|
||||
accountId: account.id,
|
||||
});
|
||||
|
||||
const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
|
||||
graphqlClient,
|
||||
accountId: account.id,
|
||||
filePath: path.join(projectDir, 'eas.json'),
|
||||
filePath: path.join(tmpDir, 'eas.json'),
|
||||
maxSizeBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
|
||||
graphqlClient,
|
||||
accountId: account.id,
|
||||
filePath: path.join(projectDir, 'package.json'),
|
||||
filePath: path.join(tmpDir, 'package.json'),
|
||||
maxSizeBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
return { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey };
|
||||
});
|
||||
|
||||
const { id: workflowRunId } = await WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
appId: projectId,
|
||||
projectSource: {
|
||||
type: WorkflowProjectSourceType.Gcs,
|
||||
projectArchiveBucketKey,
|
||||
easJsonBucketKey,
|
||||
packageJsonBucketKey,
|
||||
projectRootDirectory: '.',
|
||||
},
|
||||
}
|
||||
);
|
||||
const result = await WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
sdkVersion: repackConfig.sdkVersion,
|
||||
projectSource: {
|
||||
type: WorkflowProjectSourceType.Gcs,
|
||||
projectArchiveBucketKey,
|
||||
easJsonBucketKey,
|
||||
packageJsonBucketKey,
|
||||
},
|
||||
});
|
||||
|
||||
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
const workflowUrl = `https://expo.dev/accounts/${account.name}/projects/${app.slug}/workflows/${workflowRunId}`;
|
||||
const workflowUrl = getWorkflowRunUrl(account.name, deriveBundleIdSlug(bundleId), result.id);
|
||||
|
||||
return { workflowUrl, workflowRunId };
|
||||
return { workflowUrl, workflowRunId: result.id };
|
||||
}
|
||||
|
||||
private async monitorWorkflowJobsAsync(
|
||||
@@ -584,8 +492,8 @@ export default class Go extends EasCommand {
|
||||
});
|
||||
failedFetchesCount = 0;
|
||||
|
||||
const repackJob = workflowRun.jobs.find(j => j.name === 'Repack Expo Go');
|
||||
const submitJob = workflowRun.jobs.find(j => j.name === 'Submit to TestFlight');
|
||||
const repackJob = workflowRun.jobs.find(j => j.key === 'build');
|
||||
const submitJob = workflowRun.jobs.find(j => j.key === 'submit');
|
||||
|
||||
if (!buildCompleted) {
|
||||
if (repackJob?.status === WorkflowJobStatus.Success) {
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../../../../commandUtils/errors';
|
||||
import { selectOrCreateAscApiKeyIdAsync } from '../../../../integrations/asc/ascApiKey';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
|
||||
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
|
||||
import IntegrationsAscConnect from '../connect';
|
||||
|
||||
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
|
||||
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
|
||||
jest.mock('../../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery');
|
||||
jest.mock('../../../../integrations/asc/ascApiKey');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora');
|
||||
|
||||
const testProjectId = 'test-project-id';
|
||||
const mockMetadataConnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: {
|
||||
id: 'asc-app-link-id',
|
||||
ascAppIdentifier: '1234567890',
|
||||
remoteAppStoreConnectApp: {
|
||||
ascAppIdentifier: '1234567890',
|
||||
bundleIdentifier: 'com.test.app',
|
||||
name: 'Test App',
|
||||
appStoreIconUrl: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockMetadataDisconnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: null,
|
||||
};
|
||||
|
||||
const mockRemoteApps = [
|
||||
{
|
||||
ascAppIdentifier: '9876543210',
|
||||
bundleIdentifier: 'com.test.newapp',
|
||||
name: 'New App',
|
||||
appStoreIconUrl: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe(IntegrationsAscConnect, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const actor = { id: 'actor-id' } as any;
|
||||
const analytics = {} as any;
|
||||
const vcsClient = {} as any;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('fails when already connected', async () => {
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'key-id', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(EasCommandError);
|
||||
});
|
||||
|
||||
it('connects successfully in non-interactive mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected)
|
||||
.mockResolvedValueOnce(mockMetadataConnected);
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
|
||||
jest
|
||||
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
|
||||
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
ascAppIdentifier: '9876543210',
|
||||
appStoreConnectApiKeyId: 'key-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts Apple key identifier in non-interactive mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected)
|
||||
.mockResolvedValueOnce(mockMetadataConnected);
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
|
||||
jest
|
||||
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
|
||||
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'eas-key-uuid',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
ascAppIdentifier: '9876543210',
|
||||
appStoreConnectApiKeyId: 'eas-key-uuid',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires --api-key-id in non-interactive mode', async () => {
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('--api-key-id is required');
|
||||
});
|
||||
|
||||
it('requires --asc-app-id in non-interactive mode', async () => {
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('--asc-app-id is required');
|
||||
});
|
||||
|
||||
it('fails when asc-app-id is not found among discovered apps', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', 'nonexistent', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('was not found among accessible apps');
|
||||
});
|
||||
|
||||
it('fails when passing EAS key id in non-interactive mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'eas-key-uuid',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'eas-key-uuid', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'No App Store Connect API key found with Apple key identifier'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when multiple keys match Apple key identifier', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'eas-key-uuid-1',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
{
|
||||
id: 'eas-key-uuid-2',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'Multiple App Store Connect API keys match Apple key identifier'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when no accessible apps are discovered', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce([]);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'No accessible apps found on App Store Connect'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when app discovery throws', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync)
|
||||
.mockRejectedValueOnce(new Error('discovery failed'));
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('discovery failed');
|
||||
});
|
||||
|
||||
it('creates or selects key when no api-key-id is provided', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected)
|
||||
.mockResolvedValueOnce(mockMetadataConnected);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([]);
|
||||
jest.mocked(selectOrCreateAscApiKeyIdAsync).mockResolvedValueOnce('generated-key-id');
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
|
||||
jest
|
||||
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
|
||||
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
|
||||
|
||||
const command = new IntegrationsAscConnect(['--asc-app-id', '9876543210'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(selectOrCreateAscApiKeyIdAsync).toHaveBeenCalled();
|
||||
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
ascAppIdentifier: '9876543210',
|
||||
appStoreConnectApiKeyId: 'generated-key-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
|
||||
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../../log';
|
||||
import { toggleConfirmAsync } from '../../../../prompts';
|
||||
import IntegrationsAscDisconnect from '../disconnect';
|
||||
|
||||
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
|
||||
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora');
|
||||
jest.mock('../../../../prompts');
|
||||
|
||||
const testProjectId = 'test-project-id';
|
||||
const mockMetadataConnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: {
|
||||
id: 'asc-app-link-id',
|
||||
ascAppIdentifier: '1234567890',
|
||||
remoteAppStoreConnectApp: {
|
||||
ascAppIdentifier: '1234567890',
|
||||
bundleIdentifier: 'com.test.app',
|
||||
name: 'Test App',
|
||||
appStoreIconUrl: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockMetadataDisconnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: null,
|
||||
};
|
||||
|
||||
describe(IntegrationsAscDisconnect, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('disconnects successfully with --yes', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataConnected)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AscAppLinkMutation.deleteAppStoreConnectAppAsync).mockResolvedValueOnce(undefined);
|
||||
|
||||
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'asc-app-link-id'
|
||||
);
|
||||
expect(AscAppLinkQuery.getAppMetadataAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
graphqlClient,
|
||||
testProjectId,
|
||||
{
|
||||
useCache: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('no-op when already disconnected', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
|
||||
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints json output when already disconnected in json mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
|
||||
const command = new IntegrationsAscDisconnect(['--yes', '--json'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"action": "disconnect"')
|
||||
);
|
||||
});
|
||||
|
||||
it('handles invalid ASC API key gracefully', async () => {
|
||||
const ascError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
|
||||
),
|
||||
],
|
||||
});
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
|
||||
|
||||
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(Log.warn)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('revoked or is no longer valid')
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels disconnection when confirmation is rejected', async () => {
|
||||
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit called');
|
||||
}) as never);
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
|
||||
jest.mocked(toggleConfirmAsync).mockResolvedValueOnce(false);
|
||||
|
||||
const command = new IntegrationsAscDisconnect([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('process.exit called');
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../../log';
|
||||
import IntegrationsAscStatus from '../status';
|
||||
|
||||
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora');
|
||||
|
||||
const testProjectId = 'test-project-id';
|
||||
const mockMetadataConnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: {
|
||||
id: 'asc-app-link-id',
|
||||
ascAppIdentifier: '1234567890',
|
||||
remoteAppStoreConnectApp: {
|
||||
ascAppIdentifier: '1234567890',
|
||||
bundleIdentifier: 'com.test.app',
|
||||
name: 'Test App',
|
||||
appStoreIconUrl: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockMetadataDisconnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: null,
|
||||
};
|
||||
|
||||
describe(IntegrationsAscStatus, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('displays connected status', async () => {
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
|
||||
|
||||
const command = new IntegrationsAscStatus([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(AscAppLinkQuery.getAppMetadataAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays disconnected status', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
|
||||
const command = new IntegrationsAscStatus([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints json output in json mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
|
||||
const command = new IntegrationsAscStatus(['--json'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"action": "status"')
|
||||
);
|
||||
});
|
||||
|
||||
it('handles invalid ASC API key gracefully', async () => {
|
||||
const ascError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
|
||||
),
|
||||
],
|
||||
});
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
|
||||
|
||||
const command = new IntegrationsAscStatus([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(Log.warn)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('revoked or is no longer valid')
|
||||
);
|
||||
});
|
||||
|
||||
it('handles invalid ASC API key with json output', async () => {
|
||||
const ascError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
|
||||
),
|
||||
],
|
||||
});
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
|
||||
|
||||
const command = new IntegrationsAscStatus(['--json'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"status": "invalid"')
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when fetching status fails', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockRejectedValueOnce(new Error('status failed'));
|
||||
|
||||
const command = new IntegrationsAscStatus([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('status failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import { CredentialsContext } from '../../../credentials/context';
|
||||
import { buildJsonOutput, formatAscAppLinkStatus } from '../../../integrations/asc/utils';
|
||||
import { selectOrCreateAscApiKeyIdAsync } from '../../../integrations/asc/ascApiKey';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import { AscAppLinkMutation } from '../../../graphql/mutations/AscAppLinkMutation';
|
||||
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { selectAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
type DiscoveredAscApps = Awaited<ReturnType<typeof AscAppLinkQuery.discoverAccessibleAppsAsync>>;
|
||||
|
||||
export default class IntegrationsAscConnect extends EasCommand {
|
||||
static override description = 'connect a project to an App Store Connect app';
|
||||
|
||||
static override flags = {
|
||||
'api-key-id': Flags.string({
|
||||
description: 'Apple App Store Connect API Key ID',
|
||||
}),
|
||||
'asc-app-id': Flags.string({
|
||||
description: 'App Store Connect app identifier',
|
||||
}),
|
||||
'bundle-id': Flags.string({
|
||||
description: 'Filter discovered apps by bundle identifier',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.Analytics,
|
||||
...this.ContextOptions.Vcs,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(IntegrationsAscConnect);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
if (!flags['api-key-id']) {
|
||||
throw new EasCommandError('--api-key-id is required in non-interactive mode.');
|
||||
}
|
||||
if (!flags['asc-app-id']) {
|
||||
throw new EasCommandError('--asc-app-id is required in non-interactive mode.');
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
projectDir,
|
||||
loggedIn: { actor, graphqlClient },
|
||||
analytics,
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(IntegrationsAscConnect, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
// Step 1: Check current status
|
||||
const statusSpinner = ora('Checking current App Store Connect app link status').start();
|
||||
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
|
||||
statusSpinner.succeed('Checked current status');
|
||||
|
||||
if (metadata.appStoreConnectApp) {
|
||||
throw new EasCommandError(
|
||||
`Project ${chalk.bold(metadata.fullName)} is already connected to App Store Connect app ${chalk.bold(metadata.appStoreConnectApp.ascAppIdentifier)}. Disconnect first with ${chalk.bold('eas integrations:asc:disconnect')}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Get ASC API key
|
||||
const keysSpinner = ora('Fetching App Store Connect API keys').start();
|
||||
const keys = await AppStoreConnectApiKeyQuery.getAllForAccountAsync(
|
||||
graphqlClient,
|
||||
metadata.ownerAccount.name
|
||||
);
|
||||
keysSpinner.succeed(`Found ${keys.length} API key(s)`);
|
||||
|
||||
let apiKeyId = flags['api-key-id'];
|
||||
if (!apiKeyId) {
|
||||
const credentialsContext = new CredentialsContext({
|
||||
projectInfo: null,
|
||||
nonInteractive,
|
||||
projectDir,
|
||||
user: actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
});
|
||||
|
||||
apiKeyId = await selectOrCreateAscApiKeyIdAsync({
|
||||
credentialsContext,
|
||||
existingKeys: keys,
|
||||
ownerAccount: metadata.ownerAccount,
|
||||
});
|
||||
} else {
|
||||
const keysByAppleId = keys.filter(key => key.keyIdentifier === apiKeyId);
|
||||
if (keysByAppleId.length > 1) {
|
||||
throw new EasCommandError(
|
||||
`Multiple App Store Connect API keys match Apple key identifier "${apiKeyId}".`
|
||||
);
|
||||
} else if (keysByAppleId.length === 1) {
|
||||
apiKeyId = keysByAppleId[0].id;
|
||||
} else {
|
||||
throw new EasCommandError(
|
||||
`No App Store Connect API key found with Apple key identifier "${apiKeyId}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!apiKeyId) {
|
||||
throw new EasCommandError('No App Store Connect API key selected.');
|
||||
}
|
||||
|
||||
// Step 3: Discover remote apps
|
||||
const discoverSpinner = ora('Discovering App Store Connect apps').start();
|
||||
let remoteApps: DiscoveredAscApps;
|
||||
try {
|
||||
remoteApps = await AscAppLinkQuery.discoverAccessibleAppsAsync(
|
||||
graphqlClient,
|
||||
apiKeyId,
|
||||
flags['bundle-id']
|
||||
);
|
||||
discoverSpinner.succeed(`Found ${remoteApps.length} app(s) on App Store Connect`);
|
||||
} catch (err) {
|
||||
discoverSpinner.fail('Failed to discover apps');
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (remoteApps.length === 0) {
|
||||
throw new EasCommandError(
|
||||
'No accessible apps found on App Store Connect for the selected API key.' +
|
||||
(flags['bundle-id']
|
||||
? ` Try removing the --bundle-id filter or verify the bundle ID "${flags['bundle-id']}".`
|
||||
: '')
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Select remote app
|
||||
let selectedApp: DiscoveredAscApps[number];
|
||||
if (flags['asc-app-id']) {
|
||||
const match = remoteApps.find(app => app.ascAppIdentifier === flags['asc-app-id']);
|
||||
if (!match) {
|
||||
throw new EasCommandError(
|
||||
`App with identifier "${flags['asc-app-id']}" was not found among accessible apps. Run ${chalk.bold('eas integrations:asc:connect')} interactively to discover available apps.`
|
||||
);
|
||||
}
|
||||
selectedApp = match;
|
||||
} else {
|
||||
selectedApp = await selectAsync<DiscoveredAscApps[number]>(
|
||||
'Select an App Store Connect app:',
|
||||
remoteApps.map(app => ({
|
||||
title: `${app.name} (${app.bundleIdentifier}) [${app.ascAppIdentifier}]`,
|
||||
value: app,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Step 5: Create link
|
||||
const createSpinner = ora('Connecting project to App Store Connect app').start();
|
||||
try {
|
||||
await AscAppLinkMutation.createAppStoreConnectAppAsync(graphqlClient, {
|
||||
appId: metadata.id,
|
||||
ascAppIdentifier: selectedApp.ascAppIdentifier,
|
||||
appStoreConnectApiKeyId: apiKeyId,
|
||||
});
|
||||
createSpinner.succeed('Connected project to App Store Connect app');
|
||||
} catch (err) {
|
||||
createSpinner.fail('Failed to connect project');
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Step 6: Refetch and display
|
||||
const refetchSpinner = ora('Verifying connection').start();
|
||||
const updatedMetadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
|
||||
refetchSpinner.succeed('Verified connection');
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildJsonOutput('connect', updatedMetadata));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatAscAppLinkStatus(updatedMetadata));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import {
|
||||
buildInvalidJsonOutput,
|
||||
buildJsonOutput,
|
||||
formatAscAppLinkStatus,
|
||||
isAscAuthenticationError,
|
||||
} from '../../../integrations/asc/utils';
|
||||
import { AscAppLinkMutation } from '../../../graphql/mutations/AscAppLinkMutation';
|
||||
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { toggleConfirmAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class IntegrationsAscDisconnect extends EasCommand {
|
||||
static override description = 'disconnect the current project from its App Store Connect app';
|
||||
|
||||
static override flags = {
|
||||
yes: Flags.boolean({
|
||||
description: 'Skip confirmation prompt',
|
||||
default: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(IntegrationsAscDisconnect);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsAscDisconnect, {
|
||||
nonInteractive: nonInteractive || flags.yes,
|
||||
});
|
||||
|
||||
// Step 1: Check current status
|
||||
const metadata = await this.fetchCurrentStatusAsync(graphqlClient, projectId);
|
||||
if (!metadata) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildInvalidJsonOutput('disconnect', projectId));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
'The App Store Connect API key linked to this project has been revoked or is no longer valid.\nThe connection cannot be resolved from the CLI. Please update the API key on the Expo dashboard.'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!metadata.appStoreConnectApp) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildJsonOutput('disconnect', metadata));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`Project ${chalk.bold(metadata.fullName)} is not connected to any App Store Connect app.`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Confirm
|
||||
if (!flags.yes && !nonInteractive) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatAscAppLinkStatus(metadata));
|
||||
Log.newLine();
|
||||
Log.warn(
|
||||
'You are about to disconnect this project from its App Store Connect app.\nThis action is reversible by reconnecting.'
|
||||
);
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({
|
||||
message: 'Are you sure you wish to proceed?',
|
||||
});
|
||||
if (!confirmed) {
|
||||
Log.error('Canceled disconnection');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Delete
|
||||
const deleteSpinner = ora('Disconnecting App Store Connect app').start();
|
||||
try {
|
||||
await AscAppLinkMutation.deleteAppStoreConnectAppAsync(
|
||||
graphqlClient,
|
||||
metadata.appStoreConnectApp.id
|
||||
);
|
||||
deleteSpinner.succeed('Disconnected App Store Connect app');
|
||||
} catch (err) {
|
||||
deleteSpinner.fail('Failed to disconnect App Store Connect app');
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Step 4: Refetch and display
|
||||
const refetchSpinner = ora('Verifying disconnection').start();
|
||||
const updatedMetadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId, {
|
||||
useCache: false,
|
||||
});
|
||||
refetchSpinner.succeed('Verified disconnection');
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildJsonOutput('disconnect', updatedMetadata));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatAscAppLinkStatus(updatedMetadata));
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchCurrentStatusAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string
|
||||
): Promise<Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>> | null> {
|
||||
const spinner = ora('Checking current App Store Connect app link status').start();
|
||||
try {
|
||||
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
|
||||
spinner.succeed('Checked current status');
|
||||
return metadata;
|
||||
} catch (err) {
|
||||
if (isAscAuthenticationError(err)) {
|
||||
spinner.fail('App Store Connect connection is invalid');
|
||||
return null;
|
||||
}
|
||||
spinner.fail('Failed to check current status');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import {
|
||||
buildInvalidJsonOutput,
|
||||
buildJsonOutput,
|
||||
formatAscAppLinkStatus,
|
||||
isAscAuthenticationError,
|
||||
} from '../../../integrations/asc/utils';
|
||||
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class IntegrationsAscStatus extends EasCommand {
|
||||
static override description =
|
||||
'show the App Store Connect app link status for the current project';
|
||||
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(IntegrationsAscStatus);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsAscStatus, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
const metadata = await this.fetchStatusAsync(graphqlClient, projectId);
|
||||
if (!metadata) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildInvalidJsonOutput('status', projectId));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
'The App Store Connect API key linked to this project has been revoked or is no longer valid.'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildJsonOutput('status', metadata));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatAscAppLinkStatus(metadata));
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchStatusAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string
|
||||
): Promise<Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>> | null> {
|
||||
const spinner = ora('Fetching App Store Connect app link status').start();
|
||||
try {
|
||||
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
|
||||
spinner.succeed('Fetched App Store Connect app link status');
|
||||
return metadata;
|
||||
} catch (err) {
|
||||
if (isAscAuthenticationError(err)) {
|
||||
spinner.fail('App Store Connect connection is invalid');
|
||||
return null;
|
||||
}
|
||||
spinner.fail('Failed to fetch App Store Connect app link status');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import * as fs from 'fs-extra';
|
||||
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { DefaultEnvironment } from '../../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../../../graphql/generated';
|
||||
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
|
||||
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
|
||||
import { EnvironmentVariablesQuery } from '../../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import {
|
||||
ConvexTeamConnectionData,
|
||||
SetupConvexProjectResultData,
|
||||
} from '../../../../graphql/types/ConvexTeamConnection';
|
||||
import Log from '../../../../log';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
|
||||
import { confirmAsync, promptAsync, selectAsync } from '../../../../prompts';
|
||||
import { Actor } from '../../../../user/User';
|
||||
import IntegrationsConvexConnect from '../connect';
|
||||
|
||||
jest.mock('../../../../graphql/queries/ConvexQuery');
|
||||
jest.mock('../../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../../graphql/mutations/ConvexMutation');
|
||||
jest.mock('../../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
jest.mock('../../../../project/projectUtils');
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('@expo/spawn-async');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora', () => ({
|
||||
ora: () => ({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe(IntegrationsConvexConnect, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const testProjectDir = '/test/project';
|
||||
const testAccountId = 'test-account-id';
|
||||
const testAccountName = 'testuser';
|
||||
|
||||
const mockActor: Actor = {
|
||||
__typename: 'User',
|
||||
id: 'test-user-id',
|
||||
username: testAccountName,
|
||||
email: '[email protected]',
|
||||
featureGates: {},
|
||||
isExpoAdmin: false,
|
||||
primaryAccount: {
|
||||
id: testAccountId,
|
||||
name: testAccountName,
|
||||
ownerUserActor: null,
|
||||
users: [],
|
||||
},
|
||||
preferences: { onboarding: null },
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
const mockRobotActor: Actor = {
|
||||
__typename: 'Robot',
|
||||
id: 'robot-1',
|
||||
featureGates: {},
|
||||
isExpoAdmin: false,
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
const mockAccount = {
|
||||
id: testAccountId,
|
||||
name: testAccountName,
|
||||
ownerUserActor: { id: 'test-user-id', username: testAccountName },
|
||||
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
|
||||
};
|
||||
|
||||
const mockConnection: ConvexTeamConnectionData = {
|
||||
id: 'connection-1',
|
||||
convexTeamIdentifier: 'team-123',
|
||||
convexTeamName: 'Test Team',
|
||||
convexTeamSlug: 'test-team',
|
||||
hasBeenClaimed: false,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
invitedAt: null,
|
||||
invitedEmail: null,
|
||||
};
|
||||
|
||||
const mockSetupResult: SetupConvexProjectResultData = {
|
||||
convexDeploymentName: 'happy-otter-123',
|
||||
convexDeploymentUrl: 'https://happy-otter-123.convex.cloud',
|
||||
deployKey: 'dev:happy-otter-123|abc123token',
|
||||
convexProject: {
|
||||
id: 'convex-project-1',
|
||||
convexProjectIdentifier: 'project-123',
|
||||
convexProjectName: 'testapp',
|
||||
convexProjectSlug: 'testapp',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
convexTeamConnection: mockConnection,
|
||||
},
|
||||
};
|
||||
|
||||
function createCommand(argv: string[], actor: Actor = mockActor): IntegrationsConvexConnect {
|
||||
const command = new IntegrationsConvexConnect(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
exp: { name: 'testapp', slug: 'testapp' },
|
||||
projectDir: testProjectDir,
|
||||
},
|
||||
loggedIn: { graphqlClient, actor },
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'withTick').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
|
||||
|
||||
jest.mocked(fs.pathExists).mockResolvedValue(false as never);
|
||||
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
|
||||
jest.mocked(fs.readFile).mockResolvedValue('' as never);
|
||||
jest.mocked(spawnAsync).mockResolvedValue({} as never);
|
||||
|
||||
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
|
||||
jest.mocked(selectAsync).mockResolvedValue('aws-us-east-1');
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
jest.mocked(promptAsync).mockImplementation(async (params: any) => ({
|
||||
[params.name]: params.initial,
|
||||
}));
|
||||
jest.mocked(ConvexMutation.createConvexTeamConnectionAsync).mockResolvedValue(mockConnection);
|
||||
jest.mocked(ConvexMutation.setupConvexProjectAsync).mockResolvedValue(mockSetupResult);
|
||||
jest.mocked(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).mockResolvedValue(true);
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]);
|
||||
jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({
|
||||
id: 'env-var-1',
|
||||
name: 'EXPO_PUBLIC_CONVEX_URL',
|
||||
value: mockSetupResult.convexDeploymentUrl,
|
||||
environments: [
|
||||
DefaultEnvironment.Production,
|
||||
DefaultEnvironment.Preview,
|
||||
DefaultEnvironment.Development,
|
||||
],
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
});
|
||||
jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValue({
|
||||
id: 'env-var-1',
|
||||
name: 'EXPO_PUBLIC_CONVEX_URL',
|
||||
value: mockSetupResult.convexDeploymentUrl,
|
||||
environments: [
|
||||
DefaultEnvironment.Production,
|
||||
DefaultEnvironment.Preview,
|
||||
DefaultEnvironment.Development,
|
||||
],
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a Convex team and project with the new mutation shape', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(ConvexMutation.createConvexTeamConnectionAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
accountId: testAccountId,
|
||||
deploymentRegion: 'aws-us-east-1',
|
||||
convexTeamName: testAccountName,
|
||||
});
|
||||
expect(ConvexMutation.setupConvexProjectAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
convexTeamConnectionId: 'connection-1',
|
||||
deploymentRegion: 'aws-us-east-1',
|
||||
projectName: 'testapp',
|
||||
});
|
||||
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{ convexTeamConnectionId: 'connection-1' }
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Check your email for an invitation')
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('npx convex dev'));
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://docs.expo.dev/guides/using-convex')
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('https://docs.convex.dev/'));
|
||||
expect(spawnAsync).toHaveBeenCalledWith('npx', ['expo', 'install', 'convex'], {
|
||||
cwd: testProjectDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses an existing Convex team connection', async () => {
|
||||
jest
|
||||
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
|
||||
.mockResolvedValue([mockConnection]);
|
||||
|
||||
await createCommand(['--region', 'aws-eu-west-1']).runAsync();
|
||||
|
||||
expect(ConvexMutation.createConvexTeamConnectionAsync).not.toHaveBeenCalled();
|
||||
expect(ConvexMutation.setupConvexProjectAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
convexTeamConnectionId: 'connection-1',
|
||||
deploymentRegion: 'aws-eu-west-1',
|
||||
projectName: 'testapp',
|
||||
});
|
||||
expect(spawnAsync).toHaveBeenCalledWith('npx', ['expo', 'install', 'convex'], {
|
||||
cwd: testProjectDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not create a Convex team if installing the Convex package fails first', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
|
||||
jest.mocked(spawnAsync).mockRejectedValue(new Error('install failed') as never);
|
||||
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow('install failed');
|
||||
|
||||
expect(ConvexMutation.createConvexTeamConnectionAsync).not.toHaveBeenCalled();
|
||||
expect(ConvexMutation.setupConvexProjectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the verified email invite when the actor has no email', async () => {
|
||||
jest
|
||||
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
|
||||
.mockResolvedValue([mockConnection]);
|
||||
|
||||
await createCommand([], mockRobotActor).runAsync();
|
||||
|
||||
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not determine your verified email address')
|
||||
);
|
||||
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Convex dashboard'));
|
||||
});
|
||||
|
||||
it('logs the error when sending a verified email invite fails', async () => {
|
||||
jest
|
||||
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
|
||||
.mockResolvedValue([mockConnection]);
|
||||
jest
|
||||
.mocked(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync)
|
||||
.mockRejectedValue(new Error('Convex invite service unavailable'));
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to send Convex team invitation to [email protected]')
|
||||
);
|
||||
expect(Log.warn).toHaveBeenCalledWith('Convex invite service unavailable');
|
||||
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Convex dashboard'));
|
||||
});
|
||||
|
||||
it('asks before resending a recent team invite for an existing connection', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
|
||||
{
|
||||
...mockConnection,
|
||||
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
invitedEmail: '[email protected]',
|
||||
},
|
||||
]);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('Are you sure you want to send another invite?'),
|
||||
});
|
||||
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith('Skipped sending Convex team invitation.');
|
||||
expect(Log.log).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Check your email for an invitation')
|
||||
);
|
||||
});
|
||||
|
||||
it('skips sending an invite when the Convex team has already been claimed', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
|
||||
{
|
||||
...mockConnection,
|
||||
hasBeenClaimed: true,
|
||||
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('Are you sure you want to send another invite?'),
|
||||
});
|
||||
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
'Convex team has already been claimed. Skipping Convex team invitation.'
|
||||
);
|
||||
expect(Log.log).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Check your email for an invitation')
|
||||
);
|
||||
});
|
||||
|
||||
it('writes the deploy key and Convex URL to .env.local', async () => {
|
||||
jest
|
||||
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
|
||||
.mockResolvedValue([mockConnection]);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
const envPath = `${testProjectDir}/.env.local`;
|
||||
const writeCall = jest.mocked(fs.writeFile).mock.calls.find(call => call[0] === envPath);
|
||||
expect(writeCall?.[1]).toContain('CONVEX_DEPLOY_KEY=dev:happy-otter-123|abc123token');
|
||||
expect(writeCall?.[1]).toContain('EXPO_PUBLIC_CONVEX_URL=https://happy-otter-123.convex.cloud');
|
||||
});
|
||||
|
||||
it('creates the Convex URL as a project EAS environment variable for all default environments', async () => {
|
||||
jest
|
||||
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
|
||||
.mockResolvedValue([mockConnection]);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
filterNames: ['EXPO_PUBLIC_CONVEX_URL'],
|
||||
});
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
name: 'EXPO_PUBLIC_CONVEX_URL',
|
||||
value: 'https://happy-otter-123.convex.cloud',
|
||||
environments: [
|
||||
DefaultEnvironment.Production,
|
||||
DefaultEnvironment.Preview,
|
||||
DefaultEnvironment.Development,
|
||||
],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testProjectId
|
||||
);
|
||||
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates an existing project EAS environment variable for the Convex URL', async () => {
|
||||
jest
|
||||
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
|
||||
.mockResolvedValue([mockConnection]);
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
|
||||
{
|
||||
id: 'env-var-1',
|
||||
name: 'EXPO_PUBLIC_CONVEX_URL',
|
||||
value: 'https://old-deployment.convex.cloud',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
]);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining(
|
||||
'EAS already has an EXPO_PUBLIC_CONVEX_URL environment variable'
|
||||
),
|
||||
});
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: 'env-var-1',
|
||||
name: 'EXPO_PUBLIC_CONVEX_URL',
|
||||
value: 'https://happy-otter-123.convex.cloud',
|
||||
environments: [
|
||||
DefaultEnvironment.Production,
|
||||
DefaultEnvironment.Preview,
|
||||
DefaultEnvironment.Development,
|
||||
],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
});
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips updating an existing project EAS environment variable for the Convex URL when declined', async () => {
|
||||
jest
|
||||
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
|
||||
.mockResolvedValue([mockConnection]);
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
|
||||
{
|
||||
id: 'env-var-1',
|
||||
name: 'EXPO_PUBLIC_CONVEX_URL',
|
||||
value: 'https://old-deployment.convex.cloud',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
]);
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining(
|
||||
'EAS already has an EXPO_PUBLIC_CONVEX_URL environment variable'
|
||||
),
|
||||
});
|
||||
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Skipped updating EAS environment variable')
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts before overwriting existing Convex .env.local values', async () => {
|
||||
jest
|
||||
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
|
||||
.mockResolvedValue([mockConnection]);
|
||||
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
|
||||
jest.mocked(fs.readFile).mockResolvedValue('CONVEX_DEPLOY_KEY=old-key\n' as never);
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('already contains CONVEX_DEPLOY_KEY'),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses defaults in non-interactive mode', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
|
||||
|
||||
await createCommand(['--non-interactive']).runAsync();
|
||||
|
||||
expect(selectAsync).not.toHaveBeenCalled();
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(ConvexMutation.createConvexTeamConnectionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
deploymentRegion: 'aws-us-east-1',
|
||||
convexTeamName: testAccountName,
|
||||
})
|
||||
);
|
||||
expect(ConvexMutation.setupConvexProjectAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
appId: testProjectId,
|
||||
deploymentRegion: 'aws-us-east-1',
|
||||
projectName: 'testapp',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
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 { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
|
||||
import {
|
||||
ConvexProjectData,
|
||||
ConvexTeamConnectionData,
|
||||
} from '../../../../graphql/types/ConvexTeamConnection';
|
||||
import Log from '../../../../log';
|
||||
import { ora } from '../../../../ora';
|
||||
import IntegrationsConvexDashboard from '../dashboard';
|
||||
|
||||
jest.mock('better-opn');
|
||||
jest.mock('../../../../graphql/queries/ConvexQuery');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora', () => ({
|
||||
ora: jest.fn(() => ({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe(IntegrationsConvexDashboard, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
const mockConnection: ConvexTeamConnectionData = {
|
||||
id: 'connection-1',
|
||||
convexTeamIdentifier: 'team-123',
|
||||
convexTeamName: 'Test Team',
|
||||
convexTeamSlug: 'test team',
|
||||
hasBeenClaimed: false,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
invitedAt: null,
|
||||
invitedEmail: null,
|
||||
};
|
||||
|
||||
const mockProject: ConvexProjectData = {
|
||||
id: 'convex-project-1',
|
||||
convexProjectIdentifier: 'project-123',
|
||||
convexProjectName: 'Test Project',
|
||||
convexProjectSlug: 'test project',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
convexTeamConnection: mockConnection,
|
||||
};
|
||||
|
||||
function createCommand(): IntegrationsConvexDashboard {
|
||||
const command = new IntegrationsConvexDashboard([], mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
exp: { slug: 'testapp' },
|
||||
},
|
||||
loggedIn: { graphqlClient },
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(mockProject);
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue({} as never);
|
||||
jest.mocked(ora).mockReturnValue({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('opens the linked Convex project dashboard', async () => {
|
||||
await createCommand().runAsync();
|
||||
|
||||
expect(openBrowserAsync).toHaveBeenCalledWith(
|
||||
'https://dashboard.convex.dev/t/test%20team/test%20project'
|
||||
);
|
||||
});
|
||||
|
||||
it('logs an empty state when no project link exists', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(null);
|
||||
|
||||
await createCommand().runAsync();
|
||||
|
||||
expect(openBrowserAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No Convex project is linked to Expo app')
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the spinner when the browser cannot be opened', async () => {
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue(false);
|
||||
const spinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
};
|
||||
jest.mocked(ora).mockReturnValue(spinner as any);
|
||||
|
||||
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('open failed'));
|
||||
const spinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
};
|
||||
jest.mocked(ora).mockReturnValue(spinner as any);
|
||||
|
||||
await expect(createCommand().runAsync()).rejects.toThrow('open failed');
|
||||
|
||||
expect(spinner.fail).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unable to open a web browser')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
|
||||
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
|
||||
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
|
||||
import {
|
||||
ConvexProjectData,
|
||||
ConvexTeamConnectionData,
|
||||
} from '../../../../graphql/types/ConvexTeamConnection';
|
||||
import Log from '../../../../log';
|
||||
import { confirmAsync } from '../../../../prompts';
|
||||
import IntegrationsConvexProjectDelete from '../project/delete';
|
||||
|
||||
jest.mock('../../../../graphql/queries/ConvexQuery');
|
||||
jest.mock('../../../../graphql/mutations/ConvexMutation');
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora', () => ({
|
||||
ora: () => ({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe(IntegrationsConvexProjectDelete, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
const mockConnection: ConvexTeamConnectionData = {
|
||||
id: 'connection-1',
|
||||
convexTeamIdentifier: 'team-123',
|
||||
convexTeamName: 'Test Team',
|
||||
convexTeamSlug: 'test-team',
|
||||
hasBeenClaimed: false,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
invitedAt: null,
|
||||
invitedEmail: null,
|
||||
};
|
||||
|
||||
const mockProject: ConvexProjectData = {
|
||||
id: 'convex-project-1',
|
||||
convexProjectIdentifier: 'project-123',
|
||||
convexProjectName: 'Test Project',
|
||||
convexProjectSlug: 'test-project',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
convexTeamConnection: mockConnection,
|
||||
};
|
||||
|
||||
function createCommand(argv: string[]): IntegrationsConvexProjectDelete {
|
||||
const command = new IntegrationsConvexProjectDelete(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
exp: { slug: 'testapp' },
|
||||
},
|
||||
loggedIn: { graphqlClient },
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
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(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(mockProject);
|
||||
jest.mocked(ConvexMutation.deleteConvexProjectAsync).mockResolvedValue('convex-project-1');
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('deletes the linked Convex project after confirmation', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('This does not destroy resources on Convex'),
|
||||
});
|
||||
expect(ConvexMutation.deleteConvexProjectAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'convex-project-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('skips deletion when the user cancels', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(ConvexMutation.deleteConvexProjectAsync).not.toHaveBeenCalled();
|
||||
expect(Log.error).toHaveBeenCalledWith('Canceled deletion of the Convex project link');
|
||||
});
|
||||
|
||||
it('uses --yes to skip the confirmation prompt', async () => {
|
||||
await createCommand(['--yes']).runAsync();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('This does not destroy resources on Convex')
|
||||
);
|
||||
expect(ConvexMutation.deleteConvexProjectAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'convex-project-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('logs an empty state when no project link exists', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(null);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No Convex project is linked to Expo app')
|
||||
);
|
||||
expect(ConvexMutation.deleteConvexProjectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
|
||||
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
|
||||
import {
|
||||
ConvexProjectData,
|
||||
ConvexTeamConnectionData,
|
||||
} from '../../../../graphql/types/ConvexTeamConnection';
|
||||
import Log from '../../../../log';
|
||||
import IntegrationsConvexProject from '../project';
|
||||
|
||||
jest.mock('../../../../graphql/queries/ConvexQuery');
|
||||
jest.mock('../../../../log');
|
||||
|
||||
describe(IntegrationsConvexProject, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
const mockConnection: ConvexTeamConnectionData = {
|
||||
id: 'connection-1',
|
||||
convexTeamIdentifier: 'team-123',
|
||||
convexTeamName: 'Test Team',
|
||||
convexTeamSlug: 'test-team',
|
||||
hasBeenClaimed: false,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
invitedAt: null,
|
||||
invitedEmail: null,
|
||||
};
|
||||
|
||||
const mockProject: ConvexProjectData = {
|
||||
id: 'convex-project-1',
|
||||
convexProjectIdentifier: 'project-123',
|
||||
convexProjectName: 'Test Project',
|
||||
convexProjectSlug: 'test-project',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
convexTeamConnection: mockConnection,
|
||||
};
|
||||
|
||||
function createCommand(): IntegrationsConvexProject {
|
||||
const command = new IntegrationsConvexProject([], mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
exp: { slug: 'testapp' },
|
||||
},
|
||||
loggedIn: { graphqlClient },
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('prints linked Convex project metadata', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(mockProject);
|
||||
|
||||
await createCommand().runAsync();
|
||||
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Convex project linked to testapp')
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('project-123'));
|
||||
});
|
||||
|
||||
it('prints an empty state when no Convex project is linked', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(null);
|
||||
|
||||
await createCommand().runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No Convex project is linked to Expo app')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
|
||||
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
|
||||
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
|
||||
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
|
||||
import Log from '../../../../log';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
|
||||
import { confirmAsync, selectAsync } from '../../../../prompts';
|
||||
import IntegrationsConvexTeamDelete from '../team/delete';
|
||||
|
||||
jest.mock('../../../../graphql/queries/ConvexQuery');
|
||||
jest.mock('../../../../graphql/mutations/ConvexMutation');
|
||||
jest.mock('../../../../project/projectUtils');
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora', () => ({
|
||||
ora: () => ({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe(IntegrationsConvexTeamDelete, () => {
|
||||
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: 'OWNER' as any, actor: { id: 'test-user-id' } }],
|
||||
};
|
||||
|
||||
const mockConnection: ConvexTeamConnectionData = {
|
||||
id: 'connection-1',
|
||||
convexTeamIdentifier: 'team-123',
|
||||
convexTeamName: 'Test Team',
|
||||
convexTeamSlug: 'test-team',
|
||||
hasBeenClaimed: false,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
invitedAt: '2024-01-02T00:00:00.000Z',
|
||||
invitedEmail: '[email protected]',
|
||||
};
|
||||
|
||||
function createCommand(argv: string[]): IntegrationsConvexTeamDelete {
|
||||
const command = new IntegrationsConvexTeamDelete(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
},
|
||||
loggedIn: { graphqlClient },
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
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(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
|
||||
jest
|
||||
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
|
||||
.mockResolvedValue([mockConnection]);
|
||||
jest.mocked(ConvexMutation.deleteConvexTeamConnectionAsync).mockResolvedValue(mockConnection);
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('deletes the selected Convex team link after confirmation', async () => {
|
||||
await createCommand(['test-team']).runAsync();
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('This does not destroy resources on Convex'),
|
||||
});
|
||||
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'connection-1'
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Test Team / test-team'));
|
||||
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('team-123'));
|
||||
});
|
||||
|
||||
it('skips deletion when the user cancels', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
|
||||
await createCommand(['test-team']).runAsync();
|
||||
|
||||
expect(ConvexMutation.deleteConvexTeamConnectionAsync).not.toHaveBeenCalled();
|
||||
expect(Log.error).toHaveBeenCalledWith('Canceled deletion of the Convex team link');
|
||||
});
|
||||
|
||||
it('uses --yes to skip the confirmation prompt', async () => {
|
||||
await createCommand(['test-team', '--yes']).runAsync();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('This does not destroy resources on Convex')
|
||||
);
|
||||
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'connection-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('logs an empty state when no team links exist', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Convex team is linked'));
|
||||
expect(ConvexMutation.deleteConvexTeamConnectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the provided team is not linked to the account', async () => {
|
||||
await expect(createCommand(['missing-team']).runAsync()).rejects.toThrow(
|
||||
'Convex team missing-team is not linked to this account.'
|
||||
);
|
||||
expect(ConvexMutation.deleteConvexTeamConnectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves a team by slug when multiple links exist', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
|
||||
mockConnection,
|
||||
{
|
||||
...mockConnection,
|
||||
id: 'connection-2',
|
||||
convexTeamIdentifier: 'team-456',
|
||||
convexTeamName: 'Other Team',
|
||||
convexTeamSlug: 'other-team',
|
||||
},
|
||||
]);
|
||||
|
||||
await createCommand(['other-team', '--non-interactive', '--yes']).runAsync();
|
||||
|
||||
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'connection-2'
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a team slug in non-interactive mode when multiple links exist', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
|
||||
mockConnection,
|
||||
{
|
||||
...mockConnection,
|
||||
id: 'connection-2',
|
||||
convexTeamName: 'Other Team',
|
||||
convexTeamSlug: 'other-team',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
|
||||
'Convex team slug must be provided'
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts to select a team link when multiple links exist in interactive mode', async () => {
|
||||
const otherConnection = {
|
||||
...mockConnection,
|
||||
id: 'connection-2',
|
||||
convexTeamName: 'Other Team',
|
||||
convexTeamSlug: 'other-team',
|
||||
};
|
||||
jest
|
||||
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
|
||||
.mockResolvedValue([mockConnection, otherConnection]);
|
||||
jest.mocked(selectAsync).mockResolvedValue(otherConnection);
|
||||
|
||||
await createCommand(['--yes']).runAsync();
|
||||
|
||||
expect(selectAsync).toHaveBeenCalledWith(
|
||||
'Select a Convex team link to remove',
|
||||
expect.any(Array)
|
||||
);
|
||||
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'connection-2'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import dateFormat from 'dateformat';
|
||||
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
|
||||
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
|
||||
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
|
||||
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
|
||||
import Log from '../../../../log';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
|
||||
import { confirmAsync, selectAsync } from '../../../../prompts';
|
||||
import IntegrationsConvexTeamInvite from '../team/invite';
|
||||
|
||||
jest.mock('../../../../graphql/queries/ConvexQuery');
|
||||
jest.mock('../../../../graphql/mutations/ConvexMutation');
|
||||
jest.mock('../../../../project/projectUtils');
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora', () => ({
|
||||
ora: () => ({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe(IntegrationsConvexTeamInvite, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const testAccountId = 'test-account-id';
|
||||
const testAccountName = 'testuser';
|
||||
|
||||
const mockActor = {
|
||||
__typename: 'User',
|
||||
id: 'test-user-id',
|
||||
email: '[email protected]',
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
const mockAccount = {
|
||||
id: testAccountId,
|
||||
name: testAccountName,
|
||||
ownerUserActor: { id: 'test-user-id', username: testAccountName },
|
||||
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
|
||||
};
|
||||
|
||||
const mockConnection: ConvexTeamConnectionData = {
|
||||
id: 'connection-1',
|
||||
convexTeamIdentifier: 'team-123',
|
||||
convexTeamName: 'Test Team',
|
||||
convexTeamSlug: 'test-team',
|
||||
hasBeenClaimed: false,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
invitedAt: '2024-01-02T00:00:00.000Z',
|
||||
invitedEmail: '[email protected]',
|
||||
};
|
||||
|
||||
function createCommand(
|
||||
argv: string[],
|
||||
actor: { __typename: string; [key: string]: any } = mockActor
|
||||
): IntegrationsConvexTeamInvite {
|
||||
const command = new IntegrationsConvexTeamInvite(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
},
|
||||
loggedIn: { graphqlClient, actor },
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
|
||||
|
||||
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
|
||||
jest
|
||||
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
|
||||
.mockResolvedValue([mockConnection]);
|
||||
jest.mocked(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).mockResolvedValue(true);
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('sends an invite for the linked Convex team', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{ convexTeamConnectionId: 'connection-1' }
|
||||
);
|
||||
});
|
||||
|
||||
it('prints previous invite metadata when it exists', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Test Team / test-team'));
|
||||
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('team-123'));
|
||||
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('connection-1'));
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Previous invite'));
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Claimed'));
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('No'));
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('[email protected]'));
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(dateFormat(mockConnection.invitedAt, 'mmm dd HH:MM:ss'))
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts before resending a recent invite', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
|
||||
{
|
||||
...mockConnection,
|
||||
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('Are you sure you want to send another invite?'),
|
||||
});
|
||||
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips resending a recent invite when the user does not confirm', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
|
||||
{
|
||||
...mockConnection,
|
||||
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith('Skipped sending Convex team invitation.');
|
||||
});
|
||||
|
||||
it('skips sending an invite when the Convex team has already been claimed', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
|
||||
{
|
||||
...mockConnection,
|
||||
hasBeenClaimed: true,
|
||||
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
'Convex team has already been claimed. Skipping Convex team invitation.'
|
||||
);
|
||||
});
|
||||
|
||||
it('resends recent invites in non-interactive mode with a warning', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
|
||||
{
|
||||
...mockConnection,
|
||||
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
await createCommand(['--non-interactive']).runAsync();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('non-interactive mode'));
|
||||
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the invite when the actor has no email', async () => {
|
||||
await createCommand([], { __typename: 'Robot', id: 'robot-1' }).runAsync();
|
||||
|
||||
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not determine your verified email address')
|
||||
);
|
||||
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Convex dashboard'));
|
||||
});
|
||||
|
||||
it('selects a team link when multiple are available', async () => {
|
||||
const otherConnection = {
|
||||
...mockConnection,
|
||||
id: 'connection-2',
|
||||
convexTeamName: 'Other Team',
|
||||
convexTeamSlug: 'other-team',
|
||||
};
|
||||
jest
|
||||
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
|
||||
.mockResolvedValue([mockConnection, otherConnection]);
|
||||
jest.mocked(selectAsync).mockResolvedValue(otherConnection);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(selectAsync).toHaveBeenCalledWith(
|
||||
'Select a Convex team link to invite yourself to',
|
||||
expect.any(Array)
|
||||
);
|
||||
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{ convexTeamConnectionId: 'connection-2' }
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a team by slug when multiple links exist', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
|
||||
mockConnection,
|
||||
{
|
||||
...mockConnection,
|
||||
id: 'connection-2',
|
||||
convexTeamIdentifier: 'team-456',
|
||||
convexTeamName: 'Other Team',
|
||||
convexTeamSlug: 'other-team',
|
||||
},
|
||||
]);
|
||||
|
||||
await createCommand(['other-team', '--non-interactive']).runAsync();
|
||||
|
||||
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{ convexTeamConnectionId: 'connection-2' }
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a team slug in non-interactive mode when multiple links exist', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
|
||||
mockConnection,
|
||||
{
|
||||
...mockConnection,
|
||||
id: 'connection-2',
|
||||
convexTeamName: 'Other Team',
|
||||
convexTeamSlug: 'other-team',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
|
||||
'Convex team slug must be provided'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
|
||||
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
|
||||
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
|
||||
import Log from '../../../../log';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
|
||||
import IntegrationsConvexTeam from '../team';
|
||||
|
||||
jest.mock('../../../../graphql/queries/ConvexQuery');
|
||||
jest.mock('../../../../project/projectUtils');
|
||||
jest.mock('../../../../log');
|
||||
|
||||
describe(IntegrationsConvexTeam, () => {
|
||||
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: 'OWNER' as any, actor: { id: 'test-user-id' } }],
|
||||
};
|
||||
|
||||
const mockConnection: ConvexTeamConnectionData = {
|
||||
id: 'connection-1',
|
||||
convexTeamIdentifier: 'team-123',
|
||||
convexTeamName: 'Test Team',
|
||||
convexTeamSlug: 'test-team',
|
||||
hasBeenClaimed: true,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
invitedAt: null,
|
||||
invitedEmail: null,
|
||||
};
|
||||
|
||||
function createCommand(): IntegrationsConvexTeam {
|
||||
const command = new IntegrationsConvexTeam([], mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
},
|
||||
loggedIn: { graphqlClient },
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
|
||||
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
|
||||
});
|
||||
|
||||
it('prints linked Convex team metadata', async () => {
|
||||
jest
|
||||
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
|
||||
.mockResolvedValue([mockConnection]);
|
||||
|
||||
await createCommand().runAsync();
|
||||
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Convex teams linked to @testuser')
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Test Team / test-team'));
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Claimed'));
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Yes'));
|
||||
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('team-123'));
|
||||
});
|
||||
|
||||
it('prints an empty state when no Convex teams are linked', async () => {
|
||||
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
|
||||
|
||||
await createCommand().runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Convex team is linked'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import dotenv from 'dotenv';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { confirmRecentConvexInviteAsync, formatConvexTeam } from '../../../commandUtils/convex';
|
||||
import { EASNonInteractiveFlag } from '../../../commandUtils/flags';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../../graphql/generated';
|
||||
import { ConvexMutation } from '../../../graphql/mutations/ConvexMutation';
|
||||
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { ConvexQuery } from '../../../graphql/queries/ConvexQuery';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import { ConvexTeamConnectionData } from '../../../graphql/types/ConvexTeamConnection';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
|
||||
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
|
||||
import { Actor } from '../../../user/User';
|
||||
|
||||
const CONVEX_REGIONS = [
|
||||
{ title: 'US East (aws-us-east-1)', value: 'aws-us-east-1' },
|
||||
{ title: 'EU West (aws-eu-west-1)', value: 'aws-eu-west-1' },
|
||||
];
|
||||
|
||||
const DEFAULT_REGION = 'aws-us-east-1';
|
||||
const EAS_CONVEX_ENV_VAR_NAME = 'EXPO_PUBLIC_CONVEX_URL';
|
||||
const EAS_CONVEX_ENVIRONMENTS = [
|
||||
DefaultEnvironment.Production,
|
||||
DefaultEnvironment.Preview,
|
||||
DefaultEnvironment.Development,
|
||||
];
|
||||
|
||||
type TeamInviteResult = 'sent' | 'skipped' | 'failed';
|
||||
|
||||
export default class IntegrationsConvexConnect extends EasCommand {
|
||||
static override description = 'connect Convex to your Expo project';
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...EASNonInteractiveFlag,
|
||||
region: Flags.string({
|
||||
description: 'Convex deployment region (e.g. aws-us-east-1, aws-eu-west-1)',
|
||||
options: CONVEX_REGIONS.map(r => r.value),
|
||||
}),
|
||||
'team-name': Flags.string({
|
||||
description: 'Name for the new Convex team (defaults to EAS account name)',
|
||||
}),
|
||||
'project-name': Flags.string({
|
||||
description: 'Name for the Convex project (defaults to app slug)',
|
||||
}),
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: {
|
||||
region: regionFlag,
|
||||
'team-name': teamNameFlag,
|
||||
'project-name': projectNameFlag,
|
||||
'non-interactive': nonInteractive,
|
||||
},
|
||||
} = await this.parse(IntegrationsConvexConnect);
|
||||
|
||||
const {
|
||||
privateProjectConfig: { projectId, exp, projectDir },
|
||||
loggedIn: { graphqlClient, actor },
|
||||
} = await this.getContextAsync(IntegrationsConvexConnect, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
// 1. Check for existing Convex team connections
|
||||
const existingConnections = await ConvexQuery.getConvexTeamConnectionsByAccountIdAsync(
|
||||
graphqlClient,
|
||||
account.id
|
||||
);
|
||||
|
||||
const region = await this.resolveRegionAsync(regionFlag, nonInteractive);
|
||||
let connection: ConvexTeamConnectionData | null = null;
|
||||
let teamName: string | null = null;
|
||||
|
||||
if (existingConnections.length === 0) {
|
||||
// 2a. No connection - resolve a team name and create it after local package install succeeds
|
||||
teamName = await this.resolveTeamNameAsync(teamNameFlag, account.name, nonInteractive);
|
||||
} else if (existingConnections.length === 1) {
|
||||
// 2b. Single existing connection
|
||||
connection = existingConnections[0];
|
||||
Log.withTick(`Using existing Convex team ${chalk.bold(formatConvexTeam(connection))}`);
|
||||
} else {
|
||||
// 2c. Multiple connections - prompt to select
|
||||
connection = await this.selectConnectionAsync(existingConnections, nonInteractive);
|
||||
}
|
||||
|
||||
// 3. Resolve project name before project setup mutation
|
||||
const projectName = await this.resolveProjectNameAsync(
|
||||
projectNameFlag,
|
||||
exp.slug,
|
||||
nonInteractive
|
||||
);
|
||||
|
||||
// 4. Install the Convex package before creating new server-side resources
|
||||
await this.installConvexPackageAsync(projectDir);
|
||||
|
||||
if (!connection) {
|
||||
const spinner = ora('Creating Convex team').start();
|
||||
try {
|
||||
connection = await ConvexMutation.createConvexTeamConnectionAsync(graphqlClient, {
|
||||
accountId: account.id,
|
||||
deploymentRegion: region,
|
||||
convexTeamName: teamName ?? account.name,
|
||||
});
|
||||
spinner.succeed(`Created Convex team ${chalk.bold(formatConvexTeam(connection))}`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to create Convex team');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Set up Convex project
|
||||
const spinner = ora('Setting up Convex project').start();
|
||||
let setupResult;
|
||||
try {
|
||||
setupResult = await ConvexMutation.setupConvexProjectAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
convexTeamConnectionId: connection.id,
|
||||
deploymentRegion: region,
|
||||
projectName,
|
||||
});
|
||||
spinner.succeed(
|
||||
`Created Convex project ${chalk.bold(projectName)} with deployment ${chalk.bold(setupResult.convexDeploymentName)}`
|
||||
);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to set up Convex project');
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 6. Save the Convex URL as an EAS environment variable for builds
|
||||
await this.upsertConvexUrlEasEnvVarAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
setupResult.convexDeploymentUrl,
|
||||
nonInteractive
|
||||
);
|
||||
|
||||
// 7. Send team invite (non-fatal)
|
||||
const teamInviteResult = await this.sendTeamInviteAsync(graphqlClient, connection, actor, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
// 8. Write deploy key and URL to .env.local
|
||||
await this.writeEnvLocalAsync(
|
||||
projectDir,
|
||||
setupResult.deployKey,
|
||||
setupResult.convexDeploymentUrl,
|
||||
nonInteractive
|
||||
);
|
||||
|
||||
// 9. Success message
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.green('Convex is ready!'));
|
||||
Log.newLine();
|
||||
Log.log('Next steps:');
|
||||
Log.log(` 1. Start the Convex dev server: ${chalk.cyan('npx convex dev')}`);
|
||||
Log.log(
|
||||
` 2. Learn how to connect to your new Convex database by following our quickstart guide: ${chalk.cyan('https://docs.expo.dev/guides/using-convex')}`
|
||||
);
|
||||
Log.log(` 3. Read more about Convex: ${chalk.cyan('https://docs.convex.dev/')}`);
|
||||
Log.newLine();
|
||||
if (teamInviteResult === 'sent') {
|
||||
Log.log(
|
||||
`Check your email for an invitation to join your Convex team. Accept it for full dashboard access.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertConvexUrlEasEnvVarAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
convexUrl: string,
|
||||
nonInteractive: boolean
|
||||
): Promise<void> {
|
||||
const existingVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
filterNames: [EAS_CONVEX_ENV_VAR_NAME],
|
||||
});
|
||||
const existingProjectVariable = existingVariables.find(
|
||||
variable => variable.scope === EnvironmentVariableScope.Project
|
||||
);
|
||||
|
||||
if (existingProjectVariable) {
|
||||
if (!nonInteractive) {
|
||||
const overwrite = await confirmAsync({
|
||||
message: `EAS already has an ${EAS_CONVEX_ENV_VAR_NAME} environment variable for this project. Overwrite it?`,
|
||||
});
|
||||
if (!overwrite) {
|
||||
Log.warn(
|
||||
`Skipped updating EAS environment variable ${chalk.bold(EAS_CONVEX_ENV_VAR_NAME)}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await EnvironmentVariableMutation.updateAsync(graphqlClient, {
|
||||
id: existingProjectVariable.id,
|
||||
name: EAS_CONVEX_ENV_VAR_NAME,
|
||||
value: convexUrl,
|
||||
environments: EAS_CONVEX_ENVIRONMENTS,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
});
|
||||
Log.withTick(
|
||||
`Updated EAS environment variable ${chalk.bold(EAS_CONVEX_ENV_VAR_NAME)} for builds`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await EnvironmentVariableMutation.createForAppAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
name: EAS_CONVEX_ENV_VAR_NAME,
|
||||
value: convexUrl,
|
||||
environments: EAS_CONVEX_ENVIRONMENTS,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
projectId
|
||||
);
|
||||
Log.withTick(
|
||||
`Created EAS environment variable ${chalk.bold(EAS_CONVEX_ENV_VAR_NAME)} for builds`
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRegionAsync(
|
||||
flagValue: string | undefined,
|
||||
nonInteractive: boolean
|
||||
): Promise<string> {
|
||||
if (flagValue) {
|
||||
return flagValue;
|
||||
}
|
||||
if (nonInteractive) {
|
||||
return DEFAULT_REGION;
|
||||
}
|
||||
return await selectAsync('Select a Convex deployment region', CONVEX_REGIONS);
|
||||
}
|
||||
|
||||
private async resolveTeamNameAsync(
|
||||
flagValue: string | undefined,
|
||||
accountName: string,
|
||||
nonInteractive: boolean
|
||||
): Promise<string> {
|
||||
if (flagValue) {
|
||||
return flagValue;
|
||||
}
|
||||
if (nonInteractive) {
|
||||
return accountName;
|
||||
}
|
||||
const { teamName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'teamName',
|
||||
message: 'Convex team name',
|
||||
initial: accountName,
|
||||
validate: (value: string) => (value.trim() ? true : 'Team name cannot be empty'),
|
||||
});
|
||||
return teamName;
|
||||
}
|
||||
|
||||
private async resolveProjectNameAsync(
|
||||
flagValue: string | undefined,
|
||||
slug: string,
|
||||
nonInteractive: boolean
|
||||
): Promise<string> {
|
||||
if (flagValue) {
|
||||
return flagValue;
|
||||
}
|
||||
if (nonInteractive) {
|
||||
return slug;
|
||||
}
|
||||
const { projectName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'projectName',
|
||||
message: 'Convex project name',
|
||||
initial: slug,
|
||||
validate: (value: string) => (value.trim() ? true : 'Project name cannot be empty'),
|
||||
});
|
||||
return projectName;
|
||||
}
|
||||
|
||||
private async selectConnectionAsync(
|
||||
connections: ConvexTeamConnectionData[],
|
||||
nonInteractive: boolean
|
||||
): Promise<ConvexTeamConnectionData> {
|
||||
if (nonInteractive) {
|
||||
return connections[0];
|
||||
}
|
||||
const choices = connections.map(c => ({
|
||||
title: `${formatConvexTeam(c)} (created ${new Date(c.createdAt).toLocaleDateString()})`,
|
||||
value: c,
|
||||
}));
|
||||
return await selectAsync('Select a Convex team connection', choices);
|
||||
}
|
||||
|
||||
private getActorEmail(actor: Actor): string | null {
|
||||
return actor.__typename === 'User' ? actor.email : null;
|
||||
}
|
||||
|
||||
private async sendTeamInviteAsync(
|
||||
graphqlClient: Parameters<typeof ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync>[0],
|
||||
connection: ConvexTeamConnectionData,
|
||||
actor: Actor,
|
||||
{ nonInteractive }: { nonInteractive: boolean }
|
||||
): Promise<TeamInviteResult> {
|
||||
if (connection.hasBeenClaimed) {
|
||||
Log.warn('Convex team has already been claimed. Skipping Convex team invitation.');
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
const email = this.getActorEmail(actor);
|
||||
if (!email) {
|
||||
Log.warn(
|
||||
`Could not determine your verified email address, so no Convex team invitation was sent. Run ${chalk.cyan(
|
||||
'eas integrations:convex:team:invite'
|
||||
)} after signing in with a user account.`
|
||||
);
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
if (!(await confirmRecentConvexInviteAsync(connection, { nonInteractive }))) {
|
||||
Log.warn('Skipped sending Convex team invitation.');
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
try {
|
||||
await ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync(graphqlClient, {
|
||||
convexTeamConnectionId: connection.id,
|
||||
});
|
||||
Log.withTick(`Sent Convex team invitation to ${chalk.bold(email)}`);
|
||||
return 'sent';
|
||||
} catch (error) {
|
||||
Log.warn(
|
||||
`Failed to send Convex team invitation to ${email}. Run ${chalk.cyan(
|
||||
'eas integrations:convex:team:invite'
|
||||
)} to retry.`
|
||||
);
|
||||
Log.warn(error instanceof Error ? error.message : String(error));
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
private async installConvexPackageAsync(projectDir: string): Promise<void> {
|
||||
Log.newLine();
|
||||
Log.log(`Running ${chalk.bold('npx expo install convex')}`);
|
||||
Log.newLine();
|
||||
|
||||
try {
|
||||
await spawnAsync('npx', ['expo', 'install', 'convex'], {
|
||||
cwd: projectDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
Log.withTick(`Installed the ${chalk.bold('convex')} npm package`);
|
||||
} catch (error) {
|
||||
Log.warn(
|
||||
`Failed to install the ${chalk.bold('convex')} npm package. Run ${chalk.cyan(
|
||||
'npx expo install convex'
|
||||
)} from your project directory, then run ${chalk.cyan('npx convex dev')}.`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeEnvLocalAsync(
|
||||
projectDir: string,
|
||||
deployKey: string,
|
||||
convexUrl: string,
|
||||
nonInteractive: boolean
|
||||
): Promise<void> {
|
||||
const envPath = path.join(projectDir, '.env.local');
|
||||
let existingContent: Record<string, string> = {};
|
||||
let rawContent = '';
|
||||
|
||||
if (await fs.pathExists(envPath)) {
|
||||
rawContent = await fs.readFile(envPath, 'utf8');
|
||||
existingContent = dotenv.parse(rawContent);
|
||||
|
||||
if (existingContent.CONVEX_DEPLOY_KEY && !nonInteractive) {
|
||||
const overwrite = await confirmAsync({
|
||||
message: `.env.local already contains CONVEX_DEPLOY_KEY. Overwrite Convex values?`,
|
||||
});
|
||||
if (!overwrite) {
|
||||
Log.log('Skipping .env.local update. Deploy key:');
|
||||
Log.log(` CONVEX_DEPLOY_KEY=${deployKey}`);
|
||||
Log.log(` EXPO_PUBLIC_CONVEX_URL=${convexUrl}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedContent = this.mergeEnvContent(rawContent, {
|
||||
CONVEX_DEPLOY_KEY: deployKey,
|
||||
EXPO_PUBLIC_CONVEX_URL: convexUrl,
|
||||
});
|
||||
|
||||
await fs.writeFile(envPath, updatedContent);
|
||||
Log.withTick(`Wrote Convex config to ${chalk.bold('.env.local')}`);
|
||||
}
|
||||
|
||||
private mergeEnvContent(rawContent: string, newVars: Record<string, string>): string {
|
||||
let content = rawContent;
|
||||
const keysToAdd: Record<string, string> = { ...newVars };
|
||||
|
||||
for (const [key, value] of Object.entries(newVars)) {
|
||||
// Replace existing line if present
|
||||
const regex = new RegExp(`^${key}=.*$`, 'm');
|
||||
if (regex.test(content)) {
|
||||
content = content.replace(regex, `${key}=${value}`);
|
||||
delete keysToAdd[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Append any keys that weren't already in the file
|
||||
const remaining = Object.entries(keysToAdd);
|
||||
if (remaining.length > 0) {
|
||||
if (content.length > 0 && !content.endsWith('\n')) {
|
||||
content += '\n';
|
||||
}
|
||||
for (const [key, value] of remaining) {
|
||||
content += `${key}=${value}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import openBrowserAsync from 'better-opn';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { getConvexProjectDashboardUrl, logNoConvexProject } from '../../../commandUtils/convex';
|
||||
import { ConvexQuery } from '../../../graphql/queries/ConvexQuery';
|
||||
import { ora } from '../../../ora';
|
||||
|
||||
export default class IntegrationsConvexDashboard extends EasCommand {
|
||||
static override description = 'open the Convex dashboard for the linked Convex project';
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
privateProjectConfig: { projectId, exp },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsConvexDashboard, {
|
||||
nonInteractive: false,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const convexProject = await ConvexQuery.getConvexProjectByAppIdAsync(graphqlClient, projectId);
|
||||
|
||||
if (!convexProject) {
|
||||
logNoConvexProject(exp.slug);
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboardUrl = getConvexProjectDashboardUrl(convexProject);
|
||||
const failedMessage = `Unable to open a web browser. Convex dashboard is available at: ${dashboardUrl}`;
|
||||
const spinner = ora(`Opening ${dashboardUrl}`).start();
|
||||
try {
|
||||
const opened = await openBrowserAsync(dashboardUrl);
|
||||
if (opened) {
|
||||
spinner.succeed(`Opened ${dashboardUrl}`);
|
||||
} else {
|
||||
spinner.fail(failedMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(failedMessage);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { formatConvexProject, logNoConvexProject } from '../../../commandUtils/convex';
|
||||
import { ConvexQuery } from '../../../graphql/queries/ConvexQuery';
|
||||
import Log from '../../../log';
|
||||
|
||||
export default class IntegrationsConvexProject extends EasCommand {
|
||||
static override description = 'display the Convex project linked to the current Expo app';
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
privateProjectConfig: { projectId, exp },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsConvexProject, {
|
||||
nonInteractive: false,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const convexProject = await ConvexQuery.getConvexProjectByAppIdAsync(graphqlClient, projectId);
|
||||
|
||||
if (!convexProject) {
|
||||
logNoConvexProject(exp.slug);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.log(chalk.bold(`Convex project linked to ${exp.slug}`));
|
||||
Log.log(formatConvexProject(convexProject));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../../commandUtils/EasCommand';
|
||||
import {
|
||||
formatConvexProject,
|
||||
getConvexProjectDashboardUrl,
|
||||
logNoConvexProject,
|
||||
} from '../../../../commandUtils/convex';
|
||||
import { EASNonInteractiveFlag } from '../../../../commandUtils/flags';
|
||||
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
|
||||
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
|
||||
import Log, { link } from '../../../../log';
|
||||
import { ora } from '../../../../ora';
|
||||
import { confirmAsync } from '../../../../prompts';
|
||||
|
||||
export default class IntegrationsConvexProjectDelete extends EasCommand {
|
||||
static override description =
|
||||
'remove the Convex project link for the current Expo app from EAS servers';
|
||||
|
||||
static override flags = {
|
||||
...EASNonInteractiveFlag,
|
||||
yes: Flags.boolean({
|
||||
char: 'y',
|
||||
description: 'Skip confirmation prompt',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: { 'non-interactive': nonInteractive, yes },
|
||||
} = await this.parse(IntegrationsConvexProjectDelete);
|
||||
|
||||
const {
|
||||
privateProjectConfig: { projectId, exp },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsConvexProjectDelete, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const convexProject = await ConvexQuery.getConvexProjectByAppIdAsync(graphqlClient, projectId);
|
||||
|
||||
if (!convexProject) {
|
||||
logNoConvexProject(exp.slug);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatConvexProject(convexProject));
|
||||
Log.newLine();
|
||||
|
||||
const dashboardUrl = getConvexProjectDashboardUrl(convexProject);
|
||||
if (!nonInteractive && !yes) {
|
||||
const confirmed = await confirmAsync({
|
||||
message: `Remove this Convex project link from EAS servers? This does not destroy resources on Convex. Convex dashboard: ${link(
|
||||
dashboardUrl,
|
||||
{ dim: false }
|
||||
)}`,
|
||||
});
|
||||
if (!confirmed) {
|
||||
Log.error('Canceled deletion of the Convex project link');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Log.warn(
|
||||
`Removing the Convex project link from EAS servers. This does not destroy resources on Convex: ${dashboardUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
const spinner = ora('Removing Convex project link').start();
|
||||
try {
|
||||
await ConvexMutation.deleteConvexProjectAsync(graphqlClient, convexProject.id);
|
||||
spinner.succeed(
|
||||
`Removed Convex project ${chalk.bold(convexProject.convexProjectName)} from EAS servers`
|
||||
);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to remove Convex project link');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { formatConvexTeamConnection, logNoConvexTeams } from '../../../commandUtils/convex';
|
||||
import { ConvexQuery } from '../../../graphql/queries/ConvexQuery';
|
||||
import Log from '../../../log';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
|
||||
|
||||
export default class IntegrationsConvexTeam extends EasCommand {
|
||||
static override description =
|
||||
"display Convex teams linked to the current Expo app's owner account";
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsConvexTeam, {
|
||||
nonInteractive: false,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
const connections = await ConvexQuery.getConvexTeamConnectionsByAccountIdAsync(
|
||||
graphqlClient,
|
||||
account.id
|
||||
);
|
||||
|
||||
if (connections.length === 0) {
|
||||
logNoConvexTeams(account.name);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.log(chalk.bold(`Convex teams linked to @${account.name}`));
|
||||
for (const [index, connection] of connections.entries()) {
|
||||
if (index > 0) {
|
||||
Log.newLine();
|
||||
}
|
||||
Log.log(formatConvexTeamConnection(connection));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../../commandUtils/EasCommand';
|
||||
import {
|
||||
formatConvexTeam,
|
||||
formatConvexTeamConnection,
|
||||
getConvexTeamDashboardUrl,
|
||||
logNoConvexTeams,
|
||||
} from '../../../../commandUtils/convex';
|
||||
import { EASNonInteractiveFlag } from '../../../../commandUtils/flags';
|
||||
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
|
||||
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
|
||||
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
|
||||
import Log, { link } from '../../../../log';
|
||||
import { ora } from '../../../../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
|
||||
import { confirmAsync, selectAsync } from '../../../../prompts';
|
||||
|
||||
export default class IntegrationsConvexTeamDelete extends EasCommand {
|
||||
static override description =
|
||||
"remove a Convex team link from the current Expo app owner account's EAS servers";
|
||||
|
||||
static override args = {
|
||||
CONVEX_TEAM: Args.string({
|
||||
required: false,
|
||||
description: 'Slug of the Convex team to remove',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...EASNonInteractiveFlag,
|
||||
yes: Flags.boolean({
|
||||
char: 'y',
|
||||
description: 'Skip confirmation prompt',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { CONVEX_TEAM: team },
|
||||
flags: { 'non-interactive': nonInteractive, yes },
|
||||
} = await this.parse(IntegrationsConvexTeamDelete);
|
||||
|
||||
const {
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsConvexTeamDelete, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
const connections = await ConvexQuery.getConvexTeamConnectionsByAccountIdAsync(
|
||||
graphqlClient,
|
||||
account.id
|
||||
);
|
||||
|
||||
if (connections.length === 0) {
|
||||
logNoConvexTeams(account.name);
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = await this.resolveConnectionAsync(connections, team, nonInteractive);
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatConvexTeamConnection(connection));
|
||||
Log.newLine();
|
||||
|
||||
const dashboardUrl = getConvexTeamDashboardUrl(connection);
|
||||
if (!nonInteractive && !yes) {
|
||||
const confirmed = await confirmAsync({
|
||||
message: `Remove this Convex team link from EAS servers? This does not destroy resources on Convex. Convex dashboard: ${link(
|
||||
dashboardUrl,
|
||||
{ dim: false }
|
||||
)}`,
|
||||
});
|
||||
if (!confirmed) {
|
||||
Log.error('Canceled deletion of the Convex team link');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Log.warn(
|
||||
`Removing the Convex team link from EAS servers. This does not destroy resources on Convex: ${dashboardUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
const spinner = ora('Removing Convex team link').start();
|
||||
try {
|
||||
await ConvexMutation.deleteConvexTeamConnectionAsync(graphqlClient, connection.id);
|
||||
spinner.succeed(
|
||||
`Removed Convex team ${chalk.bold(formatConvexTeam(connection))} from EAS servers`
|
||||
);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to remove Convex team link');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveConnectionAsync(
|
||||
connections: ConvexTeamConnectionData[],
|
||||
team: string | undefined,
|
||||
nonInteractive: boolean
|
||||
): Promise<ConvexTeamConnectionData> {
|
||||
if (team) {
|
||||
const connection = connections.find(
|
||||
item => item.convexTeamSlug === team || item.convexTeamName === team || item.id === team
|
||||
);
|
||||
if (!connection) {
|
||||
throw new Error(`Convex team ${team} is not linked to this account.`);
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
if (connections.length === 1) {
|
||||
return connections[0];
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'Convex team slug must be provided in non-interactive mode when multiple Convex team links exist.'
|
||||
);
|
||||
}
|
||||
|
||||
return await selectAsync(
|
||||
'Select a Convex team link to remove',
|
||||
connections.map(connection => ({
|
||||
title: formatConvexTeam(connection),
|
||||
value: connection,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Args } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../../commandUtils/EasCommand';
|
||||
import {
|
||||
confirmRecentConvexInviteAsync,
|
||||
formatConvexInviteTimestamp,
|
||||
formatConvexTeam,
|
||||
getConvexTeamDashboardUrl,
|
||||
logNoConvexTeams,
|
||||
} from '../../../../commandUtils/convex';
|
||||
import { EASNonInteractiveFlag } from '../../../../commandUtils/flags';
|
||||
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
|
||||
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
|
||||
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
|
||||
import Log, { link } from '../../../../log';
|
||||
import { ora } from '../../../../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
|
||||
import { selectAsync } from '../../../../prompts';
|
||||
|
||||
export default class IntegrationsConvexTeamInvite extends EasCommand {
|
||||
static override description = 'send a Convex team invitation to your verified email address';
|
||||
|
||||
static override args = {
|
||||
CONVEX_TEAM: Args.string({
|
||||
required: false,
|
||||
description: 'Slug of the Convex team to invite yourself to',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { CONVEX_TEAM: team },
|
||||
flags: { 'non-interactive': nonInteractive },
|
||||
} = await this.parse(IntegrationsConvexTeamInvite);
|
||||
|
||||
const {
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient, actor },
|
||||
} = await this.getContextAsync(IntegrationsConvexTeamInvite, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const email = this.getActorEmail(actor);
|
||||
if (!email) {
|
||||
Log.warn(
|
||||
`Could not determine your verified email address, so no Convex team invitation was sent. Run ${chalk.cyan(
|
||||
'eas integrations:convex:team:invite'
|
||||
)} after signing in with a user account.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
const connections = await ConvexQuery.getConvexTeamConnectionsByAccountIdAsync(
|
||||
graphqlClient,
|
||||
account.id
|
||||
);
|
||||
|
||||
if (connections.length === 0) {
|
||||
logNoConvexTeams(account.name);
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = await this.resolveConnectionAsync(connections, team, nonInteractive);
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
this.logTeam(connection);
|
||||
this.logPreviousInvite(connection);
|
||||
Log.newLine();
|
||||
|
||||
if (connection.hasBeenClaimed) {
|
||||
Log.warn('Convex team has already been claimed. Skipping Convex team invitation.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await confirmRecentConvexInviteAsync(connection, { nonInteractive }))) {
|
||||
Log.warn('Skipped sending Convex team invitation.');
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = ora(`Sending Convex team invitation to ${email}`).start();
|
||||
try {
|
||||
await ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync(graphqlClient, {
|
||||
convexTeamConnectionId: connection.id,
|
||||
});
|
||||
spinner.succeed(
|
||||
`Sent Convex team invitation to ${chalk.bold(email)} for ${chalk.bold(formatConvexTeam(connection))}`
|
||||
);
|
||||
Log.log(`Convex dashboard: ${link(getConvexTeamDashboardUrl(connection), { dim: false })}`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to send Convex team invitation');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private getActorEmail(actor: { __typename: string; [key: string]: any }): string | null {
|
||||
return 'email' in actor && typeof actor.email === 'string' ? actor.email : null;
|
||||
}
|
||||
|
||||
private logTeam(connection: ConvexTeamConnectionData): void {
|
||||
Log.log(chalk.bold(`Convex team ${formatConvexTeam(connection)}`));
|
||||
Log.log(
|
||||
`${chalk.bold('Dashboard')}: ${link(getConvexTeamDashboardUrl(connection), { dim: false })}`
|
||||
);
|
||||
Log.log(`${chalk.bold('Claimed')}: ${connection.hasBeenClaimed ? 'Yes' : 'No'}`);
|
||||
}
|
||||
|
||||
private logPreviousInvite(connection: ConvexTeamConnectionData): void {
|
||||
if (!connection.invitedEmail && !connection.invitedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(chalk.bold('Previous invite'));
|
||||
if (connection.invitedEmail) {
|
||||
Log.log(`${chalk.bold('Email')}: ${connection.invitedEmail}`);
|
||||
}
|
||||
if (connection.invitedAt) {
|
||||
Log.log(`${chalk.bold('Sent at')}: ${formatConvexInviteTimestamp(connection.invitedAt)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveConnectionAsync(
|
||||
connections: ConvexTeamConnectionData[],
|
||||
team: string | undefined,
|
||||
nonInteractive: boolean
|
||||
): Promise<ConvexTeamConnectionData> {
|
||||
if (team) {
|
||||
const connection = connections.find(
|
||||
item => item.convexTeamSlug === team || item.convexTeamName === team || item.id === team
|
||||
);
|
||||
if (!connection) {
|
||||
throw new Error(`Convex team ${team} is not linked to this account.`);
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
if (connections.length === 1) {
|
||||
return connections[0];
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'Convex team slug must be provided in non-interactive mode when multiple Convex team links exist.'
|
||||
);
|
||||
}
|
||||
|
||||
return await selectAsync(
|
||||
'Select a Convex team link to invite yourself to',
|
||||
connections.map(connection => ({
|
||||
title: formatConvexTeam(connection),
|
||||
value: connection,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppObservePlatform } from '../../../graphql/generated';
|
||||
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveCustomEventsAsync } from '../../../observe/fetchCustomEvents';
|
||||
import {
|
||||
buildObserveCustomEventNamesJson,
|
||||
buildObserveCustomEventsEmptyWithSuggestionsJson,
|
||||
buildObserveCustomEventsEmptyWithSuggestionsTable,
|
||||
buildObserveCustomEventsJson,
|
||||
} from '../../../observe/formatCustomEvents';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveLogs from '../logs';
|
||||
|
||||
jest.mock('../../../observe/fetchCustomEvents');
|
||||
jest.mock('../../../observe/formatCustomEvents', () => ({
|
||||
buildObserveCustomEventsTable: jest.fn().mockReturnValue('table'),
|
||||
buildObserveCustomEventsJson: jest.fn().mockReturnValue({}),
|
||||
buildObserveCustomEventNamesTable: jest.fn().mockReturnValue('names-table'),
|
||||
buildObserveCustomEventNamesJson: jest.fn().mockReturnValue({ names: [], isTruncated: false }),
|
||||
buildObserveCustomEventsEmptyWithSuggestionsTable: jest
|
||||
.fn()
|
||||
.mockReturnValue('empty-with-suggestions-table'),
|
||||
buildObserveCustomEventsEmptyWithSuggestionsJson: jest.fn().mockReturnValue({
|
||||
filteredEventName: 'my_event',
|
||||
events: [],
|
||||
availableEventNames: [],
|
||||
availableEventNamesIsTruncated: false,
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
|
||||
ObserveQuery: {
|
||||
customEventNamesAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync);
|
||||
const mockBuildObserveCustomEventsJson = jest.mocked(buildObserveCustomEventsJson);
|
||||
const mockBuildObserveCustomEventNamesJson = jest.mocked(buildObserveCustomEventNamesJson);
|
||||
const mockBuildObserveCustomEventsEmptyWithSuggestionsTable = jest.mocked(
|
||||
buildObserveCustomEventsEmptyWithSuggestionsTable
|
||||
);
|
||||
const mockBuildObserveCustomEventsEmptyWithSuggestionsJson = jest.mocked(
|
||||
buildObserveCustomEventsEmptyWithSuggestionsJson
|
||||
);
|
||||
const mockCustomEventNamesAsync = jest.mocked(ObserveQuery.customEventNamesAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveLogs, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false });
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveLogs {
|
||||
const command = new ObserveLogs(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('passes eventName arg to fetchObserveCustomEventsAsync', async () => {
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [{ id: 'evt-1' } as any],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
const command = createCommand(['my_event']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.eventName).toBe('my_event');
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes to customEventNamesAsync when no positional arg is provided', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes to fetchObserveCustomEventsAsync when --all-events is set with no positional arg', async () => {
|
||||
const command = createCommand(['--all-events']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.eventName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when both an event name argument and --all-events are provided', async () => {
|
||||
const command = createCommand(['my_event', '--all-events']);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'--all-events cannot be combined with an event name argument'
|
||||
);
|
||||
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the resolved time range and platform to customEventNamesAsync', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--days', '7', '--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCustomEventNamesAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
startTime: '2025-06-08T12:00:00.000Z',
|
||||
endTime: '2025-06-15T12:00:00.000Z',
|
||||
platform: AppObservePlatform.Ios,
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses --days to compute start/end time range when an event name is provided', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['my_event', '--days', '7']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit --start and --end when provided', async () => {
|
||||
const command = createCommand([
|
||||
'my_event',
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'--end',
|
||||
'2025-02-01T00:00:00.000Z',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('rejects --days combined with --start', async () => {
|
||||
const command = createCommand([
|
||||
'my_event',
|
||||
'--days',
|
||||
'7',
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes --limit to fetchObserveCustomEventsAsync', async () => {
|
||||
const command = createCommand(['my_event', '--limit', '42']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.limit).toBe(42);
|
||||
});
|
||||
|
||||
it('passes --after cursor', async () => {
|
||||
const command = createCommand(['my_event', '--after', 'cursor-xyz']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.after).toBe('cursor-xyz');
|
||||
});
|
||||
|
||||
it('passes --platform ios as AppObservePlatform.Ios', async () => {
|
||||
const command = createCommand(['my_event', '--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBe(AppObservePlatform.Ios);
|
||||
});
|
||||
|
||||
it('passes --app-version', async () => {
|
||||
const command = createCommand(['my_event', '--app-version', '2.1.0']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.appVersion).toBe('2.1.0');
|
||||
});
|
||||
|
||||
it('passes --update-id', async () => {
|
||||
const command = createCommand(['my_event', '--update-id', 'update-xyz']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.updateId).toBe('update-xyz');
|
||||
});
|
||||
|
||||
it('passes --session-id', async () => {
|
||||
const command = createCommand(['my_event', '--session-id', 'session-xyz']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.sessionId).toBe('session-xyz');
|
||||
});
|
||||
|
||||
it('does not pass platform, appVersion, updateId, or sessionId when flags are not provided', async () => {
|
||||
const command = createCommand(['my_event']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBeUndefined();
|
||||
expect(options.appVersion).toBeUndefined();
|
||||
expect(options.updateId).toBeUndefined();
|
||||
expect(options.sessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided with an event name', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 'evt-1',
|
||||
eventName: 'my_event',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
properties: [],
|
||||
},
|
||||
];
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: mockEvents as any,
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
const command = createCommand(['my_event', '--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsJson).toHaveBeenCalledWith(
|
||||
mockEvents,
|
||||
expect.objectContaining({ hasNextPage: false })
|
||||
);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to fetching event names and renders the empty-with-suggestions table when filtered fetch returns 0 events', async () => {
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
const mockNames = [
|
||||
{ eventName: 'foo', count: 10 },
|
||||
{ eventName: 'bar', count: 5 },
|
||||
];
|
||||
mockCustomEventNamesAsync.mockResolvedValue({
|
||||
names: mockNames as any,
|
||||
isTruncated: false,
|
||||
});
|
||||
|
||||
const command = createCommand(['my_event']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).toHaveBeenCalledWith(
|
||||
'my_event',
|
||||
mockNames,
|
||||
expect.objectContaining({ isTruncated: false })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call customEventNamesAsync when filtered fetch returns at least one event', async () => {
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
eventName: 'my_event',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
easClientId: 'client-1',
|
||||
properties: [],
|
||||
} as any,
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
const command = createCommand(['my_event']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits empty-with-suggestions JSON when filtered fetch returns 0 events and --json is set', async () => {
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
const mockNames = [{ eventName: 'foo', count: 10 }];
|
||||
mockCustomEventNamesAsync.mockResolvedValue({
|
||||
names: mockNames as any,
|
||||
isTruncated: false,
|
||||
});
|
||||
|
||||
const command = createCommand(['my_event', '--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsJson).toHaveBeenCalledWith(
|
||||
'my_event',
|
||||
mockNames,
|
||||
false
|
||||
);
|
||||
expect(mockBuildObserveCustomEventsJson).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not run the empty-with-suggestions fallback when no event name is provided (event names mode)', async () => {
|
||||
mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false });
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not run the empty-with-suggestions fallback for --all-events with 0 results', async () => {
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
const command = createCommand(['--all-events']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON of event names + counts when --json is provided without an event name', async () => {
|
||||
const mockNames = [
|
||||
{ eventName: 'foo', count: 10 },
|
||||
{ eventName: 'bar', count: 5 },
|
||||
];
|
||||
mockCustomEventNamesAsync.mockResolvedValue({
|
||||
names: mockNames as any,
|
||||
isTruncated: false,
|
||||
});
|
||||
|
||||
const command = createCommand(['--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventNamesJson).toHaveBeenCalledWith(mockNames, false);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
EventsOrderPreset,
|
||||
@@ -14,6 +13,12 @@ import {
|
||||
} from '../../observe/fetchEvents';
|
||||
import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames';
|
||||
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
|
||||
import {
|
||||
allowedPlatformFlagValues,
|
||||
appObservePlatformFromFlag,
|
||||
appPlatformsFromFlag,
|
||||
} from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -41,7 +46,7 @@ export default class ObserveEvents extends EasCommand {
|
||||
})(),
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
|
||||
options: allowedPlatformFlagValues,
|
||||
})(),
|
||||
after: Flags.string({
|
||||
description:
|
||||
@@ -88,22 +93,13 @@ export default class ObserveEvents extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveEvents);
|
||||
|
||||
let projectId: string;
|
||||
let graphqlClient;
|
||||
if (flags['project-id']) {
|
||||
projectId = flags['project-id'];
|
||||
const ctx = await this.getContextAsync(
|
||||
{ contextDefinition: ObserveEvents.loggedInOnlyContextDefinition },
|
||||
{ nonInteractive: flags['non-interactive'] }
|
||||
);
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
} else {
|
||||
const ctx = await this.getContextAsync(ObserveEvents, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
projectId = ctx.projectId;
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
}
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveEvents,
|
||||
loggedInOnlyContextDefinition: ObserveEvents.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
@@ -130,15 +126,8 @@ export default class ObserveEvents extends EasCommand {
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
const platform = flags.platform
|
||||
? flags.platform === 'android'
|
||||
? AppObservePlatform.Android
|
||||
: AppObservePlatform.Ios
|
||||
: undefined;
|
||||
|
||||
const platforms: AppPlatform[] = platform
|
||||
? [platform === AppObservePlatform.Android ? AppPlatform.Android : AppPlatform.Ios]
|
||||
: [AppPlatform.Android, AppPlatform.Ios];
|
||||
const platform = appObservePlatformFromFlag(flags.platform);
|
||||
const platforms = appPlatformsFromFlag(flags.platform);
|
||||
|
||||
const [{ events, pageInfo }, totalEventCount] = await Promise.all([
|
||||
fetchObserveEventsAsync(graphqlClient, projectId, {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import Log from '../../log';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents';
|
||||
import {
|
||||
buildObserveCustomEventNamesJson,
|
||||
buildObserveCustomEventNamesTable,
|
||||
buildObserveCustomEventsEmptyWithSuggestionsJson,
|
||||
buildObserveCustomEventsEmptyWithSuggestionsTable,
|
||||
buildObserveCustomEventsJson,
|
||||
buildObserveCustomEventsTable,
|
||||
} from '../../observe/formatCustomEvents';
|
||||
import { allowedPlatformFlagValues, appObservePlatformFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const DEFAULT_EVENTS_LIMIT = 10;
|
||||
|
||||
export default class ObserveLogs extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
'display individual custom events (logs) emitted by the app, filtered by the event name in the argument. With no arguments, a list of the available event names and associated event counts is returned.';
|
||||
|
||||
static override args = {
|
||||
eventName: Args.string({
|
||||
description: 'Custom event name to filter by',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: allowedPlatformFlagValues,
|
||||
})(),
|
||||
after: Flags.string({
|
||||
description:
|
||||
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
|
||||
}),
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: DEFAULT_EVENTS_LIMIT,
|
||||
limit: 100,
|
||||
}),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description: 'Show events from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
'app-version': Flags.string({
|
||||
description: 'Filter by app version',
|
||||
}),
|
||||
'update-id': Flags.string({
|
||||
description: 'Filter by EAS update ID',
|
||||
}),
|
||||
'session-id': Flags.string({
|
||||
description: 'Filter by session ID',
|
||||
}),
|
||||
'all-events': Flags.boolean({
|
||||
description:
|
||||
'When no event name argument is provided, list all events across all event names instead of a summary of event names + counts.',
|
||||
default: false,
|
||||
}),
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveLogs);
|
||||
|
||||
if (args.eventName && flags['all-events']) {
|
||||
throw new Error(
|
||||
'--all-events cannot be combined with an event name argument. Pass an event name to filter by it, or pass --all-events to list all events across all event names.'
|
||||
);
|
||||
}
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveLogs,
|
||||
loggedInOnlyContextDefinition: ObserveLogs.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
} else {
|
||||
Log.warn('EAS Observe is in preview and subject to breaking changes.');
|
||||
}
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
const platform = appObservePlatformFromFlag(flags.platform);
|
||||
|
||||
if (!args.eventName && !flags['all-events']) {
|
||||
const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(buildObserveCustomEventNamesJson(names, isTruncated));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveCustomEventNamesTable(names, {
|
||||
daysBack,
|
||||
startTime,
|
||||
endTime,
|
||||
isTruncated,
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { events, pageInfo } = await fetchObserveCustomEventsAsync(graphqlClient, projectId, {
|
||||
eventName: args.eventName,
|
||||
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
appVersion: flags['app-version'],
|
||||
updateId: flags['update-id'],
|
||||
sessionId: flags['session-id'],
|
||||
});
|
||||
|
||||
if (args.eventName && events.length === 0) {
|
||||
const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(
|
||||
buildObserveCustomEventsEmptyWithSuggestionsJson(args.eventName, names, isTruncated)
|
||||
);
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveCustomEventsEmptyWithSuggestionsTable(args.eventName, names, {
|
||||
daysBack,
|
||||
startTime,
|
||||
endTime,
|
||||
isTruncated,
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(buildObserveCustomEventsJson(events, pageInfo));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveCustomEventsTable(events, pageInfo, {
|
||||
eventName: args.eventName,
|
||||
daysBack,
|
||||
startTime,
|
||||
endTime,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
|
||||
import {
|
||||
@@ -12,6 +11,8 @@ import {
|
||||
resolveStatKey,
|
||||
} from '../../observe/formatMetrics';
|
||||
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
|
||||
import { allowedPlatformFlagValues, appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -42,7 +43,7 @@ export default class ObserveMetrics extends EasCommand {
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
|
||||
options: allowedPlatformFlagValues,
|
||||
})(),
|
||||
metric: Flags.option({
|
||||
description: 'Metric name to display (can be specified multiple times).',
|
||||
@@ -85,22 +86,13 @@ export default class ObserveMetrics extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveMetrics);
|
||||
|
||||
let projectId: string;
|
||||
let graphqlClient;
|
||||
if (flags['project-id']) {
|
||||
projectId = flags['project-id'];
|
||||
const ctx = await this.getContextAsync(
|
||||
{ contextDefinition: ObserveMetrics.loggedInOnlyContextDefinition },
|
||||
{ nonInteractive: flags['non-interactive'] }
|
||||
);
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
} else {
|
||||
const ctx = await this.getContextAsync(ObserveMetrics, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
projectId = ctx.projectId;
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
}
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveMetrics,
|
||||
loggedInOnlyContextDefinition: ObserveMetrics.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
@@ -114,9 +106,7 @@ export default class ObserveMetrics extends EasCommand {
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
const platforms: AppPlatform[] = flags.platform
|
||||
? [flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios]
|
||||
: [AppPlatform.Android, AppPlatform.Ios];
|
||||
const platforms = appPlatformsFromFlag(flags.platform);
|
||||
|
||||
const { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts } =
|
||||
await fetchObserveMetricsAsync(
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
|
||||
import { buildObserveVersionsJson, buildObserveVersionsTable } from '../../observe/formatVersions';
|
||||
import { allowedPlatformFlagValues, appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -16,7 +17,7 @@ export default class ObserveVersions extends EasCommand {
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
|
||||
options: allowedPlatformFlagValues,
|
||||
})(),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range (ISO date)',
|
||||
@@ -49,22 +50,13 @@ export default class ObserveVersions extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveVersions);
|
||||
|
||||
let projectId: string;
|
||||
let graphqlClient;
|
||||
if (flags['project-id']) {
|
||||
projectId = flags['project-id'];
|
||||
const ctx = await this.getContextAsync(
|
||||
{ contextDefinition: ObserveVersions.loggedInOnlyContextDefinition },
|
||||
{ nonInteractive: flags['non-interactive'] }
|
||||
);
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
} else {
|
||||
const ctx = await this.getContextAsync(ObserveVersions, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
projectId = ctx.projectId;
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
}
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveVersions,
|
||||
loggedInOnlyContextDefinition: ObserveVersions.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
@@ -74,9 +66,7 @@ export default class ObserveVersions extends EasCommand {
|
||||
|
||||
const { startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
const platforms: AppPlatform[] = flags.platform
|
||||
? [flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios]
|
||||
: [AppPlatform.Android, AppPlatform.Ios];
|
||||
const platforms = appPlatformsFromFlag(flags.platform);
|
||||
|
||||
const results = await fetchObserveVersionsAsync(
|
||||
graphqlClient,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { getBareJobRunUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { formatRemoteSessionInstructions } from '../../simulator/utils';
|
||||
|
||||
export default class SimulatorGet extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
'[EXPERIMENTAL] get info about a remote simulator session on EAS by its device run session ID';
|
||||
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
description: 'Device run session ID',
|
||||
required: true,
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorGet);
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorGet, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
const fetchSpinner = ora(`Fetching device run session ${flags.id}`).start();
|
||||
let session;
|
||||
try {
|
||||
session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, flags.id);
|
||||
fetchSpinner.succeed(`Fetched device run session ${session.id}`);
|
||||
} catch (err) {
|
||||
fetchSpinner.fail(`Failed to fetch device run session ${flags.id}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const jobRunUrl = session.turtleJobRun
|
||||
? getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, session.turtleJobRun.id)
|
||||
: '';
|
||||
|
||||
Log.newLine();
|
||||
Log.log(`ID: ${session.id}`);
|
||||
Log.log(`Type: ${session.type}`);
|
||||
Log.log(`Status: ${session.status}`);
|
||||
Log.log(`URL: ${jobRunUrl ? link(jobRunUrl) : ''}`);
|
||||
|
||||
if (session.status === DeviceRunSessionStatus.InProgress) {
|
||||
Log.newLine();
|
||||
if (session.remoteConfig) {
|
||||
Log.log(formatRemoteSessionInstructions(session.remoteConfig));
|
||||
} else {
|
||||
Log.log(
|
||||
'⏳ Session is starting up — remote config is not available yet. Re-run this command in a moment.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { getBareJobRunUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
JobRunStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import {
|
||||
DeviceRunSessionRemoteConfig,
|
||||
formatRemoteSessionInstructions,
|
||||
} from '../../simulator/utils';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000; // 5 seconds
|
||||
const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes
|
||||
|
||||
// Mapping enum → CLI flag value. Declared as Record<DeviceRunSessionType, string>
|
||||
// so adding a new enum value in codegen fails the build until it is wired up here.
|
||||
const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, string> = {
|
||||
[DeviceRunSessionType.AgentDevice]: 'agent-device',
|
||||
[DeviceRunSessionType.ServeSim]: 'serve-sim',
|
||||
};
|
||||
|
||||
const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
|
||||
(Object.entries(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES) as [DeviceRunSessionType, string][]).map(
|
||||
([type, value]) => [value, type]
|
||||
)
|
||||
) as Record<string, DeviceRunSessionType>;
|
||||
|
||||
export default class SimulatorStart extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Device platform',
|
||||
options: ['android', 'ios'] as const,
|
||||
required: true,
|
||||
})(),
|
||||
type: Flags.option({
|
||||
description: 'Type of device run session to create',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
default: DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice],
|
||||
})(),
|
||||
'package-version': Flags.string({
|
||||
description:
|
||||
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorStart);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorStart, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
const platform = flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios;
|
||||
|
||||
const createSpinner = ora('🚀 Creating device run session').start();
|
||||
let deviceRunSessionId: string;
|
||||
let jobRunUrl: string;
|
||||
try {
|
||||
const session = await DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
platform,
|
||||
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
|
||||
packageVersion: flags['package-version'],
|
||||
});
|
||||
deviceRunSessionId = session.id;
|
||||
const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start');
|
||||
jobRunUrl = getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, jobRunId);
|
||||
createSpinner.succeed(
|
||||
`Device run session created (id: ${deviceRunSessionId}) ${link(jobRunUrl)}`
|
||||
);
|
||||
} catch (err) {
|
||||
createSpinner.fail('Failed to create device run session');
|
||||
throw err;
|
||||
}
|
||||
|
||||
const pollSpinner = ora(`⏳ Waiting for ${flags.type} session to be ready`).start();
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
let remoteConfig: DeviceRunSessionRemoteConfig | undefined;
|
||||
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId);
|
||||
|
||||
if (
|
||||
session.status === DeviceRunSessionStatus.Errored ||
|
||||
session.status === DeviceRunSessionStatus.Stopped
|
||||
) {
|
||||
throw new Error(
|
||||
`Device run session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(jobRunUrl)}`
|
||||
);
|
||||
}
|
||||
|
||||
const jobRunStatus = session.turtleJobRun?.status;
|
||||
if (
|
||||
jobRunStatus === JobRunStatus.Errored ||
|
||||
jobRunStatus === JobRunStatus.Canceled ||
|
||||
jobRunStatus === JobRunStatus.Finished
|
||||
) {
|
||||
throw new Error(
|
||||
`Turtle job run for device run session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(jobRunUrl)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (session.remoteConfig) {
|
||||
remoteConfig = session.remoteConfig;
|
||||
pollSpinner.succeed(`🎉 ${flags.type} session is ready`);
|
||||
break;
|
||||
}
|
||||
|
||||
await sleepAsync(POLL_INTERVAL_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
pollSpinner.fail(`Failed while polling for ${flags.type} session to be ready`);
|
||||
await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!remoteConfig) {
|
||||
pollSpinner.fail(`Timed out waiting for ${flags.type} session to be ready`);
|
||||
await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId);
|
||||
throw new Error(
|
||||
`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} session to be ready. ${link(jobRunUrl)}`
|
||||
);
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(formatRemoteSessionInstructions(remoteConfig));
|
||||
Log.newLine();
|
||||
|
||||
if (flags['non-interactive']) {
|
||||
Log.log(
|
||||
`When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForSessionEndOrInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
jobRunUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSessionEndOrInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
jobRunUrl,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
deviceRunSessionId: string;
|
||||
jobRunUrl: string;
|
||||
}): Promise<void> {
|
||||
const spinner = ora(
|
||||
`Device run session active — press Ctrl+C to stop, or run \`eas simulator:stop --id ${deviceRunSessionId}\` from another shell`
|
||||
).start();
|
||||
|
||||
const abortController = new AbortController();
|
||||
const { signal } = abortController;
|
||||
const abortPromise = new Promise<void>(resolve => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
const sigintHandler = (): void => {
|
||||
if (signal.aborted) {
|
||||
// Force exit on a second Ctrl+C in case cleanup is hanging. The session may still be
|
||||
// running on EAS, so tell the user how to make sure it gets terminated.
|
||||
spinner.fail(
|
||||
`Aborted before the device run session could be stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
|
||||
);
|
||||
process.exit(130);
|
||||
}
|
||||
abortController.abort();
|
||||
};
|
||||
process.on('SIGINT', sigintHandler);
|
||||
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
let session;
|
||||
try {
|
||||
session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId);
|
||||
} catch (err) {
|
||||
Log.debug(
|
||||
`Failed to poll device run session: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
await Promise.race([sleepAsync(POLL_INTERVAL_MS), abortPromise]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const jobRunStatus = session.turtleJobRun?.status;
|
||||
if (
|
||||
session.status === DeviceRunSessionStatus.Errored ||
|
||||
jobRunStatus === JobRunStatus.Errored
|
||||
) {
|
||||
spinner.fail(`Device run session errored. ${link(jobRunUrl)}`);
|
||||
throw new Error(`Device run session ${deviceRunSessionId} errored.`);
|
||||
}
|
||||
if (
|
||||
session.status === DeviceRunSessionStatus.Stopped ||
|
||||
jobRunStatus === JobRunStatus.Canceled ||
|
||||
jobRunStatus === JobRunStatus.Finished
|
||||
) {
|
||||
spinner.succeed(`Device run session ended. ${link(jobRunUrl)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.race([sleepAsync(POLL_INTERVAL_MS), abortPromise]);
|
||||
}
|
||||
|
||||
spinner.text = 'Stopping device run session...';
|
||||
const stopped = await ensureDeviceRunSessionStoppedSafelyAsync(
|
||||
graphqlClient,
|
||||
deviceRunSessionId
|
||||
);
|
||||
if (stopped) {
|
||||
spinner.succeed('Device run session stopped');
|
||||
} else {
|
||||
spinner.fail(
|
||||
`Could not confirm the device run session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDeviceRunSessionStoppedSafelyAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync(
|
||||
graphqlClient,
|
||||
deviceRunSessionId
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Cleanup is best-effort; surface the failure but don't mask the original error.
|
||||
Log.warn(
|
||||
`Failed to stop device run session ${deviceRunSessionId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
|
||||
import { ora } from '../../ora';
|
||||
|
||||
export default class SimulatorStop extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
'[EXPERIMENTAL] stop a remote simulator session on EAS by its device run session ID';
|
||||
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
description: 'Device run session ID',
|
||||
required: true,
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorStop);
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorStop, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
const stopSpinner = ora(`🛑 Stopping device run session ${flags.id}`).start();
|
||||
try {
|
||||
const session = await DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync(
|
||||
graphqlClient,
|
||||
flags.id
|
||||
);
|
||||
stopSpinner.succeed(`🎉 Device run session ${session.id} is ${session.status.toLowerCase()}`);
|
||||
} catch (err) {
|
||||
stopSpinner.fail(`Failed to stop device run session ${flags.id}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'path';
|
||||
import { UserRole } from '@expo/apple-utils';
|
||||
|
||||
import { formatAppleTeam } from './AppleTeamFormatting';
|
||||
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
|
||||
import { fromNow } from '../../../utils/date';
|
||||
import { CredentialsContext } from '../../context';
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ import { isAscApiKeyValidAndTrackedAsync } from '../validators/validateAscApiKey
|
||||
|
||||
export enum AppStoreApiKeyPurpose {
|
||||
SUBMISSION_SERVICE = 'EAS Submit',
|
||||
ASC_APP_CONNECTION = 'EAS Connect',
|
||||
}
|
||||
|
||||
export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Promise<AscApiKeyPath> {
|
||||
@@ -99,12 +101,27 @@ async function generateAscApiKeyAsync(
|
||||
purpose: AppStoreApiKeyPurpose
|
||||
): Promise<MinimalAscApiKey> {
|
||||
await ctx.appStore.ensureAuthenticatedAsync();
|
||||
const role = await selectRoleForGeneratedAscApiKeyAsync();
|
||||
const ascApiKey = await ctx.appStore.createAscApiKeyAsync(ctx.analytics, {
|
||||
nickname: getAscApiKeyName(purpose),
|
||||
roles: [role],
|
||||
});
|
||||
return await getMinimalAscApiKeyAsync(ascApiKey);
|
||||
}
|
||||
|
||||
async function selectRoleForGeneratedAscApiKeyAsync(): Promise<UserRole> {
|
||||
return await selectAsync<UserRole>('Select role for the generated API key:', [
|
||||
{
|
||||
title: 'ADMIN (default)',
|
||||
value: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
title: 'APP_MANAGER (least privilege for app management)',
|
||||
value: UserRole.APP_MANAGER,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export function getAscApiKeyName(purpose: AppStoreApiKeyPurpose): string {
|
||||
const nameParts = ['[Expo]', purpose, nanoid(10)];
|
||||
return nameParts.join(' ');
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { promptAsync } from '../../../../prompts';
|
||||
import { UserRole } from '@expo/apple-utils';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { promptAsync, selectAsync } from '../../../../prompts';
|
||||
import { getAppstoreMock, testAuthCtx } from '../../../__tests__/fixtures-appstore';
|
||||
import { createCtxMock } from '../../../__tests__/fixtures-context';
|
||||
import { testAscApiKey } from '../../../__tests__/fixtures-ios';
|
||||
import { getCredentialsFromUserAsync } from '../../../utils/promptForCredentials';
|
||||
import {
|
||||
getCredentialsFromUserAsync,
|
||||
shouldAutoGenerateCredentialsAsync,
|
||||
} from '../../../utils/promptForCredentials';
|
||||
import {
|
||||
AppStoreApiKeyPurpose,
|
||||
getAscApiKeyName,
|
||||
promptForAscApiKeyPathAsync,
|
||||
provideOrGenerateAscApiKeyAsync,
|
||||
} from '../AscApiKeyUtils';
|
||||
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('../../../utils/promptForCredentials');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
|
||||
return Object.keys(obj) as K[];
|
||||
@@ -18,7 +26,10 @@ function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
|
||||
|
||||
afterEach(() => {
|
||||
jest.mocked(promptAsync).mockClear();
|
||||
jest.mocked(selectAsync).mockClear();
|
||||
jest.mocked(getCredentialsFromUserAsync).mockClear();
|
||||
jest.mocked(shouldAutoGenerateCredentialsAsync).mockClear();
|
||||
jest.mocked(fs.readFile).mockClear();
|
||||
});
|
||||
|
||||
describe(getAscApiKeyName, () => {
|
||||
@@ -87,3 +98,84 @@ describe(promptForAscApiKeyPathAsync, () => {
|
||||
expect(jest.mocked(ctx.appStore.getAscApiKeyAsync).mock.calls.length).toBe(1); // issuerId
|
||||
});
|
||||
});
|
||||
|
||||
describe(provideOrGenerateAscApiKeyAsync, () => {
|
||||
it('creates a key with ADMIN role by default', async () => {
|
||||
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(true);
|
||||
jest.mocked(selectAsync).mockResolvedValue(UserRole.ADMIN);
|
||||
|
||||
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: testAuthCtx,
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
createAscApiKeyAsync,
|
||||
},
|
||||
});
|
||||
|
||||
await provideOrGenerateAscApiKeyAsync(ctx, AppStoreApiKeyPurpose.ASC_APP_CONNECTION);
|
||||
|
||||
expect(createAscApiKeyAsync).toHaveBeenCalledWith(ctx.analytics, {
|
||||
nickname: expect.any(String),
|
||||
roles: [UserRole.ADMIN],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows creating a key with APP_MANAGER role', async () => {
|
||||
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(true);
|
||||
jest.mocked(selectAsync).mockResolvedValue(UserRole.APP_MANAGER);
|
||||
|
||||
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: testAuthCtx,
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
createAscApiKeyAsync,
|
||||
},
|
||||
});
|
||||
|
||||
await provideOrGenerateAscApiKeyAsync(ctx, AppStoreApiKeyPurpose.ASC_APP_CONNECTION);
|
||||
|
||||
expect(createAscApiKeyAsync).toHaveBeenCalledWith(ctx.analytics, {
|
||||
nickname: expect.any(String),
|
||||
roles: [UserRole.APP_MANAGER],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not apply role override when using a user-provided key', async () => {
|
||||
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(false);
|
||||
jest.mocked(promptAsync).mockResolvedValue({ keyP8Path: '/asc-api-key.p8' });
|
||||
jest.mocked(fs.readFile).mockImplementation(async () => 'test-key-p8' as any);
|
||||
jest
|
||||
.mocked(getCredentialsFromUserAsync)
|
||||
.mockResolvedValueOnce({ keyId: 'test-key-id' })
|
||||
.mockResolvedValueOnce({ issuerId: 'test-issuer-id' });
|
||||
|
||||
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: null,
|
||||
createAscApiKeyAsync,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provideOrGenerateAscApiKeyAsync(
|
||||
ctx,
|
||||
AppStoreApiKeyPurpose.ASC_APP_CONNECTION
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
keyP8: expect.any(String),
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id',
|
||||
});
|
||||
expect(selectAsync).not.toHaveBeenCalled();
|
||||
expect(createAscApiKeyAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ProfileType } from '@expo/app-store';
|
||||
import { UserRole } from '@expo/apple-utils';
|
||||
|
||||
import {
|
||||
AscApiKey,
|
||||
@@ -195,10 +196,10 @@ export default class AppStoreApi {
|
||||
|
||||
public async createAscApiKeyAsync(
|
||||
analytics: Analytics,
|
||||
{ nickname }: { nickname: string }
|
||||
{ nickname, roles }: { nickname: string; roles?: UserRole[] }
|
||||
): Promise<AscApiKey> {
|
||||
const userCtx = await this.ensureUserAuthenticatedAsync();
|
||||
return await createAscApiKeyAsync(analytics, userCtx, { nickname });
|
||||
return await createAscApiKeyAsync(analytics, userCtx, { nickname, roles });
|
||||
}
|
||||
|
||||
public async revokeAscApiKeyAsync(keyId: string): Promise<AscApiKeyInfo> {
|
||||
|
||||
@@ -90,6 +90,23 @@ test(`createAscApiKeyAsync`, async () => {
|
||||
expect(result).toEqual({ ...mockAscApiKeyInfo, keyP8: 'super secret' });
|
||||
});
|
||||
|
||||
test(`createAscApiKeyAsync forwards explicit roles`, async () => {
|
||||
const analytics = instance(mock<Analytics>());
|
||||
const mockRequestContext = {};
|
||||
jest.spyOn(ApiKey, 'createAsync').mockImplementation(async () => mockApiKey);
|
||||
jest.mocked(getRequestContext).mockImplementation(() => mockRequestContext);
|
||||
await createAscApiKeyAsync(analytics, mockAuthCtx, {
|
||||
nickname: 'test-name',
|
||||
roles: [UserRole.APP_MANAGER],
|
||||
});
|
||||
expect(ApiKey.createAsync).toHaveBeenLastCalledWith(mockRequestContext, {
|
||||
nickname: 'test-name',
|
||||
allAppsVisible: true,
|
||||
roles: [UserRole.APP_MANAGER],
|
||||
keyType: ApiKeyType.PUBLIC_API,
|
||||
});
|
||||
});
|
||||
|
||||
test(`revokeAscApiKeyAsync`, async () => {
|
||||
const mockRequestContext = {};
|
||||
jest.spyOn(ApiKey, 'infoAsync').mockImplementation(async () => mockApiKey);
|
||||
|
||||
@@ -103,6 +103,7 @@ async function createFingerprintWithoutLoggingAsync(
|
||||
if (options.debug) {
|
||||
fingerprintOptions.debug = true;
|
||||
}
|
||||
fingerprintOptions.silent = true;
|
||||
|
||||
return await withTemporaryEnvAsync(options.env ?? {}, () =>
|
||||
Fingerprint.createFingerprintAsync(projectDir, fingerprintOptions)
|
||||
|
||||
+665
-31
File diff suppressed because it is too large.
Load diff
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user