mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bad188babe | ||
|
|
f6ba878b29 | ||
|
|
07fbe5c777 | ||
|
|
c8739b66af | ||
|
|
777a3c947f | ||
|
|
ce3d17ae4c | ||
|
|
2f9dfb6610 | ||
|
|
b862a0575c | ||
|
|
c1fe243a70 | ||
|
|
8065715d4c | ||
|
|
402cc40bb9 | ||
|
|
61eeabb404 | ||
|
|
edbaaf8000 | ||
|
|
68e561e879 |
No files matched your search
@@ -1,16 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Keep SHA-pinned GitHub Actions fresh. Without this, pins never receive
|
||||
# upstream security patches because tag movements no longer reach us.
|
||||
- package-ecosystem: github-actions
|
||||
directories:
|
||||
- /
|
||||
- /.github/actions/setup-mise
|
||||
- /.github/internal-actions/setup-gcloud
|
||||
- /.github/internal-actions/notify-slack-on-fail-or-recover
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- '*'
|
||||
@@ -12,7 +12,7 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Get previous workflow run status
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
uses: actions/github-script@v6
|
||||
id: run-status
|
||||
with:
|
||||
script: |
|
||||
@@ -28,7 +28,7 @@ runs:
|
||||
|
||||
- name: Send Slack Success Notification
|
||||
if: fromJSON(steps.run-status.outputs.result).previous == 'failure' && fromJSON(steps.run-status.outputs.result).current == 'success'
|
||||
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
env:
|
||||
SLACK_CHANNEL: ${{ inputs.channel }}
|
||||
SLACK_COLOR: good
|
||||
@@ -41,7 +41,7 @@ runs:
|
||||
|
||||
- name: Send Slack Failure Notification
|
||||
if: fromJSON(steps.run-status.outputs.result).current == 'failure'
|
||||
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
env:
|
||||
SLACK_CHANNEL: ${{ inputs.channel }}
|
||||
SLACK_COLOR: danger
|
||||
|
||||
@@ -4,7 +4,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Auth gcloud
|
||||
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: 'projects/321830142373/locations/global/workloadIdentityPools/github/providers/expo'
|
||||
project_id: exponentjs
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
name: Check CHANGELOG.md updated
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
|
||||
- uses: actions/checkout@v2
|
||||
- name: Check if CHANGELOG.md was updated
|
||||
id: changelog-updated
|
||||
uses: tj-actions/changed-files@b1ba699b304f2083b602164e06a89b868c84f076
|
||||
@@ -26,11 +26,11 @@ jobs:
|
||||
files: CHANGELOG.md
|
||||
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
|
||||
if: steps.changelog-updated.outputs.any_changed == 'false' && !contains(github.event.pull_request.labels.*.name, 'no changelog')
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
core.setFailed('Please add a changelog entry!')
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
- uses: mshick/[email protected]
|
||||
if: always()
|
||||
with:
|
||||
message-id: changelog-entry-check
|
||||
|
||||
@@ -1,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 }}
|
||||
@@ -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: |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
-2
@@ -5,6 +5,5 @@
|
||||
"**/*.code-search": true,
|
||||
"**/src/**/build/**": false,
|
||||
"**/src/build/**": false,
|
||||
},
|
||||
"js/ts.tsdk.path": "node_modules/typescript/lib"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,2 @@
|
||||
defaultSemverRangePrefix: ''
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 2d
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- '@expo/*'
|
||||
- 'eslint-plugin-universe'
|
||||
- 'oxlint-config-universe'
|
||||
+2
-201
@@ -10,208 +10,9 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 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))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "20.5.0",
|
||||
"version": "18.9.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -3,6 +3,4 @@ _.path = "./tools/bin"
|
||||
MISE_NODE_COREPACK = "true"
|
||||
|
||||
[tools]
|
||||
gcloud = "570.0.0"
|
||||
jq = "1.8.1"
|
||||
node = "20.19.4"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "20.4.0",
|
||||
"version": "18.9.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -37,21 +37,19 @@
|
||||
"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/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.8.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "20.0.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "20.1.0",
|
||||
"@expo/template-file": "20.0.0",
|
||||
"@expo/turtle-spawn": "20.0.0",
|
||||
"@expo/steps": "18.9.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@google-cloud/storage": "^7.11.2",
|
||||
"@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",
|
||||
@@ -77,7 +75,7 @@
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/repack-app": "~0.6.1",
|
||||
"@expo/repack-app": "~0.4.1",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
|
||||
@@ -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,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,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;
|
||||
}
|
||||
@@ -32,27 +32,18 @@ export async function runGradleCommand(
|
||||
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
|
||||
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : '';
|
||||
|
||||
const spawnPromise = spawn(
|
||||
'bash',
|
||||
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
|
||||
{
|
||||
cwd: androidDir,
|
||||
logger,
|
||||
lineTransformer: (line?: string) => {
|
||||
if (!line || /^\.+$/.exec(line)) {
|
||||
return null;
|
||||
} else {
|
||||
return line;
|
||||
}
|
||||
},
|
||||
env: {
|
||||
...ctx.env,
|
||||
...extraEnv,
|
||||
...resolveVersionOverridesEnvs(ctx),
|
||||
LC_ALL: 'C.UTF-8',
|
||||
},
|
||||
}
|
||||
);
|
||||
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
|
||||
cwd: androidDir,
|
||||
logger,
|
||||
lineTransformer: (line?: string) => {
|
||||
if (!line || /^\.+$/.exec(line)) {
|
||||
return null;
|
||||
} else {
|
||||
return line;
|
||||
}
|
||||
},
|
||||
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx), LC_ALL: 'C.UTF-8' },
|
||||
});
|
||||
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
|
||||
adjustOOMScore(spawnPromise, logger);
|
||||
}
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
export interface GradleProfileTask {
|
||||
path: string;
|
||||
durationMs: number;
|
||||
result: string;
|
||||
}
|
||||
|
||||
export async function parseGradleProfile(androidDir: string): Promise<GradleProfileTask[]> {
|
||||
const profileDir = path.join(androidDir, 'build', 'reports', 'profile');
|
||||
// Gradle profile may not exist for all builds (e.g. custom, repack
|
||||
// jobs). We should short circuit and not unnecessarily log.
|
||||
if (!(await fs.pathExists(profileDir))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await fs.readdir(profileDir);
|
||||
const htmlFile = files
|
||||
.filter(f => f.startsWith('profile-') && f.endsWith('.html'))
|
||||
.sort()
|
||||
.pop();
|
||||
|
||||
if (!htmlFile) {
|
||||
throw new Error(`No Gradle profile HTML found in ${profileDir}`);
|
||||
}
|
||||
|
||||
const html = await fs.readFile(path.join(profileDir, htmlFile), 'utf8');
|
||||
|
||||
// Locate the <h2>Task Execution</h2> heading and extract its <table>
|
||||
const headingMatch = html.match(/<h2[^>]*>\s*Task Execution\s*<\/h2>/i);
|
||||
if (!headingMatch || headingMatch.index === undefined) {
|
||||
throw new Error('Could not find Task Execution section in Gradle profile');
|
||||
}
|
||||
|
||||
const sectionStart = headingMatch.index;
|
||||
const tableStart = html.indexOf('<table', sectionStart);
|
||||
const tableEnd = html.indexOf('</table>', tableStart);
|
||||
if (tableStart === -1 || tableEnd === -1) {
|
||||
throw new Error('Could not find task execution table in Gradle profile');
|
||||
}
|
||||
|
||||
const tableHtml = html.slice(tableStart, tableEnd + '</table>'.length);
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: true,
|
||||
isArray: name => name === 'tr' || name === 'td',
|
||||
trimValues: true,
|
||||
});
|
||||
|
||||
const parsed = parser.parse(tableHtml);
|
||||
const rows: any[] = parsed?.table?.tbody?.tr ?? parsed?.table?.tr ?? [];
|
||||
|
||||
const tasks: GradleProfileTask[] = [];
|
||||
for (const row of rows) {
|
||||
const cells: unknown[] = row?.td;
|
||||
if (!cells || cells.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const taskPath = String(cells[0] ?? '').trim();
|
||||
const durationStr = String(cells[1] ?? '').trim();
|
||||
const result = String(cells[2] ?? '').trim() || 'executed';
|
||||
|
||||
if (!taskPath || !durationStr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const durationMs = parseDurationToMs(durationStr);
|
||||
tasks.push({ path: taskPath, durationMs, result: result.toLowerCase() });
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
function parseDurationToMs(duration: string): number {
|
||||
// Gradle profile durations can be like "1.234s", "0.045s", "12.5s"
|
||||
const secondsMatch = duration.match(/^([\d.]+)s$/);
|
||||
if (secondsMatch) {
|
||||
return Math.round(parseFloat(secondsMatch[1]) * 1000);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function formatSeconds(ms: number): string {
|
||||
const s = ms / 1000;
|
||||
if (s < 0.1) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
return `${s.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export function formatGradleProfileReport(tasks: GradleProfileTask[]): string {
|
||||
// Filter out tasks under 1 second
|
||||
const significantTasks = tasks.filter(t => t.durationMs >= 1000);
|
||||
|
||||
// Separate module totals from individual tasks
|
||||
const moduleTotals = significantTasks.filter(t => t.result === '(total)');
|
||||
const individualTasks = significantTasks.filter(t => t.result !== '(total)');
|
||||
|
||||
// Group individual tasks by their module prefix
|
||||
const moduleChildren = new Map<string, GradleProfileTask[]>();
|
||||
const orphanTasks: GradleProfileTask[] = [];
|
||||
|
||||
for (const task of individualTasks) {
|
||||
const parent = moduleTotals.find(m => task.path.startsWith(m.path + ':'));
|
||||
if (parent) {
|
||||
const children = moduleChildren.get(parent.path) ?? [];
|
||||
children.push(task);
|
||||
moduleChildren.set(parent.path, children);
|
||||
} else {
|
||||
orphanTasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort module totals by duration, sort children within each module
|
||||
const sortedModules = [...moduleTotals].sort((a, b) => b.durationMs - a.durationMs);
|
||||
for (const children of moduleChildren.values()) {
|
||||
children.sort((a, b) => b.durationMs - a.durationMs);
|
||||
}
|
||||
orphanTasks.sort((a, b) => b.durationMs - a.durationMs);
|
||||
|
||||
// Build display rows: [displayName, task]
|
||||
const rows: { displayName: string; task: GradleProfileTask }[] = [];
|
||||
|
||||
for (const mod of sortedModules) {
|
||||
rows.push({ displayName: mod.path, task: mod });
|
||||
const children = moduleChildren.get(mod.path) ?? [];
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const isLast = i === children.length - 1;
|
||||
const prefix = isLast ? ' └─ ' : ' ├─ ';
|
||||
const shortName = children[i].path.slice(mod.path.length + 1);
|
||||
rows.push({ displayName: prefix + shortName, task: children[i] });
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of orphanTasks) {
|
||||
rows.push({ displayName: task.path, task });
|
||||
}
|
||||
|
||||
// Compute totals from individual tasks only (avoid double-counting)
|
||||
const totalMs = individualTasks.reduce((sum, t) => sum + t.durationMs, 0);
|
||||
const maxMs = totalMs || 1;
|
||||
|
||||
const nameWidth = Math.max(4, ...rows.map(r => r.displayName.length)) + 2;
|
||||
const barMaxWidth = 20;
|
||||
|
||||
const header =
|
||||
'┌─' +
|
||||
'─'.repeat(nameWidth) +
|
||||
'─┬────────────┬──────────┬────────────┬─' +
|
||||
'─'.repeat(barMaxWidth) +
|
||||
'─┐';
|
||||
const divider =
|
||||
'├─' +
|
||||
'─'.repeat(nameWidth) +
|
||||
'─┼────────────┼──────────┼────────────┼─' +
|
||||
'─'.repeat(barMaxWidth) +
|
||||
'─┤';
|
||||
const footer =
|
||||
'└─' +
|
||||
'─'.repeat(nameWidth) +
|
||||
'─┴────────────┴──────────┴────────────┴─' +
|
||||
'─'.repeat(barMaxWidth) +
|
||||
'─┘';
|
||||
|
||||
const taskCount = individualTasks.length;
|
||||
const cachedCount = individualTasks.filter(t => t.result !== 'executed').length;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('Gradle Build — Task Execution Profile');
|
||||
const cachedSuffix = cachedCount > 0 ? ` (${cachedCount} cached/up-to-date)` : '';
|
||||
lines.push(`${taskCount} tasks${cachedSuffix}, total task time: ${formatSeconds(totalMs)}`);
|
||||
lines.push('% Time = share of total task execution time');
|
||||
lines.push('');
|
||||
lines.push(header);
|
||||
lines.push(
|
||||
'│ ' +
|
||||
'Task'.padEnd(nameWidth) +
|
||||
' │ ' +
|
||||
'Duration'.padStart(10) +
|
||||
' │ ' +
|
||||
'% Time'.padStart(8) +
|
||||
' │ ' +
|
||||
'Result'.padEnd(10) +
|
||||
' │ ' +
|
||||
' '.repeat(barMaxWidth) +
|
||||
' │'
|
||||
);
|
||||
lines.push(divider);
|
||||
|
||||
for (const row of rows) {
|
||||
const pct = totalMs === 0 ? 0 : (row.task.durationMs / totalMs) * 100;
|
||||
const barLength = Math.round((row.task.durationMs / maxMs) * barMaxWidth);
|
||||
const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength);
|
||||
const result = row.task.result === '(total)' ? 'total' : row.task.result;
|
||||
|
||||
lines.push(
|
||||
'│ ' +
|
||||
row.displayName.padEnd(nameWidth) +
|
||||
' │ ' +
|
||||
formatSeconds(row.task.durationMs).padStart(10) +
|
||||
' │ ' +
|
||||
`${pct.toFixed(1)}%`.padStart(8) +
|
||||
' │ ' +
|
||||
result.padEnd(10) +
|
||||
' │ ' +
|
||||
bar +
|
||||
' │'
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(divider);
|
||||
lines.push(
|
||||
'│ ' +
|
||||
'TOTAL'.padEnd(nameWidth) +
|
||||
' │ ' +
|
||||
formatSeconds(totalMs).padStart(10) +
|
||||
' │ ' +
|
||||
'100.0%'.padStart(8) +
|
||||
' │ ' +
|
||||
' '.repeat(10) +
|
||||
' │ ' +
|
||||
' '.repeat(barMaxWidth) +
|
||||
' │'
|
||||
);
|
||||
lines.push(footer);
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -4,9 +4,7 @@ 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 {
|
||||
@@ -58,9 +56,6 @@ 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',
|
||||
@@ -72,11 +67,8 @@ jest.mock('../../utils/prepareBuildExecutable', () => ({
|
||||
prepareExecutableAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const datadogDistributionMock = jest.spyOn(Datadog, 'distribution');
|
||||
|
||||
describe(androidBuilder, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vol.fromJSON(
|
||||
{
|
||||
'/workingdir/env/.gitkeep': '',
|
||||
@@ -146,6 +138,7 @@ describe(androidBuilder, () => {
|
||||
});
|
||||
|
||||
it('marks the configure Android version phase as warning for legacy eas-build.gradle', async () => {
|
||||
const reportBuildPhaseStats = jest.fn();
|
||||
const job: Android.Job = {
|
||||
...createTestAndroidJob(),
|
||||
secrets: {
|
||||
@@ -164,23 +157,23 @@ describe(androidBuilder, () => {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
reportBuildPhaseStats,
|
||||
});
|
||||
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(reportBuildPhaseStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
|
||||
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the prepare credentials phase as warning for legacy eas-build.gradle', async () => {
|
||||
const reportBuildPhaseStats = jest.fn();
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
@@ -189,23 +182,23 @@ describe(androidBuilder, () => {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
reportBuildPhaseStats,
|
||||
});
|
||||
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(reportBuildPhaseStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
|
||||
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('injects credentials and version config when both are configured', async () => {
|
||||
const reportBuildPhaseStats = jest.fn();
|
||||
const job: Android.Job = {
|
||||
...createTestAndroidJob(),
|
||||
version: {
|
||||
@@ -221,6 +214,7 @@ describe(androidBuilder, () => {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
reportBuildPhaseStats,
|
||||
});
|
||||
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
|
||||
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
|
||||
@@ -240,19 +234,15 @@ describe(androidBuilder, () => {
|
||||
versionName: '1.2.3',
|
||||
}
|
||||
);
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
|
||||
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
|
||||
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
@@ -273,37 +263,4 @@ describe(androidBuilder, () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ 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 +17,6 @@ const findAndUploadXcodeBuildLogsAsyncMock = jest.mocked(findAndUploadXcodeBuild
|
||||
|
||||
describe(runCustomBuildAsync, () => {
|
||||
let ctx: BuildContext<BuildJob>;
|
||||
const datadogLogSpy = jest.spyOn(Datadog, 'log');
|
||||
|
||||
beforeEach(() => {
|
||||
const job = createTestIosJob();
|
||||
@@ -29,24 +27,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'
|
||||
);
|
||||
@@ -93,21 +77,6 @@ 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
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
resolveGradleCommand,
|
||||
runGradleCommand,
|
||||
} from '../android/gradle';
|
||||
import { parseGradleProfile, formatGradleProfileReport } from '../android/gradleProfile';
|
||||
import { Sentry } from '../sentry';
|
||||
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
import { setupAsync } from '../common/setup';
|
||||
@@ -32,7 +30,6 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
|
||||
@@ -183,20 +180,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 +192,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.');
|
||||
|
||||
@@ -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();
|
||||
@@ -111,26 +103,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(','),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
|
||||
import { installPods } from '../ios/pod';
|
||||
import { downloadApplicationArchiveAsync } from '../ios/resign';
|
||||
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
@@ -25,7 +24,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';
|
||||
@@ -175,28 +173,17 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
|
||||
if (ctx.isLocal) {
|
||||
ctx.logger.info('Local builds skip build performance analysis.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
|
||||
const { skipped } = await parseAndReportXcactivitylog({
|
||||
if (ctx.env.EXPERIMENTAL_EAS_XCACTIVITYLOG === '1') {
|
||||
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
|
||||
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
logger: ctx.logger,
|
||||
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
env: ctx.env,
|
||||
});
|
||||
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 +197,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,4 +1,4 @@
|
||||
import { ArchiveSourceType, BuildJob, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { BuildJob } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
|
||||
@@ -15,7 +15,7 @@ jest.mock('../../utils/easCli', () => ({
|
||||
|
||||
describe('easBuildInternal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
|
||||
cmd: 'npx',
|
||||
args: ['-y', 'eas-cli@latest'],
|
||||
@@ -69,131 +69,4 @@ describe('easBuildInternal', () => {
|
||||
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,12 +1,4 @@
|
||||
import {
|
||||
BuildJob,
|
||||
Env,
|
||||
Ios,
|
||||
Metadata,
|
||||
Platform,
|
||||
sanitizeBuildJob,
|
||||
sanitizeMetadata,
|
||||
} from '@expo/eas-build-job';
|
||||
import { BuildJob, Env, Metadata, sanitizeBuildJob, sanitizeMetadata } from '@expo/eas-build-job';
|
||||
import { PipeMode, bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -50,48 +42,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>(
|
||||
@@ -152,9 +134,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,7 +19,6 @@ 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[];
|
||||
@@ -72,108 +69,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 +87,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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,8 +62,9 @@ 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;
|
||||
@@ -80,7 +80,7 @@ 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;
|
||||
@@ -97,6 +97,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
private buildPhaseSkipped = false;
|
||||
private buildPhaseHasWarnings = false;
|
||||
private _appConfig?: Promise<ExpoConfig>;
|
||||
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
|
||||
public readonly graphqlClient: Client;
|
||||
|
||||
constructor(job: TJob, options: BuildContextOptions) {
|
||||
@@ -112,6 +113,7 @@ export class BuildContext<TJob extends 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 = {
|
||||
@@ -352,13 +354,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,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);
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -9,10 +8,6 @@ import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { installPods } from '../pod';
|
||||
|
||||
jest.mock('@expo/eas-build-job', () => ({
|
||||
...jest.requireActual('@expo/eas-build-job'),
|
||||
getInstalledExpoPackageVersionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
@@ -24,14 +19,10 @@ 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')]: '',
|
||||
@@ -57,13 +48,7 @@ function makeIosBuildContext({
|
||||
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 } : {}),
|
||||
},
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
}
|
||||
@@ -71,23 +56,32 @@ function makeIosBuildContext({
|
||||
describe(installPods, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
jest.mocked(getInstalledExpoPackageVersionAsync).mockImplementation(async ({ projectDir }) => {
|
||||
return (await fs.readJson(path.join(projectDir, 'node_modules/expo/package.json'))).version;
|
||||
(spawn as jest.Mock).mockImplementation(command => {
|
||||
if (command === 'node') {
|
||||
return Promise.resolve({
|
||||
stdout: Buffer.from(path.join(PROJECT_DIR, 'node_modules/expo/package.json')),
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
(spawn as jest.Mock).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: '999.0.0' });
|
||||
it('adds precompiled modules env var for Expo versions that support them', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: '55.0.18' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect(getInstalledExpoPackageVersionAsync).toHaveBeenCalledWith({
|
||||
env: expect.objectContaining({
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
}),
|
||||
projectDir: PROJECT_DIR,
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'node',
|
||||
['--print', "require.resolve('expo/package.json')"],
|
||||
expect.objectContaining({
|
||||
cwd: PROJECT_DIR,
|
||||
env: expect.objectContaining({
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
}),
|
||||
stdio: 'pipe',
|
||||
})
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
@@ -95,51 +89,25 @@ describe(installPods, () => {
|
||||
cwd: IOS_DIR,
|
||||
env: expect.objectContaining({
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL:
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules/',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(
|
||||
(spawn as jest.Mock).mock.calls[1][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
|
||||
).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
|
||||
);
|
||||
});
|
||||
|
||||
it('proxies precompiled modules through CocoaPods cache when enabled', async () => {
|
||||
const ctx = makeIosBuildContext({
|
||||
expoVersion: '999.0.0',
|
||||
cocoapodsCacheUrl: 'https://cocoapods-cache.expo.test/',
|
||||
});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: IOS_DIR,
|
||||
env: expect.objectContaining({
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL:
|
||||
'https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
|
||||
'Detected expo=55.0.18; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nPrecompiled modules pod install environment is configured.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: '55.0.25' });
|
||||
const ctx = makeIosBuildContext({ expoVersion: '55.0.17' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/^Detected expo=55\.0\.25; not enabling precompiled modules use because precompiled modules require expo>=55\.0\.26\./
|
||||
)
|
||||
'Detected expo=55.0.17; not enabling precompiled modules use because precompiled modules require expo>=55.0.18.'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -148,7 +116,7 @@ describe(installPods, () => {
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
|
||||
);
|
||||
@@ -159,7 +127,7 @@ describe(installPods, () => {
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
|
||||
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
|
||||
@@ -168,7 +136,7 @@ describe(installPods, () => {
|
||||
|
||||
it('does not add precompiled modules env vars without the builder flag', async () => {
|
||||
const ctx = makeIosBuildContext({
|
||||
expoVersion: '999.0.0',
|
||||
expoVersion: '55.0.18',
|
||||
usePrecompiledModules: false,
|
||||
});
|
||||
|
||||
@@ -176,28 +144,6 @@ describe(installPods, () => {
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(
|
||||
(spawn as jest.Mock).mock.calls[0][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
|
||||
).toBeUndefined();
|
||||
expect(ctx.logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,8 @@ 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>(
|
||||
@@ -93,7 +93,7 @@ export async function runFastlane(
|
||||
cwd,
|
||||
}: {
|
||||
logger?: bunyan;
|
||||
env?: Env;
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
} = {}
|
||||
): Promise<SpawnResult> {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Env, Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
|
||||
import { Env, Ios } from '@expo/eas-build-job';
|
||||
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.26';
|
||||
const PRECOMPILED_MODULES_BASE_URL =
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules/';
|
||||
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.18';
|
||||
// const PRECOMPILED_MODULES_BASE_URL = 'https://storage.googleapis.com/eas-build-precompiled-modules/';
|
||||
|
||||
export async function installPods<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>,
|
||||
@@ -46,21 +46,13 @@ 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') {
|
||||
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
|
||||
return {};
|
||||
}
|
||||
|
||||
let expoPackageVersion: string;
|
||||
try {
|
||||
expoPackageVersion = await getInstalledExpoPackageVersionAsync({
|
||||
env: ctx.env,
|
||||
projectDir: ctx.getReactNativeProjectDirectory(),
|
||||
});
|
||||
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
|
||||
} catch (err) {
|
||||
ctx.logger.info(
|
||||
{ err },
|
||||
@@ -84,9 +76,10 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
|
||||
return {};
|
||||
}
|
||||
|
||||
// Start rollout with Expo precompiled modules only. Add third-party modules after this is stable.
|
||||
const env: Env = {
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(ctx),
|
||||
// EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(),
|
||||
};
|
||||
|
||||
ctx.logger.info(
|
||||
@@ -100,14 +93,26 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
|
||||
return env;
|
||||
}
|
||||
|
||||
function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
|
||||
if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
|
||||
return PRECOMPILED_MODULES_BASE_URL;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
|
||||
return PRECOMPILED_MODULES_BASE_URL.replace(
|
||||
`${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
`${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
|
||||
);
|
||||
async function getInstalledExpoPackageVersionAsync<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>
|
||||
): Promise<string> {
|
||||
const { stdout } = await spawn('node', ['--print', "require.resolve('expo/package.json')"], {
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
env: ctx.env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const expoPackageJsonPath = stdout.toString().trim();
|
||||
return (await fs.readJson(expoPackageJsonPath)).version;
|
||||
}
|
||||
|
||||
// function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
|
||||
// if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
|
||||
// return PRECOMPILED_MODULES_BASE_URL;
|
||||
// }
|
||||
//
|
||||
// const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
|
||||
// return PRECOMPILED_MODULES_BASE_URL.replace(
|
||||
// `${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
// `${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
|
||||
// );
|
||||
// }
|
||||
+3
-18
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -41,10 +41,8 @@ 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';
|
||||
@@ -61,7 +59,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createDownloadBuildFunction(ctx),
|
||||
createDownloadBuildFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
createEasDeployBuildFunction(),
|
||||
createRepackBuildFunction(),
|
||||
@@ -81,12 +79,10 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
generateGymfileFromTemplateFunction(),
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
createStartServeSimRemoteSessionBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
|
||||
createInstallPodsBuildFunction(),
|
||||
@@ -98,7 +94,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createUploadToAscBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(ctx),
|
||||
createMaestroTestsBuildFunction(),
|
||||
];
|
||||
|
||||
if (ctx.hasBuildJob()) {
|
||||
|
||||
@@ -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,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,15 +1,9 @@
|
||||
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { 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', () => ({
|
||||
@@ -22,29 +16,7 @@ jest.mock('../../../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,
|
||||
@@ -68,10 +40,7 @@ function createStep(
|
||||
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
|
||||
> {
|
||||
const logger = createMockLogger();
|
||||
const ctx = {
|
||||
runtimeApi: { uploadArtifact: mockUploadArtifact },
|
||||
} as unknown as CustomBuildContext;
|
||||
const fn = createMaestroTestsBuildFunction(ctx);
|
||||
const fn = createMaestroTestsBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({ logger });
|
||||
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
|
||||
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
|
||||
@@ -80,22 +49,14 @@ function createStep(
|
||||
describe('createMaestroTestsBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
// Default-stub the merge helper so tests that don't care about it don't
|
||||
// hit the real disk. Individual tests override this.
|
||||
// Default-stub copyLatestAttemptXml so tests that don't care about it
|
||||
// don't hit the real disk. Individual tests override this.
|
||||
jest.restoreAllMocks();
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
mockedHarvest.mockReset();
|
||||
mockedHarvest.mockResolvedValue([]);
|
||||
mockUploadArtifact.mockReset();
|
||||
mockUploadArtifact.mockResolvedValue({ artifactId: 'artifact-1' });
|
||||
jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
|
||||
});
|
||||
|
||||
it('exports a factory that returns a BuildFunction instance', () => {
|
||||
expect(
|
||||
createMaestroTestsBuildFunction({
|
||||
runtimeApi: { uploadArtifact: mockUploadArtifact },
|
||||
} as unknown as CustomBuildContext)
|
||||
).toBeDefined();
|
||||
expect(createMaestroTestsBuildFunction()).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets all outputs before running any flows', async () => {
|
||||
@@ -180,34 +141,10 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries only the failed flows on the next attempt (junit mode)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Attempt 0 saw all 3
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
// Attempt 1 saw only the failed one
|
||||
const a1Args = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1Args).toContain('flows/b.yaml');
|
||||
expect(a1Args).not.toContain('flows/a.yaml');
|
||||
expect(a1Args).not.toContain('flows/c.yaml');
|
||||
});
|
||||
|
||||
it('retries all flows when parseFailedFlowsFromJUnit returns null', async () => {
|
||||
it('retries all flows on failure', async () => {
|
||||
const spawnMock = mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(null);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
|
||||
@@ -217,7 +154,6 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Both attempts saw all 3 flows
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
@@ -226,215 +162,8 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('always retries all flows when output_format is not junit', async () => {
|
||||
const spawnMock = mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
expect(spawnMock.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('runs all original flows on every retry when retry_failed_only=false', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest
|
||||
.spyOn(parser, 'parseFailedFlowsFromJUnit')
|
||||
.mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
retry_failed_only: false,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Both attempts must see both flows in the original input order. Tail-slice
|
||||
// equality locks order, not just membership — guards against any future
|
||||
// failed-first reshuffling of the dumb-retry list.
|
||||
const a0 = mockedSpawn.mock.calls[0][1]!;
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a0.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
|
||||
expect(a1.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('subsets to failing flows on retry when retry_failed_only=true (default behaviour)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
retry_failed_only: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1).toContain('flows/b.yaml');
|
||||
expect(a1).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('falls back to all-flows retry when retry_failed_only=true but output_format!=junit', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'html',
|
||||
retry_failed_only: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('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 () => {
|
||||
it('writes final report via copyLatestAttemptXml on success', 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({
|
||||
@@ -444,15 +173,19 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(copySpy).toHaveBeenCalled();
|
||||
expect(copySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceDir: expect.stringContaining('junit-reports'),
|
||||
outputPath: expect.stringContaining('android-maestro-junit.xml'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows merge and copy-latest failures when maestro succeeded', async () => {
|
||||
it('swallows copyLatestAttemptXml failure when maestro succeeded', async () => {
|
||||
// Throwing on copy failure would mask the real reason for early-failing
|
||||
// maestro runs (bad YAML writes no *.xml) and would also wrap a user-side
|
||||
// failure as SystemError, which cancels the build's billing.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
|
||||
jest
|
||||
.spyOn(parser, 'copyLatestAttemptXml')
|
||||
.mockRejectedValue(Object.assign(new Error('no space'), { code: 'ENOSPC' }));
|
||||
@@ -497,8 +230,6 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/a.yaml']);
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
@@ -567,9 +298,9 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
|
||||
it('accepts output_format casing variations (e.g. JUNIT) case-insensitively', async () => {
|
||||
// output_format.toLowerCase() must propagate end-to-end: final_report_path
|
||||
// populated, per-attempt XMLs written into junit-reports/, merge invoked.
|
||||
// populated, per-attempt XMLs written into junit-reports/, copy invoked.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
@@ -578,16 +309,13 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Phase 1: final_report_path populated
|
||||
expect(step.getOutputValueByName('final_report_path')).toMatch(
|
||||
/\.maestro\/tests\/android-maestro-junit\.xml$/
|
||||
);
|
||||
// Retry loop: --output written into junit-reports/ (not legacy path)
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/junit-reports\/android-maestro-junit-attempt-0\.xml$/);
|
||||
// Phase 3: merge invoked
|
||||
expect(mergeSpy).toHaveBeenCalledWith(
|
||||
expect(copySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceDir: expect.stringContaining('junit-reports'),
|
||||
outputPath: expect.stringContaining('android-maestro-junit.xml'),
|
||||
@@ -742,149 +470,4 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
+5
-86
@@ -3,7 +3,6 @@ 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 = [
|
||||
@@ -44,24 +43,15 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
} 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(),
|
||||
logger: createMockLogger(),
|
||||
projectTargetDirectory: '/root/project',
|
||||
staticContextContent: {
|
||||
expoApiServerURL: 'https://api.expo.test',
|
||||
@@ -82,7 +72,7 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
it('parses JUnit results and calls GraphQL mutation', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': JUNIT_PASS,
|
||||
'/root/project/.maestro/home.yml': '',
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
@@ -93,11 +83,7 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
},
|
||||
});
|
||||
|
||||
await createStep({
|
||||
callInputs: {
|
||||
flow_path: ['.maestro/home.yml'],
|
||||
},
|
||||
}).executeAsync();
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
|
||||
@@ -115,54 +101,6 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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': [
|
||||
@@ -279,7 +217,7 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
await createStep().executeAsync();
|
||||
});
|
||||
|
||||
it('skips report when duplicate testcase names found (legacy), advising unique names', async () => {
|
||||
it('skips report when duplicate testcase names found', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
@@ -296,27 +234,8 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const logger = createSelfChildedLogger();
|
||||
await createStep({ logger }).executeAsync();
|
||||
await createStep().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 () => {
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
|
||||
export function createSubmissionEntityFunction(): BuildFunction {
|
||||
@@ -55,42 +54,18 @@ export function createSubmissionEntityFunction(): BuildFunction {
|
||||
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
|
||||
if (!robotAccessToken) {
|
||||
stepsCtx.logger.error('Failed to create submission entity: no robot access token found');
|
||||
Sentry.capture('Failed to create submission entity: missing robot access token');
|
||||
return;
|
||||
}
|
||||
|
||||
const buildId = inputs.build_id.value;
|
||||
if (!buildId) {
|
||||
stepsCtx.logger.error('Failed to create submission entity: no build ID provided');
|
||||
Sentry.capture('Failed to create submission entity: missing build ID', {
|
||||
extras: {
|
||||
buildId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowJobId = stepsCtx.global.env.__WORKFLOW_JOB_ID;
|
||||
if (!workflowJobId) {
|
||||
stepsCtx.logger.error('Failed to create submission entity: no workflow job ID found');
|
||||
Sentry.capture('Failed to create submission entity: missing workflow job ID', {
|
||||
extras: {
|
||||
buildId,
|
||||
workflowJobId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const expoApiServerURL = stepsCtx.global.staticContext.expoApiServerURL;
|
||||
if (!expoApiServerURL) {
|
||||
stepsCtx.logger.error('Failed to create submission entity: no Expo API server URL found');
|
||||
Sentry.capture('Failed to create submission entity: missing Expo API server URL', {
|
||||
extras: {
|
||||
buildId,
|
||||
workflowJobId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,7 +83,7 @@ export function createSubmissionEntityFunction(): BuildFunction {
|
||||
|
||||
try {
|
||||
const response = await retryOnDNSFailure(fetch)(
|
||||
new URL('/v2/app-store-submissions/', expoApiServerURL),
|
||||
new URL('/v2/app-store-submissions/', stepsCtx.global.staticContext.expoApiServerURL),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -56,23 +56,18 @@ export function createDownloadArtifactFunction(): BuildFunction {
|
||||
const interpolationContext = stepsCtx.global.getInterpolationContext();
|
||||
|
||||
if (!('workflow' in interpolationContext)) {
|
||||
throw new SystemError('No workflow found in the interpolation context.', {
|
||||
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
|
||||
});
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
|
||||
'No workflow found in the interpolation context.'
|
||||
);
|
||||
}
|
||||
|
||||
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
|
||||
if (!robotAccessToken) {
|
||||
throw new SystemError('No robot access token found in the job secrets.', {
|
||||
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
|
||||
});
|
||||
}
|
||||
|
||||
const expoApiServerURL = stepsCtx.global.staticContext.expoApiServerURL;
|
||||
if (!expoApiServerURL) {
|
||||
throw new SystemError('Missing Expo API server URL.', {
|
||||
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_EXPO_API_SERVER_URL',
|
||||
});
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
|
||||
'No robot access token found in the job secrets.'
|
||||
);
|
||||
}
|
||||
|
||||
const workflowRunId = interpolationContext.workflow.id;
|
||||
@@ -87,7 +82,7 @@ export function createDownloadArtifactFunction(): BuildFunction {
|
||||
const { artifactPath } = await downloadArtifactAsync({
|
||||
logger,
|
||||
workflowRunId,
|
||||
expoApiServerURL,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
robotAccessToken,
|
||||
params,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import { glob } from 'fast-glob';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch from 'node-fetch';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -18,7 +16,6 @@ import stream from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
@@ -26,21 +23,7 @@ import { pluralize } from '../../utils/strings';
|
||||
|
||||
const streamPipeline = promisify(stream.pipeline);
|
||||
|
||||
const BUILD_BY_ID_QUERY = graphql(`
|
||||
query DownloadBuildByIdQuery($buildId: ID!) {
|
||||
builds {
|
||||
byId(buildId: $buildId) {
|
||||
id
|
||||
platform
|
||||
artifacts {
|
||||
applicationArchiveUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
export function createDownloadBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'download_build',
|
||||
@@ -76,7 +59,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger,
|
||||
buildId,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions,
|
||||
});
|
||||
@@ -86,45 +69,16 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
graphqlClient,
|
||||
}: {
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
}): Promise<string> {
|
||||
const result = await graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
|
||||
|
||||
if (result.error) {
|
||||
const { error } = result;
|
||||
const message = `Could not fetch build ${buildId}: ${error.message}`;
|
||||
|
||||
throw error.networkError || result.error.response?.status >= 500
|
||||
? new SystemError(message, { cause: error })
|
||||
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
|
||||
}
|
||||
|
||||
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
|
||||
if (!applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
|
||||
'Build does not have an application archive url'
|
||||
);
|
||||
}
|
||||
|
||||
return applicationArchiveUrl;
|
||||
}
|
||||
|
||||
export async function downloadBuildAsync({
|
||||
logger,
|
||||
buildId,
|
||||
graphqlClient,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
extensions,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
expoApiServerURL: string;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
}): Promise<{ artifactPath: string }> {
|
||||
@@ -132,11 +86,12 @@ export async function downloadBuildAsync({
|
||||
path.join(os.tmpdir(), 'download_build-downloaded-')
|
||||
);
|
||||
|
||||
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
|
||||
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
|
||||
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
|
||||
});
|
||||
const response = await retryOnDNSFailure(fetch)(
|
||||
new URL(`/v2/artifacts/eas/${buildId}`, expoApiServerURL),
|
||||
{
|
||||
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
|
||||
@@ -26,14 +26,14 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
|
||||
try {
|
||||
await uploadApplicationArchivesAsync({ ctx, stepCtx });
|
||||
} catch (err: unknown) {
|
||||
stepCtx.logger.error({ err }, `Failed to upload application archives.`);
|
||||
stepCtx.logger.error(`Failed to upload application archives.`, err);
|
||||
firstError ||= err;
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadBuildArtifacts({ ctx, stepCtx });
|
||||
} catch (err: unknown) {
|
||||
stepCtx.logger.error({ err }, `Failed to upload build artifacts.`);
|
||||
stepCtx.logger.error(`Failed to upload build artifacts.`, err);
|
||||
firstError ||= err;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { Datadog } from '../../datadog';
|
||||
|
||||
export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
@@ -125,24 +123,13 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
|
||||
logger.info(`Maestro ${maestroVersionResult.value} is ready.`);
|
||||
outputs.maestro_version.set(maestroVersionResult.value);
|
||||
|
||||
Datadog.distribution('eas.maestro.install', 1, {
|
||||
maestro_version: maestroVersionResult.value,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getMaestroVersion({ env }: { env: BuildStepEnv }): Promise<string> {
|
||||
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
|
||||
// `maestro --version` can print an analytics notice to stdout before the version,
|
||||
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
|
||||
// Take the last version-looking token: the real version is printed after the notice, so
|
||||
// this stays correct even if the notice itself contains a version-like string. Keeps the
|
||||
// step output and the eas.maestro.install metric tag clean. Best-effort only: fall back to
|
||||
// the raw output if none is found, so we never fail the build over a version string.
|
||||
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
|
||||
return versions?.at(-1) ?? stdout.trim();
|
||||
const maestroVersion = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
|
||||
return maestroVersion.stdout.trim();
|
||||
}
|
||||
|
||||
async function installMaestro({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BuildFunction, BuildStepContext, BuildStepEnv } from '@expo/steps';
|
||||
import path from 'path';
|
||||
|
||||
import { installDependenciesWithNpmCacheFallbackAsync } from '../../common/installDependencies';
|
||||
import { installDependenciesAsync } from '../../common/installDependencies';
|
||||
import {
|
||||
findPackagerRootDir,
|
||||
getPackageVersionFromPackageJson,
|
||||
@@ -63,7 +63,7 @@ export async function installNodeModules(
|
||||
packageName: 'react-native',
|
||||
});
|
||||
|
||||
await installDependenciesWithNpmCacheFallbackAsync({
|
||||
const { spawnPromise } = await installDependenciesAsync({
|
||||
packageManager,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
@@ -74,4 +74,5 @@ export async function installNodeModules(
|
||||
reactNativeVersion,
|
||||
}),
|
||||
});
|
||||
await spawnPromise;
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import yaml from 'yaml';
|
||||
|
||||
const YAML_EXT = /\.ya?ml$/i;
|
||||
const WORKSPACE_CONFIG_BASENAME = 'config.yaml';
|
||||
|
||||
export interface BuildFlowNameToPathMapArgs {
|
||||
inputFlowPaths: string[];
|
||||
projectRoot: string;
|
||||
logger: bunyan;
|
||||
}
|
||||
|
||||
interface FlowEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
realpath: string;
|
||||
}
|
||||
|
||||
export async function buildFlowNameToPathMap(
|
||||
args: BuildFlowNameToPathMapArgs
|
||||
): Promise<Map<string, string> | null> {
|
||||
try {
|
||||
const flows = await discoverFlows(args);
|
||||
return dedupAndDetectDuplicates(flows, args.logger);
|
||||
} catch (err: any) {
|
||||
args.logger.warn(`buildFlowNameToPathMap failed unexpectedly: ${err?.message ?? String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverFlows({
|
||||
inputFlowPaths,
|
||||
projectRoot,
|
||||
logger,
|
||||
}: BuildFlowNameToPathMapArgs): Promise<FlowEntry[]> {
|
||||
const realRootResult = await asyncResult(fs.realpath(projectRoot));
|
||||
const realRoot = realRootResult.ok ? realRootResult.value : projectRoot;
|
||||
const fileLists = await Promise.all(
|
||||
inputFlowPaths.map(async input => {
|
||||
const abs = path.resolve(projectRoot, input);
|
||||
const statResult = await asyncResult(fs.stat(abs));
|
||||
if (!statResult.ok) {
|
||||
logger.warn(`flow_path entry "${input}" not found, skipping`);
|
||||
return [];
|
||||
}
|
||||
const stat = statResult.value;
|
||||
if (stat.isFile() && YAML_EXT.test(abs)) {
|
||||
return [abs];
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
const out: string[] = [];
|
||||
await walkDir(abs, new Set(), out, logger);
|
||||
return out;
|
||||
}
|
||||
return [];
|
||||
})
|
||||
);
|
||||
return Promise.all(fileLists.flat().map(absFile => makeEntry(absFile, realRoot)));
|
||||
}
|
||||
|
||||
async function makeEntry(absFile: string, realRoot: string): Promise<FlowEntry> {
|
||||
const [name, realFileResult] = await Promise.all([
|
||||
readFlowName(absFile),
|
||||
asyncResult(fs.realpath(absFile)),
|
||||
]);
|
||||
const realFile = realFileResult.ok ? realFileResult.value : absFile;
|
||||
const rel = path.relative(realRoot, realFile);
|
||||
const value = rel.startsWith('..') || path.isAbsolute(rel) ? absFile : rel;
|
||||
return { name, path: value, realpath: realFile };
|
||||
}
|
||||
|
||||
function dedupAndDetectDuplicates(flows: FlowEntry[], logger: bunyan): Map<string, string> | null {
|
||||
const byKey = new Map<string, FlowEntry>();
|
||||
for (const f of flows) {
|
||||
if (!byKey.has(f.realpath)) {
|
||||
byKey.set(f.realpath, f);
|
||||
}
|
||||
}
|
||||
const unique = [...byKey.values()];
|
||||
|
||||
const nameToPath = new Map<string, string>();
|
||||
const duplicates = new Map<string, string[]>();
|
||||
for (const f of unique) {
|
||||
if (nameToPath.has(f.name)) {
|
||||
const existing = nameToPath.get(f.name)!;
|
||||
const list = duplicates.get(f.name) ?? [existing];
|
||||
list.push(f.path);
|
||||
duplicates.set(f.name, list);
|
||||
}
|
||||
nameToPath.set(f.name, f.path);
|
||||
}
|
||||
if (duplicates.size > 0) {
|
||||
for (const [name, paths] of duplicates) {
|
||||
logger.warn(`Duplicate Maestro flow name "${name}" across paths: ${paths.join(', ')}.`);
|
||||
}
|
||||
logger.warn(
|
||||
'Retry-failed-only is disabled for this run; will retry all flows on failure. ' +
|
||||
'Give each Maestro flow a unique name (file basename or top-level name) to enable retry-failed-only.'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return nameToPath;
|
||||
}
|
||||
|
||||
export async function walkDir(
|
||||
dir: string,
|
||||
visited: Set<string>,
|
||||
out: string[],
|
||||
logger: bunyan
|
||||
): Promise<void> {
|
||||
// Cycle protection: dedup on realpath (fall back to the original path when
|
||||
// realpath fails so we still make progress on missing or restricted dirs).
|
||||
const realResult = await asyncResult(fs.realpath(dir));
|
||||
const real = realResult.ok ? realResult.value : dir;
|
||||
if (visited.has(real)) {
|
||||
return;
|
||||
}
|
||||
visited.add(real);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch (err: any) {
|
||||
logger.warn(`readdir failed for ${dir}: ${err?.message ?? String(err)}`);
|
||||
return;
|
||||
}
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
const tgtResult = await asyncResult(fs.stat(full));
|
||||
if (!tgtResult.ok) {
|
||||
continue;
|
||||
}
|
||||
const tgt = tgtResult.value;
|
||||
if (tgt.isDirectory()) {
|
||||
await walkDir(full, visited, out, logger);
|
||||
} else if (tgt.isFile() && isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
} else if (entry.isDirectory()) {
|
||||
await walkDir(full, visited, out, logger);
|
||||
} else if (entry.isFile()) {
|
||||
if (isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFlowName(absFile: string): Promise<string> {
|
||||
const ext = path.extname(absFile);
|
||||
const fallback = path.basename(absFile, ext);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absFile, 'utf-8');
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let firstDoc;
|
||||
try {
|
||||
const docs = yaml.parseAllDocuments(content);
|
||||
firstDoc = docs[0];
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
if (!firstDoc || firstDoc.errors.length > 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = firstDoc.toJS();
|
||||
const name = parsed?.name;
|
||||
if (typeof name === 'string' && name.length > 0) {
|
||||
return name;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function isYamlFile(name: string): boolean {
|
||||
return YAML_EXT.test(name);
|
||||
}
|
||||
|
||||
function isWorkspaceConfig(name: string): boolean {
|
||||
return name.toLowerCase() === WORKSPACE_CONFIG_BASENAME;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface MaestroFlowResult {
|
||||
name: string;
|
||||
@@ -14,12 +14,19 @@ export interface MaestroFlowResult {
|
||||
properties: Record<string, string>;
|
||||
}
|
||||
|
||||
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
|
||||
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
|
||||
|
||||
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
|
||||
const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
||||
|
||||
export function extractFlowKey(filename: string, prefix: string): string | null {
|
||||
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
export interface JUnitTestCaseResult {
|
||||
name: string;
|
||||
file: string | undefined;
|
||||
status: 'passed' | 'failed';
|
||||
duration: number; // milliseconds
|
||||
errorMessage: string | null;
|
||||
@@ -34,15 +41,11 @@ const xmlParser = new XMLParser({
|
||||
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
||||
});
|
||||
|
||||
// A `file=` attribute counts as present only when it is a non-empty string.
|
||||
function fileAttrOf(tc: any): string | undefined {
|
||||
const f = tc?.['@_file'];
|
||||
return typeof f === 'string' && f.length > 0 ? f : undefined;
|
||||
}
|
||||
|
||||
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
// Internal helper — not exported. Parses a single JUnit XML file.
|
||||
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
|
||||
const results: JUnitTestCaseResult[] = [];
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const parsed = xmlParser.parse(content);
|
||||
|
||||
const testsuites = parsed?.testsuites?.testsuite;
|
||||
@@ -62,8 +65,6 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = fileAttrOf(tc);
|
||||
|
||||
const timeStr = tc['@_time'];
|
||||
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
|
||||
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
|
||||
@@ -104,24 +105,15 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
: [];
|
||||
delete properties['tags'];
|
||||
|
||||
results.push({ name, file, status, duration, errorMessage, tags, properties });
|
||||
results.push({ name, status, duration, errorMessage, tags, properties });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed XML — return whatever we collected before the parser bailed.
|
||||
// Skip malformed XML files
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
return parseJUnitContent(content);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
@@ -135,152 +127,49 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
|
||||
return [];
|
||||
}
|
||||
|
||||
const perFile = await Promise.all(
|
||||
xmlFiles.map(f => parseJUnitFile(path.join(junitDirectory, f)))
|
||||
);
|
||||
return perFile.flat();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Maestro >= 2.6.0 code path: every <testcase> carries a `file=` attribute with
|
||||
// its flow's own path (all-or-none per run). Callers check the report first
|
||||
// (isFileAttrRun / junitFileHasFileAttrs) and route here; the legacy name→path
|
||||
// scan is then never consulted.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isFileAttrRun(
|
||||
testcases: JUnitTestCaseResult[]
|
||||
): testcases is (JUnitTestCaseResult & { file: string })[] {
|
||||
return testcases.length > 0 && testcases.every(tc => tc.file !== undefined);
|
||||
}
|
||||
|
||||
// Unreadable or attribute-less reports ⇒ false: the caller then takes the
|
||||
// legacy path, whose own guards degrade safely.
|
||||
export async function junitFileHasFileAttrs(junitFile: string): Promise<boolean> {
|
||||
return isFileAttrRun(await parseJUnitFile(junitFile));
|
||||
}
|
||||
|
||||
async function fileExists(absPath: string): Promise<boolean> {
|
||||
return (await asyncResult(fs.stat(absPath))).ok;
|
||||
}
|
||||
|
||||
// Group by `file=` so two same-named flows in different files stay separate.
|
||||
export async function parseMaestroResultsFromFileAttrs(
|
||||
junitDirectory: string
|
||||
): Promise<MaestroFlowResult[]> {
|
||||
interface JUnitEntry {
|
||||
result: JUnitTestCaseResult;
|
||||
sourceFile: string;
|
||||
}
|
||||
|
||||
let junitEntries: string[];
|
||||
try {
|
||||
junitEntries = await fs.readdir(junitDirectory);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
|
||||
|
||||
const entries: JUnitEntry[] = [];
|
||||
const results: JUnitTestCaseResult[] = [];
|
||||
for (const xmlFile of xmlFiles) {
|
||||
const fileResults = await parseJUnitFile(path.join(junitDirectory, xmlFile));
|
||||
for (const result of fileResults) {
|
||||
entries.push({ result, sourceFile: xmlFile });
|
||||
}
|
||||
}
|
||||
|
||||
const byPath = new Map<string, JUnitEntry[]>();
|
||||
for (const entry of entries) {
|
||||
// Callers verify the report with isFileAttrRun/junitFileHasFileAttrs first;
|
||||
// skip (rather than emit an undefined path) if that contract is violated.
|
||||
const flowPath = entry.result.file;
|
||||
if (flowPath === undefined) {
|
||||
continue;
|
||||
}
|
||||
const group = byPath.get(flowPath) ?? [];
|
||||
group.push(entry);
|
||||
byPath.set(flowPath, group);
|
||||
}
|
||||
|
||||
const results: MaestroFlowResult[] = [];
|
||||
for (const [flowPath, group] of byPath) {
|
||||
// retryCount is derived from each entry's source filename (`attempt-N`);
|
||||
// a single entry from `report.xml` (no marker) collapses to attempt 0.
|
||||
const sorted = group
|
||||
.map(entry => {
|
||||
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
return { ...entry, attemptIndex };
|
||||
})
|
||||
.sort((a, b) => a.attemptIndex - b.attemptIndex);
|
||||
|
||||
for (const { result, attemptIndex } of sorted) {
|
||||
results.push({
|
||||
name: result.name,
|
||||
path: flowPath,
|
||||
status: result.status,
|
||||
errorMessage: result.errorMessage,
|
||||
duration: result.duration,
|
||||
retryCount: attemptIndex,
|
||||
tags: result.tags,
|
||||
properties: result.properties,
|
||||
});
|
||||
}
|
||||
results.push(...(await parseJUnitFile(path.join(junitDirectory, xmlFile))));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const FlowMetadataFileSchema = z.object({
|
||||
flow_name: z.string(),
|
||||
flow_file_path: z.string(),
|
||||
});
|
||||
|
||||
type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
|
||||
|
||||
/**
|
||||
* Returns the `file=` paths of the failing testcases in the given attempt's
|
||||
* JUnit file, or `null` when the result cannot be trusted (caller then falls
|
||||
* back to dumb retry — re-run everything; never the legacy scan).
|
||||
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
|
||||
*
|
||||
* Callers verify the report carries `file=` attributes first
|
||||
* (junitFileHasFileAttrs).
|
||||
* The file contains:
|
||||
* - `flow_name`: derived from the YAML `config.name` field if present, otherwise
|
||||
* the flow filename without extension.
|
||||
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
|
||||
* - `flow_file_path`: absolute path to the original flow YAML file.
|
||||
* - `outputs`: screenshot defect data (unused here).
|
||||
*
|
||||
* Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
|
||||
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
|
||||
*/
|
||||
export async function parseFailedFlowsFromFileAttrs(args: {
|
||||
junitFile: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<string[] | null> {
|
||||
// Same hardening as the legacy parser: fast-xml-parser is lenient — a
|
||||
// truncated XML could drop testcases, and trusting that partial failing set
|
||||
// would skip retries for cut-off flows.
|
||||
let content: string;
|
||||
export async function parseFlowMetadata(filePath: string): Promise<FlowMetadata | null> {
|
||||
try {
|
||||
content = await fs.readFile(args.junitFile, 'utf-8');
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
return FlowMetadataFileSchema.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (XMLValidator.validate(content) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const testcases = parseJUnitContent(content);
|
||||
if (!isFileAttrRun(testcases)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failing = testcases.filter(tc => tc.status === 'failed');
|
||||
if (failing.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Each failing testcase is self-identifying via `file=`, so duplicate flow
|
||||
// names retry correctly. path.resolve (not join): `file=` may be absolute
|
||||
// when the flow lives outside the working directory.
|
||||
const paths = [...new Set(failing.map(tc => tc.file))];
|
||||
for (const p of paths) {
|
||||
if (!(await fileExists(path.resolve(args.workingDirectory, p)))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export async function parseMaestroResults(
|
||||
junitDirectory: string,
|
||||
nameToPath: Map<string, string> | null
|
||||
testsDirectory: string,
|
||||
projectRoot: string
|
||||
): Promise<MaestroFlowResult[]> {
|
||||
// 1. Parse JUnit XML files, tracking which file each result came from
|
||||
let junitEntries: string[];
|
||||
try {
|
||||
junitEntries = await fs.readdir(junitDirectory);
|
||||
@@ -309,8 +198,51 @@ export async function parseMaestroResults(
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. Parse ai-*.json from debug output for flow_file_path + retryCount
|
||||
const flowPathMap = new Map<string, string>(); // flowName → flowFilePath
|
||||
const flowOccurrences = new Map<string, number>(); // flowName → count
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(testsDirectory);
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
|
||||
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
|
||||
|
||||
for (const dir of timestampDirs) {
|
||||
const dirPath = path.join(testsDirectory, dir);
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fs.readdir(dirPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const flowKey = extractFlowKey(file, 'ai');
|
||||
if (!flowKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = await parseFlowMetadata(path.join(dirPath, file));
|
||||
if (!metadata) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track latest path (last timestamp dir wins)
|
||||
flowPathMap.set(metadata.flow_name, metadata.flow_file_path);
|
||||
|
||||
// Count occurrences for retryCount
|
||||
flowOccurrences.set(metadata.flow_name, (flowOccurrences.get(metadata.flow_name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Merge: JUnit results + ai-*.json metadata
|
||||
const results: MaestroFlowResult[] = [];
|
||||
|
||||
// Group results by flow name
|
||||
const resultsByName = new Map<string, JUnitResultWithSource[]>();
|
||||
for (const entry of junitResultsWithSource) {
|
||||
const group = resultsByName.get(entry.result.name) ?? [];
|
||||
@@ -319,237 +251,83 @@ export async function parseMaestroResults(
|
||||
}
|
||||
|
||||
for (const [flowName, flowEntries] of resultsByName) {
|
||||
const flowPath = nameToPath?.get(flowName) ?? flowName;
|
||||
// retryCount is derived from each entry's source filename (`attempt-N`);
|
||||
// a single entry from `report.xml` (no marker) collapses to attempt 0.
|
||||
const sorted = flowEntries
|
||||
.map(entry => {
|
||||
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
return { ...entry, attemptIndex };
|
||||
})
|
||||
.sort((a, b) => a.attemptIndex - b.attemptIndex);
|
||||
const flowFilePath = flowPathMap.get(flowName);
|
||||
const relativePath = flowFilePath
|
||||
? await relativizePathAsync(flowFilePath, projectRoot)
|
||||
: flowName;
|
||||
|
||||
if (flowEntries.length === 1) {
|
||||
// Single result for this flow — use ai-*.json occurrence count for retryCount
|
||||
// (backward compat with old-style single JUnit file that gets overwritten)
|
||||
const { result } = flowEntries[0];
|
||||
const occurrences = flowOccurrences.get(flowName) ?? 0;
|
||||
const retryCount = Math.max(0, occurrences - 1);
|
||||
|
||||
for (const { result, attemptIndex } of sorted) {
|
||||
results.push({
|
||||
name: flowName,
|
||||
path: flowPath,
|
||||
path: relativePath,
|
||||
status: result.status,
|
||||
errorMessage: result.errorMessage,
|
||||
duration: result.duration,
|
||||
retryCount: attemptIndex,
|
||||
retryCount,
|
||||
tags: result.tags,
|
||||
properties: result.properties,
|
||||
});
|
||||
} else {
|
||||
// Multiple results — per-attempt JUnit files. Sort by attempt index from filename.
|
||||
const sorted = flowEntries
|
||||
.map(entry => {
|
||||
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
return { ...entry, attemptIndex };
|
||||
})
|
||||
.sort((a, b) => a.attemptIndex - b.attemptIndex);
|
||||
|
||||
for (const { result, attemptIndex } of sorted) {
|
||||
results.push({
|
||||
name: flowName,
|
||||
path: relativePath,
|
||||
status: result.status,
|
||||
errorMessage: result.errorMessage,
|
||||
duration: result.duration,
|
||||
retryCount: attemptIndex,
|
||||
tags: result.tags,
|
||||
properties: result.properties,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subset of `inputFlowPaths` whose testcases failed in the given
|
||||
* attempt's JUnit file, or `null` when the result cannot be trusted (caller
|
||||
* then falls back to dumb retry — re-run everything).
|
||||
*
|
||||
* Caller passes a precomputed `nameToPath` map built from the user's
|
||||
* `inputFlowPaths`. We translate failed testcase names to flow paths via
|
||||
* that map; unknown names → null.
|
||||
*/
|
||||
export async function parseFailedFlowsFromJUnit(args: {
|
||||
junitFile: string;
|
||||
nameToPath: Map<string, string>;
|
||||
}): Promise<string[] | null> {
|
||||
// fast-xml-parser is lenient — truncated XML can produce a partial parse
|
||||
// with some testcases dropped. Trusting that subset for retry-failed-only would
|
||||
// skip retries for cut-off flows; reject any malformed XML and fall back
|
||||
// to dumb retry.
|
||||
let content: string;
|
||||
async function relativizePathAsync(flowFilePath: string, projectRoot: string): Promise<string> {
|
||||
if (!path.isAbsolute(flowFilePath)) {
|
||||
return flowFilePath;
|
||||
}
|
||||
|
||||
// Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
|
||||
let resolvedRoot = projectRoot;
|
||||
let resolvedFlow = flowFilePath;
|
||||
try {
|
||||
content = await fs.readFile(args.junitFile, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (XMLValidator.validate(content) !== true) {
|
||||
return null;
|
||||
}
|
||||
resolvedRoot = await fs.realpath(projectRoot);
|
||||
} catch {}
|
||||
try {
|
||||
resolvedFlow = await fs.realpath(flowFilePath);
|
||||
} catch {}
|
||||
|
||||
const testcases = parseJUnitContent(content);
|
||||
if (testcases.length === 0) {
|
||||
return null;
|
||||
const relative = path.relative(resolvedRoot, resolvedFlow);
|
||||
if (relative.startsWith('..')) {
|
||||
return flowFilePath;
|
||||
}
|
||||
|
||||
const failing = testcases.filter(tc => tc.status === 'failed');
|
||||
if (failing.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Two testcases with the same name (pass+fail or fail+fail) make it
|
||||
// impossible to map back to a single input flow_path. Signal "unknown".
|
||||
// Complementary to the map-build duplicate check (which catches the case
|
||||
// where tag filter hides duplication from JUnit).
|
||||
const allNameCounts = new Map<string, number>();
|
||||
for (const tc of testcases) {
|
||||
allNameCounts.set(tc.name, (allNameCounts.get(tc.name) ?? 0) + 1);
|
||||
}
|
||||
for (const count of allNameCounts.values()) {
|
||||
if (count > 1) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const matched: string[] = [];
|
||||
for (const tc of failing) {
|
||||
const p = args.nameToPath.get(tc.name);
|
||||
if (p === undefined) {
|
||||
return null;
|
||||
}
|
||||
matched.push(p);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
|
||||
* attempt's <testcase> per unique flow name (latest determined from the
|
||||
* filename's `attempt-(\d+)` marker; files without the marker = attempt 0),
|
||||
* and writes a merged document to outputPath.
|
||||
*
|
||||
* Throws on empty/malformed/no-testcase input so the caller can fall back to
|
||||
* copyLatestAttemptXml — silently dropping bad attempts could keep stale
|
||||
* failure rows around and produce a misleading merged report.
|
||||
*/
|
||||
export async function mergeJUnitReports(args: {
|
||||
sourceDir: string;
|
||||
outputPath: string;
|
||||
}): Promise<void> {
|
||||
const entries = await fs.readdir(args.sourceDir);
|
||||
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
|
||||
|
||||
if (xmlFiles.length === 0) {
|
||||
throw new Error(`mergeJUnitReports: no *.xml files found in ${args.sourceDir}`);
|
||||
}
|
||||
|
||||
interface FileGroup {
|
||||
attemptIndex: number;
|
||||
filename: string;
|
||||
content: string;
|
||||
testcasesByName: Map<string, unknown[]>;
|
||||
}
|
||||
const contents = await Promise.all(
|
||||
xmlFiles.map(async f => ({
|
||||
filename: f,
|
||||
content: await fs.readFile(path.join(args.sourceDir, f), 'utf-8'),
|
||||
}))
|
||||
);
|
||||
const fileGroups: FileGroup[] = [];
|
||||
for (const { filename, content } of contents) {
|
||||
if (XMLValidator.validate(content) !== true) {
|
||||
throw new Error(`mergeJUnitReports: invalid XML in ${filename}`);
|
||||
}
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = xmlParser.parse(content);
|
||||
} catch (err) {
|
||||
throw new Error(`mergeJUnitReports: failed to parse ${filename}`, { cause: err });
|
||||
}
|
||||
const testsuites = parsed?.testsuites?.testsuite;
|
||||
if (!Array.isArray(testsuites)) {
|
||||
throw new Error(`mergeJUnitReports: no <testsuite> array in ${filename}`);
|
||||
}
|
||||
const match = filename.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
// Maestro >= 2.6.0 reports: key by `file=` so two same-named flows in
|
||||
// different files merge separately. Legacy reports keep name keying.
|
||||
const allCases = testsuites.flatMap((suite: any) =>
|
||||
Array.isArray(suite?.testcase) ? suite.testcase : []
|
||||
);
|
||||
const useFileKeys =
|
||||
allCases.length > 0 && allCases.every((tc: any) => fileAttrOf(tc) !== undefined);
|
||||
const testcasesByName = new Map<string, unknown[]>();
|
||||
for (const suite of testsuites) {
|
||||
const cases = suite?.testcase;
|
||||
if (!Array.isArray(cases)) {
|
||||
continue;
|
||||
}
|
||||
for (const tc of cases) {
|
||||
const name = tc?.['@_name'];
|
||||
if (typeof name !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const key = useFileKeys ? fileAttrOf(tc)! : name;
|
||||
const group = testcasesByName.get(key) ?? [];
|
||||
group.push(tc);
|
||||
testcasesByName.set(key, group);
|
||||
}
|
||||
}
|
||||
if (testcasesByName.size === 0) {
|
||||
throw new Error(`mergeJUnitReports: no parseable testcases in ${filename}`);
|
||||
}
|
||||
fileGroups.push({ attemptIndex, filename, content, testcasesByName });
|
||||
}
|
||||
|
||||
// Single attempt: copy the original XML so suite-level metadata (testsuite
|
||||
// attributes, <system-out>, etc.) survives. The rebuild path below would
|
||||
// collapse those to a single attribute-less <testsuite>.
|
||||
if (fileGroups.length === 1) {
|
||||
await fs.writeFile(args.outputPath, fileGroups[0].content);
|
||||
return;
|
||||
}
|
||||
|
||||
// For each unique name, pick the file with the highest attempt index that
|
||||
// contains it (ties broken by sorted filename — later wins). Preserve every
|
||||
// <testcase> element from the winning file for that name, so same-attempt
|
||||
// duplicates survive.
|
||||
const nameToWinningFile = new Map<string, FileGroup>();
|
||||
for (const group of fileGroups) {
|
||||
for (const name of group.testcasesByName.keys()) {
|
||||
const current = nameToWinningFile.get(name);
|
||||
if (!current || group.attemptIndex >= current.attemptIndex) {
|
||||
nameToWinningFile.set(name, group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit in first-seen order (iteration over `fileGroups` yields stable order
|
||||
// matching the sorted filename list).
|
||||
const testcases: unknown[] = [];
|
||||
const emitted = new Set<string>();
|
||||
for (const group of fileGroups) {
|
||||
for (const [name, cases] of group.testcasesByName) {
|
||||
if (emitted.has(name)) {
|
||||
continue;
|
||||
}
|
||||
const winner = nameToWinningFile.get(name);
|
||||
if (winner === group) {
|
||||
for (const tc of cases) {
|
||||
testcases.push(tc);
|
||||
}
|
||||
emitted.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const builder = new XMLBuilder({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
format: true,
|
||||
suppressEmptyNode: true,
|
||||
});
|
||||
const xml = builder.build({
|
||||
testsuites: {
|
||||
testsuite: {
|
||||
testcase: testcases,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fs.writeFile(args.outputPath, xml);
|
||||
return relative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
|
||||
* Used as a fallback when mergeJUnitReports fails due to data issues but the
|
||||
* step still needs to produce final_report_path.
|
||||
* After the maestro retry loop completes, this produces a single canonical
|
||||
* JUnit report at final_report_path matching the bash step's "cp latest
|
||||
* attempt" semantics.
|
||||
*
|
||||
* Throws if sourceDir contains no *.xml files or if the copy fails.
|
||||
*/
|
||||
@@ -564,8 +342,7 @@ export async function copyLatestAttemptXml(args: {
|
||||
}
|
||||
|
||||
// Pick the file with the highest attempt index. Files without the marker are
|
||||
// treated as attempt 0. Ties are broken by sorted filename — later wins
|
||||
// (same rule as mergeJUnitReports).
|
||||
// treated as attempt 0. Ties are broken by sorted filename — later wins.
|
||||
let winner = xmlFiles[0];
|
||||
let winnerAttempt = (() => {
|
||||
const m = winner.match(ATTEMPT_PATTERN);
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { type Dirent } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
export interface ParsedFailureScreenshot {
|
||||
flowName: string;
|
||||
capturedAtMs: number;
|
||||
}
|
||||
|
||||
// Maestro failure screenshot: `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png`.
|
||||
// The flow name may contain parentheses, so anchor on the `-(` after the epoch and the
|
||||
// `).png` suffix rather than scanning for parens.
|
||||
const FAILURE_SCREENSHOT_PATTERN = /^screenshot-(?:shard-\d+-)?❌-(\d+)-\((.+)\)\.png$/u;
|
||||
|
||||
export function parseFailureScreenshotFilename(filename: string): ParsedFailureScreenshot | null {
|
||||
const match = filename.match(FAILURE_SCREENSHOT_PATTERN);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const capturedAtMs = Number(match[1]);
|
||||
if (!Number.isFinite(capturedAtMs)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
flowName: match[2],
|
||||
capturedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
export interface HarvestedScreenshot {
|
||||
fileAbsPath: string;
|
||||
displayName: string; // `Failure Screenshot: <flowName> (attempt <n>)`
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot';
|
||||
// The Maestro flow name straight from the filename (already '/'->'_' substituted by
|
||||
// Maestro). The website matches this against `attempt.name` (normalized the same way).
|
||||
flowName: string;
|
||||
attemptIndex: number;
|
||||
capturedAtMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Scans every debug dir under testsDirectory whose mtime is recent enough (mtime-based to cover
|
||||
// the maestro <2.5.0 bug that splits one invocation across two dirs), then keeps only screenshots
|
||||
// whose own capturedAtMs is at/after capturedSinceMs. Never throws: an unreadable dir is logged
|
||||
// and skipped so a screenshot harvest can't affect the maestro step outcome.
|
||||
export async function harvestFailureScreenshotsAsync(args: {
|
||||
testsDirectory: string;
|
||||
capturedSinceMs: number;
|
||||
attemptIndex: number;
|
||||
logger: bunyan;
|
||||
}): Promise<HarvestedScreenshot[]> {
|
||||
// Gate dirs by mtime floored to the second, so a dir created within the same second as the
|
||||
// attempt start isn't stat'd below the baseline. The exact capturedAtMs gate below is what
|
||||
// prevents mis-attributing an earlier attempt's screenshot.
|
||||
const dirSinceMtimeMs = Math.floor(args.capturedSinceMs / 1000) * 1000;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(args.testsDirectory, { withFileTypes: true });
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping screenshot harvest: cannot read ${args.testsDirectory}.`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: HarvestedScreenshot[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const dirPath = path.join(args.testsDirectory, entry.name);
|
||||
let filenames: string[];
|
||||
try {
|
||||
if ((await fs.stat(dirPath)).mtimeMs < dirSinceMtimeMs) {
|
||||
continue;
|
||||
}
|
||||
filenames = await fs.readdir(dirPath);
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping unreadable debug dir ${dirPath}.`);
|
||||
continue;
|
||||
}
|
||||
for (const filename of filenames) {
|
||||
const parsed = parseFailureScreenshotFilename(filename);
|
||||
// Re-check capture time per file: a dir's mtime can advance after this attempt began (or
|
||||
// fall within the floored-second dir baseline), so the dir gate alone would re-attribute an
|
||||
// earlier attempt's screenshot to this one.
|
||||
if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
fileAbsPath: path.join(dirPath, filename),
|
||||
displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: parsed.flowName,
|
||||
attemptIndex: args.attemptIndex,
|
||||
capturedAtMs: parsed.capturedAtMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// Maestro substitutes '/' -> '_' in screenshot filenames (so HarvestedScreenshot.flowName
|
||||
// is already in that form) but keeps '/' in the JUnit testcase name. Normalize the JUnit
|
||||
// name the same way before comparing — matching the website's own normalization.
|
||||
function normalizeFlowName(name: string): string {
|
||||
return name.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
// A flow is a "pure failure" when it failed and never passed across all attempts. The website
|
||||
// surfaces only the final screenshot for such a flow, so keeping its earlier attempts would
|
||||
// spend the per-job artifact budget on screenshots that are never shown — we keep only the final
|
||||
// one (selectFailureScreenshots). A flow that passed at least once (flaky / fail-pass-fail) is
|
||||
// NOT pure: each of its failed attempts is a distinct failure and is kept.
|
||||
export function computePureFailureFlowNames(
|
||||
testCases: readonly { name: string; status: 'passed' | 'failed' }[]
|
||||
): Set<string> {
|
||||
const passed = new Set<string>();
|
||||
const failed = new Set<string>();
|
||||
for (const testCase of testCases) {
|
||||
const flowName = normalizeFlowName(testCase.name);
|
||||
if (testCase.status === 'passed') {
|
||||
passed.add(flowName);
|
||||
} else {
|
||||
failed.add(flowName);
|
||||
}
|
||||
}
|
||||
const pure = new Set<string>();
|
||||
for (const flowName of failed) {
|
||||
if (!passed.has(flowName)) {
|
||||
pure.add(flowName);
|
||||
}
|
||||
}
|
||||
return pure;
|
||||
}
|
||||
|
||||
// Keep every harvested screenshot for flaky/mixed flows; for pure-failure flows keep only
|
||||
// the final (highest attemptIndex) screenshot.
|
||||
export function selectFailureScreenshots(
|
||||
harvested: readonly HarvestedScreenshot[],
|
||||
pureFailureFlowNames: ReadonlySet<string>
|
||||
): HarvestedScreenshot[] {
|
||||
const finalAttemptByPureFlow = new Map<string, number>();
|
||||
for (const shot of harvested) {
|
||||
if (!pureFailureFlowNames.has(shot.metadata.flowName)) {
|
||||
continue;
|
||||
}
|
||||
const finalAttempt = finalAttemptByPureFlow.get(shot.metadata.flowName);
|
||||
if (finalAttempt === undefined || shot.metadata.attemptIndex > finalAttempt) {
|
||||
finalAttemptByPureFlow.set(shot.metadata.flowName, shot.metadata.attemptIndex);
|
||||
}
|
||||
}
|
||||
return harvested.filter(shot => {
|
||||
const finalAttempt = finalAttemptByPureFlow.get(shot.metadata.flowName);
|
||||
return finalAttempt === undefined || shot.metadata.attemptIndex === finalAttempt;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -9,26 +8,10 @@ import {
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
junitFileHasFileAttrs,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseJUnitTestCases,
|
||||
} from './maestroResultParser';
|
||||
import {
|
||||
type HarvestedScreenshot,
|
||||
computePureFailureFlowNames,
|
||||
harvestFailureScreenshotsAsync,
|
||||
selectFailureScreenshots,
|
||||
} from './maestroScreenshots';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { copyLatestAttemptXml } from './maestroResultParser';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
const FlowPathSchema = z.array(z.string().min(1)).min(1);
|
||||
@@ -47,19 +30,6 @@ function parseInput<S extends z.ZodTypeAny>(
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// ENOENT is excluded — "input XML missing" is a data issue, not a storage
|
||||
// fault, so the post-loop merge should fall through to copy-latest instead
|
||||
// of throwing.
|
||||
function isFilesystemError(err: any): boolean {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const code = err.code;
|
||||
return (
|
||||
code === 'ENOSPC' || code === 'EACCES' || code === 'EROFS' || code === 'EIO' || code === 'EPERM'
|
||||
);
|
||||
}
|
||||
|
||||
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
|
||||
// other declared formats pass an explicit path so downstream upload steps
|
||||
// know where to find the result.
|
||||
@@ -91,7 +61,7 @@ function buildMaestroArgs(args: {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'maestro_tests',
|
||||
@@ -109,12 +79,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
defaultValue: 0,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'retry_failed_only',
|
||||
required: false,
|
||||
defaultValue: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'shards',
|
||||
required: false,
|
||||
@@ -204,7 +168,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
inputs.shards.value,
|
||||
'shards must be a positive integer.'
|
||||
);
|
||||
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
|
||||
|
||||
try {
|
||||
await fs.mkdir(junitReportDirectory, { recursive: true });
|
||||
@@ -212,24 +175,13 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
throw new SystemError('Failed to create JUnit report directory', { cause: err });
|
||||
}
|
||||
|
||||
// Legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
|
||||
// flow scan is built lazily in the retry branch below and memoized so
|
||||
// retries share one scan. Never runs when the report has `file=`.
|
||||
let nameToPathPromise: Promise<Map<string, string> | null> | undefined;
|
||||
|
||||
// Retry loop. spawn-async error shapes:
|
||||
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
|
||||
// numeric err.status → maestro exited non-zero → retry.
|
||||
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
|
||||
// downgraded to "tests failed".
|
||||
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
|
||||
// flows. The failed-flow parsers return null when the JUnit cannot be
|
||||
// trusted; we then fall through to dumb retry (re-run everything).
|
||||
let flowsToRun: string[] = flowPaths;
|
||||
let lastAttemptExitCode: number | null = null;
|
||||
const harvested: HarvestedScreenshot[] = [];
|
||||
|
||||
const totalAttempts = retries + 1;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
const outputPath =
|
||||
outputFormat === 'junit'
|
||||
@@ -238,27 +190,19 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
|
||||
: null;
|
||||
|
||||
const maestroArgs = buildMaestroArgs({
|
||||
flow_path: flowsToRun,
|
||||
outputPath,
|
||||
output_format: outputFormat,
|
||||
shards,
|
||||
include_tags: includeTags,
|
||||
exclude_tags: excludeTags,
|
||||
});
|
||||
logger.info(
|
||||
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
|
||||
);
|
||||
|
||||
const attemptStartedAtMs = Date.now();
|
||||
|
||||
try {
|
||||
await spawn('maestro', maestroArgs, {
|
||||
cwd: stepCtx.workingDirectory,
|
||||
env: spawnEnv,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
await spawn(
|
||||
'maestro',
|
||||
buildMaestroArgs({
|
||||
flow_path: flowPaths,
|
||||
outputPath,
|
||||
output_format: outputFormat,
|
||||
shards,
|
||||
include_tags: includeTags,
|
||||
exclude_tags: excludeTags,
|
||||
}),
|
||||
{ cwd: stepCtx.workingDirectory, env: spawnEnv, logger, signal }
|
||||
);
|
||||
lastAttemptExitCode = 0;
|
||||
} catch (err: any) {
|
||||
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
|
||||
@@ -271,102 +215,38 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
}
|
||||
}
|
||||
|
||||
// Harvest this attempt's failure screenshots before any retry subsetting. Gated on
|
||||
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
|
||||
// runs, so harvesting other formats would just create orphan artifacts the website hides.
|
||||
if (outputFormat === 'junit') {
|
||||
harvested.push(
|
||||
...(await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: attemptStartedAtMs,
|
||||
attemptIndex: attempt,
|
||||
logger,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (lastAttemptExitCode === 0 || attempt === retries) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
|
||||
let failed: string[] | null;
|
||||
if (await junitFileHasFileAttrs(outputPath)) {
|
||||
failed = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: outputPath,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
});
|
||||
} else {
|
||||
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
|
||||
// paths via the flow-file scan. DELETE this arm once the fleet is
|
||||
// on >= 2.6.0.
|
||||
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
|
||||
inputFlowPaths: flowPaths,
|
||||
projectRoot: stepCtx.workingDirectory,
|
||||
logger,
|
||||
}));
|
||||
failed = nameToPath
|
||||
? await parseFailedFlowsFromJUnit({ junitFile: outputPath, nameToPath })
|
||||
: null;
|
||||
}
|
||||
if (failed !== null && failed.length > 0) {
|
||||
flowsToRun = failed;
|
||||
logger.info(
|
||||
`Test failed; retrying ${failed.length} failed flow(s): ${failed.join(', ')}`
|
||||
);
|
||||
} else {
|
||||
flowsToRun = flowPaths;
|
||||
logger.info('Test failed; could not determine failed subset, retrying all flows');
|
||||
}
|
||||
} else {
|
||||
flowsToRun = flowPaths;
|
||||
logger.info('Test failed, retrying all flows');
|
||||
}
|
||||
|
||||
logger.info('Test failed, retrying...');
|
||||
await sleepAsync(2000);
|
||||
}
|
||||
|
||||
// Smart merge first; on data errors (bad XML, missing input) fall back
|
||||
// to copy-latest so the caller still gets a single JUnit file.
|
||||
// Filesystem errors short-circuit straight to SystemError.
|
||||
// Copy the latest attempt's JUnit to the final report path so downstream
|
||||
// upload/report steps have a single canonical file.
|
||||
if (finalReportPath !== undefined) {
|
||||
try {
|
||||
await mergeJUnitReports({
|
||||
await copyLatestAttemptXml({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
});
|
||||
} catch (mergeErr: any) {
|
||||
if (isFilesystemError(mergeErr)) {
|
||||
throw new SystemError('Failed to write final_report_path', { cause: mergeErr });
|
||||
}
|
||||
logger.warn({ err: mergeErr }, 'Smart merge failed; falling back to copy-latest.');
|
||||
try {
|
||||
await copyLatestAttemptXml({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
});
|
||||
} catch (copyErr: any) {
|
||||
// Swallow: a copy failure here usually means maestro itself failed
|
||||
// early (bad YAML wrote no *.xml). Throwing SystemError would mask
|
||||
// the real reason and cancel billing for a user-side failure — let
|
||||
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
|
||||
logger.warn(
|
||||
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
|
||||
);
|
||||
}
|
||||
} catch (copyErr: any) {
|
||||
// Swallow: a copy failure usually means maestro itself failed early
|
||||
// (bad YAML wrote no *.xml). Throwing SystemError here would mask
|
||||
// the real reason and cancel billing for a user-side failure — let
|
||||
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
|
||||
logger.warn(
|
||||
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload before the ERR_MAESTRO_TESTS_FAILED throw below so fully-failed runs (which need
|
||||
// screenshots most) still upload. Harvest only ran for junit, so guard the same way.
|
||||
if (outputFormat === 'junit') {
|
||||
await uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger });
|
||||
}
|
||||
|
||||
// The retry loop exits via success (0), numeric status (retryable),
|
||||
// or throw (infra). A non-null non-zero status means the user's tests
|
||||
// failed every attempt.
|
||||
if (lastAttemptExitCode !== 0) {
|
||||
const totalAttempts = retries + 1;
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_TESTS_FAILED',
|
||||
`Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`
|
||||
@@ -375,82 +255,3 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Reduce harvested failure screenshots to what's worth uploading, then upload them as workflow
|
||||
// artifacts. Best-effort and verdict-neutral: never throws, so a screenshot problem can't mask
|
||||
// the maestro test result. Caller guards on junit (harvest only runs for junit).
|
||||
async function uploadFailureScreenshotsAsync({
|
||||
harvested,
|
||||
junitReportDirectory,
|
||||
ctx,
|
||||
logger,
|
||||
}: {
|
||||
harvested: HarvestedScreenshot[];
|
||||
junitReportDirectory: string;
|
||||
ctx: CustomBuildContext;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
// Reduce to the attempts worth uploading — every failed attempt for flaky flows, only the final
|
||||
// attempt for all-failed flows. See computePureFailureFlowNames / selectFailureScreenshots.
|
||||
// Guard the JUnit re-parse so a malformed/missing report can't throw past here and mask the
|
||||
// test verdict (the whole step is verdict-neutral for screenshots).
|
||||
let selected: HarvestedScreenshot[];
|
||||
try {
|
||||
const pureFailureFlowNames = computePureFailureFlowNames(
|
||||
await parseJUnitTestCases(junitReportDirectory)
|
||||
);
|
||||
selected = selectFailureScreenshots(harvested, pureFailureFlowNames);
|
||||
} catch (err: any) {
|
||||
logger.warn({ err }, 'Failed to classify failure screenshots; skipping screenshot upload.');
|
||||
return;
|
||||
}
|
||||
if (selected.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cap well under www's 50-artifact-per-job limit.
|
||||
const MAX_SCREENSHOT_UPLOADS = 30;
|
||||
const toUpload = selected.slice(0, MAX_SCREENSHOT_UPLOADS);
|
||||
if (selected.length > toUpload.length) {
|
||||
logger.warn(
|
||||
`Found ${selected.length} failure screenshots; uploading only the first ${toUpload.length}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Copy each shot to an ASCII-safe name outside testsDirectory (the originals contain a
|
||||
// non-ASCII marker and testsDirectory is uploaded wholesale as the tarball).
|
||||
let safeScreenshotDir: string;
|
||||
try {
|
||||
safeScreenshotDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-maestro-screenshots-'));
|
||||
} catch (err: any) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Failed to create the failure-screenshot staging dir; skipping screenshot upload.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
toUpload.map(async (shot, index) => {
|
||||
try {
|
||||
// `index` disambiguates two flows that fail within the same millisecond of an attempt.
|
||||
const safePath = path.join(
|
||||
safeScreenshotDir,
|
||||
`failure-attempt-${shot.metadata.attemptIndex}-${index}-${shot.metadata.capturedAtMs}.png`
|
||||
);
|
||||
await fs.copyFile(shot.fileAbsPath, safePath);
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
artifact: {
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: shot.displayName,
|
||||
paths: [safePath],
|
||||
metadata: shot.metadata,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.warn({ err }, 'Failed to upload failure screenshot.');
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,6 @@ export function parseXcactivitylogFunction(): BuildFunction {
|
||||
xclogparserVersion: version,
|
||||
logger: stepCtx.logger,
|
||||
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
env,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,6 +42,11 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'output_path',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'embed_bundle_assets',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
@@ -52,16 +57,6 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ios_signing_use_source_app_entitlements',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ios_signing_app_entitlements_path',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'repack_version',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
@@ -104,10 +99,9 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
stepsCtx.logger.info(`Created temporary working directory: ${workingDirectory}`);
|
||||
|
||||
const sourceAppPath = inputs.source_app_path.value as string;
|
||||
const outputPath = createOutputPath({
|
||||
sourceAppPath,
|
||||
tmpDir,
|
||||
});
|
||||
const outputPath =
|
||||
(inputs.output_path.value as string) ??
|
||||
path.join(tmpDir, `repacked-${randomUUID()}${path.extname(sourceAppPath)}`);
|
||||
const exportEmbedOptions = inputs.embed_bundle_assets.value
|
||||
? {
|
||||
sourcemapOutput: undefined,
|
||||
@@ -144,12 +138,6 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
iosSigningOptions: await resolveIosSigningOptionsAsync({
|
||||
job: stepsCtx.global.staticContext.job,
|
||||
logger: stepsCtx.logger,
|
||||
useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value as
|
||||
| boolean
|
||||
| undefined,
|
||||
entitlementsPath: inputs.ios_signing_app_entitlements_path.value as
|
||||
| string
|
||||
| undefined,
|
||||
}),
|
||||
logger: stepsCtx.logger,
|
||||
spawnAsync: repackSpawnAsync,
|
||||
@@ -198,21 +186,6 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
function createOutputPath({
|
||||
sourceAppPath,
|
||||
tmpDir,
|
||||
}: {
|
||||
sourceAppPath: string;
|
||||
tmpDir: string;
|
||||
}): string {
|
||||
const outputPath = path.join(tmpDir, `repacked-${randomUUID()}${path.extname(sourceAppPath)}`);
|
||||
const extension = path.extname(outputPath);
|
||||
if (extension.toLowerCase() !== '.aab') {
|
||||
return outputPath;
|
||||
}
|
||||
return `${outputPath.slice(0, -extension.length)}.apk`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install `@expo/repack-app` in a sandbox directory and import it.
|
||||
*/
|
||||
@@ -302,13 +275,9 @@ export async function resolveAndroidSigningOptionsAsync({
|
||||
export async function resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger,
|
||||
useAppEntitlements,
|
||||
entitlementsPath,
|
||||
}: {
|
||||
job: Job;
|
||||
logger: bunyan;
|
||||
useAppEntitlements?: boolean;
|
||||
entitlementsPath?: string;
|
||||
}): Promise<IosSigningOptions | undefined> {
|
||||
const iosJob = job as Ios.Job;
|
||||
const buildCredentials = iosJob.secrets?.buildCredentials;
|
||||
@@ -326,7 +295,5 @@ export async function resolveIosSigningOptionsAsync({
|
||||
provisioningProfile,
|
||||
keychainPath: credentials.keychainPath,
|
||||
signingIdentity: credentials.applicationTargetProvisioningProfile.data.certificateCommonName,
|
||||
useAppEntitlements,
|
||||
entitlementsPath,
|
||||
};
|
||||
}
|
||||
@@ -1,19 +1,9 @@
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import {
|
||||
MaestroFlowResult,
|
||||
isFileAttrRun,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
} from './maestroResultParser';
|
||||
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
const FlowPathSchema = z.array(z.string().min(1)).min(1);
|
||||
|
||||
const CREATE_MUTATION = graphql(`
|
||||
mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
|
||||
workflowDeviceTestCaseResult {
|
||||
@@ -48,11 +38,6 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
defaultValue: '${{ env.HOME }}/.maestro/tests',
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'flow_path',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs }) => {
|
||||
const { logger } = stepsCtx;
|
||||
@@ -66,65 +51,36 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
|
||||
logger.info('No JUnit directory provided, skipping test results report');
|
||||
return;
|
||||
}
|
||||
const testsDirectory = inputs.tests_directory.value as string;
|
||||
|
||||
try {
|
||||
// Maestro >= 2.6.0 stamps every <testcase> with its flow's path in a
|
||||
// `file=` attribute; older reports need the legacy flow-file scan to
|
||||
// map testcase names back to paths.
|
||||
const usedFileAttrs = isFileAttrRun(await parseJUnitTestCases(junitDirectory));
|
||||
|
||||
let flowResults: MaestroFlowResult[];
|
||||
if (usedFileAttrs) {
|
||||
flowResults = await parseMaestroResultsFromFileAttrs(junitDirectory);
|
||||
} else {
|
||||
// Legacy (Maestro < 2.6.0) — DELETE this arm once the fleet is on >= 2.6.0.
|
||||
const flowPathRaw = inputs.flow_path.value;
|
||||
let nameToPath: Map<string, string> | null = null;
|
||||
if (flowPathRaw !== undefined) {
|
||||
const parsed = FlowPathSchema.safeParse(flowPathRaw);
|
||||
if (parsed.success) {
|
||||
nameToPath = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: parsed.data,
|
||||
projectRoot: stepsCtx.workingDirectory,
|
||||
logger,
|
||||
});
|
||||
} else {
|
||||
logger.warn(
|
||||
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
|
||||
);
|
||||
}
|
||||
}
|
||||
flowResults = await parseMaestroResults(junitDirectory, nameToPath);
|
||||
}
|
||||
|
||||
const flowResults = await parseMaestroResults(
|
||||
junitDirectory,
|
||||
testsDirectory,
|
||||
stepsCtx.workingDirectory
|
||||
);
|
||||
if (flowResults.length === 0) {
|
||||
logger.info('No maestro test results found, skipping report');
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect truly conflicting results: the same (path, retryCount) twice means two
|
||||
// flow files resolved to the same path, which we can't disambiguate and the API
|
||||
// rejects as a duplicate. Same path with different retryCount is expected
|
||||
// (per-attempt results from retries).
|
||||
// Detect truly conflicting results: same (name, retryCount) pair means different flow files
|
||||
// share the same name (Maestro config override), which we can't disambiguate.
|
||||
// Same name with different retryCount is expected (per-attempt results from retries).
|
||||
const seen = new Set<string>();
|
||||
const conflicting = new Set<string>();
|
||||
for (const r of flowResults) {
|
||||
const key = `${r.path}:${r.retryCount}`;
|
||||
const key = `${r.name}:${r.retryCount}`;
|
||||
if (seen.has(key)) {
|
||||
conflicting.add(r.path);
|
||||
conflicting.add(r.name);
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
if (conflicting.size > 0) {
|
||||
const conflictList = [...conflicting].join(', ');
|
||||
logger.error(
|
||||
usedFileAttrs
|
||||
? `The same flow file was reported more than once in a single attempt: ` +
|
||||
`${conflictList}. Skipping report. Check for duplicate flow_path entries ` +
|
||||
`or leftover XML files in the JUnit report directory.`
|
||||
: `Duplicate Maestro flow names found: ${conflictList}. Skipping report. ` +
|
||||
`Give each flow a unique name, or upgrade Maestro to >= 2.6.0 (flows are ` +
|
||||
`then identified by file path, so duplicate names are fine).`
|
||||
`Duplicate test case names found in JUnit output: ${[...conflicting].join(
|
||||
', '
|
||||
)}. Skipping report. Ensure each Maestro flow has a unique name.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,8 @@ import {
|
||||
generateDefaultBuildCacheKeyAsync,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
|
||||
import { TurtleFetchError } from '../../utils/turtleFetch';
|
||||
|
||||
export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -206,16 +205,16 @@ export async function restoreGradleCacheAsync({
|
||||
}
|
||||
|
||||
try {
|
||||
// Enable build cache via user-level gradle.properties.
|
||||
// This avoids mutating android/gradle.properties which affects fingerprinting.
|
||||
const gradleUserHome = path.join(os.homedir(), '.gradle');
|
||||
await fs.promises.mkdir(gradleUserHome, { recursive: true });
|
||||
const userGradlePropertiesPath = path.join(gradleUserHome, 'gradle.properties');
|
||||
await fs.promises.appendFile(userGradlePropertiesPath, '\norg.gradle.caching=true\n');
|
||||
const gradlePropertiesPath = path.join(workingDirectory, 'android', 'gradle.properties');
|
||||
const gradlePropertiesContent = await fs.promises.readFile(gradlePropertiesPath, 'utf-8');
|
||||
await fs.promises.writeFile(
|
||||
gradlePropertiesPath,
|
||||
`${gradlePropertiesContent}\n\norg.gradle.caching=true\n`
|
||||
);
|
||||
|
||||
// Configure cache cleanup via init script.
|
||||
// Works with both Gradle 8 and 9 (org.gradle.cache.cleanup property was removed in Gradle 9).
|
||||
const initScriptDir = path.join(gradleUserHome, 'init.d');
|
||||
// Configure cache cleanup via init script (works with both Gradle 8 and 9,
|
||||
// org.gradle.cache.cleanup property was removed in Gradle 9)
|
||||
const initScriptDir = path.join(os.homedir(), '.gradle', 'init.d');
|
||||
await fs.promises.mkdir(initScriptDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
|
||||
@@ -278,15 +277,9 @@ export async function restoreGradleCacheAsync({
|
||||
logger,
|
||||
});
|
||||
|
||||
const hitType = matchedKey === cacheKey ? 'direct_hit' : 'prefix_match';
|
||||
logger.info(
|
||||
`Gradle cache restored to ${gradleCachesPath} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
|
||||
`Gradle cache restored to ${gradleCachesPath} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`
|
||||
);
|
||||
|
||||
Datadog.log(`Gradle cache restored (${hitType})`, {
|
||||
event: 'gradle_cache_restored',
|
||||
cache_hit_type: hitType,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof TurtleFetchError && err.response?.status === 404) {
|
||||
logger.info('No Gradle cache found for this key');
|
||||
|
||||
@@ -69,10 +69,6 @@ export function createRestoreCacheFunction(): BuildFunction {
|
||||
.filter(key => key !== '');
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const expoApiServerURL = nullthrows(
|
||||
stepsCtx.global.staticContext.expoApiServerURL,
|
||||
'expoApiServerURL is not set'
|
||||
);
|
||||
const robotAccessToken = nullthrows(
|
||||
stepsCtx.global.staticContext.job.secrets?.robotAccessToken,
|
||||
'robotAccessToken is not set'
|
||||
@@ -81,7 +77,7 @@ export function createRestoreCacheFunction(): BuildFunction {
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths,
|
||||
key,
|
||||
|
||||
@@ -43,10 +43,6 @@ export function createSaveCacheFunction(): BuildFunction {
|
||||
.filter(path => path.length > 0);
|
||||
const key = z.string().parse(inputs.key.value);
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const expoApiServerURL = nullthrows(
|
||||
stepsCtx.global.staticContext.expoApiServerURL,
|
||||
'expoApiServerURL is not set'
|
||||
);
|
||||
const robotAccessToken = nullthrows(
|
||||
stepsCtx.global.staticContext.job.secrets?.robotAccessToken,
|
||||
'robotAccessToken is not set'
|
||||
@@ -65,7 +61,7 @@ export function createSaveCacheFunction(): BuildFunction {
|
||||
await uploadPublicCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key,
|
||||
@@ -77,7 +73,7 @@ export function createSaveCacheFunction(): BuildFunction {
|
||||
await uploadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key,
|
||||
|
||||
@@ -1,41 +1,23 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
type BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import childProcess from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForFileAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
const AGENT_DEVICE_PACKAGE_NAME = 'agent-device';
|
||||
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
|
||||
const SRC_DIR = '/tmp/agent-device-src';
|
||||
const RUN_DIR = '/tmp/agent-device';
|
||||
const DAEMON_LOG = path.join(RUN_DIR, 'daemon.log');
|
||||
const TUNNEL_LOG = path.join(RUN_DIR, 'cloudflared.log');
|
||||
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
export function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_agent_device_remote_session',
|
||||
@@ -48,67 +30,76 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
|
||||
fn: async ({ logger }, { inputs, env }) => {
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}).`);
|
||||
|
||||
logger.info(`Preparing runtime directory at ${RUN_DIR}.`);
|
||||
await fs.promises.mkdir(RUN_DIR, { recursive: true });
|
||||
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
|
||||
logger.info('Ensuring cloudflared is installed.');
|
||||
await ensureCloudflaredInstalledAsync({ env, logger });
|
||||
|
||||
logger.info('Ensuring bun is installed.');
|
||||
await ensureBunInstalledAsync({ env, logger });
|
||||
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
packageVersion
|
||||
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
|
||||
: `Cloning agent-device (latest) into ${SRC_DIR}.`
|
||||
);
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
logger.info('Installing agent-device dependencies.');
|
||||
await spawn('bun', ['install', '--production'], {
|
||||
cwd: SRC_DIR,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Launching agent-device daemon.');
|
||||
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
|
||||
logger.info(`Launching agent-device daemon (log file: ${DAEMON_LOG}).`);
|
||||
await spawnDetachedAsync({
|
||||
command: 'bun run src/daemon.ts',
|
||||
cwd: SRC_DIR,
|
||||
logFile: DAEMON_LOG,
|
||||
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
|
||||
const { port: daemonPort, token: daemonToken } = await waitForDaemonInfoAsync({
|
||||
daemonProcess,
|
||||
await waitForFileAsync({
|
||||
filePath: DAEMON_JSON_PATH,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'agent-device daemon',
|
||||
});
|
||||
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
|
||||
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
|
||||
|
||||
const agentDeviceRemoteSessionUrl = await startNgrokTunnelAsync({
|
||||
port: daemonPort,
|
||||
subdomainPrefix: 'agent-device',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger.info(
|
||||
`Starting cloudflared tunnel to http://localhost:${daemonPort} (log file: ${TUNNEL_LOG}).`
|
||||
);
|
||||
await spawnDetachedAsync({
|
||||
command: `cloudflared tunnel --url "http://localhost:${daemonPort}"`,
|
||||
logFile: TUNNEL_LOG,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
|
||||
|
||||
// serve-sim is iOS-only — only launch it (and report a webPreviewUrl)
|
||||
// on Darwin. Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
const { previewUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
webPreviewUrl = previewUrl;
|
||||
logger.info(`Web preview URL: ${webPreviewUrl}`);
|
||||
}
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
...(webPreviewUrl ? { webPreviewUrl } : {}),
|
||||
},
|
||||
logger,
|
||||
logger.info('Waiting for a public tunnel URL.');
|
||||
const tunnelUrl = await waitForMatchInLogAsync({
|
||||
logFile: TUNNEL_LOG,
|
||||
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'cloudflared tunnel',
|
||||
});
|
||||
logger.info(`Tunnel is ready at ${tunnelUrl}.`);
|
||||
|
||||
logger.info('Emitting agent-device credentials for the CLI to pick up:');
|
||||
logger.info(`export AGENT_DEVICE_DAEMON_BASE_URL="${tunnelUrl}"`);
|
||||
logger.info(`export AGENT_DEVICE_DAEMON_AUTH_TOKEN="${daemonToken}"`);
|
||||
|
||||
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
|
||||
// Keep the turtle job alive so the daemon and tunnel stay reachable
|
||||
@@ -118,91 +109,40 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
});
|
||||
}
|
||||
|
||||
async function startAgentDeviceDaemonAsync({
|
||||
packageVersion,
|
||||
async function ensureCloudflaredInstalledAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
const packageSpec = createAgentDevicePackageSpec(packageVersion);
|
||||
try {
|
||||
logger.info(`Installing ${packageSpec} globally with Bun.`);
|
||||
await spawn('bun', ['add', '--global', packageSpec], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
|
||||
if (!fs.existsSync(daemonPath)) {
|
||||
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
|
||||
}
|
||||
|
||||
logger.info(`Launching daemon from ${daemonPath}.`);
|
||||
return spawnDetached({
|
||||
command: 'node',
|
||||
args: [daemonPath],
|
||||
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
|
||||
Sentry.capture(
|
||||
'Failed to start agent-device daemon from global Bun package; falling back to git clone',
|
||||
error,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: {
|
||||
phase: 'agent-device-daemon-start',
|
||||
fallback: 'git-clone',
|
||||
},
|
||||
extras: {
|
||||
packageSpec,
|
||||
packageVersion: packageVersion ?? 'latest',
|
||||
bunVersion,
|
||||
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
|
||||
},
|
||||
}
|
||||
);
|
||||
logger.warn(
|
||||
`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`
|
||||
);
|
||||
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
|
||||
}
|
||||
}): Promise<void> {
|
||||
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
|
||||
}
|
||||
|
||||
async function startAgentDeviceDaemonFromGitAsync({
|
||||
packageVersion,
|
||||
async function ensureBunInstalledAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
logger.info(
|
||||
packageVersion
|
||||
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
|
||||
: `Cloning agent-device (latest) into ${SRC_DIR}.`
|
||||
}): Promise<void> {
|
||||
await ensureBrewPackageInstalledAsync({ name: 'bun', env, logger });
|
||||
}
|
||||
|
||||
async function ensureBrewPackageInstalledAsync({
|
||||
name,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
name: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
await spawn(
|
||||
'bash',
|
||||
['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`],
|
||||
{ env, logger }
|
||||
);
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
||||
|
||||
logger.info('Installing agent-device dependencies.');
|
||||
await spawn('bun', ['install', '--production'], {
|
||||
cwd: SRC_DIR,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Launching daemon from cloned agent-device source.');
|
||||
return spawnDetached({
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: SRC_DIR,
|
||||
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
|
||||
});
|
||||
}
|
||||
|
||||
async function cloneAgentDeviceAsync({
|
||||
@@ -211,7 +151,7 @@ async function cloneAgentDeviceAsync({
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
|
||||
@@ -221,64 +161,98 @@ async function cloneAgentDeviceAsync({
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDaemonInfoAsync({
|
||||
daemonProcess,
|
||||
async function spawnDetachedAsync({
|
||||
command,
|
||||
cwd,
|
||||
logFile,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
daemonProcess: DetachedProcessHandle;
|
||||
}): Promise<{ port: number; token: string }> {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
logFile: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
// Launch the process fully detached so this function returns immediately and
|
||||
// the grandchild survives the step. Stdio goes to a log file so the daemon
|
||||
// output can be polled, and we unref so Node doesn't wait on it.
|
||||
const fd = fs.openSync(logFile, 'a');
|
||||
try {
|
||||
return await waitForFileAsync({
|
||||
filePath: DAEMON_JSON_PATH,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'agent-device daemon credentials',
|
||||
parse: parseDaemonInfo,
|
||||
const child = childProcess.spawn('bash', ['-c', command], {
|
||||
cwd,
|
||||
env,
|
||||
detached: true,
|
||||
stdio: ['ignore', fd, fd],
|
||||
});
|
||||
} catch (err) {
|
||||
const output = daemonProcess.getOutput();
|
||||
throw new SystemError(
|
||||
`${
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `Timed out waiting for agent-device daemon credentials.`
|
||||
}${output ? `\nagent-device daemon output:\n${output}` : ''}`
|
||||
);
|
||||
if (!child.pid) {
|
||||
throw new Error(`Failed to spawn detached process: ${command}`);
|
||||
}
|
||||
child.unref();
|
||||
logger.info(`Started detached process (pid ${child.pid}).`);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
async function getBunVersionForDiagnosticsAsync(env: BuildStepEnv): Promise<string> {
|
||||
try {
|
||||
const result = await spawn('bun', ['--version'], { stdio: 'pipe', env, cwd: os.tmpdir() });
|
||||
return result.stdout.trim() || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
async function waitForMatchInLogAsync({
|
||||
logFile,
|
||||
pattern,
|
||||
timeoutMs,
|
||||
description,
|
||||
}: {
|
||||
logFile: string;
|
||||
pattern: RegExp;
|
||||
timeoutMs: number;
|
||||
description: string;
|
||||
}): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const content = await readFileOrEmptyAsync(logFile);
|
||||
const match = pattern.exec(content);
|
||||
if (match) {
|
||||
return match[1] ?? match[0];
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
}
|
||||
|
||||
function createAgentDevicePackageSpec(packageVersion: string | undefined): string {
|
||||
const versionSpec = packageVersion ? packageVersion.replace(/^v(?=\d)/, '') : 'latest';
|
||||
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
|
||||
}
|
||||
|
||||
function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
|
||||
return path.join(
|
||||
getBunInstallDirectory(env),
|
||||
'install',
|
||||
'global',
|
||||
'node_modules',
|
||||
AGENT_DEVICE_PACKAGE_NAME,
|
||||
'dist',
|
||||
'src',
|
||||
'internal',
|
||||
'daemon.js'
|
||||
const tail = await readFileOrEmptyAsync(logFile);
|
||||
throw new Error(
|
||||
`Timed out waiting for ${description} to start. Last log contents:\n${tail || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
function getBunInstallDirectory(env: BuildStepEnv): string {
|
||||
const bunInstall = env.BUN_INSTALL?.trim();
|
||||
return bunInstall ? bunInstall : path.join(os.homedir(), '.bun');
|
||||
async function waitForFileAsync({
|
||||
filePath,
|
||||
timeoutMs,
|
||||
description,
|
||||
}: {
|
||||
filePath: string;
|
||||
timeoutMs: number;
|
||||
description: string;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
return;
|
||||
} catch {
|
||||
// not yet; keep polling
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${description} to write ${filePath}.`);
|
||||
}
|
||||
|
||||
function parseDaemonInfo(raw: string): { port: number; token: string } {
|
||||
async function readFileOrEmptyAsync(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.promises.readFile(filePath, 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function readDaemonInfo(filePath: string): { port: number; token: string } {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
!parsed ||
|
||||
@@ -286,9 +260,7 @@ function parseDaemonInfo(raw: string): { port: number; token: string } {
|
||||
typeof (parsed as { httpPort: unknown }).httpPort !== 'number' ||
|
||||
typeof (parsed as { token: unknown }).token !== 'string'
|
||||
) {
|
||||
throw new SystemError(
|
||||
'Expected daemon credentials to contain { "httpPort": <number>, "token": "..." }.'
|
||||
);
|
||||
throw new Error(`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`);
|
||||
}
|
||||
const { httpPort, token } = parsed as { httpPort: number; token: string };
|
||||
return { port: httpPort, token };
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForFileAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
|
||||
export const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.12.0';
|
||||
const ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG = 'artifacts-list-endpoint';
|
||||
const ARGENT_STATE_FILE = path.join(os.homedir(), '.argent', 'tool-server.json');
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
const ArgentToolServerStateSchema = z.object({
|
||||
port: z.number(),
|
||||
token: z.string().optional(),
|
||||
});
|
||||
|
||||
export function createStartArgentRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_argent_remote_session',
|
||||
name: 'Start argent remote session',
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
logger.info(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
// Stale state from a previous run would mask the new server's port.
|
||||
await fs.promises.rm(ARGENT_STATE_FILE, { force: true });
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
await spawn(
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
|
||||
const argentServer = spawnDetached({
|
||||
command: 'bunx',
|
||||
args: [
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--detach',
|
||||
],
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info(`Waiting for argent tool-server state at ${ARGENT_STATE_FILE}.`);
|
||||
let toolServerPort: number;
|
||||
let toolServerToken: string | undefined;
|
||||
try {
|
||||
const toolServerState = await waitForFileAsync({
|
||||
filePath: ARGENT_STATE_FILE,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'argent tool-server state',
|
||||
parse: parseArgentToolServerState,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
} catch (err) {
|
||||
const output = argentServer.getOutput();
|
||||
throw new SystemError(
|
||||
`${
|
||||
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
|
||||
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
void pollArgentArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
|
||||
toolsAuthToken: toolServerToken,
|
||||
logger,
|
||||
});
|
||||
|
||||
const publicToolsUrl = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
subdomainPrefix: 'argent',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
rewriteHostHeader: true,
|
||||
logger,
|
||||
});
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
// serve-sim is iOS-only — Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
const serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
webPreviewUrl = serveSim.previewUrl;
|
||||
logger.info(`Web preview URL: ${webPreviewUrl}`);
|
||||
}
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
...(webPreviewUrl ? { webPreviewUrl } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
|
||||
// Keep the turtle job alive so the tool-server and tunnel stay reachable
|
||||
// until stopDeviceRunSession cancels the run.
|
||||
await new Promise<never>(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
logger: bunyan;
|
||||
}): void {
|
||||
if (!packageVersion || packageVersion === 'latest') {
|
||||
return;
|
||||
}
|
||||
|
||||
const validVersion = semver.valid(packageVersion);
|
||||
if (!validVersion) {
|
||||
logger.warn(
|
||||
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
|
||||
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
|
||||
`Continuing and letting bunx resolve it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (semver.lt(validVersion, MIN_ARGENT_REMOTE_SESSION_VERSION)) {
|
||||
throw new SystemError(
|
||||
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer. ` +
|
||||
`The requested package_version "${packageVersion}" is too old for the EAS remote-session API. ` +
|
||||
`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgentToolServerState(raw: string): { port: number; token?: string } {
|
||||
const result = ArgentToolServerStateSchema.safeParse(JSON.parse(raw));
|
||||
if (!result.success) {
|
||||
throw new SystemError(
|
||||
`Expected tool-server state to contain { "port": <number>, ... }: ${result.error.message}`
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -31,12 +31,6 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
defaultValue: 1,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'enable_accessibility_settings',
|
||||
required: false,
|
||||
defaultValue: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env }) => {
|
||||
try {
|
||||
@@ -61,29 +55,16 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
| undefined;
|
||||
const originalDeviceIdentifier =
|
||||
deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
|
||||
const enableAccessibilitySettings = Boolean(inputs.enable_accessibility_settings.value);
|
||||
|
||||
if (!originalDeviceIdentifier) {
|
||||
throw new Error('Could not find an iPhone among available simulator devices.');
|
||||
}
|
||||
|
||||
if (enableAccessibilitySettings) {
|
||||
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: originalDeviceIdentifier,
|
||||
env,
|
||||
});
|
||||
}
|
||||
const { udid } = await IosSimulatorUtils.startAsync({
|
||||
deviceIdentifier: originalDeviceIdentifier,
|
||||
env,
|
||||
});
|
||||
|
||||
try {
|
||||
await IosSimulatorUtils.disableApsdAsync({ udid, env });
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
|
||||
}
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
|
||||
|
||||
logger.info('');
|
||||
@@ -110,23 +91,11 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
env,
|
||||
});
|
||||
|
||||
if (enableAccessibilitySettings) {
|
||||
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: cloneDeviceName,
|
||||
env,
|
||||
});
|
||||
}
|
||||
const { udid: cloneUdid } = await IosSimulatorUtils.startAsync({
|
||||
deviceIdentifier: cloneDeviceName,
|
||||
env,
|
||||
});
|
||||
|
||||
try {
|
||||
await IosSimulatorUtils.disableApsdAsync({ udid: cloneUdid, env });
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
|
||||
}
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({
|
||||
udid: cloneUdid,
|
||||
env,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartServeSimRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_serve_sim_remote_session',
|
||||
name: 'Start serve-sim remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
|
||||
logger.info('Starting serve-sim remote session.');
|
||||
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
|
||||
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Preview URL: ${previewUrl}`);
|
||||
logger.info(`Stream URL: ${streamUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: { previewUrl, streamUrl },
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
|
||||
// Keep the turtle job alive so the serve-sim tunnel stays reachable
|
||||
// until stopDeviceRunSession cancels the run.
|
||||
await new Promise<never>(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
GenericArtifactType,
|
||||
Job,
|
||||
ManagedArtifactType,
|
||||
isGenericArtifact,
|
||||
} from '@expo/eas-build-job';
|
||||
import { GenericArtifactType, Job, ManagedArtifactType } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
@@ -70,11 +65,6 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'metadata',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
@@ -118,7 +108,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
throw result.reason;
|
||||
});
|
||||
|
||||
const artifactSpec = {
|
||||
const artifact = {
|
||||
type: parseArtifactTypeInput({
|
||||
platform: ctx.job.platform,
|
||||
inputValue: `${inputs.type.value ?? ''}`,
|
||||
@@ -126,22 +116,19 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
paths: artifactPaths,
|
||||
name: (inputs.name.value || inputs.key.value) as string,
|
||||
};
|
||||
const artifact = isGenericArtifact(artifactSpec)
|
||||
? {
|
||||
...artifactSpec,
|
||||
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
|
||||
}
|
||||
: artifactSpec;
|
||||
|
||||
try {
|
||||
const { artifactId } = await ctx.runtimeApi.uploadArtifact({ artifact, logger });
|
||||
const { artifactId } = await ctx.runtimeApi.uploadArtifact({
|
||||
artifact,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (artifactId) {
|
||||
outputs.artifact_id.set(artifactId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (inputs.ignore_error.value) {
|
||||
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
|
||||
logger.error(`Failed to upload ${artifact.type}. Ignoring error.`, error);
|
||||
// Ignoring error.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import fetch from 'node-fetch';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { listArgentArtifactsAsync, uploadArgentArtifactAsync } from '../argentArtifacts';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<void> {
|
||||
for await (const chunk of stream as Readable) {
|
||||
void chunk;
|
||||
}
|
||||
}
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
}
|
||||
|
||||
describe(listArgentArtifactsAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
it('lists Argent artifacts with bearer auth', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
artifacts: [
|
||||
{
|
||||
id: 'artifact-id',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const artifacts = await listArgentArtifactsAsync({
|
||||
toolsUrl: 'http://127.0.0.1:1234',
|
||||
toolsAuthToken: 'tools-token',
|
||||
});
|
||||
|
||||
expect(artifacts).toEqual([
|
||||
{
|
||||
id: 'artifact-id',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
isDirectory: false,
|
||||
},
|
||||
]);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts', {
|
||||
headers: { Authorization: 'Bearer tools-token' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(uploadArgentArtifactAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset();
|
||||
});
|
||||
|
||||
it('downloads an Argent artifact and uploads it as a device run session artifact', async () => {
|
||||
const data = Buffer.from('artifact-data');
|
||||
const logger = createLoggerMock();
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
});
|
||||
|
||||
await uploadArgentArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
toolsUrl: 'http://127.0.0.1:1234',
|
||||
toolsAuthToken: 'tools-token',
|
||||
logger,
|
||||
artifact: {
|
||||
id: 'artifact-id',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts/artifact-id', {
|
||||
headers: { Authorization: 'Bearer tools-token' },
|
||||
});
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'artifact-id',
|
||||
name: 'report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
size: data.length,
|
||||
stream: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
it('streams an artifact through a signed upload URL', async () => {
|
||||
const stream = Readable.from(Buffer.from('artifact-data'));
|
||||
const reportedSize = 1024;
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {
|
||||
'Content-Length': String(reportedSize),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const ctx = {
|
||||
graphqlClient: {
|
||||
mutation,
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'artifact-id',
|
||||
name: 'Artifact report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
size: reportedSize,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'drs-id',
|
||||
input: {
|
||||
name: 'Artifact report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
size: reportedSize,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/artifact',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: stream,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
fetchServeSimTurnArgsAsync,
|
||||
turnIceServersToServeSimArgs,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
}
|
||||
|
||||
function createCtxMock(): CustomBuildContext {
|
||||
return {
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
},
|
||||
job: {
|
||||
secrets: { robotAccessToken: 'robot-token' },
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function createEnvMock(): BuildStepEnv {
|
||||
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
|
||||
}
|
||||
|
||||
describe(turnIceServersToServeSimArgs, () => {
|
||||
it('returns no args for an empty ICE server list', () => {
|
||||
expect(turnIceServersToServeSimArgs([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds --stun-url and --turn-url flags from Cloudflare ICE servers', () => {
|
||||
expect(
|
||||
turnIceServersToServeSimArgs([
|
||||
{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.cloudflare.com:53'] },
|
||||
{
|
||||
urls: [
|
||||
'turn:turn.cloudflare.com:3478?transport=udp',
|
||||
'turns:turn.cloudflare.com:443?transport=tcp',
|
||||
],
|
||||
username: 'user-123',
|
||||
credential: 'cred-456',
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478,stun:stun.cloudflare.com:53',
|
||||
'--turn-url',
|
||||
'turn:turn.cloudflare.com:3478?transport=udp,turns:turn.cloudflare.com:443?transport=tcp',
|
||||
'--turn-username',
|
||||
'user-123',
|
||||
'--turn-credential',
|
||||
'cred-456',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits only --turn-url flags when no credential-less (STUN) entry is present', () => {
|
||||
expect(
|
||||
turnIceServersToServeSimArgs([
|
||||
{
|
||||
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
|
||||
username: 'u',
|
||||
credential: 'c',
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
'--turn-url',
|
||||
'turns:turn.cloudflare.com:443?transport=tcp',
|
||||
'--turn-username',
|
||||
'u',
|
||||
'--turn-credential',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits only --stun-url when there is no credentialed TURN entry', () => {
|
||||
expect(turnIceServersToServeSimArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchServeSimTurnArgsAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('requests TURN ICE servers from the device run session endpoint and returns serve-sim args', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
json: async () => ({
|
||||
data: {
|
||||
iceServers: [
|
||||
{ urls: ['stun:stun.cloudflare.com:3478'] },
|
||||
{
|
||||
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
|
||||
username: 'u',
|
||||
credential: 'c',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
|
||||
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
expect(args).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478',
|
||||
'--turn-url',
|
||||
'turns:turn.cloudflare.com:443?transport=tcp',
|
||||
'--turn-username',
|
||||
'u',
|
||||
'--turn-credential',
|
||||
'c',
|
||||
]);
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer robot-token' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns [] and warns when the request fails so serve-sim falls back to P2P/STUN', async () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(args).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -23,27 +23,18 @@ export async function runGradleCommand({
|
||||
|
||||
logger.info(`Running 'gradlew ${gradleCommand} ${verboseFlag}' in ${androidDir}`);
|
||||
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
|
||||
|
||||
const spawnPromise = spawn(
|
||||
'bash',
|
||||
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
|
||||
{
|
||||
cwd: androidDir,
|
||||
logger,
|
||||
lineTransformer: (line?: string) => {
|
||||
if (!line || /^\.+$/.exec(line)) {
|
||||
return null;
|
||||
} else {
|
||||
return line;
|
||||
}
|
||||
},
|
||||
env: {
|
||||
...env,
|
||||
...extraEnv,
|
||||
LC_ALL: 'C.UTF-8',
|
||||
},
|
||||
}
|
||||
);
|
||||
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
|
||||
cwd: androidDir,
|
||||
logger,
|
||||
lineTransformer: (line?: string) => {
|
||||
if (!line || /^\.+$/.exec(line)) {
|
||||
return null;
|
||||
} else {
|
||||
return line;
|
||||
}
|
||||
},
|
||||
env: { ...env, ...extraEnv, LC_ALL: 'C.UTF-8' },
|
||||
});
|
||||
if (env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
|
||||
adjustOOMScore(spawnPromise, logger);
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises';
|
||||
import fetch from 'node-fetch';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
|
||||
const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
const ArgentArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
filename: z.string(),
|
||||
mimeType: z.string(),
|
||||
isDirectory: z.boolean().optional(),
|
||||
});
|
||||
const ArgentArtifactsListResponseSchema = z.object({
|
||||
artifacts: z.array(ArgentArtifactSchema),
|
||||
});
|
||||
|
||||
type ArgentArtifact = z.infer<typeof ArgentArtifactSchema>;
|
||||
|
||||
export async function pollArgentArtifactsForUploadAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
logger,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
toolsUrl: string;
|
||||
toolsAuthToken?: string;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<never> {
|
||||
logger.info('Started polling Argent tool-server for artifacts.');
|
||||
const seenArtifactIds = new Set<string>();
|
||||
let listArtifactsErrorCount = 0;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
const artifacts = await listArgentArtifactsAsync({ toolsUrl, toolsAuthToken });
|
||||
listArtifactsErrorCount = 0;
|
||||
for (const artifact of artifacts) {
|
||||
if (seenArtifactIds.has(artifact.id)) {
|
||||
continue;
|
||||
}
|
||||
seenArtifactIds.add(artifact.id);
|
||||
void uploadArgentArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
artifact,
|
||||
logger,
|
||||
}).catch(err => {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload Argent remote session artifact', error);
|
||||
logger.warn({ err: error }, 'Could not upload Argent remote session artifact.');
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
listArtifactsErrorCount += 1;
|
||||
if (listArtifactsErrorCount === 1 || listArtifactsErrorCount % 5 === 0) {
|
||||
Sentry.capture('Could not list Argent remote session artifacts', error);
|
||||
logger.warn(
|
||||
{ err: error, failedArtifactListCount: listArtifactsErrorCount },
|
||||
'Could not list Argent remote session artifacts.'
|
||||
);
|
||||
}
|
||||
}
|
||||
await sleepAsync(ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listArgentArtifactsAsync({
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
}: {
|
||||
toolsUrl: string;
|
||||
toolsAuthToken?: string;
|
||||
}): Promise<ArgentArtifact[]> {
|
||||
const response = await fetch(new URL('/artifacts', toolsUrl).toString(), {
|
||||
headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
`Failed to list Argent artifacts: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
const result = ArgentArtifactsListResponseSchema.safeParse(await response.json());
|
||||
if (!result.success) {
|
||||
throw new SystemError(`Invalid Argent artifacts response: ${result.error.message}`);
|
||||
}
|
||||
return result.data.artifacts;
|
||||
}
|
||||
|
||||
export async function uploadArgentArtifactAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
artifact,
|
||||
logger,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
toolsUrl: string;
|
||||
toolsAuthToken?: string;
|
||||
artifact: ArgentArtifact;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
const filename = artifact.isDirectory ? `${artifact.filename}.tar.gz` : artifact.filename;
|
||||
logger.info(`Downloading artifact ${filename}.`);
|
||||
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'argent-artifact-'));
|
||||
try {
|
||||
const temporaryArtifactPath = path.join(temporaryDirectory, path.basename(filename));
|
||||
await downloadArgentArtifactToFileAsync({
|
||||
artifact,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
destinationPath: temporaryArtifactPath,
|
||||
});
|
||||
const { size } = await stat(temporaryArtifactPath);
|
||||
logger.info(`Uploading artifact ${filename} (${formatBytes(size)}).`);
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: artifact.id,
|
||||
name: `${filename} (${artifact.id})`,
|
||||
filename,
|
||||
size,
|
||||
stream: createReadStream(temporaryArtifactPath),
|
||||
});
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadArgentArtifactToFileAsync({
|
||||
artifact,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
destinationPath,
|
||||
}: {
|
||||
artifact: ArgentArtifact;
|
||||
toolsUrl: string;
|
||||
toolsAuthToken?: string;
|
||||
destinationPath: string;
|
||||
}): Promise<void> {
|
||||
const response = await fetch(new URL(`/artifacts/${artifact.id}`, toolsUrl).toString(), {
|
||||
headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
`Failed to download Argent artifact ${artifact.id}: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new SystemError(
|
||||
`Argent artifact ${artifact.id} response did not include a readable body.`
|
||||
);
|
||||
}
|
||||
await pipeline(response.body, createWriteStream(destinationPath));
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch, { Headers } from 'node-fetch';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateDeviceRunSessionArtifactUploadSession(
|
||||
$deviceRunSessionId: ID!
|
||||
$input: CreateDeviceRunSessionArtifactUploadSessionInput!
|
||||
) {
|
||||
deviceRunSession {
|
||||
createArtifactUploadSession(deviceRunSessionId: $deviceRunSessionId, input: $input) {
|
||||
uploadSession {
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export async function uploadDeviceRunSessionArtifactAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
size,
|
||||
stream,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
stream: NodeJS.ReadableStream;
|
||||
}
|
||||
): Promise<void> {
|
||||
const uploadSession = await createDeviceRunSessionArtifactUploadSessionAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
size,
|
||||
});
|
||||
const response = await fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
headers: new Headers(uploadSession.headers as Record<string, string>),
|
||||
body: stream,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
`Failed to upload device run session artifact ${artifactId}: ${response.status} ${response.statusText}`,
|
||||
{ cause: response }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDeviceRunSessionArtifactUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
size,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
}
|
||||
) {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION, {
|
||||
deviceRunSessionId,
|
||||
input: {
|
||||
name,
|
||||
filename,
|
||||
size,
|
||||
},
|
||||
})
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw new SystemError(
|
||||
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
return result.data!.deviceRunSession.createArtifactUploadSession.uploadSession;
|
||||
}
|
||||
@@ -4,12 +4,11 @@ import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../../sentry';
|
||||
import {
|
||||
buildTargetMetrics,
|
||||
isCompileStep,
|
||||
collectTopLevelCompileSteps,
|
||||
formatReport,
|
||||
isCompileStep,
|
||||
parseAndReportXcactivitylog,
|
||||
} from '../xcactivitylog';
|
||||
|
||||
@@ -18,13 +17,6 @@ jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../sentry', () => ({
|
||||
Sentry: {
|
||||
setup: jest.fn(),
|
||||
capture: jest.fn(),
|
||||
flush: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Minimal fixture: two modules with compile steps
|
||||
const FIXTURE_TWO_MODULES = {
|
||||
@@ -154,67 +146,19 @@ const FIXTURE_NESTED = {
|
||||
],
|
||||
};
|
||||
|
||||
// Overlapping + gapped intervals with pairwise-distinct totals
|
||||
// (taskSeconds=11, wallSpan=12, activeWallTime=8). Distinct values catch a
|
||||
// regression that leaks wallSpan or taskSeconds into the rendered Active cell.
|
||||
const FIXTURE_OVERLAP_WITH_GAP = {
|
||||
schema: { name: 'OverlapWithGap' },
|
||||
subSteps: [
|
||||
{
|
||||
title: 'Build target GappedModule',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'cCompilation',
|
||||
duration: 5.0,
|
||||
startTimestamp: 100.0,
|
||||
endTimestamp: 105.0,
|
||||
signature: 'CompileC a.c',
|
||||
subSteps: [],
|
||||
},
|
||||
{
|
||||
detailStepType: 'cCompilation',
|
||||
duration: 4.0,
|
||||
startTimestamp: 102.0,
|
||||
endTimestamp: 106.0,
|
||||
signature: 'CompileC b.c',
|
||||
subSteps: [],
|
||||
},
|
||||
{
|
||||
detailStepType: 'cCompilation',
|
||||
duration: 2.0,
|
||||
startTimestamp: 110.0,
|
||||
endTimestamp: 112.0,
|
||||
signature: 'CompileC c.c',
|
||||
subSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockedDownloadFile = jest.mocked(downloadFile);
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
const TEST_ENV = { PATH: '/custom/bin:/usr/bin' };
|
||||
|
||||
function mockFilesystem({
|
||||
tempDir = '/tmp/xclogparser-123',
|
||||
jsonOutput = JSON.stringify(FIXTURE_TWO_MODULES),
|
||||
buildLogEntries = ['12345-abc.xcactivitylog'] as string[] | Error,
|
||||
}: {
|
||||
tempDir?: string;
|
||||
jsonOutput?: string;
|
||||
buildLogEntries?: string[] | Error;
|
||||
} = {}) {
|
||||
jest.spyOn(fs, 'mkdtemp').mockImplementation(async () => tempDir);
|
||||
jest.spyOn(fs, 'chmod').mockImplementation(async () => undefined);
|
||||
jest.spyOn(fs, 'readFile').mockImplementation(async () => jsonOutput);
|
||||
jest.spyOn(fs, 'readdir').mockImplementation((async () => {
|
||||
if (buildLogEntries instanceof Error) {
|
||||
throw buildLogEntries;
|
||||
}
|
||||
return buildLogEntries;
|
||||
}) as any);
|
||||
jest.spyOn(fs, 'rm').mockImplementation(async () => undefined);
|
||||
|
||||
return {
|
||||
@@ -227,46 +171,38 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
beforeEach(() => {
|
||||
mockedDownloadFile.mockReset();
|
||||
mockedSpawn.mockReset();
|
||||
jest.mocked(Sentry.capture).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('downloads xclogparser from GCS and logs resolution + final report when no binary is preinstalled', async () => {
|
||||
it('downloads xclogparser from GCS and logs only the final report on success', async () => {
|
||||
const logger = createMockLogger();
|
||||
const { tempDir, reportPath } = mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
const result = await parseAndReportXcactivitylog({
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ skipped: false });
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(1, 'xclogparser', ['version'], {
|
||||
stdio: 'pipe',
|
||||
env: TEST_ENV,
|
||||
});
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
1,
|
||||
'unzip',
|
||||
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'), '-d', tempDir],
|
||||
{ stdio: 'pipe', env: TEST_ENV }
|
||||
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'), '-d', tempDir],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
2,
|
||||
path.join(tempDir, 'xclogparser'),
|
||||
[
|
||||
'parse',
|
||||
@@ -276,22 +212,18 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
'/tmp/workspace',
|
||||
'--reporter',
|
||||
'json',
|
||||
'--omit_warnings',
|
||||
'--omit_notes',
|
||||
'--trunc_large_issues',
|
||||
'--output',
|
||||
reportPath,
|
||||
],
|
||||
{ stdio: 'pipe', env: TEST_ENV }
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
expect(fs.readFile).toHaveBeenCalledWith(reportPath, 'utf8');
|
||||
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
|
||||
expect(logger.info).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Xcode Build — Compile Metrics by Module')
|
||||
);
|
||||
expect(logger.debug).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tries the proxy URL first and falls back to the direct GCS URL', async () => {
|
||||
@@ -301,7 +233,6 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
mockedDownloadFile
|
||||
.mockRejectedValueOnce(new Error('proxy failed'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
@@ -309,19 +240,18 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
proxyBaseUrl: 'https://cache.example.com',
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
@@ -337,7 +267,6 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('xclogparser not found'))
|
||||
.mockRejectedValueOnce(new Error('invalid zip'))
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any);
|
||||
@@ -347,19 +276,18 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
proxyBaseUrl: 'https://cache.example.com',
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
@@ -372,12 +300,11 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports phase "parsing_xclogparser_output" when the JSON output is invalid', async () => {
|
||||
it('skips reporting and logs debug when the JSON output is invalid', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem({ jsonOutput: '{not valid json' });
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await expect(
|
||||
@@ -385,20 +312,18 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
})
|
||||
).resolves.toEqual({ skipped: true });
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "parsing_xclogparser_output"',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ tags: { phase: 'parsing_xclogparser_output' } })
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to analyze build performance; continuing without a report'
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports phase "parsing_xclogparser_output" when the JSON output does not match the expected schema', async () => {
|
||||
it('skips reporting and logs debug when the JSON output does not match the expected schema', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem({
|
||||
jsonOutput: JSON.stringify({
|
||||
@@ -408,7 +333,6 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
});
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await expect(
|
||||
@@ -416,211 +340,15 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
})
|
||||
).resolves.toEqual({ skipped: true });
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to analyze build performance; continuing without a report'
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "parsing_xclogparser_output"',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ tags: { phase: 'parsing_xclogparser_output' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports phase "running_xclogparser" when the xclogparser invocation fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('xclogparser not found')) // version detect
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
|
||||
.mockRejectedValueOnce(new Error('spawn xclogparser ENOENT')); // xclogparser run
|
||||
|
||||
await expect(
|
||||
parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
})
|
||||
).resolves.toEqual({ skipped: true });
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "running_xclogparser"',
|
||||
expect.objectContaining({ message: 'spawn xclogparser ENOENT' }),
|
||||
expect.objectContaining({ tags: { phase: 'running_xclogparser' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces spawn error stderr/stdout/status/signal as Sentry extras', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
const spawnError: any = new Error('xclogparser exited with non-zero code: 1');
|
||||
spawnError.stderr = 'XCLogParser fatal: missing xcactivitylog\n';
|
||||
spawnError.stdout = 'some stdout';
|
||||
spawnError.status = 1;
|
||||
spawnError.signal = null;
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('xclogparser not found')) // version detect
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
|
||||
.mockRejectedValueOnce(spawnError); // xclogparser run
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "running_xclogparser"',
|
||||
expect.any(Error),
|
||||
{
|
||||
tags: { phase: 'running_xclogparser' },
|
||||
extras: {
|
||||
exitStatus: 1,
|
||||
signal: null,
|
||||
stderr: 'XCLogParser fatal: missing xcactivitylog\n',
|
||||
stdout: 'some stdout',
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('skips with actionable guidance when Logs/Build has no .xcactivitylog files', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem({ buildLogEntries: [] });
|
||||
|
||||
const result = await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ skipped: true });
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Build performance analysis skipped: no .xcactivitylog files found at ${path.join(
|
||||
'/tmp/derived-data',
|
||||
'Logs',
|
||||
'Build'
|
||||
)}.`
|
||||
)
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('custom ios/Gymfile'));
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('derived_data_path("./build")')
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('result_bundle(true)'));
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('result_bundle_path("./build/result-bundle.xcresult")')
|
||||
);
|
||||
});
|
||||
|
||||
it('skips when Logs/Build directory does not exist', async () => {
|
||||
const logger = createMockLogger();
|
||||
const enoent: any = new Error('ENOENT');
|
||||
enoent.code = 'ENOENT';
|
||||
mockFilesystem({ buildLogEntries: enoent });
|
||||
|
||||
const result = await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ skipped: true });
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Build performance analysis skipped: no .xcactivitylog files found')
|
||||
);
|
||||
});
|
||||
|
||||
it('captures to Sentry and skips when readdir fails with a non-ENOENT error', async () => {
|
||||
const logger = createMockLogger();
|
||||
const eacces: any = new Error('EACCES: permission denied');
|
||||
eacces.code = 'EACCES';
|
||||
mockFilesystem({ buildLogEntries: eacces });
|
||||
|
||||
const result = await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ skipped: true });
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "checking_xcactivitylog_existence"',
|
||||
expect.objectContaining({ code: 'EACCES' }),
|
||||
expect.objectContaining({
|
||||
tags: { phase: 'checking_xcactivitylog_existence' },
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
|
||||
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('custom ios/Gymfile'));
|
||||
});
|
||||
|
||||
it('ignores non-xcactivitylog entries in Logs/Build when deciding to skip', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem({ buildLogEntries: ['LogStoreManifest.plist', 'some-other-file.txt'] });
|
||||
|
||||
const result = await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ skipped: true });
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Build performance analysis skipped: no .xcactivitylog files found')
|
||||
);
|
||||
});
|
||||
|
||||
it('reports phase "downloading_xclogparser" when the direct download fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockRejectedValue(new Error('network unreachable'));
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await expect(
|
||||
parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
})
|
||||
).resolves.toEqual({ skipped: true });
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "downloading_xclogparser"',
|
||||
expect.objectContaining({ message: 'network unreachable' }),
|
||||
expect.objectContaining({ tags: { phase: 'downloading_xclogparser' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows cleanup failures without emitting additional logs', async () => {
|
||||
@@ -631,7 +359,6 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
});
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await expect(
|
||||
@@ -639,174 +366,13 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
})
|
||||
).resolves.toEqual({ skipped: false });
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledTimes(1);
|
||||
expect(logger.debug).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('xclogparser version resolution', () => {
|
||||
it('uses preinstalled binary when no version is requested', async () => {
|
||||
const logger = createMockLogger();
|
||||
const { reportPath } = mockFilesystem();
|
||||
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.50\n', stderr: '' } as any) // version detect
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith('unzip', expect.any(Array), expect.any(Object));
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'xclogparser',
|
||||
[
|
||||
'parse',
|
||||
'--derived_data',
|
||||
'/tmp/derived-data',
|
||||
'--workspace',
|
||||
'/tmp/workspace',
|
||||
'--reporter',
|
||||
'json',
|
||||
'--omit_warnings',
|
||||
'--omit_notes',
|
||||
'--trunc_large_issues',
|
||||
'--output',
|
||||
reportPath,
|
||||
],
|
||||
{ stdio: 'pipe', env: TEST_ENV }
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Using preinstalled xclogparser v0.2.50.');
|
||||
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('downloaded'));
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads default version when no version requested and no preinstall found', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
|
||||
expect.any(String),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
|
||||
});
|
||||
|
||||
it('uses preinstalled binary when requested version matches', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.50\n', stderr: '' } as any) // version detect
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
xclogparserVersion: 'v0.2.50',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith('Using preinstalled xclogparser v0.2.50.');
|
||||
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('downloaded'));
|
||||
});
|
||||
|
||||
it('downloads requested version when it does not match the preinstalled binary', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.48\n', stderr: '' } as any) // version detect
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
xclogparserVersion: 'v0.2.50',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.50.zip'),
|
||||
expect.any(String),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.50.');
|
||||
});
|
||||
|
||||
it('downloads requested version when no preinstall is found', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
xclogparserVersion: 'v0.2.50',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.50.zip'),
|
||||
expect.any(String),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.50.');
|
||||
});
|
||||
|
||||
it('falls back to download when the version output cannot be parsed', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({ stdout: 'something unexpected', stderr: '' } as any) // version detect
|
||||
.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
|
||||
expect.any(String),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCompileStep', () => {
|
||||
@@ -912,38 +478,17 @@ describe('buildTargetMetrics', () => {
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod!.taskSeconds).toBeCloseTo(3.0);
|
||||
expect(mod!.wallSpan).toBe(0); // no valid intervals
|
||||
expect(mod!.activeWallTime).toBe(0); // no valid intervals
|
||||
});
|
||||
|
||||
it('computes activeWallTime by merging overlapping intervals and excluding gaps', () => {
|
||||
const { results } = buildTargetMetrics(FIXTURE_OVERLAP_WITH_GAP);
|
||||
const mod = results.find(r => r.moduleName === 'GappedModule');
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod!.taskSeconds).toBeCloseTo(11.0); // 5 + 4 + 2
|
||||
expect(mod!.wallSpan).toBeCloseTo(12.0); // 112 - 100
|
||||
expect(mod!.activeWallTime).toBeCloseTo(8.0); // (106-100) + (112-110)
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatReport', () => {
|
||||
it('produces table with header, legend, and module rows', () => {
|
||||
it('produces table with header and module rows', () => {
|
||||
const report = formatReport(FIXTURE_TWO_MODULES);
|
||||
expect(report).toContain('Xcode Build — Compile Metrics by Module');
|
||||
expect(report).toContain('ModuleA');
|
||||
expect(report).toContain('ModuleB');
|
||||
expect(report).toContain('TOTAL');
|
||||
expect(report).toContain('% Sum');
|
||||
expect(report).toContain('Sum = sum of compile-step wall durations');
|
||||
expect(report).toContain('Active = merged compile-step wall time');
|
||||
expect(report).not.toContain('% Task');
|
||||
expect(report).not.toContain('Wall = ');
|
||||
});
|
||||
|
||||
it('renders Active using activeWallTime, not wallSpan', () => {
|
||||
const report = formatReport(FIXTURE_OVERLAP_WITH_GAP);
|
||||
expect(report).toContain('11.0s');
|
||||
expect(report).toContain('8.0s');
|
||||
expect(report).not.toContain('12.0s');
|
||||
expect(report).toContain('% Task');
|
||||
});
|
||||
|
||||
it('does not include modules below threshold', () => {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import path from 'path';
|
||||
|
||||
import { XcodeBuildLogger } from './xcpretty';
|
||||
import { COMMON_FASTLANE_ENV } from '../../../common/fastlane';
|
||||
import { XcodeBuildLogger } from '../../../common/xcpretty';
|
||||
|
||||
export async function runFastlaneGym({
|
||||
workingDir,
|
||||
|
||||
@@ -1,126 +1,58 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../../../sentry';
|
||||
|
||||
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.47';
|
||||
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.46';
|
||||
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
|
||||
const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
|
||||
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
|
||||
|
||||
/**
|
||||
* Catches all internal failures (logged + routed to Sentry); returns
|
||||
* `{ skipped: true }` when analysis did not produce a report so callers can
|
||||
* mark the build phase skipped.
|
||||
* Download xclogparser, parse xcactivitylog from derived data, and log a
|
||||
* compile metrics report. Never throws — all errors are logged at debug level.
|
||||
*
|
||||
* Can be called from both the step-based flow (BuildFunction) and the
|
||||
* traditional builder flow (runBuildPhase).
|
||||
*/
|
||||
export async function parseAndReportXcactivitylog({
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
xclogparserVersion,
|
||||
xclogparserVersion = DEFAULT_XCLOGPARSER_VERSION,
|
||||
logger,
|
||||
proxyBaseUrl,
|
||||
env,
|
||||
}: {
|
||||
derivedDataPath: string;
|
||||
workspacePath: string;
|
||||
xclogparserVersion?: string;
|
||||
logger: bunyan;
|
||||
proxyBaseUrl?: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<{ skipped: boolean }> {
|
||||
}): Promise<void> {
|
||||
let tempDir: string | undefined;
|
||||
let phase = 'checking_xcactivitylog_existence';
|
||||
try {
|
||||
const logsBuildDir = path.join(derivedDataPath, 'Logs', 'Build');
|
||||
let buildLogEntries: string[];
|
||||
try {
|
||||
buildLogEntries = await fs.readdir(logsBuildDir);
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
buildLogEntries = [];
|
||||
}
|
||||
const hasActivityLog = buildLogEntries.some(entry => entry.endsWith('.xcactivitylog'));
|
||||
if (!hasActivityLog) {
|
||||
logger.info(
|
||||
[
|
||||
`Build performance analysis skipped: no .xcactivitylog files found at ${logsBuildDir}.`,
|
||||
'',
|
||||
'This typically happens when your project has a custom ios/Gymfile. To enable',
|
||||
'build performance analysis, add the following lines to your Gymfile:',
|
||||
'',
|
||||
' derived_data_path("./build")',
|
||||
' result_bundle(true)',
|
||||
' result_bundle_path("./build/result-bundle.xcresult")',
|
||||
].join('\n')
|
||||
);
|
||||
return { skipped: true };
|
||||
}
|
||||
|
||||
phase = 'creating_temp_directory';
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xclogparser-'));
|
||||
|
||||
phase = 'resolving_xclogparser';
|
||||
const preinstalledVersion = await detectPreinstalledXclogparserVersion(env);
|
||||
let binaryPath: string;
|
||||
let resolvedVersion: string;
|
||||
if (
|
||||
preinstalledVersion &&
|
||||
(!xclogparserVersion || preinstalledVersion === xclogparserVersion)
|
||||
) {
|
||||
binaryPath = 'xclogparser';
|
||||
resolvedVersion = preinstalledVersion;
|
||||
logger.info(`Using preinstalled xclogparser ${resolvedVersion}.`);
|
||||
} else {
|
||||
phase = 'downloading_xclogparser';
|
||||
resolvedVersion = xclogparserVersion ?? DEFAULT_XCLOGPARSER_VERSION;
|
||||
binaryPath = await downloadXclogparser({
|
||||
tempDir,
|
||||
version: resolvedVersion,
|
||||
logger,
|
||||
proxyBaseUrl,
|
||||
env,
|
||||
});
|
||||
logger.info(`Using downloaded xclogparser ${resolvedVersion}.`);
|
||||
}
|
||||
|
||||
phase = 'running_xclogparser';
|
||||
const xclogparserPath = await downloadXclogparser(
|
||||
tempDir,
|
||||
xclogparserVersion,
|
||||
logger,
|
||||
proxyBaseUrl
|
||||
);
|
||||
const jsonOutputPath = await runXclogparser({
|
||||
binaryPath,
|
||||
binaryPath: xclogparserPath,
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
outputDir: tempDir,
|
||||
env,
|
||||
});
|
||||
|
||||
phase = 'parsing_xclogparser_output';
|
||||
const data = XcactivitylogDataSchemaZ.parse(
|
||||
JSON.parse(await fs.readFile(jsonOutputPath, 'utf8'))
|
||||
);
|
||||
|
||||
logger.info(formatReport(data));
|
||||
return { skipped: false };
|
||||
} catch (err: any) {
|
||||
logger.info('Build performance analysis skipped.');
|
||||
const msg = `Build performance analysis failed during "${phase}"`;
|
||||
Sentry.capture(msg, err, {
|
||||
tags: { phase },
|
||||
extras: {
|
||||
exitStatus: err?.status,
|
||||
signal: err?.signal,
|
||||
stderr: err?.stderr?.slice(-4000),
|
||||
stdout: err?.stdout?.slice(-4000),
|
||||
},
|
||||
});
|
||||
return { skipped: true };
|
||||
} catch (err: unknown) {
|
||||
logger.debug({ err }, 'Failed to analyze build performance; continuing without a report');
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
|
||||
@@ -128,28 +60,12 @@ export async function parseAndReportXcactivitylog({
|
||||
}
|
||||
}
|
||||
|
||||
async function detectPreinstalledXclogparserVersion(env: BuildStepEnv): Promise<string | null> {
|
||||
const result = await asyncResult(spawn('xclogparser', ['version'], { stdio: 'pipe', env }));
|
||||
if (!result.ok) {
|
||||
return null;
|
||||
}
|
||||
const match = result.value.stdout.match(/(\d+\.\d+\.\d+)/);
|
||||
return match ? `v${match[1]}` : null;
|
||||
}
|
||||
|
||||
async function downloadXclogparser({
|
||||
tempDir,
|
||||
version,
|
||||
logger,
|
||||
proxyBaseUrl,
|
||||
env,
|
||||
}: {
|
||||
tempDir: string;
|
||||
version: string;
|
||||
logger: bunyan;
|
||||
proxyBaseUrl?: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
async function downloadXclogparser(
|
||||
tempDir: string,
|
||||
version: string,
|
||||
logger: bunyan,
|
||||
proxyBaseUrl?: string
|
||||
): Promise<string> {
|
||||
const zipName = getXclogparserZipName(version);
|
||||
const zipPath = path.join(tempDir, zipName);
|
||||
const directUrl = `${XCLOGPARSER_DOWNLOAD_URL}/${zipName}`;
|
||||
@@ -157,7 +73,7 @@ async function downloadXclogparser({
|
||||
|
||||
if (proxiedUrl) {
|
||||
const proxiedDownloadResult = await asyncResult(
|
||||
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl, env })
|
||||
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl })
|
||||
);
|
||||
if (!proxiedDownloadResult.ok) {
|
||||
logger.debug(
|
||||
@@ -170,35 +86,31 @@ async function downloadXclogparser({
|
||||
}
|
||||
}
|
||||
|
||||
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl, env });
|
||||
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl });
|
||||
}
|
||||
|
||||
async function downloadAndUnpackXclogparser({
|
||||
tempDir,
|
||||
zipPath,
|
||||
sourceUrl,
|
||||
env,
|
||||
}: {
|
||||
tempDir: string;
|
||||
zipPath: string;
|
||||
sourceUrl: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
await downloadFile(sourceUrl, zipPath, { retry: 3, timeout: XCLOGPARSER_DOWNLOAD_TIMEOUT_MS });
|
||||
|
||||
return await unpackXclogparser({ tempDir, zipPath, env });
|
||||
return await unpackXclogparser({ tempDir, zipPath });
|
||||
}
|
||||
|
||||
async function unpackXclogparser({
|
||||
tempDir,
|
||||
zipPath,
|
||||
env,
|
||||
}: {
|
||||
tempDir: string;
|
||||
zipPath: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe', env });
|
||||
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe' });
|
||||
|
||||
const binaryPath = path.join(tempDir, 'xclogparser');
|
||||
await fs.chmod(binaryPath, 0o755);
|
||||
@@ -221,13 +133,11 @@ async function runXclogparser({
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
outputDir,
|
||||
env,
|
||||
}: {
|
||||
binaryPath: string;
|
||||
derivedDataPath: string;
|
||||
workspacePath: string;
|
||||
outputDir: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const outputPath = path.join(outputDir, XCLOGPARSER_OUTPUT_FILENAME);
|
||||
|
||||
@@ -241,13 +151,10 @@ async function runXclogparser({
|
||||
workspacePath,
|
||||
'--reporter',
|
||||
'json',
|
||||
'--omit_warnings',
|
||||
'--omit_notes',
|
||||
'--trunc_large_issues',
|
||||
'--output',
|
||||
outputPath,
|
||||
],
|
||||
{ stdio: 'pipe', env }
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
|
||||
return outputPath;
|
||||
@@ -276,7 +183,6 @@ type XcactivitylogData = z.infer<typeof XcactivitylogDataSchemaZ>;
|
||||
interface TargetMetric {
|
||||
moduleName: string;
|
||||
taskSeconds: number;
|
||||
// Not rendered; retained as secondary sort tie-breaker in buildTargetMetrics.
|
||||
wallSpan: number;
|
||||
activeWallTime: number;
|
||||
}
|
||||
@@ -452,22 +358,19 @@ export function formatReport(data: XcactivitylogData): string {
|
||||
lines.push(
|
||||
`Schema: ${typeof data.schema === 'string' ? data.schema : (data.schema?.name ?? 'unknown')}`
|
||||
);
|
||||
lines.push(
|
||||
'Sum = sum of compile-step wall durations within the target; overlapping steps are counted separately'
|
||||
);
|
||||
lines.push('% Sum = share of total Sum');
|
||||
lines.push('Active = merged compile-step wall time, excluding idle gaps between compile steps');
|
||||
lines.push('% Task = share of total Compile Task Seconds');
|
||||
lines.push('Wall = first compile start to last compile end');
|
||||
lines.push('');
|
||||
lines.push(header);
|
||||
lines.push(
|
||||
'│ ' +
|
||||
'Module'.padEnd(nameWidth) +
|
||||
' │ ' +
|
||||
'Sum'.padStart(taskWidth) +
|
||||
'Task'.padStart(taskWidth) +
|
||||
' │ ' +
|
||||
'% Sum'.padStart(pctWidth) +
|
||||
'% Task'.padStart(pctWidth) +
|
||||
' │ ' +
|
||||
'Active'.padStart(wallWidth) +
|
||||
'Wall'.padStart(wallWidth) +
|
||||
' │ ' +
|
||||
' '.repeat(barMaxWidth) +
|
||||
' │'
|
||||
@@ -487,7 +390,7 @@ export function formatReport(data: XcactivitylogData): string {
|
||||
' │ ' +
|
||||
`${pct.toFixed(1)}%`.padStart(pctWidth) +
|
||||
' │ ' +
|
||||
formatSeconds(result.activeWallTime).padStart(wallWidth) +
|
||||
formatSeconds(result.wallSpan).padStart(wallWidth) +
|
||||
' │ ' +
|
||||
bar +
|
||||
' │'
|
||||
@@ -503,7 +406,7 @@ export function formatReport(data: XcactivitylogData): string {
|
||||
' │ ' +
|
||||
'100.0%'.padStart(pctWidth) +
|
||||
' │ ' +
|
||||
' '.repeat(wallWidth) +
|
||||
'n/a'.padStart(wallWidth) +
|
||||
' │ ' +
|
||||
' '.repeat(barMaxWidth) +
|
||||
' │'
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawnAsync, { SpawnPromise, SpawnResult } from '@expo/spawn-async';
|
||||
import { ExpoRunFormatter } from '@expo/xcpretty';
|
||||
import assert from 'assert';
|
||||
import fg from 'fast-glob';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
const CHECK_FILE_INTERVAL_MS = 1000;
|
||||
|
||||
export class XcodeBuildLogger {
|
||||
private loggerError?: Error;
|
||||
private flushing: boolean = false;
|
||||
private logReaderPromise?: SpawnPromise<SpawnResult>;
|
||||
private logsPath?: string;
|
||||
|
||||
constructor(
|
||||
private readonly logger: bunyan,
|
||||
private readonly projectRoot: string
|
||||
) {}
|
||||
|
||||
public async watchLogFiles(logsDirectory: string): Promise<void> {
|
||||
while (!this.flushing) {
|
||||
const logsFilename = await this.getBuildLogFilename(logsDirectory);
|
||||
if (logsFilename) {
|
||||
this.logsPath = path.join(logsDirectory, logsFilename);
|
||||
void this.startBuildLogger(this.logsPath);
|
||||
return;
|
||||
}
|
||||
await new Promise(res => setTimeout(res, CHECK_FILE_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
public async flush(): Promise<void> {
|
||||
this.flushing = true;
|
||||
if (this.loggerError) {
|
||||
throw this.loggerError;
|
||||
}
|
||||
if (this.logReaderPromise) {
|
||||
this.logReaderPromise.child.kill('SIGINT');
|
||||
try {
|
||||
await this.logReaderPromise;
|
||||
} catch {}
|
||||
}
|
||||
if (this.logsPath) {
|
||||
await this.findBundlerErrors(this.logsPath);
|
||||
}
|
||||
}
|
||||
private async getBuildLogFilename(logsDirectory: string): Promise<string | undefined> {
|
||||
const paths = await fg('*.log', { cwd: logsDirectory });
|
||||
return paths.length >= 1 ? paths[0] : undefined;
|
||||
}
|
||||
|
||||
private async startBuildLogger(logsPath: string): Promise<void> {
|
||||
try {
|
||||
const formatter = ExpoRunFormatter.create(this.projectRoot, {
|
||||
// TODO: Can provide xcode project name for better parsing
|
||||
isDebug: false,
|
||||
});
|
||||
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) => {
|
||||
const lines = formatter.pipe(data.toString());
|
||||
for (const line of lines) {
|
||||
this.logger.info(line);
|
||||
}
|
||||
});
|
||||
await this.logReaderPromise;
|
||||
|
||||
this.logger.info(formatter.getBuildSummary());
|
||||
} catch (err: any) {
|
||||
if (!this.flushing) {
|
||||
this.loggerError = err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async findBundlerErrors(logsPath: string): Promise<void> {
|
||||
try {
|
||||
const logFile = await fs.readFile(logsPath, 'utf-8');
|
||||
const match = logFile.match(
|
||||
/Welcome to Metro!\s* Fast - Scalable - Integrated\s*([\s\S]*)Run CLI with --verbose flag for more details.\nCommand PhaseScriptExecution failed with a nonzero exit code/
|
||||
);
|
||||
if (match) {
|
||||
this.logger.info(match[1]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({ err }, 'Failed to read Xcode logs');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv, spawnAsync } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { graphql } from 'gql.tada';
|
||||
import nullthrows from 'nullthrows';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
||||
deviceRunSession {
|
||||
startDeviceRunSession(
|
||||
deviceRunSessionId: $deviceRunSessionId
|
||||
remoteConfig: $remoteConfig
|
||||
) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string {
|
||||
const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID;
|
||||
if (!deviceRunSessionId) {
|
||||
throw new SystemError(
|
||||
'DEVICE_RUN_SESSION_ID is not set. ' +
|
||||
'This step must run as part of a device run session ' +
|
||||
'which injects DEVICE_RUN_SESSION_ID into the job environment.'
|
||||
);
|
||||
}
|
||||
return deviceRunSessionId;
|
||||
}
|
||||
|
||||
export function getNgrokTunnelDomainOrThrow(env: BuildStepEnv): string {
|
||||
const baseDomain = env.EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN;
|
||||
if (!baseDomain) {
|
||||
throw new SystemError(
|
||||
'EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN is not set. ' +
|
||||
'This step must run as part of a device run session ' +
|
||||
'which injects EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN into the job environment.'
|
||||
);
|
||||
}
|
||||
return baseDomain;
|
||||
}
|
||||
|
||||
export function getNgrokAuthtokenOrThrow(env: BuildStepEnv): string {
|
||||
const authtoken = env.NGROK_AUTHTOKEN;
|
||||
if (!authtoken) {
|
||||
throw new SystemError(
|
||||
'NGROK_AUTHTOKEN is not set. ' +
|
||||
'This step must run as part of a device run session ' +
|
||||
'which injects NGROK_AUTHTOKEN into the job environment.'
|
||||
);
|
||||
}
|
||||
return authtoken;
|
||||
}
|
||||
|
||||
const TurnIceServersSchema = z.array(
|
||||
z.object({
|
||||
urls: z.array(z.string()),
|
||||
username: z.string().optional(),
|
||||
credential: z.string().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
export type TurnIceServers = z.infer<typeof TurnIceServersSchema>;
|
||||
|
||||
export async function selectXcodeDeveloperDirectoryAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
if (process.env.ENVIRONMENT === 'development') {
|
||||
logger.info('Job running outside of EAS, not selecting Xcode developer directory.');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawnAsync('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], {
|
||||
env,
|
||||
logger,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
const TurnIceServersResponseSchema = z.object({
|
||||
data: z.object({
|
||||
iceServers: TurnIceServersSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the
|
||||
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
|
||||
* (the entry carrying the short-lived credentials).
|
||||
*/
|
||||
export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string[] {
|
||||
const stunUrls = iceServers
|
||||
.filter(server => !server.username && !server.credential)
|
||||
.flatMap(server => server.urls);
|
||||
const turnServer = iceServers.find(server => server.username && server.credential);
|
||||
|
||||
const args: string[] = [];
|
||||
if (stunUrls.length > 0) {
|
||||
args.push('--stun-url', stunUrls.join(','));
|
||||
}
|
||||
if (turnServer?.username && turnServer.credential && turnServer.urls.length > 0) {
|
||||
args.push(
|
||||
'--turn-url',
|
||||
turnServer.urls.join(','),
|
||||
'--turn-username',
|
||||
turnServer.username,
|
||||
'--turn-credential',
|
||||
turnServer.credential
|
||||
);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch short-lived Cloudflare TURN ICE servers for this job run from www
|
||||
* (minted on demand, mirroring how the worker fetches project clone URLs) and
|
||||
* translate them into serve-sim CLI flags.
|
||||
*
|
||||
* Best-effort: on any failure we log and return [] so serve-sim falls back to
|
||||
* its built-in P2P/STUN behavior. The credential is passed to serve-sim as a
|
||||
* process arg and deliberately not logged (turtle-spawn never logs argv and the
|
||||
* worker is single-tenant).
|
||||
*/
|
||||
export async function fetchServeSimTurnArgsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const expoApiServerUrl = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
ctx.job.secrets?.robotAccessToken,
|
||||
'robot access token is not set'
|
||||
);
|
||||
|
||||
const response = await turtleFetch(
|
||||
new URL(
|
||||
`/v2/device-run-sessions/${deviceRunSessionId}/turn-ice-servers`,
|
||||
expoApiServerUrl
|
||||
).toString(),
|
||||
'POST',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
timeout: 5000,
|
||||
retries: 1,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const { data } = TurnIceServersResponseSchema.parse(await response.json());
|
||||
const args = turnIceServersToServeSimArgs(data.iceServers);
|
||||
if (args.length > 0) {
|
||||
logger.info('Configured serve-sim with Cloudflare TURN ICE servers.');
|
||||
}
|
||||
return args;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not fetch Cloudflare TURN ICE servers; serve-sim will fall back to P2P/STUN.'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig,
|
||||
logger,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
remoteConfig: Record<string, unknown>;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
logger.info(
|
||||
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
|
||||
);
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(START_DEVICE_RUN_SESSION_MUTATION, { deviceRunSessionId, remoteConfig })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw new SystemError(
|
||||
`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type DetachedProcessHandle = {
|
||||
getOutput: () => string;
|
||||
};
|
||||
|
||||
export function spawnDetached({
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
}: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env: BuildStepEnv;
|
||||
}): DetachedProcessHandle {
|
||||
const promise = spawn(command, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
// We don't await the process — it should outlive this step. Failures show
|
||||
// up in the captured output; suppress unhandled rejections here.
|
||||
promise.catch(() => {});
|
||||
promise.child.unref();
|
||||
|
||||
let output = '';
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
output += chunk.toString();
|
||||
};
|
||||
promise.child.stdout?.on('data', appendChunk);
|
||||
promise.child.stderr?.on('data', appendChunk);
|
||||
|
||||
return { getOutput: () => output };
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
}
|
||||
): Promise<{ previewUrl: string; streamUrl: string }> {
|
||||
logger.info('Launching serve-sim with tunnel.');
|
||||
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
|
||||
const serveSim = spawnDetached({
|
||||
command: 'npx',
|
||||
args: [
|
||||
'serve-sim-szdziedzic@latest',
|
||||
'--tunnel',
|
||||
'--tunnel-provider',
|
||||
'ngrok',
|
||||
'--tunnel-domain',
|
||||
baseDomain,
|
||||
'--stream-max-dimension',
|
||||
'1280',
|
||||
'--stream-quality',
|
||||
'0.55',
|
||||
'--codec',
|
||||
'webrtc',
|
||||
...turnArgs,
|
||||
],
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const output = serveSim.getOutput();
|
||||
const previewUrl = matchLabeledUrl({ output, label: 'Tunnel', baseDomain });
|
||||
const streamUrl = matchLabeledUrl({ output, label: 'Stream', baseDomain });
|
||||
if (previewUrl && streamUrl) {
|
||||
return { previewUrl, streamUrl };
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for serve-sim to report Tunnel and Stream URLs. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
function matchLabeledUrl({
|
||||
output,
|
||||
label,
|
||||
baseDomain,
|
||||
}: {
|
||||
output: string;
|
||||
label: string;
|
||||
baseDomain: string;
|
||||
}): string | null {
|
||||
const labelPattern = new RegExp(
|
||||
`${label}:\\s*(https:\\/\\/[a-z0-9-]+\\.${escapeRegExp(baseDomain)})`
|
||||
);
|
||||
const match = labelPattern.exec(output);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export async function startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix,
|
||||
baseDomain,
|
||||
authtoken,
|
||||
rewriteHostHeader,
|
||||
logger,
|
||||
}: {
|
||||
port: number;
|
||||
subdomainPrefix: string;
|
||||
baseDomain: string;
|
||||
authtoken: string;
|
||||
rewriteHostHeader?: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<string> {
|
||||
const domain = `${subdomainPrefix}-${randomBytes(8).toString('hex')}.${baseDomain}`;
|
||||
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
|
||||
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
|
||||
// the process exits, and the step blocks forever to hold it open.
|
||||
const listener = await ngrok.forward({
|
||||
addr: port,
|
||||
authtoken,
|
||||
domain,
|
||||
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
|
||||
});
|
||||
const url = listener.url();
|
||||
if (!url) {
|
||||
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export async function waitForFileAsync<T>({
|
||||
filePath,
|
||||
timeoutMs,
|
||||
description,
|
||||
parse,
|
||||
}: {
|
||||
filePath: string;
|
||||
timeoutMs: number;
|
||||
description: string;
|
||||
parse: (raw: string) => T;
|
||||
}): Promise<T> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
return parse(raw);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${description} to be ready at ${filePath}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}.`
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ export const FastfileResignTemplate = `lane :do_resign do
|
||||
provisioning_profile: {<% _.forEach(PROFILES, function(profile) { %>
|
||||
"<%- profile.BUNDLE_ID %>" => "<%- profile.PATH %>",<% }); %>
|
||||
},
|
||||
use_app_entitlements: true,
|
||||
keychain_path: "<%- KEYCHAIN_PATH %>"
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ExpoError, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -93,67 +92,6 @@ export namespace IosSimulatorUtils {
|
||||
});
|
||||
}
|
||||
|
||||
export async function enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
|
||||
const device = devices.find(
|
||||
device =>
|
||||
device.isAvailable &&
|
||||
(device.udid === deviceIdentifier || device.name === deviceIdentifier)
|
||||
);
|
||||
if (!device) {
|
||||
throw new UserError(
|
||||
'EAS_IOS_SIMULATOR_NOT_FOUND',
|
||||
`Failed to find available iOS Simulator "${deviceIdentifier}" to update accessibility settings.`
|
||||
);
|
||||
}
|
||||
if (device.state !== 'Shutdown') {
|
||||
throw new UserError(
|
||||
'EAS_IOS_SIMULATOR_NOT_SHUTDOWN',
|
||||
`Expected iOS Simulator "${deviceIdentifier}" to be shutdown before updating accessibility settings, but it is ${device.state}.`
|
||||
);
|
||||
}
|
||||
|
||||
const plistPath = path.join(
|
||||
device.dataPath,
|
||||
'Library',
|
||||
'Preferences',
|
||||
'com.apple.Accessibility.plist'
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(plistPath), { recursive: true });
|
||||
|
||||
const plistExists = await fs.promises
|
||||
.access(plistPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!plistExists) {
|
||||
await spawn('plutil', ['-create', 'binary1', plistPath], { env });
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
'AutomationEnabled',
|
||||
'IgnoreAXServerEntitlements',
|
||||
'AccessibilityEnabled',
|
||||
'ApplicationAccessibilityEnabled',
|
||||
]) {
|
||||
await spawn('plutil', ['-replace', key, '-bool', 'true', plistPath], { env });
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ExpoError) {
|
||||
throw err;
|
||||
}
|
||||
throw new SystemError('Failed to update iOS Simulator accessibility settings.', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
@@ -184,10 +122,9 @@ export namespace IosSimulatorUtils {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
const readinessScreenshotPath = path.join(os.tmpdir(), 'eas-simulator-readiness.png');
|
||||
await retryAsync(
|
||||
async () => {
|
||||
await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', readinessScreenshotPath], {
|
||||
await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', '/dev/null'], {
|
||||
env,
|
||||
});
|
||||
},
|
||||
@@ -199,7 +136,6 @@ export namespace IosSimulatorUtils {
|
||||
},
|
||||
}
|
||||
);
|
||||
await fs.promises.rm(readinessScreenshotPath, { force: true });
|
||||
|
||||
// Wait for data migration to complete before declaring the simulator ready
|
||||
// Based on WebKit's approach: https://trac.webkit.org/changeset/231452/webkit
|
||||
@@ -219,25 +155,6 @@ export namespace IosSimulatorUtils {
|
||||
);
|
||||
}
|
||||
|
||||
export async function disableApsdAsync({
|
||||
udid,
|
||||
env,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
await spawn(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', udid, 'launchctl', 'disable', 'system/com.apple.apsd'],
|
||||
{ env }
|
||||
);
|
||||
await spawn(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', udid, 'launchctl', 'bootout', 'system/com.apple.apsd'],
|
||||
{ env }
|
||||
);
|
||||
}
|
||||
|
||||
export async function collectLogsAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { IosSimulatorUtils } from '../IosSimulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../retry', () => ({
|
||||
retryAsync: jest.fn(async fn => await fn(0)),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
describe('IosSimulatorUtils', () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.startAsync, () => {
|
||||
it('waits for boot completion without writing accessibility prefs', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'xcrun' && args.join(' ') === 'simctl bootstatus test-udid -b') {
|
||||
return {
|
||||
stdout: 'Monitoring boot status for iPhone 15 (test-udid).\n',
|
||||
stderr: '',
|
||||
} as any;
|
||||
}
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}) as any);
|
||||
|
||||
await IosSimulatorUtils.startAsync({
|
||||
deviceIdentifier: 'test-udid' as any,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'list', 'devices', '--json', '--no-escape-slashes', 'test-udid'],
|
||||
expect.anything()
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith('plutil', expect.anything(), expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.enableAccessibilitySettingsAsync, () => {
|
||||
function device(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
dataPath: '/tmp/eas-test-simulator-data',
|
||||
dataPathSize: 1,
|
||||
logPath: '/tmp/eas-test-simulator-logs',
|
||||
udid: 'test-udid',
|
||||
isAvailable: true,
|
||||
deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15',
|
||||
state: 'Shutdown',
|
||||
name: 'iPhone 15',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockAvailableDevices(devices: Record<string, unknown>[]) {
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (
|
||||
command === 'xcrun' &&
|
||||
args.join(' ') === 'simctl list devices --json --no-escape-slashes available'
|
||||
) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
devices: {
|
||||
'com.apple.CoreSimulator.SimRuntime.iOS-18-0': devices,
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
} as any;
|
||||
}
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}) as any);
|
||||
}
|
||||
|
||||
it('writes accessibility prefs for an exact shutdown simulator match', async () => {
|
||||
mockAvailableDevices([
|
||||
device({
|
||||
udid: 'test-udid-pro',
|
||||
name: 'iPhone 17 Pro',
|
||||
dataPath: '/tmp/eas-test-simulator-data-pro',
|
||||
}),
|
||||
device({ name: 'iPhone 17' }),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: 'iPhone 17' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
for (const key of [
|
||||
'AutomationEnabled',
|
||||
'IgnoreAXServerEntitlements',
|
||||
'AccessibilityEnabled',
|
||||
'ApplicationAccessibilityEnabled',
|
||||
]) {
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'plutil',
|
||||
[
|
||||
'-replace',
|
||||
key,
|
||||
'-bool',
|
||||
'true',
|
||||
'/tmp/eas-test-simulator-data/Library/Preferences/com.apple.Accessibility.plist',
|
||||
],
|
||||
{ env: process.env }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws UserError when no available simulator exactly matches the identifier', async () => {
|
||||
mockAvailableDevices([device({ name: 'iPhone 17 Pro' })]);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: 'iPhone 17' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow(UserError);
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith('plutil', expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
it('throws UserError when the matched simulator is already booted', async () => {
|
||||
mockAvailableDevices([device({ state: 'Booted' })]);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: 'test-udid' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws SystemError when pre-boot accessibility setup fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(new Error('Failed to list simulators'));
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: 'test-udid' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow(SystemError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.waitForReadyAsync, () => {
|
||||
it('takes the readiness screenshot into a writable temp file, not /dev/null', async () => {
|
||||
await IosSimulatorUtils.waitForReadyAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'io',
|
||||
'test-udid',
|
||||
'screenshot',
|
||||
path.join(os.tmpdir(), 'eas-simulator-readiness.png'),
|
||||
],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.disableApsdAsync, () => {
|
||||
it('disables and boots out apsd in the simulator', async () => {
|
||||
await IosSimulatorUtils.disableApsdAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', 'test-udid', 'launchctl', 'disable', 'system/com.apple.apsd'],
|
||||
{ env: process.env }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', 'test-udid', 'launchctl', 'bootout', 'system/com.apple.apsd'],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -71,26 +71,6 @@ describe(findArtifacts, () => {
|
||||
expect(paths.length).toBe(2);
|
||||
});
|
||||
|
||||
test('with absolute glob pattern', async () => {
|
||||
await fs.mkdirp('/tmp');
|
||||
await fs.mkdirp('/tmp/maestro_xctestrunner_xcodebuild_output123');
|
||||
await fs.mkdirp('/tmp/maestro_xctestrunner_xcodebuild_output456');
|
||||
const loggerMock = {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
const paths = await findArtifacts({
|
||||
rootDir: '/Users/expo/build',
|
||||
patternOrPath: '/tmp/maestro_xctestrunner_xcodebuild_output*',
|
||||
logger: loggerMock as any,
|
||||
});
|
||||
expect(loggerMock.error).toHaveBeenCalledTimes(0);
|
||||
expect(paths).toEqual([
|
||||
'/tmp/maestro_xctestrunner_xcodebuild_output123',
|
||||
'/tmp/maestro_xctestrunner_xcodebuild_output456',
|
||||
]);
|
||||
});
|
||||
|
||||
test('with missing file in empty directory', async () => {
|
||||
await fs.mkdirp('/dir1/dir2/dir3');
|
||||
let errMsg = '';
|
||||
|
||||
@@ -104,33 +104,4 @@ describe(expoUpdates.configureExpoUpdatesIfInstalledAsync, () => {
|
||||
expect(iosSetChannelNativelyAsync).toBeCalledTimes(1);
|
||||
expect(getExpoUpdatesPackageVersionIfInstalledAsync).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not fail on missing EAS_BUILD_ID when logging fingerprint diffs', async () => {
|
||||
jest.mocked(getExpoUpdatesPackageVersionIfInstalledAsync).mockResolvedValue('0.18.0');
|
||||
|
||||
const warn = jest.fn();
|
||||
const managedCtx: BuildContext<BuildJob> = {
|
||||
appConfig: {},
|
||||
env: {},
|
||||
job: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
logger: { warn },
|
||||
metadata: {
|
||||
runtimeVersion: 'runtime-version-from-local-machine',
|
||||
},
|
||||
getReactNativeProjectDirectory: () => '/app',
|
||||
} as any;
|
||||
|
||||
await expect(
|
||||
expoUpdates.configureExpoUpdatesIfInstalledAsync(managedCtx, {
|
||||
resolvedRuntimeVersion: 'runtime-version-from-eas-build',
|
||||
resolvedFingerprintSources: [],
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Runtime version calculated on local machine not equal to runtime version calculated during build.'
|
||||
);
|
||||
|
||||
expect(warn).toHaveBeenCalledWith('Skipping fingerprint diff because EAS_BUILD_ID is not set');
|
||||
});
|
||||
});
|
||||
@@ -1,273 +0,0 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { BuildContext } from '../../context';
|
||||
import * as expoUpdates from '../expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../expoUpdatesEmbedded';
|
||||
import * as easCli from '../easCli';
|
||||
import * as artifacts from '../artifacts';
|
||||
|
||||
jest.mock('../expoUpdates');
|
||||
jest.mock('../easCli');
|
||||
jest.mock('../artifacts');
|
||||
|
||||
const mockZipEntries = jest.fn();
|
||||
const mockZipExtract = jest.fn();
|
||||
const mockZipClose = jest.fn();
|
||||
|
||||
jest.mock('node-stream-zip', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
async: jest.fn(() => ({
|
||||
entries: mockZipEntries,
|
||||
extract: mockZipExtract,
|
||||
close: mockZipClose,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
function zipEntryMap(entries: Record<string, true>): Record<string, { name: string }> {
|
||||
return Object.fromEntries(Object.keys(entries).map(name => [name, { name }]));
|
||||
}
|
||||
|
||||
function makeCtx(overrides: {
|
||||
platform: Platform;
|
||||
simulator?: boolean;
|
||||
channel?: string;
|
||||
env?: Record<string, string>;
|
||||
}): BuildContext<any> {
|
||||
const job =
|
||||
overrides.platform === Platform.IOS
|
||||
? {
|
||||
platform: Platform.IOS,
|
||||
simulator: overrides.simulator ?? false,
|
||||
updates: overrides.channel ? { channel: overrides.channel } : undefined,
|
||||
}
|
||||
: {
|
||||
platform: Platform.ANDROID,
|
||||
updates: overrides.channel ? { channel: overrides.channel } : undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
job,
|
||||
env: overrides.env ?? {},
|
||||
appConfig: Promise.resolve({
|
||||
updates: { url: 'https://u.expo.dev/project-id' },
|
||||
}),
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
markBuildPhaseSkipped: jest.fn(),
|
||||
markBuildPhaseHasWarnings: jest.fn(),
|
||||
getReactNativeProjectDirectory: () => '/project',
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('uploadEmbeddedBundleAsync', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(true);
|
||||
jest.mocked(easCli.runEasCliCommand).mockResolvedValue({} as any);
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
|
||||
mockZipEntries.mockResolvedValue({});
|
||||
mockZipExtract.mockResolvedValue(undefined);
|
||||
mockZipClose.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('skips when EAS Update is not configured', async () => {
|
||||
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(false);
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
|
||||
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns when no channel is configured and does not look for the archive', async () => {
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
'Skipping embedded bundle upload: no channel configured for this build profile.'
|
||||
);
|
||||
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
|
||||
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws for an unsupported platform', async () => {
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
(ctx.job as { platform: string }).platform = 'web';
|
||||
|
||||
await expect(uploadEmbeddedBundleAsync(ctx)).rejects.toThrow(
|
||||
'Uploading embedded updates is not supported for the web platform.'
|
||||
);
|
||||
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads from Android APK archives', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
|
||||
mockZipEntries.mockResolvedValue(
|
||||
zipEntryMap({
|
||||
'assets/index.android.bundle': true,
|
||||
'assets/app.manifest': true,
|
||||
})
|
||||
);
|
||||
const ctx = makeCtx({
|
||||
platform: Platform.ANDROID,
|
||||
channel: 'production',
|
||||
env: { EAS_BUILD_ID: 'build-123' },
|
||||
});
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(mockZipExtract).toHaveBeenCalledWith(
|
||||
'assets/index.android.bundle',
|
||||
expect.stringContaining('index.android.bundle')
|
||||
);
|
||||
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: expect.arrayContaining([
|
||||
'update:embedded:upload',
|
||||
'--platform',
|
||||
Platform.ANDROID,
|
||||
'--channel',
|
||||
'production',
|
||||
'--build-id',
|
||||
'build-123',
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads from Android AAB archives', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.aab']);
|
||||
mockZipEntries.mockResolvedValue(
|
||||
zipEntryMap({
|
||||
'base/assets/index.android.bundle': true,
|
||||
'base/assets/app.manifest': true,
|
||||
})
|
||||
);
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(mockZipExtract).toHaveBeenCalledWith(
|
||||
'base/assets/index.android.bundle',
|
||||
expect.stringContaining('index.android.bundle')
|
||||
);
|
||||
expect(easCli.runEasCliCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads from iOS IPA archives', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/App.ipa']);
|
||||
mockZipEntries.mockResolvedValue(
|
||||
zipEntryMap({
|
||||
'Payload/App.app/main.jsbundle': true,
|
||||
'Payload/App.app/EXUpdates.bundle/app.manifest': true,
|
||||
})
|
||||
);
|
||||
const ctx = makeCtx({ platform: Platform.IOS, channel: 'production' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: expect.arrayContaining(['--platform', Platform.IOS]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips simulator builds', async () => {
|
||||
const ctx = makeCtx({ platform: Platform.IOS, simulator: true, channel: 'preview' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
|
||||
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns when bundle or manifest is missing from the archive', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
|
||||
mockZipEntries.mockResolvedValue(
|
||||
zipEntryMap({
|
||||
'assets/app.manifest': true,
|
||||
})
|
||||
);
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
'Skipping embedded bundle upload: bundle or manifest not found in archive.'
|
||||
);
|
||||
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns when build archive is not found', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
'Skipping embedded bundle upload: build archive not found.'
|
||||
);
|
||||
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
|
||||
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats findArtifacts errors as no archive found', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockRejectedValue(new Error('glob failed'));
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
'Skipping embedded bundle upload: build archive not found.'
|
||||
);
|
||||
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
|
||||
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and continues when CLI upload throws', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
|
||||
mockZipEntries.mockResolvedValue(
|
||||
zipEntryMap({
|
||||
'assets/index.android.bundle': true,
|
||||
'assets/app.manifest': true,
|
||||
})
|
||||
);
|
||||
jest.mocked(easCli.runEasCliCommand).mockRejectedValue(new Error('upload failed'));
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to upload embedded bundle.'
|
||||
);
|
||||
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows zip.close() failures so they do not mask the upload result', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
|
||||
mockZipEntries.mockResolvedValue(
|
||||
zipEntryMap({
|
||||
'assets/index.android.bundle': true,
|
||||
'assets/app.manifest': true,
|
||||
})
|
||||
);
|
||||
mockZipClose.mockRejectedValue(new Error('close failed'));
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
|
||||
await expect(uploadEmbeddedBundleAsync(ctx)).resolves.toBeUndefined();
|
||||
expect(easCli.runEasCliCommand).toHaveBeenCalled();
|
||||
expect(mockZipClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -19,12 +19,11 @@ export async function findArtifacts({
|
||||
/** If provided, will log error suggesting possible files to upload. */
|
||||
logger: bunyan | null;
|
||||
}): Promise<string[]> {
|
||||
const files =
|
||||
path.isAbsolute(patternOrPath) && !fg.isDynamicPattern(patternOrPath)
|
||||
? (await fs.pathExists(patternOrPath))
|
||||
? [patternOrPath]
|
||||
: []
|
||||
: await fg(patternOrPath, { cwd: rootDir, onlyFiles: false });
|
||||
const files = path.isAbsolute(patternOrPath)
|
||||
? (await fs.pathExists(patternOrPath))
|
||||
? [patternOrPath]
|
||||
: []
|
||||
: await fg(patternOrPath, { cwd: rootDir, onlyFiles: false });
|
||||
if (files.length === 0) {
|
||||
if (fg.isDynamicPattern(patternOrPath)) {
|
||||
throw new FindArtifactsError(`There are no files matching pattern "${patternOrPath}"`);
|
||||
|
||||
Loaded 100 of 353 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user