Compare commits

..
Author SHA1 Message Date
Mustafa Abban bad188babe Merge branch 'main' into eas-build-valid-lock 2026-04-30 10:46:50 -05:00
Mustafa Abban f6ba878b29 Merge branch 'main' into eas-build-valid-lock 2026-03-13 12:59:07 -05:00
AbbanMustafa 07fbe5c777 fmt 2026-03-11 14:32:41 -05:00
AbbanMustafa c8739b66af changelog 2026-03-11 14:16:43 -05:00
AbbanMustafa 777a3c947f package lockfile-utils 2026-03-11 14:14:17 -05:00
AbbanMustafa ce3d17ae4c casing 2026-03-11 13:47:16 -05:00
AbbanMustafa 2f9dfb6610 env var to skip check 2026-03-11 13:45:53 -05:00
AbbanMustafa b862a0575c turtlebot 2026-03-02 16:02:30 -06:00
AbbanMustafa c1fe243a70 peer dependency conflicts 2026-03-02 15:33:39 -06:00
AbbanMustafa 8065715d4c changelog 2026-03-02 16:14:29 -05:00
AbbanMustafa 402cc40bb9 clean log 2026-03-02 15:57:11 -05:00
AbbanMustafa 61eeabb404 verify package versions 2026-03-02 15:48:01 -05:00
AbbanMustafa edbaaf8000 warn on staleness 2026-03-01 16:34:18 -05:00
AbbanMustafa 68e561e879 [eas-build] - lockfile preflight checks to eas build command 2026-02-26 12:51:07 -05:00
190 changed files with 3338 additions and 15526 deletions

No files matched your search

-16
View File
@@ -1,16 +0,0 @@
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@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
uses: actions/github-script@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@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
uses: rtCamp/action-slack-notify@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@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
uses: rtCamp/action-slack-notify@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@c200f3691d83b41bf9bbd8638997a462592937ed # v2
uses: google-github-actions/auth@v2
with:
workload_identity_provider: 'projects/321830142373/locations/global/workloadIdentityPools/github/providers/expo'
project_id: exponentjs
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
name: Check CHANGELOG.md updated
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- uses: actions/checkout@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@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
uses: actions/github-script@v6
with:
script: |
core.setFailed('Please add a changelog entry!')
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
- uses: mshick/[email protected]
if: always()
with:
message-id: changelog-entry-check
+1 -7
View File
@@ -1,8 +1,5 @@
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:
@@ -12,9 +9,6 @@ jobs:
contents: read
pull-requests: write
steps:
# 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
- uses: tobyhs/[email protected]
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
+5 -5
View File
@@ -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@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
- uses: actions/github-script@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@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
- uses: actions/github-script@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@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
- uses: actions/github-script@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@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
- uses: actions/github-script@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@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
- uses: actions/github-script@v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
+1 -1
View File
@@ -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@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2
- uses: actions/setup-node@v2
with:
registry-url: "https://registry.npmjs.org/"
scope: "expo"
+4 -4
View File
@@ -15,7 +15,7 @@ jobs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- id: create_release
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -29,7 +29,7 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@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@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
uses: rtCamp/action-slack-notify@v2
env:
SLACK_CHANNEL: eas-cli
SLACK_COLOR: ${{ job.status }}
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
close-issues:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@a20b814fb01b71def3bd6f56e7494d667ddf28da # v4
- uses: actions/stale@v4
with:
ascending: false
operations-per-run: 300
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
name: Ensure GraphQL schema and generated code is up-to-date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- uses: actions/checkout@v2
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- uses: actions/checkout@v2
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- uses: actions/checkout@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@29386c70ef20e286228c72b668a06fd0e8399192 # v1
- uses: codecov/codecov-action@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@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- uses: actions/checkout@v2
- name: Notify Slack
uses: ./.github/internal-actions/notify-slack-on-fail-or-recover
with:
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
-7
View File
@@ -1,9 +1,2 @@
defaultSemverRangePrefix: ''
nodeLinker: node-modules
npmMinimalAgeGate: 2d
npmPreapprovedPackages:
- '@expo/*'
- 'eslint-plugin-universe'
- 'oxlint-config-universe'
+2 -94
View File
@@ -10,101 +10,9 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
### 🧹 Chores
## [19.0.1](https://github.com/expo/eas-cli/releases/tag/v19.0.1) - 2026-05-19
### 🐛 Bug fixes
- [eas-cli] Disable dotenv loading when resolving managed iOS entitlements. ([#3752](https://github.com/expo/eas-cli/pull/3752) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Restore the bundled config fallback for managed iOS entitlements introspection. ([#3753](https://github.com/expo/eas-cli/pull/3753) by [@sjchmiela](https://github.com/sjchmiela))
## [19.0.0](https://github.com/expo/eas-cli/releases/tag/v19.0.0) - 2026-05-19
### 🛠 Breaking changes
- [eas-cli] Make browser-based authentication the default for `eas login`. Use `--no-browser` for CLI-based login. ([#3746](https://github.com/expo/eas-cli/pull/3746) by [@byronkarlen](https://github.com/byronkarlen))
### 🎉 New features
- [eas-cli] Allow command `observe:routes` to filter results to specific route names. ([#3744](https://github.com/expo/eas-cli/pull/3744) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [eas-cli] Check that `expo` is resolvable before assuming it's installed ([#3748](https://github.com/expo/eas-cli/pull/3748) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- [worker] Report build phase durations stats. ([#3732](https://github.com/expo/eas-cli/pull/3732) by [@gwdp](https://github.com/gwdp))
## [18.13.1](https://github.com/expo/eas-cli/releases/tag/v18.13.1) - 2026-05-18
### 🎉 New features
- [eas-cli] Send the installed `expo` package version in build metadata. ([#3725](https://github.com/expo/eas-cli/pull/3725) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- [eas-cli] Prevent `npx` invocations that can be unreliable and fail when retrieving entitlements or project configs ([#3282](https://github.com/expo/eas-cli/pull/3282) by [@kitten](https://github.com/kitten))
## [18.13.0](https://github.com/expo/eas-cli/releases/tag/v18.13.0) - 2026-05-14
### 🎉 New features
- [eas-cli] New command `observe:routes` for navigation metrics. ([#3730](https://github.com/expo/eas-cli/pull/3730) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- [eas-cli] Refactor observe commands to extract common flag definitions. ([#3731](https://github.com/expo/eas-cli/pull/3731) by [@douglowder](https://github.com/douglowder))
## [18.12.3](https://github.com/expo/eas-cli/releases/tag/v18.12.3) - 2026-05-13
## [18.12.2](https://github.com/expo/eas-cli/releases/tag/v18.12.2) - 2026-05-12
### 🐛 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))
### 🧹 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
- [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))
### 🧹 Chores
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "19.0.1",
"version": "18.9.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+7 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "19.0.0",
"version": "18.9.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -37,20 +37,19 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "19.0.0",
"@expo/eas-build-job": "19.0.0",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.8.0",
"@expo/env": "^0.4.0",
"@expo/logger": "19.0.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": "19.0.0",
"@expo/template-file": "19.0.0",
"@expo/turtle-spawn": "19.0.0",
"@expo/steps": "18.9.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@sentry/node": "7.77.0",
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
"fast-glob": "^3.3.2",
@@ -1,7 +1,10 @@
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, rm: fsRm };
module.exports = { ...fs.promises, realpath: fsRealpath, rm: fsRm };
@@ -1,36 +1,14 @@
import {
BuildPhase,
BuildPhaseResult,
BuildTrigger,
Job,
Metadata,
Platform,
} from '@expo/eas-build-job';
import { BuildTrigger, Job, Metadata } from '@expo/eas-build-job';
import { randomUUID } from 'crypto';
import fs from 'fs-extra';
import { vol } from 'memfs';
import { createMockLogger } from './utils/logger';
import { BuildContext } from '../context';
import { Datadog } from '../datadog';
jest.mock('fs');
jest.mock('fs-extra');
jest.mock('../datadog', () => ({
Datadog: {
distribution: jest.fn(),
},
}));
const datadogDistributionMock = jest.mocked(Datadog.distribution);
describe('BuildContext', () => {
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
(fs.readdir as unknown as jest.Mock).mockResolvedValue([]);
});
it('should merge secrets', async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
@@ -138,68 +116,4 @@ describe('BuildContext', () => {
foo: 'bar',
});
});
it('emits build phase duration metrics for successful build phases', async () => {
const ctx = createTestBuildContext({ platform: Platform.IOS });
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {});
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
{
build_phase: 'run_fastlane',
platform: Platform.IOS,
result: BuildPhaseResult.SUCCESS,
}
);
});
it('emits build phase duration metrics for failed build phases', async () => {
const ctx = createTestBuildContext({ platform: Platform.ANDROID });
await expect(
ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
throw new Error('boom');
})
).rejects.toThrow();
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
{
build_phase: 'install_dependencies',
platform: Platform.ANDROID,
result: BuildPhaseResult.FAIL,
}
);
});
it('does not emit build phase duration metrics for non-platform jobs', async () => {
const ctx = createTestBuildContext({});
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {});
expect(datadogDistributionMock).not.toHaveBeenCalled();
});
});
function createTestBuildContext({ platform }: { platform?: Platform }): BuildContext {
vol.mkdirSync('/workingdir/env', { recursive: true });
return new BuildContext(
{
...(platform ? { platform } : {}),
builderEnvironment: { env: {} },
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
}
@@ -1,150 +0,0 @@
import { Response } from 'node-fetch';
import { Datadog } from '../datadog';
import { Sentry } from '../sentry';
import { turtleFetch } from '../utils/turtleFetch';
jest.mock('../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
jest.mock('../utils/turtleFetch', () => ({
turtleFetch: jest.fn(),
}));
const turtleFetchMock = jest.mocked(turtleFetch);
const sentryCaptureMock = jest.mocked(Sentry.capture);
describe('Datadog singleton', () => {
beforeEach(() => {
jest.clearAllMocks();
Datadog.setup(null);
});
it('POSTs distribution metrics to the turtle build metrics endpoint', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
Datadog.distribution('eas.build.phase_duration', 1234, {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/build-id/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.build.phase_duration',
type: 'distribution',
value: 1234,
tags: {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
},
},
],
},
headers: { Authorization: 'Bearer token-abc' },
retries: 2,
}
);
});
it('is a no-op when setup is null', () => {
Datadog.setup(null);
Datadog.distribution('eas.build.phase_duration', 1);
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('swallows upload failures', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
Datadog.distribution('eas.build.phase_duration', 1);
await flushPromises();
expect(turtleFetchMock).toHaveBeenCalled();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle build metric',
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [{ name: 'eas.build.phase_duration', type: 'distribution', value: 1, tags: {} }],
},
})
);
});
it('uses the latest setup options', () => {
turtleFetchMock.mockResolvedValue({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'first-build-id',
robotAccessToken: 'first-token',
});
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'second-build-id',
robotAccessToken: 'second-token',
});
Datadog.distribution('eas.build.phase_duration', 1);
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/second-build-id/metrics',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
})
);
});
it('flushes pending metric uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
let flushed = false;
Datadog.distribution('eas.build.phase_duration', 1);
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
await Promise.resolve();
expect(flushed).toBe(false);
resolveUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
});
async function flushPromises(): Promise<void> {
await new Promise(resolve => setImmediate(resolve));
}
@@ -1,256 +0,0 @@
import * as sentryNode from '@sentry/node';
import { Sentry } from '../sentry';
jest.mock('@sentry/node');
describe('Sentry singleton', () => {
beforeEach(() => {
jest.mocked(sentryNode.init).mockReset();
jest.mocked(sentryNode.flush).mockReset();
jest.mocked(sentryNode.captureException).mockReset();
jest.mocked(sentryNode.captureMessage).mockReset();
jest.mocked(sentryNode.withScope).mockReset();
});
describe('setup', () => {
it('initializes the SDK with dsn + environment when dsn is non-null', () => {
Sentry.setup({ dsn: 'https://[email protected]/1', environment: 'production' });
expect(sentryNode.init).toHaveBeenCalledWith({
dsn: 'https://[email protected]/1',
environment: 'production',
});
});
it('passes default tags into the SDK init as initialScope', () => {
Sentry.setup({
dsn: 'https://[email protected]/1',
environment: 'production',
tags: { service: 'worker:darwin' },
});
expect(sentryNode.init).toHaveBeenCalledWith({
dsn: 'https://[email protected]/1',
environment: 'production',
initialScope: { tags: { service: 'worker:darwin' } },
});
});
it('skips SDK init when dsn is null', () => {
Sentry.setup({ dsn: null, environment: 'development' });
expect(sentryNode.init).not.toHaveBeenCalled();
});
it('passes the latest initialScope tags when setup is called more than once', () => {
Sentry.setup({
dsn: 'https://[email protected]/1',
environment: 'test',
tags: { service: 'first' },
});
Sentry.setup({
dsn: 'https://[email protected]/1',
environment: 'test',
tags: { service: 'second' },
});
expect(sentryNode.init).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ initialScope: { tags: { service: 'second' } } })
);
});
});
describe('capture', () => {
it('routes (msg) to captureMessage with msg only', () => {
const scope = mockWithScope();
Sentry.capture('plain message');
expect(sentryNode.captureMessage).toHaveBeenCalledWith('plain message');
expect(sentryNode.captureException).not.toHaveBeenCalled();
expect(scope.setExtra).not.toHaveBeenCalled();
});
it('routes (msg, options) and sets per-call tags/extras/level on the scope', () => {
const scope = mockWithScope();
Sentry.capture('something happened', {
tags: { phase: 'running-xclogparser' },
extras: { buildId: 'abc' },
level: 'warning',
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'running-xclogparser' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setLevel).toHaveBeenCalledWith('warning');
expect(sentryNode.captureMessage).toHaveBeenCalledWith('something happened');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('routes (msg, err) to captureException and sets msg as `message` extra when distinct from err.message', () => {
const scope = mockWithScope();
const err = new Error('boom');
Sentry.capture('something went wrong', err);
expect(scope.setExtra).toHaveBeenCalledWith('message', 'something went wrong');
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('routes (msg, err, options) to captureException with per-call tags/extras', () => {
const scope = mockWithScope();
const err = new Error('spawn ENOENT');
Sentry.capture('xclogparser failed', err, {
tags: { phase: 'running-xclogparser' },
extras: { buildId: 'abc' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'running-xclogparser' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setExtra).toHaveBeenCalledWith('message', 'xclogparser failed');
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
});
it('does not set the `message` extra when msg equals err.message', () => {
const scope = mockWithScope();
const err = new Error('build failed');
Sentry.capture('build failed', err);
expect(scope.setExtra).not.toHaveBeenCalledWith('message', expect.anything());
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
});
it('routes (err) alone to captureException with no scope mutations', () => {
const scope = mockWithScope();
const err = new Error('boom');
Sentry.capture(err);
expect(scope.setTags).not.toHaveBeenCalled();
expect(scope.setExtras).not.toHaveBeenCalled();
expect(scope.setLevel).not.toHaveBeenCalled();
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('treats (msg, null) as a no-err call (parity with 3-arg null behavior)', () => {
const scope = mockWithScope();
Sentry.capture('forwarded null', null as unknown as Error);
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded null');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('treats (msg, null, options) as a no-err call', () => {
const scope = mockWithScope();
Sentry.capture('forwarded null with opts', null as unknown as Error, {
tags: { phase: 'cleanup' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'cleanup' });
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded null with opts');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('treats (msg, undefined, options) as a no-err call (forwarding `Error | undefined` typed vars)', () => {
const scope = mockWithScope();
Sentry.capture('forwarded with no err', undefined, {
tags: { phase: 'install-dependencies' },
extras: { buildId: 'abc' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded with no err');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('coerces 2-arg primitive thrown value to Error (e.g. `throw "boom"`)', () => {
mockWithScope();
Sentry.capture('caught a string', 'boom' as unknown as Error);
expect(sentryNode.captureException).toHaveBeenCalledWith(expect.any(Error));
const coerced = jest.mocked(sentryNode.captureException).mock.calls[0][0] as Error;
expect(coerced.message).toBe('boom');
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('coerces non-Error middle arg to Error when caller passes 3 args (e.g. `catch (err: any)`)', () => {
const scope = mockWithScope();
Sentry.capture('something failed', 'raw string thrown' as unknown as Error, {
tags: { phase: 'install-dependencies' },
extras: { buildId: 'abc' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setExtra).toHaveBeenCalledWith('message', 'something failed');
expect(sentryNode.captureException).toHaveBeenCalledWith(expect.any(Error));
const coerced = jest.mocked(sentryNode.captureException).mock.calls[0][0] as Error;
expect(coerced.message).toBe('raw string thrown');
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('routes (err, options) to captureException with no message extra', () => {
const scope = mockWithScope();
const err = new Error('boom');
Sentry.capture(err, { tags: { phase: 'install-dependencies' }, level: 'warning' });
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
expect(scope.setLevel).toHaveBeenCalledWith('warning');
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
});
describe('flush', () => {
it('forwards the timeout to the SDK and resolves with its result', async () => {
jest.mocked(sentryNode.flush).mockResolvedValue(true);
const result = await Sentry.flush(3000);
expect(sentryNode.flush).toHaveBeenCalledWith(3000);
expect(result).toBe(true);
});
it('defaults the timeout to 2000ms when not specified', async () => {
jest.mocked(sentryNode.flush).mockResolvedValue(true);
await Sentry.flush();
expect(sentryNode.flush).toHaveBeenCalledWith(2000);
});
});
});
function mockWithScope(): {
setTags: jest.Mock;
setExtras: jest.Mock;
setExtra: jest.Mock;
setLevel: jest.Mock;
} {
const scope = {
setTags: jest.fn(),
setExtras: jest.fn(),
setExtra: jest.fn(),
setLevel: jest.fn(),
};
jest.mocked(sentryNode.withScope).mockImplementation((cb: any) => {
cb(scope as unknown as sentryNode.Scope);
return undefined as any;
});
return scope;
}
+12 -21
View File
@@ -32,27 +32,18 @@ export async function runGradleCommand(
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : '';
const spawnPromise = spawn(
'bash',
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
{
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: {
...ctx.env,
...extraEnv,
...resolveVersionOverridesEnvs(ctx),
LC_ALL: 'C.UTF-8',
},
}
);
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx), LC_ALL: 'C.UTF-8' },
});
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
}
@@ -1,231 +0,0 @@
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs-extra';
import path from 'path';
export interface GradleProfileTask {
path: string;
durationMs: number;
result: string;
}
export async function parseGradleProfile(androidDir: string): Promise<GradleProfileTask[]> {
const profileDir = path.join(androidDir, 'build', 'reports', 'profile');
// Gradle profile may not exist for all builds (e.g. custom, repack
// jobs). We should short circuit and not unnecessarily log.
if (!(await fs.pathExists(profileDir))) {
return [];
}
const files = await fs.readdir(profileDir);
const htmlFile = files
.filter(f => f.startsWith('profile-') && f.endsWith('.html'))
.sort()
.pop();
if (!htmlFile) {
throw new Error(`No Gradle profile HTML found in ${profileDir}`);
}
const html = await fs.readFile(path.join(profileDir, htmlFile), 'utf8');
// Locate the <h2>Task Execution</h2> heading and extract its <table>
const headingMatch = html.match(/<h2[^>]*>\s*Task Execution\s*<\/h2>/i);
if (!headingMatch || headingMatch.index === undefined) {
throw new Error('Could not find Task Execution section in Gradle profile');
}
const sectionStart = headingMatch.index;
const tableStart = html.indexOf('<table', sectionStart);
const tableEnd = html.indexOf('</table>', tableStart);
if (tableStart === -1 || tableEnd === -1) {
throw new Error('Could not find task execution table in Gradle profile');
}
const tableHtml = html.slice(tableStart, tableEnd + '</table>'.length);
const parser = new XMLParser({
ignoreAttributes: true,
isArray: name => name === 'tr' || name === 'td',
trimValues: true,
});
const parsed = parser.parse(tableHtml);
const rows: any[] = parsed?.table?.tbody?.tr ?? parsed?.table?.tr ?? [];
const tasks: GradleProfileTask[] = [];
for (const row of rows) {
const cells: unknown[] = row?.td;
if (!cells || cells.length < 2) {
continue;
}
const taskPath = String(cells[0] ?? '').trim();
const durationStr = String(cells[1] ?? '').trim();
const result = String(cells[2] ?? '').trim() || 'executed';
if (!taskPath || !durationStr) {
continue;
}
const durationMs = parseDurationToMs(durationStr);
tasks.push({ path: taskPath, durationMs, result: result.toLowerCase() });
}
return tasks;
}
function parseDurationToMs(duration: string): number {
// Gradle profile durations can be like "1.234s", "0.045s", "12.5s"
const secondsMatch = duration.match(/^([\d.]+)s$/);
if (secondsMatch) {
return Math.round(parseFloat(secondsMatch[1]) * 1000);
}
return 0;
}
function formatSeconds(ms: number): string {
const s = ms / 1000;
if (s < 0.1) {
return `${ms}ms`;
}
return `${s.toFixed(1)}s`;
}
export function formatGradleProfileReport(tasks: GradleProfileTask[]): string {
// Filter out tasks under 1 second
const significantTasks = tasks.filter(t => t.durationMs >= 1000);
// Separate module totals from individual tasks
const moduleTotals = significantTasks.filter(t => t.result === '(total)');
const individualTasks = significantTasks.filter(t => t.result !== '(total)');
// Group individual tasks by their module prefix
const moduleChildren = new Map<string, GradleProfileTask[]>();
const orphanTasks: GradleProfileTask[] = [];
for (const task of individualTasks) {
const parent = moduleTotals.find(m => task.path.startsWith(m.path + ':'));
if (parent) {
const children = moduleChildren.get(parent.path) ?? [];
children.push(task);
moduleChildren.set(parent.path, children);
} else {
orphanTasks.push(task);
}
}
// Sort module totals by duration, sort children within each module
const sortedModules = [...moduleTotals].sort((a, b) => b.durationMs - a.durationMs);
for (const children of moduleChildren.values()) {
children.sort((a, b) => b.durationMs - a.durationMs);
}
orphanTasks.sort((a, b) => b.durationMs - a.durationMs);
// Build display rows: [displayName, task]
const rows: { displayName: string; task: GradleProfileTask }[] = [];
for (const mod of sortedModules) {
rows.push({ displayName: mod.path, task: mod });
const children = moduleChildren.get(mod.path) ?? [];
for (let i = 0; i < children.length; i++) {
const isLast = i === children.length - 1;
const prefix = isLast ? ' └─ ' : ' ├─ ';
const shortName = children[i].path.slice(mod.path.length + 1);
rows.push({ displayName: prefix + shortName, task: children[i] });
}
}
for (const task of orphanTasks) {
rows.push({ displayName: task.path, task });
}
// Compute totals from individual tasks only (avoid double-counting)
const totalMs = individualTasks.reduce((sum, t) => sum + t.durationMs, 0);
const maxMs = totalMs || 1;
const nameWidth = Math.max(4, ...rows.map(r => r.displayName.length)) + 2;
const barMaxWidth = 20;
const header =
'┌─' +
'─'.repeat(nameWidth) +
'─┬────────────┬──────────┬────────────┬─' +
'─'.repeat(barMaxWidth) +
'─┐';
const divider =
'├─' +
'─'.repeat(nameWidth) +
'─┼────────────┼──────────┼────────────┼─' +
'─'.repeat(barMaxWidth) +
'─┤';
const footer =
'└─' +
'─'.repeat(nameWidth) +
'─┴────────────┴──────────┴────────────┴─' +
'─'.repeat(barMaxWidth) +
'─┘';
const taskCount = individualTasks.length;
const cachedCount = individualTasks.filter(t => t.result !== 'executed').length;
const lines: string[] = [];
lines.push('Gradle Build — Task Execution Profile');
const cachedSuffix = cachedCount > 0 ? ` (${cachedCount} cached/up-to-date)` : '';
lines.push(`${taskCount} tasks${cachedSuffix}, total task time: ${formatSeconds(totalMs)}`);
lines.push('% Time = share of total task execution time');
lines.push('');
lines.push(header);
lines.push(
'│ ' +
'Task'.padEnd(nameWidth) +
' │ ' +
'Duration'.padStart(10) +
' │ ' +
'% Time'.padStart(8) +
' │ ' +
'Result'.padEnd(10) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(divider);
for (const row of rows) {
const pct = totalMs === 0 ? 0 : (row.task.durationMs / totalMs) * 100;
const barLength = Math.round((row.task.durationMs / maxMs) * barMaxWidth);
const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength);
const result = row.task.result === '(total)' ? 'total' : row.task.result;
lines.push(
'│ ' +
row.displayName.padEnd(nameWidth) +
' │ ' +
formatSeconds(row.task.durationMs).padStart(10) +
' │ ' +
`${pct.toFixed(1)}%`.padStart(8) +
' │ ' +
result.padEnd(10) +
' │ ' +
bar +
' │'
);
}
lines.push(divider);
lines.push(
'│ ' +
'TOTAL'.padEnd(nameWidth) +
' │ ' +
formatSeconds(totalMs).padStart(10) +
' │ ' +
'100.0%'.padStart(8) +
' │ ' +
' '.repeat(10) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(footer);
lines.push('');
return lines.join('\n');
}
+5 -14
View File
@@ -16,7 +16,6 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
import { installPods } from '../ios/pod';
import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { Sentry } from '../sentry';
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
@@ -174,25 +173,17 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds skip build performance analysis.');
return;
}
try {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
if (ctx.env.EXPERIMENTAL_EAS_XCACTIVITYLOG === '1') {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
await parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
logger: ctx.logger,
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
env: ctx.env,
});
} catch (err: any) {
Sentry.capture('Failed to parse xcactivitylog', err);
ctx.markBuildPhaseSkipped();
}
});
});
}
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
@@ -42,42 +42,38 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
autoSubmitArgs.push('--auto-submit');
}
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 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,
});
} catch (err: any) {
throw new Error('Failed to run eas build:internal');
}
const stdout = result.stdout.toString();
const parsed = JSON.parse(stdout);
return validateEasBuildInternalResult({
result: parsed,
oldJob: job,
});
}
export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
-8
View File
@@ -20,7 +20,6 @@ import fs from 'fs-extra';
import path from 'path';
import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError';
import { Datadog } from './datadog';
import { readAppConfig } from './utils/appConfig';
import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets';
import { PackageManager, resolvePackageManager } from './utils/packageManager';
@@ -356,13 +355,6 @@ export class BuildContext<TJob extends Job = Job> {
await this.collectAndUpdateEnvVariablesAsync();
this.reportBuildPhaseStats?.({ buildPhase: this.buildPhase, result, durationMs });
if (this.job.platform) {
Datadog.distribution('eas.build.phase_duration', durationMs, {
build_phase: this.buildPhase.toLowerCase(),
platform: this.job.platform,
result,
});
}
if (!doNotMarkEnd) {
this.logger.info(
-54
View File
@@ -1,54 +0,0 @@
import { Sentry } from './sentry';
import { turtleFetch } from './utils/turtleFetch';
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
turtleBuildId: string;
robotAccessToken: string;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingMetricUploads: Promise<unknown>[] = [];
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
setupOptions = opts;
},
distribution(name: string, value: number, tags: Record<string, string> = {}): void {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, turtleBuildId, robotAccessToken } = setupOptions;
const metrics = [
{
name,
type: 'distribution' as const,
value,
tags,
},
];
const uploadPromise = turtleFetch(
new URL(`turtle-builds/${turtleBuildId}/metrics`, expoApiV2BaseUrl).toString(),
'POST',
{
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}
).catch(err => {
Sentry.capture('Failed to report turtle build metric', err, {
extras: { metrics },
});
});
pendingMetricUploads.push(uploadPromise);
},
async flushAsync(): Promise<void> {
await Promise.allSettled(pendingMetricUploads);
},
};
-6
View File
@@ -20,10 +20,4 @@ export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
export type { GradleProfileTask } from './android/gradleProfile';
export * from './generic';
export { Datadog } from './datadog';
export { Sentry } from './sentry';
@@ -1,6 +1,5 @@
import { Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
import { Ios } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
@@ -9,10 +8,6 @@ import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { installPods } from '../pod';
jest.mock('@expo/eas-build-job', () => ({
...jest.requireActual('@expo/eas-build-job'),
getInstalledExpoPackageVersionAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
@@ -24,11 +19,9 @@ 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({
@@ -55,10 +48,7 @@ function makeIosBuildContext({
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: {
__API_SERVER_URL: 'http://api.expo.test',
...(cocoapodsCacheUrl ? { EAS_BUILD_COCOAPODS_CACHE_URL: cocoapodsCacheUrl } : {}),
},
env: { __API_SERVER_URL: 'http://api.expo.test' },
uploadArtifact: jest.fn(),
});
}
@@ -66,23 +56,32 @@ function makeIosBuildContext({
describe(installPods, () => {
beforeEach(() => {
vol.reset();
jest.mocked(getInstalledExpoPackageVersionAsync).mockImplementation(async ({ projectDir }) => {
return (await fs.readJson(path.join(projectDir, 'node_modules/expo/package.json'))).version;
(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);
});
(spawn as jest.Mock).mockResolvedValue(undefined);
});
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
const ctx = makeIosBuildContext({ expoVersion: '999.0.0' });
it('adds precompiled modules env var for Expo versions that support them', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.18' });
await installPods(ctx, {});
expect(getInstalledExpoPackageVersionAsync).toHaveBeenCalledWith({
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
projectDir: PROJECT_DIR,
});
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'],
@@ -90,38 +89,14 @@ describe(installPods, () => {
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(
(spawn as jest.Mock).mock.calls[1][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
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.'
'Detected expo=55.0.18; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nPrecompiled modules pod install environment is configured.'
);
});
@@ -130,11 +105,9 @@ describe(installPods, () => {
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
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>=/
)
'Detected expo=55.0.17; not enabling precompiled modules use because precompiled modules require expo>=55.0.18.'
);
});
@@ -143,7 +116,7 @@ describe(installPods, () => {
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
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.'
);
@@ -154,7 +127,7 @@ describe(installPods, () => {
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
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.'
@@ -163,7 +136,7 @@ describe(installPods, () => {
it('does not add precompiled modules env vars without the builder flag', async () => {
const ctx = makeIosBuildContext({
expoVersion: '999.0.0',
expoVersion: '55.0.18',
usePrecompiledModules: false,
});
@@ -171,9 +144,6 @@ describe(installPods, () => {
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();
});
});
+29 -19
View File
@@ -1,13 +1,13 @@
import { Env, Ios, getInstalledExpoPackageVersionAsync } 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/';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.18';
// const PRECOMPILED_MODULES_BASE_URL = 'https://storage.googleapis.com/eas-build-precompiled-modules/';
export async function installPods<TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
@@ -52,10 +52,7 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
let expoPackageVersion: string;
try {
expoPackageVersion = await getInstalledExpoPackageVersionAsync({
env: ctx.env,
projectDir: ctx.getReactNativeProjectDirectory(),
});
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
} catch (err) {
ctx.logger.info(
{ err },
@@ -79,9 +76,10 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
return {};
}
// Start rollout with Expo precompiled modules only. Add third-party modules after this is stable.
const env: Env = {
EXPO_USE_PRECOMPILED_MODULES: '1',
EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(ctx),
// EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(),
};
ctx.logger.info(
@@ -95,14 +93,26 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
return env;
}
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}`
);
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}`
// );
// }
-81
View File
@@ -1,81 +0,0 @@
import * as sentryNode from '@sentry/node';
type CaptureOptions = {
tags?: Record<string, string>;
extras?: Record<string, unknown>;
level?: sentryNode.SeverityLevel;
};
type SentryAPI = {
setup(opts: { dsn: string | null; environment: string; tags?: Record<string, string> }): void;
capture(msg: string, options?: CaptureOptions): void;
capture(msg: string, err: Error | undefined, options?: CaptureOptions): void;
capture(err: Error, options?: CaptureOptions): void;
flush(timeoutMs?: number): Promise<boolean>;
};
export const Sentry: SentryAPI = {
setup({ dsn, environment, tags }) {
if (dsn) {
sentryNode.init({
dsn,
environment,
...(tags ? { initialScope: { tags } } : {}),
});
}
},
capture(arg1: string | Error, arg2?: Error | CaptureOptions, arg3?: CaptureOptions): void {
let msg: string | undefined;
let err: Error | undefined;
let options: CaptureOptions = {};
if (arg1 instanceof Error) {
err = arg1;
options = (arg2 as CaptureOptions | undefined) ?? {};
} else {
msg = arg1;
if (arg3 !== undefined) {
// 3-arg form: arg2 unambiguously means err (null/undefined → no err)
options = arg3;
if (arg2 !== undefined && arg2 !== null) {
err = arg2 instanceof Error ? arg2 : new Error(String(arg2));
}
} else if (arg2 instanceof Error) {
err = arg2;
} else if (arg2 !== undefined && arg2 !== null) {
// 2-arg form: non-Error object → options; primitive → coerced err
if (typeof arg2 === 'object') {
options = arg2;
} else {
err = new Error(String(arg2));
}
}
}
sentryNode.withScope(scope => {
if (options.tags) {
scope.setTags(options.tags);
}
if (options.extras) {
scope.setExtras(options.extras);
}
if (options.level) {
scope.setLevel(options.level);
}
if (err) {
if (msg && err.message !== msg) {
scope.setExtra('message', msg);
}
sentryNode.captureException(err);
} else if (msg) {
sentryNode.captureMessage(msg);
}
});
},
flush(timeoutMs: number = 2000): Promise<boolean> {
return sentryNode.flush(timeoutMs);
},
};
@@ -43,7 +43,6 @@ import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/st
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';
@@ -80,11 +79,10 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartAgentDeviceRemoteSessionBuildFunction(),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
createStartServeSimRemoteSessionBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallPodsBuildFunction(),
@@ -1,302 +0,0 @@
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,18 +1,65 @@
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs/promises';
import { vol } from 'memfs';
import {
copyLatestAttemptXml,
mergeJUnitReports,
parseFailedFlowsFromJUnit,
parseFlowMetadata,
parseJUnitTestCases,
parseMaestroResults,
} from '../maestroResultParser';
describe(parseMaestroResults, () => {
it('parses JUnit results and enriches with name→path map', async () => {
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 () => {
vol.fromJSON({
// JUnit XML (primary data)
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -24,15 +71,18 @@ 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',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
@@ -60,7 +110,7 @@ describe(parseMaestroResults, () => {
);
});
it('uses flow name as fallback path when nameToPath is null', async () => {
it('calculates retryCount from timestamp directory occurrences', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
@@ -70,33 +120,49 @@ 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', null);
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');
expect(results[0].path).toBe('home');
expect(results[0].retryCount).toBe(0);
});
it('uses flow name as fallback path when name not in map', async () => {
it('returns empty array when no JUnit files 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'),
'/junit/.gitkeep': '',
'/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', 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']]));
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results).toEqual([]);
});
@@ -115,13 +181,44 @@ 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', new Map([['home', '.maestro/home.yml']]));
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
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
@@ -144,11 +241,20 @@ 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', new Map([['login', '.maestro/login.yml']]));
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
// Should return 2 results — one per attempt; retryCount from filename
// Should return 2 results — one per attempt
expect(results).toHaveLength(2);
expect(results).toEqual([
expect.objectContaining({
@@ -194,15 +300,25 @@ 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',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
// 4 results: 2 flows × 2 attempts
expect(results).toHaveLength(4);
@@ -216,6 +332,7 @@ 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>',
@@ -229,6 +346,7 @@ 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>',
@@ -240,15 +358,25 @@ 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',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
expect(results).toHaveLength(4);
expect(results).toEqual([
@@ -259,8 +387,52 @@ describe(parseMaestroResults, () => {
]);
});
it('handles reuse_devices=false (separate junit_report_directory, per-flow files)', async () => {
it('backward compat: reuse_devices=true with retries but old single JUnit file', 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>',
@@ -279,14 +451,21 @@ 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',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
'/tests',
'/root/project'
);
expect(results).toHaveLength(2);
expect(results).toEqual(
@@ -296,6 +475,51 @@ 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, () => {
@@ -582,294 +806,6 @@ describe(parseJUnitTestCases, () => {
});
});
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({
@@ -3,7 +3,6 @@ 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';
@@ -50,10 +49,10 @@ function createStep(
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.
// Default-stub copyLatestAttemptXml 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();
jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
});
it('exports a factory that returns a BuildFunction instance', () => {
@@ -142,34 +141,10 @@ describe('createMaestroTestsBuildFunction', () => {
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 () => {
it('retries all flows on failure', 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'],
@@ -179,7 +154,6 @@ describe('createMaestroTestsBuildFunction', () => {
});
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'])
);
@@ -188,150 +162,8 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
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 () => {
it('writes final report via copyLatestAttemptXml 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({
@@ -341,15 +173,19 @@ describe('createMaestroTestsBuildFunction', () => {
});
await step.executeAsync();
expect(copySpy).toHaveBeenCalled();
expect(copySpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
});
it('swallows merge and copy-latest failures when maestro succeeded', async () => {
it('swallows copyLatestAttemptXml failure 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' }));
@@ -394,8 +230,6 @@ describe('createMaestroTestsBuildFunction', () => {
pid: 1,
})
);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/a.yaml']);
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
@@ -464,9 +298,9 @@ describe('createMaestroTestsBuildFunction', () => {
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.
// populated, per-attempt XMLs written into junit-reports/, copy invoked.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
@@ -475,16 +309,13 @@ describe('createMaestroTestsBuildFunction', () => {
});
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(copySpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
@@ -72,7 +72,7 @@ describe(createReportMaestroTestResultsFunction, () => {
it('parses JUnit results and calls GraphQL mutation', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/root/project/.maestro/home.yml': '',
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
@@ -83,11 +83,7 @@ describe(createReportMaestroTestResultsFunction, () => {
},
});
await createStep({
callInputs: {
flow_path: ['.maestro/home.yml'],
},
}).executeAsync();
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
@@ -1,190 +0,0 @@
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,6 +1,7 @@
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
export interface MaestroFlowResult {
name: string;
@@ -13,9 +14,17 @@ 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}$/;
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
const ATTEMPT_PATTERN = /attempt-(\d+)/;
export function extractFlowKey(filename: string, prefix: string): string | null {
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
return match?.[1] ?? null;
}
export interface JUnitTestCaseResult {
name: string;
status: 'passed' | 'failed';
@@ -32,9 +41,11 @@ const xmlParser = new XMLParser({
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
// Internal helper — not exported. Parses a single JUnit XML file.
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
const results: JUnitTestCaseResult[] = [];
try {
const content = await fs.readFile(filePath, 'utf-8');
const parsed = xmlParser.parse(content);
const testsuites = parsed?.testsuites?.testsuite;
@@ -98,20 +109,11 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
}
}
} catch {
// Malformed XML — return whatever we collected before the parser bailed.
// Skip malformed XML files
}
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 {
@@ -125,16 +127,49 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
return [];
}
const perFile = await Promise.all(
xmlFiles.map(f => parseJUnitFile(path.join(junitDirectory, f)))
);
return perFile.flat();
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;
}
}
export async function parseMaestroResults(
junitDirectory: string,
nameToPath: Map<string, string> | null
testsDirectory: string,
projectRoot: string
): Promise<MaestroFlowResult[]> {
// 1. Parse JUnit XML files, tracking which file each result came from
let junitEntries: string[];
try {
junitEntries = await fs.readdir(junitDirectory);
@@ -163,8 +198,51 @@ 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[] = [];
// Group results by flow name
const resultsByName = new Map<string, JUnitResultWithSource[]>();
for (const entry of junitResultsWithSource) {
const group = resultsByName.get(entry.result.name) ?? [];
@@ -173,229 +251,83 @@ export async function parseMaestroResults(
}
for (const [flowName, flowEntries] of resultsByName) {
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);
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);
for (const { result, attemptIndex } of sorted) {
results.push({
name: flowName,
path: flowPath,
path: relativePath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount: attemptIndex,
retryCount,
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;
}
/**
* 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;
async function relativizePathAsync(flowFilePath: string, projectRoot: string): Promise<string> {
if (!path.isAbsolute(flowFilePath)) {
return flowFilePath;
}
// Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
let resolvedRoot = projectRoot;
let resolvedFlow = flowFilePath;
try {
content = await fs.readFile(args.junitFile, 'utf-8');
} catch {
return null;
}
if (XMLValidator.validate(content) !== true) {
return null;
}
resolvedRoot = await fs.realpath(projectRoot);
} catch {}
try {
resolvedFlow = await fs.realpath(flowFilePath);
} catch {}
const testcases = parseJUnitContent(content);
if (testcases.length === 0) {
return null;
const relative = path.relative(resolvedRoot, resolvedFlow);
if (relative.startsWith('..')) {
return flowFilePath;
}
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);
return relative;
}
/**
* 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.
* After the maestro retry loop completes, this produces a single canonical
* JUnit report at final_report_path matching the bash step's "cp latest
* attempt" semantics.
*
* Throws if sourceDir contains no *.xml files or if the copy fails.
*/
@@ -410,8 +342,7 @@ export async function copyLatestAttemptXml(args: {
}
// 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).
// treated as attempt 0. Ties are broken by sorted filename — later wins.
let winner = xmlFiles[0];
let winnerAttempt = (() => {
const m = winner.match(ATTEMPT_PATTERN);
@@ -11,12 +11,7 @@ import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
copyLatestAttemptXml,
mergeJUnitReports,
parseFailedFlowsFromJUnit,
} from './maestroResultParser';
import { copyLatestAttemptXml } from './maestroResultParser';
import { sleepAsync } from '../../utils/retry';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
@@ -35,19 +30,6 @@ function parseInput<S extends z.ZodTypeAny>(
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.
@@ -97,12 +79,6 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
defaultValue: 0,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'retry_failed_only',
required: false,
defaultValue: true,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
BuildStepInput.createProvider({
id: 'shards',
required: false,
@@ -192,7 +168,6 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
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 });
@@ -200,26 +175,13 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
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'
@@ -228,25 +190,19 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
? 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,
});
await spawn(
'maestro',
buildMaestroArgs({
flow_path: flowPaths,
outputPath,
output_format: outputFormat,
shards,
include_tags: includeTags,
exclude_tags: excludeTags,
}),
{ cwd: stepCtx.workingDirectory, env: spawnEnv, logger, signal }
);
lastAttemptExitCode = 0;
} catch (err: any) {
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
@@ -263,56 +219,26 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
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');
}
logger.info('Test failed, retrying...');
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.
// Copy the latest attempt's JUnit to the final report path so downstream
// upload/report steps have a single canonical file.
if (finalReportPath !== undefined) {
try {
await mergeJUnitReports({
await copyLatestAttemptXml({
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}`
);
}
} catch (copyErr: any) {
// Swallow: a copy failure usually means maestro itself failed early
// (bad YAML wrote no *.xml). Throwing SystemError here 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}`
);
}
}
@@ -320,6 +246,7 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
// or throw (infra). A non-null non-zero status means the user's tests
// failed every attempt.
if (lastAttemptExitCode !== 0) {
const totalAttempts = retries + 1;
throw new UserError(
'ERR_MAESTRO_TESTS_FAILED',
`Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`
@@ -45,7 +45,6 @@ export function parseXcactivitylogFunction(): BuildFunction {
xclogparserVersion: version,
logger: stepCtx.logger,
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
env,
});
},
});
@@ -1,13 +1,9 @@
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 {
@@ -42,11 +38,6 @@ 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;
@@ -60,26 +51,14 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
logger.info('No JUnit directory provided, skipping test results report');
return;
}
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).'
);
}
}
const testsDirectory = inputs.tests_directory.value as string;
try {
const flowResults = await parseMaestroResults(junitDirectory, nameToPath);
const flowResults = await parseMaestroResults(
junitDirectory,
testsDirectory,
stepsCtx.workingDirectory
);
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, turtleFetch } from '../../utils/turtleFetch';
import { TurtleFetchError } from '../../utils/turtleFetch';
export function createRestoreBuildCacheFunction(): BuildFunction {
return new BuildFunction({
@@ -277,27 +277,9 @@ export async function restoreGradleCacheAsync({
logger,
});
const hitType = matchedKey === cacheKey ? 'direct_hit' : 'prefix_match';
logger.info(
`Gradle cache restored to ${gradleCachesPath} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
`Gradle cache restored to ${gradleCachesPath} ${matchedKey === cacheKey ? '(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');
@@ -1,38 +1,23 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import childProcess from 'node:child_process';
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,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
} from '../utils/remoteDeviceRunSession';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const RUN_DIR = '/tmp/agent-device';
const DAEMON_LOG = path.join(RUN_DIR, 'daemon.log');
const TUNNEL_LOG = path.join(RUN_DIR, 'cloudflared.log');
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 {
export function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_agent_device_remote_session',
@@ -45,29 +30,21 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
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);
fn: async ({ logger }, { inputs, 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}).`
);
logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}).`);
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(`Preparing runtime directory at ${RUN_DIR}.`);
await fs.promises.mkdir(RUN_DIR, { recursive: true });
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,
});
await ensureCloudflaredInstalledAsync({ env, logger });
logger.info('Ensuring bun is installed.');
await ensureBunInstalledAsync({ env, logger });
logger.info(
packageVersion
@@ -83,12 +60,13 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
logger,
});
logger.info('Launching agent-device daemon.');
spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
logger.info(`Launching agent-device daemon (log file: ${DAEMON_LOG}).`);
await spawnDetachedAsync({
command: 'bun run src/daemon.ts',
cwd: SRC_DIR,
logFile: DAEMON_LOG,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
logger,
});
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
@@ -100,45 +78,28 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
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}`],
logger.info(
`Starting cloudflared tunnel to http://localhost:${daemonPort} (log file: ${TUNNEL_LOG}).`
);
await spawnDetachedAsync({
command: `cloudflared tunnel --url "http://localhost:${daemonPort}"`,
logFile: TUNNEL_LOG,
env,
logger,
});
logger.info('Waiting for a public tunnel URL.');
const agentDeviceRemoteSessionUrl = await waitForMatchInOutputAsync({
process: cloudflared,
const tunnelUrl = await waitForMatchInLogAsync({
logFile: TUNNEL_LOG,
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'cloudflared tunnel',
});
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
logger.info(`Tunnel is ready at ${tunnelUrl}.`);
// serve-sim is iOS-only — only launch it (and report a webPreviewUrl)
// on Darwin. Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const { previewUrl } = await startServeSimWithTunnelAsync({
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
...(webPreviewUrl ? { webPreviewUrl } : {}),
},
logger,
});
logger.info('Emitting agent-device credentials for the CLI to pick up:');
logger.info(`export AGENT_DEVICE_DAEMON_BASE_URL="${tunnelUrl}"`);
logger.info(`export AGENT_DEVICE_DAEMON_AUTH_TOKEN="${daemonToken}"`);
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
@@ -149,59 +110,39 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
}
async function ensureCloudflaredInstalledAsync({
runtimePlatform,
env,
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
env: NodeJS.ProcessEnv;
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;
}): Promise<void> {
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
}
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,
async function ensureBunInstalledAsync({
env,
logger,
}: {
command: string;
env: BuildStepEnv;
}): Promise<boolean> {
try {
await spawn('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
return true;
} catch {
return false;
}
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await ensureBrewPackageInstalledAsync({ name: 'bun', env, logger });
}
async function ensureBrewPackageInstalledAsync({
name,
env,
logger,
}: {
name: string;
env: NodeJS.ProcessEnv;
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 }
);
}
async function cloneAgentDeviceAsync({
@@ -210,7 +151,7 @@ async function cloneAgentDeviceAsync({
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
@@ -220,27 +161,63 @@ async function cloneAgentDeviceAsync({
});
}
async function waitForMatchInOutputAsync({
process,
async function spawnDetachedAsync({
command,
cwd,
logFile,
env,
logger,
}: {
command: string;
cwd?: string;
logFile: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
// Launch the process fully detached so this function returns immediately and
// the grandchild survives the step. Stdio goes to a log file so the daemon
// output can be polled, and we unref so Node doesn't wait on it.
const fd = fs.openSync(logFile, 'a');
try {
const child = childProcess.spawn('bash', ['-c', command], {
cwd,
env,
detached: true,
stdio: ['ignore', fd, fd],
});
if (!child.pid) {
throw new Error(`Failed to spawn detached process: ${command}`);
}
child.unref();
logger.info(`Started detached process (pid ${child.pid}).`);
} finally {
fs.closeSync(fd);
}
}
async function waitForMatchInLogAsync({
logFile,
pattern,
timeoutMs,
description,
}: {
process: DetachedProcessHandle;
logFile: string;
pattern: RegExp;
timeoutMs: number;
description: string;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const match = pattern.exec(process.getOutput());
const content = await readFileOrEmptyAsync(logFile);
const match = pattern.exec(content);
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>'}`
const tail = await readFileOrEmptyAsync(logFile);
throw new Error(
`Timed out waiting for ${description} to start. Last log contents:\n${tail || '<empty>'}`
);
}
@@ -263,7 +240,15 @@ async function waitForFileAsync({
}
await sleepAsync(1_000);
}
throw new SystemError(`Timed out waiting for ${description} to write ${filePath}.`);
throw new Error(`Timed out waiting for ${description} to write ${filePath}.`);
}
async function readFileOrEmptyAsync(filePath: string): Promise<string> {
try {
return await fs.promises.readFile(filePath, 'utf8');
} catch {
return '';
}
}
function readDaemonInfo(filePath: string): { port: number; token: string } {
@@ -275,9 +260,7 @@ function readDaemonInfo(filePath: string): { port: number; token: string } {
typeof (parsed as { httpPort: unknown }).httpPort !== 'number' ||
typeof (parsed as { token: unknown }).token !== 'string'
) {
throw new SystemError(
`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`
);
throw new Error(`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`);
}
const { httpPort, token } = parsed as { httpPort: number; token: string };
return { port: httpPort, token };
@@ -1,56 +0,0 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
ensureBrewPackageInstalledAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
} from '../utils/remoteDeviceRunSession';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
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 });
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync({
env,
logger,
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>(() => {});
},
});
}
@@ -23,27 +23,18 @@ export async function runGradleCommand({
logger.info(`Running 'gradlew ${gradleCommand} ${verboseFlag}' in ${androidDir}`);
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
const spawnPromise = spawn(
'bash',
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
{
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: {
...env,
...extraEnv,
LC_ALL: 'C.UTF-8',
},
}
);
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: { ...env, ...extraEnv, LC_ALL: 'C.UTF-8' },
});
if (env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
}
@@ -4,7 +4,6 @@ import fs from 'fs-extra';
import path from 'path';
import { createMockLogger } from '../../../../__tests__/utils/logger';
import { Sentry } from '../../../../sentry';
import {
buildTargetMetrics,
isCompileStep,
@@ -18,13 +17,6 @@ jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../../sentry', () => ({
Sentry: {
setup: jest.fn(),
capture: jest.fn(),
flush: jest.fn(),
},
}));
// Minimal fixture: two modules with compile steps
const FIXTURE_TWO_MODULES = {
@@ -154,49 +146,9 @@ 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);
const TEST_ENV = { PATH: '/custom/bin:/usr/bin' };
function mockFilesystem({
tempDir = '/tmp/xclogparser-123',
jsonOutput = JSON.stringify(FIXTURE_TWO_MODULES),
@@ -219,45 +171,38 @@ describe('parseAndReportXcactivitylog', () => {
beforeEach(() => {
mockedDownloadFile.mockReset();
mockedSpawn.mockReset();
jest.mocked(Sentry.capture).mockClear();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('downloads xclogparser from GCS and logs resolution + final report when no binary is preinstalled', async () => {
it('downloads xclogparser from GCS and logs only the final report on success', async () => {
const logger = createMockLogger();
const { tempDir, reportPath } = mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(1, 'xclogparser', ['version'], {
stdio: 'pipe',
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
'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'),
'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'),
{ retry: 3, timeout: 20_000 }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
1,
'unzip',
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'), '-d', tempDir],
{ stdio: 'pipe', env: TEST_ENV }
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'), '-d', tempDir],
{ stdio: 'pipe' }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
3,
2,
path.join(tempDir, 'xclogparser'),
[
'parse',
@@ -270,16 +215,15 @@ describe('parseAndReportXcactivitylog', () => {
'--output',
reportPath,
],
{ stdio: 'pipe', env: TEST_ENV }
{ stdio: 'pipe' }
);
expect(fs.readFile).toHaveBeenCalledWith(reportPath, 'utf8');
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Xcode Build — Compile Metrics by Module')
);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('tries the proxy URL first and falls back to the direct GCS URL', async () => {
@@ -289,7 +233,6 @@ describe('parseAndReportXcactivitylog', () => {
mockedDownloadFile
.mockRejectedValueOnce(new Error('proxy failed'))
.mockResolvedValueOnce(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
@@ -297,19 +240,18 @@ describe('parseAndReportXcactivitylog', () => {
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
1,
'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',
'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',
{ retry: 3, timeout: 20_000 }
);
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
2,
'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',
'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',
{ retry: 3, timeout: 20_000 }
);
expect(logger.debug).toHaveBeenCalledWith(
@@ -325,7 +267,6 @@ describe('parseAndReportXcactivitylog', () => {
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('xclogparser not found'))
.mockRejectedValueOnce(new Error('invalid zip'))
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any);
@@ -335,19 +276,18 @@ describe('parseAndReportXcactivitylog', () => {
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
1,
'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',
'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',
{ retry: 3, timeout: 20_000 }
);
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
2,
'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',
'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',
{ retry: 3, timeout: 20_000 }
);
expect(logger.debug).toHaveBeenCalledWith(
@@ -360,12 +300,11 @@ describe('parseAndReportXcactivitylog', () => {
expect(logger.warn).not.toHaveBeenCalled();
});
it('reports phase "parsing_xclogparser_output" when the JSON output is invalid', async () => {
it('skips reporting and logs debug when the JSON output is invalid', async () => {
const logger = createMockLogger();
mockFilesystem({ jsonOutput: '{not valid json' });
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -373,20 +312,18 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "parsing_xclogparser_output"',
expect.any(Error),
{ tags: { phase: 'parsing_xclogparser_output' } }
expect(logger.info).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to analyze build performance; continuing without a report'
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('reports phase "parsing_xclogparser_output" when the JSON output does not match the expected schema', async () => {
it('skips reporting and logs debug when the JSON output does not match the expected schema', async () => {
const logger = createMockLogger();
mockFilesystem({
jsonOutput: JSON.stringify({
@@ -396,7 +333,6 @@ describe('parseAndReportXcactivitylog', () => {
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -404,71 +340,15 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "parsing_xclogparser_output"',
expect.any(Error),
{ tags: { phase: 'parsing_xclogparser_output' } }
expect(logger.info).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to analyze build performance; continuing without a report'
);
});
it('reports phase "running_xclogparser" when the xclogparser invocation fails', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('xclogparser not found')) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
.mockRejectedValueOnce(new Error('spawn xclogparser ENOENT')); // xclogparser run
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "running_xclogparser"',
expect.objectContaining({ message: 'spawn xclogparser ENOENT' }),
{ tags: { phase: 'running_xclogparser' } }
);
});
it('reports phase "downloading_xclogparser" when the direct download fails', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockRejectedValue(new Error('network unreachable'));
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "downloading_xclogparser"',
expect.objectContaining({ message: 'network unreachable' }),
{ tags: { phase: 'downloading_xclogparser' } }
);
});
it('swallows cleanup failures without emitting additional logs', async () => {
@@ -479,7 +359,6 @@ describe('parseAndReportXcactivitylog', () => {
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -487,171 +366,13 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
describe('xclogparser version resolution', () => {
it('uses preinstalled binary when no version is requested', async () => {
const logger = createMockLogger();
const { reportPath } = mockFilesystem();
mockedSpawn
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.50\n', stderr: '' } as any) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn).not.toHaveBeenCalledWith('unzip', expect.any(Array), expect.any(Object));
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'xclogparser',
[
'parse',
'--derived_data',
'/tmp/derived-data',
'--workspace',
'/tmp/workspace',
'--reporter',
'json',
'--output',
reportPath,
],
{ stdio: 'pipe', env: TEST_ENV }
);
expect(logger.info).toHaveBeenCalledWith('Using preinstalled xclogparser v0.2.50.');
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('downloaded'));
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('downloads default version when no version requested and no preinstall found', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
});
it('uses preinstalled binary when requested version matches', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedSpawn
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.50\n', stderr: '' } as any) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
xclogparserVersion: 'v0.2.50',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Using preinstalled xclogparser v0.2.50.');
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('downloaded'));
});
it('downloads requested version when it does not match the preinstalled binary', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.48\n', stderr: '' } as any) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
xclogparserVersion: 'v0.2.50',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.50.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.50.');
});
it('downloads requested version when no preinstall is found', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
xclogparserVersion: 'v0.2.50',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.50.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.50.');
});
it('falls back to download when the version output cannot be parsed', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockResolvedValueOnce({ stdout: 'something unexpected', stderr: '' } as any) // version detect
.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
});
});
});
describe('isCompileStep', () => {
@@ -757,38 +478,17 @@ 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, legend, and module rows', () => {
it('produces table with header 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('% 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');
expect(report).toContain('% Task');
});
it('does not include modules below threshold', () => {
@@ -1,88 +1,58 @@
import downloadFile from '@expo/downloader';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { z } from 'zod';
import { Sentry } from '../../../sentry';
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.47';
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.46';
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
/**
* Never throws — best-effort observability that does not affect build status.
* Failures route to Sentry via `Sentry.capture` for engineering triage;
* users see only a generic skip message.
* Download xclogparser, parse xcactivitylog from derived data, and log a
* compile metrics report. Never throws — all errors are logged at debug level.
*
* Can be called from both the step-based flow (BuildFunction) and the
* traditional builder flow (runBuildPhase).
*/
export async function parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
xclogparserVersion,
xclogparserVersion = DEFAULT_XCLOGPARSER_VERSION,
logger,
proxyBaseUrl,
env,
}: {
derivedDataPath: string;
workspacePath: string;
xclogparserVersion?: string;
logger: bunyan;
proxyBaseUrl?: string;
env: BuildStepEnv;
}): Promise<void> {
let tempDir: string | undefined;
let phase = 'creating_temp_directory';
try {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xclogparser-'));
phase = 'resolving_xclogparser';
const preinstalledVersion = await detectPreinstalledXclogparserVersion(env);
let binaryPath: string;
let resolvedVersion: string;
if (
preinstalledVersion &&
(!xclogparserVersion || preinstalledVersion === xclogparserVersion)
) {
binaryPath = 'xclogparser';
resolvedVersion = preinstalledVersion;
logger.info(`Using preinstalled xclogparser ${resolvedVersion}.`);
} else {
phase = 'downloading_xclogparser';
resolvedVersion = xclogparserVersion ?? DEFAULT_XCLOGPARSER_VERSION;
binaryPath = await downloadXclogparser({
tempDir,
version: resolvedVersion,
logger,
proxyBaseUrl,
env,
});
logger.info(`Using downloaded xclogparser ${resolvedVersion}.`);
}
phase = 'running_xclogparser';
const xclogparserPath = await downloadXclogparser(
tempDir,
xclogparserVersion,
logger,
proxyBaseUrl
);
const jsonOutputPath = await runXclogparser({
binaryPath,
binaryPath: xclogparserPath,
derivedDataPath,
workspacePath,
outputDir: tempDir,
env,
});
phase = 'parsing_xclogparser_output';
const data = XcactivitylogDataSchemaZ.parse(
JSON.parse(await fs.readFile(jsonOutputPath, 'utf8'))
);
logger.info(formatReport(data));
} catch (err: any) {
logger.info('Build performance analysis skipped.');
const msg = `Build performance analysis failed during "${phase}"`;
Sentry.capture(msg, err, { tags: { phase } });
} catch (err: unknown) {
logger.debug({ err }, 'Failed to analyze build performance; continuing without a report');
} finally {
if (tempDir) {
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
@@ -90,28 +60,12 @@ export async function parseAndReportXcactivitylog({
}
}
async function detectPreinstalledXclogparserVersion(env: BuildStepEnv): Promise<string | null> {
const result = await asyncResult(spawn('xclogparser', ['version'], { stdio: 'pipe', env }));
if (!result.ok) {
return null;
}
const match = result.value.stdout.match(/(\d+\.\d+\.\d+)/);
return match ? `v${match[1]}` : null;
}
async function downloadXclogparser({
tempDir,
version,
logger,
proxyBaseUrl,
env,
}: {
tempDir: string;
version: string;
logger: bunyan;
proxyBaseUrl?: string;
env: BuildStepEnv;
}): Promise<string> {
async function downloadXclogparser(
tempDir: string,
version: string,
logger: bunyan,
proxyBaseUrl?: string
): Promise<string> {
const zipName = getXclogparserZipName(version);
const zipPath = path.join(tempDir, zipName);
const directUrl = `${XCLOGPARSER_DOWNLOAD_URL}/${zipName}`;
@@ -119,7 +73,7 @@ async function downloadXclogparser({
if (proxiedUrl) {
const proxiedDownloadResult = await asyncResult(
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl, env })
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl })
);
if (!proxiedDownloadResult.ok) {
logger.debug(
@@ -132,35 +86,31 @@ async function downloadXclogparser({
}
}
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl, env });
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl });
}
async function downloadAndUnpackXclogparser({
tempDir,
zipPath,
sourceUrl,
env,
}: {
tempDir: string;
zipPath: string;
sourceUrl: string;
env: BuildStepEnv;
}): Promise<string> {
await downloadFile(sourceUrl, zipPath, { retry: 3, timeout: XCLOGPARSER_DOWNLOAD_TIMEOUT_MS });
return await unpackXclogparser({ tempDir, zipPath, env });
return await unpackXclogparser({ tempDir, zipPath });
}
async function unpackXclogparser({
tempDir,
zipPath,
env,
}: {
tempDir: string;
zipPath: string;
env: BuildStepEnv;
}): Promise<string> {
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe', env });
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe' });
const binaryPath = path.join(tempDir, 'xclogparser');
await fs.chmod(binaryPath, 0o755);
@@ -183,13 +133,11 @@ async function runXclogparser({
derivedDataPath,
workspacePath,
outputDir,
env,
}: {
binaryPath: string;
derivedDataPath: string;
workspacePath: string;
outputDir: string;
env: BuildStepEnv;
}): Promise<string> {
const outputPath = path.join(outputDir, XCLOGPARSER_OUTPUT_FILENAME);
@@ -206,7 +154,7 @@ async function runXclogparser({
'--output',
outputPath,
],
{ stdio: 'pipe', env }
{ stdio: 'pipe' }
);
return outputPath;
@@ -235,7 +183,6 @@ 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;
}
@@ -411,22 +358,19 @@ export function formatReport(data: XcactivitylogData): string {
lines.push(
`Schema: ${typeof data.schema === 'string' ? data.schema : (data.schema?.name ?? 'unknown')}`
);
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('% Task = share of total Compile Task Seconds');
lines.push('Wall = first compile start to last compile end');
lines.push('');
lines.push(header);
lines.push(
'│ ' +
'Module'.padEnd(nameWidth) +
' │ ' +
'Sum'.padStart(taskWidth) +
'Task'.padStart(taskWidth) +
' │ ' +
'% Sum'.padStart(pctWidth) +
'% Task'.padStart(pctWidth) +
' │ ' +
'Active'.padStart(wallWidth) +
'Wall'.padStart(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
@@ -446,7 +390,7 @@ export function formatReport(data: XcactivitylogData): string {
' │ ' +
`${pct.toFixed(1)}%`.padStart(pctWidth) +
' │ ' +
formatSeconds(result.activeWallTime).padStart(wallWidth) +
formatSeconds(result.wallSpan).padStart(wallWidth) +
' │ ' +
bar +
' │'
@@ -462,7 +406,7 @@ export function formatReport(data: XcactivitylogData): string {
' │ ' +
'100.0%'.padStart(pctWidth) +
' │ ' +
' '.repeat(wallWidth) +
'n/a'.padStart(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
@@ -1,159 +0,0 @@
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';
import { sleepAsync } from '../../utils/retry';
const TRYCLOUDFLARE_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
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 };
}
export async function startServeSimWithTunnelAsync({
env,
logger,
timeoutMs,
}: {
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
}): Promise<{ previewUrl: string; streamUrl: string }> {
logger.info('Launching serve-sim with tunnel.');
const serveSim = spawnDetached({
command: 'npx',
args: [
'serve-sim-szdziedzic@latest',
'--tunnel',
'--tunnel-protocol',
'quic',
'--stream-max-dimension',
'1280',
'--stream-quality',
'0.55',
],
env,
});
logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
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;
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "19.0.0",
"version": "18.9.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "19.0.0"
"@expo/steps": "18.9.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "19.0.0",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "19.0.0",
"version": "18.5.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "19.0.0",
"version": "18.8.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "19.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "19.0.0",
"@expo/logger": "18.5.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -266,9 +266,6 @@ describe('Android.JobSchema', () => {
customBuildConfig: {
path: 'production.android.yml',
},
outputs: {
android_build_type: 'apk',
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
@@ -343,28 +340,6 @@ 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,9 +77,6 @@ describe('Ios.JobSchema', () => {
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
outputs: {
resign_job: 'true',
},
};
const { value, error } = Ios.JobSchema.validate(genericJob, joiOptions);
@@ -183,9 +180,6 @@ describe('Ios.JobSchema', () => {
customBuildConfig: {
path: 'production.ios.yml',
},
outputs: {
ios_build_type: 'simulator',
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
@@ -260,30 +254,6 @@ 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: {
@@ -1,77 +0,0 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { errors } from '..';
import { getInstalledExpoPackageVersionAsync } from '../projectPackage';
describe(getInstalledExpoPackageVersionAsync, () => {
it('returns the installed expo package version resolved by project node', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
await fs.writeFile(
path.join(projectDir, 'node_modules/expo/package.json'),
JSON.stringify({ version: '55.0.17' })
);
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).resolves.toBe('55.0.17');
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
it('rejects when expo cannot be resolved from the project', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
});
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toBeInstanceOf(
errors.UserError
);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
it('rejects when expo package.json cannot be read', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
await fs.writeFile(
path.join(projectDir, 'node_modules/expo/package.json'),
JSON.stringify({ version: '55.0.17' })
);
jest.spyOn(fs, 'readFile').mockRejectedValueOnce(new Error('read failed'));
const promise = getInstalledExpoPackageVersionAsync({ projectDir });
await expect(promise).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_READ_FAILED',
});
await expect(promise).rejects.toBeInstanceOf(errors.UserError);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
it('rejects when the installed expo package version is not valid semver', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
await fs.writeFile(
path.join(projectDir, 'node_modules/expo/package.json'),
JSON.stringify({ version: 'invalid-version' })
);
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
});
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toBeInstanceOf(
errors.UserError
);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
});
+1 -4
View File
@@ -15,8 +15,6 @@ import {
EnvironmentSecretsSchema,
Hooks,
HooksSchema,
JobOutputs,
JobOutputsSchema,
Platform,
StaticWorkflowInterpolationContext,
StaticWorkflowInterpolationContextZ,
@@ -112,7 +110,7 @@ export interface Job {
};
hooks?: Hooks;
steps?: Step[];
outputs?: JobOutputs;
outputs?: Record<string, string>;
experimental?: {
prebuildCommand?: string;
@@ -178,7 +176,6 @@ 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(),
+3 -4
View File
@@ -352,10 +352,6 @@ 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', {
@@ -365,6 +361,7 @@ 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(),
@@ -372,11 +369,13 @@ 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(),
}),
});
+1 -2
View File
@@ -6,7 +6,6 @@ import {
BuildTrigger,
EnvironmentSecretZ,
HooksZ,
JobOutputsSchemaZ,
StaticWorkflowInterpolationContextZ,
} from './common';
import { StepZ } from './step';
@@ -49,7 +48,7 @@ export namespace Generic {
hooks: HooksZ.optional(),
steps: z.array(StepZ).min(1),
outputs: JobOutputsSchemaZ.optional(),
outputs: z.record(z.string(), z.string()).optional(),
});
export type PartialJob = z.infer<typeof PartialJobZ>;
-1
View File
@@ -27,7 +27,6 @@ export * from './context';
export * from './generic';
export * from './step';
export * from './submission-config';
export * from './projectPackage';
const version = require('../package.json').version;
export { version };
+1 -4
View File
@@ -15,8 +15,6 @@ import {
EnvironmentSecretsSchema,
Hooks,
HooksSchema,
JobOutputs,
JobOutputsSchema,
Platform,
StaticWorkflowInterpolationContext,
StaticWorkflowInterpolationContextZ,
@@ -126,7 +124,7 @@ export interface Job {
};
hooks?: Hooks;
steps?: Step[];
outputs?: JobOutputs;
outputs?: Record<string, string>;
experimental?: {
prebuildCommand?: string;
@@ -212,7 +210,6 @@ export const JobSchema = Joi.object({
username: Joi.string(),
hooks: HooksSchema,
outputs: JobOutputsSchema,
experimental: Joi.object({
prebuildCommand: Joi.string(),
-3
View File
@@ -36,7 +36,6 @@ export enum BuildPhase {
// ANDROID
FIX_GRADLEW = 'FIX_GRADLEW',
RUN_GRADLEW = 'RUN_GRADLEW',
GRADLE_BUILD_PROFILE = 'GRADLE_BUILD_PROFILE',
// IOS
INSTALL_PODS = 'INSTALL_PODS',
@@ -105,7 +104,6 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
// ANDROID
[BuildPhase.FIX_GRADLEW]: 'Fix gradlew',
[BuildPhase.RUN_GRADLEW]: 'Run gradlew',
[BuildPhase.GRADLE_BUILD_PROFILE]: 'Gradle build profile',
// IOS
[BuildPhase.INSTALL_PODS]: 'Install pods',
@@ -171,7 +169,6 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
// ANDROID
[BuildPhase.FIX_GRADLEW]: 'fix-gradlew',
[BuildPhase.RUN_GRADLEW]: 'run-gradlew',
[BuildPhase.GRADLE_BUILD_PROFILE]: 'gradle-build-profile',
// IOS
[BuildPhase.INSTALL_PODS]: 'install-pods',
-6
View File
@@ -49,11 +49,6 @@ export type Metadata = {
*/
sdkVersion?: string;
/**
* Version of the expo package used in the project.
*/
expoPackageVersion?: string;
/**
* Runtime version (for Expo Updates)
*/
@@ -185,7 +180,6 @@ export const MetadataSchema = Joi.object({
distribution: Joi.string().valid('store', 'internal', 'simulator'),
credentialsSource: Joi.string().valid('local', 'remote'),
sdkVersion: Joi.string(),
expoPackageVersion: Joi.string(),
runtimeVersion: Joi.string(),
fingerprintHash: Joi.string(),
reactNativeVersion: Joi.string(),
@@ -1,54 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { asyncResult } from '@expo/results';
import fs from 'fs/promises';
import semver from 'semver';
import { type Env } from './common';
import * as errors from './errors';
export async function getInstalledExpoPackageVersionAsync({
env = process.env,
projectDir,
}: {
env?: Env | NodeJS.ProcessEnv;
projectDir: string;
}): Promise<string> {
const expoPackageJsonPathResult = await asyncResult(
spawn('node', ['--print', "require.resolve('expo/package.json')"], {
cwd: projectDir,
env,
stdio: 'pipe',
})
);
if (!expoPackageJsonPathResult.ok) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
'Cannot resolve the installed expo package version because require.resolve("expo/package.json") failed.',
{ cause: expoPackageJsonPathResult.reason }
);
}
const expoPackageJsonPath = expoPackageJsonPathResult.value.stdout.toString().trim();
const expoPackageJsonResult = await asyncResult(readJsonAsync(expoPackageJsonPath));
if (!expoPackageJsonResult.ok) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_READ_FAILED',
'Cannot resolve the installed expo package version because expo/package.json could not be read.',
{ cause: expoPackageJsonResult.reason }
);
}
const expoPackageVersion = expoPackageJsonResult.value.version;
if (typeof expoPackageVersion !== 'string' || !semver.valid(expoPackageVersion)) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
'Cannot resolve the installed expo package version because expo/package.json has an invalid version.'
);
}
return expoPackageVersion;
}
async function readJsonAsync(filePath: string): Promise<any> {
return JSON.parse(await fs.readFile(filePath, 'utf8'));
}
+1 -1
View File
@@ -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 the API URL resolved by `src/api.ts` and generates:
The GraphQL schema is fetched from `https://staging-api.expo.dev/graphql` and generates:
- `src/graphql/generated.ts` - 499KB+ of TypeScript types
- Types for all queries, mutations, and fragments
+99 -234
View File
@@ -120,13 +120,6 @@ eas --help COMMAND
* [`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,8 +173,8 @@ USAGE
$ eas account:login [-s] [-b]
FLAGS
-b, --[no-]browser Log in with your browser (default; use --no-browser for CLI-based login)
-s, --sso Log in with SSO
-b, --browser Login with your browser
-s, --sso Login with SSO
DESCRIPTION
log in with your Expo account
@@ -190,7 +183,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -207,7 +200,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -229,7 +222,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -246,7 +239,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -260,7 +253,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -312,7 +305,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -333,7 +326,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -353,7 +346,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -373,7 +366,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -396,7 +389,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -434,7 +427,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -454,7 +447,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -472,7 +465,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -492,7 +485,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -500,16 +493,13 @@ 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] [--simulator
<value>]
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found] [--skip-bundler]
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.
@@ -517,7 +507,7 @@ 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/v19.0.1/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.9.1/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -544,7 +534,7 @@ DESCRIPTION
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/v19.0.1/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.9.1/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -582,7 +572,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -622,7 +612,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -652,7 +642,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -661,7 +651,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]
[--simulator <value>] [--offset <value>] [--limit <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
@@ -672,15 +662,13 @@ 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/v19.0.1/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.9.1/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -732,7 +720,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -750,7 +738,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -768,7 +756,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -785,7 +773,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -806,7 +794,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -827,7 +815,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -849,7 +837,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:insights`
@@ -873,7 +861,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/channel/insights.ts)_
## `eas channel:list`
@@ -893,7 +881,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -915,7 +903,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -937,7 +925,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -972,7 +960,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -995,7 +983,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -1016,7 +1004,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/v19.0.1/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -1033,7 +1021,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1051,7 +1039,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1080,7 +1068,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1105,7 +1093,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1126,7 +1114,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1147,7 +1135,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1184,7 +1172,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1204,7 +1192,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1225,7 +1213,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1246,7 +1234,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1260,7 +1248,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1274,7 +1262,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1306,7 +1294,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1332,7 +1320,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1354,7 +1342,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1382,7 +1370,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1410,7 +1398,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1433,7 +1421,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1455,7 +1443,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1489,7 +1477,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1533,7 +1521,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1564,7 +1552,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1643,7 +1631,7 @@ 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/v19.0.1/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
## `eas integrations:asc:disconnect`
@@ -1662,7 +1650,7 @@ 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/v19.0.1/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
## `eas integrations:asc:status`
@@ -1680,130 +1668,7 @@ 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/v19.0.1/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/v19.0.1/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/v19.0.1/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/v19.0.1/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/v19.0.1/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/v19.0.1/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/v19.0.1/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/v19.0.1/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/integrations/asc/status.ts)_
## `eas login`
@@ -1814,8 +1679,8 @@ USAGE
$ eas login [-s] [-b]
FLAGS
-b, --[no-]browser Log in with your browser (default; use --no-browser for CLI-based login)
-s, --sso Log in with SSO
-b, --browser Login with your browser
-s, --sso Login with SSO
DESCRIPTION
log in with your Expo account
@@ -1855,7 +1720,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1873,7 +1738,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1891,7 +1756,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1943,7 +1808,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/v19.0.1/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1957,7 +1822,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1980,7 +1845,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -2004,7 +1869,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -2022,7 +1887,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -2055,7 +1920,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -2098,7 +1963,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -2119,7 +1984,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -2140,7 +2005,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -2165,7 +2030,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:insights GROUPID`
@@ -2192,7 +2057,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/update/insights.ts)_
## `eas update:list`
@@ -2219,7 +2084,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -2257,7 +2122,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2285,7 +2150,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2315,7 +2180,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2336,7 +2201,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2360,7 +2225,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2381,7 +2246,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/v19.0.1/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2403,7 +2268,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2423,7 +2288,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2442,7 +2307,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2465,7 +2330,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2482,7 +2347,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2603,7 +2468,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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.9.1/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2623,7 +2488,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2647,7 +2512,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2687,7 +2552,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2709,7 +2574,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2737,7 +2602,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2757,7 +2622,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2779,5 +2644,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/v19.0.1/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.9.1/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
-28
View File
@@ -1,28 +0,0 @@
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;
+17
View File
@@ -0,0 +1,17 @@
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'
+9 -3394
View File
File diff suppressed because it is too large. Load diff
+7 -12
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "19.0.1",
"version": "18.9.1",
"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.ts",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"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,11 +47,11 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "19.0.0",
"@expo/eas-json": "19.0.0",
"@expo/eas-build-job": "18.8.0",
"@expo/eas-json": "18.8.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "19.0.0",
"@expo/logger": "18.5.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -63,7 +63,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "19.0.0",
"@expo/steps": "18.9.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -78,7 +78,6 @@
"chalk": "4.1.2",
"cli-progress": "3.12.0",
"dateformat": "4.6.3",
"debug": "4.4.3",
"diff": "7.0.0",
"dotenv": "16.3.1",
"env-paths": "2.2.0",
@@ -139,7 +138,6 @@
"@tsconfig/node20": "20.1.8",
"@types/cli-progress": "3.11.5",
"@types/dateformat": "3.0.1",
"@types/debug": "4.1.13",
"@types/diff": "6.0.0",
"@types/envinfo": "7.8.3",
"@types/express": "4.17.15",
@@ -204,7 +202,7 @@
"description": "manage project and account environment variables"
},
"integrations": {
"description": "manage third-party service integrations"
"description": "manage service connections"
},
"metadata": {
"description": "manage store configuration"
@@ -221,9 +219,6 @@
"secret": {
"description": "manage project and account secrets"
},
"simulator": {
"description": "manage remote simulator sessions"
},
"update": {
"description": "manage individual updates"
},
-346
View File
@@ -1,346 +0,0 @@
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;
});
@@ -1,81 +1,377 @@
import { Platform } from '@expo/eas-build-job';
import { vol } from 'memfs';
import { ensureLockfileExistsAsync } from '../validateLockfile';
import { CommonContext } from '../context';
import { checkLockfileAsync } from '../validateLockfile';
import { Client } from '../../vcs/vcs';
jest.mock('fs');
jest.mock('@expo/package-manager', () => ({
NPM_LOCK_FILE: 'package-lock.json',
YARN_LOCK_FILE: 'yarn.lock',
PNPM_LOCK_FILE: 'pnpm-lock.yaml',
BUN_LOCK_FILE: 'bun.lockb',
BUN_TEXT_LOCK_FILE: 'bun.lock',
resolveWorkspaceRoot: jest.fn(() => null),
}));
const mockResolveWorkspaceRoot = jest.fn();
jest.mock('@expo/package-manager', () => {
const actual = jest.requireActual('@expo/package-manager');
return {
...actual,
resolveWorkspaceRoot: (...args: unknown[]) => mockResolveWorkspaceRoot(...args),
};
const { resolveWorkspaceRoot } = jest.requireMock<{
resolveWorkspaceRoot: jest.Mock;
}>('@expo/package-manager');
const mockProcessExit = jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
function createMockContext(
overrides: Partial<{
projectDir: string;
requiredPackageManager: string | null;
isFileIgnoredAsync: (filePath: string) => Promise<boolean>;
getRootPathAsync: () => Promise<string>;
}> = {}
): CommonContext<Platform> {
const vcsClient = {
isFileIgnoredAsync: overrides.isFileIgnoredAsync ?? (async () => false),
getRootPathAsync: overrides.getRootPathAsync ?? (async () => '/project'),
} as unknown as Client;
return {
projectDir: overrides.projectDir ?? '/project',
requiredPackageManager: overrides.requiredPackageManager ?? null,
vcsClient,
} as unknown as CommonContext<Platform>;
}
const PACKAGE_JSON_WITH_REACT = JSON.stringify({
dependencies: { react: '19.0.4', 'react-native': '~0.79.0' },
});
const NPM_LOCKFILE_SYNCED = JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.4' },
'node_modules/react-native': { version: '0.79.4' },
},
});
const NPM_LOCKFILE_STALE = JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.0' },
'node_modules/react-native': { version: '0.79.4' },
},
});
// bun.lock uses JSONC with trailing commas
const BUN_LOCKFILE_STALE = `{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"react": "19.0.4",
},
},
},
"packages": {
"react": ["[email protected]", "", {}, "sha512-abc"],
},
}`;
const BUN_LOCKFILE_SYNCED = `{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"react": "19.0.4",
},
},
},
"packages": {
"react": ["[email protected]", "", {}, "sha512-abc"],
},
}`;
const YARN_LOCKFILE_STALE = `# yarn lockfile v1
[email protected]:
version "19.0.0"
resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz"
`;
const YARN_LOCKFILE_SYNCED = `# yarn lockfile v1
[email protected]:
version "19.0.4"
resolved "https://registry.yarnpkg.com/react/-/react-19.0.4.tgz"
react-native@~0.79.0:
version "0.79.4"
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.79.4.tgz"
`;
beforeEach(() => {
vol.reset();
mockResolveWorkspaceRoot.mockReturnValue(null);
resolveWorkspaceRoot.mockReturnValue(null);
mockProcessExit.mockClear();
delete process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK;
});
describe(ensureLockfileExistsAsync, () => {
it('passes when package-lock.json exists', async () => {
vol.fromJSON({ './package-lock.json': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
describe(checkLockfileAsync, () => {
it('skips all checks when EAS_BUILD_SKIP_LOCKFILE_CHECK is set', async () => {
process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK = '1';
// This would normally fail — no lockfile, no package manager
vol.fromJSON({ '/project/package.json': '{}' });
const ctx = createMockContext({ requiredPackageManager: null });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('passes when yarn.lock exists', async () => {
vol.fromJSON({ './yarn.lock': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
it('exits when no package manager is detected', async () => {
vol.fromJSON({ '/project/package.json': '{}' });
const ctx = createMockContext({ requiredPackageManager: null });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('passes when pnpm-lock.yaml exists', async () => {
vol.fromJSON({ './pnpm-lock.yaml': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
it('exits when npm is detected but package-lock.json is missing', async () => {
vol.fromJSON({ '/project/package.json': '{}' });
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('passes when bun.lockb exists', async () => {
vol.fromJSON({ './bun.lockb': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
it('does not error when lockfile exists and is in sync', async () => {
vol.fromJSON({
'/project/package.json': PACKAGE_JSON_WITH_REACT,
'/project/package-lock.json': NPM_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('passes when bun.lock exists', async () => {
vol.fromJSON({ './bun.lock': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
it('exits when lockfile exists but is gitignored', async () => {
vol.fromJSON({
'/project/package.json': '{}',
'/project/yarn.lock': '# yarn lockfile',
});
const ctx = createMockContext({
requiredPackageManager: 'yarn',
isFileIgnoredAsync: async () => true,
});
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
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('exits when conflicting lockfiles from multiple managers exist', async () => {
vol.fromJSON({
'/project/package.json': '{}',
'/project/package-lock.json': NPM_LOCKFILE_SYNCED,
'/project/yarn.lock': '# yarn lockfile',
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
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('exits when npm lockfile version does not satisfy package.json specifier', async () => {
vol.fromJSON({
'/project/package.json': PACKAGE_JSON_WITH_REACT,
'/project/package-lock.json': NPM_LOCKFILE_STALE,
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
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.'
);
it('exits when bun.lock version does not satisfy package.json specifier', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/bun.lock': BUN_LOCKFILE_STALE,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('does not error when bun.lock is in sync', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/bun.lock': BUN_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('exits when yarn lockfile version does not satisfy package.json specifier', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/yarn.lock': YARN_LOCKFILE_STALE,
});
const ctx = createMockContext({ requiredPackageManager: 'yarn' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('does not error when yarn lockfile is in sync', async () => {
vol.fromJSON({
'/project/package.json': PACKAGE_JSON_WITH_REACT,
'/project/yarn.lock': YARN_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'yarn' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('finds lockfile at workspace root for monorepos', async () => {
vol.fromJSON({
'/monorepo/yarn.lock': YARN_LOCKFILE_SYNCED,
'/monorepo/packages/app/package.json': PACKAGE_JSON_WITH_REACT,
});
resolveWorkspaceRoot.mockReturnValue('/monorepo');
const ctx = createMockContext({
projectDir: '/monorepo/packages/app',
requiredPackageManager: 'yarn',
getRootPathAsync: async () => '/monorepo',
});
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('does not report a conflict when both bun.lock and bun.lockb are present', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/bun.lockb': 'binary',
'/project/bun.lock': BUN_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('skips sync check for non-registry specifiers', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: {
'my-local-pkg': 'file:../local',
'my-workspace-pkg': 'workspace:*',
react: '19.0.4',
},
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.4' },
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
describe('peer dependency conflicts', () => {
it('exits when a direct dependency has an unsatisfied peer (npm)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { react: '19.0.0', 'react-dom': '19.2.4' },
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.0' },
'node_modules/react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('exits when a direct dependency has an unsatisfied peer (bun)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { react: '19.0.0', 'react-dom': '19.2.4' },
}),
'/project/bun.lock': `{
"lockfileVersion": 1,
"workspaces": { "": { "dependencies": { "react": "19.0.0", "react-dom": "19.2.4" } } },
"packages": {
"react": ["[email protected]", "", {}, "sha512-abc"],
"react-dom": ["[email protected]", "", { "peerDependencies": { "react": "^19.2.4" } }, "sha512-def"],
},
}`,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('does not error when peer dependencies are satisfied', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { react: '19.2.4', 'react-dom': '19.2.4' },
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.2.4' },
'node_modules/react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('skips optional peer dependencies (npm peerDependenciesMeta)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { '@0no-co/graphql.web': '1.2.0' },
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/@0no-co/graphql.web': {
version: '1.2.0',
peerDependencies: { graphql: '^14.0.0 || ^15.0.0 || ^16.0.0' },
peerDependenciesMeta: { graphql: { optional: true } },
},
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('skips optional peer dependencies (bun optionalPeers)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { '@0no-co/graphql.web': '1.2.0' },
}),
'/project/bun.lock': `{
"lockfileVersion": 1,
"workspaces": { "": { "dependencies": { "@0no-co/graphql.web": "1.2.0" } } },
"packages": {
"@0no-co/graphql.web": ["@0no-co/[email protected]", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-abc"],
},
}`,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
});
});
+4 -19
View File
@@ -1,11 +1,5 @@
import {
Metadata,
Platform,
getInstalledExpoPackageVersionAsync,
sanitizeMetadata,
} from '@expo/eas-build-job';
import { Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
import { IosEnterpriseProvisioning } from '@expo/eas-json';
import { asyncResult } from '@expo/results';
import fs from 'fs-extra';
import resolveFrom from 'resolve-from';
@@ -15,7 +9,9 @@ import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios
import { LocalBuildMode } from './local';
import { BuildDistributionType } from './types';
import Log from '../log';
import { isExpoUpdatesInstalled } from '../project/projectUtils';
import {
isExpoUpdatesInstalled,
} from '../project/projectUtils';
import { readChannelSafelyAsync as readAndroidChannelSafelyAsync } from '../update/android/UpdatesModule';
import { readChannelSafelyAsync as readIosChannelSafelyAsync } from '../update/ios/UpdatesModule';
import { easCliVersion } from '../utils/easCli';
@@ -30,16 +26,6 @@ export async function collectMetadataAsync<T extends Platform>(
): Promise<Metadata> {
const channelObject = await resolveChannelAsync(ctx);
const distribution = ctx.buildProfile.distribution ?? BuildDistributionType.STORE;
const expoPackageVersionResult = await asyncResult(
getInstalledExpoPackageVersionAsync({
env: process.env,
projectDir: ctx.projectDir,
})
);
if (!expoPackageVersionResult.ok) {
Log.debug('Failed to resolve expo package version:');
Log.debug(expoPackageVersionResult.reason);
}
const metadata: Metadata = {
trackingContext: ctx.analyticsEventProperties,
...(await maybeResolveVersionsAsync(ctx)),
@@ -50,7 +36,6 @@ export async function collectMetadataAsync<T extends Platform>(
runtimeVersion: runtimeAndFingerprintMetadata?.runtimeVersion,
fingerprintHash: runtimeAndFingerprintMetadata?.fingerprintHash,
reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir),
expoPackageVersion: expoPackageVersionResult.value,
...channelObject,
distribution,
appName: ctx.exp.name,
@@ -22,8 +22,8 @@ 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 { checkLockfileAsync } from './validateLockfile';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureRepoIsCleanAsync } from './utils/repository';
import { Analytics } from '../analytics/AnalyticsManager';
@@ -66,7 +66,6 @@ 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 {
@@ -105,7 +104,6 @@ export interface BuildFlags {
freezeCredentials: boolean;
isVerboseLoggingEnabled?: boolean;
whatToTest?: string;
simulator?: SimulatorRunTarget;
}
export async function runBuildAndSubmitAsync({
@@ -418,10 +416,8 @@ async function prepareAndStartBuildAsync({
env,
});
if (!lockfileChecked && !flags.localBuildOptions.localBuildMode) {
if (!process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK) {
await ensureLockfileExistsAsync(projectDir);
}
if (!lockfileChecked) {
await checkLockfileAsync(buildCtx);
lockfileChecked = true;
}
@@ -590,17 +586,14 @@ function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | nul
}
}
export async function downloadAndRunAsync(
build: BuildFragment,
simulator?: SimulatorRunTarget
): Promise<void> {
export async function downloadAndRunAsync(build: BuildFragment): 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, simulator);
await runAsync(cachedAppPath, build.platform);
return;
}
@@ -609,7 +602,7 @@ export async function downloadAndRunAsync(
build.platform,
cachedAppPath
);
await runAsync(buildPath, build.platform, simulator);
await runAsync(buildPath, build.platform);
}
async function maybeDownloadAndRunSimulatorBuildsAsync(
@@ -631,9 +624,9 @@ async function maybeDownloadAndRunSimulatorBuildsAsync(
} build on ${
simBuild.platform === AppPlatform.Android ? 'an emulator' : 'a simulator'
}?`,
}));
}));
if (confirm) {
await downloadAndRunAsync(simBuild, flags.simulator);
await downloadAndRunAsync(simBuild);
}
}
}
@@ -1,75 +0,0 @@
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();
}
});
});
+3 -18
View File
@@ -143,24 +143,9 @@ export async function makeProjectTarballAsync(vcsClient: Client): Promise<LocalF
try {
await vcsClient.makeShallowCopyAsync(shallowClonePath);
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;
}
},
},
['.']
);
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
'.',
]);
} catch (err) {
clearTimeout(timer);
if (spinner.isSpinning) {
+210 -26
View File
@@ -6,42 +6,226 @@ import {
YARN_LOCK_FILE,
resolveWorkspaceRoot,
} from '@expo/package-manager';
import { pathExists } from 'fs-extra';
import { Platform } from '@expo/eas-build-job';
import {
LockfileData,
findLockfileMismatches,
findPeerDependencyConflicts,
parseLockfile,
} from '@expo/lockfile-utils';
import fs 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,
];
import { CommonContext } from './context';
import Log from '../log';
async function hasLockfileAsync(dir: string): Promise<boolean> {
for (const lockfile of LOCKFILE_NAMES) {
if (await pathExists(path.join(dir, lockfile))) {
return true;
}
}
return false;
const SKIP_HINT =
'To skip this check, set the EAS_BUILD_SKIP_LOCKFILE_CHECK=1 environment variable.';
interface LockfileInfo {
/** Filename of the lockfile (e.g. 'yarn.lock') */
filename: string;
/** Package manager name for display (e.g. 'yarn') */
managerName: string;
}
export async function ensureLockfileExistsAsync(projectDir: string): Promise<void> {
if (await hasLockfileAsync(projectDir)) {
const LOCKFILE_BY_MANAGER: Record<string, LockfileInfo> = {
npm: { filename: NPM_LOCK_FILE, managerName: 'npm' },
yarn: { filename: YARN_LOCK_FILE, managerName: 'yarn' },
pnpm: { filename: PNPM_LOCK_FILE, managerName: 'pnpm' },
bun: { filename: BUN_LOCK_FILE, managerName: 'bun' },
};
const ALL_LOCKFILES: { filename: string; manager: string }[] = [
{ filename: NPM_LOCK_FILE, manager: 'npm' },
{ filename: YARN_LOCK_FILE, manager: 'yarn' },
{ filename: PNPM_LOCK_FILE, manager: 'pnpm' },
{ filename: BUN_LOCK_FILE, manager: 'bun' },
{ filename: BUN_TEXT_LOCK_FILE, manager: 'bun' },
];
/**
* Find the lockfile for the given filename by checking the workspace root first
* (for monorepos), then the project directory.
*/
async function findLockfileAsync(
projectDir: string,
filename: string
): Promise<string | null> {
const workspaceRoot = resolveWorkspaceRoot(projectDir);
if (workspaceRoot) {
const workspaceLockfile = path.join(workspaceRoot, filename);
if (await fs.pathExists(workspaceLockfile)) {
return workspaceLockfile;
}
}
const projectLockfile = path.join(projectDir, filename);
if (await fs.pathExists(projectLockfile)) {
return projectLockfile;
}
return null;
}
async function readLockfileDataAsync(
projectDir: string,
lockfilePath: string,
manager: string
): Promise<{ lockfileData: LockfileData; packageJson: Record<string, unknown> } | null> {
const packageJsonPath = path.join(projectDir, 'package.json');
if (!(await fs.pathExists(packageJsonPath))) {
return null;
}
const packageJson = await fs.readJson(packageJsonPath);
const lockfileContent = await fs.readFile(lockfilePath, 'utf-8');
const lockfileData = parseLockfile(lockfileContent, manager, lockfilePath);
if (!lockfileData) {
return null;
}
return { lockfileData, packageJson };
}
/**
* Pre-flight lockfile validation for `eas build`. Catches common lockfile
* problems before uploading to the builder.
*/
export async function checkLockfileAsync<T extends Platform>(
ctx: CommonContext<T>
): Promise<void> {
if (process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK) {
Log.warn('Skipping lockfile validation (EAS_BUILD_SKIP_LOCKFILE_CHECK is set).');
return;
}
const workspaceRoot = resolveWorkspaceRoot(projectDir);
if (workspaceRoot && workspaceRoot !== projectDir) {
if (await hasLockfileAsync(workspaceRoot)) {
return;
const { projectDir, requiredPackageManager, vcsClient } = ctx;
// --- 1. Lockfile existence ---
if (!requiredPackageManager) {
Log.error('No lockfile found in the project directory.');
Log.error(
'A lockfile is required to ensure deterministic dependency installation on the EAS builder.'
);
Log.error(
'Run your package manager\'s install command (e.g. "npm install") to generate one, then commit it to version control.'
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
const lockInfo = LOCKFILE_BY_MANAGER[requiredPackageManager];
if (!lockInfo) {
// Unknown package manager — nothing we can validate
return;
}
// For bun, also check the text lockfile variant
const lockfilePath =
requiredPackageManager === 'bun'
? (await findLockfileAsync(projectDir, BUN_LOCK_FILE)) ??
(await findLockfileAsync(projectDir, BUN_TEXT_LOCK_FILE))
: await findLockfileAsync(projectDir, lockInfo.filename);
if (!lockfilePath) {
Log.error(
`${lockInfo.managerName} lockfile (${lockInfo.filename}) not found.`
);
Log.error(
`Run "${requiredPackageManager} install" to generate a lockfile, then commit it to version control.`
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 2. Lockfile tracked in VCS ---
const rootDir = path.normalize(await vcsClient.getRootPathAsync());
const relativeLockfilePath = path.relative(rootDir, lockfilePath);
if (await vcsClient.isFileIgnoredAsync(relativeLockfilePath)) {
Log.error(
`${lockInfo.managerName} lockfile (${relativeLockfilePath}) is ignored by your version control system and won't be uploaded to the EAS builder.`
);
Log.error(
'Remove the lockfile from .gitignore (and .easignore, if applicable), then commit it.'
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 3. Conflicting lockfiles ---
const foundManagers = new Set<string>();
for (const { filename, manager } of ALL_LOCKFILES) {
if (await findLockfileAsync(projectDir, filename)) {
foundManagers.add(manager);
}
}
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.`
if (foundManagers.size > 1) {
const managerList = [...foundManagers].sort().join(', ');
Log.error(`Conflicting lockfiles detected from multiple package managers: ${managerList}.`);
Log.error(
'Having lockfiles from multiple package managers can cause install failures on the EAS builder. Remove the lockfiles you do not need and commit the change.'
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 4 & 5. Parse lockfile for dependency sync and peer dep checks ---
// For bun, prefer the text lockfile for parsing (bun.lockb is binary)
const parsableLockfilePath =
requiredPackageManager === 'bun'
? (await findLockfileAsync(projectDir, BUN_TEXT_LOCK_FILE)) ?? lockfilePath
: lockfilePath;
const parsed = await readLockfileDataAsync(
projectDir,
parsableLockfilePath,
requiredPackageManager
);
if (!parsed) {
return; // Can't parse — skip remaining checks
}
// --- 4. Lockfile out of sync ---
const mismatches = findLockfileMismatches(parsed.packageJson, parsed.lockfileData);
if (mismatches.length > 0) {
Log.error('Lockfile is out of sync with package.json:');
for (const m of mismatches) {
Log.error(
` ${m.name}: package.json requires ${m.specifier}, but lockfile has ${m.lockedVersion}`
);
}
Log.error(
`Run "${requiredPackageManager} install" to update your lockfile, then commit the changes.`
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 5. Peer dependency conflicts ---
const peerConflicts = findPeerDependencyConflicts(parsed.packageJson, parsed.lockfileData);
if (peerConflicts.length > 0) {
Log.error('Peer dependency conflicts detected:');
for (const c of peerConflicts) {
Log.error(
` ${c.source}@${c.sourceVersion} requires peer ${c.peer}@"${c.peerRange}", but ${c.peer}@${c.installedVersion} is installed`
);
}
Log.error(
'Update the conflicting packages to compatible versions, then run ' +
`"${requiredPackageManager} install" and commit the changes.`
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
}
@@ -1,137 +0,0 @@
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'));
});
});
@@ -1,20 +0,0 @@
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)];
}
@@ -74,10 +74,7 @@ describe(getProjectIdAsync, () => {
);
jest.mocked(findProjectRootAsync).mockResolvedValue('/app');
// NOTE(@kitten): Updating this test is easiest by letting it fallback to `@expo/config`
// This isn't a great solution, but the test is pretty involved
jest.mocked(isExpoInstalled).mockReturnValue(false);
jest.mocked(isExpoInstalled).mockReturnValue(true);
});
it('gets the project ID from app config if exists', async () => {
@@ -1,89 +0,0 @@
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?`,
});
}
@@ -1,14 +1,12 @@
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
import { installDependenciesAsync } from '../../../onboarding/installDependencies';
import { runCommandAsync } from '../../../onboarding/runCommand';
import { expoCommandAsync } from '../../../utils/expoCli';
import {
cloneTemplateAsync,
initializeGitRepositoryAsync,
installProjectDependenciesAsync,
} from '../commands';
jest.mock('../../../utils/expoCli');
jest.mock('../../../onboarding/git');
jest.mock('../../../onboarding/runCommand');
jest.mock('../../../onboarding/installDependencies');
@@ -81,11 +79,21 @@ describe('commands', () => {
packageManager: 'npm',
});
expect(expoCommandAsync).toHaveBeenCalledWith(
projectDir,
['install', 'expo-updates', '@expo/metro-runtime'],
{ silent: true }
);
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', 'expo-updates'],
showOutput: false,
showSpinner: false,
});
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', '@expo/metro-runtime'],
showOutput: false,
showSpinner: false,
});
});
});
@@ -7,7 +7,6 @@ import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboar
import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies';
import { runCommandAsync } from '../../onboarding/runCommand';
import { ora } from '../../ora';
import { expoCommandAsync } from '../../utils/expoCli';
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
const githubUsername = 'expo';
@@ -49,8 +48,16 @@ export async function installProjectDependenciesAsync(
});
const dependencies = ['expo-updates', '@expo/metro-runtime'];
spinner.text = `Installing ${dependencies.map(dep => chalk.bold(dep)).join(', ')}`;
await expoCommandAsync(projectDir, ['install', ...dependencies], { silent: true });
for (const dependency of dependencies) {
spinner.text = `Installing ${chalk.bold(dependency)}`;
await runCommandAsync({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', dependency],
showOutput: false,
showSpinner: false,
});
}
spinner.succeed(`Installed project dependencies`);
}
@@ -13,15 +13,14 @@ export default class AccountLogin extends EasCommand {
static override flags = {
// can pass either --sso or -s
sso: Flags.boolean({
description: 'Log in with SSO',
description: 'Login with SSO',
char: 's',
default: false,
}),
browser: Flags.boolean({
description: 'Log in with your browser (default; use --no-browser for CLI-based login)',
description: 'Login with your browser',
char: 'b',
default: true,
allowNo: true,
default: false,
}),
};
+2 -13
View File
@@ -12,7 +12,6 @@ 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';
@@ -52,11 +51,6 @@ 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 = {
@@ -69,7 +63,7 @@ export default class BuildDev extends EasCommand {
};
protected override async runAsync(): Promise<any> {
const { flags } = await this.parse(BuildDev, preprocessBuildCommandArgs(this.argv));
const { flags } = await this.parse(BuildDev);
const {
loggedIn: { actor, graphqlClient },
@@ -87,10 +81,6 @@ 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);
@@ -142,7 +132,7 @@ export default class BuildDev extends EasCommand {
);
if (build.artifacts?.applicationArchiveUrl) {
await downloadAndRunAsync(build, simulator);
await downloadAndRunAsync(build);
if (!flags['skip-bundler']) {
await this.startDevServerAsync({ projectDir, platform });
}
@@ -192,7 +182,6 @@ export default class BuildDev extends EasCommand {
autoSubmit: false,
localBuildOptions: {},
profile: flags.profile ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
simulator,
},
actor,
getDynamicPrivateProjectConfigAsync,
+3 -16
View File
@@ -4,7 +4,6 @@ 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,
@@ -18,7 +17,6 @@ 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,
@@ -34,7 +32,6 @@ interface RawRunFlags {
limit?: number;
offset?: number;
profile?: string;
simulator?: string;
}
interface RunCommandFlags {
@@ -43,7 +40,6 @@ interface RunCommandFlags {
limit?: number;
offset?: number;
profile?: string;
simulator?: SimulatorRunTarget;
}
export default class Run extends EasCommand {
@@ -76,11 +72,6 @@ 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,
};
@@ -91,7 +82,7 @@ export default class Run extends EasCommand {
};
async runAsync(): Promise<void> {
const { flags: rawFlags } = await this.parse(Run, preprocessBuildCommandArgs(this.argv));
const { flags: rawFlags } = await this.parse(Run);
const flags = await this.sanitizeFlagsAsync(rawFlags);
const queryOptions = getPaginatedQueryOptions(flags);
const {
@@ -108,11 +99,11 @@ export default class Run extends EasCommand {
queryOptions
);
await runAsync(simulatorBuildPath, flags.selectedPlatform, flags.simulator);
await runAsync(simulatorBuildPath, flags.selectedPlatform);
}
private async sanitizeFlagsAsync(flags: RawRunFlags): Promise<RunCommandFlags> {
const { platform, limit, offset, profile, simulator, ...runArchiveFlags } = flags;
const { platform, limit, offset, profile, ...runArchiveFlags } = flags;
const selectedPlatform = await resolvePlatformAsync(platform);
@@ -121,9 +112,6 @@ 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 &&
@@ -149,7 +137,6 @@ export default class Run extends EasCommand {
limit,
offset,
profile,
simulator: simulator === '' ? true : simulator,
};
}
}
+216 -124
View File
@@ -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,6 +8,7 @@ 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';
@@ -21,33 +22,31 @@ 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 { Client } from '../vcs/vcs';
import NoVcsClient from '../vcs/clients/noVcs';
import {
INVALID_BUNDLE_IDENTIFIER_MESSAGE,
isBundleIdentifierValid,
} from '../project/ios/bundleIdentifier';
import { resolveVcsClient } from '../vcs';
import { Client as VcsClient } from '../vcs/vcs';
function deriveBundleIdSlug(bundleId: string): string {
return bundleId.split('.').filter(Boolean).pop()!;
}
// 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';
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 {
@@ -71,7 +70,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 sleepAsync(10_000);
await new Promise(resolve => setTimeout(resolve, 10000));
continue;
}
}
@@ -119,27 +118,30 @@ async function withSuppressedOutputAsync<T>(fn: () => Promise<T>): Promise<T> {
return true;
};
// Suppress stdout and console output during credential setup so its verbose
// log lines don't interleave with our progress spinners.
// Only suppress stdout, not stderr — ora writes spinner frames to stderr and
// patching it would freeze the spinner animation during suppressed async work.
process.stdout.write = capture as any;
console.log = () => {};
console.error = () => {};
console.warn = () => {};
let didThrow = false;
try {
return await fn();
} catch (error) {
didThrow = true;
process.stdout.write = originalStdoutWrite;
console.log = originalConsoleLog;
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
if (capturedOutput) {
originalConsoleLog(capturedOutput);
}
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 */
@@ -157,10 +159,6 @@ 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,
@@ -189,34 +187,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);
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);
const appName = flags.name ?? 'My Expo Go';
const slug = bundleId.split('.').pop() || 'my-expo-go';
const setupSpinner = ora('Setting up project...').start();
let projectId: string;
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 {
projectId = await withSuppressedOutputAsync(() =>
this.ensureEasProjectAsync(graphqlClient, actor, slug)
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)
);
} 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 {
// Step 3: Set up iOS credentials and create App Store Connect app
const ascApp = await this.setupCredentialsAsync(
slug,
projectDir,
projectId,
bundleId,
appName,
@@ -231,30 +229,28 @@ export default class Go extends EasCommand {
}
);
const { workflowUrl, workflowRunId } = await this.dispatchWorkflowAsync(
// Step 4: Start workflow and monitor progress
const { workflowUrl, workflowRunId } = await this.runWorkflowAsync(
graphqlClient,
projectDir,
projectId,
actor,
bundleId,
appName,
ascApp.id,
sdkVersion,
tmpDir,
vcsClient
);
Log.withTick(`Build started: ${chalk.cyan(workflowUrl)}`);
Log.withTick(`Workflow started: ${chalk.cyan(workflowUrl)}`);
const status = await this.monitorWorkflowJobsAsync(graphqlClient, workflowRunId);
if (status === WorkflowRunStatus.Failure) {
throw new Error('Build failed');
throw new Error('Workflow failed');
} else if (status === WorkflowRunStatus.Canceled) {
throw new Error('Build was canceled');
throw new Error('Workflow was canceled');
}
// Step 5: Set up TestFlight group (silently)
try {
await setupTestFlightAsync(ascApp);
} catch (e) {
Log.debug('TestFlight group setup failed:', e);
} catch {
// Non-fatal: TestFlight group setup failure shouldn't block the user
}
Log.newLine();
@@ -270,27 +266,112 @@ 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 {
await fs.remove(tmpDir);
process.chdir(originalCwd);
}
}
private generateBundleId(actor: Actor): string {
const username = ensureActorHasPrimaryAccount(actor).name;
const username = actor.accounts[0].name;
// Sanitize username for bundle ID: only alphanumeric and hyphens allowed
const sanitizedUsername = username
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '');
return `com.${sanitizedUsername}.expogo`;
.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 });
}
private async ensureEasProjectAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
slug: string
projectDir: string,
bundleId: string
): Promise<string> {
const account = ensureActorHasPrimaryAccount(actor);
const slug = bundleId.split('.').pop() || 'custom-expo-go';
const account = actor.accounts[0];
const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
@@ -299,36 +380,38 @@ export default class Go extends EasCommand {
);
if (existingProjectId) {
await saveProjectIdToAppConfigAsync(projectDir, existingProjectId);
return existingProjectId;
}
return await AppMutation.createAppAsync(graphqlClient, {
const projectId = await AppMutation.createAppAsync(graphqlClient, {
accountId: account.id,
projectName: slug,
});
await saveProjectIdToAppConfigAsync(projectDir, projectId);
return projectId;
}
private async setupCredentialsAsync(
slug: string,
projectDir: string,
projectId: string,
bundleId: string,
appName: string,
graphqlClient: ExpoGraphqlClient,
actor: Actor,
analytics: Analytics,
vcsClient: Client,
vcsClient: VcsClient,
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: process.cwd(),
projectDir,
user: actor,
graphqlClient,
analytics,
@@ -342,7 +425,7 @@ export default class Go extends EasCommand {
const targets: Target[] = [
{
targetName: slug,
targetName: exp.slug,
bundleIdentifier: bundleId,
entitlements: {},
},
@@ -354,25 +437,43 @@ export default class Go extends EasCommand {
},
];
await new SetUpBuildCredentials({
app,
targets,
distribution: 'store',
}).runAsync(credentialsCtx);
const ascApp = await withSuppressedOutputAsync(async () => {
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 ascApp = await ensureAppExistsAsync(userAuthCtx, {
name: appName,
bundleIdentifier: bundleId,
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 setupPushKeyAction = new SetUpPushKey(appLookupParams);
// Set up push notifications (outside suppressed block so prompts are visible)
const appLookupParamsForPushKey = { ...app, bundleIdentifier: bundleId };
const setupPushKeyAction = new SetUpPushKey(appLookupParamsForPushKey);
const isPushKeySetup = await setupPushKeyAction.isPushKeySetupAsync(credentialsCtx);
if (!isPushKeySetup) {
if (customizeCreds) {
@@ -391,30 +492,14 @@ export default class Go extends EasCommand {
return ascApp;
}
private async dispatchWorkflowAsync(
private async runWorkflowAsync(
graphqlClient: ExpoGraphqlClient,
projectDir: string,
projectId: string,
actor: Actor,
bundleId: string,
appName: string,
ascAppId: string,
sdkVersion: string | undefined,
tmpDir: string,
vcsClient: Client
vcsClient: VcsClient
): Promise<{ workflowUrl: string; workflowRunId: string }> {
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 account = actor.accounts[0];
const { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey } =
await withSuppressedOutputAsync(async () => {
@@ -423,35 +508,42 @@ export default class Go extends EasCommand {
vcsClient,
accountId: account.id,
});
const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(tmpDir, 'eas.json'),
filePath: path.join(projectDir, 'eas.json'),
maxSizeBytes: 1024 * 1024,
});
const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(tmpDir, 'package.json'),
filePath: path.join(projectDir, 'package.json'),
maxSizeBytes: 1024 * 1024,
});
return { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey };
});
const result = await WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync(graphqlClient, {
appId: projectId,
sdkVersion: repackConfig.sdkVersion,
projectSource: {
type: WorkflowProjectSourceType.Gcs,
projectArchiveBucketKey,
easJsonBucketKey,
packageJsonBucketKey,
},
});
const { id: workflowRunId } = await WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync(
graphqlClient,
{
appId: projectId,
projectSource: {
type: WorkflowProjectSourceType.Gcs,
projectArchiveBucketKey,
easJsonBucketKey,
packageJsonBucketKey,
projectRootDirectory: '.',
},
}
);
const workflowUrl = getWorkflowRunUrl(account.name, deriveBundleIdSlug(bundleId), result.id);
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
const workflowUrl = `https://expo.dev/accounts/${account.name}/projects/${app.slug}/workflows/${workflowRunId}`;
return { workflowUrl, workflowRunId: result.id };
return { workflowUrl, workflowRunId };
}
private async monitorWorkflowJobsAsync(
@@ -492,8 +584,8 @@ export default class Go extends EasCommand {
});
failedFetchesCount = 0;
const repackJob = workflowRun.jobs.find(j => j.key === 'build');
const submitJob = workflowRun.jobs.find(j => j.key === 'submit');
const repackJob = workflowRun.jobs.find(j => j.name === 'Repack Expo Go');
const submitJob = workflowRun.jobs.find(j => j.name === 'Submit to TestFlight');
if (!buildCompleted) {
if (repackJob?.status === WorkflowJobStatus.Success) {
@@ -1,471 +0,0 @@
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',
})
);
});
});
@@ -1,126 +0,0 @@
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')
);
});
});
@@ -1,121 +0,0 @@
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();
});
});
@@ -1,79 +0,0 @@
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')
);
});
});
@@ -1,188 +0,0 @@
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'
);
});
});
@@ -1,245 +0,0 @@
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'
);
});
});
@@ -1,81 +0,0 @@
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'));
});
});
@@ -1,445 +0,0 @@
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;
}
}
@@ -1,46 +0,0 @@
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;
}
}
}
@@ -1,34 +0,0 @@
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));
}
}
@@ -1,87 +0,0 @@
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;
}
}
}
@@ -1,45 +0,0 @@
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));
}
}
}
@@ -1,138 +0,0 @@
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,
}))
);
}
}
@@ -1,166 +0,0 @@
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,
}))
);
}
}
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more