Compare commits

..
676 changed files with 18358 additions and 76325 deletions

No files matched your search

-11
View File
@@ -1,11 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "eas-cli"
[setup]
script = '''
mise trust
mise install
yarn
yarn typecheck
'''
-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,8 +12,6 @@ jobs:
profile: system-test
custom_job:
name: Custom Job
runs_on: linux-medium-nested-virtualization
steps:
- name: Echo
run: echo "This is a custom job step"
- uses: eas/start_android_emulator
@@ -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 -4
View File
@@ -1,7 +1,6 @@
name: CHANGELOG.md PR check
on:
workflow_dispatch:
pull_request:
types:
- opened
@@ -18,7 +17,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 +25,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/add-pr-comment@v2.1.0
if: always()
with:
message-id: changelog-entry-check
+2 -8
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 a tag or branch) because this runs under
# pull_request_target with a write-scoped token — a re-pointed tag or
# moving branch would allow unreviewed upstream code to run here.
- uses: tobyhs/codemention@ebac5877393ab8693fd73b42708c8552043e9a3b # post-v1.5.2
- uses: tobyhs/[email protected]
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
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
-1
View File
@@ -16,7 +16,6 @@
"packages/eas-cli/README.md",
"packages/eas-cli/schema/**",
"packages/eas-cli/graphql.schema.json",
"packages/eas-cli/src/graphql/generated.ts",
"packages/build-tools/schema.graphql"
]
}
+1 -2
View File
@@ -5,6 +5,5 @@
"**/*.code-search": true,
"**/src/**/build/**": false,
"**/src/build/**": false,
},
"js/ts.tsdk.path": "node_modules/typescript/lib"
}
}
-7
View File
@@ -1,9 +1,2 @@
defaultSemverRangePrefix: ''
nodeLinker: node-modules
npmMinimalAgeGate: 2d
npmPreapprovedPackages:
- '@expo/*'
- 'eslint-plugin-universe'
- 'oxlint-config-universe'
-1
View File
@@ -3,4 +3,3 @@
When working with this repository follow instructions from CLAUDE.md.
- [./CLAUDE.md](./CLAUDE.md)
- Before committing, run the formatter on modified files.
+9 -336
View File
@@ -6,344 +6,16 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
### 🎉 New features
### 🐛 Bug fixes
- [eas-cli] Select correct tvOS build target for non-interactive Apple builds when EXPO_TV env variable is set. ([#3907](https://github.com/expo/eas-cli/pull/3907) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- [eas-cli] Bump `@expo/apple-utils` to `2.1.22`. ([#3913](https://github.com/expo/eas-cli/pull/3913) by [@sjchmiela](https://github.com/sjchmiela))
## [20.5.0](https://github.com/expo/eas-cli/releases/tag/v20.5.0) - 2026-06-29
### 🎉 New features
- [eas-cli] `eas integrations:posthog:connect` now supports existing PostHog user connection. ([#3895](https://github.com/expo/eas-cli/pull/3895) by [@gwdp](https://github.com/gwdp))
## [20.4.0](https://github.com/expo/eas-cli/releases/tag/v20.4.0) - 2026-06-25
### 🎉 New features
- [eas-cli] New command `observe:session` for inspecting events by session ID. ([#3868](https://github.com/expo/eas-cli/pull/3868) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add tiered build-credit usage warnings (approaching/at/over) before each build, lowering the warning threshold to 80% to match email notifications. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8))
### 🐛 Bug fixes
- [eas-cli] Fix `eas update --channel` failing with "Channel has no branches associated with it" when the channel was auto-created by a build. A branch with the channel's name is now created and linked, matching the behavior for a channel that doesn't exist yet. ([#3891](https://github.com/expo/eas-cli/pull/3891) by [@gwdp](https://github.com/gwdp))
## [20.3.0](https://github.com/expo/eas-cli/releases/tag/v20.3.0) - 2026-06-18
### 🎉 New features
- [eas-cli] Add `eas account:audit [ACCOUNT_NAME]` command to view an account's audit logs. ([#3863](https://github.com/expo/eas-cli/pull/3863) by [@keith-kurak](https://github.com/keith-kurak))
- [eas-cli] `eas update:view [GROUP_ID]` now also accepts a platform-specific update ID, resolving it to and displaying its update group. ([#3864](https://github.com/expo/eas-cli/pull/3864) by [@keith-kurak](https://github.com/keith-kurak))
- [eas-cli] Add `eas integrations:posthog:connect` command. ([#3836](https://github.com/expo/eas-cli/pull/3836) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:posthog:dashboard` command. ([#3837](https://github.com/expo/eas-cli/pull/3837) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:posthog:disconnect` command. ([#3838](https://github.com/expo/eas-cli/pull/3838) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [eas-cli] `eas integrations:posthog:connect` no longer aborts when `npx expo install` reports an error (for example, on a dynamic app config); it finishes writing the environment variables and prints any remaining manual steps. ([#3877](https://github.com/expo/eas-cli/pull/3877) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Sync new app agent templates from `expo/llm-configs` before publishing. ([#3865](https://github.com/expo/eas-cli/pull/3865) by [@davidmokos](https://github.com/davidmokos))
- [eas-cli] Simplify 2FA now that SMS is no longer supported. ([#3859](https://github.com/expo/eas-cli/pull/3859) by [@wschurman](https://github.com/wschurman))
## [20.2.0](https://github.com/expo/eas-cli/releases/tag/v20.2.0) - 2026-06-15
### 🎉 New features
- [eas-cli] Non-interactive iOS App Store and Enterprise builds can now use the App Store Connect API key stored in EAS credentials as a submission key to validate and repair provisioning profiles on Apple servers, without requiring `EXPO_ASC_*` environment variables or an interactive Apple login. ([#3805](https://github.com/expo/eas-cli/pull/3805) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Add support for non-interactive `eas update:rollback [GROUP_ID]`. The given update group must be the latest for its branch and runtime version; the previous update group is republished, or a roll back to the embedded update is published if there is none. ([#3825](https://github.com/expo/eas-cli/pull/3825) by [@quinlanj](https://github.com/quinlanj))
### 🐛 Bug fixes
- [expo-cocoapods-proxy] Fix iOS worker tarball build failing on macOS Tahoe due to bundler incompatibility with RubyGems 4. ([#3824](https://github.com/expo/eas-cli/pull/3824) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix `update:embedded` topic showing the `update:embedded:delete` subcommand description in the command listing. ([#3866](https://github.com/expo/eas-cli/pull/3866) by [@jc-expo](https://github.com/jc-expo))
## [20.1.0](https://github.com/expo/eas-cli/releases/tag/v20.1.0) - 2026-06-05
### 🎉 New features
- [eas-cli] Add `eas update:embedded:view` command. ([#3810](https://github.com/expo/eas-cli/pull/3810) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:list` command. ([#3811](https://github.com/expo/eas-cli/pull/3811) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:delete` command. ([#3809](https://github.com/expo/eas-cli/pull/3809) by [@gwdp](https://github.com/gwdp))
- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp))
## [20.0.0](https://github.com/expo/eas-cli/releases/tag/v20.0.0) - 2026-05-29
### 🛠 Breaking changes
- [eas-cli] Rename observe commands: `observe:logs` → `observe:events` (events emitted via `logEvent`), previous `observe:events` → `observe:metrics` (individual performance metric samples), previous `observe:metrics` → `observe:metrics-summary` (aggregated stats by app version). ([#3778](https://github.com/expo/eas-cli/pull/3778) by [@kadikraman](https://github.com/kadikraman))
### 🎉 New features
- [eas-cli] `eas go` now prompts to select an Expo SDK version interactively when `--sdk-version` is not provided. ([#3768](https://github.com/expo/eas-cli/pull/3768) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:upload` command. ([#3720](https://github.com/expo/eas-cli/pull/3720) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Fix `eas/start_ios_simulator` hanging on Xcode 26.4 by writing the readiness screenshot to a temp file instead of `/dev/null`. ([#3794](https://github.com/expo/eas-cli/pull/3794) by [@gwdp](https://github.com/gwdp))
- [build-tools] Disable `apsd` in the iOS Simulator after boot to stop a push-registration log storm that pegs `diagnosticd` CPU for the lifetime of the Simulator. ([#3795](https://github.com/expo/eas-cli/pull/3795) by [@gwdp](https://github.com/gwdp))
## [19.1.0](https://github.com/expo/eas-cli/releases/tag/v19.1.0) - 2026-05-25
### 🎉 New features
- [eas-cli] Add `--refresh-ad-hoc-provisioning-profile` flag to refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials in non-interactive mode. ([#3716](https://github.com/expo/eas-cli/pull/3716) by [@sswrk](https://github.com/sswrk))
- [eas-build-job] Add optional `refreshAdHocProvisioningProfile` field to iOS build jobs. ([#3717](https://github.com/expo/eas-cli/pull/3717) by [@sswrk](https://github.com/sswrk))
- [build-tools] Pass `refreshAdHocProvisioningProfile` through `eas build:internal` for git-based integration builds. ([#3770](https://github.com/expo/eas-cli/pull/3770) by [@sswrk](https://github.com/sswrk))
## [19.0.8](https://github.com/expo/eas-cli/releases/tag/v19.0.8) - 2026-05-21
### 🐛 Bug fixes
- [eas-cli] `eas go` now pre-selects the SDK version from the current project's `app.json` or `app.config.js` when available. ([#3776](https://github.com/expo/eas-cli/pull/3776) by [@gwdp](https://github.com/gwdp))
## [19.0.7](https://github.com/expo/eas-cli/releases/tag/v19.0.7) - 2026-05-21
## [19.0.6](https://github.com/expo/eas-cli/releases/tag/v19.0.6) - 2026-05-21
### 🐛 Bug fixes
- [build-tools] Update the minimum Expo version required for iOS precompiled modules to 55.0.26. ([#3771](https://github.com/expo/eas-cli/pull/3771) by [@chrfalch](https://github.com/chrfalch))
## [19.0.5](https://github.com/expo/eas-cli/releases/tag/v19.0.5) - 2026-05-20
### 🐛 Bug fixes
- [eas-cli] Improve Sentry diagnostics for managed iOS entitlements config fallback. ([#3763](https://github.com/expo/eas-cli/pull/3763) by [@sjchmiela](https://github.com/sjchmiela))
## [19.0.4](https://github.com/expo/eas-cli/releases/tag/v19.0.4) - 2026-05-20
## [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))
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
## [18.9.0](https://github.com/expo/eas-cli/releases/tag/v18.9.0) - 2026-04-30
### 🎉 New features
- [eas-cli] Add `eas integrations:asc` commands to manage App Store Connect integrations for EAS projects. ([#3558](https://github.com/expo/eas-cli/pull/3558) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Allow `eas build:download` to accept a build ID. ([#3655](https://github.com/expo/eas-cli/pull/3655) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add `--id` alias for `--build-id` flag in `eas build:download`. ([#3656](https://github.com/expo/eas-cli/pull/3656) by [@brentvatne](https://github.com/brentvatne))
### 🐛 Bug fixes
- [steps] Fix workflow job context interpolation when a string contains multiple `${{ }}` expressions on the same line (for example a changelog built from two context values). ([#3644](https://github.com/expo/eas-cli/pull/3644) by [@gwdp](https://github.com/gwdp))
## [18.8.1](https://github.com/expo/eas-cli/releases/tag/v18.8.1) - 2026-04-23
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
### 🎉 New features
- [eas-cli] Support custom params in `observe:events` results. ([#3620](https://github.com/expo/eas-cli/pull/3620) by [@douglowder](https://github.com/douglowder))
- [build-tools] Add `eas/deploy` function for EAS Hosting web deployments. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/export` function for Expo web exports. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:insights <groupId>` command to display launch, crash, unique-user, and payload-size metrics for an update group. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `--insights` flag to `eas update:view <groupId>` to append the same metrics below the existing output. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `eas channel:insights --channel <name> --runtime-version <version>` command to display adoption and crash metrics for a channel + runtime version. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
- [eas-cli] Fix `metadata:push` not deleting video previews from App Store Connect when removed from config. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Warn and skip unknown preview types in `metadata:push` with a helpful suggestion when the `APP_` screenshot prefix is mistakenly used. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Delete all existing previews in a preview set before uploading to avoid Apple's "Too many app previews" error. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Fixes for `observe` commands, including an issue for apps with many update IDs. ([#3609](https://github.com/expo/eas-cli/pull/3609) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add existing capability identifiers. ([#3615](https://github.com/expo/eas-cli/pull/3615) by [@jakex7](https://github.com/jakex7))
## [18.7.0](https://github.com/expo/eas-cli/releases/tag/v18.7.0) - 2026-04-14
### 🎉 New features
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
## [18.6.0](https://github.com/expo/eas-cli/releases/tag/v18.6.0) - 2026-04-10
### 🎉 New features
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [build-tools] Add `working_directory` input to `eas/build` function group for custom builds. ([#3582](https://github.com/expo/eas-cli/pull/3582) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [steps] Coerce numeric env values to strings in workflow step configuration. ([#3583](https://github.com/expo/eas-cli/pull/3583) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
- [build-tools] Early stop `eas/start_android_emulator` when Android emulator host setup is invalid. ([#3580](https://github.com/expo/eas-cli/pull/3580) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.18` to fix `metadata:push` failing on `ageRatingDeclarations` due to the removed `gamblingAndContests` attribute. ([#3588](https://github.com/expo/eas-cli/pull/3588) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.19` to fix image and video uploads via `metadata:push` getting stuck in `AWAITING_UPLOAD` state. The asset client was inheriting Bearer token injection from the App Store Connect API client, which caused S3 presigned URL uploads to be silently mishandled by Apple's CDN. Fixes screenshots, previews, and App Clip header image uploads. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] `metadata:pull` now preserves screenshot, video preview, and App Clip header image entries with placeholder paths when the asset is in an unrendered state, so users can recover broken records by replacing the file or removing the entry instead of having entries silently dropped from `store.config.json`. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Surface hosting deployment's asset upload errors sooner ([#3600](https://github.com/expo/eas-cli/pull/3600) by [@kitten](https://github.com/kitten))
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
### 🎉 New features
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `observe:metrics`, `observe:events`, and `observe:versions` commands. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)), [@douglowder](https://github.com/douglowder)
### 🐛 Bug fixes
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
- Fix `hasIgnoredIosProjectAsync()` always returning `false` for ignored iOS projects. ([#3562](https://github.com/expo/eas-cli/pull/3562) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
- [eas-cli][build-tools][worker] Bump `@expo/config` and `@expo/config-plugins`. ([#3527](https://github.com/expo/eas-cli/pull/3527) by [@quinlanj](https://github.com/quinlanj))
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
### 🎉 New features
- Auto-detect non-interactive mode in CI and non-TTY environments. ([#3486](https://github.com/expo/eas-cli/pull/3486) by [@EvanBacon](https://github.com/EvanBacon))
- Add `skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
## [18.3.0](https://github.com/expo/eas-cli/releases/tag/v18.3.0) - 2026-03-10
### 🎉 New features
- Show user email in `eas account:view` output. ([#3485](https://github.com/expo/eas-cli/pull/3485) by [@brentvatne](https://github.com/brentvatne))
## [18.2.0](https://github.com/expo/eas-cli/releases/tag/v18.2.0) - 2026-03-09
### 🎉 New features
- Auto-enable `--non-interactive` when `--json` is passed, so users no longer need to specify both flags. ([#3476](https://github.com/expo/eas-cli/pull/3476) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Auto-generate Android keystore in non-interactive mode instead of throwing an error. ([#3477](https://github.com/expo/eas-cli/pull/3477) by [@EvanBacon](https://github.com/EvanBacon))
- Add `--source-maps` flag to prevent uploading server sourcemaps ([#3478](https://github.com/expo/eas-cli/pull/3478) by [@hassankhan](https://github.com/hassankhan))
### 🐛 Bug fixes
- Check all certificates in provisioning profile during verification instead of only the first one. ([#3484](https://github.com/expo/eas-cli/pull/3484) by [@qwertey6](https://github.com/qwertey6))
- Provide an override for the new --environment flag requirement in the update command. ([#3442](https://github.com/expo/eas-cli/pull/3442) by [@douglowder](https://github.com/douglowder))
- Add missing `--include=dev` for `npm install` commands ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
- Add missing `--production false` for `yarn install` commands for Yarn Classic ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- Upgrade `oclif` to v4. ([#3472](https://github.com/expo/eas-cli/pull/3472) by [@sjchmiela](https://github.com/sjchmiela))
## [18.1.0](https://github.com/expo/eas-cli/releases/tag/v18.1.0) - 2026-03-04
### 🎉 New features
- Support YAML anchors and aliases when parsing workflow files. ([#3435](https://github.com/expo/eas-cli/pull/3435) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- Remove --environment flag requirement for update:configure. ([#3440](https://github.com/expo/eas-cli/pull/3440) by [@douglowder](https://github.com/douglowder))
- Fix login spinner interfering with prompts in `eas go` command when not logged in. ([#3451](https://github.com/expo/eas-cli/pull/3451) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.6](https://github.com/expo/eas-cli/releases/tag/v18.0.6) - 2026-02-27
## [18.0.5](https://github.com/expo/eas-cli/releases/tag/v18.0.5) - 2026-02-25
### 🛠 Breaking changes
- Require --environment flag for update commands when SDK >= 55. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@douglowder](https://github.com/douglowder))
### 🎉 New features
- Add `eas observe:metrics` and `eas observe:events` commands for monitoring app performance metrics. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax))
### 🐛 Bug fixes
### 🧹 Chores
## [18.0.4](https://github.com/expo/eas-cli/releases/tag/v18.0.4) - 2026-02-23
### 🐛 Bug fixes
@@ -1961,6 +1633,7 @@ This is the log of notable changes to EAS CLI and related packages.
- Add rollback disambiguation command. ([#2004](https://github.com/expo/eas-cli/pull/2004) by [@wschurman](https://github.com/wschurman))
- Detect devices that fail to be provisioned, list them to the user and show the explanation message with the link to the devices page to check actual status. ([#2011](https://github.com/expo/eas-cli/pull/2011) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
- Add info to EAS Update asset upload process about asset counts and limits. ([#2013](https://github.com/expo/eas-cli/pull/2013) by [@wschurman](https://github.com/wschurman))
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
### 🐛 Bug fixes
+1 -3
View File
@@ -296,11 +296,9 @@ export EXPO_DEBUG=1 # Enable debug logging
### Validation
All changes should be validated before committing. At minimum run package-level tests (e.g. `jest-unit`)
for touched code, then run type checks/linting and format the repo:
All changes should be validated with TypeScript and the linter before committing:
```bash
yarn fmt # Apply Oxfmt formatting
yarn typecheck # Validate TypeScript types
yarn lint # Run Oxlint
yarn fmt:check # Run Oxfmt format check
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "20.5.0",
"version": "18.0.4",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
-2
View File
@@ -3,6 +3,4 @@ _.path = "./tools/bin"
MISE_NODE_COREPACK = "true"
[tools]
gcloud = "570.0.0"
jq = "1.8.1"
node = "20.19.4"
+2 -3
View File
@@ -17,7 +17,7 @@
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rimraf node_modules coverage"
"clean": "lerna run clean && rm -rf node_modules coverage"
},
"devDependencies": {
"@types/jest": "29.5.11",
@@ -26,11 +26,10 @@
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
"lerna": "9.0.3",
"oclif": "^4.22.85",
"oclif": "^3.9.0",
"oxfmt": "0.28.0",
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
"rimraf": "3.0.2",
"ts-jest": "29.1.1",
"typescript": "5.5.4"
},
+12 -16
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "20.4.0",
"version": "18.0.2",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -24,38 +24,35 @@
"prebuild": "yarn gql",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"prepack": "rm -rf dist *.tsbuildinfo && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo",
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
"test": "yarn jest-unit"
},
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.0.2",
"@expo/env": "^0.4.0",
"@expo/logger": "20.0.0",
"@expo/logger": "18.0.1",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.1.0",
"@expo/template-file": "20.0.0",
"@expo/turtle-spawn": "20.0.0",
"@expo/steps": "18.0.2",
"@expo/template-file": "18.0.1",
"@expo/turtle-spawn": "18.0.1",
"@expo/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
"fast-glob": "^3.3.2",
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
"gql.tada": "^1.8.13",
"joi": "^17.13.1",
@@ -77,7 +74,7 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.6.1",
"@expo/repack-app": "~0.2.5",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
@@ -93,7 +90,6 @@
"jest": "^29.7.0",
"memfs": "^4.17.1",
"nock": "13.4.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"ts-mockito": "^2.6.1",
"tslib": "^2.6.3",
@@ -19,7 +19,3 @@ export enum LoggerLevel {
ERROR = 'error',
FATAL = 'fatal',
}
export enum PipeMode {
COMBINED = 'combined',
}
@@ -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,14 +1,9 @@
import { BuildTrigger, Ios, Metadata } from '@expo/eas-build-job';
import { StepMetric } from '@expo/steps';
import { createTestIosJob } from './utils/job';
import { createMockLogger } from './utils/logger';
import { BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { uploadStepMetricsToWwwAsync } from '../utils/stepMetrics';
jest.mock('../utils/stepMetrics');
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
describe(CustomBuildContext, () => {
it('should not lose workflowInterpolationContext', () => {
@@ -39,122 +34,4 @@ describe(CustomBuildContext, () => {
foo: 'bar',
});
});
describe('reportStepMetric', () => {
const expoApiV2BaseUrl = 'http://exp.test/--/api/v2/';
const workflowJobId = 'test-workflow-job-id';
const robotAccessToken = 'test-robot-token';
const sampleMetric: StepMetric = {
metricsId: 'eas/checkout',
result: 'success',
durationMs: 1000,
platform: 'linux',
};
function createCustomBuildContext(
overrides: {
expoApiV2BaseUrl?: string | undefined;
includeRobotAccessToken?: boolean;
includeWorkflowJobId?: boolean;
} = {}
): CustomBuildContext {
const job = createTestIosJob();
if (overrides.includeRobotAccessToken !== false) {
(job.secrets as any).robotAccessToken = robotAccessToken;
}
const env: Record<string, string> = {
__API_SERVER_URL: 'http://api.expo.test',
};
if (overrides.includeWorkflowJobId !== false) {
env.__WORKFLOW_JOB_ID = workflowJobId;
}
const ctx = new BuildContext(job, {
env,
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '/workingdir',
expoApiV2BaseUrl:
'expoApiV2BaseUrl' in overrides ? overrides.expoApiV2BaseUrl : expoApiV2BaseUrl,
});
return new CustomBuildContext(ctx);
}
beforeEach(() => {
jest.clearAllMocks();
mockUploadStepMetricsToWwwAsync.mockResolvedValue(undefined);
});
it('calls uploadStepMetricsToWwwAsync when all required fields are present', () => {
const customCtx = createCustomBuildContext();
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).toHaveBeenCalledTimes(1);
expect(mockUploadStepMetricsToWwwAsync).toHaveBeenCalledWith(
expect.objectContaining({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl,
stepMetrics: [sampleMetric],
})
);
});
it('does not call upload when workflowJobId is missing', () => {
const customCtx = createCustomBuildContext({ includeWorkflowJobId: false });
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
});
it('does not call upload when robotAccessToken is missing', () => {
const customCtx = createCustomBuildContext({ includeRobotAccessToken: false });
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
});
it('does not call upload when expoApiV2BaseUrl is missing', () => {
const customCtx = createCustomBuildContext({ expoApiV2BaseUrl: undefined });
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
});
it('drainPendingMetricUploads awaits all pending uploads', async () => {
let resolveUpload!: () => void;
const uploadPromise = new Promise<void>(resolve => {
resolveUpload = resolve;
});
mockUploadStepMetricsToWwwAsync.mockReturnValue(uploadPromise);
const customCtx = createCustomBuildContext();
customCtx.reportStepMetric(sampleMetric);
let drained = false;
const drainPromise = customCtx.drainPendingMetricUploads().then(() => {
drained = true;
});
await new Promise(r => setImmediate(r));
expect(drained).toBe(false);
resolveUpload();
await drainPromise;
expect(drained).toBe(true);
});
it('drainPendingMetricUploads settles even if an upload rejects', async () => {
mockUploadStepMetricsToWwwAsync.mockRejectedValue(new Error('upload failed'));
const customCtx = createCustomBuildContext();
customCtx.reportStepMetric(sampleMetric);
await expect(customCtx.drainPendingMetricUploads()).resolves.not.toThrow();
});
});
});
@@ -1,407 +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 for build targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
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',
target: 'build',
},
},
],
},
headers: { Authorization: 'Bearer token-abc' },
retries: 2,
}
);
});
it('POSTs distribution metrics to the turtle job run metrics endpoint for jobRun targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.maestro.install', 1, {
maestro_version: '2.1.0',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.maestro.install',
type: 'distribution',
value: 1,
tags: {
maestro_version: '2.1.0',
target: 'job_run',
},
},
],
},
headers: { Authorization: 'Bearer token-abc' },
retries: 2,
}
);
});
it('POSTs logs to the turtle build logs endpoint with buildId for build targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.log('artifact dry-run matched', {
event: 'find_artifacts_absolute_path_dry_run',
status: 'match',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/logs',
'POST',
{
json: {
buildId: 'build-id',
message: 'artifact dry-run matched',
tags: {
event: 'find_artifacts_absolute_path_dry_run',
status: 'match',
target: 'build',
},
},
headers: { Authorization: 'Bearer token-abc' },
shouldThrowOnNotOk: false,
}
);
});
it('POSTs logs to the turtle job run logs endpoint with jobRunId for jobRun targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.log('Gradle cache restored (hit)', {
cache_type: 'gradle',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-job-runs/logs',
'POST',
{
json: {
jobRunId: 'job-run-id',
message: 'Gradle cache restored (hit)',
tags: {
cache_type: 'gradle',
target: 'job_run',
},
},
headers: { Authorization: 'Bearer token-abc' },
shouldThrowOnNotOk: false,
}
);
});
it('is a no-op when setup is null', () => {
Datadog.setup(null);
Datadog.distribution('eas.build.phase_duration', 1);
Datadog.log('artifact dry-run matched');
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('swallows metric upload failures for build targets', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
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: { target: 'build' },
},
],
},
})
);
});
it('labels metric upload failures for jobRun targets with the jobRun kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.maestro.install', 1);
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun metric',
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [
{
name: 'eas.maestro.install',
type: 'distribution',
value: 1,
tags: { target: 'job_run' },
},
],
},
})
);
});
it('swallows log upload failures for build targets', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.log('artifact dry-run matched');
await flushPromises();
expect(turtleFetchMock).toHaveBeenCalled();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle build log',
expect.any(Error),
expect.objectContaining({
extras: {
log: {
buildId: 'build-id',
message: 'artifact dry-run matched',
tags: { target: 'build' },
},
},
})
);
});
it('labels log upload failures for jobRun targets with the jobRun kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.log('Gradle cache restored (hit)');
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun log',
expect.any(Error),
expect.objectContaining({
extras: {
log: {
jobRunId: 'job-run-id',
message: 'Gradle cache restored (hit)',
tags: { target: 'job_run' },
},
},
})
);
});
it('uses the latest setup options', () => {
turtleFetchMock.mockResolvedValue({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'first-token',
target: { kind: 'build', turtleBuildId: 'first-build-id' },
});
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'second-token',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.build.phase_duration', 1);
Datadog.log('artifact dry-run matched');
expect(turtleFetchMock).toHaveBeenNthCalledWith(
1,
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
})
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
2,
'https://api.expo.test/v2/turtle-job-runs/logs',
'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/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
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);
});
it('flushes pending log uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
let flushed = false;
Datadog.log('artifact dry-run matched');
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
await Promise.resolve();
expect(flushed).toBe(false);
resolveUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
it('only awaits uploads enqueued since the previous flush', async () => {
let resolveSecondUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(new Promise<Response>(() => {})).mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveSecondUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
// The first upload never settles; the first flush rotates it out of the queue.
Datadog.distribution('eas.build.phase_duration', 1);
void Datadog.flushAsync();
Datadog.distribution('eas.build.phase_duration', 2);
let flushed = false;
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
resolveSecondUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
});
async function flushPromises(): Promise<void> {
await new Promise(resolve => setImmediate(resolve));
}
@@ -1,128 +0,0 @@
import { BuildPhase } from '@expo/eas-build-job';
import { BuildStepGlobalContext, StepsConfigParser } from '@expo/steps';
import { runGenericJobAsync } from '../generic';
import { CustomBuildContext } from '../customBuildContext';
import { uploadJobOutputsToWwwAsync } from '../utils/outputs';
jest.mock('../customBuildContext');
jest.mock('../utils/outputs');
jest.mock('../common/projectSources');
jest.mock('../steps/easFunctions', () => ({ getEasFunctions: jest.fn().mockReturnValue([]) }));
jest.mock('../steps/easFunctionGroups', () => ({
getEasFunctionGroups: jest.fn().mockReturnValue([]),
}));
jest.mock('@expo/steps');
jest.mock('fs/promises');
const mockUploadJobOutputsToWwwAsync = jest.mocked(uploadJobOutputsToWwwAsync);
describe(runGenericJobAsync, () => {
const expoApiV2BaseUrl = 'http://exp.test/--/api/v2/';
let mockDrainPendingMetricUploads: jest.Mock;
let mockCtx: any;
beforeEach(() => {
jest.clearAllMocks();
mockDrainPendingMetricUploads = jest.fn().mockResolvedValue(undefined);
(CustomBuildContext as unknown as jest.Mock).mockImplementation(() => ({
projectSourceDirectory: '/tmp/src',
env: { __WORKFLOW_JOB_ID: 'test-workflow-job-id' },
drainPendingMetricUploads: mockDrainPendingMetricUploads,
}));
(BuildStepGlobalContext as unknown as jest.Mock).mockImplementation(() => ({
env: { __WORKFLOW_JOB_ID: 'test-workflow-job-id' },
}));
mockCtx = {
expoApiV2BaseUrl,
job: {
secrets: { robotAccessToken: 'test-token' },
steps: [],
},
logger: {
info: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn().mockReturnThis(),
},
runBuildPhase: jest.fn(async (_phase: BuildPhase, fn: () => Promise<any>) => fn()),
};
mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined);
});
it('awaits drainPendingMetricUploads in COMPLETE_JOB phase', async () => {
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
let resolveDrain!: () => void;
mockDrainPendingMetricUploads.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
let resolved = false;
const resultPromise = runGenericJobAsync(mockCtx).then(() => {
resolved = true;
});
await new Promise(r => setImmediate(r));
expect(resolved).toBe(false);
resolveDrain();
await resultPromise;
expect(resolved).toBe(true);
});
it('awaits drainPendingMetricUploads even when outputs upload fails', async () => {
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
mockUploadJobOutputsToWwwAsync.mockRejectedValue(new Error('outputs upload failed'));
let resolveDrain!: () => void;
mockDrainPendingMetricUploads.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
let rejected = false;
const resultPromise = runGenericJobAsync(mockCtx).catch(err => {
rejected = true;
throw err;
});
await new Promise(r => setImmediate(r));
expect(rejected).toBe(false);
resolveDrain();
await expect(resultPromise).rejects.toThrow('outputs upload failed');
expect(rejected).toBe(true);
});
it('throws before workflow execution when expoApiV2BaseUrl is missing', async () => {
mockCtx.expoApiV2BaseUrl = undefined;
const mockWorkflow = { executeAsync: jest.fn() };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
await expect(runGenericJobAsync(mockCtx)).rejects.toThrow(
'expoApiV2BaseUrl is required for generic jobs'
);
expect(CustomBuildContext).not.toHaveBeenCalled();
});
});
@@ -1,329 +0,0 @@
import fetch from 'node-fetch';
import { RuntimeSettings } from '../runtimeSettings';
import { Sentry } from '../sentry';
jest.mock('node-fetch', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
describe('runtimeSettings', () => {
const originalCacheUrls = {
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
EAS_NODEJS_CACHE_URL: process.env.EAS_NODEJS_CACHE_URL,
EAS_MAVEN_CACHE_URL: process.env.EAS_MAVEN_CACHE_URL,
EAS_COCOAPODS_CACHE_URL: process.env.EAS_COCOAPODS_CACHE_URL,
};
const originalPlatform = process.platform;
afterEach(() => {
jest.mocked(fetch).mockReset();
jest.restoreAllMocks();
jest.mocked(Sentry.capture).mockReset();
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
restoreEnv('EAS_NODEJS_CACHE_URL', originalCacheUrls.EAS_NODEJS_CACHE_URL);
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
restoreEnv('EAS_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_COCOAPODS_CACHE_URL);
mockProcessPlatform(originalPlatform);
});
beforeEach(async () => {
jest.mocked(fetch).mockResolvedValue(response({}));
await RuntimeSettings.loadAsync({ environment: 'staging' });
jest.mocked(fetch).mockReset();
});
it('fetches runtime settings from the staging and production buckets', async () => {
jest.mocked(fetch).mockResolvedValue(response({}));
await RuntimeSettings.loadAsync({ environment: 'staging' });
await RuntimeSettings.loadAsync({ environment: 'production' });
await RuntimeSettings.loadAsync({ environment: 'test' });
expect(fetch).toHaveBeenNthCalledWith(
1,
'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
{
signal: expect.anything(),
}
);
expect(fetch).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
{
signal: expect.anything(),
}
);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('does not use caches by default when remote settings are unavailable', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({}, 404));
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
expect(Sentry.capture).toHaveBeenCalledWith('Failed to fetch worker runtime settings', {
extras: {
status: 404,
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
},
level: 'warning',
});
});
it('does not override existing settings when remote settings are invalid', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest
.mocked(fetch)
.mockResolvedValueOnce(response({ caches: { linux: { npm: true } } }))
.mockResolvedValueOnce(response({ iosPrecompiledModules: 'enabled' }));
await RuntimeSettings.loadAsync({ environment: 'staging' });
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to load worker runtime settings',
expect.any(Error),
{
extras: {
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
},
level: 'warning',
}
);
});
it('accepts partial runtime settings and ignores unknown fields', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: false, nodejs: true, maven: true },
darwin: { npm: true, maven: true, cocoapods: false },
},
iosPrecompiledModules: true,
unknownFutureSetting: true,
})
);
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://node.example');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(true);
});
it('uses Maven and CocoaPods caches when enabled for the current platform and their environment variables exist', async () => {
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { maven: true },
darwin: { maven: true, cocoapods: true },
},
})
);
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://pods.example');
});
it('uses job environment flags to enable cache URLs from worker environment variables', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
});
it('returns null when job env enables cache but worker env var is empty', async () => {
process.env.EAS_NPM_CACHE_URL = '';
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not use cache URLs from the job environment', async () => {
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
EAS_NPM_CACHE_URL: 'https://job-npm.example',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not enable cache when runtime settings explicitly disable it, even with job env flag', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: false } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_NPM_CACHE is set', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_MAVEN_CACHE is set', async () => {
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { maven: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_MAVEN_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getMavenCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_COCOAPODS_CACHE is set', async () => {
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { darwin: { cocoapods: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_COCOAPODS_CACHE: '1',
},
});
mockProcessPlatform('darwin');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
});
it('allows job environment flags to disable remotely enabled cache URLs', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_USE_NPM_CACHE: '0',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not use caches when runtime settings are not loaded', () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.isolateModules(() => {
const { RuntimeSettings } = require('../runtimeSettings');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
});
});
it('reports when loaded runtime settings omit the current platform cache settings', async () => {
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
mockProcessPlatform('darwin');
await RuntimeSettings.loadAsync({ environment: 'staging' });
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Runtime settings are missing cache settings for platform "darwin"',
}),
{
extras: {
configuredPlatforms: ['linux'],
platform: 'darwin',
},
level: 'error',
}
);
});
});
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
function mockProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
}
function response(body: unknown, status = 200): any {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
};
}
@@ -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) },
});
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
}
@@ -0,0 +1,57 @@
import { AndroidConfig } from '@expo/config-plugins';
import { Android } from '@expo/eas-build-job';
import fs from 'fs-extra';
import path from 'path';
import { BuildContext } from '../context';
import { EasBuildGradle } from '../templates/EasBuildGradle';
const APPLY_EAS_BUILD_GRADLE_LINE = 'apply from: "./eas-build.gradle"';
export async function configureBuildGradle(ctx: BuildContext<Android.Job>): Promise<void> {
ctx.logger.info('Injecting signing config into build.gradle');
if (await fs.pathExists(getEasBuildGradlePath(ctx.getReactNativeProjectDirectory()))) {
ctx.markBuildPhaseHasWarnings();
ctx.logger.warn('eas-build.gradle script is deprecated, please remove it from your project.');
}
await deleteEasBuildGradle(ctx.getReactNativeProjectDirectory());
await createEasBuildGradle(ctx.getReactNativeProjectDirectory());
await addApplyToBuildGradle(ctx.getReactNativeProjectDirectory());
}
async function deleteEasBuildGradle(projectRoot: string): Promise<void> {
const easBuildGradlePath = getEasBuildGradlePath(projectRoot);
await fs.remove(easBuildGradlePath);
}
function getEasBuildGradlePath(projectRoot: string): string {
return path.join(projectRoot, 'android/app/eas-build.gradle');
}
async function createEasBuildGradle(projectRoot: string): Promise<void> {
const easBuildGradlePath = getEasBuildGradlePath(projectRoot);
await fs.writeFile(easBuildGradlePath, EasBuildGradle);
}
async function addApplyToBuildGradle(projectRoot: string): Promise<void> {
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectRoot);
const buildGradleContents = await fs.readFile(path.join(buildGradlePath), 'utf8');
if (hasLine(buildGradleContents, APPLY_EAS_BUILD_GRADLE_LINE)) {
return;
}
await fs.writeFile(
buildGradlePath,
`${buildGradleContents.trim()}\n${APPLY_EAS_BUILD_GRADLE_LINE}\n`
);
}
function hasLine(haystack: string, needle: string): boolean {
return (
haystack
.replace(/\r\n/g, '\n')
.split('\n')
// Check for both single and double quotes
.some(line => line === needle || line === needle.replace(/"/g, "'"))
);
}
@@ -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');
}
@@ -26,14 +26,8 @@ describe(resolveBuildPhaseErrorAsync, () => {
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBe('NPM_CORRUPTED_PACKAGE');
expect(err.metadata).toEqual({
packageName: '@typescript-eslint/[email protected]',
});
expect(err.errorCode).toBe('NPM_CORRUPTED_PACKAGE');
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
});
it('detects log for invalid bundler and reports it to user', async () => {
@@ -51,11 +45,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
expect(err.message).toBe(
'Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.'
);
expect(err.trackingCode).toBeUndefined();
expect(err.metadata).toBeUndefined();
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
});
it('does not detect errors if they show up in different build phase', async () => {
@@ -73,10 +63,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBeUndefined();
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
});
it('detects npm cache error if cache is enabled', async () => {
@@ -95,11 +82,8 @@ describe(resolveBuildPhaseErrorAsync, () => {
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBe('NPM_CACHE_ERROR');
expect(err.errorCode).toBe('NPM_CACHE_ERROR');
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
});
it('does not detect npm cache error if cache is disabled', async () => {
@@ -119,10 +103,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBeUndefined();
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
});
it('detects xcode line error', async () => {
@@ -145,7 +126,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/path/to/'
);
expect(err.errorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.userFacingErrorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
});
it('detects minimum deployment target error correctly', async () => {
@@ -163,7 +144,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.userFacingErrorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
});
it('detects provisioning profile mismatch error correctly', async () => {
@@ -181,7 +162,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.userFacingErrorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
});
it('detects generic "Run Fastlane" error for resign correctly', async () => {
@@ -197,7 +178,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
});
it('detects build error in "Run Fastlane" phase correctly', async () => {
@@ -213,7 +194,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
});
it('detects provisioning profile mismatch error correctly', async () => {
@@ -259,8 +240,8 @@ note`;
'/path/to'
);
expect(err.errorCode).toBe('XCODE_BUILD_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.message)
expect(err.userFacingErrorCode).toBe('XCODE_BUILD_ERROR');
expect(err.userFacingMessage)
.toBe(`The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:
- The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified.
- Some other error
@@ -282,9 +263,9 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.trackingCode).toBe('MAVEN_CACHE_ERROR');
expect(err.message).toBe(
expect(err.errorCode).toBe('MAVEN_CACHE_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.userFacingMessage).toBe(
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
);
});
@@ -304,8 +285,8 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.message).toBe(
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.userFacingMessage).toBe(
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
);
});
@@ -6,8 +6,7 @@ import { ErrorContext, ErrorHandler } from './errors.types';
export class TrackedBuildError extends Error {
constructor(
public errorCode: string,
public message: string,
public metadata?: Record<string, unknown>
public message: string
) {
super(message);
}
@@ -155,9 +154,7 @@ export const buildErrorHandlers: ErrorHandler<TrackedBuildError>[] = [
// [stderr] WARN tarball tarball data for @typescript-eslint/[email protected] (sha512-cozo/GbwixVR0sgfHItz3t1yXu521yn71Wj6PlYCFA3WPhy51CUPkifFKfBis91bDclGmAY45hhaAXVjdn4new==) seems to be corrupted. Trying again.
regexp: /tarball tarball data for ([^ ]*) .* seems to be corrupted. Trying again/,
createError: (match: RegExpMatchArray) =>
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`, {
packageName: match[1],
}),
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`),
})),
...[BuildPhase.INSTALL_DEPENDENCIES, BuildPhase.PREBUILD].map(phase => ({
phase,
@@ -67,26 +67,40 @@ export async function resolveBuildPhaseErrorAsync(
logLines: string[],
errorContext: ErrorContext,
buildLogsDirectory: string
): Promise<errors.ExpoError> {
): Promise<errors.BuildError> {
const { phase } = errorContext;
if (error instanceof errors.ExpoError) {
error.buildPhase ??= phase;
if (error instanceof errors.BuildError) {
return error;
}
const xcodeBuildLogs = await maybeReadXcodeBuildLogs(phase, buildLogsDirectory);
const userError =
resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
new errors.UnknownError(errorContext.phase);
const userFacingError =
error instanceof errors.UserFacingError
? error
: (resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
new errors.UnknownError(errorContext.phase));
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
const trackingCode =
buildError && buildError.errorCode !== userError.errorCode ? buildError.errorCode : undefined;
const isUnknownUserError =
!userFacingError ||
(
[
errors.ErrorCode.UNKNOWN_ERROR,
errors.ErrorCode.UNKNOWN_GRADLE_ERROR,
errors.ErrorCode.UNKNOWN_FASTLANE_ERROR,
] as string[]
).includes(userFacingError.errorCode);
const message =
(isUnknownUserError ? buildError?.message : userFacingError.message) ?? userFacingError.message;
const errorCode =
(isUnknownUserError ? buildError?.errorCode : userFacingError.errorCode) ??
userFacingError.errorCode;
return new errors.UserError(userError.errorCode, userError.message, {
trackingCode,
docsUrl: userError.docsUrl,
return new errors.BuildError(message, {
errorCode,
userFacingErrorCode: userFacingError.errorCode,
userFacingMessage: userFacingError.message,
docsUrl: userFacingError.docsUrl,
innerError: error,
buildPhase: phase,
metadata: buildError?.metadata,
cause: error,
});
}
@@ -2,9 +2,9 @@ import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-bui
import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
import UserError = errors.UserError;
import UserFacingError = errors.UserFacingError;
export const userErrorHandlers: ErrorHandler<UserError>[] = [
export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
@@ -12,7 +12,7 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
// example log:
// [!] `React` requires CocoaPods version `>= 1.10.1`, which is not satisfied by your current version, `1.10.0`.
createError: () =>
new UserError(
new UserFacingError(
'EAS_BUILD_UNSUPPORTED_COCOAPODS_VERSION_ERROR',
`Your project requires a newer version of CocoaPods. You can update it in the build profile in eas.json by either:
- changing the current version under key "cocoapods"
@@ -27,7 +27,7 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
// example log:
// /System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems/dependency.rb:313:in `to_specs': Could not find 'bundler' (2.2.3) required by your /Users/expo/project/build/ios/Gemfile.lock. (Gem::MissingSpecVersionError)
createError: () =>
new UserError(
new UserFacingError(
'EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR',
`Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.`,
{ docsUrl: 'https://docs.expo.dev/build-reference/eas-json/' }
@@ -40,7 +40,7 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
// > Failed to read key keyalias from store "/build/workingdir/build/generic/keystore-5787e6af-3002-4cb7-8a57-3e73d13313c2.jks": Invalid keystore format
regexp: /Invalid keystore format/,
createError: () =>
new UserError(
new UserFacingError(
'EAS_BUILD_INVALID_KEYSTORE_FORMAT_ERROR',
'The keystore used in this build is malformed or it has an unsupported type. Make sure you provided the correct file.'
),
@@ -52,7 +52,7 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
// > Failed to read key keyalias from store "/build/workingdir/build/generic/keystore-286069a8-4bb9-48a6-add9-acf6b58ea06d.jks": null
regexp: /Failed to read key[^\n]+from store/,
createError: () =>
new UserError(
new UserFacingError(
'EAS_BUILD_INVALID_KEYSTORE_ALIAS_ERROR',
'The alias specified for this keystore does not exist. Make sure you specified the correct value.'
),
@@ -67,7 +67,7 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
// [11:17:29] Error: [android.dangerous]: withAndroidDangerousBaseMod: Cannot copy google-services.json from /home/expo/workingdir/build/test/test-google-services.json to /home/expo/workingdir/build/android/app/google-services.json. Please make sure the source and destination paths exist.
regexp: /Cannot copy google-services\.json/,
createError: () =>
new UserError(
new UserFacingError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR',
'"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
@@ -82,7 +82,7 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
regexp:
/File google-services\.json is missing\. The Google Services Plugin cannot function without it/,
createError: () =>
new UserError(
new UserFacingError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR',
'"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
@@ -95,7 +95,7 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
// [08:44:18] ENOENT: no such file or directory, copyfile '/Users/expo/workingdir/build/managed/abc' -> '/Users/expo/workingdir/build/managed/ios/testapp/GoogleService-Info.plist'
regexp: /ENOENT: no such file or directory, copyfile .*GoogleService-Info.plist/,
createError: () =>
new UserError(
new UserFacingError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_PLIST_ERROR',
'"GoogleService-Info.plist" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
@@ -113,7 +113,7 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
regexp:
/Specs satisfying the `(.*)` dependency were found, but they required a higher minimum deployment target/,
createError: (_, { job }) => {
return new UserError(
return new UserFacingError(
'EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR',
`Some pods require a higher minimum deployment target.
${
@@ -141,7 +141,7 @@ ${
// You should run `pod update Firebase/Core` to apply changes you've made.
regexp: /CocoaPods could not find compatible versions for pod /,
createError: () => {
return new UserError(
return new UserFacingError(
'EAS_BUILD_INCOMPATIBLE_PODS_ERROR',
`Compatible versions of some pods could not be resolved.
You are seeing this error because either:
@@ -161,7 +161,7 @@ You are seeing this error because either:
/Fix the upstream dependency conflict, or retry.*\s.*this command with --force, or --legacy-peer-deps/,
createError: (matchResult: RegExpMatchArray) => {
if (matchResult.length >= 2) {
return new UserError(
return new UserFacingError(
'EAS_BUILD_NPM_CONFLICTING_PEER_DEPENDENCIES',
`Some of your peer dependencies are not compatible. The recommended approach is to fix your dependencies by resolving any conflicts listed by "npm install". As a temporary workaround you can:
- Add ".npmrc" file with "legacy-peer-deps=true" and commit that to your repo.
@@ -179,7 +179,7 @@ You are seeing this error because either:
regexp: /Integrity check failed for "(.*)" \(computed integrity doesn't match our records, got/,
createError: (matchResult: RegExpMatchArray) => {
if (matchResult.length >= 2) {
return new UserError(
return new UserFacingError(
'EAS_BUILD_YARN_LOCK_CHECKSUM_ERROR',
`Checksum for package "${matchResult[1]}" does not match value in registry. To fix that:
- run "yarn cache clean"
@@ -203,7 +203,7 @@ You are seeing this error because either:
/\[1\/4\] Resolving packages...\s*\[2\/4\] Fetching packages...\s*\[1\/4\] Resolving packages...\s*\[2\/4\] Fetching packages.../,
createError: (matchResult: RegExpMatchArray) => {
if (matchResult) {
return new UserError(
return new UserFacingError(
'EAS_BUILD_YARN_MULTIPLE_INSTANCES_ERROR',
`One of project dependencies is starting new install process while the main one is still in progress, which might result in corrupted packages. Most likely the reason for error is "prepare" script in git-referenced dependency of your project. Learn more: https://github.com/yarnpkg/yarn/issues/7212#issuecomment-493720324`
);
@@ -230,7 +230,7 @@ You are seeing this error because either:
regexp: /error: Signing for "[a-zA-Z-0-9_]+" requires a development team/,
createError: (_, { job }) =>
'type' in job && job.type === Workflow.MANAGED
? new UserError(
? new UserFacingError(
'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR',
`Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target.
To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or upgrade to SDK 46 or higher.`,
@@ -239,7 +239,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
}
)
: new UserError(
: new UserFacingError(
'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR',
`Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target.
To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or turn off signing resource bundles in your Podfile: https://expo.fyi/r/disable-bundle-resource-signing`,
@@ -254,7 +254,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
phase: BuildPhase.RUN_GRADLEW,
regexp: /.*/,
createError: () =>
new UserError(
new UserFacingError(
errors.ErrorCode.UNKNOWN_GRADLE_ERROR,
'Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.'
),
@@ -265,7 +265,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
mode: BuildMode.RESIGN,
regexp: /No provisioning profile for application: '(.+)' with bundle identifier '(.+)'/,
createError: () =>
new UserError(
new UserFacingError(
'EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR',
`The bundle identifier in provisioning profile used to resign the app does not match the bundle identifier of the app selected to be resigned. See logs above for more information.`
),
@@ -276,7 +276,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
mode: BuildMode.RESIGN,
regexp: /.*/,
createError: () =>
new UserError(
new UserFacingError(
errors.ErrorCode.UNKNOWN_FASTLANE_RESIGN_ERROR,
`The "Run fastlane" step failed with an unknown error.`
),
@@ -297,7 +297,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
// file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary
regexp: /error: .+/g,
createError: matchResult =>
new UserError(
new UserFacingError(
'XCODE_BUILD_ERROR',
`The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:\n${matchResult
.map(match => `- ${match.replace('error: ', '')}`)
@@ -309,7 +309,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
phase: BuildPhase.RUN_FASTLANE,
regexp: /.*/,
createError: () =>
new UserError(
new UserFacingError(
errors.ErrorCode.UNKNOWN_FASTLANE_ERROR,
`The "Run fastlane" step failed with an unknown error. Refer to "Xcode Logs" below for additional, more detailed logs.`
),
@@ -1,309 +0,0 @@
import { Android, BuildPhase, BuildPhaseResult } from '@expo/eas-build-job';
import { vol } from 'memfs';
import { createTestAndroidJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { Datadog } from '../../datadog';
import { restoreCredentials } from '../../android/credentials';
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
import androidBuilder from '../android';
import { runBuilderWithHooksAsync } from '../common';
import {
injectConfigureVersionGradleConfig,
injectCredentialsGradleConfig,
} from '../../steps/utils/android/gradleConfig';
jest.mock('../common', () => ({
runBuilderWithHooksAsync: jest.fn(async (ctx, buildFn) => {
await buildFn(ctx);
return ctx.artifacts;
}),
}));
jest.mock('../custom', () => ({
runCustomBuildAsync: jest.fn(),
}));
jest.mock('../../android/credentials');
jest.mock('../../android/gradle', () => ({
ensureLFLineEndingsInGradlewScript: jest.fn(),
resolveGradleCommand: jest.fn(() => ':app:bundleRelease'),
runGradleCommand: jest.fn(),
}));
jest.mock('../../common/eagerBundle', () => ({
eagerBundleAsync: jest.fn(),
shouldUseEagerBundle: jest.fn(() => false),
}));
jest.mock('../../common/prebuild', () => ({
prebuildAsync: jest.fn(),
}));
jest.mock('../../common/setup', () => ({
setupAsync: jest.fn(),
}));
jest.mock('../../steps/functions/restoreBuildCache', () => ({
cacheStatsAsync: jest.fn(),
restoreCcacheAsync: jest.fn(),
}));
jest.mock('../../steps/functions/saveBuildCache', () => ({
saveCcacheAsync: jest.fn(),
}));
jest.mock('../../steps/utils/android/gradleConfig', () => ({
...jest.requireActual('../../steps/utils/android/gradleConfig'),
injectConfigureVersionGradleConfig: jest.fn(),
injectCredentialsGradleConfig: jest.fn(),
}));
jest.mock('../../utils/artifacts', () => ({
uploadApplicationArchive: jest.fn(),
}));
jest.mock('../../utils/expoUpdates', () => ({
configureExpoUpdatesIfInstalledAsync: jest.fn(),
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
uploadEmbeddedBundleAsync: jest.fn(),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
POST_INSTALL: 'POST_INSTALL',
PRE_UPLOAD_ARTIFACTS: 'PRE_UPLOAD_ARTIFACTS',
},
runHookIfPresent: jest.fn(),
}));
jest.mock('../../utils/prepareBuildExecutable', () => ({
prepareExecutableAsync: jest.fn(),
}));
const datadogDistributionMock = jest.spyOn(Datadog, 'distribution');
describe(androidBuilder, () => {
beforeEach(() => {
jest.clearAllMocks();
vol.fromJSON(
{
'/workingdir/env/.gitkeep': '',
'/workingdir/build/package.json': JSON.stringify({
name: 'test-app',
version: '1.0.0',
}),
},
'/'
);
});
it('injects Android version config without build credentials', async () => {
const job: Android.Job = {
...createTestAndroidJob(),
secrets: {
buildCredentials: undefined,
},
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(injectConfigureVersionGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build',
{
versionCode: '42',
versionName: '1.2.3',
}
);
expect(injectCredentialsGradleConfig).not.toHaveBeenCalled();
expect(restoreCredentials).not.toHaveBeenCalled();
});
it('injects Android credentials config when build credentials are present', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(injectCredentialsGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build'
);
expect(restoreCredentials).toHaveBeenCalledWith(ctx);
expect(injectConfigureVersionGradleConfig).not.toHaveBeenCalled();
});
it('marks the configure Android version phase as warning for legacy eas-build.gradle', async () => {
const job: Android.Job = {
...createTestAndroidJob(),
secrets: {
buildCredentials: undefined,
},
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
});
it('marks the prepare credentials phase as warning for legacy eas-build.gradle', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
});
it('injects credentials and version config when both are configured', async () => {
const job: Android.Job = {
...createTestAndroidJob(),
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(injectCredentialsGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build'
);
expect(restoreCredentials).toHaveBeenCalledWith(ctx);
expect(injectConfigureVersionGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build',
{
versionCode: '42',
versionName: '1.2.3',
}
);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
});
it('runs the builder through hooks', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
});
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
});
@@ -1,13 +1,10 @@
import { BuildJob } from '@expo/eas-build-job';
import { BuildWorkflow } from '@expo/steps';
import { vol } from 'memfs';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { prepareProjectSourcesAsync } from '../../common/projectSources';
import { BuildContext } from '../../context';
import { CustomBuildContext } from '../../customBuildContext';
import { Datadog } from '../../datadog';
import { findAndUploadXcodeBuildLogsAsync } from '../../ios/xcodeBuildLogs';
import { runCustomBuildAsync } from '../custom';
@@ -18,7 +15,6 @@ const findAndUploadXcodeBuildLogsAsyncMock = jest.mocked(findAndUploadXcodeBuild
describe(runCustomBuildAsync, () => {
let ctx: BuildContext<BuildJob>;
const datadogLogSpy = jest.spyOn(Datadog, 'log');
beforeEach(() => {
const job = createTestIosJob();
@@ -29,24 +25,10 @@ describe(runCustomBuildAsync, () => {
vol.fromJSON(
{
'test.yaml': `
functions:
test_function:
name: Test function
path: ./custom-function
shell: /bin/zsh
supported_platforms:
- darwin
- linux
inputs:
- name
- profile
outputs:
- artifact_path
build:
steps:
- eas/checkout
`,
'custom-function/package.json': '{}',
},
'/workingdir/temporary-custom-build'
);
@@ -92,75 +74,4 @@ describe(runCustomBuildAsync, () => {
expect(prepareProjectSourcesAsync).toHaveBeenCalledTimes(2);
});
it('reports user-provided custom functions when parsing the custom build config', async () => {
await runCustomBuildAsync(ctx);
expect(datadogLogSpy).toHaveBeenCalledWith('Custom build saw user-provided function', {
event: 'custom_build_user_provided_function',
custom_function_id: 'test_function',
custom_function_input_count: '2',
custom_function_module_path: '/workingdir/temporary-custom-build/custom-function',
custom_function_name: 'Test function',
custom_function_output_count: '1',
custom_function_shell: '/bin/zsh',
custom_function_supported_runtime_platforms: 'darwin,linux',
});
});
it('awaits drainPendingMetricUploads after workflow execution', async () => {
let resolveDrain!: () => void;
const drainSpy = jest
.spyOn(CustomBuildContext.prototype, 'drainPendingMetricUploads')
.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
let resolved = false;
const resultPromise = runCustomBuildAsync(ctx).then(() => {
resolved = true;
});
await new Promise(r => setImmediate(r));
expect(resolved).toBe(false);
resolveDrain();
await resultPromise;
expect(resolved).toBe(true);
drainSpy.mockRestore();
});
it('awaits drainPendingMetricUploads even when workflow throws', async () => {
let resolveDrain!: () => void;
const drainSpy = jest
.spyOn(CustomBuildContext.prototype, 'drainPendingMetricUploads')
.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
const executeSpy = jest
.spyOn(BuildWorkflow.prototype, 'executeAsync')
.mockRejectedValue(new Error('workflow failed'));
let rejected = false;
const resultPromise = runCustomBuildAsync(ctx).catch(err => {
rejected = true;
throw err;
});
await new Promise(r => setImmediate(r));
expect(rejected).toBe(false);
resolveDrain();
await expect(resultPromise).rejects.toThrow('workflow failed');
expect(rejected).toBe(true);
drainSpy.mockRestore();
executeSpy.mockRestore();
});
});
+5 -60
View File
@@ -10,29 +10,18 @@ import {
resolveGradleCommand,
runGradleCommand,
} from '../android/gradle';
import { parseGradleProfile, formatGradleProfileReport } from '../android/gradleProfile';
import { Sentry } from '../sentry';
import { configureBuildGradle } from '../android/gradleConfig';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
import {
cacheStatsAsync,
restoreCcacheAsync,
restoreGradleCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveGradleCacheAsync } from '../steps/functions/saveBuildCache';
import {
injectConfigureVersionGradleConfig,
injectCredentialsGradleConfig,
warnIfLegacyEasBuildGradleExists,
} from '../steps/utils/android/gradleConfig';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import { uploadApplicationArchive } from '../utils/artifacts';
import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
@@ -88,12 +77,6 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
env: ctx.env,
secrets: ctx.job.secrets,
});
await restoreGradleCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
@@ -106,7 +89,7 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
appConfig: await ctx.appConfig,
appConfig: ctx.appConfig,
platform: ctx.job.platform,
workflow: ctx.job.type,
env: ctx.env,
@@ -118,22 +101,10 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
nullthrows(ctx.job.secrets, 'Secrets must be defined for non-custom builds').buildCredentials
) {
await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
await warnIfLegacyEasBuildGradleExists(ctx);
await restoreCredentials(ctx);
await injectCredentialsGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory());
await configureBuildGradle(ctx);
});
}
if (ctx.job.version?.versionCode || ctx.job.version?.versionName) {
await ctx.runBuildPhase(BuildPhase.CONFIGURE_ANDROID_VERSION, async () => {
await warnIfLegacyEasBuildGradleExists(ctx);
await injectConfigureVersionGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory(), {
versionCode: ctx.job.version?.versionCode,
versionName: ctx.job.version?.versionName,
});
});
}
await ctx.runBuildPhase(BuildPhase.CONFIGURE_EXPO_UPDATES, async () => {
await configureExpoUpdatesIfInstalledAsync(ctx, {
resolvedRuntimeVersion: resolvedExpoUpdatesRuntimeVersion?.runtimeVersion ?? null,
@@ -183,20 +154,6 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
});
});
await ctx.runBuildPhase(BuildPhase.GRADLE_BUILD_PROFILE, async () => {
try {
const androidDir = path.join(ctx.getReactNativeProjectDirectory(), 'android');
const profileTasks = await parseGradleProfile(androidDir);
if (profileTasks.length > 0) {
const report = formatGradleProfileReport(profileTasks);
ctx.logger.info(report);
}
} catch (err: any) {
Sentry.capture('Failed to parse Gradle build profile', err);
ctx.markBuildPhaseSkipped();
}
});
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
});
@@ -209,12 +166,6 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
@@ -229,12 +180,6 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
env: ctx.env,
secrets: ctx.job.secrets,
});
await saveGradleCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
+1 -33
View File
@@ -1,11 +1,5 @@
import { BuildJob, BuildPhase, BuildTrigger, Ios, Platform } from '@expo/eas-build-job';
import {
BuildConfigParser,
BuildStepGlobalContext,
BuildWorkflow,
StepsConfigParser,
errors,
} from '@expo/steps';
import { BuildConfigParser, BuildStepGlobalContext, StepsConfigParser, errors } from '@expo/steps';
import assert from 'assert';
import fs from 'fs/promises';
import nullthrows from 'nullthrows';
@@ -15,7 +9,6 @@ import { resolveEnvFromBuildProfileAsync } from '../common/easBuildInternal';
import { prepareProjectSourcesAsync } from '../common/projectSources';
import { Artifacts, BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { Datadog } from '../datadog';
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
import { getEasFunctions } from '../steps/easFunctions';
@@ -88,7 +81,6 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
throw parseError;
}
});
logUserProvidedCustomFunctions(workflow);
try {
try {
await workflow.executeAsync();
@@ -102,7 +94,6 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
// do nothing, it's a non-breaking error.
}
}
await customBuildCtx.drainPendingMetricUploads();
}
} catch (err: any) {
err.artifacts = ctx.artifacts;
@@ -111,26 +102,3 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
return ctx.artifacts;
}
function logUserProvidedCustomFunctions(workflow: BuildWorkflow): void {
for (const buildFunction of Object.values(workflow.buildFunctions)) {
if (!buildFunction.customFunctionModulePath) {
continue;
}
Datadog.log('Custom build saw user-provided function', {
event: 'custom_build_user_provided_function',
custom_function_id: buildFunction.getFullId(),
custom_function_module_path: buildFunction.customFunctionModulePath,
custom_function_input_count: String(buildFunction.inputProviders?.length ?? 0),
custom_function_output_count: String(buildFunction.outputProviders?.length ?? 0),
...(buildFunction.name ? { custom_function_name: buildFunction.name } : {}),
...(buildFunction.shell ? { custom_function_shell: buildFunction.shell } : {}),
...(buildFunction.supportedRuntimePlatforms
? {
custom_function_supported_runtime_platforms:
buildFunction.supportedRuntimePlatforms.join(','),
}
: {}),
});
}
}
+3 -36
View File
@@ -16,8 +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';
import { uploadApplicationArchive } from '../utils/artifacts';
@@ -25,7 +23,6 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
@@ -54,7 +51,6 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
const evictUsedBefore = new Date();
const credentialsManager = new CredentialsManager(ctx);
const workingDirectory = ctx.getReactNativeProjectDirectory();
let fastlaneResult: { derivedDataPath: string; workspacePath: string } | undefined;
try {
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
return await credentialsManager.prepare();
@@ -107,7 +103,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
appConfig: await ctx.appConfig,
appConfig: ctx.appConfig,
platform: ctx.job.platform,
workflow: ctx.job.type,
env: ctx.env,
@@ -150,10 +146,10 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
const scheme = resolveScheme(ctx);
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
return await runFastlaneGym(ctx, {
await runFastlaneGym(ctx, {
credentials,
scheme,
buildConfiguration,
@@ -175,29 +171,6 @@ 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);
const { skipped } = await parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
logger: ctx.logger,
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
env: ctx.env,
});
if (skipped) {
ctx.markBuildPhaseSkipped();
}
} 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);
});
@@ -210,12 +183,6 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
@@ -1,199 +0,0 @@
import { ArchiveSourceType, BuildJob, Platform, Workflow } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
import { resolveEasCommandPrefixAndEnvAsync } from '../../utils/easCli';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../utils/easCli', () => ({
...jest.requireActual('../../utils/easCli'),
resolveEasCommandPrefixAndEnvAsync: jest.fn(),
}));
describe('easBuildInternal', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
cmd: 'npx',
args: ['-y', 'eas-cli@latest'],
extraEnv: {},
});
});
it('calls resolveEasCommandPrefixAndEnvAsync in runEasBuildInternalAsync', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const job = {
platform: 'android',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
await expect(
runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
})
).rejects.toThrow('build profile is missing in a build from git-based integration.');
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
it('calls resolveEasCommandPrefixAndEnvAsync in resolveEnvFromBuildProfileAsync', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const ctx = {
logger,
env: {},
job: { platform: 'ios' },
} as any;
await expect(resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' })).rejects.toThrow(
'build profile is missing in a build from git-based integration.'
);
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const internalJob = {
platform: Platform.IOS,
type: Workflow.GENERIC,
triggeredBy: 'EAS_CLI',
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
projectRootDirectory: '.',
secrets: {
buildCredentials: {
testapp: {
distributionCertificate: {
dataBase64: 'YmluYXJ5Y29udGVudDE=',
password: 'distCertPassword',
},
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
},
},
},
initiatingUserId: 'user-id',
appId: 'app-id',
};
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(
JSON.stringify({
job: internalJob,
metadata: {},
})
),
stderr: Buffer.from(''),
} as any);
const job = {
platform: Platform.IOS,
buildProfile: 'preview',
refreshAdHocProvisioningProfile: true,
appId: 'app-id',
initiatingUserId: 'user-id',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
await runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
});
expect(spawn).toHaveBeenCalledWith(
'npx',
expect.arrayContaining([
'build:internal',
'--platform',
Platform.IOS,
'--profile',
'preview',
'--refresh-ad-hoc-provisioning-profile',
]),
expect.any(Object)
);
});
it('preserves refreshAdHocProvisioningProfile from the incoming job', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const internalJob = {
platform: Platform.IOS,
type: Workflow.GENERIC,
triggeredBy: 'EAS_CLI',
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
projectRootDirectory: '.',
secrets: {
buildCredentials: {
testapp: {
distributionCertificate: {
dataBase64: 'YmluYXJ5Y29udGVudDE=',
password: 'distCertPassword',
},
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
},
},
},
initiatingUserId: 'user-id',
appId: 'app-id',
};
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(
JSON.stringify({
job: internalJob,
metadata: {},
})
),
stderr: Buffer.from(''),
} as any);
const job = {
platform: Platform.IOS,
buildProfile: 'preview',
refreshAdHocProvisioningProfile: true,
appId: 'app-id',
initiatingUserId: 'user-id',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
const { newJob } = await runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
});
expect((newJob as any).refreshAdHocProvisioningProfile).toBe(true);
});
});
@@ -1,256 +0,0 @@
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { PackageManager } from '../../utils/packageManager';
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
jest.mock('@expo/turtle-spawn', () => jest.fn());
jest.mock('../../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
describe(installDependenciesWithNpmCacheFallbackAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('retries without NPM_CONFIG_REGISTRY when npm cache install fails', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
}
);
jest
.mocked(spawn)
.mockReturnValueOnce(createSpawnPromise(Promise.reject(error)))
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenNthCalledWith(1, 'npm', ['install', '--include=dev', '--verbose'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
lineTransformer: expect.any(Function),
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
});
expect(spawn).toHaveBeenNthCalledWith(2, 'npm', ['install', '--include=dev', '--verbose'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
});
expect(logger.warn).toHaveBeenCalledWith(
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Failed to install dependencies using npm cache registry',
name: 'NpmCacheRegistryInstallError',
}),
{
level: 'warning',
tags: {
packageManager: 'npm',
},
extras: {
cwd: '/tmp/build',
npmCacheUrl,
useFrozenLockfile: false,
originalErrorMessage: 'npm install --include=dev exited with non-zero code: 1',
status: 1,
signal: null,
},
}
);
});
it('reports non-fatal npm cache registry errors from successful installs', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const auditErrorLine = `npm verbose audit error FetchError: request to ${npmCacheUrl}/-/npm/v1/security/advisories/bulk failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`;
jest.mocked(spawn).mockImplementationOnce((_command, _args, options: any) => {
options.lineTransformer(auditErrorLine);
options.lineTransformer(
`npm http cache left-pad@${npmCacheUrl}/left-pad/-/left-pad-1.3.0.tgz 0ms (cache hit)`
);
return createSpawnPromise(Promise.resolve(createSpawnResult()));
});
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Non-fatal npm cache registry error during dependency install',
name: 'NpmCacheRegistryNonFatalError',
}),
{
level: 'warning',
tags: {
packageManager: 'npm',
},
extras: {
cwd: '/tmp/build',
npmCacheUrl,
useFrozenLockfile: false,
firstErrorLine: auditErrorLine,
errorLineCount: 1,
},
}
);
});
it('does not retry when install failure does not reference npm cache registry', async () => {
const logger = createMockLogger();
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: 'npm error command sh -c node postinstall.js',
}
);
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
await expect(
installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: 'http://npm.staging.caches.eas-build.internal',
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
})
).rejects.toThrow(error);
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('does not retry when the npm cache is not enabled', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
}
);
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
await expect(
installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
})
).rejects.toThrow(error);
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('installs through the npm cache registry without retrying when the cache install succeeds', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
jest
.mocked(spawn)
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith('npm', ['install', '--include=dev'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
lineTransformer: expect.any(Function),
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
});
expect(Sentry.capture).not.toHaveBeenCalled();
});
});
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
return Object.assign(result, {
child: {
pid: 123,
},
}) as SpawnPromise<SpawnResult>;
}
function createSpawnResult(): SpawnResult {
return {
pid: 123,
output: ['', ''],
stdout: '',
stderr: '',
status: 0,
signal: null,
};
}
@@ -1,94 +0,0 @@
import { EventEmitter } from 'events';
import spawnAsync from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import fg from 'fast-glob';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { XcodeBuildLogger } from '../xcpretty';
jest.mock('@expo/spawn-async');
jest.mock('@expo/xcpretty');
jest.mock('fast-glob');
jest.mock('../../sentry', () => ({
Sentry: {
setup: jest.fn(),
capture: jest.fn(),
flush: jest.fn(),
},
}));
describe(XcodeBuildLogger, () => {
let stdout: EventEmitter;
let formatter: { pipe: jest.Mock; getBuildSummary: jest.Mock };
beforeEach(() => {
jest.clearAllMocks();
stdout = new EventEmitter();
const spawnPromise = Object.assign(new Promise(() => {}), {
child: { stdout, kill: jest.fn() },
});
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
formatter = {
pipe: jest.fn(),
getBuildSummary: jest.fn().mockReturnValue('summary'),
};
jest.mocked(ExpoRunFormatter.create).mockReturnValue(formatter as any);
jest.mocked(fg).mockResolvedValue(['build.log'] as any);
});
async function startWatchingAsync(logger: ReturnType<typeof createMockLogger>): Promise<void> {
const buildLogger = new XcodeBuildLogger(logger, '/project');
await buildLogger.watchLogFiles('/logs');
}
it('logs formatted lines', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockReturnValue(['line1', 'line2']);
stdout.emit('data', 'raw xcodebuild output');
expect(formatter.pipe).toHaveBeenCalledWith('raw xcodebuild output');
expect(logger.info).toHaveBeenCalledWith('line1');
expect(logger.info).toHaveBeenCalledWith('line2');
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('falls back to raw logs when the formatter throws', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockImplementation(() => {
throw new RangeError('Invalid array length');
});
stdout.emit('data', 'offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
});
it('reports the formatter error to Sentry only once', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
const error = new RangeError('Invalid array length');
formatter.pipe.mockImplementation(() => {
throw error;
});
stdout.emit('data', 'offending output');
stdout.emit('data', 'more offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
expect(logger.info).toHaveBeenCalledWith('more offending output');
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'xcpretty formatter failed to parse xcodebuild logs',
error,
{ extras: { data: 'offending output' } }
);
});
});
@@ -1,9 +1,8 @@
import {
BuildJob,
EasCliNpmTags,
Env,
Ios,
Metadata,
Platform,
sanitizeBuildJob,
sanitizeMetadata,
} from '@expo/eas-build-job';
@@ -15,7 +14,7 @@ import Joi from 'joi';
import nullthrows from 'nullthrows';
import { BuildContext } from '../context';
import { resolveEasCommandPrefixAndEnvAsync } from '../utils/easCli';
import { isAtLeastNpm7Async } from '../utils/packageManager';
const EasBuildInternalResultSchema = Joi.object<{ job: object; metadata: object }>({
job: Joi.object().unknown(),
@@ -50,48 +49,38 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
autoSubmitArgs.push('--auto-submit');
}
const refreshAdHocProvisioningProfileArgs = [];
if (job.platform === Platform.IOS && job.refreshAdHocProvisioningProfile === true) {
refreshAdHocProvisioningProfileArgs.push('--refresh-ad-hoc-provisioning-profile');
}
const result = await spawn(
cmd,
[
...args,
'build:internal',
'--platform',
job.platform,
'--profile',
buildProfile,
...autoSubmitArgs,
],
{
cwd,
env: {
...env,
EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds')
.robotAccessToken,
...extraEnv,
EAS_PROJECT_ROOT: projectRootOverride,
},
logger,
// This prevents printing stdout with job secrets and credentials to logs.
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
}
);
try {
const result = await spawn(
cmd,
[
...args,
'build:internal',
'--platform',
job.platform,
'--profile',
buildProfile,
...autoSubmitArgs,
...refreshAdHocProvisioningProfileArgs,
],
{
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>(
@@ -132,6 +121,33 @@ export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
return env;
}
async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
cmd: string;
args: string[];
extraEnv: Env;
}> {
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
if (process.env.ENVIRONMENT === 'development') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
};
} else if (process.env.ENVIRONMENT === 'staging') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
};
} else {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
};
}
}
function validateEasBuildInternalResult<TJob extends BuildJob>({
oldJob,
result,
@@ -152,9 +168,6 @@ function validateEasBuildInternalResult<TJob extends BuildJob>({
// We want to retain values that we have set on the job.
appId: oldJob.appId,
initiatingUserId: oldJob.initiatingUserId,
...(oldJob.platform === Platform.IOS && oldJob.refreshAdHocProvisioningProfile === true
? { refreshAdHocProvisioningProfile: true }
: null),
}) as TJob;
assert(newJob.platform === oldJob.platform, 'eas-cli returned a job for a wrong platform');
const newMetadata = sanitizeMetadata(value.metadata);
@@ -1,9 +1,8 @@
import { Job, SystemError } from '@expo/eas-build-job';
import { Job } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import path from 'path';
import { BuildContext } from '../context';
import { Sentry } from '../sentry';
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
import { isUsingModernYarnVersion } from '../utils/project';
@@ -12,7 +11,6 @@ export async function installDependenciesAsync({
env,
logger,
infoCallbackFn,
lineTransformer,
cwd,
useFrozenLockfile,
}: {
@@ -21,14 +19,12 @@ export async function installDependenciesAsync({
cwd: string;
logger: Exclude<SpawnOptions['logger'], undefined>;
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
lineTransformer?: SpawnOptions['lineTransformer'];
useFrozenLockfile: boolean;
}): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
let args: string[];
switch (packageManager) {
case PackageManager.NPM: {
args = useFrozenLockfile ? ['ci'] : ['install'];
args.push('--include=dev');
args = useFrozenLockfile ? ['ci', '--include=dev'] : ['install'];
break;
}
case PackageManager.PNPM: {
@@ -48,12 +44,7 @@ export async function installDependenciesAsync({
];
}
} else {
args = [
'install',
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
'--production',
'false',
];
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
}
break;
}
@@ -72,108 +63,11 @@ export async function installDependenciesAsync({
cwd,
logger,
infoCallbackFn,
lineTransformer,
env,
}),
};
}
export async function installDependenciesWithNpmCacheFallbackAsync({
packageManager,
env,
logger,
infoCallbackFn,
cwd,
useFrozenLockfile,
}: {
packageManager: PackageManager;
env: Record<string, string | undefined>;
cwd: string;
logger: Exclude<SpawnOptions['logger'], undefined>;
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
useFrozenLockfile: boolean;
}): Promise<void> {
const npmCacheUrl = env.EAS_BUILD_NPM_CACHE_URL;
if (!npmCacheUrl) {
await (
await installDependenciesAsync({
packageManager,
env,
logger,
infoCallbackFn,
cwd,
useFrozenLockfile,
})
).spawnPromise;
return;
}
logger.info(`Installing dependencies using the npm cache registry (${npmCacheUrl}).`);
let firstErrorLine: string | undefined;
let errorLineCount = 0;
try {
await (
await installDependenciesAsync({
packageManager,
env: { ...env, NPM_CONFIG_REGISTRY: npmCacheUrl },
logger,
infoCallbackFn,
lineTransformer: (line: string) => {
if (isNpmCacheRegistryErrorLine(line, npmCacheUrl)) {
firstErrorLine ??= line;
errorLineCount += 1;
}
return line;
},
cwd,
useFrozenLockfile,
})
).spawnPromise;
if (firstErrorLine) {
Sentry.capture(new NpmCacheRegistryNonFatalError(), {
level: 'warning',
tags: { packageManager },
extras: { cwd, npmCacheUrl, useFrozenLockfile, firstErrorLine, errorLineCount },
});
}
} catch (err: unknown) {
if (!isNpmCacheInstallFailure(err, npmCacheUrl)) {
throw err;
}
logger.warn(
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
Sentry.capture(new NpmCacheRegistryInstallError(err), {
level: 'warning',
tags: { packageManager },
extras: {
cwd,
npmCacheUrl,
useFrozenLockfile,
originalErrorMessage: err instanceof Error ? err.message : String(err),
status: err instanceof Error ? (err as any).status : undefined,
signal: err instanceof Error ? (err as any).signal : undefined,
},
});
await (
await installDependenciesAsync({
packageManager,
env,
logger,
infoCallbackFn,
cwd,
useFrozenLockfile,
})
).spawnPromise;
}
}
export function resolvePackagerDir(ctx: BuildContext<Job>): string {
const packagerRunDir = findPackagerRootDir(ctx.getReactNativeProjectDirectory());
if (packagerRunDir !== ctx.getReactNativeProjectDirectory()) {
@@ -187,36 +81,3 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
}
return packagerRunDir;
}
function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
return getErrorOutput(err).includes(npmCacheUrl);
}
function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean {
return (
line.includes(npmCacheUrl) &&
/(?:error|failed|ENOTFOUND|ECONN|ETIMEDOUT|EAI_AGAIN|FetchError)/i.test(line)
);
}
function getErrorOutput(err: unknown): string {
if (!(err instanceof Error)) {
return '';
}
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
return [stdout, stderr].filter(Boolean).join('\n');
}
class NpmCacheRegistryNonFatalError extends SystemError {
override name = 'NpmCacheRegistryNonFatalError';
constructor() {
super('Non-fatal npm cache registry error during dependency install');
}
}
class NpmCacheRegistryInstallError extends SystemError {
override name = 'NpmCacheRegistryInstallError';
constructor(cause: unknown) {
super('Failed to install dependencies using npm cache registry', { cause });
}
}
+12 -12
View File
@@ -2,10 +2,7 @@ import { BuildJob } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { SpawnOptions } from '@expo/turtle-spawn';
import {
installDependenciesWithNpmCacheFallbackAsync,
resolvePackagerDir,
} from './installDependencies';
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
import { BuildContext } from '../context';
import { runExpoCliCommand } from '../utils/project';
@@ -33,14 +30,17 @@ export async function prebuildAsync<TJob extends BuildJob>(
options: spawnOptions,
packageManager: ctx.packageManager,
});
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: ctx.packageManager,
env: ctx.env,
logger,
cwd: resolvePackagerDir(ctx),
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
useFrozenLockfile: false,
});
const installDependenciesSpawnPromise = (
await installDependenciesAsync({
packageManager: ctx.packageManager,
env: ctx.env,
logger,
cwd: resolvePackagerDir(ctx),
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
useFrozenLockfile: false,
})
).spawnPromise;
await installDependenciesSpawnPromise;
}
function getPrebuildCommandArgs<TJob extends BuildJob>(ctx: BuildContext<TJob>): string[] {
@@ -233,7 +233,7 @@ async function uploadProjectMetadataAsync(
}
`),
{
buildId: nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'),
buildId: ctx.env.EAS_BUILD_ID,
projectMetadataFile: {
type: 'GCS',
bucketKey: uploadSession.bucketKey,
+12 -16
View File
@@ -1,13 +1,7 @@
import { ExpoConfig } from '@expo/config';
import {
BuildJob,
BuildPhase,
BuildTrigger,
Ios,
Job,
Platform,
UserError,
} from '@expo/eas-build-job';
import { BuildJob, BuildPhase, Ios, Job, Platform } from '@expo/eas-build-job';
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
@@ -26,7 +20,7 @@ import {
shouldUseFrozenLockfile,
} from '../utils/packageManager';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
import { readEasJsonContents, readPackageJson } from '../utils/project';
import { retryAsync } from '../utils/retry';
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
@@ -82,7 +76,10 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
ctx.logger.info('Using package.json:');
const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
ctx.logger.info(JSON.stringify(packageJson, null, 2));
return packageJson;
});
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
@@ -110,7 +107,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
const appConfig = await ctx.appConfig;
const appConfig = ctx.appConfig;
ctx.logger.info('Using app configuration:');
ctx.logger.info(JSON.stringify(appConfig, null, 2));
await validateAppConfigAsync(ctx, appConfig);
@@ -118,13 +115,12 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) {
await ctx.runBuildPhase(BuildPhase.EAS_BUILD_INTERNAL, async () => {
const appConfig = await ctx.appConfig;
if (!appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
if (!ctx.appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
throw new Error(
'The "ios.bundleIdentifier" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#bundleidentifier.'
);
}
if (!appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
if (!ctx.appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
throw new Error(
'The "android.package" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#package.'
);
@@ -285,7 +281,7 @@ async function validateAppConfigAsync(
extraMessage =
'If you are using environment variables to switch between projects in app.config.js/app.config.ts, make sure those variables are also set inside EAS Build. You can do that using "env" field in eas.json or EAS environment variables. ';
}
throw new UserError(
throw new UserFacingError(
'EAS_BUILD_PROJECT_ID_MISMATCH',
`The value of the "extra.eas.projectId" field (${appConfig.extra.eas.projectId}) in the app config does not match the current project id (${ctx.env.EAS_BUILD_PROJECT_ID}). ${extraMessage}Learn more: https://expo.fyi/eas-config-mismatch.`
);
+13 -22
View File
@@ -2,6 +2,7 @@ import { ExpoConfig } from '@expo/config';
import {
BuildPhase,
BuildPhaseResult,
BuildPhaseStats,
Env,
EnvironmentSecretType,
GenericArtifactType,
@@ -19,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';
@@ -45,7 +45,6 @@ export type ArtifactToUpload =
type: GenericArtifactType;
name: string;
paths: string[];
metadata?: Record<string, unknown>;
};
export interface BuildContextOptions {
@@ -63,11 +62,11 @@ export interface BuildContextOptions {
reportError?: (
msg: string,
err?: Error,
options?: { tags?: Record<string, string>; extras?: Record<string, string | undefined> }
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
) => void;
reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
skipNativeBuild?: boolean;
metadata?: Metadata;
expoApiV2BaseUrl?: string;
}
export class SkipNativeBuildError extends Error {}
@@ -80,10 +79,9 @@ export class BuildContext<TJob extends Job = Job> {
public readonly reportError?: (
msg: string,
err?: Error,
options?: { tags?: Record<string, string>; extras?: Record<string, string | undefined> }
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
) => void;
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
public artifacts: Artifacts = {};
private readonly _isLocal: boolean;
@@ -96,7 +94,8 @@ export class BuildContext<TJob extends Job = Job> {
private buildPhase?: BuildPhase;
private buildPhaseSkipped = false;
private buildPhaseHasWarnings = false;
private _appConfig?: Promise<ExpoConfig>;
private _appConfig?: ExpoConfig;
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
public readonly graphqlClient: Client;
constructor(job: TJob, options: BuildContextOptions) {
@@ -111,7 +110,7 @@ export class BuildContext<TJob extends Job = Job> {
this._job = job;
this._metadata = options.metadata;
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.reportBuildPhaseStats = options.reportBuildPhaseStats;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -171,18 +170,16 @@ export class BuildContext<TJob extends Job = Job> {
return path.join(this.workingdir, 'env');
}
public get packageManager(): PackageManager {
return resolvePackageManager(this.getReactNativeProjectDirectory(), {
env: this.env,
});
return resolvePackageManager(this.getReactNativeProjectDirectory());
}
public get appConfig(): Promise<ExpoConfig> {
public get appConfig(): ExpoConfig {
if (!this._appConfig) {
this._appConfig = readAppConfig({
projectDir: this.getReactNativeProjectDirectory(),
env: this.env,
logger: this.logger,
sdkVersion: this.metadata?.sdkVersion,
}).then(config => config.exp);
}).exp;
}
return this._appConfig;
}
@@ -287,7 +284,7 @@ export class BuildContext<TJob extends Job = Job> {
private async handleBuildPhaseErrorAsync(
err: any,
buildPhase: BuildPhase
): Promise<errors.ExpoError> {
): Promise<errors.BuildError> {
const buildError = await resolveBuildPhaseErrorAsync(
err,
this.logBuffer.getPhaseLogs(buildPhase),
@@ -302,7 +299,7 @@ export class BuildContext<TJob extends Job = Job> {
// leaving message empty, website will display err.stack which already includes err.message
this.logger.error({ err }, '');
} else {
this.logger.error(`Error: ${buildError.message}`);
this.logger.error(`Error: ${buildError.userFacingMessage}`);
}
return buildError;
}
@@ -352,13 +349,7 @@ export class BuildContext<TJob extends Job = Job> {
}
await this.collectAndUpdateEnvVariablesAsync();
if (this.job.platform) {
Datadog.distribution('eas.build.phase_duration', durationMs, {
build_phase: this.buildPhase.toLowerCase(),
platform: this.job.platform,
result,
});
}
this.reportBuildPhaseStats?.({ buildPhase: this.buildPhase, result, durationMs });
if (!doNotMarkEnd) {
this.logger.info(
+1 -30
View File
@@ -9,13 +9,11 @@ import {
StaticJobInterpolationContext,
} from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, ExternalBuildContextProvider, StepMetric } from '@expo/steps';
import { Client } from '@urql/core';
import { BuildRuntimePlatform, ExternalBuildContextProvider } from '@expo/steps';
import assert from 'assert';
import path from 'path';
import { ArtifactToUpload, BuildContext } from './context';
import { uploadStepMetricsToWwwAsync } from './utils/stepMetrics';
const platformToBuildRuntimePlatform: Record<Platform, BuildRuntimePlatform> = {
[Platform.ANDROID]: BuildRuntimePlatform.LINUX,
@@ -55,14 +53,11 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
public readonly startTime: Date;
public readonly logger: bunyan;
public readonly graphqlClient: Client;
public readonly runtimeApi: BuilderRuntimeApi;
public job: TJob;
public metadata?: Metadata;
private _env: Env;
private readonly expoApiV2BaseUrl?: string;
private readonly pendingMetricUploads: Promise<void>[] = [];
constructor(buildCtx: BuildContext<TJob>) {
this._env = buildCtx.env;
@@ -70,7 +65,6 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.metadata = buildCtx.metadata;
this.logger = buildCtx.logger.child({ phase: BuildPhase.CUSTOM });
this.graphqlClient = buildCtx.graphqlClient;
this.projectSourceDirectory = path.join(buildCtx.workingdir, 'temporary-custom-build');
this.projectTargetDirectory = path.join(buildCtx.workingdir, 'build');
this.defaultWorkingDirectory = buildCtx.getReactNativeProjectDirectory();
@@ -78,7 +72,6 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.runtimeApi = {
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
};
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
this.startTime = new Date();
}
@@ -146,26 +139,4 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
};
this.metadata = metadata;
}
public reportStepMetric(metric: StepMetric): void {
const workflowJobId = this._env.__WORKFLOW_JOB_ID;
const robotAccessToken = this.job.secrets?.robotAccessToken;
if (!workflowJobId || !robotAccessToken || !this.expoApiV2BaseUrl) {
return;
}
const p = uploadStepMetricsToWwwAsync({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl: this.expoApiV2BaseUrl,
stepMetrics: [metric],
logger: this.logger,
});
this.pendingMetricUploads.push(p);
}
public async drainPendingMetricUploads(): Promise<void> {
await Promise.allSettled(this.pendingMetricUploads);
}
}
-97
View File
@@ -1,97 +0,0 @@
import { Sentry } from './sentry';
import { turtleFetch } from './utils/turtleFetch';
type MetricsTarget =
| { kind: 'build'; turtleBuildId: string }
| { kind: 'jobRun'; turtleJobRunId: string };
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: MetricsTarget;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingUploads: Promise<unknown>[] = [];
function withTargetTag(
tags: Record<string, string>,
target: MetricsTarget
): Record<string, string> {
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
}
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, robotAccessToken, target } = setupOptions;
const metrics = [
{
name,
type: 'distribution' as const,
value,
tags: withTargetTag(tags, target),
},
];
const metricsPath =
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
extras: { metrics },
});
});
pendingUploads.push(uploadPromise);
},
log(message: string, tags: Record<string, string> = {}): void {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const tagsWithTarget = withTargetTag(tags, target);
const log =
target.kind === 'build'
? { buildId: target.turtleBuildId, message, tags: tagsWithTarget }
: { jobRunId: target.turtleJobRunId, message, tags: tagsWithTarget };
const logsPath = target.kind === 'build' ? 'turtle-builds/logs' : 'turtle-job-runs/logs';
const uploadPromise = turtleFetch(new URL(logsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: log,
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} log`, err, {
extras: { log },
});
});
pendingUploads.push(uploadPromise);
},
async flushAsync(): Promise<void> {
// Rotate so each flush only awaits its own batch; uploads enqueued during the
// await land in the fresh array for a later flush.
const uploads = pendingUploads;
pendingUploads = [];
await Promise.allSettled(uploads);
},
};
+18 -17
View File
@@ -2,7 +2,6 @@ import { BuildPhase, Generic } from '@expo/eas-build-job';
import { Result, asyncResult } from '@expo/results';
import { BuildStepGlobalContext, BuildWorkflow, StepsConfigParser, errors } from '@expo/steps';
import fs from 'fs/promises';
import nullthrows from 'nullthrows';
import { prepareProjectSourcesAsync } from './common/projectSources';
import { BuildContext } from './context';
@@ -11,17 +10,12 @@ import { getEasFunctionGroups } from './steps/easFunctionGroups';
import { getEasFunctions } from './steps/easFunctions';
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
import { retryAsync } from './utils/retry';
import { uploadStepMetricsToWwwAsync } from './utils/stepMetrics';
export async function runGenericJobAsync(
ctx: BuildContext<Generic.Job>
ctx: BuildContext<Generic.Job>,
{ expoApiV2BaseUrl }: { expoApiV2BaseUrl: string }
): Promise<{ runResult: Result<void>; buildWorkflow: BuildWorkflow }> {
// expoApiV2BaseUrl is empty when running a local build, but generic jobs
// are never executed locally, so it is always present here.
const expoApiV2BaseUrl = nullthrows(
ctx.expoApiV2BaseUrl,
'expoApiV2BaseUrl is required for generic jobs'
);
const customBuildCtx = new CustomBuildContext(ctx);
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {
@@ -66,15 +60,22 @@ export async function runGenericJobAsync(
const runResult = await asyncResult(workflow.executeAsync());
await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => {
const results = await Promise.allSettled([
uploadJobOutputsToWwwAsync(globalContext, {
logger: ctx.logger,
await uploadJobOutputsToWwwAsync(globalContext, {
logger: ctx.logger,
expoApiV2BaseUrl,
});
const workflowJobId = globalContext.env.__WORKFLOW_JOB_ID;
const robotAccessToken = ctx.job.secrets?.robotAccessToken;
if (workflowJobId && robotAccessToken) {
await uploadStepMetricsToWwwAsync({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl,
}),
customBuildCtx.drainPendingMetricUploads(),
]);
if (results[0].status === 'rejected') {
throw results[0].reason;
stepMetrics: globalContext.stepMetrics,
logger: ctx.logger,
});
}
});
-7
View File
@@ -20,11 +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';
export * from './runtimeSettings';
@@ -9,7 +9,6 @@ exports[`fastfile createFastfileForResigningBuild should create Fastfile with al
"com.example.app" => "/path/to/profiles/main.mobileprovision",
"com.example.app.widget" => "/path/to/profiles/widget.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/Users/expo/Library/Keychains/login.keychain"
)
end
@@ -23,7 +22,6 @@ exports[`fastfile createFastfileForResigningBuild should handle empty provisioni
signing_identity: "iPhone Distribution",
provisioning_profile: {
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -41,7 +39,6 @@ exports[`fastfile createFastfileForResigningBuild should handle multiple provisi
"com.example.app.extension" => "/profiles/extension.mobileprovision",
"com.example.app.intents" => "/profiles/intents.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -56,7 +53,6 @@ exports[`fastfile createFastfileForResigningBuild should handle paths with speci
provisioning_profile: {
"com.example.app" => "/path/with spaces/profile (1).mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/Users/expo/Library/Keychains/login keychain.keychain"
)
end
@@ -71,7 +67,6 @@ exports[`fastfile createFastfileForResigningBuild should handle single provision
provisioning_profile: {
"com.example.app" => "/tmp/profile.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -86,7 +81,6 @@ exports[`fastfile createFastfileForResigningBuild should produce valid Ruby synt
provisioning_profile: {
"com.example.app" => "/path/to/profile.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -22,10 +22,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /Users/expo/Library/Keychains
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -49,10 +45,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -78,10 +70,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -109,10 +97,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -133,9 +117,6 @@ destination("generic/platform=iOS Simulator")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -153,9 +134,6 @@ destination("platform=iOS Simulator,name=iPhone 15")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -175,8 +153,5 @@ destination("generic/platform=tvOS Simulator")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -165,52 +165,6 @@ describe(configureXcodeProject, () => {
'Info.plist application target'
);
});
it('configures credentials with Apple Development identity for development profile', async () => {
vol.fromJSON(
{
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
'utf-8'
),
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const options = {
credentials: {
keychainPath: 'fake/path',
targetProvisioningProfiles: {
testapp: {
path: 'fake/path.mobileprovision',
target: 'testapp',
bundleIdentifier: 'abc',
teamId: 'ABCDEFGH',
uuid: 'abc',
name: 'profile name',
developerCertificate: Buffer.from('test'),
certificateCommonName: 'Apple Development: Test User',
distributionType: DistributionType.DEVELOPMENT,
},
},
distributionType: DistributionType.DEVELOPMENT,
teamId: 'ABCDEFGH',
applicationTargetProvisioningProfile: {} as ProvisioningProfile<Ios.Job>,
},
buildConfiguration: 'Release',
};
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: jest.fn() },
job: {},
};
await configureXcodeProject(ctx as any, options);
const pbxproj = vol.readFileSync(
'/app/ios/testapp.xcodeproj/project.pbxproj',
'utf-8'
) as string;
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
expect(pbxproj).not.toContain('"iPhone Distribution"');
});
it('configures credentials and versions for multi target project', async () => {
vol.fromJSON(
{
@@ -1,128 +0,0 @@
import { Ios } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import path from 'path';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { Credentials } from '../credentials/manager';
import { DistributionType } from '../credentials/provisioningProfile';
import { runFastlaneGym } from '../fastlane';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../tvos', () => ({
isTVOS: jest.fn().mockResolvedValue(false),
}));
const WORKING_DIR = '/workingdir';
// BuildContext.getReactNativeProjectDirectory() nests the RN project under `${workingdir}/build`.
const IOS_DIR = path.join(WORKING_DIR, 'build', 'ios');
function makeIosBuildContext({ simulator }: { simulator: boolean }): BuildContext<Ios.Job> {
const job: Ios.Job = { ...createTestIosJob(), simulator };
return new BuildContext(job, {
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: { __API_SERVER_URL: 'http://api.expo.test' },
uploadArtifact: jest.fn(),
});
}
const ARCHIVE_CREDENTIALS: Credentials = {
keychainPath: '/Users/expo/Library/Keychains/login.keychain',
distributionType: DistributionType.APP_STORE,
teamId: 'TEAM123',
applicationTargetProvisioningProfile: {} as any,
targetProvisioningProfiles: {
'com.example.app': {
bundleIdentifier: 'com.example.app',
uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
path: '/path/to/profile1.mobileprovision',
target: 'com.example.app',
teamId: 'TEAM123',
name: 'Main App Profile',
developerCertificate: Buffer.from('cert'),
certificateCommonName: 'iPhone Distribution',
distributionType: DistributionType.APP_STORE,
},
},
};
function readDerivedDataPathFromGymfile(gymfileContent: string, workspacePath: string): string {
const match = gymfileContent.match(/derived_data_path\("([^"]+)"\)/);
if (!match) {
throw new Error(
`Gymfile did not contain derived_data_path("..."). Content:\n${gymfileContent}`
);
}
return path.resolve(workspacePath, match[1]);
}
describe(runFastlaneGym, () => {
beforeEach(() => {
vol.reset();
vol.fromJSON({ [path.join(IOS_DIR, '.keep')]: '' });
(spawn as jest.Mock).mockClear();
});
describe.each([
{
name: 'archive',
simulator: false,
credentials: ARCHIVE_CREDENTIALS,
buildConfiguration: 'Release',
},
{
name: 'simulator',
simulator: true,
credentials: null,
buildConfiguration: 'Debug',
},
])('$name build', ({ simulator, credentials, buildConfiguration }) => {
it('returns paths that match the generated Gymfile template', async () => {
const ctx = makeIosBuildContext({ simulator });
const result = await runFastlaneGym(ctx, {
scheme: 'App',
buildConfiguration,
credentials,
entitlements: null,
});
expect(result).toEqual({
derivedDataPath: path.join(IOS_DIR, 'build'),
workspacePath: IOS_DIR,
});
const gymfileContent = vol.readFileSync(path.join(IOS_DIR, 'Gymfile'), 'utf8') as string;
expect(readDerivedDataPathFromGymfile(gymfileContent, result.workspacePath)).toBe(
result.derivedDataPath
);
});
});
it('still returns EAS-convention paths when a pre-existing ios/Gymfile is present', async () => {
vol.fromJSON({
[path.join(IOS_DIR, 'Gymfile')]: '# user-supplied Gymfile\n',
});
const ctx = makeIosBuildContext({ simulator: false });
const result = await runFastlaneGym(ctx, {
scheme: 'App',
credentials: ARCHIVE_CREDENTIALS,
entitlements: null,
});
expect(result).toEqual({
derivedDataPath: path.join(IOS_DIR, 'build'),
workspacePath: IOS_DIR,
});
});
});
@@ -58,8 +58,6 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Release',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
});
@@ -96,8 +94,6 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'TestScheme',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: false,
logsDirectory: '/tmp/logs',
});
@@ -135,8 +131,6 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Release',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
entitlements: {
@@ -198,8 +192,6 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'MyApp',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
});
@@ -218,7 +210,6 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Debug',
derivedDataPath: '/tmp/derived-data',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
simulatorDestination: 'generic/platform=iOS Simulator',
@@ -235,7 +226,6 @@ describe('gymfile', () => {
outputFile,
scheme: 'TestApp',
derivedDataPath: '/tmp/derived',
resultBundlePath: './build/result-bundle.xcresult',
clean: false,
logsDirectory: '/tmp/logs',
simulatorDestination: 'platform=iOS Simulator,name=iPhone 15',
@@ -253,7 +243,6 @@ describe('gymfile', () => {
scheme: 'MyTVApp',
buildConfiguration: 'Debug',
derivedDataPath: '/tmp/derived',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
simulatorDestination: 'generic/platform=tvOS Simulator',
@@ -1,203 +0,0 @@
import { Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { installPods } from '../pod';
jest.mock('@expo/eas-build-job', () => ({
...jest.requireActual('@expo/eas-build-job'),
getInstalledExpoPackageVersionAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const WORKING_DIR = '/workingdir';
const PROJECT_DIR = path.join(WORKING_DIR, 'build');
const IOS_DIR = path.join(PROJECT_DIR, 'ios');
function makeIosBuildContext({
expoVersion,
cocoapodsCacheUrl,
usePrecompiledModules = true,
expoUsePrecompiledModules,
}: {
expoVersion?: string;
cocoapodsCacheUrl?: string;
usePrecompiledModules?: boolean;
expoUsePrecompiledModules?: string;
}): BuildContext<Ios.Job> {
vol.fromJSON({
[path.join(IOS_DIR, '.keep')]: '',
...(expoVersion
? {
[path.join(PROJECT_DIR, 'node_modules/expo/package.json')]: JSON.stringify({
version: expoVersion,
}),
}
: {}),
});
const job: Ios.Job = {
...createTestIosJob(),
builderEnvironment: {
env: {
...(usePrecompiledModules ? { EAS_USE_PRECOMPILED_MODULES: '1' } : {}),
},
},
};
return new BuildContext(job, {
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: {
__API_SERVER_URL: 'http://api.expo.test',
...(expoUsePrecompiledModules
? { EXPO_USE_PRECOMPILED_MODULES: expoUsePrecompiledModules }
: {}),
...(cocoapodsCacheUrl ? { EAS_BUILD_COCOAPODS_CACHE_URL: cocoapodsCacheUrl } : {}),
},
uploadArtifact: jest.fn(),
});
}
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).mockResolvedValue(undefined);
});
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
const ctx = makeIosBuildContext({ expoVersion: '999.0.0' });
await installPods(ctx, {});
expect(getInstalledExpoPackageVersionAsync).toHaveBeenCalledWith({
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
projectDir: PROJECT_DIR,
});
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: IOS_DIR,
env: expect.objectContaining({
EXPO_USE_PRECOMPILED_MODULES: '1',
EXPO_PRECOMPILED_MODULES_BASE_URL:
'https://storage.googleapis.com/eas-build-precompiled-modules/',
}),
})
);
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
);
});
it('proxies precompiled modules through CocoaPods cache when enabled', async () => {
const ctx = makeIosBuildContext({
expoVersion: '999.0.0',
cocoapodsCacheUrl: 'https://cocoapods-cache.expo.test/',
});
await installPods(ctx, {});
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: IOS_DIR,
env: expect.objectContaining({
EXPO_USE_PRECOMPILED_MODULES: '1',
EXPO_PRECOMPILED_MODULES_BASE_URL:
'https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/',
}),
})
);
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
);
});
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.25' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringMatching(
/^Detected expo=55\.0\.25; not enabling precompiled modules use because precompiled modules require expo>=55\.0\.26\./
)
);
});
it('does not add precompiled modules env vars when Expo version is invalid', async () => {
const ctx = makeIosBuildContext({ expoVersion: 'invalid-version' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
);
});
it('does not add precompiled modules env vars when Expo version detection fails', async () => {
const ctx = makeIosBuildContext({});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
);
});
it('does not add precompiled modules env vars without the builder flag', async () => {
const ctx = makeIosBuildContext({
expoVersion: '999.0.0',
usePrecompiledModules: false,
});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(
(spawn as jest.Mock).mock.calls[0][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
expect(ctx.logger.info).not.toHaveBeenCalled();
});
it('does not override EXPO_USE_PRECOMPILED_MODULES=0 even when builder flag is enabled', async () => {
const ctx = makeIosBuildContext({
expoVersion: '999.0.0',
usePrecompiledModules: true,
expoUsePrecompiledModules: '0',
});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBe('0');
expect(
(spawn as jest.Mock).mock.calls[0][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'EXPO_USE_PRECOMPILED_MODULES=0 is set; not enabling precompiled modules use.'
);
expect(getInstalledExpoPackageVersionAsync).not.toHaveBeenCalled();
});
});
@@ -6,7 +6,6 @@ import uniq from 'lodash/uniq';
import path from 'path';
import { Credentials } from './credentials/manager';
import { DistributionType } from './credentials/provisioningProfile';
import { BuildContext } from '../context';
async function configureXcodeProject(
@@ -57,9 +56,6 @@ async function configureCredentialsAsync(
profileName: profile.name,
appleTeamId: profile.teamId,
buildConfiguration,
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
codeSignIdentity: profile.certificateCommonName,
}),
}
);
}
@@ -1,6 +1,5 @@
import { Ios } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import crypto from 'crypto';
import { provisioningProfile } from './fixtures';
import { BuildContext } from '../../../context';
@@ -74,45 +73,5 @@ describe('ProvisioningProfile class', () => {
await pp.destroy();
}
});
it('should match a certificate that is not at index 0 in DeveloperCertificates', async () => {
const pp = new ProvisioningProfile(
ctx,
Buffer.from(provisioningProfile.dataBase64, 'base64'),
keychain.data.path,
'testapp',
'Abc 123'
);
try {
await pp.init();
// Prepend an unrelated certificate so the real one moves to index 1
const dummyCert = Buffer.from('dummy-certificate-data');
(pp as any).developerCertificates = [dummyCert, ...(pp as any).developerCertificates];
// The original cert fingerprint should still match
expect(() => {
pp.verifyCertificate(provisioningProfile.certFingerprint);
}).not.toThrow();
// A non-matching fingerprint should still fail
expect(() => {
pp.verifyCertificate('2137');
}).toThrowError(/don't match/);
// The dummy cert's fingerprint should also match
const dummyFingerprint = crypto
.createHash('sha1')
.update(new Uint8Array(dummyCert))
.digest('hex')
.toUpperCase();
expect(() => {
pp.verifyCertificate(dummyFingerprint);
}).not.toThrow();
} finally {
await pp.destroy();
}
});
});
});
@@ -24,7 +24,6 @@ export interface ProvisioningProfileData {
export enum DistributionType {
AD_HOC = 'ad-hoc',
APP_STORE = 'app-store',
DEVELOPMENT = 'development',
ENTERPRISE = 'enterprise',
}
@@ -44,7 +43,6 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
private readonly profilePath: string;
private profileData?: ProvisioningProfileData;
private developerCertificates: Buffer[] = [];
constructor(
private readonly ctx: BuildContext<TJob>,
@@ -79,12 +77,11 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
}
public verifyCertificate(fingerprint: string): void {
const devCertFingerprints = this.getAllDerCertFingerprints();
if (!devCertFingerprints.includes(fingerprint)) {
const devCertFingerprint = this.genDerCertFingerprint();
if (devCertFingerprint !== fingerprint) {
throw new errors.CredentialsDistCertMismatchError(
`Provisioning profile and distribution certificate don't match.\n` +
`Profile's certificate fingerprints = [${devCertFingerprints.join(', ')}], ` +
`distribution certificate fingerprint = ${fingerprint}`
`Provisioning profile and distribution certificate don't match.
Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}`
);
}
}
@@ -117,10 +114,6 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
] as string;
const bundleIdentifier = applicationIdentifier.replace(/^.+?\./, '');
this.developerCertificates = (plistData.DeveloperCertificates as string[]).map((cert: string) =>
Buffer.from(cert, 'base64')
);
this.profileData = {
path: this.profilePath,
target: this.target,
@@ -128,7 +121,7 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
teamId: (plistData.TeamIdentifier as string[])[0],
uuid: plistData.UUID as string,
name: plistData.Name as string,
developerCertificate: this.developerCertificates[0],
developerCertificate: Buffer.from((plistData.DeveloperCertificates as string[])[0], 'base64'),
certificateCommonName: this.certificateCommonName,
distributionType: this.resolveDistributionType(plistData),
};
@@ -138,19 +131,17 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
if (plistData.ProvisionsAllDevices) {
return DistributionType.ENTERPRISE;
} else if (plistData.ProvisionedDevices) {
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
if (entitlements?.['get-task-allow']) {
return DistributionType.DEVELOPMENT;
}
return DistributionType.AD_HOC;
} else {
return DistributionType.APP_STORE;
}
}
private getAllDerCertFingerprints(): string[] {
return this.developerCertificates.map(cert =>
crypto.createHash('sha1').update(new Uint8Array(cert)).digest('hex').toUpperCase()
);
private genDerCertFingerprint(): string {
return crypto
.createHash('sha1')
.update(new Uint8Array(this.data.developerCertificate))
.digest('hex')
.toUpperCase();
}
}
+4 -18
View File
@@ -3,15 +3,14 @@ import { bunyan } from '@expo/logger';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import os from 'os';
import path from 'path';
import type { Credentials } from './credentials/manager';
import { createFastfileForResigningBuild } from './fastfile';
import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile';
import { isTVOS } from './tvos';
import { XcodeBuildLogger } from './xcpretty';
import { COMMON_FASTLANE_ENV } from '../common/fastlane';
import { XcodeBuildLogger } from '../common/xcpretty';
import { BuildContext, SkipNativeBuildError } from '../context';
export async function runFastlaneGym<TJob extends Ios.Job>(
@@ -29,13 +28,7 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
entitlements: object | null;
extraEnv?: Env;
}
): Promise<{
derivedDataPath: string;
workspacePath: string;
}> {
const workspacePath = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
const derivedDataPath = path.join(workspacePath, 'build');
): Promise<void> {
await ensureGymfileExists(ctx, {
scheme,
buildConfiguration,
@@ -50,15 +43,13 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
try {
await runFastlane(['gym'], {
cwd: workspacePath,
cwd: path.join(ctx.getReactNativeProjectDirectory(), 'ios'),
logger: ctx.logger,
env: { ...ctx.env, ...extraEnv },
});
} finally {
await buildLogger.flush();
}
return { derivedDataPath, workspacePath };
}
export async function runFastlaneResign<TJob extends Ios.Job>(
@@ -93,7 +84,7 @@ export async function runFastlane(
cwd,
}: {
logger?: bunyan;
env?: Env;
env?: Record<string, string>;
cwd?: string;
} = {}
): Promise<SpawnResult> {
@@ -131,8 +122,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
}
ctx.logger.info('Creating Gymfile');
const resultBundlePath = path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`);
if (ctx.job.simulator) {
const isTV = await isTVOS(ctx);
const simulatorDestination = `generic/platform=${isTV ? 'tvOS' : 'iOS'} Simulator`;
@@ -142,7 +131,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
scheme,
buildConfiguration: buildConfiguration ?? 'release',
derivedDataPath: './build',
resultBundlePath,
clean: false,
logsDirectory,
simulatorDestination,
@@ -154,8 +142,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
scheme,
buildConfiguration,
outputDirectory: './build',
derivedDataPath: './build',
resultBundlePath,
clean: false,
logsDirectory,
entitlements: entitlements ?? undefined,
-9
View File
@@ -11,8 +11,6 @@ interface ArchiveBuildOptions {
scheme: string;
buildConfiguration?: string;
outputDirectory: string;
derivedDataPath: string;
resultBundlePath: string;
clean: boolean;
logsDirectory: string;
entitlements?: object;
@@ -23,7 +21,6 @@ interface SimulatorBuildOptions {
scheme: string;
buildConfiguration?: string;
derivedDataPath: string;
resultBundlePath: string;
clean: boolean;
logsDirectory: string;
simulatorDestination: string;
@@ -37,8 +34,6 @@ export async function createGymfileForArchiveBuild({
buildConfiguration,
entitlements,
outputDirectory,
derivedDataPath,
resultBundlePath,
logsDirectory,
}: ArchiveBuildOptions): Promise<void> {
const PROFILES: { BUNDLE_ID: string; UUID: string }[] = [];
@@ -64,8 +59,6 @@ export async function createGymfileForArchiveBuild({
SCHEME: scheme,
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
OUTPUT_DIRECTORY: outputDirectory,
DERIVED_DATA_PATH: derivedDataPath,
RESULT_BUNDLE_PATH: resultBundlePath,
EXPORT_METHOD: credentials.distributionType,
CLEAN: String(clean),
LOGS_DIRECTORY: logsDirectory,
@@ -81,7 +74,6 @@ export async function createGymfileForSimulatorBuild({
scheme,
buildConfiguration,
derivedDataPath,
resultBundlePath,
logsDirectory,
simulatorDestination,
}: SimulatorBuildOptions): Promise<void> {
@@ -94,7 +86,6 @@ export async function createGymfileForSimulatorBuild({
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
DERIVED_DATA_PATH: derivedDataPath,
RESULT_BUNDLE_PATH: resultBundlePath,
CLEAN: String(clean),
LOGS_DIRECTORY: logsDirectory,
},
+1 -77
View File
@@ -1,14 +1,9 @@
import { Env, Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
import { Ios } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import path from 'path';
import semver from 'semver';
import { BuildContext } from '../context';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.26';
const PRECOMPILED_MODULES_BASE_URL =
'https://storage.googleapis.com/eas-build-precompiled-modules/';
export async function installPods<TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
{ infoCallbackFn }: SpawnOptions
@@ -17,7 +12,6 @@ export async function installPods<TJob extends Ios.Job>(
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
const cocoapodsDeploymentFlag = ctx.env['POD_INSTALL_DEPLOYMENT'] === '1' ? ['--deployment'] : [];
const precompiledModulesEnv = await resolvePrecompiledModulesPodInstallEnvAsync(ctx);
return {
spawnPromise: spawn('pod', ['install', ...verboseFlag, ...cocoapodsDeploymentFlag], {
@@ -26,7 +20,6 @@ export async function installPods<TJob extends Ios.Job>(
env: {
...ctx.env,
LANG: 'en_US.UTF-8',
...precompiledModulesEnv,
},
lineTransformer: (line?: string) => {
if (
@@ -42,72 +35,3 @@ export async function installPods<TJob extends Ios.Job>(
}),
};
}
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<Env> {
if (ctx.env.EXPO_USE_PRECOMPILED_MODULES === '0') {
ctx.logger.info('EXPO_USE_PRECOMPILED_MODULES=0 is set; not enabling precompiled modules use.');
return {};
}
if (ctx.env.EAS_USE_PRECOMPILED_MODULES !== '1') {
return {};
}
let expoPackageVersion: string;
try {
expoPackageVersion = await getInstalledExpoPackageVersionAsync({
env: ctx.env,
projectDir: ctx.getReactNativeProjectDirectory(),
});
} catch (err) {
ctx.logger.info(
{ err },
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
);
return {};
}
const validExpoPackageVersion = semver.valid(expoPackageVersion);
if (!validExpoPackageVersion) {
ctx.logger.info(
`Detected expo=${expoPackageVersion}; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.`
);
return {};
}
if (semver.lt(validExpoPackageVersion, MIN_PRECOMPILED_MODULES_EXPO_VERSION)) {
ctx.logger.info(
`Detected expo=${validExpoPackageVersion}; not enabling precompiled modules use because precompiled modules require expo>=${MIN_PRECOMPILED_MODULES_EXPO_VERSION}.`
);
return {};
}
const env: Env = {
EXPO_USE_PRECOMPILED_MODULES: '1',
EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(ctx),
};
ctx.logger.info(
`Detected expo=${validExpoPackageVersion}; enabling precompiled modules use. Installing pods with additional environment variables.\n${Object.entries(
env
)
.map(([key, value]) => `${key}=${value}`)
.join('\n')}\nPrecompiled modules pod install environment is configured.`
);
return env;
}
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}`
);
}
@@ -6,14 +6,11 @@ import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
import { Sentry } from '../sentry';
const CHECK_FILE_INTERVAL_MS = 1000;
export class XcodeBuildLogger {
private loggerError?: Error;
private flushing: boolean = false;
private reportedFormatterError: boolean = false;
private logReaderPromise?: SpawnPromise<SpawnResult>;
private logsPath?: string;
@@ -63,21 +60,9 @@ export class XcodeBuildLogger {
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
this.logReaderPromise.child.stdout.on('data', (data: string) => {
try {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
} catch (err: any) {
// The formatter can throw on unexpected xcodebuild output. Fall back to
// raw logs instead of crashing the process with an uncaught exception.
if (!this.reportedFormatterError) {
this.reportedFormatterError = true;
Sentry.capture('xcpretty formatter failed to parse xcodebuild logs', err, {
extras: { data: data.toString().slice(-2000) },
});
}
this.logger.info(data.toString());
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
});
await this.logReaderPromise;
-138
View File
@@ -1,138 +0,0 @@
import { Env } from '@expo/eas-build-job';
import fetch from 'node-fetch';
import { z } from 'zod';
import { Sentry } from './sentry';
const ENVIRONMENT_TO_RUNTIME_SETTINGS_URL: Record<string, string | undefined> = {
staging: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
production: 'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
};
const RuntimeSettingsSchema = z
.object({
caches: z.record(
z.string(),
z
.object({
npm: z.boolean(),
nodejs: z.boolean(),
maven: z.boolean(),
cocoapods: z.boolean(),
})
.partial()
),
iosPrecompiledModules: z.boolean(),
})
.partial();
let runtimeSettings: z.infer<typeof RuntimeSettingsSchema> = {};
let runtimeEnvironment: Env = {};
export namespace RuntimeSettings {
export async function loadAsync({
environment,
env: nextRuntimeEnvironment = {},
}: {
environment: string;
env?: Env;
}): Promise<void> {
runtimeEnvironment = nextRuntimeEnvironment;
const url = ENVIRONMENT_TO_RUNTIME_SETTINGS_URL[environment];
if (!url) {
return;
}
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(1000),
});
if (!response.ok) {
Sentry.capture('Failed to fetch worker runtime settings', {
extras: { url, status: response.status },
level: 'warning',
});
return;
}
runtimeSettings = RuntimeSettingsSchema.parse(await response.json());
if (runtimeSettings.caches && !runtimeSettings.caches[process.platform]) {
Sentry.capture(
new Error(
`Runtime settings are missing cache settings for platform "${process.platform}"`
),
{
extras: {
configuredPlatforms: Object.keys(runtimeSettings.caches),
platform: process.platform,
},
level: 'error',
}
);
}
} catch (err) {
Sentry.capture(
'Failed to load worker runtime settings',
err instanceof Error ? err : new Error(String(err)),
{
extras: { url },
level: 'warning',
}
);
}
}
export function isUsingIosPrecompiledModulesEnabled(): boolean {
return runtimeSettings.iosPrecompiledModules ?? false;
}
export function getNpmCacheUrl(): string | null {
if (runtimeEnvironment['EAS_BUILD_DISABLE_NPM_CACHE'] === '1') {
return null;
}
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.npm;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_NPM_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_NPM_CACHE_URL || null : null;
}
export function getNodeJsCacheUrl(): string | null {
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.nodejs;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_NODEJS_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_NODEJS_CACHE_URL || null : null;
}
export function getMavenCacheUrl(): string | null {
if (runtimeEnvironment['EAS_BUILD_DISABLE_MAVEN_CACHE'] === '1') {
return null;
}
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.maven;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_MAVEN_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_MAVEN_CACHE_URL || null : null;
}
export function getCocoapodsCacheUrl(): string | null {
if (runtimeEnvironment['EAS_BUILD_DISABLE_COCOAPODS_CACHE'] === '1') {
return null;
}
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.cocoapods;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_COCOAPODS_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_COCOAPODS_CACHE_URL || null : null;
}
}
-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);
},
};
@@ -1,27 +0,0 @@
import { getEasFunctionGroups } from '../easFunctionGroups';
describe(getEasFunctionGroups, () => {
it('includes eas/maestro_test for non-build jobs', () => {
const ctx = {
hasBuildJob: () => false,
job: {},
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
functionGroup.getFullId()
);
expect(functionGroupIds).toEqual(['eas/maestro_test']);
});
it('includes eas/build for build jobs', () => {
const ctx = {
hasBuildJob: () => true,
job: {},
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
functionGroup.getFullId()
);
expect(functionGroupIds).toEqual(expect.arrayContaining(['eas/build', 'eas/maestro_test']));
});
});
+3 -21
View File
@@ -9,8 +9,6 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
import { createDownloadBuildFunction } from './functions/downloadBuild';
import { createEasDeployBuildFunction } from './functions/deploy';
import { createEasExportBuildFunction } from './functions/export';
import { eagerBundleBuildFunction } from './functions/eagerBundle';
import { createFindAndUploadBuildArtifactsBuildFunction } from './functions/findAndUploadBuildArtifacts';
import { generateGymfileFromTemplateFunction } from './functions/generateGymfileFromTemplate';
@@ -19,12 +17,10 @@ import { injectAndroidCredentialsFunction } from './functions/injectAndroidCrede
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
import { createInternalEasMaestroTestFunction } from './functions/internalMaestroTest';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
import {
@@ -32,19 +28,14 @@ import {
createRestoreBuildCacheFunction,
} from './functions/restoreBuildCache';
import { createRestoreCacheFunction } from './functions/restoreCache';
import { parseXcactivitylogFunction } from './functions/parseXcactivitylog';
import { runFastlaneFunction } from './functions/runFastlane';
import { runGradleFunction } from './functions/runGradle';
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
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';
@@ -56,14 +47,10 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createDownloadArtifactFunction(),
createUploadArtifactBuildFunction(ctx),
createSetUpNpmrcBuildFunction(),
createReadPackageJsonBuildFunction(),
createReadAppConfigBuildFunction(),
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(ctx),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createDownloadBuildFunction(),
createRepackBuildFunction(),
createRestoreCacheFunction(),
createRestoreBuildCacheFunction(),
@@ -80,13 +67,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
configureIosVersionFunction(),
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
createStartServeSimRemoteSessionBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallPodsBuildFunction(),
@@ -97,8 +80,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSubmissionEntityFunction(),
createUploadToAscBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(ctx),
createInternalEasMaestroTestFunction(ctx),
];
if (ctx.hasBuildJob()) {
@@ -1,200 +0,0 @@
import { BuildJob, Platform } from '@expo/eas-build-job';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { CustomBuildContext } from '../../../customBuildContext';
import { createEasBuildBuildFunctionGroup } from '../build';
function createMockBuildToolsContext(
overrides: Partial<{
platform: Platform;
simulator: boolean;
buildCredentials: Record<string, unknown>;
}> = {}
): CustomBuildContext<BuildJob> {
return {
job: {
platform: overrides.platform ?? Platform.ANDROID,
simulator: overrides.simulator ?? false,
secrets: overrides.buildCredentials
? { buildCredentials: overrides.buildCredentials }
: undefined,
},
} as unknown as CustomBuildContext<BuildJob>;
}
describe(createEasBuildBuildFunctionGroup, () => {
describe('working_directory input', () => {
it('does not set working directory on steps when not provided (Android)', () => {
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
for (const step of steps) {
// Without working_directory, only installPods would have a relative dir (iOS only).
// On Android, no step should have a relative working directory.
expect(step.ctx.relativeWorkingDirectory).toBeUndefined();
}
});
it('sets working directory on all steps except checkout (Android)', () => {
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
}
});
it('sets working directory on all steps except checkout (iOS simulator)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
}
});
it('sets working directory on all steps except checkout (iOS with credentials)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
buildCredentials: { test: {} },
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
}
});
it('wires configure_ios_credentials target_names into configure_ios_version', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
buildCredentials: { test: {} },
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const configureIosVersionStep = steps.find(s => s.displayName === 'Configure iOS version');
const targetNamesInput = configureIosVersionStep?.inputs?.find(
input => input.id === 'target_names'
);
expect(configureIosVersionStep).toBeDefined();
expect(targetNamesInput?.rawValue).toBe(
'${{ steps.configure_ios_credentials.outputs.target_names }}'
);
});
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
expect(installPodsStep).toBeDefined();
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('my-app/ios');
});
it('uses ./ios for installPods when no working_directory provided (iOS)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
expect(installPodsStep).toBeDefined();
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
});
it('sets working directory on all steps except checkout (Android with credentials)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.ANDROID,
buildCredentials: { test: {} },
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
}
});
});
it('throws for generic jobs (no platform)', () => {
const buildToolsContext = {
job: { platform: undefined },
} as unknown as CustomBuildContext<BuildJob>;
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
expect(() => functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx)).toThrow(
'Build function group is not supported in generic jobs.'
);
});
});
@@ -1,12 +1,5 @@
import { BuildJob, Platform } from '@expo/eas-build-job';
import {
BuildFunctionGroup,
BuildStep,
BuildStepGlobalContext,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import path from 'path';
import { BuildFunctionGroup, BuildStep, BuildStepGlobalContext } from '@expo/steps';
import { shouldUseEagerBundle } from '../../common/eagerBundle';
import { CustomBuildContext } from '../../customBuildContext';
@@ -37,7 +30,6 @@ import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
interface HelperFunctionsInput {
globalCtx: BuildStepGlobalContext;
buildToolsContext: CustomBuildContext<BuildJob>;
workingDirectory?: string;
}
export function createEasBuildBuildFunctionGroup(
@@ -46,30 +38,17 @@ export function createEasBuildBuildFunctionGroup(
return new BuildFunctionGroup({
namespace: 'eas',
id: 'build',
inputProviders: [
BuildStepInput.createProvider({
id: 'working_directory',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
createBuildStepsFromFunctionGroupCall: (globalCtx, { inputs }) => {
const workingDirectory = inputs.working_directory?.getValue({
interpolationContext: globalCtx.getInterpolationContext(),
}) as string | undefined;
createBuildStepsFromFunctionGroupCall: globalCtx => {
if (buildToolsContext.job.platform === Platform.IOS) {
if (buildToolsContext.job.simulator) {
return createStepsForIosSimulatorBuild({
globalCtx,
buildToolsContext,
workingDirectory,
});
} else {
return createStepsForIosBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
}
} else if (buildToolsContext.job.platform === Platform.ANDROID) {
@@ -77,13 +56,11 @@ export function createEasBuildBuildFunctionGroup(
return createStepsForAndroidBuildWithoutCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
} else {
return createStepsForAndroidBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
}
}
@@ -96,19 +73,16 @@ export function createEasBuildBuildFunctionGroup(
function createStepsForIosSimulatorBuild({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
workingDirectory: './ios',
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -117,7 +91,6 @@ function createStepsForIosSimulatorBuild({
});
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_fastlane',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -125,26 +98,18 @@ function createStepsForIosSimulatorBuild({
});
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{ workingDirectory }
globalCtx
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
calculateEASUpdateRuntimeVersion,
installPods,
configureEASUpdate,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -152,35 +117,29 @@ function createStepsForIosSimulatorBuild({
}),
]
: []),
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx),
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx),
];
}
function createStepsForIosBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const resolveAppleTeamIdFromCredentials =
resolveAppleTeamIdFromCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'resolve_apple_team_id_from_credentials',
workingDirectory,
});
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const prebuildStep = createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
apple_team_id: '${ steps.resolve_apple_team_id_from_credentials.apple_team_id }',
},
@@ -188,18 +147,16 @@ function createStepsForIosBuildWithCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.IOS,
},
}
);
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
workingDirectory: './ios',
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -209,7 +166,6 @@ function createStepsForIosBuildWithCredentials({
const generateGymfile = generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
credentials: '${ eas.job.secrets.buildCredentials }',
},
@@ -217,7 +173,6 @@ function createStepsForIosBuildWithCredentials({
);
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_fastlane',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -226,7 +181,6 @@ function createStepsForIosBuildWithCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.IOS,
},
@@ -234,15 +188,10 @@ function createStepsForIosBuildWithCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{ workingDirectory }
globalCtx
),
resolveAppleTeamIdFromCredentials,
prebuildStep,
@@ -250,20 +199,11 @@ function createStepsForIosBuildWithCredentials({
calculateEASUpdateRuntimeVersion,
installPods,
configureEASUpdate,
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'configure_ios_credentials',
workingDirectory,
}),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
},
}),
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -275,29 +215,24 @@ function createStepsForIosBuildWithCredentials({
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
];
}
function createStepsForAndroidBuildWithoutCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -307,7 +242,6 @@ function createStepsForAndroidBuildWithoutCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -315,7 +249,6 @@ function createStepsForAndroidBuildWithoutCredentials({
);
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_gradle',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -324,7 +257,6 @@ function createStepsForAndroidBuildWithoutCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -332,26 +264,18 @@ function createStepsForAndroidBuildWithoutCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{ workingDirectory }
globalCtx
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
restoreCache,
calculateEASUpdateRuntimeVersion,
configureEASUpdate,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -362,29 +286,24 @@ function createStepsForAndroidBuildWithoutCredentials({
runGradle,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
];
}
function createStepsForAndroidBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -394,7 +313,6 @@ function createStepsForAndroidBuildWithCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -402,7 +320,6 @@ function createStepsForAndroidBuildWithCredentials({
);
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_gradle',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -411,7 +328,6 @@ function createStepsForAndroidBuildWithCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -419,33 +335,21 @@ function createStepsForAndroidBuildWithCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{ workingDirectory }
globalCtx
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
restoreCache,
calculateEASUpdateRuntimeVersion,
configureEASUpdate,
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx),
runGradle,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -455,10 +359,8 @@ function createStepsForAndroidBuildWithCredentials({
: []),
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
];
}
@@ -51,6 +51,7 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'install_app',
displayName: `Install app to Simulator`,
command: `
# shopt -s nullglob is necessary not to try to install
@@ -96,6 +97,7 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'install_app',
displayName: `Install app to Emulator`,
command: `
# shopt -s globstar is necessary to add /**/ support
@@ -132,6 +134,7 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'maestro_test',
ifCondition: '${ always() }',
displayName: `maestro test ${flowPath}`,
command: `maestro test ${flowPath}`,
@@ -1,81 +0,0 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestIosJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { configureCredentialsAsync } from '../../utils/ios/configure';
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
import { configureIosCredentialsFunction } from '../configureIosCredentials';
jest.mock('../../utils/ios/configure');
jest.mock('../../utils/ios/credentials/manager');
function createCredentials({
targetNames = ['app', 'share-extension'],
}: {
targetNames?: string[];
} = {}): Credentials {
return {
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
keychainPath: '/tmp/keychain',
targetProvisioningProfiles: Object.fromEntries(
targetNames.map(targetName => [
targetName,
{
path: `/tmp/${targetName}.mobileprovision`,
target: targetName,
bundleIdentifier: `com.example.${targetName}`,
teamId: 'TEAMID123',
uuid: `${targetName}-uuid`,
name: `${targetName} profile`,
distributionType: DistributionType.AD_HOC,
developerCertificate: Buffer.from('certificate'),
certificateCommonName: `${targetName} cert`,
},
])
),
distributionType: DistributionType.AD_HOC,
teamId: 'TEAMID123',
};
}
function createRawCredentials() {
return {
app: {
provisioningProfileBase64: 'cHJvZmlsZQ==',
distributionCertificate: {
dataBase64: 'Y2VydA==',
password: '',
},
},
};
}
describe(configureIosCredentialsFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(configureCredentialsAsync).mockResolvedValue(undefined);
});
it('exports target_names from prepared credentials', async () => {
jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = configureIosCredentialsFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job: createTestIosJob() },
}),
{
callInputs: {
credentials: createRawCredentials(),
},
}
);
await buildStep.executeAsync();
expect(configureCredentialsAsync).toHaveBeenCalled();
expect(buildStep.outputById.target_names.value).toBe(JSON.stringify(['app', 'widget']));
});
});
@@ -1,286 +0,0 @@
import { UserError } from '@expo/eas-build-job';
import { BuildWorkflow } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestIosJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { updateVersionsAsync } from '../../utils/ios/configure';
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
import { configureIosCredentialsFunction } from '../configureIosCredentials';
import { configureIosVersionFunction } from '../configureIosVersion';
jest.mock('../../utils/ios/configure');
jest.mock('../../utils/ios/credentials/manager');
function createCredentials({
targetNames = ['app', 'share-extension'],
}: {
targetNames?: string[];
} = {}): Credentials {
return {
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
keychainPath: '/tmp/keychain',
targetProvisioningProfiles: Object.fromEntries(
targetNames.map(targetName => [
targetName,
{
path: `/tmp/${targetName}.mobileprovision`,
target: targetName,
bundleIdentifier: `com.example.${targetName}`,
teamId: 'TEAMID123',
uuid: `${targetName}-uuid`,
name: `${targetName} profile`,
distributionType: DistributionType.AD_HOC,
developerCertificate: Buffer.from('certificate'),
certificateCommonName: `${targetName} cert`,
},
])
),
distributionType: DistributionType.AD_HOC,
teamId: 'TEAMID123',
};
}
function createRawCredentials() {
return {
app: {
provisioningProfileBase64: 'cHJvZmlsZQ==',
distributionCertificate: {
dataBase64: 'Y2VydA==',
password: '',
},
},
};
}
function createBuildStep({
callInputs = {},
job = createTestIosJob(),
}: {
callInputs?: Record<string, unknown>;
job?: ReturnType<typeof createTestIosJob>;
} = {}) {
return configureIosVersionFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job },
}),
{ callInputs }
);
}
describe(configureIosVersionFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(updateVersionsAsync).mockResolvedValue(undefined);
});
it('uses target_names without an explicit credentials input', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app', 'widget'],
build_number: '42',
},
});
await buildStep.executeAsync();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: '42',
appVersion: undefined,
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('uses credentials only to preserve the existing fallback path', async () => {
jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = createBuildStep({
callInputs: {
credentials: createRawCredentials(),
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(IosCredentialsManager.prototype.prepare).toHaveBeenCalledTimes(1);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('uses the implicit credentials default fallback from the job secrets', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = createBuildStep({
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
callInputs: {
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(prepareSpy).toHaveBeenCalledTimes(1);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('prefers target_names when both target_names and credentials are provided', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['credentials-target'] }));
const buildStep = createBuildStep({
callInputs: {
target_names: ['input-target'],
credentials: createRawCredentials(),
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(prepareSpy).not.toHaveBeenCalled();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['input-target'],
buildConfiguration: 'Release',
}
);
});
it('parses target_names exported by configure_ios_credentials through workflow interpolation', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: {
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
},
});
const configureIosCredentialsStep =
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'configure_ios_credentials',
});
const configureIosVersionStep = configureIosVersionFunction().createBuildStepFromFunctionCall(
globalCtx,
{
callInputs: {
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
app_version: '1.2.3',
},
}
);
await new BuildWorkflow(globalCtx, {
buildSteps: [configureIosCredentialsStep, configureIosVersionStep],
buildFunctions: {},
}).executeAsync();
expect(prepareSpy).toHaveBeenCalledTimes(1);
expect(configureIosCredentialsStep.outputById.target_names.value).toBe(
JSON.stringify(['app', 'widget'])
);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
configureIosVersionStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('accepts an empty target_names array without crashing', async () => {
const prepareSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare');
const buildStep = createBuildStep({
callInputs: {
target_names: [],
app_version: '1.2.3',
},
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(prepareSpy).not.toHaveBeenCalled();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: [],
buildConfiguration: 'Release',
}
);
});
it('rejects invalid target_names', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app', 123],
app_version: '1.2.3',
},
});
const promise = buildStep.executeAsync();
await expect(promise).rejects.toBeInstanceOf(UserError);
await expect(promise).rejects.toThrow('"target_names" input must be an array of strings.');
});
it('uses the corrected App version error text', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app'],
app_version: 'invalid-semver',
},
});
await expect(buildStep.executeAsync()).rejects.toThrow(
'App version provided by the "app_version" input is not a valid semver version: invalid-semver'
);
});
});
@@ -1,290 +0,0 @@
import { BuildRuntimePlatform } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { runEasCliCommand } from '../../../utils/easCli';
import { createEasDeployBuildFunction } from '../deploy';
jest.mock('../../../utils/easCli', () => ({
runEasCliCommand: jest.fn(),
}));
const mockDeployStdout = JSON.stringify({
url: 'https://example.dev',
production: { url: 'https://example.prod' },
aliases: [{ url: 'https://example.alias' }],
identifier: 'abc',
dashboardUrl: 'https://expo.dev/dashboard',
});
describe(createEasDeployBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('passes deploy flags from function inputs and sets deploy_json output', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
alias: 'preview-link',
prod: true,
source_maps: false,
},
env: { HOME: '/tmp/home' },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
'Running deploy command: eas deploy --non-interactive --json --export-dir dist --alias preview-link --prod'
);
expect(runEasCliCommand).toHaveBeenCalledWith({
args: [
'deploy',
'--non-interactive',
'--json',
'--export-dir',
'dist',
'--alias',
'preview-link',
'--prod',
],
options: expect.objectContaining({
cwd: buildStep.ctx.workingDirectory,
env: expect.objectContaining({ HOME: '/tmp/home' }),
logger: buildStep.ctx.logger,
}),
});
expect(buildStep.outputById.deploy_json.value).toBe(mockDeployStdout);
expect(buildStep.outputById.deploy_url.value).toBe('https://example.prod');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev');
expect(buildStep.outputById.deploy_identifier.value).toBe('abc');
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard');
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias');
});
it('forwards workflow step env to the eas deploy process', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { source_maps: true },
env: { HOME: '/tmp/home', EXAMPLE_WORKFLOW_ENV: 'from-workflow' },
});
await buildStep.executeAsync();
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--source-maps']),
})
);
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: expect.objectContaining({
HOME: '/tmp/home',
EXAMPLE_WORKFLOW_ENV: 'from-workflow',
}),
}),
})
);
});
it('throws an actionable error message when deploy command fails', async () => {
jest.mocked(runEasCliCommand).mockRejectedValue(new Error('deploy failed'));
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
let errorMessage = '';
try {
await buildStep.executeAsync();
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
}
expect(errorMessage).toContain('Deploy command failed.');
expect(errorMessage).toContain('This can happen when deploy inputs are invalid');
expect(errorMessage).toContain(
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.'
);
expect(errorMessage).toContain('Original error: deploy failed');
});
it('passes custom export_dir to eas deploy', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { export_dir: 'web-build' },
});
await buildStep.executeAsync();
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--export-dir', 'web-build']),
})
);
});
it('does not pass --source-maps when source_maps is false', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { source_maps: false },
});
await buildStep.executeAsync();
const { args } = jest.mocked(runEasCliCommand).mock.calls[0][0];
expect(args).not.toContain('--source-maps');
});
it('sets deploy_json and skips optional outputs when stdout is not valid JSON', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from('not-json'),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_json.value).toBe('not-json');
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.anything() }),
expect.stringContaining('Failed to parse')
);
expect(buildStep.outputById.deploy_url.value).toBeUndefined();
});
it('sets outputs from partial deploy JSON and leaves missing fields undefined', async () => {
const partialDeployStdout = JSON.stringify({
url: 'https://example.dev-only',
dashboardUrl: 'https://expo.dev/dashboard-only',
});
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(partialDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_json.value).toBe(partialDeployStdout);
expect(buildStep.outputById.deploy_url.value).toBe('https://example.dev-only');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev-only');
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard-only');
expect(buildStep.outputById.deploy_identifier.value).toBeUndefined();
expect(buildStep.outputById.deploy_alias_url.value).toBeUndefined();
});
it('sets deploy_url from aliases[0].url when production is absent but aliases exist', async () => {
const stdout = JSON.stringify({
url: 'https://example.deployment',
aliases: [{ url: 'https://example.alias-first' }],
identifier: 'partial-alias',
});
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(stdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_url.value).toBe('https://example.alias-first');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.deployment');
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias-first');
expect(buildStep.outputById.deploy_identifier.value).toBe('partial-alias');
});
});
@@ -1,6 +1,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import { Client, CombinedError } from '@urql/core';
import fetch, { Response } from 'node-fetch';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
@@ -19,63 +17,22 @@ const APP_TAR_GZ_BUFFER = Buffer.from(
'base64'
);
const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.tar.gz`;
function createMockGraphqlClient({
applicationArchiveUrl,
error,
}: {
applicationArchiveUrl?: string | null;
error?: Error;
}): Client {
const toPromise = jest.fn().mockResolvedValue(
error
? { error, data: undefined }
: {
data: {
builds: {
byId: {
id: randomUUID(),
platform: 'IOS',
artifacts: {
applicationArchiveUrl: applicationArchiveUrl ?? null,
},
},
},
},
}
);
return {
query: jest.fn().mockReturnValue({ toPromise }),
} as unknown as Client;
}
describe('downloadBuild', () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
it('should handle an archive', async () => {
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: APPLICATION_ARCHIVE_URL,
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
} as unknown as Response);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
buildId: randomUUID(),
expoApiServerURL: 'http://api.expo.test',
robotAccessToken: null,
extensions: ['app'],
});
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(artifactPath).toBeDefined();
expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe(
'i am executable\n'
@@ -83,21 +40,16 @@ describe('downloadBuild', () => {
});
it('should handle a straight-up file', async () => {
const applicationArchiveUrl = `https://expo.dev/artifacts/eas/${randomUUID()}.apk`;
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(Buffer.from('hello')),
url: applicationArchiveUrl,
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.txt?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
} as unknown as Response);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
expoApiServerURL: 'http://api.expo.test',
robotAccessToken: null,
extensions: ['app'],
});
@@ -106,114 +58,18 @@ describe('downloadBuild', () => {
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('throws UserError when the build has no application archive url', async () => {
const graphqlClient = createMockGraphqlClient({ applicationArchiveUrl: null });
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
message: 'Build does not have an application archive url',
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request fails with a network error', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
networkError: new Error('Network request failed'),
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: Object.assign(new Error('Internal Server Error'), {
response: { status: 500 },
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: Internal Server Error`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws UserError when GraphQL returns a client error', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
graphQLErrors: [{ message: 'Build not found' }],
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(UserError);
await expect(promise).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('should throw an error if no matching files are found', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: APPLICATION_ARCHIVE_URL,
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
} as unknown as Response);
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
expoApiServerURL: 'http://api.expo.test',
robotAccessToken: null,
extensions: ['apk'],
})
@@ -223,11 +79,7 @@ describe('downloadBuild', () => {
describe('createDownloadBuildFunction', () => {
it('should download a build', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
const downloadBuild = createDownloadBuildFunction({ graphqlClient } as any);
const downloadBuild = createDownloadBuildFunction();
const logger = createMockLogger();
const buildStep = downloadBuild.createBuildStepFromFunctionCall(
@@ -240,7 +92,7 @@ describe('createDownloadBuildFunction', () => {
}),
{
callInputs: {
build_id: buildId,
build_id: randomUUID(),
extensions: ['app'],
},
}
@@ -253,9 +105,5 @@ describe('createDownloadBuildFunction', () => {
} as unknown as Response);
await expect(buildStep.executeAsync()).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
});
});
@@ -1,122 +0,0 @@
jest.mock('../../../utils/project', () => ({
runExpoCliCommand: jest.fn().mockResolvedValue({}),
}));
jest.mock('../../../utils/packageManager', () => ({
...jest.requireActual('../../../utils/packageManager'),
resolvePackageManager: jest.fn(),
}));
import { BuildRuntimePlatform } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { PackageManager, resolvePackageManager } from '../../../utils/packageManager';
import { runExpoCliCommand } from '../../../utils/project';
import { createEasExportBuildFunction } from '../export';
describe(createEasExportBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.NPM);
jest.mocked(runExpoCliCommand).mockResolvedValue({} as any);
});
it('runs expo export via runExpoCliCommand with default output_dir and platform web', async () => {
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
env: { HOME: '/tmp/home' },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
'Running export command: expo export --output-dir dist --platform web'
);
expect(runExpoCliCommand).toHaveBeenCalledWith({
packageManager: PackageManager.NPM,
args: ['export', '--output-dir', 'dist', '--platform', 'web'],
options: {
cwd: buildStep.ctx.workingDirectory,
env: expect.objectContaining({ HOME: '/tmp/home' }),
logger: buildStep.ctx.logger,
stdio: 'pipe',
},
});
expect(buildStep.outputById.export_dir.value).toBe('dist');
});
it('passes optional flags matching expo export CLI', async () => {
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
output_dir: 'web-out',
dev: true,
minify: false,
dump_assetmap: true,
ssg: false,
api_only: true,
platform: 'web',
},
});
await buildStep.executeAsync();
expect(runExpoCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: [
'export',
'--output-dir',
'web-out',
'--platform',
'web',
'--dev',
'--no-minify',
'--dump-assetmap',
'--no-ssg',
'--api-only',
],
})
);
expect(buildStep.outputById.export_dir.value).toBe('web-out');
});
it('uses resolved package manager (e.g. yarn)', async () => {
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.YARN);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(runExpoCliCommand).toHaveBeenCalledWith(
expect.objectContaining({ packageManager: PackageManager.YARN })
);
});
it('throws when export command fails', async () => {
jest.mocked(runExpoCliCommand).mockRejectedValue(new Error('export failed'));
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await expect(buildStep.executeAsync()).rejects.toThrow('Export command failed: export failed');
});
});
@@ -1,90 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { Datadog } from '../../../datadog';
import { createInstallMaestroBuildFunction } from '../installMaestro';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../datadog', () => ({
Datadog: {
distribution: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
describe('createInstallMaestroBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
// `maestro --version` reports an installed version; `java -version` succeeds.
// With no requested version and Maestro present, the step skips installation.
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro' ? '1.41.0\n' : '',
})) as any);
});
it('reports the detected Maestro version to Datadog on EAS Build VMs', async () => {
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('1.41.0');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '1.41.0',
});
});
it('does not report to Datadog outside EAS Build VMs', async () => {
const installMaestro = createInstallMaestroBuildFunction();
const step = installMaestro.createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: {},
});
await step.executeAsync();
expect(Datadog.distribution).not.toHaveBeenCalled();
});
it('extracts the version when `maestro --version` prints an analytics notice', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'maestro'
? 'Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS environment variable to any value before running Maestro.\n2.0.10\n'
: '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '2.0.10',
});
});
it('uses the trailing version when the notice itself contains an earlier version-like string', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro' ? 'Analytics schema v2.0.0 enabled.\n2.0.10\n' : '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
});
});
@@ -1,132 +0,0 @@
import { BuildStepContext, BuildStepEnv } from '@expo/steps';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
PackageManager,
findPackagerRootDir,
resolvePackageManager,
shouldUseFrozenLockfile,
} from '../../../utils/packageManager';
import { readPackageJson } from '../../../utils/project';
import { installNodeModules } from '../installNodeModules';
jest.mock('@expo/turtle-spawn', () => jest.fn());
jest.mock('../../../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
jest.mock('../../../utils/packageManager', () => ({
...jest.requireActual('../../../utils/packageManager'),
resolvePackageManager: jest.fn(),
findPackagerRootDir: jest.fn(),
shouldUseFrozenLockfile: jest.fn(),
}));
jest.mock('../../../utils/project', () => ({
...jest.requireActual('../../../utils/project'),
readPackageJson: jest.fn(),
}));
describe(installNodeModules, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.NPM);
jest.mocked(findPackagerRootDir).mockReturnValue('/tmp/build');
jest.mocked(shouldUseFrozenLockfile).mockReturnValue(false);
jest.mocked(readPackageJson).mockReturnValue({});
});
function createStepCtx(logger: ReturnType<typeof createMockLogger>): BuildStepContext {
return {
logger,
workingDirectory: '/tmp/build',
global: {
projectTargetDirectory: '/tmp/build',
staticContext: { metadata: {} },
},
} as unknown as BuildStepContext;
}
it('retries the install without the npm cache registry when the cache install fails', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.caches.eas-build.internal';
const env: BuildStepEnv = {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
};
const error = Object.assign(new Error('npm install exited with non-zero code: 1'), {
status: 1,
signal: null,
stdout: '',
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND`,
});
jest
.mocked(spawn)
.mockReturnValueOnce(createSpawnPromise(Promise.reject(error)))
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
await installNodeModules(createStepCtx(logger), env);
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenNthCalledWith(
1,
'npm',
['install', '--include=dev'],
expect.objectContaining({
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
})
);
expect(spawn).toHaveBeenNthCalledWith(
2,
'npm',
['install', '--include=dev'],
expect.objectContaining({
env: { EAS_USE_NPM_CACHE: '1', EAS_BUILD_NPM_CACHE_URL: npmCacheUrl },
})
);
expect(logger.warn).toHaveBeenCalledWith(
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
});
it('does not retry when the npm cache is not enabled', async () => {
const logger = createMockLogger();
const error = Object.assign(new Error('npm install exited with non-zero code: 1'), {
status: 1,
signal: null,
stdout: '',
stderr: 'npm error some unrelated failure',
});
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
await expect(installNodeModules(createStepCtx(logger), {})).rejects.toThrow(error);
expect(spawn).toHaveBeenCalledTimes(1);
});
});
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
return Object.assign(result, {
child: {
pid: 123,
},
}) as SpawnPromise<SpawnResult>;
}
function createSpawnResult(): SpawnResult {
return {
pid: 123,
output: ['', ''],
stdout: '',
stderr: '',
status: 0,
signal: null,
};
}
@@ -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')
);
});
});
File diff suppressed because it is too large. Load diff
@@ -1,287 +0,0 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
type HarvestedScreenshot,
computePureFailureFlowNames,
harvestFailureScreenshotsAsync,
parseFailureScreenshotFilename,
selectFailureScreenshots,
} from '../maestroScreenshots';
describe(parseFailureScreenshotFilename, () => {
it('parses a plain failure screenshot', () => {
expect(parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login Flow).png')).toEqual({
flowName: 'Login Flow',
capturedAtMs: 1781186692250,
});
});
it('parses a sharded failure screenshot', () => {
expect(
parseFailureScreenshotFilename('screenshot-shard-2-❌-1781186692250-(Login Flow).png')
).toEqual({
flowName: 'Login Flow',
capturedAtMs: 1781186692250,
});
});
it('handles parentheses inside the flow name (right-to-left parse)', () => {
expect(
parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login (staging) Flow).png')
).toEqual({
flowName: 'Login (staging) Flow',
capturedAtMs: 1781186692250,
});
});
it('rejects non-failure screenshots (✅, ⚠️) and unrelated files', () => {
expect(
parseFailureScreenshotFilename('screenshot-✅-1781186692250-(Login Flow).png')
).toBeNull();
expect(parseFailureScreenshotFilename('commands-(Login Flow).json')).toBeNull();
expect(parseFailureScreenshotFilename('android-maestro-junit-attempt-0.xml')).toBeNull();
});
});
describe(harvestFailureScreenshotsAsync, () => {
const logger = createMockLogger();
let testsDirectory: string;
beforeEach(async () => {
testsDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'maestro-harvest-test-'));
});
afterEach(async () => {
await fs.rm(testsDirectory, { recursive: true, force: true });
});
async function makeDebugDir(name: string, files: string[], mtimeMs: number): Promise<void> {
const dir = path.join(testsDirectory, name);
await fs.mkdir(dir);
for (const file of files) {
await fs.writeFile(path.join(dir, file), '');
}
const when = new Date(mtimeMs);
await fs.utimes(dir, when, when);
}
it('returns shots from dirs modified at/after sinceMtimeMs, attributed to the attempt', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'attempt-dir',
['screenshot-❌-1781186692250-(Login Flow).png', 'commands.json'],
sinceMtimeMs + 5_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 1,
logger,
});
expect(shots).toEqual([
{
fileAbsPath: path.join(
testsDirectory,
'attempt-dir',
'screenshot-❌-1781186692250-(Login Flow).png'
),
displayName: 'Failure Screenshot: Login Flow (attempt 2)',
metadata: {
kind: 'maestro-test-screenshot',
flowName: 'Login Flow',
attemptIndex: 1,
capturedAtMs: 1781186692250,
},
},
]);
});
it('ignores dirs older than sinceMtimeMs (previous attempts)', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'old-dir',
['screenshot-❌-1781186692250-(Old Flow).png'],
sinceMtimeMs - 5_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
logger,
});
expect(shots).toEqual([]);
});
it('scans multiple new dirs (maestro <2.5.0 split-dir bug)', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'dir-a',
[`screenshot-❌-${sinceMtimeMs + 100}-(Flow A).png`],
sinceMtimeMs + 1_000
);
await makeDebugDir(
'dir-b',
[`screenshot-❌-${sinceMtimeMs + 200}-(Flow B).png`],
sinceMtimeMs + 2_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
logger,
});
expect(shots.map(shot => shot.metadata.flowName).sort()).toEqual(['Flow A', 'Flow B']);
});
it('keeps the disk flow name (slashes already substituted) and strips the shard prefix', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'sharded-dir',
['screenshot-shard-3-❌-1781186692250-(Login_Sub Flow).png'],
sinceMtimeMs + 1_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
logger,
});
expect(shots).toHaveLength(1);
expect(shots[0].metadata.flowName).toBe('Login_Sub Flow');
});
it('ignores a stale screenshot whose capturedAtMs predates sinceMtimeMs (dir touched later)', async () => {
const sinceMtimeMs = 1_000_000;
// The dir mtime is AFTER this attempt started (so it passes the dir gate), but the
// screenshot inside was captured BEFORE it — a prior attempt's shot in a dir touched later.
await makeDebugDir(
'touched-dir',
['screenshot-❌-999000-(Stale Flow).png'],
sinceMtimeMs + 5_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 1,
logger,
});
expect(shots).toEqual([]);
});
it('logs and returns [] when the tests dir cannot be read', async () => {
const shots = await harvestFailureScreenshotsAsync({
testsDirectory: path.join(testsDirectory, 'does-not-exist'),
capturedSinceMs: 0,
attemptIndex: 0,
logger,
});
expect(shots).toEqual([]);
});
});
describe(computePureFailureFlowNames, () => {
it('classifies a flow that failed every attempt as pure', () => {
const pure = computePureFailureFlowNames([
{ name: 'Login Flow', status: 'failed' },
{ name: 'Login Flow', status: 'failed' },
]);
expect([...pure]).toEqual(['Login Flow']);
});
it('excludes a flaky flow (failed then passed)', () => {
const pure = computePureFailureFlowNames([
{ name: 'Flaky Flow', status: 'failed' },
{ name: 'Flaky Flow', status: 'passed' },
]);
expect(pure.size).toBe(0);
});
it('excludes a fail-pass-fail flow (it passed at some point)', () => {
const pure = computePureFailureFlowNames([
{ name: 'FPF', status: 'failed' },
{ name: 'FPF', status: 'passed' },
{ name: 'FPF', status: 'failed' },
]);
expect(pure.size).toBe(0);
});
it('excludes a flow that only passed', () => {
expect(computePureFailureFlowNames([{ name: 'Happy', status: 'passed' }]).size).toBe(0);
});
it('normalizes the JUnit name to the screenshot form (/ -> _)', () => {
const pure = computePureFailureFlowNames([{ name: 'sub/Login', status: 'failed' }]);
expect([...pure]).toEqual(['sub_Login']);
});
it('excludes flows that collide only after / -> _ normalization (one failed, one passed)', () => {
const pure = computePureFailureFlowNames([
{ name: 'sub/Login', status: 'failed' },
{ name: 'sub_Login', status: 'passed' },
]);
expect(pure.size).toBe(0);
});
it('returns an empty set for no testcases', () => {
expect(computePureFailureFlowNames([]).size).toBe(0);
});
});
describe(selectFailureScreenshots, () => {
function makeShot(flowName: string, attemptIndex: number): HarvestedScreenshot {
return {
fileAbsPath: `/tmp/${flowName}-${attemptIndex}.png`,
displayName: `Failure Screenshot: ${flowName} (attempt ${attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName,
attemptIndex,
capturedAtMs: 1_000 + attemptIndex,
},
};
}
it('keeps only the final attempt for a pure-failure flow', () => {
const shots = [makeShot('Pure', 0), makeShot('Pure', 1), makeShot('Pure', 2)];
const selected = selectFailureScreenshots(shots, new Set(['Pure']));
expect(selected.map(shot => shot.metadata.attemptIndex)).toEqual([2]);
});
it('keeps every attempt for a flow not classified pure (flaky)', () => {
const shots = [makeShot('Flaky', 0), makeShot('Flaky', 1)];
const selected = selectFailureScreenshots(shots, new Set());
expect(selected.map(shot => shot.metadata.attemptIndex)).toEqual([0, 1]);
});
it('reduces pure flows while preserving flaky flows and input order', () => {
const shots = [
makeShot('Pure', 0),
makeShot('Flaky', 0),
makeShot('Pure', 1),
makeShot('Flaky', 1),
];
const selected = selectFailureScreenshots(shots, new Set(['Pure']));
expect(selected.map(shot => `${shot.metadata.flowName}#${shot.metadata.attemptIndex}`)).toEqual(
['Flaky#0', 'Pure#1', 'Flaky#1']
);
});
it('keeps all when the pure set is empty', () => {
const shots = [makeShot('A', 0), makeShot('A', 1)];
expect(selectFailureScreenshots(shots, new Set())).toHaveLength(2);
});
});
@@ -1,890 +0,0 @@
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { CustomBuildContext } from '../../../customBuildContext';
import * as discovery from '../maestroFlowDiscovery';
import * as parser from '../maestroResultParser';
import * as maestroScreenshots from '../maestroScreenshots';
import { createMaestroTestsBuildFunction } from '../maestroTests';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/retry', () => ({
...jest.requireActual('../../../utils/retry'),
sleepAsync: jest.fn().mockResolvedValue(undefined),
}));
// Partial mock: stub the harvest (filesystem) but keep the real reduction helpers
// (computePureFailureFlowNames / selectFailureScreenshots) the step now calls post-loop.
jest.mock('../maestroScreenshots', () => ({
...jest.requireActual('../maestroScreenshots'),
harvestFailureScreenshotsAsync: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
const mockedHarvest = jest.mocked(maestroScreenshots.harvestFailureScreenshotsAsync);
const mockUploadArtifact = jest.fn();
function makeShot(index: number): maestroScreenshots.HarvestedScreenshot {
return {
fileAbsPath: path.join(os.tmpdir(), `src-screenshot-${index}.png`),
displayName: 'Failure Screenshot: Login (attempt 1)',
metadata: {
kind: 'maestro-test-screenshot',
flowName: 'Login',
attemptIndex: 0,
capturedAtMs: 1781186692250,
},
};
}
const SPAWN_SUCCESS = {
status: 0,
pid: 1,
output: [''],
stdout: '',
stderr: '',
signal: null,
} as any;
const rejectExit1 = (): Error => {
const e: any = new Error('exit 1');
e.status = 1;
return e;
};
function createStep(
callInputs?: Record<string, unknown>,
options: { env?: Record<string, string | undefined> } = {}
): ReturnType<
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
> {
const logger = createMockLogger();
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
} as unknown as CustomBuildContext;
const fn = createMaestroTestsBuildFunction(ctx);
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
}
describe('createMaestroTestsBuildFunction', () => {
beforeEach(() => {
mockedSpawn.mockReset();
// Default-stub the merge helper so tests that don't care about it don't
// hit the real disk. Individual tests override this.
jest.restoreAllMocks();
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
mockedHarvest.mockReset();
mockedHarvest.mockResolvedValue([]);
mockUploadArtifact.mockReset();
mockUploadArtifact.mockResolvedValue({ artifactId: 'artifact-1' });
});
it('exports a factory that returns a BuildFunction instance', () => {
expect(
createMaestroTestsBuildFunction({
runtimeApi: { uploadArtifact: mockUploadArtifact },
} as unknown as CustomBuildContext)
).toBeDefined();
});
it('sets all outputs before running any flows', async () => {
// Make spawn reject so the step still throws; we're locking in that outputs
// are set BEFORE the spawn call (downstream `upload_artifact` fails if
// `final_report_path` is empty).
mockedSpawn.mockRejectedValue(new Error('stop'));
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow();
expect(step.getOutputValueByName('junit_report_directory')).toMatch(
/\.maestro\/tests\/junit-reports$/
);
expect(step.getOutputValueByName('tests_directory')).toMatch(/\.maestro\/tests$/);
expect(step.getOutputValueByName('final_report_path')).toMatch(
/\.maestro\/tests\/android-maestro-junit\.xml$/
);
});
it('succeeds when maestro exits 0 on first attempt', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
const [cmd, args] = mockedSpawn.mock.calls[0];
expect(cmd).toBe('maestro');
expect(args).toEqual(expect.arrayContaining(['test', 'flows/a.yaml', 'flows/b.yaml']));
expect(args).toEqual(expect.arrayContaining(['--format=JUNIT']));
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
});
it('throws SystemError when spawn fails with ENOENT (binary missing)', async () => {
mockedSpawn.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws SystemError for unknown-shape spawn rejections', async () => {
mockedSpawn.mockRejectedValue(new Error('mystery'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws SystemError on signal-only spawn rejections (OOM / SIGTERM)', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('killed'), {
status: null,
signal: 'SIGKILL',
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('treats reject-with-status as a retryable test failure', async () => {
const rejectWithExit = (status: number): Error =>
Object.assign(new Error(`maestro exited with non-zero code: ${status}`), {
status,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
});
mockedSpawn.mockRejectedValueOnce(rejectWithExit(1)).mockResolvedValueOnce(SPAWN_SUCCESS);
// Default `retries` is 0, so even though the first attempt rejects with
// `status: 1`, no retry occurs and the post-loop check throws UserError.
// Spawn ran once.
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('retries only the failed flows on the next attempt (junit mode)', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
retries: 1,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
// Attempt 0 saw all 3
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
// Attempt 1 saw only the failed one
const a1Args = mockedSpawn.mock.calls[1][1]!;
expect(a1Args).toContain('flows/b.yaml');
expect(a1Args).not.toContain('flows/a.yaml');
expect(a1Args).not.toContain('flows/c.yaml');
});
it('retries all flows when parseFailedFlowsFromJUnit returns null', async () => {
const spawnMock = mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(null);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
retries: 1,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
// Both attempts saw all 3 flows
expect(spawnMock.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
expect(spawnMock.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
});
it('always retries all flows when output_format is not junit', async () => {
const spawnMock = mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'html',
platform: 'android',
});
await step.executeAsync();
expect(parseSpy).not.toHaveBeenCalled();
expect(spawnMock.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
);
});
it('runs all original flows on every retry when retry_failed_only=false', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
const parseSpy = jest
.spyOn(parser, 'parseFailedFlowsFromJUnit')
.mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
retry_failed_only: false,
platform: 'android',
});
await step.executeAsync();
// Both attempts must see both flows in the original input order. Tail-slice
// equality locks order, not just membership — guards against any future
// failed-first reshuffling of the dumb-retry list.
const a0 = mockedSpawn.mock.calls[0][1]!;
const a1 = mockedSpawn.mock.calls[1][1]!;
expect(a0.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
expect(a1.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
expect(parseSpy).not.toHaveBeenCalled();
});
it('subsets to failing flows on retry when retry_failed_only=true (default behaviour)', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
retry_failed_only: true,
platform: 'android',
});
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
);
const a1 = mockedSpawn.mock.calls[1][1]!;
expect(a1).toContain('flows/b.yaml');
expect(a1).not.toContain('flows/a.yaml');
});
it('falls back to all-flows retry when retry_failed_only=true but output_format!=junit', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'html',
retry_failed_only: true,
platform: 'android',
});
await step.executeAsync();
expect(parseSpy).not.toHaveBeenCalled();
expect(mockedSpawn.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
);
});
it('uses the file= path on reports that carry it, without scanning flow files', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'junitFileHasFileAttrs').mockResolvedValue(true);
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
const legacyParseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const newParseSpy = jest
.spyOn(parser, 'parseFailedFlowsFromFileAttrs')
.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(newParseSpy).toHaveBeenCalledTimes(1);
// The new branch needs no name→path map: no scan, no legacy parser.
expect(discoverySpy).not.toHaveBeenCalled();
expect(legacyParseSpy).not.toHaveBeenCalled();
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 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('never scans flow files when all attempts succeed', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
await step.executeAsync();
expect(discoverySpy).not.toHaveBeenCalled();
});
it('memoizes the flow scan across retries (legacy reports)', async () => {
// Routing note: the step lands in the legacy arm because the real
// junitFileHasFileAttrs returns false — the attempt XML does not exist on
// the mock fs. Creating that file with file= attrs would flip the branch.
mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
const discoverySpy = jest
.spyOn(discovery, 'buildFlowNameToPathMap')
.mockResolvedValue(new Map([['a', 'flows/a.yaml']]));
const parseSpy = jest
.spyOn(parser, 'parseFailedFlowsFromJUnit')
.mockResolvedValue(['flows/a.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 2,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
// Two failed attempts hit the legacy branch twice; the scan runs once.
expect(parseSpy).toHaveBeenCalledTimes(2);
expect(discoverySpy).toHaveBeenCalledTimes(1);
});
it('defaults retry_failed_only to true when omitted from inputs', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const a1 = mockedSpawn.mock.calls[1][1]!;
expect(a1).toContain('flows/b.yaml');
expect(a1).not.toContain('flows/a.yaml');
});
it('writes merged junit on success', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(mergeSpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
});
it('falls back to copyLatestAttemptXml when mergeJUnitReports throws a data error', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(copySpy).toHaveBeenCalled();
});
it('swallows merge and copy-latest failures when maestro succeeded', async () => {
// Throwing on copy failure would mask the real reason for early-failing
// maestro runs (bad YAML writes no *.xml) and would also wrap a user-side
// failure as SystemError, which cancels the build's billing.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
jest
.spyOn(parser, 'copyLatestAttemptXml')
.mockRejectedValue(Object.assign(new Error('no space'), { code: 'ENOSPC' }));
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).resolves.toBeUndefined();
});
it('surfaces ERR_MAESTRO_TESTS_FAILED (not copy error) when maestro fails and copy fails', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('exit 1'), {
status: 1,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
jest.spyOn(parser, 'copyLatestAttemptXml').mockRejectedValue(new Error('No *.xml files found'));
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries are exhausted with a non-zero exit', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('exit 1'), {
status: 1,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/a.yaml']);
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 2,
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError early on empty flow_path', async () => {
const step = createStep({ flow_path: [], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
// whole-directory upload picks it up.
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
expect(outputArg).not.toMatch(/junit-reports/);
});
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'HTML',
platform: 'ios',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/ios-maestro-html\.html$/);
});
it('does not include --output when output_format is undefined', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: '',
platform: 'android',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
expect(args.some(a => a.startsWith('--output'))).toBe(false);
expect(args).not.toContain('--output');
expect(args).not.toContain('--output=undefined');
expect(args).not.toContain('--output=null');
expect(args).not.toContain('--output=');
});
it('accepts output_format casing variations (e.g. JUNIT) case-insensitively', async () => {
// output_format.toLowerCase() must propagate end-to-end: final_report_path
// populated, per-attempt XMLs written into junit-reports/, merge invoked.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'JUNIT',
platform: 'android',
});
await step.executeAsync();
// Phase 1: final_report_path populated
expect(step.getOutputValueByName('final_report_path')).toMatch(
/\.maestro\/tests\/android-maestro-junit\.xml$/
);
// Retry loop: --output written into junit-reports/ (not legacy path)
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/junit-reports\/android-maestro-junit-attempt-0\.xml$/);
// Phase 3: merge invoked
expect(mergeSpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
});
it('passes include_tags, exclude_tags, and shards through to maestro argv', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
shards: 3,
include_tags: 'smoke,critical',
exclude_tags: 'slow',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
expect(args).toContain('--shard-split=3');
expect(args).toContain('--include-tags=smoke,critical');
expect(args).toContain('--exclude-tags=slow');
});
it('passes cwd and env to spawn so relative flow paths resolve correctly', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
cwd?: string;
env?: Record<string, string | undefined>;
};
expect(spawnOptions).toBeDefined();
expect(typeof spawnOptions.cwd).toBe('string');
expect(spawnOptions.cwd!.length).toBeGreaterThan(0);
expect(spawnOptions.env).toBeDefined();
expect(spawnOptions.env!.HOME).toBe('/home/expo');
});
it('exports MAESTRO_TESTS_DIR to maestro so flows can reference ${MAESTRO_TESTS_DIR}', async () => {
// Public docs (EAS workflows pre-packaged-jobs) instruct users to write
// screenshots/recordings into ${MAESTRO_TESTS_DIR}. The step must export
// it in the spawn env.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
env?: Record<string, string | undefined>;
};
expect(spawnOptions.env!.MAESTRO_TESTS_DIR).toBe('/home/expo/.maestro/tests');
});
it('uses env.HOME (not os.homedir()) for maestro tests output paths', async () => {
// Even when os.homedir() differs, the step should derive paths from env.HOME
// so maestro (which receives env) and the step (which inspects those paths)
// agree on where reports land.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
},
{ env: { HOME: '/custom/home' } }
);
await step.executeAsync();
expect(step.getOutputValueByName('tests_directory')).toBe('/custom/home/.maestro/tests');
expect(step.getOutputValueByName('junit_report_directory')).toBe(
'/custom/home/.maestro/tests/junit-reports'
);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/custom/home/.maestro/tests/android-maestro-junit.xml'
);
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(
/^--output=\/custom\/home\/\.maestro\/tests\/junit-reports\/android-maestro-junit-attempt-0\.xml$/
);
});
it('throws SystemError when env.HOME is unset', async () => {
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
},
{ env: {} }
);
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws UserError on non-string entries in flow_path', async () => {
const step = createStep({
flow_path: ['flows/a.yaml', 2 as unknown as string],
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries is negative', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: -1,
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries is a non-integer number', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 1.5,
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is zero', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 0 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is negative', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: -1 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is a non-integer', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 1.5 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('accepts shards: undefined (optional input)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
await step.executeAsync(); // should succeed, not throw
});
it('uploads harvested screenshots after the retry loop with metadata', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
await fs.writeFile(shot.fileAbsPath, '');
mockedHarvest.mockResolvedValue([shot]);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: shot.displayName,
metadata: shot.metadata,
}),
})
);
});
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const shot = makeShot(0);
await fs.writeFile(shot.fileAbsPath, '');
mockedHarvest.mockResolvedValue([shot]);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
});
it('swallows upload errors without affecting the test verdict', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
await fs.writeFile(shot.fileAbsPath, '');
mockedHarvest.mockResolvedValue([shot]);
mockUploadArtifact.mockRejectedValue(new Error('upload boom'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
});
it('skips screenshot upload but preserves the verdict when the JUnit re-parse throws', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
mockedHarvest.mockResolvedValue([makeShot(0)]);
jest.spyOn(parser, 'parseJUnitTestCases').mockRejectedValue(new Error('malformed junit'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
// The classify throw is swallowed; the maestro failure verdict still surfaces.
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('skips screenshot upload but preserves the verdict when the staging dir cannot be created', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
mockedHarvest.mockResolvedValue([makeShot(0)]);
jest.spyOn(parser, 'parseJUnitTestCases').mockResolvedValue([]);
jest.spyOn(fs, 'mkdtemp').mockRejectedValue(new Error('no tmp space'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
// mkdtemp failure is swallowed; the maestro failure verdict still surfaces.
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('caps uploads at 30 screenshots (excess dropped)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shots = Array.from({ length: 35 }, (_, index) => makeShot(index));
await Promise.all(shots.map(shot => fs.writeFile(shot.fileAbsPath, '')));
mockedHarvest.mockResolvedValue(shots);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
});
it('does not harvest or upload screenshots when output_format is not junit', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'html' });
await step.executeAsync();
expect(mockedHarvest).not.toHaveBeenCalled();
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const tc = (name: string, status: 'passed' | 'failed'): parser.JUnitTestCaseResult => ({
name,
file: undefined,
status,
duration: 0,
errorMessage: null,
tags: [],
properties: {},
});
jest
.spyOn(parser, 'parseJUnitTestCases')
.mockResolvedValue([
tc('PureFlow', 'failed'),
tc('FlakyFlow', 'failed'),
tc('FlakyFlow', 'passed'),
]);
const makeShotFor = (
flowName: string,
attemptIndex: number
): maestroScreenshots.HarvestedScreenshot => ({
fileAbsPath: path.join(os.tmpdir(), `wiring-${flowName}-${attemptIndex}.png`),
displayName: `Failure Screenshot: ${flowName} (attempt ${attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName,
attemptIndex,
capturedAtMs: 1781186692250 + attemptIndex,
},
});
const shots = [
makeShotFor('PureFlow', 0),
makeShotFor('PureFlow', 1),
makeShotFor('FlakyFlow', 0),
makeShotFor('FlakyFlow', 1),
];
await Promise.all(shots.map(shot => fs.writeFile(shot.fileAbsPath, '')));
mockedHarvest.mockResolvedValue(shots);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
// PureFlow keeps only its final attempt (1); FlakyFlow keeps every failed attempt.
// Uploads run concurrently, so compare the set, not the order.
const uploaded = mockUploadArtifact.mock.calls
.map(([arg]) => `${arg.artifact.metadata.flowName}#${arg.artifact.metadata.attemptIndex}`)
.sort();
expect(uploaded).toEqual(['FlakyFlow#0', 'FlakyFlow#1', 'PureFlow#1']);
});
});
@@ -1,81 +0,0 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { parseAndReportXcactivitylog } from '../../utils/ios/xcactivitylog';
import { parseXcactivitylogFunction } from '../parseXcactivitylog';
jest.mock('../../utils/ios/xcactivitylog');
describe(parseXcactivitylogFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('passes proxyBaseUrl from env into parseAndReportXcactivitylog', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
globalContext.updateEnv({ EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com' });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
proxyBaseUrl: 'https://cache.example.com',
})
);
});
it('passes xclogparserVersion as undefined when the input is not provided', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
xclogparserVersion: undefined,
})
);
});
it('resolves derived_data_path and workspace_path inputs against stepCtx.workingDirectory', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
callInputs: {
derived_data_path: 'my-app/ios/Build',
workspace_path: 'my-app/ios',
},
});
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
derivedDataPath: expect.stringMatching(/\/my-app\/ios\/Build$/),
workspacePath: expect.stringMatching(/\/my-app\/ios$/),
})
);
});
it('accepts absolute paths for derived_data_path and workspace_path', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
callInputs: {
derived_data_path: '/abs/derived',
workspace_path: '/abs/workspace',
},
});
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
derivedDataPath: '/abs/derived',
workspacePath: '/abs/workspace',
})
);
});
});
@@ -1,77 +0,0 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { readAppConfig } from '../../../utils/appConfig';
import { createReadAppConfigBuildFunction } from '../readAppConfig';
jest.mock('../../../utils/appConfig');
describe(createReadAppConfigBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('reads and logs app configuration', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({
logger,
staticContextContent: { metadata: { sdkVersion: '54.0.0' } },
});
globalContext.updateEnv({ EXPO_PUBLIC_TEST: '1' });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest
.mocked(readAppConfig)
.mockResolvedValue({ exp: { name: 'my-app', slug: 'my-app-slug', version: '1.2.3' } } as any);
await buildStep.executeAsync();
expect(readAppConfig).toHaveBeenCalledWith(
expect.objectContaining({
projectDir: globalContext.defaultWorkingDirectory,
env: expect.objectContaining({ EXPO_PUBLIC_TEST: '1' }),
logger: stepLogger,
sdkVersion: '54.0.0',
})
);
expect(stepLogger.info).toHaveBeenCalledWith('Using app configuration:');
expect(stepLogger.info).toHaveBeenCalledWith(
JSON.stringify({ name: 'my-app', slug: 'my-app-slug', version: '1.2.3' }, null, 2)
);
const firstOutput = buildStep.outputById.app_config.value;
expect(firstOutput).toBeDefined();
expect(JSON.parse(firstOutput!)).toEqual({
name: 'my-app',
slug: 'my-app-slug',
version: '1.2.3',
});
});
it('does not throw if reading app config fails', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAppConfig).mockRejectedValue(new Error('failed to read app config'));
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
});
it('exports app fields as returned by app config', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest
.mocked(readAppConfig)
.mockResolvedValue({ exp: { name: 'my-app', slug: 123, version: undefined } } as any);
await buildStep.executeAsync();
const secondOutput = buildStep.outputById.app_config.value;
expect(secondOutput).toBeDefined();
expect(JSON.parse(secondOutput!)).toEqual({
name: 'my-app',
slug: 123,
});
});
});
@@ -1,50 +0,0 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { readAndLogPackageJson } from '../../../utils/project';
import { createReadPackageJsonBuildFunction } from '../readPackageJson';
jest.mock('../../../utils/project');
describe(createReadPackageJsonBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('logs package.json contents', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({ logger });
const buildStep =
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAndLogPackageJson).mockReturnValue({ name: 'app', version: '1.0.0' });
await buildStep.executeAsync();
expect(readAndLogPackageJson).toHaveBeenCalledWith(
stepLogger,
globalContext.defaultWorkingDirectory
);
expect(buildStep.outputById.package_json.value).toBe(
JSON.stringify({ name: 'app', version: '1.0.0' })
);
});
it('does not throw if reading package.json fails', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({ logger });
const buildStep =
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAndLogPackageJson).mockImplementation(() => {
throw new Error('failed to read package.json');
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(stepLogger.error).toHaveBeenCalledWith({
err: expect.any(Error),
});
expect(buildStep.outputById.package_json.value).toBeUndefined();
});
});
@@ -27,7 +27,7 @@ describe(createRepackBuildFunction, () => {
vol.reset();
});
it('should set generated output path for successful repack', async () => {
it('should set the output path for successful repack', async () => {
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(
createGlobalContextMock({
@@ -38,33 +38,14 @@ describe(createRepackBuildFunction, () => {
{
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app.ipa',
source_app_path: '/path/to/source_app',
output_path: '/path/to/output_app',
},
}
);
await repackStep.executeAsync();
expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.ipa$/);
});
it('should rename generated aab output path to apk', async () => {
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(
createGlobalContextMock({
staticContextContent: {
job: createTestAndroidJob(),
},
}),
{
callInputs: {
platform: 'android',
source_app_path: '/path/to/source_app.aab',
},
}
);
await repackStep.executeAsync();
expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.apk$/);
expect(repackStep.outputById['output_path'].value).toBe('/path/to/output_app');
});
it('should throw for unsupported platforms', async () => {
@@ -1,393 +0,0 @@
import { Client } from '@urql/core';
import { vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as discovery from '../maestroFlowDiscovery';
import { createReportMaestroTestResultsFunction } from '../reportMaestroTestResults';
const JUNIT_PASS = [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n');
const JUNIT_FAIL = [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n');
const FLOW_AI = JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
});
describe(createReportMaestroTestResultsFunction, () => {
let mockMutationFn: jest.Mock;
let mockGraphqlClient: Client;
beforeEach(() => {
mockMutationFn = jest.fn();
mockGraphqlClient = {
mutation: jest.fn().mockReturnValue({
toPromise: mockMutationFn,
}),
} as unknown as Client;
});
// Returns a mock logger whose child() is itself, so step-level log calls can
// be asserted on the returned object.
function createSelfChildedLogger(): ReturnType<typeof createMockLogger> {
const logger = createMockLogger();
(logger.child as jest.Mock).mockReturnValue(logger);
return logger;
}
function createStep(overrides?: {
callInputs?: Record<string, unknown>;
staticContextContent?: Record<string, unknown>;
env?: Record<string, string>;
logger?: ReturnType<typeof createMockLogger>;
}) {
const ctx = { graphqlClient: mockGraphqlClient };
const fn = createReportMaestroTestResultsFunction(ctx as any);
const globalCtx = createGlobalContextMock({
logger: overrides?.logger ?? createMockLogger(),
projectTargetDirectory: '/root/project',
staticContextContent: {
expoApiServerURL: 'https://api.expo.test',
job: { secrets: { robotAccessToken: 'test-token' } },
...overrides?.staticContextContent,
},
});
globalCtx.updateEnv(overrides?.env ?? { __WORKFLOW_JOB_ID: 'job-uuid-123' });
return fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
junit_report_directory: '/junit',
tests_directory: '/tests',
...overrides?.callInputs,
},
});
}
it('parses JUnit results and calls GraphQL mutation', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/root/project/.maestro/home.yml': '',
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep({
callInputs: {
flow_path: ['.maestro/home.yml'],
},
}).executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.workflowJobId).toBe('job-uuid-123');
expect(variables.input.testCaseResults).toEqual([
expect.objectContaining({
name: 'home',
path: '.maestro/home.yml',
status: 'PASSED',
errorMessage: null,
duration: 10000,
retryCount: 0,
properties: {},
}),
]);
});
it('reports both flows when two share a name but have distinct file= paths', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="login" file="b/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: '1' }, { id: '2' }],
},
},
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults.map((r: any) => r.path).sort()).toEqual([
'a/login.yaml',
'b/login.yaml',
]);
});
it('never scans flow files for a file= report, even when flow_path is provided', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: { createWorkflowDeviceTestCaseResults: [{ id: '1' }] },
},
});
await createStep({ callInputs: { flow_path: ['flows/login.yaml'] } }).executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
expect(discoverySpy).not.toHaveBeenCalled();
});
it('sends tags and properties from JUnit XML', 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">',
' <properties>',
' <property name="testCaseId" value="TC-001"/>',
' <property name="priority" value="high"/>',
' <property name="tags" value="e2e"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults[0].tags).toEqual(['e2e']);
expect(variables.input.testCaseResults[0].properties).toEqual({
testCaseId: 'TC-001',
priority: 'high',
});
});
it('reports failed flow with correct status and errorMessage', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_FAIL,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults[0]).toEqual(
expect.objectContaining({
status: 'FAILED',
errorMessage: 'Tap failed',
duration: 5000,
})
);
});
// robotAccessToken guard removed — Generic.JobZ requires robotAccessToken (z.string(),
// not optional), so it's always present in workflow jobs. graphqlClient is already
// initialized with the token in BuildContext constructor.
it('skips report when junit_report_directory is empty string (upstream step failed)', async () => {
const step = createStep({
callInputs: { junit_report_directory: '' },
});
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('skips report when __WORKFLOW_JOB_ID env is not set', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
const step = createStep({ env: {} });
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('skips report when no JUnit files found', async () => {
const step = createStep();
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('does not throw when GraphQL returns error', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
error: { message: 'Something went wrong' },
});
await createStep().executeAsync();
});
it('does not throw when mutation rejects', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockRejectedValue(new Error('Network error'));
await createStep().executeAsync();
});
it('skips report when duplicate testcase names found (legacy), advising unique names', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="0">',
' <testcase id="login" name="login" classname="login" time="10.0" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="5.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const logger = createSelfChildedLogger();
await createStep({ logger }).executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('unique name'));
});
it('skips report when the same flow file appears twice in one attempt, advising flow_path check', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="2.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const logger = createSelfChildedLogger();
await createStep({ logger }).executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('flow_path entries'));
});
it('sends per-attempt results with same name but different retryCount', async () => {
vol.fromJSON({
// Two JUnit files for same flow (per-attempt)
'/junit/junit-report-flow-1-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/junit/junit-report-flow-1-attempt-1.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
'/tests/2026-01-28_055420/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }, { id: 'id-2' }],
},
},
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults).toHaveLength(2);
expect(variables.input.testCaseResults[0]).toEqual(
expect.objectContaining({ name: 'home', status: 'FAILED', retryCount: 0 })
);
expect(variables.input.testCaseResults[1]).toEqual(
expect.objectContaining({ name: 'home', status: 'PASSED', retryCount: 1 })
);
});
it('uses default directories when inputs are not provided', async () => {
vol.fromJSON({
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
'/home/expo/.maestro/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
const ctx = { graphqlClient: mockGraphqlClient };
const fn = createReportMaestroTestResultsFunction(ctx as any);
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
projectTargetDirectory: '/root/project',
staticContextContent: {
expoApiServerURL: 'https://api.expo.test',
job: { secrets: { robotAccessToken: 'test-token' } },
},
});
globalCtx.updateEnv({ __WORKFLOW_JOB_ID: 'job-uuid-123', HOME: '/home/expo' });
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
await step.executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
});
});
@@ -1,274 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
getAvailableDevicesAsync: jest.fn(),
createAsync: jest.fn(),
cloneAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
disableWindowAndTransitionAnimationsAsync: jest.fn(),
deleteAsync: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
const logger = createMockLogger();
const fn = createStartAndroidEmulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home', ...envOverrides });
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs,
});
return Object.assign(step, { logger });
}
function createStartResult(serialId: string) {
return {
serialId: serialId as any,
emulatorPromise: Promise.resolve({}) as any,
} as any;
}
describe(createStartAndroidEmulatorBuildFunction, () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined);
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-default'));
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
try {
return await fn(attemptCount);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
});
});
it('retries base emulator startup with increasing readiness timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
.mockResolvedValueOnce(createStartResult('emulator-2222'));
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
await createStep().executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-1111',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-2222',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-2222',
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-1111',
})
);
});
it('retries clone startup independently and cleans up failed clone attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-base'))
.mockResolvedValueOnce(createStartResult('emulator-clone-1-attempt-1'))
.mockResolvedValueOnce(createStartResult('emulator-clone-1-attempt-2'))
.mockResolvedValueOnce(createStartResult('emulator-clone-2-attempt-1'));
mockedAndroidUtils.waitForReadyAsync
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('clone network unavailable'))
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
await createStep({ count: 2 }).executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-base',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
4,
expect.objectContaining({
serialId: 'emulator-clone-2-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-base',
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-2',
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-clone-2-attempt-1',
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-1',
deviceName: 'eas-simulator-1' as AndroidVirtualDeviceName,
})
);
});
it('fails after exhausting all startup attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-attempt-1'))
.mockResolvedValueOnce(createStartResult('emulator-attempt-2'))
.mockResolvedValueOnce(createStartResult('emulator-attempt-3'));
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
await expect(createStep().executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
});
it('skips animation scale adjustments when opt out env var is disabled', async () => {
const step = createStep(undefined, {
ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE: 'false',
});
await step.executeAsync();
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
});
it('fails early on Linux when emulator host support check fails', async () => {
mockedSpawn.mockRejectedValueOnce(new Error('accel check failed') as any);
const step = createStep();
await expect(step.executeAsync()).rejects.toThrow(/nested virtualization/i);
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(false);
});
it('continues startup on Linux when emulator host support check succeeds', async () => {
await createStep().executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
});
it('skips emulator host support check when opt out env var is enabled', async () => {
const step = createStep(undefined, {
EAS_NO_EMULATOR_HOST_SUPPORT_CHECK: '1',
});
await step.executeAsync();
expect(mockedSpawn.mock.calls.some(([command]) => command.includes('/emulator/emulator'))).toBe(
false
);
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
});
it('falls back to empty SDK path when Android SDK path env vars are missing', async () => {
await createStep(undefined, {
ANDROID_HOME: undefined,
ANDROID_SDK_ROOT: undefined,
}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
});
it('uses ANDROID_SDK_ROOT when ANDROID_HOME is missing', async () => {
await createStep(undefined, {
ANDROID_HOME: undefined,
ANDROID_SDK_ROOT: '/android/sdk/root',
}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'/android/sdk/root/emulator/emulator',
['-accel-check'],
{
env: expect.any(Object),
}
);
});
});
@@ -1,72 +0,0 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import {
MIN_ARGENT_REMOTE_SESSION_VERSION,
warnIfArgentPackageVersionCannotBeVerified,
} from '../startArgentRemoteSession';
describe(warnIfArgentPackageVersionCannotBeVerified, () => {
const warn = jest.fn();
const logger = { warn } as unknown as bunyan;
const supportedVersion = '999.0.0';
const oldVersion = '0.0.0';
beforeEach(() => {
warn.mockClear();
});
it('allows the default latest package version', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: undefined, logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'latest', logger })
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('allows exact supported semver versions', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
logger,
})
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: supportedVersion, logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: `v${supportedVersion}`,
logger,
})
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('rejects exact semver versions that are too old', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
).toThrow(SystemError);
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
).toThrow(`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}`);
});
it('warns for package tags or ranges that cannot be verified', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'next', logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: `^${MIN_ARGENT_REMOTE_SESSION_VERSION}`,
logger,
})
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting bunx resolve it.')
);
});
});
@@ -1,121 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { createStartIosSimulatorBuildFunction } from '../startIosSimulator';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
getAvailableDevicesAsync: jest.fn(),
getDeviceAsync: jest.fn(),
cloneAsync: jest.fn(),
enableAccessibilitySettingsAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
disableApsdAsync: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
const mockedUtils = jest.mocked(IosSimulatorUtils);
function createStep(callInputs?: Record<string, unknown>) {
const logger = createMockLogger();
const fn = createStartIosSimulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
const step = fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
return Object.assign(step, { logger });
}
describe(createStartIosSimulatorBuildFunction, () => {
beforeEach(() => {
jest.clearAllMocks();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedUtils.getDeviceAsync.mockResolvedValue(null);
mockedUtils.cloneAsync.mockResolvedValue(undefined);
mockedUtils.enableAccessibilitySettingsAsync.mockResolvedValue(undefined);
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
});
it('does not enable accessibility settings by default', async () => {
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 15',
env: expect.any(Object),
});
});
it('enables accessibility settings before starting the main device and every clone when requested', async () => {
mockedUtils.startAsync
.mockResolvedValueOnce({ udid: 'base' as any })
.mockResolvedValueOnce({ udid: 'clone-1' as any })
.mockResolvedValueOnce({ udid: 'clone-2' as any });
await createStep({
device_identifier: 'iPhone 15',
count: 2,
enable_accessibility_settings: true,
}).executeAsync();
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 15',
env: expect.any(Object),
});
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
deviceIdentifier: 'eas-simulator-1',
env: expect.any(Object),
});
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
deviceIdentifier: 'eas-simulator-2',
env: expect.any(Object),
});
for (const [callIndex] of mockedUtils.startAsync.mock.calls.entries()) {
const enableCallOrder =
mockedUtils.enableAccessibilitySettingsAsync.mock.invocationCallOrder[callIndex];
const startCallOrder = mockedUtils.startAsync.mock.invocationCallOrder[callIndex];
expect(enableCallOrder).toBeLessThan(startCallOrder);
}
});
it('disables apsd on the main device and every clone', async () => {
mockedUtils.startAsync
.mockResolvedValueOnce({ udid: 'base' as any })
.mockResolvedValueOnce({ udid: 'clone-1' as any })
.mockResolvedValueOnce({ udid: 'clone-2' as any });
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'base',
env: expect.any(Object),
});
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'clone-1',
env: expect.any(Object),
});
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'clone-2',
env: expect.any(Object),
});
});
it('continues when disabling apsd fails', async () => {
mockedUtils.disableApsdAsync.mockRejectedValue(new Error('apsd disable failed'));
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
// Startup is not aborted: readiness is still awaited for each device.
expect(mockedUtils.waitForReadyAsync).toHaveBeenCalled();
});
});
@@ -120,44 +120,4 @@ describe(createUploadArtifactBuildFunction, () => {
const typeInput = buildStep.inputs?.find(input => input.id === 'type')!;
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
});
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
type: 'other',
key: 'Screen Recording',
path: '/',
metadata,
},
});
await buildStep.executeAsync();
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
);
});
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
contextUploadArtifact.mockClear();
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
type: 'build-artifact',
path: '/',
metadata: { kind: 'screen-recording' },
},
});
await buildStep.executeAsync();
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.not.objectContaining({ metadata: expect.anything() }),
})
);
});
});
@@ -1,112 +0,0 @@
import {
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
describe(isClosedVersionTrainError, () => {
it('returns true when all errors are closed-version-train codes', () => {
expect(
isClosedVersionTrainError([
{ code: '90062' },
{ code: '90186' },
{ code: '90478' },
{ code: '90062' },
])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(
isClosedVersionTrainError([{ code: '90062' }, { code: '90186' }, { code: '12345' }])
).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isClosedVersionTrainError([])).toBe(false);
});
});
describe(isInvalidBundleIdentifierError, () => {
it('returns true when all errors are invalid-bundle-id codes', () => {
expect(
isInvalidBundleIdentifierError([{ code: '90054' }, { code: '90055' }, { code: '90055' }])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isInvalidBundleIdentifierError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isMissingPurposeStringError([])).toBe(false);
});
});
describe(isSdkVersionIssueError, () => {
it('returns true when all errors are SDK-version-issue codes', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isSdkVersionIssueError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSPhotoLibraryUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSPhotoLibraryUsageDescription', 'NSCameraUsageDescription']);
});
it('deduplicates repeated keys', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSCameraUsageDescription']);
});
it('returns an empty array when no usage description key is present', () => {
expect(
parseMissingUsageDescriptionKeys([{ description: 'Some other upload validation error.' }])
).toEqual([]);
});
});
@@ -34,20 +34,18 @@ export function calculateEASUpdateRuntimeVersionFunction(): BuildFunction {
}),
],
fn: async (stepCtx, { env, inputs, outputs }) => {
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
})
).exp;
const appConfig = readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
}).exp;
const platform =
(inputs.platform.value as Platform) ?? stepCtx.global.staticContext.job.platform;
Loaded 100 of 676 files, more files were not shown because too many files have changed in this diff. Show more