mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
150b8c5cdf | ||
|
|
f256e1f044 | ||
|
|
9f5e8d5547 |
No files matched your search
@@ -1,11 +0,0 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "eas-cli"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
mise trust
|
||||
mise install
|
||||
yarn
|
||||
yarn typecheck
|
||||
'''
|
||||
@@ -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/add-pr-comment@v2.1.0
|
||||
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 the v1.4.0 tag) because this runs under
|
||||
# pull_request_target with a write-scoped token — a re-pointed tag from
|
||||
# a compromised upstream account would give the attacker repo write access.
|
||||
- uses: tobyhs/codemention@bb6bfb2c3ff1e6fee7ee37006bbee6d114057225 # v1.4.0
|
||||
- uses: tobyhs/[email protected]
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"packages/eas-cli/README.md",
|
||||
"packages/eas-cli/schema/**",
|
||||
"packages/eas-cli/graphql.schema.json",
|
||||
"packages/eas-cli/src/graphql/generated.ts",
|
||||
"packages/build-tools/schema.graphql"
|
||||
]
|
||||
}
|
||||
@@ -1,9 +1,2 @@
|
||||
defaultSemverRangePrefix: ''
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 2d
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- '@expo/*'
|
||||
- 'eslint-plugin-universe'
|
||||
- 'oxlint-config-universe'
|
||||
@@ -3,4 +3,3 @@
|
||||
When working with this repository follow instructions from CLAUDE.md.
|
||||
|
||||
- [./CLAUDE.md](./CLAUDE.md)
|
||||
- Before committing, run the formatter on modified files.
|
||||
+3
-197
@@ -8,207 +8,12 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `--skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [19.0.7](https://github.com/expo/eas-cli/releases/tag/v19.0.7) - 2026-05-21
|
||||
|
||||
## [19.0.6](https://github.com/expo/eas-cli/releases/tag/v19.0.6) - 2026-05-21
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Update the minimum Expo version required for iOS precompiled modules to 55.0.26. ([#3771](https://github.com/expo/eas-cli/pull/3771) by [@chrfalch](https://github.com/chrfalch))
|
||||
|
||||
## [19.0.5](https://github.com/expo/eas-cli/releases/tag/v19.0.5) - 2026-05-20
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Improve Sentry diagnostics for managed iOS entitlements config fallback. ([#3763](https://github.com/expo/eas-cli/pull/3763) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [19.0.4](https://github.com/expo/eas-cli/releases/tag/v19.0.4) - 2026-05-20
|
||||
|
||||
## [19.0.1](https://github.com/expo/eas-cli/releases/tag/v19.0.1) - 2026-05-19
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Disable dotenv loading when resolving managed iOS entitlements. ([#3752](https://github.com/expo/eas-cli/pull/3752) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Restore the bundled config fallback for managed iOS entitlements introspection. ([#3753](https://github.com/expo/eas-cli/pull/3753) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [19.0.0](https://github.com/expo/eas-cli/releases/tag/v19.0.0) - 2026-05-19
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Make browser-based authentication the default for `eas login`. Use `--no-browser` for CLI-based login. ([#3746](https://github.com/expo/eas-cli/pull/3746) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Allow command `observe:routes` to filter results to specific route names. ([#3744](https://github.com/expo/eas-cli/pull/3744) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Check that `expo` is resolvable before assuming it's installed ([#3748](https://github.com/expo/eas-cli/pull/3748) by [@kitten](https://github.com/kitten))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [worker] Report build phase durations stats. ([#3732](https://github.com/expo/eas-cli/pull/3732) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [18.13.1](https://github.com/expo/eas-cli/releases/tag/v18.13.1) - 2026-05-18
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Send the installed `expo` package version in build metadata. ([#3725](https://github.com/expo/eas-cli/pull/3725) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Prevent `npx` invocations that can be unreliable and fail when retrieving entitlements or project configs ([#3282](https://github.com/expo/eas-cli/pull/3282) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [18.13.0](https://github.com/expo/eas-cli/releases/tag/v18.13.0) - 2026-05-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] New command `observe:routes` for navigation metrics. ([#3730](https://github.com/expo/eas-cli/pull/3730) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Refactor observe commands to extract common flag definitions. ([#3731](https://github.com/expo/eas-cli/pull/3731) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [18.12.3](https://github.com/expo/eas-cli/releases/tag/v18.12.3) - 2026-05-13
|
||||
|
||||
## [18.12.2](https://github.com/expo/eas-cli/releases/tag/v18.12.2) - 2026-05-12
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix `eas go` to use server-side project setup and support multiple Expo Go SDK versions. ([#3657](https://github.com/expo/eas-cli/pull/3657) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [ci] Audit GitHub Actions workflows: harden `pull_request_target` usage, pin all external actions to commit SHAs, and add Dependabot to refresh them. ([#3718](https://github.com/expo/eas-cli/pull/3718) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [18.12.1](https://github.com/expo/eas-cli/releases/tag/v18.12.1) - 2026-05-12
|
||||
|
||||
## [18.12.0](https://github.com/expo/eas-cli/releases/tag/v18.12.0) - 2026-05-12
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Automatically save the Convex deployment URL as an EAS environment variable when connecting a Convex project. ([#3685](https://github.com/expo/eas-cli/pull/3685) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Update the minimum Expo version required for iOS precompiled modules. ([#3677](https://github.com/expo/eas-cli/pull/3677) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] recommend that users read the docs after connecting Convex ([#3683](https://github.com/expo/eas-cli/pull/3683) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
## [18.11.0](https://github.com/expo/eas-cli/releases/tag/v18.11.0) - 2026-05-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--simulator` to `eas build:dev` and `eas build:run` to select which iOS simulator to install and run builds on. ([#3637](https://github.com/expo/eas-cli/pull/3637) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix Convex team invite output after skipped or unnecessary invitations. ([#3672](https://github.com/expo/eas-cli/pull/3672) by [@fiberjw](https://github.com/fiberjw))
|
||||
- Make directories marked as read-only writable when creating a tar, to prevent the cross-filesystem issues when extracting the tar. ([#3489](https://github.com/expo/eas-cli/pull/3489) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## [18.10.0](https://github.com/expo/eas-cli/releases/tag/v18.10.0) - 2026-05-04
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas integrations:convex` commands to manage Convex integrations for EAS projects. ([#3575](https://github.com/expo/eas-cli/pull/3575) by [@fiberjw](https://github.com/fiberjw))
|
||||
- [eas-cli] New command `observe:logs` for custom events. ([#3638](https://github.com/expo/eas-cli/pull/3638) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Create portable project archives on all platforms to normalize cross-platform tar metadata and permissions. ([#3234](https://github.com/expo/eas-cli/pull/3234) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Remove hardcoded `builderEnvironment.image` override in `eas build:resign`. ([#3661](https://github.com/expo/eas-cli/pull/3661) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Fix `eas update --json` intermittently failing with JSON parse errors during "Computing project fingerprints" by passing `silent: true` to `@expo/fingerprint` to suppress subprocess stdout pollution. ([#3659](https://github.com/expo/eas-cli/pull/3659) by [@Mookiies](https://github.com/Mookiies))
|
||||
|
||||
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
|
||||
|
||||
## [18.9.0](https://github.com/expo/eas-cli/releases/tag/v18.9.0) - 2026-04-30
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas integrations:asc` commands to manage App Store Connect integrations for EAS projects. ([#3558](https://github.com/expo/eas-cli/pull/3558) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Allow `eas build:download` to accept a build ID. ([#3655](https://github.com/expo/eas-cli/pull/3655) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `--id` alias for `--build-id` flag in `eas build:download`. ([#3656](https://github.com/expo/eas-cli/pull/3656) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [steps] Fix workflow job context interpolation when a string contains multiple `${{ }}` expressions on the same line (for example a changelog built from two context values). ([#3644](https://github.com/expo/eas-cli/pull/3644) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [18.8.1](https://github.com/expo/eas-cli/releases/tag/v18.8.1) - 2026-04-23
|
||||
|
||||
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Support custom params in `observe:events` results. ([#3620](https://github.com/expo/eas-cli/pull/3620) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Add `eas/deploy` function for EAS Hosting web deployments. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/export` function for Expo web exports. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas update:insights <groupId>` command to display launch, crash, unique-user, and payload-size metrics for an update group. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `--insights` flag to `eas update:view <groupId>` to append the same metrics below the existing output. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `eas channel:insights --channel <name> --runtime-version <version>` command to display adoption and crash metrics for a channel + runtime version. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix `metadata:push` not deleting video previews from App Store Connect when removed from config. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Warn and skip unknown preview types in `metadata:push` with a helpful suggestion when the `APP_` screenshot prefix is mistakenly used. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Delete all existing previews in a preview set before uploading to avoid Apple's "Too many app previews" error. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Fixes for `observe` commands, including an issue for apps with many update IDs. ([#3609](https://github.com/expo/eas-cli/pull/3609) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add existing capability identifiers. ([#3615](https://github.com/expo/eas-cli/pull/3615) by [@jakex7](https://github.com/jakex7))
|
||||
|
||||
## [18.7.0](https://github.com/expo/eas-cli/releases/tag/v18.7.0) - 2026-04-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
## [18.6.0](https://github.com/expo/eas-cli/releases/tag/v18.6.0) - 2026-04-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [build-tools] Add `working_directory` input to `eas/build` function group for custom builds. ([#3582](https://github.com/expo/eas-cli/pull/3582) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [steps] Coerce numeric env values to strings in workflow step configuration. ([#3583](https://github.com/expo/eas-cli/pull/3583) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
|
||||
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
|
||||
- [build-tools] Early stop `eas/start_android_emulator` when Android emulator host setup is invalid. ([#3580](https://github.com/expo/eas-cli/pull/3580) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.18` to fix `metadata:push` failing on `ageRatingDeclarations` due to the removed `gamblingAndContests` attribute. ([#3588](https://github.com/expo/eas-cli/pull/3588) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.19` to fix image and video uploads via `metadata:push` getting stuck in `AWAITING_UPLOAD` state. The asset client was inheriting Bearer token injection from the App Store Connect API client, which caused S3 presigned URL uploads to be silently mishandled by Apple's CDN. Fixes screenshots, previews, and App Clip header image uploads. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] `metadata:pull` now preserves screenshot, video preview, and App Clip header image entries with placeholder paths when the asset is in an unrendered state, so users can recover broken records by replacing the file or removing the entry instead of having entries silently dropped from `store.config.json`. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Surface hosting deployment's asset upload errors sooner ([#3600](https://github.com/expo/eas-cli/pull/3600) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add `observe:metrics`, `observe:events`, and `observe:versions` commands. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)), [@douglowder](https://github.com/douglowder)
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
|
||||
- Fix `hasIgnoredIosProjectAsync()` always returning `false` for ignored iOS projects. ([#3562](https://github.com/expo/eas-cli/pull/3562) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
|
||||
- [eas-cli][build-tools][worker] Bump `@expo/config` and `@expo/config-plugins`. ([#3527](https://github.com/expo/eas-cli/pull/3527) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Auto-detect non-interactive mode in CI and non-TTY environments. ([#3486](https://github.com/expo/eas-cli/pull/3486) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- Add `skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
## [18.3.0](https://github.com/expo/eas-cli/releases/tag/v18.3.0) - 2026-03-10
|
||||
|
||||
### 🎉 New features
|
||||
@@ -1870,6 +1675,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- Add rollback disambiguation command. ([#2004](https://github.com/expo/eas-cli/pull/2004) by [@wschurman](https://github.com/wschurman))
|
||||
- Detect devices that fail to be provisioned, list them to the user and show the explanation message with the link to the devices page to check actual status. ([#2011](https://github.com/expo/eas-cli/pull/2011) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
- Add info to EAS Update asset upload process about asset counts and limits. ([#2013](https://github.com/expo/eas-cli/pull/2013) by [@wschurman](https://github.com/wschurman))
|
||||
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "19.0.7",
|
||||
"version": "18.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+1
-2
@@ -17,7 +17,7 @@
|
||||
"lint-changelog": "./scripts/bin/run lint-changelog",
|
||||
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
|
||||
"test": "lerna run test",
|
||||
"clean": "lerna run clean && rimraf node_modules coverage"
|
||||
"clean": "lerna run clean && rm -rf node_modules coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
@@ -30,7 +30,6 @@
|
||||
"oxfmt": "0.28.0",
|
||||
"oxlint": "1.43.0",
|
||||
"oxlint-tsgolint": "0.11.1",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "19.0.6",
|
||||
"version": "18.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -24,33 +24,32 @@
|
||||
"prebuild": "yarn gql",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"prepack": "rm -rf dist *.tsbuildinfo && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
|
||||
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
|
||||
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo",
|
||||
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
|
||||
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
|
||||
"test": "yarn jest-unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "19.0.0",
|
||||
"@expo/eas-build-job": "19.0.0",
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/downloader": "18.0.1",
|
||||
"@expo/eas-build-job": "18.2.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "19.0.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "19.0.0",
|
||||
"@expo/template-file": "19.0.0",
|
||||
"@expo/turtle-spawn": "19.0.0",
|
||||
"@expo/steps": "18.2.0",
|
||||
"@expo/template-file": "18.0.1",
|
||||
"@expo/turtle-spawn": "18.0.1",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@google-cloud/storage": "^7.11.2",
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "^6.0.1",
|
||||
"bplist-parser": "0.3.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
@@ -76,7 +75,7 @@
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/repack-app": "~0.4.1",
|
||||
"@expo/repack-app": "~0.2.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
@@ -92,7 +91,6 @@
|
||||
"jest": "^29.7.0",
|
||||
"memfs": "^4.17.1",
|
||||
"nock": "13.4.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"ts-mockito": "^2.6.1",
|
||||
"tslib": "^2.6.3",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { fs } from 'memfs';
|
||||
|
||||
const fsRealpath = fs.realpath;
|
||||
(fsRealpath as any).native = fsRealpath;
|
||||
|
||||
const fsRm = (path: string, options: object): Promise<void> => {
|
||||
return fs.promises.rm(path, options);
|
||||
};
|
||||
|
||||
module.exports = { ...fs.promises, rm: fsRm };
|
||||
module.exports = { ...fs.promises, realpath: fsRealpath, rm: fsRm };
|
||||
@@ -1,36 +1,14 @@
|
||||
import {
|
||||
BuildPhase,
|
||||
BuildPhaseResult,
|
||||
BuildTrigger,
|
||||
Job,
|
||||
Metadata,
|
||||
Platform,
|
||||
} from '@expo/eas-build-job';
|
||||
import { BuildTrigger, Job, Metadata } from '@expo/eas-build-job';
|
||||
import { randomUUID } from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from './utils/logger';
|
||||
import { BuildContext } from '../context';
|
||||
import { Datadog } from '../datadog';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../datadog', () => ({
|
||||
Datadog: {
|
||||
distribution: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const datadogDistributionMock = jest.mocked(Datadog.distribution);
|
||||
|
||||
describe('BuildContext', () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
jest.clearAllMocks();
|
||||
(fs.readdir as unknown as jest.Mock).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('should merge secrets', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
|
||||
@@ -138,68 +116,4 @@ describe('BuildContext', () => {
|
||||
foo: 'bar',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits build phase duration metrics for successful build phases', async () => {
|
||||
const ctx = createTestBuildContext({ platform: Platform.IOS });
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {});
|
||||
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
{
|
||||
build_phase: 'run_fastlane',
|
||||
platform: Platform.IOS,
|
||||
result: BuildPhaseResult.SUCCESS,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('emits build phase duration metrics for failed build phases', async () => {
|
||||
const ctx = createTestBuildContext({ platform: Platform.ANDROID });
|
||||
|
||||
await expect(
|
||||
ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
|
||||
throw new Error('boom');
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
{
|
||||
build_phase: 'install_dependencies',
|
||||
platform: Platform.ANDROID,
|
||||
result: BuildPhaseResult.FAIL,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not emit build phase duration metrics for non-platform jobs', async () => {
|
||||
const ctx = createTestBuildContext({});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {});
|
||||
|
||||
expect(datadogDistributionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createTestBuildContext({ platform }: { platform?: Platform }): BuildContext {
|
||||
vol.mkdirSync('/workingdir/env', { recursive: true });
|
||||
|
||||
return new BuildContext(
|
||||
{
|
||||
...(platform ? { platform } : {}),
|
||||
builderEnvironment: { env: {} },
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
import { Datadog } from '../datadog';
|
||||
import { Sentry } from '../sentry';
|
||||
import { turtleFetch } from '../utils/turtleFetch';
|
||||
|
||||
jest.mock('../sentry', () => ({
|
||||
Sentry: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../utils/turtleFetch', () => ({
|
||||
turtleFetch: jest.fn(),
|
||||
}));
|
||||
|
||||
const turtleFetchMock = jest.mocked(turtleFetch);
|
||||
const sentryCaptureMock = jest.mocked(Sentry.capture);
|
||||
|
||||
describe('Datadog singleton', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Datadog.setup(null);
|
||||
});
|
||||
|
||||
it('POSTs distribution metrics to the turtle build metrics endpoint', () => {
|
||||
turtleFetchMock.mockResolvedValueOnce({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'build-id',
|
||||
robotAccessToken: 'token-abc',
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1234, {
|
||||
build_phase: 'install_dependencies',
|
||||
platform: 'ios',
|
||||
result: 'success',
|
||||
});
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/turtle-builds/build-id/metrics',
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'eas.build.phase_duration',
|
||||
type: 'distribution',
|
||||
value: 1234,
|
||||
tags: {
|
||||
build_phase: 'install_dependencies',
|
||||
platform: 'ios',
|
||||
result: 'success',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
headers: { Authorization: 'Bearer token-abc' },
|
||||
retries: 2,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op when setup is null', () => {
|
||||
Datadog.setup(null);
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
|
||||
expect(turtleFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows upload failures', async () => {
|
||||
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'build-id',
|
||||
robotAccessToken: 'token-abc',
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
await flushPromises();
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalled();
|
||||
expect(sentryCaptureMock).toHaveBeenCalledWith(
|
||||
'Failed to report turtle build metric',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: {
|
||||
metrics: [{ name: 'eas.build.phase_duration', type: 'distribution', value: 1, tags: {} }],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the latest setup options', () => {
|
||||
turtleFetchMock.mockResolvedValue({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'first-build-id',
|
||||
robotAccessToken: 'first-token',
|
||||
});
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'second-build-id',
|
||||
robotAccessToken: 'second-token',
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/turtle-builds/second-build-id/metrics',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer second-token' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('flushes pending metric uploads', async () => {
|
||||
let resolveUpload!: (response: Response) => void;
|
||||
turtleFetchMock.mockReturnValueOnce(
|
||||
new Promise<Response>(resolve => {
|
||||
resolveUpload = resolve;
|
||||
})
|
||||
);
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'build-id',
|
||||
robotAccessToken: 'token-abc',
|
||||
});
|
||||
|
||||
let flushed = false;
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
const flushPromise = Datadog.flushAsync().then(() => {
|
||||
flushed = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(flushed).toBe(false);
|
||||
|
||||
resolveUpload({} as Response);
|
||||
await flushPromise;
|
||||
|
||||
expect(flushed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import * as sentryNode from '@sentry/node';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
|
||||
jest.mock('@sentry/node');
|
||||
|
||||
describe('Sentry singleton', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(sentryNode.init).mockReset();
|
||||
jest.mocked(sentryNode.flush).mockReset();
|
||||
jest.mocked(sentryNode.captureException).mockReset();
|
||||
jest.mocked(sentryNode.captureMessage).mockReset();
|
||||
jest.mocked(sentryNode.withScope).mockReset();
|
||||
});
|
||||
|
||||
describe('setup', () => {
|
||||
it('initializes the SDK with dsn + environment when dsn is non-null', () => {
|
||||
Sentry.setup({ dsn: 'https://[email protected]/1', environment: 'production' });
|
||||
expect(sentryNode.init).toHaveBeenCalledWith({
|
||||
dsn: 'https://[email protected]/1',
|
||||
environment: 'production',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes default tags into the SDK init as initialScope', () => {
|
||||
Sentry.setup({
|
||||
dsn: 'https://[email protected]/1',
|
||||
environment: 'production',
|
||||
tags: { service: 'worker:darwin' },
|
||||
});
|
||||
expect(sentryNode.init).toHaveBeenCalledWith({
|
||||
dsn: 'https://[email protected]/1',
|
||||
environment: 'production',
|
||||
initialScope: { tags: { service: 'worker:darwin' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('skips SDK init when dsn is null', () => {
|
||||
Sentry.setup({ dsn: null, environment: 'development' });
|
||||
expect(sentryNode.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the latest initialScope tags when setup is called more than once', () => {
|
||||
Sentry.setup({
|
||||
dsn: 'https://[email protected]/1',
|
||||
environment: 'test',
|
||||
tags: { service: 'first' },
|
||||
});
|
||||
Sentry.setup({
|
||||
dsn: 'https://[email protected]/1',
|
||||
environment: 'test',
|
||||
tags: { service: 'second' },
|
||||
});
|
||||
|
||||
expect(sentryNode.init).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ initialScope: { tags: { service: 'second' } } })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capture', () => {
|
||||
it('routes (msg) to captureMessage with msg only', () => {
|
||||
const scope = mockWithScope();
|
||||
|
||||
Sentry.capture('plain message');
|
||||
|
||||
expect(sentryNode.captureMessage).toHaveBeenCalledWith('plain message');
|
||||
expect(sentryNode.captureException).not.toHaveBeenCalled();
|
||||
expect(scope.setExtra).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes (msg, options) and sets per-call tags/extras/level on the scope', () => {
|
||||
const scope = mockWithScope();
|
||||
|
||||
Sentry.capture('something happened', {
|
||||
tags: { phase: 'running-xclogparser' },
|
||||
extras: { buildId: 'abc' },
|
||||
level: 'warning',
|
||||
});
|
||||
|
||||
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'running-xclogparser' });
|
||||
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
|
||||
expect(scope.setLevel).toHaveBeenCalledWith('warning');
|
||||
expect(sentryNode.captureMessage).toHaveBeenCalledWith('something happened');
|
||||
expect(sentryNode.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes (msg, err) to captureException and sets msg as `message` extra when distinct from err.message', () => {
|
||||
const scope = mockWithScope();
|
||||
const err = new Error('boom');
|
||||
|
||||
Sentry.capture('something went wrong', err);
|
||||
|
||||
expect(scope.setExtra).toHaveBeenCalledWith('message', 'something went wrong');
|
||||
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
|
||||
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes (msg, err, options) to captureException with per-call tags/extras', () => {
|
||||
const scope = mockWithScope();
|
||||
const err = new Error('spawn ENOENT');
|
||||
|
||||
Sentry.capture('xclogparser failed', err, {
|
||||
tags: { phase: 'running-xclogparser' },
|
||||
extras: { buildId: 'abc' },
|
||||
});
|
||||
|
||||
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'running-xclogparser' });
|
||||
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
|
||||
expect(scope.setExtra).toHaveBeenCalledWith('message', 'xclogparser failed');
|
||||
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
|
||||
});
|
||||
|
||||
it('does not set the `message` extra when msg equals err.message', () => {
|
||||
const scope = mockWithScope();
|
||||
const err = new Error('build failed');
|
||||
|
||||
Sentry.capture('build failed', err);
|
||||
|
||||
expect(scope.setExtra).not.toHaveBeenCalledWith('message', expect.anything());
|
||||
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
|
||||
});
|
||||
|
||||
it('routes (err) alone to captureException with no scope mutations', () => {
|
||||
const scope = mockWithScope();
|
||||
const err = new Error('boom');
|
||||
|
||||
Sentry.capture(err);
|
||||
|
||||
expect(scope.setTags).not.toHaveBeenCalled();
|
||||
expect(scope.setExtras).not.toHaveBeenCalled();
|
||||
expect(scope.setLevel).not.toHaveBeenCalled();
|
||||
expect(scope.setExtra).not.toHaveBeenCalled();
|
||||
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
|
||||
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats (msg, null) as a no-err call (parity with 3-arg null behavior)', () => {
|
||||
const scope = mockWithScope();
|
||||
|
||||
Sentry.capture('forwarded null', null as unknown as Error);
|
||||
|
||||
expect(scope.setExtra).not.toHaveBeenCalled();
|
||||
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded null');
|
||||
expect(sentryNode.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats (msg, null, options) as a no-err call', () => {
|
||||
const scope = mockWithScope();
|
||||
|
||||
Sentry.capture('forwarded null with opts', null as unknown as Error, {
|
||||
tags: { phase: 'cleanup' },
|
||||
});
|
||||
|
||||
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'cleanup' });
|
||||
expect(scope.setExtra).not.toHaveBeenCalled();
|
||||
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded null with opts');
|
||||
expect(sentryNode.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats (msg, undefined, options) as a no-err call (forwarding `Error | undefined` typed vars)', () => {
|
||||
const scope = mockWithScope();
|
||||
|
||||
Sentry.capture('forwarded with no err', undefined, {
|
||||
tags: { phase: 'install-dependencies' },
|
||||
extras: { buildId: 'abc' },
|
||||
});
|
||||
|
||||
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
|
||||
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
|
||||
expect(scope.setExtra).not.toHaveBeenCalled();
|
||||
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded with no err');
|
||||
expect(sentryNode.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('coerces 2-arg primitive thrown value to Error (e.g. `throw "boom"`)', () => {
|
||||
mockWithScope();
|
||||
|
||||
Sentry.capture('caught a string', 'boom' as unknown as Error);
|
||||
|
||||
expect(sentryNode.captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
const coerced = jest.mocked(sentryNode.captureException).mock.calls[0][0] as Error;
|
||||
expect(coerced.message).toBe('boom');
|
||||
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('coerces non-Error middle arg to Error when caller passes 3 args (e.g. `catch (err: any)`)', () => {
|
||||
const scope = mockWithScope();
|
||||
|
||||
Sentry.capture('something failed', 'raw string thrown' as unknown as Error, {
|
||||
tags: { phase: 'install-dependencies' },
|
||||
extras: { buildId: 'abc' },
|
||||
});
|
||||
|
||||
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
|
||||
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
|
||||
expect(scope.setExtra).toHaveBeenCalledWith('message', 'something failed');
|
||||
expect(sentryNode.captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
const coerced = jest.mocked(sentryNode.captureException).mock.calls[0][0] as Error;
|
||||
expect(coerced.message).toBe('raw string thrown');
|
||||
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes (err, options) to captureException with no message extra', () => {
|
||||
const scope = mockWithScope();
|
||||
const err = new Error('boom');
|
||||
|
||||
Sentry.capture(err, { tags: { phase: 'install-dependencies' }, level: 'warning' });
|
||||
|
||||
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
|
||||
expect(scope.setLevel).toHaveBeenCalledWith('warning');
|
||||
expect(scope.setExtra).not.toHaveBeenCalled();
|
||||
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
|
||||
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('flush', () => {
|
||||
it('forwards the timeout to the SDK and resolves with its result', async () => {
|
||||
jest.mocked(sentryNode.flush).mockResolvedValue(true);
|
||||
|
||||
const result = await Sentry.flush(3000);
|
||||
|
||||
expect(sentryNode.flush).toHaveBeenCalledWith(3000);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults the timeout to 2000ms when not specified', async () => {
|
||||
jest.mocked(sentryNode.flush).mockResolvedValue(true);
|
||||
|
||||
await Sentry.flush();
|
||||
|
||||
expect(sentryNode.flush).toHaveBeenCalledWith(2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockWithScope(): {
|
||||
setTags: jest.Mock;
|
||||
setExtras: jest.Mock;
|
||||
setExtra: jest.Mock;
|
||||
setLevel: jest.Mock;
|
||||
} {
|
||||
const scope = {
|
||||
setTags: jest.fn(),
|
||||
setExtras: jest.fn(),
|
||||
setExtra: jest.fn(),
|
||||
setLevel: jest.fn(),
|
||||
};
|
||||
jest.mocked(sentryNode.withScope).mockImplementation((cb: any) => {
|
||||
cb(scope as unknown as sentryNode.Scope);
|
||||
return undefined as any;
|
||||
});
|
||||
return scope;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -67,26 +67,28 @@ export async function resolveBuildPhaseErrorAsync(
|
||||
logLines: string[],
|
||||
errorContext: ErrorContext,
|
||||
buildLogsDirectory: string
|
||||
): Promise<errors.ExpoError> {
|
||||
): Promise<errors.BuildError> {
|
||||
const { phase } = errorContext;
|
||||
if (error instanceof errors.ExpoError) {
|
||||
error.buildPhase ??= phase;
|
||||
if (error instanceof errors.BuildError) {
|
||||
return error;
|
||||
}
|
||||
const xcodeBuildLogs = await maybeReadXcodeBuildLogs(phase, buildLogsDirectory);
|
||||
const userError =
|
||||
resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
|
||||
new errors.UnknownError(errorContext.phase);
|
||||
error instanceof errors.UserError
|
||||
? error
|
||||
: (resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
|
||||
new errors.UnknownError(errorContext.phase));
|
||||
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
|
||||
|
||||
const trackingCode =
|
||||
buildError && buildError.errorCode !== userError.errorCode ? buildError.errorCode : undefined;
|
||||
|
||||
return new errors.UserError(userError.errorCode, userError.message, {
|
||||
return new errors.BuildError(userError.message, {
|
||||
errorCode: userError.errorCode,
|
||||
trackingCode,
|
||||
docsUrl: userError.docsUrl,
|
||||
cause: error,
|
||||
buildPhase: phase,
|
||||
metadata: buildError?.metadata,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
@@ -10,18 +10,12 @@ 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';
|
||||
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
restoreGradleCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveGradleCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
injectConfigureVersionGradleConfig,
|
||||
injectCredentialsGradleConfig,
|
||||
@@ -87,12 +81,6 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await restoreGradleCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
|
||||
@@ -105,7 +93,7 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
appConfig: await ctx.appConfig,
|
||||
appConfig: ctx.appConfig,
|
||||
platform: ctx.job.platform,
|
||||
workflow: ctx.job.type,
|
||||
env: ctx.env,
|
||||
@@ -182,20 +170,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);
|
||||
});
|
||||
@@ -222,12 +196,6 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await saveGradleCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
|
||||
|
||||
@@ -16,8 +16,6 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
|
||||
import { installPods } from '../ios/pod';
|
||||
import { downloadApplicationArchiveAsync } from '../ios/resign';
|
||||
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
@@ -53,7 +51,6 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
const evictUsedBefore = new Date();
|
||||
const credentialsManager = new CredentialsManager(ctx);
|
||||
const workingDirectory = ctx.getReactNativeProjectDirectory();
|
||||
let fastlaneResult: { derivedDataPath: string; workspacePath: string } | undefined;
|
||||
try {
|
||||
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
|
||||
return await credentialsManager.prepare();
|
||||
@@ -106,7 +103,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
appConfig: await ctx.appConfig,
|
||||
appConfig: ctx.appConfig,
|
||||
platform: ctx.job.platform,
|
||||
workflow: ctx.job.type,
|
||||
env: ctx.env,
|
||||
@@ -149,10 +146,10 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
|
||||
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
|
||||
const scheme = resolveScheme(ctx);
|
||||
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
|
||||
return await runFastlaneGym(ctx, {
|
||||
await runFastlaneGym(ctx, {
|
||||
credentials,
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
@@ -174,26 +171,6 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
|
||||
if (ctx.isLocal) {
|
||||
ctx.logger.info('Local builds skip build performance analysis.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
logger: ctx.logger,
|
||||
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
env: ctx.env,
|
||||
});
|
||||
} catch (err: any) {
|
||||
Sentry.capture('Failed to parse xcactivitylog', err);
|
||||
ctx.markBuildPhaseSkipped();
|
||||
}
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
|
||||
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
|
||||
});
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { BuildJob } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
|
||||
import { resolveEasCommandPrefixAndEnvAsync } from '../../utils/easCli';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/easCli', () => ({
|
||||
...jest.requireActual('../../utils/easCli'),
|
||||
resolveEasCommandPrefixAndEnvAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('easBuildInternal', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
|
||||
cmd: 'npx',
|
||||
args: ['-y', 'eas-cli@latest'],
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('calls resolveEasCommandPrefixAndEnvAsync in runEasBuildInternalAsync', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const job = {
|
||||
platform: 'android',
|
||||
secrets: { robotAccessToken: 'token' },
|
||||
} as unknown as BuildJob;
|
||||
|
||||
await expect(
|
||||
runEasBuildInternalAsync({
|
||||
job,
|
||||
logger,
|
||||
env: {},
|
||||
cwd: '/tmp/project',
|
||||
})
|
||||
).rejects.toThrow('build profile is missing in a build from git-based integration.');
|
||||
|
||||
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls resolveEasCommandPrefixAndEnvAsync in resolveEnvFromBuildProfileAsync', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
const ctx = {
|
||||
logger,
|
||||
env: {},
|
||||
job: { platform: 'ios' },
|
||||
} as any;
|
||||
|
||||
await expect(resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' })).rejects.toThrow(
|
||||
'build profile is missing in a build from git-based integration.'
|
||||
);
|
||||
|
||||
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,11 @@
|
||||
import { BuildJob, Env, Metadata, sanitizeBuildJob, sanitizeMetadata } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildJob,
|
||||
EasCliNpmTags,
|
||||
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';
|
||||
@@ -7,7 +14,7 @@ import Joi from 'joi';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { resolveEasCommandPrefixAndEnvAsync } from '../utils/easCli';
|
||||
import { isAtLeastNpm7Async } from '../utils/packageManager';
|
||||
|
||||
const EasBuildInternalResultSchema = Joi.object<{ job: object; metadata: object }>({
|
||||
job: Joi.object().unknown(),
|
||||
@@ -42,42 +49,38 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
|
||||
autoSubmitArgs.push('--auto-submit');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await spawn(
|
||||
cmd,
|
||||
[
|
||||
...args,
|
||||
'build:internal',
|
||||
'--platform',
|
||||
job.platform,
|
||||
'--profile',
|
||||
buildProfile,
|
||||
...autoSubmitArgs,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...env,
|
||||
EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds')
|
||||
.robotAccessToken,
|
||||
...extraEnv,
|
||||
EAS_PROJECT_ROOT: projectRootOverride,
|
||||
},
|
||||
logger,
|
||||
// This prevents printing stdout with job secrets and credentials to logs.
|
||||
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
|
||||
}
|
||||
);
|
||||
const result = await spawn(
|
||||
cmd,
|
||||
[
|
||||
...args,
|
||||
'build:internal',
|
||||
'--platform',
|
||||
job.platform,
|
||||
'--profile',
|
||||
buildProfile,
|
||||
...autoSubmitArgs,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...env,
|
||||
EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds')
|
||||
.robotAccessToken,
|
||||
...extraEnv,
|
||||
EAS_PROJECT_ROOT: projectRootOverride,
|
||||
},
|
||||
logger,
|
||||
// This prevents printing stdout with job secrets and credentials to logs.
|
||||
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
|
||||
}
|
||||
);
|
||||
|
||||
const stdout = result.stdout.toString();
|
||||
const parsed = JSON.parse(stdout);
|
||||
return validateEasBuildInternalResult({
|
||||
result: parsed,
|
||||
oldJob: job,
|
||||
});
|
||||
} catch (err: any) {
|
||||
throw new Error('Failed to run eas build:internal');
|
||||
}
|
||||
const stdout = result.stdout.toString();
|
||||
const parsed = JSON.parse(stdout);
|
||||
return validateEasBuildInternalResult({
|
||||
result: parsed,
|
||||
oldJob: job,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
|
||||
@@ -118,6 +121,33 @@ export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
|
||||
return env;
|
||||
}
|
||||
|
||||
async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
|
||||
cmd: string;
|
||||
args: string[];
|
||||
extraEnv: Env;
|
||||
}> {
|
||||
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
|
||||
if (process.env.ENVIRONMENT === 'development') {
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: {},
|
||||
};
|
||||
} else if (process.env.ENVIRONMENT === 'staging') {
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function validateEasBuildInternalResult<TJob extends BuildJob>({
|
||||
oldJob,
|
||||
result,
|
||||
|
||||
@@ -51,10 +51,6 @@ export async function installDependenciesAsync({
|
||||
'--production',
|
||||
'false',
|
||||
];
|
||||
// Yarn Classic doesn't respect NPM_CONFIG_REGISTRY, so pass --registry flag
|
||||
if (env['EAS_BUILD_NPM_CACHE_URL']) {
|
||||
args.push('--registry', env['EAS_BUILD_NPM_CACHE_URL']);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
shouldUseFrozenLockfile,
|
||||
} from '../utils/packageManager';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
|
||||
import { readEasJsonContents, readPackageJson } from '../utils/project';
|
||||
import { retryAsync } from '../utils/retry';
|
||||
|
||||
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
|
||||
@@ -82,7 +82,10 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
});
|
||||
|
||||
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
|
||||
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
ctx.logger.info('Using package.json:');
|
||||
const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
|
||||
ctx.logger.info(JSON.stringify(packageJson, null, 2));
|
||||
return packageJson;
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
|
||||
@@ -110,7 +113,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
|
||||
const appConfig = await ctx.appConfig;
|
||||
const appConfig = ctx.appConfig;
|
||||
ctx.logger.info('Using app configuration:');
|
||||
ctx.logger.info(JSON.stringify(appConfig, null, 2));
|
||||
await validateAppConfigAsync(ctx, appConfig);
|
||||
@@ -118,13 +121,12 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
|
||||
if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) {
|
||||
await ctx.runBuildPhase(BuildPhase.EAS_BUILD_INTERNAL, async () => {
|
||||
const appConfig = await ctx.appConfig;
|
||||
if (!appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
|
||||
if (!ctx.appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
|
||||
throw new Error(
|
||||
'The "ios.bundleIdentifier" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#bundleidentifier.'
|
||||
);
|
||||
}
|
||||
if (!appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
|
||||
if (!ctx.appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
|
||||
throw new Error(
|
||||
'The "android.package" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#package.'
|
||||
);
|
||||
|
||||
@@ -20,7 +20,6 @@ import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError';
|
||||
import { Datadog } from './datadog';
|
||||
import { readAppConfig } from './utils/appConfig';
|
||||
import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets';
|
||||
import { PackageManager, resolvePackageManager } from './utils/packageManager';
|
||||
@@ -97,7 +96,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
private buildPhase?: BuildPhase;
|
||||
private buildPhaseSkipped = false;
|
||||
private buildPhaseHasWarnings = false;
|
||||
private _appConfig?: Promise<ExpoConfig>;
|
||||
private _appConfig?: ExpoConfig;
|
||||
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
|
||||
public readonly graphqlClient: Client;
|
||||
|
||||
@@ -174,18 +173,16 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
return path.join(this.workingdir, 'env');
|
||||
}
|
||||
public get packageManager(): PackageManager {
|
||||
return resolvePackageManager(this.getReactNativeProjectDirectory(), {
|
||||
env: this.env,
|
||||
});
|
||||
return resolvePackageManager(this.getReactNativeProjectDirectory());
|
||||
}
|
||||
public get appConfig(): Promise<ExpoConfig> {
|
||||
public get appConfig(): ExpoConfig {
|
||||
if (!this._appConfig) {
|
||||
this._appConfig = readAppConfig({
|
||||
projectDir: this.getReactNativeProjectDirectory(),
|
||||
env: this.env,
|
||||
logger: this.logger,
|
||||
sdkVersion: this.metadata?.sdkVersion,
|
||||
}).then(config => config.exp);
|
||||
}).exp;
|
||||
}
|
||||
return this._appConfig;
|
||||
}
|
||||
@@ -290,7 +287,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
private async handleBuildPhaseErrorAsync(
|
||||
err: any,
|
||||
buildPhase: BuildPhase
|
||||
): Promise<errors.ExpoError> {
|
||||
): Promise<errors.BuildError> {
|
||||
const buildError = await resolveBuildPhaseErrorAsync(
|
||||
err,
|
||||
this.logBuffer.getPhaseLogs(buildPhase),
|
||||
@@ -356,13 +353,6 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
await this.collectAndUpdateEnvVariablesAsync();
|
||||
|
||||
this.reportBuildPhaseStats?.({ buildPhase: this.buildPhase, result, durationMs });
|
||||
if (this.job.platform) {
|
||||
Datadog.distribution('eas.build.phase_duration', durationMs, {
|
||||
build_phase: this.buildPhase.toLowerCase(),
|
||||
platform: this.job.platform,
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
if (!doNotMarkEnd) {
|
||||
this.logger.info(
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Sentry } from './sentry';
|
||||
import { turtleFetch } from './utils/turtleFetch';
|
||||
|
||||
type DatadogSetupOptions = {
|
||||
expoApiV2BaseUrl: string;
|
||||
turtleBuildId: string;
|
||||
robotAccessToken: string;
|
||||
};
|
||||
|
||||
let setupOptions: DatadogSetupOptions | null = null;
|
||||
let pendingMetricUploads: Promise<unknown>[] = [];
|
||||
|
||||
export const Datadog = {
|
||||
setup(opts: DatadogSetupOptions | null): void {
|
||||
setupOptions = opts;
|
||||
},
|
||||
|
||||
distribution(name: string, value: number, tags: Record<string, string> = {}): void {
|
||||
if (!setupOptions) {
|
||||
return;
|
||||
}
|
||||
const { expoApiV2BaseUrl, turtleBuildId, robotAccessToken } = setupOptions;
|
||||
const metrics = [
|
||||
{
|
||||
name,
|
||||
type: 'distribution' as const,
|
||||
value,
|
||||
tags,
|
||||
},
|
||||
];
|
||||
|
||||
const uploadPromise = turtleFetch(
|
||||
new URL(`turtle-builds/${turtleBuildId}/metrics`, expoApiV2BaseUrl).toString(),
|
||||
'POST',
|
||||
{
|
||||
json: { metrics },
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
}
|
||||
).catch(err => {
|
||||
Sentry.capture('Failed to report turtle build metric', err, {
|
||||
extras: { metrics },
|
||||
});
|
||||
});
|
||||
|
||||
pendingMetricUploads.push(uploadPromise);
|
||||
},
|
||||
|
||||
async flushAsync(): Promise<void> {
|
||||
await Promise.allSettled(pendingMetricUploads);
|
||||
},
|
||||
};
|
||||
@@ -20,10 +20,4 @@ export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
|
||||
|
||||
export { Hook, runHookIfPresent } from './utils/hooks';
|
||||
|
||||
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
|
||||
export type { GradleProfileTask } from './android/gradleProfile';
|
||||
|
||||
export * from './generic';
|
||||
|
||||
export { Datadog } from './datadog';
|
||||
export { Sentry } from './sentry';
|
||||
@@ -22,10 +22,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /Users/expo/Library/Keychains
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
derived_data_path("./build")
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
|
||||
output_directory("/tmp/output")
|
||||
"
|
||||
`;
|
||||
@@ -49,10 +45,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
derived_data_path("./build")
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
|
||||
output_directory("/tmp/output")
|
||||
"
|
||||
`;
|
||||
@@ -78,10 +70,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
derived_data_path("./build")
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
|
||||
output_directory("/tmp/output")
|
||||
"
|
||||
`;
|
||||
@@ -109,10 +97,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
derived_data_path("./build")
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
|
||||
output_directory("/tmp/output")
|
||||
"
|
||||
`;
|
||||
@@ -133,9 +117,6 @@ destination("generic/platform=iOS Simulator")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -153,9 +134,6 @@ destination("platform=iOS Simulator,name=iPhone 15")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -175,8 +153,5 @@ destination("generic/platform=tvOS Simulator")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
@@ -165,52 +165,6 @@ describe(configureXcodeProject, () => {
|
||||
'Info.plist application target'
|
||||
);
|
||||
});
|
||||
it('configures credentials with Apple Development identity for development profile', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
|
||||
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
|
||||
'utf-8'
|
||||
),
|
||||
'ios/testapp/AppDelegate.m': 'placeholder',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
const options = {
|
||||
credentials: {
|
||||
keychainPath: 'fake/path',
|
||||
targetProvisioningProfiles: {
|
||||
testapp: {
|
||||
path: 'fake/path.mobileprovision',
|
||||
target: 'testapp',
|
||||
bundleIdentifier: 'abc',
|
||||
teamId: 'ABCDEFGH',
|
||||
uuid: 'abc',
|
||||
name: 'profile name',
|
||||
developerCertificate: Buffer.from('test'),
|
||||
certificateCommonName: 'Apple Development: Test User',
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
},
|
||||
},
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
teamId: 'ABCDEFGH',
|
||||
applicationTargetProvisioningProfile: {} as ProvisioningProfile<Ios.Job>,
|
||||
},
|
||||
buildConfiguration: 'Release',
|
||||
};
|
||||
const ctx = {
|
||||
getReactNativeProjectDirectory: () => '/app',
|
||||
logger: { info: jest.fn() },
|
||||
job: {},
|
||||
};
|
||||
await configureXcodeProject(ctx as any, options);
|
||||
const pbxproj = vol.readFileSync(
|
||||
'/app/ios/testapp.xcodeproj/project.pbxproj',
|
||||
'utf-8'
|
||||
) as string;
|
||||
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
|
||||
expect(pbxproj).not.toContain('"iPhone Distribution"');
|
||||
});
|
||||
it('configures credentials and versions for multi target project', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { Credentials } from '../credentials/manager';
|
||||
import { DistributionType } from '../credentials/provisioningProfile';
|
||||
import { runFastlaneGym } from '../fastlane';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../tvos', () => ({
|
||||
isTVOS: jest.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
const WORKING_DIR = '/workingdir';
|
||||
// BuildContext.getReactNativeProjectDirectory() nests the RN project under `${workingdir}/build`.
|
||||
const IOS_DIR = path.join(WORKING_DIR, 'build', 'ios');
|
||||
|
||||
function makeIosBuildContext({ simulator }: { simulator: boolean }): BuildContext<Ios.Job> {
|
||||
const job: Ios.Job = { ...createTestIosJob(), simulator };
|
||||
return new BuildContext(job, {
|
||||
workingdir: WORKING_DIR,
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
const ARCHIVE_CREDENTIALS: Credentials = {
|
||||
keychainPath: '/Users/expo/Library/Keychains/login.keychain',
|
||||
distributionType: DistributionType.APP_STORE,
|
||||
teamId: 'TEAM123',
|
||||
applicationTargetProvisioningProfile: {} as any,
|
||||
targetProvisioningProfiles: {
|
||||
'com.example.app': {
|
||||
bundleIdentifier: 'com.example.app',
|
||||
uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
path: '/path/to/profile1.mobileprovision',
|
||||
target: 'com.example.app',
|
||||
teamId: 'TEAM123',
|
||||
name: 'Main App Profile',
|
||||
developerCertificate: Buffer.from('cert'),
|
||||
certificateCommonName: 'iPhone Distribution',
|
||||
distributionType: DistributionType.APP_STORE,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function readDerivedDataPathFromGymfile(gymfileContent: string, workspacePath: string): string {
|
||||
const match = gymfileContent.match(/derived_data_path\("([^"]+)"\)/);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Gymfile did not contain derived_data_path("..."). Content:\n${gymfileContent}`
|
||||
);
|
||||
}
|
||||
return path.resolve(workspacePath, match[1]);
|
||||
}
|
||||
|
||||
describe(runFastlaneGym, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
vol.fromJSON({ [path.join(IOS_DIR, '.keep')]: '' });
|
||||
(spawn as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: 'archive',
|
||||
simulator: false,
|
||||
credentials: ARCHIVE_CREDENTIALS,
|
||||
buildConfiguration: 'Release',
|
||||
},
|
||||
{
|
||||
name: 'simulator',
|
||||
simulator: true,
|
||||
credentials: null,
|
||||
buildConfiguration: 'Debug',
|
||||
},
|
||||
])('$name build', ({ simulator, credentials, buildConfiguration }) => {
|
||||
it('returns paths that match the generated Gymfile template', async () => {
|
||||
const ctx = makeIosBuildContext({ simulator });
|
||||
|
||||
const result = await runFastlaneGym(ctx, {
|
||||
scheme: 'App',
|
||||
buildConfiguration,
|
||||
credentials,
|
||||
entitlements: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
derivedDataPath: path.join(IOS_DIR, 'build'),
|
||||
workspacePath: IOS_DIR,
|
||||
});
|
||||
|
||||
const gymfileContent = vol.readFileSync(path.join(IOS_DIR, 'Gymfile'), 'utf8') as string;
|
||||
expect(readDerivedDataPathFromGymfile(gymfileContent, result.workspacePath)).toBe(
|
||||
result.derivedDataPath
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('still returns EAS-convention paths when a pre-existing ios/Gymfile is present', async () => {
|
||||
vol.fromJSON({
|
||||
[path.join(IOS_DIR, 'Gymfile')]: '# user-supplied Gymfile\n',
|
||||
});
|
||||
|
||||
const ctx = makeIosBuildContext({ simulator: false });
|
||||
|
||||
const result = await runFastlaneGym(ctx, {
|
||||
scheme: 'App',
|
||||
credentials: ARCHIVE_CREDENTIALS,
|
||||
entitlements: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
derivedDataPath: path.join(IOS_DIR, 'build'),
|
||||
workspacePath: IOS_DIR,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,8 +58,6 @@ describe('gymfile', () => {
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Release',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -96,8 +94,6 @@ describe('gymfile', () => {
|
||||
credentials: mockCredentials,
|
||||
scheme: 'TestScheme',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: false,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -135,8 +131,6 @@ describe('gymfile', () => {
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Release',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
entitlements: {
|
||||
@@ -198,8 +192,6 @@ describe('gymfile', () => {
|
||||
credentials: mockCredentials,
|
||||
scheme: 'MyApp',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -218,7 +210,6 @@ describe('gymfile', () => {
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Debug',
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
simulatorDestination: 'generic/platform=iOS Simulator',
|
||||
@@ -235,7 +226,6 @@ describe('gymfile', () => {
|
||||
outputFile,
|
||||
scheme: 'TestApp',
|
||||
derivedDataPath: '/tmp/derived',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: false,
|
||||
logsDirectory: '/tmp/logs',
|
||||
simulatorDestination: 'platform=iOS Simulator,name=iPhone 15',
|
||||
@@ -253,7 +243,6 @@ describe('gymfile', () => {
|
||||
scheme: 'MyTVApp',
|
||||
buildConfiguration: 'Debug',
|
||||
derivedDataPath: '/tmp/derived',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
simulatorDestination: 'generic/platform=tvOS Simulator',
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { installPods } from '../pod';
|
||||
|
||||
jest.mock('@expo/eas-build-job', () => ({
|
||||
...jest.requireActual('@expo/eas-build-job'),
|
||||
getInstalledExpoPackageVersionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const WORKING_DIR = '/workingdir';
|
||||
const PROJECT_DIR = path.join(WORKING_DIR, 'build');
|
||||
const IOS_DIR = path.join(PROJECT_DIR, 'ios');
|
||||
|
||||
function makeIosBuildContext({
|
||||
expoVersion,
|
||||
cocoapodsCacheUrl,
|
||||
usePrecompiledModules = true,
|
||||
}: {
|
||||
expoVersion?: string;
|
||||
cocoapodsCacheUrl?: string;
|
||||
usePrecompiledModules?: boolean;
|
||||
}): BuildContext<Ios.Job> {
|
||||
vol.fromJSON({
|
||||
[path.join(IOS_DIR, '.keep')]: '',
|
||||
...(expoVersion
|
||||
? {
|
||||
[path.join(PROJECT_DIR, 'node_modules/expo/package.json')]: JSON.stringify({
|
||||
version: expoVersion,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const job: Ios.Job = {
|
||||
...createTestIosJob(),
|
||||
builderEnvironment: {
|
||||
env: {
|
||||
...(usePrecompiledModules ? { EAS_USE_PRECOMPILED_MODULES: '1' } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return new BuildContext(job, {
|
||||
workingdir: WORKING_DIR,
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
...(cocoapodsCacheUrl ? { EAS_BUILD_COCOAPODS_CACHE_URL: cocoapodsCacheUrl } : {}),
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe(installPods, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
jest.mocked(getInstalledExpoPackageVersionAsync).mockImplementation(async ({ projectDir }) => {
|
||||
return (await fs.readJson(path.join(projectDir, 'node_modules/expo/package.json'))).version;
|
||||
});
|
||||
(spawn as jest.Mock).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: '999.0.0' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect(getInstalledExpoPackageVersionAsync).toHaveBeenCalledWith({
|
||||
env: expect.objectContaining({
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
}),
|
||||
projectDir: PROJECT_DIR,
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: IOS_DIR,
|
||||
env: expect.objectContaining({
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL:
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules/',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
|
||||
);
|
||||
});
|
||||
|
||||
it('proxies precompiled modules through CocoaPods cache when enabled', async () => {
|
||||
const ctx = makeIosBuildContext({
|
||||
expoVersion: '999.0.0',
|
||||
cocoapodsCacheUrl: 'https://cocoapods-cache.expo.test/',
|
||||
});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: IOS_DIR,
|
||||
env: expect.objectContaining({
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL:
|
||||
'https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: '55.0.25' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/^Detected expo=55\.0\.25; not enabling precompiled modules use because precompiled modules require expo>=55\.0\.26\./
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars when Expo version is invalid', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: 'invalid-version' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars when Expo version detection fails', async () => {
|
||||
const ctx = makeIosBuildContext({});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
|
||||
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars without the builder flag', async () => {
|
||||
const ctx = makeIosBuildContext({
|
||||
expoVersion: '999.0.0',
|
||||
usePrecompiledModules: false,
|
||||
});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(
|
||||
(spawn as jest.Mock).mock.calls[0][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
|
||||
).toBeUndefined();
|
||||
expect(ctx.logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import uniq from 'lodash/uniq';
|
||||
import path from 'path';
|
||||
|
||||
import { Credentials } from './credentials/manager';
|
||||
import { DistributionType } from './credentials/provisioningProfile';
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
async function configureXcodeProject(
|
||||
@@ -57,9 +56,6 @@ async function configureCredentialsAsync(
|
||||
profileName: profile.name,
|
||||
appleTeamId: profile.teamId,
|
||||
buildConfiguration,
|
||||
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
|
||||
codeSignIdentity: profile.certificateCommonName,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ export interface ProvisioningProfileData {
|
||||
export enum DistributionType {
|
||||
AD_HOC = 'ad-hoc',
|
||||
APP_STORE = 'app-store',
|
||||
DEVELOPMENT = 'development',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
@@ -138,10 +137,6 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
if (plistData.ProvisionsAllDevices) {
|
||||
return DistributionType.ENTERPRISE;
|
||||
} else if (plistData.ProvisionedDevices) {
|
||||
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
|
||||
if (entitlements?.['get-task-allow']) {
|
||||
return DistributionType.DEVELOPMENT;
|
||||
}
|
||||
return DistributionType.AD_HOC;
|
||||
} else {
|
||||
return DistributionType.APP_STORE;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { bunyan } from '@expo/logger';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import nullthrows from 'nullthrows';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import type { Credentials } from './credentials/manager';
|
||||
@@ -29,13 +28,7 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
|
||||
entitlements: object | null;
|
||||
extraEnv?: Env;
|
||||
}
|
||||
): Promise<{
|
||||
derivedDataPath: string;
|
||||
workspacePath: string;
|
||||
}> {
|
||||
const workspacePath = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
|
||||
const derivedDataPath = path.join(workspacePath, 'build');
|
||||
|
||||
): Promise<void> {
|
||||
await ensureGymfileExists(ctx, {
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
@@ -50,15 +43,13 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
|
||||
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
|
||||
try {
|
||||
await runFastlane(['gym'], {
|
||||
cwd: workspacePath,
|
||||
cwd: path.join(ctx.getReactNativeProjectDirectory(), 'ios'),
|
||||
logger: ctx.logger,
|
||||
env: { ...ctx.env, ...extraEnv },
|
||||
});
|
||||
} finally {
|
||||
await buildLogger.flush();
|
||||
}
|
||||
|
||||
return { derivedDataPath, workspacePath };
|
||||
}
|
||||
|
||||
export async function runFastlaneResign<TJob extends Ios.Job>(
|
||||
@@ -131,8 +122,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
|
||||
}
|
||||
|
||||
ctx.logger.info('Creating Gymfile');
|
||||
const resultBundlePath = path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`);
|
||||
|
||||
if (ctx.job.simulator) {
|
||||
const isTV = await isTVOS(ctx);
|
||||
const simulatorDestination = `generic/platform=${isTV ? 'tvOS' : 'iOS'} Simulator`;
|
||||
@@ -142,7 +131,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
|
||||
scheme,
|
||||
buildConfiguration: buildConfiguration ?? 'release',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath,
|
||||
clean: false,
|
||||
logsDirectory,
|
||||
simulatorDestination,
|
||||
@@ -154,8 +142,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
outputDirectory: './build',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath,
|
||||
clean: false,
|
||||
logsDirectory,
|
||||
entitlements: entitlements ?? undefined,
|
||||
|
||||
@@ -11,8 +11,6 @@ interface ArchiveBuildOptions {
|
||||
scheme: string;
|
||||
buildConfiguration?: string;
|
||||
outputDirectory: string;
|
||||
derivedDataPath: string;
|
||||
resultBundlePath: string;
|
||||
clean: boolean;
|
||||
logsDirectory: string;
|
||||
entitlements?: object;
|
||||
@@ -23,7 +21,6 @@ interface SimulatorBuildOptions {
|
||||
scheme: string;
|
||||
buildConfiguration?: string;
|
||||
derivedDataPath: string;
|
||||
resultBundlePath: string;
|
||||
clean: boolean;
|
||||
logsDirectory: string;
|
||||
simulatorDestination: string;
|
||||
@@ -37,8 +34,6 @@ export async function createGymfileForArchiveBuild({
|
||||
buildConfiguration,
|
||||
entitlements,
|
||||
outputDirectory,
|
||||
derivedDataPath,
|
||||
resultBundlePath,
|
||||
logsDirectory,
|
||||
}: ArchiveBuildOptions): Promise<void> {
|
||||
const PROFILES: { BUNDLE_ID: string; UUID: string }[] = [];
|
||||
@@ -64,8 +59,6 @@ export async function createGymfileForArchiveBuild({
|
||||
SCHEME: scheme,
|
||||
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
|
||||
OUTPUT_DIRECTORY: outputDirectory,
|
||||
DERIVED_DATA_PATH: derivedDataPath,
|
||||
RESULT_BUNDLE_PATH: resultBundlePath,
|
||||
EXPORT_METHOD: credentials.distributionType,
|
||||
CLEAN: String(clean),
|
||||
LOGS_DIRECTORY: logsDirectory,
|
||||
@@ -81,7 +74,6 @@ export async function createGymfileForSimulatorBuild({
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
derivedDataPath,
|
||||
resultBundlePath,
|
||||
logsDirectory,
|
||||
simulatorDestination,
|
||||
}: SimulatorBuildOptions): Promise<void> {
|
||||
@@ -94,7 +86,6 @@ export async function createGymfileForSimulatorBuild({
|
||||
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
|
||||
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
|
||||
DERIVED_DATA_PATH: derivedDataPath,
|
||||
RESULT_BUNDLE_PATH: resultBundlePath,
|
||||
CLEAN: String(clean),
|
||||
LOGS_DIRECTORY: logsDirectory,
|
||||
},
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { Env, Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.26';
|
||||
const PRECOMPILED_MODULES_BASE_URL =
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules/';
|
||||
|
||||
export async function installPods<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>,
|
||||
{ infoCallbackFn }: SpawnOptions
|
||||
@@ -17,7 +12,6 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
|
||||
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
||||
const cocoapodsDeploymentFlag = ctx.env['POD_INSTALL_DEPLOYMENT'] === '1' ? ['--deployment'] : [];
|
||||
const precompiledModulesEnv = await resolvePrecompiledModulesPodInstallEnvAsync(ctx);
|
||||
|
||||
return {
|
||||
spawnPromise: spawn('pod', ['install', ...verboseFlag, ...cocoapodsDeploymentFlag], {
|
||||
@@ -26,7 +20,6 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
env: {
|
||||
...ctx.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
...precompiledModulesEnv,
|
||||
},
|
||||
lineTransformer: (line?: string) => {
|
||||
if (
|
||||
@@ -42,67 +35,3 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>
|
||||
): Promise<Env> {
|
||||
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
|
||||
return {};
|
||||
}
|
||||
|
||||
let expoPackageVersion: string;
|
||||
try {
|
||||
expoPackageVersion = await getInstalledExpoPackageVersionAsync({
|
||||
env: ctx.env,
|
||||
projectDir: ctx.getReactNativeProjectDirectory(),
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.info(
|
||||
{ err },
|
||||
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
const validExpoPackageVersion = semver.valid(expoPackageVersion);
|
||||
if (!validExpoPackageVersion) {
|
||||
ctx.logger.info(
|
||||
`Detected expo=${expoPackageVersion}; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
if (semver.lt(validExpoPackageVersion, MIN_PRECOMPILED_MODULES_EXPO_VERSION)) {
|
||||
ctx.logger.info(
|
||||
`Detected expo=${validExpoPackageVersion}; not enabling precompiled modules use because precompiled modules require expo>=${MIN_PRECOMPILED_MODULES_EXPO_VERSION}.`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
const env: Env = {
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(ctx),
|
||||
};
|
||||
|
||||
ctx.logger.info(
|
||||
`Detected expo=${validExpoPackageVersion}; enabling precompiled modules use. Installing pods with additional environment variables.\n${Object.entries(
|
||||
env
|
||||
)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')}\nPrecompiled modules pod install environment is configured.`
|
||||
);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
|
||||
if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
|
||||
return PRECOMPILED_MODULES_BASE_URL;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
|
||||
return PRECOMPILED_MODULES_BASE_URL.replace(
|
||||
`${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
`${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import * as sentryNode from '@sentry/node';
|
||||
|
||||
type CaptureOptions = {
|
||||
tags?: Record<string, string>;
|
||||
extras?: Record<string, unknown>;
|
||||
level?: sentryNode.SeverityLevel;
|
||||
};
|
||||
|
||||
type SentryAPI = {
|
||||
setup(opts: { dsn: string | null; environment: string; tags?: Record<string, string> }): void;
|
||||
capture(msg: string, options?: CaptureOptions): void;
|
||||
capture(msg: string, err: Error | undefined, options?: CaptureOptions): void;
|
||||
capture(err: Error, options?: CaptureOptions): void;
|
||||
flush(timeoutMs?: number): Promise<boolean>;
|
||||
};
|
||||
|
||||
export const Sentry: SentryAPI = {
|
||||
setup({ dsn, environment, tags }) {
|
||||
if (dsn) {
|
||||
sentryNode.init({
|
||||
dsn,
|
||||
environment,
|
||||
...(tags ? { initialScope: { tags } } : {}),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
capture(arg1: string | Error, arg2?: Error | CaptureOptions, arg3?: CaptureOptions): void {
|
||||
let msg: string | undefined;
|
||||
let err: Error | undefined;
|
||||
let options: CaptureOptions = {};
|
||||
|
||||
if (arg1 instanceof Error) {
|
||||
err = arg1;
|
||||
options = (arg2 as CaptureOptions | undefined) ?? {};
|
||||
} else {
|
||||
msg = arg1;
|
||||
if (arg3 !== undefined) {
|
||||
// 3-arg form: arg2 unambiguously means err (null/undefined → no err)
|
||||
options = arg3;
|
||||
if (arg2 !== undefined && arg2 !== null) {
|
||||
err = arg2 instanceof Error ? arg2 : new Error(String(arg2));
|
||||
}
|
||||
} else if (arg2 instanceof Error) {
|
||||
err = arg2;
|
||||
} else if (arg2 !== undefined && arg2 !== null) {
|
||||
// 2-arg form: non-Error object → options; primitive → coerced err
|
||||
if (typeof arg2 === 'object') {
|
||||
options = arg2;
|
||||
} else {
|
||||
err = new Error(String(arg2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sentryNode.withScope(scope => {
|
||||
if (options.tags) {
|
||||
scope.setTags(options.tags);
|
||||
}
|
||||
if (options.extras) {
|
||||
scope.setExtras(options.extras);
|
||||
}
|
||||
if (options.level) {
|
||||
scope.setLevel(options.level);
|
||||
}
|
||||
|
||||
if (err) {
|
||||
if (msg && err.message !== msg) {
|
||||
scope.setExtra('message', msg);
|
||||
}
|
||||
sentryNode.captureException(err);
|
||||
} else if (msg) {
|
||||
sentryNode.captureMessage(msg);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
flush(timeoutMs: number = 2000): Promise<boolean> {
|
||||
return sentryNode.flush(timeoutMs);
|
||||
},
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { getEasFunctionGroups } from '../easFunctionGroups';
|
||||
|
||||
describe(getEasFunctionGroups, () => {
|
||||
it('includes eas/maestro_test for non-build jobs', () => {
|
||||
const ctx = {
|
||||
hasBuildJob: () => false,
|
||||
job: {},
|
||||
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
|
||||
|
||||
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
|
||||
functionGroup.getFullId()
|
||||
);
|
||||
expect(functionGroupIds).toEqual(['eas/maestro_test']);
|
||||
});
|
||||
|
||||
it('includes eas/build for build jobs', () => {
|
||||
const ctx = {
|
||||
hasBuildJob: () => true,
|
||||
job: {},
|
||||
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
|
||||
|
||||
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
|
||||
functionGroup.getFullId()
|
||||
);
|
||||
expect(functionGroupIds).toEqual(expect.arrayContaining(['eas/build', 'eas/maestro_test']));
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,6 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
|
||||
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
|
||||
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
|
||||
import { createDownloadBuildFunction } from './functions/downloadBuild';
|
||||
import { createEasDeployBuildFunction } from './functions/deploy';
|
||||
import { createEasExportBuildFunction } from './functions/export';
|
||||
import { eagerBundleBuildFunction } from './functions/eagerBundle';
|
||||
import { createFindAndUploadBuildArtifactsBuildFunction } from './functions/findAndUploadBuildArtifacts';
|
||||
import { generateGymfileFromTemplateFunction } from './functions/generateGymfileFromTemplate';
|
||||
@@ -19,10 +17,9 @@ import { injectAndroidCredentialsFunction } from './functions/injectAndroidCrede
|
||||
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
|
||||
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
|
||||
import { createInstallPodsBuildFunction } from './functions/installPods';
|
||||
import { createInternalEasMaestroTestFunction } from './functions/internalMaestroTest';
|
||||
import { createPrebuildBuildFunction } from './functions/prebuild';
|
||||
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
|
||||
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
|
||||
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
|
||||
import { createRepackBuildFunction } from './functions/repack';
|
||||
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
|
||||
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
|
||||
@@ -32,19 +29,14 @@ import {
|
||||
createRestoreBuildCacheFunction,
|
||||
} from './functions/restoreBuildCache';
|
||||
import { createRestoreCacheFunction } from './functions/restoreCache';
|
||||
import { parseXcactivitylogFunction } from './functions/parseXcactivitylog';
|
||||
import { runFastlaneFunction } from './functions/runFastlane';
|
||||
import { runGradleFunction } from './functions/runGradle';
|
||||
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
|
||||
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
|
||||
import { createSaveCacheFunction } from './functions/saveCache';
|
||||
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
|
||||
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
|
||||
@@ -56,14 +48,10 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createDownloadArtifactFunction(),
|
||||
createUploadArtifactBuildFunction(ctx),
|
||||
createSetUpNpmrcBuildFunction(),
|
||||
createReadPackageJsonBuildFunction(),
|
||||
createReadAppConfigBuildFunction(),
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createDownloadBuildFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
createEasDeployBuildFunction(),
|
||||
createRepackBuildFunction(),
|
||||
createRestoreCacheFunction(),
|
||||
createRestoreBuildCacheFunction(),
|
||||
@@ -80,13 +68,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
configureIosVersionFunction(),
|
||||
generateGymfileFromTemplateFunction(),
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
createStartServeSimRemoteSessionBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
|
||||
createInstallPodsBuildFunction(),
|
||||
@@ -97,8 +81,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createSubmissionEntityFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
|
||||
createInternalEasMaestroTestFunction(ctx),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(),
|
||||
];
|
||||
|
||||
if (ctx.hasBuildJob()) {
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { BuildJob, Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { createEasBuildBuildFunctionGroup } from '../build';
|
||||
|
||||
function createMockBuildToolsContext(
|
||||
overrides: Partial<{
|
||||
platform: Platform;
|
||||
simulator: boolean;
|
||||
buildCredentials: Record<string, unknown>;
|
||||
}> = {}
|
||||
): CustomBuildContext<BuildJob> {
|
||||
return {
|
||||
job: {
|
||||
platform: overrides.platform ?? Platform.ANDROID,
|
||||
simulator: overrides.simulator ?? false,
|
||||
secrets: overrides.buildCredentials
|
||||
? { buildCredentials: overrides.buildCredentials }
|
||||
: undefined,
|
||||
},
|
||||
} as unknown as CustomBuildContext<BuildJob>;
|
||||
}
|
||||
|
||||
describe(createEasBuildBuildFunctionGroup, () => {
|
||||
describe('working_directory input', () => {
|
||||
it('does not set working directory on steps when not provided (Android)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
|
||||
for (const step of steps) {
|
||||
// Without working_directory, only installPods would have a relative dir (iOS only).
|
||||
// On Android, no step should have a relative working directory.
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (iOS simulator)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
|
||||
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (iOS with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
buildCredentials: { test: {} },
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
|
||||
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('wires configure_ios_credentials target_names into configure_ios_version', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
buildCredentials: { test: {} },
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const configureIosVersionStep = steps.find(s => s.displayName === 'Configure iOS version');
|
||||
const targetNamesInput = configureIosVersionStep?.inputs?.find(
|
||||
input => input.id === 'target_names'
|
||||
);
|
||||
|
||||
expect(configureIosVersionStep).toBeDefined();
|
||||
expect(targetNamesInput?.rawValue).toBe(
|
||||
'${{ steps.configure_ios_credentials.outputs.target_names }}'
|
||||
);
|
||||
});
|
||||
|
||||
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
|
||||
expect(installPodsStep).toBeDefined();
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('my-app/ios');
|
||||
});
|
||||
|
||||
it('uses ./ios for installPods when no working_directory provided (iOS)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
|
||||
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
|
||||
expect(installPodsStep).toBeDefined();
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.ANDROID,
|
||||
buildCredentials: { test: {} },
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for generic jobs (no platform)', () => {
|
||||
const buildToolsContext = {
|
||||
job: { platform: undefined },
|
||||
} as unknown as CustomBuildContext<BuildJob>;
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
expect(() => functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx)).toThrow(
|
||||
'Build function group is not supported in generic jobs.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,5 @@
|
||||
import { BuildJob, Platform } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunctionGroup,
|
||||
BuildStep,
|
||||
BuildStepGlobalContext,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import path from 'path';
|
||||
import { BuildFunctionGroup, BuildStep, BuildStepGlobalContext } from '@expo/steps';
|
||||
|
||||
import { shouldUseEagerBundle } from '../../common/eagerBundle';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -37,7 +30,6 @@ import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
|
||||
interface HelperFunctionsInput {
|
||||
globalCtx: BuildStepGlobalContext;
|
||||
buildToolsContext: CustomBuildContext<BuildJob>;
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
export function createEasBuildBuildFunctionGroup(
|
||||
@@ -46,30 +38,17 @@ export function createEasBuildBuildFunctionGroup(
|
||||
return new BuildFunctionGroup({
|
||||
namespace: 'eas',
|
||||
id: 'build',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'working_directory',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
createBuildStepsFromFunctionGroupCall: (globalCtx, { inputs }) => {
|
||||
const workingDirectory = inputs.working_directory?.getValue({
|
||||
interpolationContext: globalCtx.getInterpolationContext(),
|
||||
}) as string | undefined;
|
||||
|
||||
createBuildStepsFromFunctionGroupCall: globalCtx => {
|
||||
if (buildToolsContext.job.platform === Platform.IOS) {
|
||||
if (buildToolsContext.job.simulator) {
|
||||
return createStepsForIosSimulatorBuild({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
} else {
|
||||
return createStepsForIosBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
}
|
||||
} else if (buildToolsContext.job.platform === Platform.ANDROID) {
|
||||
@@ -77,13 +56,11 @@ export function createEasBuildBuildFunctionGroup(
|
||||
return createStepsForAndroidBuildWithoutCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
} else {
|
||||
return createStepsForAndroidBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -96,19 +73,16 @@ export function createEasBuildBuildFunctionGroup(
|
||||
function createStepsForIosSimulatorBuild({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
workingDirectory: './ios',
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -117,7 +91,6 @@ function createStepsForIosSimulatorBuild({
|
||||
});
|
||||
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_fastlane',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -125,26 +98,18 @@ function createStepsForIosSimulatorBuild({
|
||||
});
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
globalCtx
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -152,35 +117,29 @@ function createStepsForIosSimulatorBuild({
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForIosBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const resolveAppleTeamIdFromCredentials =
|
||||
resolveAppleTeamIdFromCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'resolve_apple_team_id_from_credentials',
|
||||
workingDirectory,
|
||||
});
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const prebuildStep = createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
apple_team_id: '${ steps.resolve_apple_team_id_from_credentials.apple_team_id }',
|
||||
},
|
||||
@@ -188,18 +147,16 @@ function createStepsForIosBuildWithCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
}
|
||||
);
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
workingDirectory: './ios',
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -209,7 +166,6 @@ function createStepsForIosBuildWithCredentials({
|
||||
const generateGymfile = generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
credentials: '${ eas.job.secrets.buildCredentials }',
|
||||
},
|
||||
@@ -217,7 +173,6 @@ function createStepsForIosBuildWithCredentials({
|
||||
);
|
||||
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_fastlane',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -226,7 +181,6 @@ function createStepsForIosBuildWithCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
@@ -234,15 +188,10 @@ function createStepsForIosBuildWithCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
globalCtx
|
||||
),
|
||||
resolveAppleTeamIdFromCredentials,
|
||||
prebuildStep,
|
||||
@@ -250,20 +199,11 @@ function createStepsForIosBuildWithCredentials({
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'configure_ios_credentials',
|
||||
workingDirectory,
|
||||
}),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
|
||||
},
|
||||
}),
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -275,29 +215,24 @@ function createStepsForIosBuildWithCredentials({
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForAndroidBuildWithoutCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -307,7 +242,6 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -315,7 +249,6 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
);
|
||||
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_gradle',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -324,7 +257,6 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -332,26 +264,18 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
globalCtx
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
configureEASUpdate,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -362,29 +286,24 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
runGradle,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForAndroidBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -394,7 +313,6 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -402,7 +320,6 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
);
|
||||
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_gradle',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -411,7 +328,6 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -419,33 +335,21 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
globalCtx
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
configureEASUpdate,
|
||||
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
runGradle,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -455,10 +359,8 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
: []),
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
];
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export function createEasMaestroTestFunctionGroup(
|
||||
steps.push(
|
||||
new BuildStep(globalCtx, {
|
||||
id: BuildStep.getNewId(),
|
||||
name: 'install_app',
|
||||
displayName: `Install app to Simulator`,
|
||||
command: `
|
||||
# shopt -s nullglob is necessary not to try to install
|
||||
@@ -96,6 +97,7 @@ export function createEasMaestroTestFunctionGroup(
|
||||
steps.push(
|
||||
new BuildStep(globalCtx, {
|
||||
id: BuildStep.getNewId(),
|
||||
name: 'install_app',
|
||||
displayName: `Install app to Emulator`,
|
||||
command: `
|
||||
# shopt -s globstar is necessary to add /**/ support
|
||||
@@ -132,6 +134,7 @@ export function createEasMaestroTestFunctionGroup(
|
||||
steps.push(
|
||||
new BuildStep(globalCtx, {
|
||||
id: BuildStep.getNewId(),
|
||||
name: 'maestro_test',
|
||||
ifCondition: '${ always() }',
|
||||
displayName: `maestro test ${flowPath}`,
|
||||
command: `maestro test ${flowPath}`,
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createTestIosJob } from '../../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { configureCredentialsAsync } from '../../utils/ios/configure';
|
||||
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
|
||||
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
|
||||
import { configureIosCredentialsFunction } from '../configureIosCredentials';
|
||||
|
||||
jest.mock('../../utils/ios/configure');
|
||||
jest.mock('../../utils/ios/credentials/manager');
|
||||
|
||||
function createCredentials({
|
||||
targetNames = ['app', 'share-extension'],
|
||||
}: {
|
||||
targetNames?: string[];
|
||||
} = {}): Credentials {
|
||||
return {
|
||||
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
|
||||
keychainPath: '/tmp/keychain',
|
||||
targetProvisioningProfiles: Object.fromEntries(
|
||||
targetNames.map(targetName => [
|
||||
targetName,
|
||||
{
|
||||
path: `/tmp/${targetName}.mobileprovision`,
|
||||
target: targetName,
|
||||
bundleIdentifier: `com.example.${targetName}`,
|
||||
teamId: 'TEAMID123',
|
||||
uuid: `${targetName}-uuid`,
|
||||
name: `${targetName} profile`,
|
||||
distributionType: DistributionType.AD_HOC,
|
||||
developerCertificate: Buffer.from('certificate'),
|
||||
certificateCommonName: `${targetName} cert`,
|
||||
},
|
||||
])
|
||||
),
|
||||
distributionType: DistributionType.AD_HOC,
|
||||
teamId: 'TEAMID123',
|
||||
};
|
||||
}
|
||||
|
||||
function createRawCredentials() {
|
||||
return {
|
||||
app: {
|
||||
provisioningProfileBase64: 'cHJvZmlsZQ==',
|
||||
distributionCertificate: {
|
||||
dataBase64: 'Y2VydA==',
|
||||
password: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(configureIosCredentialsFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(configureCredentialsAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('exports target_names from prepared credentials', async () => {
|
||||
jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
|
||||
|
||||
const buildStep = configureIosCredentialsFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { job: createTestIosJob() },
|
||||
}),
|
||||
{
|
||||
callInputs: {
|
||||
credentials: createRawCredentials(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(configureCredentialsAsync).toHaveBeenCalled();
|
||||
expect(buildStep.outputById.target_names.value).toBe(JSON.stringify(['app', 'widget']));
|
||||
});
|
||||
});
|
||||
@@ -1,286 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { BuildWorkflow } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createTestIosJob } from '../../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { updateVersionsAsync } from '../../utils/ios/configure';
|
||||
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
|
||||
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
|
||||
import { configureIosCredentialsFunction } from '../configureIosCredentials';
|
||||
import { configureIosVersionFunction } from '../configureIosVersion';
|
||||
|
||||
jest.mock('../../utils/ios/configure');
|
||||
jest.mock('../../utils/ios/credentials/manager');
|
||||
|
||||
function createCredentials({
|
||||
targetNames = ['app', 'share-extension'],
|
||||
}: {
|
||||
targetNames?: string[];
|
||||
} = {}): Credentials {
|
||||
return {
|
||||
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
|
||||
keychainPath: '/tmp/keychain',
|
||||
targetProvisioningProfiles: Object.fromEntries(
|
||||
targetNames.map(targetName => [
|
||||
targetName,
|
||||
{
|
||||
path: `/tmp/${targetName}.mobileprovision`,
|
||||
target: targetName,
|
||||
bundleIdentifier: `com.example.${targetName}`,
|
||||
teamId: 'TEAMID123',
|
||||
uuid: `${targetName}-uuid`,
|
||||
name: `${targetName} profile`,
|
||||
distributionType: DistributionType.AD_HOC,
|
||||
developerCertificate: Buffer.from('certificate'),
|
||||
certificateCommonName: `${targetName} cert`,
|
||||
},
|
||||
])
|
||||
),
|
||||
distributionType: DistributionType.AD_HOC,
|
||||
teamId: 'TEAMID123',
|
||||
};
|
||||
}
|
||||
|
||||
function createRawCredentials() {
|
||||
return {
|
||||
app: {
|
||||
provisioningProfileBase64: 'cHJvZmlsZQ==',
|
||||
distributionCertificate: {
|
||||
dataBase64: 'Y2VydA==',
|
||||
password: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createBuildStep({
|
||||
callInputs = {},
|
||||
job = createTestIosJob(),
|
||||
}: {
|
||||
callInputs?: Record<string, unknown>;
|
||||
job?: ReturnType<typeof createTestIosJob>;
|
||||
} = {}) {
|
||||
return configureIosVersionFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { job },
|
||||
}),
|
||||
{ callInputs }
|
||||
);
|
||||
}
|
||||
|
||||
describe(configureIosVersionFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(updateVersionsAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('uses target_names without an explicit credentials input', async () => {
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: ['app', 'widget'],
|
||||
build_number: '42',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: '42',
|
||||
appVersion: undefined,
|
||||
},
|
||||
{
|
||||
targetNames: ['app', 'widget'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('uses credentials only to preserve the existing fallback path', async () => {
|
||||
jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
credentials: createRawCredentials(),
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(IosCredentialsManager.prototype.prepare).toHaveBeenCalledTimes(1);
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: ['app', 'widget'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the implicit credentials default fallback from the job secrets', async () => {
|
||||
const prepareSpy = jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
|
||||
const buildStep = createBuildStep({
|
||||
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
|
||||
callInputs: {
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(prepareSpy).toHaveBeenCalledTimes(1);
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: ['app', 'widget'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers target_names when both target_names and credentials are provided', async () => {
|
||||
const prepareSpy = jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['credentials-target'] }));
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: ['input-target'],
|
||||
credentials: createRawCredentials(),
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(prepareSpy).not.toHaveBeenCalled();
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: ['input-target'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('parses target_names exported by configure_ios_credentials through workflow interpolation', async () => {
|
||||
const prepareSpy = jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: {
|
||||
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
|
||||
},
|
||||
});
|
||||
const configureIosCredentialsStep =
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'configure_ios_credentials',
|
||||
});
|
||||
const configureIosVersionStep = configureIosVersionFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
callInputs: {
|
||||
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await new BuildWorkflow(globalCtx, {
|
||||
buildSteps: [configureIosCredentialsStep, configureIosVersionStep],
|
||||
buildFunctions: {},
|
||||
}).executeAsync();
|
||||
|
||||
expect(prepareSpy).toHaveBeenCalledTimes(1);
|
||||
expect(configureIosCredentialsStep.outputById.target_names.value).toBe(
|
||||
JSON.stringify(['app', 'widget'])
|
||||
);
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
configureIosVersionStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: ['app', 'widget'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an empty target_names array without crashing', async () => {
|
||||
const prepareSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare');
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: [],
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
|
||||
expect(prepareSpy).not.toHaveBeenCalled();
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: [],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid target_names', async () => {
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: ['app', 123],
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
const promise = buildStep.executeAsync();
|
||||
await expect(promise).rejects.toBeInstanceOf(UserError);
|
||||
await expect(promise).rejects.toThrow('"target_names" input must be an array of strings.');
|
||||
});
|
||||
|
||||
it('uses the corrected App version error text', async () => {
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: ['app'],
|
||||
app_version: 'invalid-semver',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow(
|
||||
'App version provided by the "app_version" input is not a valid semver version: invalid-semver'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,290 +0,0 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { runEasCliCommand } from '../../../utils/easCli';
|
||||
import { createEasDeployBuildFunction } from '../deploy';
|
||||
|
||||
jest.mock('../../../utils/easCli', () => ({
|
||||
runEasCliCommand: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockDeployStdout = JSON.stringify({
|
||||
url: 'https://example.dev',
|
||||
production: { url: 'https://example.prod' },
|
||||
aliases: [{ url: 'https://example.alias' }],
|
||||
identifier: 'abc',
|
||||
dashboardUrl: 'https://expo.dev/dashboard',
|
||||
});
|
||||
|
||||
describe(createEasDeployBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('passes deploy flags from function inputs and sets deploy_json output', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(mockDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {
|
||||
alias: 'preview-link',
|
||||
prod: true,
|
||||
source_maps: false,
|
||||
},
|
||||
env: { HOME: '/tmp/home' },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Running deploy command: eas deploy --non-interactive --json --export-dir dist --alias preview-link --prod'
|
||||
);
|
||||
expect(runEasCliCommand).toHaveBeenCalledWith({
|
||||
args: [
|
||||
'deploy',
|
||||
'--non-interactive',
|
||||
'--json',
|
||||
'--export-dir',
|
||||
'dist',
|
||||
'--alias',
|
||||
'preview-link',
|
||||
'--prod',
|
||||
],
|
||||
options: expect.objectContaining({
|
||||
cwd: buildStep.ctx.workingDirectory,
|
||||
env: expect.objectContaining({ HOME: '/tmp/home' }),
|
||||
logger: buildStep.ctx.logger,
|
||||
}),
|
||||
});
|
||||
expect(buildStep.outputById.deploy_json.value).toBe(mockDeployStdout);
|
||||
expect(buildStep.outputById.deploy_url.value).toBe('https://example.prod');
|
||||
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev');
|
||||
expect(buildStep.outputById.deploy_identifier.value).toBe('abc');
|
||||
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard');
|
||||
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias');
|
||||
});
|
||||
|
||||
it('forwards workflow step env to the eas deploy process', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(mockDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { source_maps: true },
|
||||
env: { HOME: '/tmp/home', EXAMPLE_WORKFLOW_ENV: 'from-workflow' },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(runEasCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: expect.arrayContaining(['--source-maps']),
|
||||
})
|
||||
);
|
||||
expect(runEasCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
HOME: '/tmp/home',
|
||||
EXAMPLE_WORKFLOW_ENV: 'from-workflow',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an actionable error message when deploy command fails', async () => {
|
||||
jest.mocked(runEasCliCommand).mockRejectedValue(new Error('deploy failed'));
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
|
||||
let errorMessage = '';
|
||||
try {
|
||||
await buildStep.executeAsync();
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
expect(errorMessage).toContain('Deploy command failed.');
|
||||
expect(errorMessage).toContain('This can happen when deploy inputs are invalid');
|
||||
expect(errorMessage).toContain(
|
||||
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.'
|
||||
);
|
||||
expect(errorMessage).toContain('Original error: deploy failed');
|
||||
});
|
||||
|
||||
it('passes custom export_dir to eas deploy', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(mockDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { export_dir: 'web-build' },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(runEasCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: expect.arrayContaining(['--export-dir', 'web-build']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not pass --source-maps when source_maps is false', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(mockDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { source_maps: false },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const { args } = jest.mocked(runEasCliCommand).mock.calls[0][0];
|
||||
expect(args).not.toContain('--source-maps');
|
||||
});
|
||||
|
||||
it('sets deploy_json and skips optional outputs when stdout is not valid JSON', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from('not-json'),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.deploy_json.value).toBe('not-json');
|
||||
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.anything() }),
|
||||
expect.stringContaining('Failed to parse')
|
||||
);
|
||||
expect(buildStep.outputById.deploy_url.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets outputs from partial deploy JSON and leaves missing fields undefined', async () => {
|
||||
const partialDeployStdout = JSON.stringify({
|
||||
url: 'https://example.dev-only',
|
||||
dashboardUrl: 'https://expo.dev/dashboard-only',
|
||||
});
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(partialDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.deploy_json.value).toBe(partialDeployStdout);
|
||||
expect(buildStep.outputById.deploy_url.value).toBe('https://example.dev-only');
|
||||
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev-only');
|
||||
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard-only');
|
||||
expect(buildStep.outputById.deploy_identifier.value).toBeUndefined();
|
||||
expect(buildStep.outputById.deploy_alias_url.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets deploy_url from aliases[0].url when production is absent but aliases exist', async () => {
|
||||
const stdout = JSON.stringify({
|
||||
url: 'https://example.deployment',
|
||||
aliases: [{ url: 'https://example.alias-first' }],
|
||||
identifier: 'partial-alias',
|
||||
});
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.deploy_url.value).toBe('https://example.alias-first');
|
||||
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.deployment');
|
||||
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias-first');
|
||||
expect(buildStep.outputById.deploy_identifier.value).toBe('partial-alias');
|
||||
});
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
jest.mock('../../../utils/project', () => ({
|
||||
runExpoCliCommand: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/packageManager', () => ({
|
||||
...jest.requireActual('../../../utils/packageManager'),
|
||||
resolvePackageManager: jest.fn(),
|
||||
}));
|
||||
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { PackageManager, resolvePackageManager } from '../../../utils/packageManager';
|
||||
import { runExpoCliCommand } from '../../../utils/project';
|
||||
import { createEasExportBuildFunction } from '../export';
|
||||
|
||||
describe(createEasExportBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.NPM);
|
||||
jest.mocked(runExpoCliCommand).mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
it('runs expo export via runExpoCliCommand with default output_dir and platform web', async () => {
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
env: { HOME: '/tmp/home' },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Running export command: expo export --output-dir dist --platform web'
|
||||
);
|
||||
expect(runExpoCliCommand).toHaveBeenCalledWith({
|
||||
packageManager: PackageManager.NPM,
|
||||
args: ['export', '--output-dir', 'dist', '--platform', 'web'],
|
||||
options: {
|
||||
cwd: buildStep.ctx.workingDirectory,
|
||||
env: expect.objectContaining({ HOME: '/tmp/home' }),
|
||||
logger: buildStep.ctx.logger,
|
||||
stdio: 'pipe',
|
||||
},
|
||||
});
|
||||
expect(buildStep.outputById.export_dir.value).toBe('dist');
|
||||
});
|
||||
|
||||
it('passes optional flags matching expo export CLI', async () => {
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {
|
||||
output_dir: 'web-out',
|
||||
dev: true,
|
||||
minify: false,
|
||||
dump_assetmap: true,
|
||||
ssg: false,
|
||||
api_only: true,
|
||||
platform: 'web',
|
||||
},
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(runExpoCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: [
|
||||
'export',
|
||||
'--output-dir',
|
||||
'web-out',
|
||||
'--platform',
|
||||
'web',
|
||||
'--dev',
|
||||
'--no-minify',
|
||||
'--dump-assetmap',
|
||||
'--no-ssg',
|
||||
'--api-only',
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(buildStep.outputById.export_dir.value).toBe('web-out');
|
||||
});
|
||||
|
||||
it('uses resolved package manager (e.g. yarn)', async () => {
|
||||
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.YARN);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(runExpoCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ packageManager: PackageManager.YARN })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when export command fails', async () => {
|
||||
jest.mocked(runExpoCliCommand).mockRejectedValue(new Error('export failed'));
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow('Export command failed: export failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { spawnAsync } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import os from 'os';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { findMaestroPathsFlowsToExecuteAsync } from '../../../utils/findMaestroPathsFlowsToExecuteAsync';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { createInternalEasMaestroTestFunction } from '../internalMaestroTest';
|
||||
|
||||
jest.mock('@expo/steps', () => ({
|
||||
...jest.requireActual('@expo/steps'),
|
||||
spawnAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils');
|
||||
jest.mock('../../../utils/findMaestroPathsFlowsToExecuteAsync');
|
||||
jest.mock('../../../utils/retry', () => ({
|
||||
retryAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawnAsync = jest.mocked(spawnAsync);
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
const mockedIosUtils = jest.mocked(IosSimulatorUtils);
|
||||
const mockedFindFlows = jest.mocked(findMaestroPathsFlowsToExecuteAsync);
|
||||
|
||||
describe(createInternalEasMaestroTestFunction, () => {
|
||||
const mockUploadArtifact = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vol.mkdirSync(os.tmpdir(), { recursive: true });
|
||||
|
||||
mockedSpawnAsync.mockResolvedValue(undefined as any);
|
||||
mockedSpawn.mockResolvedValue({ stdout: 'source-emulator\n', stderr: '' } as any);
|
||||
mockUploadArtifact.mockResolvedValue({ artifactId: null });
|
||||
|
||||
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
|
||||
let lastErr: unknown;
|
||||
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
|
||||
try {
|
||||
return await fn(attemptCount);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
});
|
||||
|
||||
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([
|
||||
{ name: 'iPhone 15', udid: 'test-udid-123' } as any,
|
||||
]);
|
||||
mockedIosUtils.cloneAsync.mockResolvedValue(undefined as any);
|
||||
mockedIosUtils.startAsync.mockResolvedValue({ udid: 'cloned-udid' } as any);
|
||||
mockedIosUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
|
||||
mockedIosUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
|
||||
mockedIosUtils.deleteAsync.mockResolvedValue(undefined as any);
|
||||
|
||||
mockedAndroidUtils.getAttachedDevicesAsync.mockResolvedValue([
|
||||
{ serialId: 'emulator-5554', state: 'device' } as any,
|
||||
]);
|
||||
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined as any);
|
||||
mockedAndroidUtils.startAsync.mockResolvedValue({
|
||||
serialId: 'emulator-5556',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any);
|
||||
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
|
||||
mockedAndroidUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
|
||||
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined as any);
|
||||
|
||||
mockedFindFlows.mockResolvedValue(['/project/.maestro/home.yml']);
|
||||
});
|
||||
|
||||
function createStep(overrides?: { callInputs?: Record<string, unknown> }) {
|
||||
const ctx = {
|
||||
runtimeApi: { uploadArtifact: mockUploadArtifact },
|
||||
};
|
||||
const fn = createInternalEasMaestroTestFunction(ctx as any);
|
||||
return fn.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
}),
|
||||
{
|
||||
callInputs: {
|
||||
platform: 'ios',
|
||||
flow_paths: JSON.stringify(['.maestro']),
|
||||
...overrides?.callInputs,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
it('sets junit_report_directory output when output_format is junit', async () => {
|
||||
const step = createStep({
|
||||
callInputs: { output_format: 'junit' },
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const junitDir = step.getOutputValueByName('junit_report_directory');
|
||||
expect(junitDir).toMatch(/maestro-reports-/);
|
||||
});
|
||||
|
||||
it('does not set junit_report_directory output when output_format is not junit', async () => {
|
||||
const step = createStep();
|
||||
await step.executeAsync();
|
||||
|
||||
const junitDir = step.getOutputValueByName('junit_report_directory');
|
||||
expect(junitDir).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not set junit_report_directory when function throws before reaching output code', async () => {
|
||||
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
|
||||
const step = createStep({
|
||||
callInputs: { output_format: 'junit' },
|
||||
});
|
||||
|
||||
try {
|
||||
await step.executeAsync();
|
||||
} catch {
|
||||
// Expected - no booted device found
|
||||
}
|
||||
|
||||
const junitDir = step.getOutputValueByName('junit_report_directory');
|
||||
expect(junitDir).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retries Android clone startup with increasing timeouts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-1',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-2',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any);
|
||||
mockedAndroidUtils.waitForReadyAsync
|
||||
.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const step = createStep({
|
||||
callInputs: { platform: 'android' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-1',
|
||||
timeoutMs: 60_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-2',
|
||||
timeoutMs: 120_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-1',
|
||||
deviceName: 'eas-simulator-0-0',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('fails Android flow when clone startup exhausts all attempts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-1',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-2',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-3',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any);
|
||||
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
|
||||
|
||||
const step = createStep({
|
||||
callInputs: { platform: 'android' },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow('network unavailable');
|
||||
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-3',
|
||||
timeoutMs: 180_000,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { buildFlowNameToPathMap, readFlowName, walkDir } from '../maestroFlowDiscovery';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
|
||||
describe(readFlowName, () => {
|
||||
beforeEach(() => vol.reset());
|
||||
|
||||
it('returns top-level `name` when present', async () => {
|
||||
vol.fromJSON({
|
||||
'/p/login.yml': `appId: com.example\nname: Login Flow\n---\n- launchApp\n`,
|
||||
});
|
||||
expect(await readFlowName('/p/login.yml')).toBe('Login Flow');
|
||||
});
|
||||
|
||||
it('falls back to basename without extension when name absent', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `appId: com.example\n---\n- launchApp\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when name is empty string', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `name: ""\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when name is non-string', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `name: 42\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when YAML is malformed', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `appId: [unclosed\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when file is empty', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': '' });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when file does not exist', async () => {
|
||||
expect(await readFlowName('/p/missing.yml')).toBe('missing');
|
||||
});
|
||||
|
||||
it('only parses the first YAML document (commands document errors are ignored)', async () => {
|
||||
vol.fromJSON({
|
||||
'/p/login.yml': `name: Login\n---\n- this is: { malformed yaml\n`,
|
||||
});
|
||||
expect(await readFlowName('/p/login.yml')).toBe('Login');
|
||||
});
|
||||
|
||||
it('handles .yaml extension', async () => {
|
||||
vol.fromJSON({ '/p/checkout.yaml': `name: Checkout\n` });
|
||||
expect(await readFlowName('/p/checkout.yaml')).toBe('Checkout');
|
||||
});
|
||||
|
||||
it('handles a leading `---` document marker', async () => {
|
||||
vol.fromJSON({
|
||||
'/p/login.yml': `---\nappId: com.example\nname: Login\n---\n- launchApp\n`,
|
||||
});
|
||||
expect(await readFlowName('/p/login.yml')).toBe('Login');
|
||||
});
|
||||
});
|
||||
|
||||
describe(walkDir, () => {
|
||||
beforeEach(() => vol.reset());
|
||||
|
||||
it('collects all .yml/.yaml files recursively, sorted', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/p/flows/a.yml': '',
|
||||
'/p/flows/b.yaml': '',
|
||||
'/p/flows/sub/c.yml': '',
|
||||
'/p/flows/sub/d.YAML': '',
|
||||
'/p/flows/readme.md': '',
|
||||
});
|
||||
const out: string[] = [];
|
||||
await walkDir('/p/flows', new Set(), out, logger);
|
||||
expect(out.sort()).toEqual([
|
||||
'/p/flows/a.yml',
|
||||
'/p/flows/b.yaml',
|
||||
'/p/flows/sub/c.yml',
|
||||
'/p/flows/sub/d.YAML',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips files named config.yaml (case-insensitive)', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/p/flows/config.yaml': '',
|
||||
'/p/flows/CONFIG.yaml': '',
|
||||
'/p/flows/login.yml': '',
|
||||
});
|
||||
const out: string[] = [];
|
||||
await walkDir('/p/flows', new Set(), out, logger);
|
||||
expect(out.sort()).toEqual(['/p/flows/login.yml']);
|
||||
});
|
||||
|
||||
it('returns silently when realpath fails (e.g. missing dir)', async () => {
|
||||
const logger = createMockLogger();
|
||||
const out: string[] = [];
|
||||
await walkDir('/p/missing', new Set(), out, logger);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildFlowNameToPathMap, () => {
|
||||
beforeEach(() => vol.reset());
|
||||
|
||||
it('builds a map from a single file input (basename name)', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/flows/login.yml': 'appId: x\n' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['flows/login.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['login', 'flows/login.yml']]));
|
||||
});
|
||||
|
||||
it('uses top-level name when present', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/flows/a.yml': 'name: Login Flow\n' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['flows/a.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['Login Flow', 'flows/a.yml']]));
|
||||
});
|
||||
|
||||
it('walks a directory input and adds each flow', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/login.yml': 'name: Login\n',
|
||||
'/proj/.maestro/checkout.yml': '',
|
||||
'/proj/.maestro/sub/profile.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(
|
||||
new Map([
|
||||
['Login', '.maestro/login.yml'],
|
||||
['checkout', '.maestro/checkout.yml'],
|
||||
['profile', '.maestro/sub/profile.yml'],
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('merges file + directory inputs', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/a.yml': '',
|
||||
'/proj/extra/b.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro', 'extra/b.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(
|
||||
new Map([
|
||||
['a', '.maestro/a.yml'],
|
||||
['b', 'extra/b.yml'],
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('skips non-existent inputs with warn', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/flows/a.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['flows/missing.yml', 'flows/a.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['a', 'flows/a.yml']]));
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('flows/missing.yml'));
|
||||
});
|
||||
|
||||
it('skips workspace config.yaml inside directory inputs', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/config.yaml': 'flows:\n - "*.yml"\n',
|
||||
'/proj/.maestro/login.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
|
||||
});
|
||||
|
||||
it('does NOT skip config.yaml when passed as an explicit file input', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/config.yaml': 'name: Workspace Defaults\nflows:\n - "*.yml"\n',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro/config.yaml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['Workspace Defaults', '.maestro/config.yaml']]));
|
||||
});
|
||||
|
||||
it('returns null when two files share top-level name', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/a.yml': 'name: Login\n',
|
||||
'/proj/b.yml': 'name: Login\n',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['a.yml', 'b.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('"Login"'));
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('a.yml'));
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('b.yml'));
|
||||
});
|
||||
|
||||
it('returns null when two files share basename across directories', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/login/index.yml': '',
|
||||
'/proj/checkout/index.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['login', 'checkout'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when name collides with another file basename', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/login.yml': 'name: Other\n',
|
||||
'/proj/Other.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['login.yml', 'Other.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
});
|
||||
|
||||
it('dedupes the same physical file passed via overlapping inputs', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/.maestro/login.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro', '.maestro/login.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses absolute fallback when input file lives outside projectRoot', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/elsewhere/a.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['/elsewhere/a.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['a', '/elsewhere/a.yml']]));
|
||||
});
|
||||
|
||||
it('absolute directory input under projectRoot yields project-relative children', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/.maestro/a.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['/proj/.maestro'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['a', '.maestro/a.yml']]));
|
||||
});
|
||||
|
||||
it('returns null and warns on unexpected helper failure', async () => {
|
||||
const logger = createMockLogger();
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: [Symbol('bad') as unknown as string],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('buildFlowNameToPathMap failed unexpectedly')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,63 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseFlowMetadata,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
} from '../maestroResultParser';
|
||||
|
||||
describe(parseMaestroResults, () => {
|
||||
it('parses JUnit results and enriches with name→path map', async () => {
|
||||
describe(parseFlowMetadata, () => {
|
||||
it('parses valid ai-*.json', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toEqual({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when flow_name is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when flow_file_path is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid JSON', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': 'not json',
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMaestroResults, () => {
|
||||
it('parses JUnit results and enriches with ai-*.json metadata', async () => {
|
||||
vol.fromJSON({
|
||||
// JUnit XML (primary data)
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
@@ -24,15 +69,18 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Debug output (for flow_file_path)
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults(
|
||||
'/junit',
|
||||
new Map([
|
||||
['home', '.maestro/home.yml'],
|
||||
['login', '.maestro/login.yml'],
|
||||
])
|
||||
);
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -60,7 +108,7 @@ describe(parseMaestroResults, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses flow name as fallback path when nameToPath is null', async () => {
|
||||
it('calculates retryCount from timestamp directory occurrences', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
@@ -70,33 +118,49 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Two timestamp dirs = 1 retry
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', null);
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results[0].retryCount).toBe(1);
|
||||
});
|
||||
|
||||
it('uses flow name as fallback path when ai-*.json not found', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// No ai-*.json files
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results[0].path).toBe('home');
|
||||
expect(results[0].retryCount).toBe(0);
|
||||
});
|
||||
|
||||
it('uses flow name as fallback path when name not in map', async () => {
|
||||
it('returns empty array when no JUnit files found', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/junit/.gitkeep': '',
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', new Map());
|
||||
expect(results[0].path).toBe('home');
|
||||
});
|
||||
|
||||
it('returns empty array when no JUnit files found', async () => {
|
||||
vol.fromJSON({ '/junit/.gitkeep': '' });
|
||||
|
||||
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -115,152 +179,47 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results[0].tags).toEqual(['e2e', 'smoke']);
|
||||
expect(results[0].properties).toEqual({ env: 'staging' });
|
||||
});
|
||||
|
||||
it('returns per-attempt results when multiple JUnit files exist for the same flow', async () => {
|
||||
it('handles reuse_devices=true (junit_report_directory == tests_directory)', async () => {
|
||||
vol.fromJSON({
|
||||
// Attempt 0: login FAILED
|
||||
'/junit/junit-report-flow-1-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
|
||||
' <failure>Timeout</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Attempt 1: login PASSED
|
||||
'/junit/junit-report-flow-1-attempt-1.xml': [
|
||||
// Same directory for both JUnit and debug output
|
||||
'/maestro-tests/android-maestro-junit.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', new Map([['login', '.maestro/login.yml']]));
|
||||
|
||||
// Should return 2 results — one per attempt; retryCount from filename
|
||||
expect(results).toHaveLength(2);
|
||||
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'login',
|
||||
path: '.maestro/login.yml',
|
||||
status: 'failed',
|
||||
errorMessage: 'Timeout',
|
||||
duration: 5000,
|
||||
retryCount: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'login',
|
||||
path: '.maestro/login.yml',
|
||||
name: 'home',
|
||||
path: '.maestro/home.yml',
|
||||
status: 'passed',
|
||||
errorMessage: null,
|
||||
duration: 3000,
|
||||
retryCount: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns per-attempt results for reuse_devices=true (all flows in every attempt)', async () => {
|
||||
vol.fromJSON({
|
||||
// Attempt 0: home FAILED, login PASSED
|
||||
'/junit-reports/android-maestro-junit-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="2" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
|
||||
' <failure>Timeout</failure>',
|
||||
' </testcase>',
|
||||
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Attempt 1: both PASSED
|
||||
'/junit-reports/android-maestro-junit-attempt-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="2" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
|
||||
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults(
|
||||
'/junit-reports',
|
||||
new Map([
|
||||
['home', '.maestro/home.yml'],
|
||||
['login', '.maestro/login.yml'],
|
||||
])
|
||||
);
|
||||
|
||||
// 4 results: 2 flows × 2 attempts
|
||||
expect(results).toHaveLength(4);
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ name: 'home', status: 'failed', retryCount: 0 }),
|
||||
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
|
||||
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 0 }),
|
||||
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns per-attempt results with sharding (multiple testsuites per attempt file)', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit-reports/android-maestro-junit-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
|
||||
' <failure>Timeout</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="0">',
|
||||
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/junit-reports/android-maestro-junit-attempt-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="0">',
|
||||
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults(
|
||||
'/junit-reports',
|
||||
new Map([
|
||||
['home', '.maestro/home.yml'],
|
||||
['login', '.maestro/login.yml'],
|
||||
])
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(4);
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ name: 'home', status: 'failed', retryCount: 0 }),
|
||||
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
|
||||
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 0 }),
|
||||
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles reuse_devices=false (separate junit_report_directory, per-flow files)', async () => {
|
||||
it('handles reuse_devices=false (separate junit_report_directory)', async () => {
|
||||
vol.fromJSON({
|
||||
// JUnit in temp dir (per-flow files)
|
||||
'/tmp/maestro-reports-abc123/junit-report-flow-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
@@ -279,14 +238,21 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Debug output in default location
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults(
|
||||
'/tmp/maestro-reports-abc123',
|
||||
new Map([
|
||||
['home', '.maestro/home.yml'],
|
||||
['login', '.maestro/login.yml'],
|
||||
])
|
||||
'/tests',
|
||||
'/root/project'
|
||||
);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual(
|
||||
@@ -296,6 +262,51 @@ describe(parseMaestroResults, () => {
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('uses raw path when flow_file_path is outside project_root', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/somewhere/else/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results[0].path).toBe('/somewhere/else/.maestro/home.yml');
|
||||
});
|
||||
|
||||
it('filters out non-timestamp directories when scanning debug output', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/not-a-timestamp/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
// Only 1 occurrence (not-a-timestamp dir should be ignored)
|
||||
expect(results[0].retryCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseJUnitTestCases, () => {
|
||||
@@ -581,310 +592,3 @@ describe(parseJUnitTestCases, () => {
|
||||
expect(results[0].properties).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFailedFlowsFromJUnit', () => {
|
||||
it('returns the subset of input flow paths whose testcases failed', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites>
|
||||
<testsuite>
|
||||
<testcase name="Login" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Search" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Checkout" time="1.0"><failure>something</failure></testcase>
|
||||
</testsuite>
|
||||
</testsuites>`,
|
||||
});
|
||||
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
|
||||
nameToPath: new Map([
|
||||
['Login', 'flows/login.yaml'],
|
||||
['Search', 'flows/search.yaml'],
|
||||
['Checkout', 'flows/checkout.yaml'],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result).toEqual(['flows/checkout.yaml']);
|
||||
});
|
||||
|
||||
it('returns null when a failing testcase has no entry in nameToPath', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="Login" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Unknown" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/attempt-0.xml',
|
||||
nameToPath: new Map([['Login', 'flows/login.yaml']]),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when two failing testcases share the same name (collision)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="Duplicate" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="Duplicate" time="1.0"><failure>y</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
|
||||
nameToPath: new Map([['Duplicate', 'flows/a.yaml']]),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when a failing testcase shares a name with any other testcase (pass or fail)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="Shared" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Shared" time="2.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
|
||||
nameToPath: new Map([['Shared', 'flows/a.yaml']]),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when junit file does not exist', async () => {
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/missing.xml',
|
||||
nameToPath: new Map([['A', 'flows/a.yaml']]),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when junit file is malformed', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/bad.xml': 'this is not xml',
|
||||
});
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/bad.xml',
|
||||
nameToPath: new Map([['A', 'flows/a.yaml']]),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
|
||||
// fast-xml-parser can produce a partial parse from truncated XML — without
|
||||
// strict validation, retry-failed-only would only retry the visible failures and
|
||||
// silently skip flows that were cut off.
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="B"`, // intentionally truncated mid-tag
|
||||
});
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/attempt-0.xml',
|
||||
nameToPath: new Map([
|
||||
['A', 'flows/a.yaml'],
|
||||
['B', 'flows/b.yaml'],
|
||||
]),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when junit XML has unclosed tags (partial parse risk)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite>`, // missing </testsuites>
|
||||
});
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/attempt-0.xml',
|
||||
nameToPath: new Map([['A', 'flows/a.yaml']]),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeJUnitReports', () => {
|
||||
it('identity-copies a single attempt (preserves suite-level metadata)', async () => {
|
||||
// Single-attempt runs should land in final_report_path with the same
|
||||
// suite-level attributes (tests/failures/time) and non-testcase children
|
||||
// (e.g. <system-out>) that the legacy bash `cp` upload preserved. The
|
||||
// rebuild path used for multi-attempt merges drops these, so the single
|
||||
// case must short-circuit to a byte-equivalent copy.
|
||||
const input = `<?xml version="1.0"?>
|
||||
<testsuites>
|
||||
<testsuite name="Maestro Flows" tests="2" failures="0" time="3.0" device="Pixel 7">
|
||||
<testcase name="A" status="SUCCESS" time="1.0" />
|
||||
<testcase name="B" status="SUCCESS" time="2.0" />
|
||||
<system-out>boot complete</system-out>
|
||||
</testsuite>
|
||||
</testsuites>`;
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': input,
|
||||
});
|
||||
|
||||
await mergeJUnitReports({
|
||||
sourceDir: '/tmp/junit-reports',
|
||||
outputPath: '/tmp/final.xml',
|
||||
});
|
||||
|
||||
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
|
||||
expect(out).toBe(input);
|
||||
});
|
||||
|
||||
it('keeps the latest attempt per flow name across multiple files', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" status="SUCCESS" time="1.0" />
|
||||
<testcase name="B" time="2.0"><failure>bad</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/tmp/r/android-maestro-junit-attempt-1.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="B" status="SUCCESS" time="3.0" />
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
|
||||
|
||||
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
|
||||
const parsed = new XMLParser({ ignoreAttributes: false }).parse(out);
|
||||
const testcases = parsed.testsuites.testsuite.testcase;
|
||||
const a = testcases.find((t: any) => t['@_name'] === 'A');
|
||||
const b = testcases.find((t: any) => t['@_name'] === 'B');
|
||||
expect(a['@_status']).toBe('SUCCESS'); // from attempt 0
|
||||
expect(b['@_status']).toBe('SUCCESS'); // from attempt 1 (latest)
|
||||
expect(b.failure).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when source directory contains no *.xml files', async () => {
|
||||
// No per-attempt JUnit files were ever written (maestro crashed before
|
||||
// producing output). Phase 3 in runMaestroTests treats this as a SystemError
|
||||
// — without a throw here, mergeJUnitReports would silently write an empty
|
||||
// <testsuite> and the caller would upload a misleading empty report.
|
||||
vol.fromJSON({
|
||||
'/tmp/r/.gitkeep': '',
|
||||
});
|
||||
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow(/no \*\.xml files/);
|
||||
});
|
||||
|
||||
it('throws on invalid XML so caller can fall back to copyLatestAttemptXml', async () => {
|
||||
// Without a throw, mergeJUnitReports would silently emit an empty merged
|
||||
// document and the copy-latest fallback (which only triggers on throw)
|
||||
// would never run.
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': 'not xml at all',
|
||||
});
|
||||
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow(/invalid XML/);
|
||||
});
|
||||
|
||||
it('throws when every input has <testsuites> but no <testcase> elements', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite></testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow(/no parseable testcases/);
|
||||
});
|
||||
|
||||
it('throws when any input XML is malformed (even if others parse)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?><testsuites><testsuite><testcase name="A" status="SUCCESS" time="1.0"/></testsuite></testsuites>`,
|
||||
'/tmp/r/attempt-1.xml': 'garbage not xml',
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when an input XML is truncated mid-tag (would otherwise partial-parse)', async () => {
|
||||
// fast-xml-parser is lenient and can return partial results from truncated
|
||||
// XML. Without strict validation, mergeJUnitReports would emit a merged
|
||||
// report missing the cut-off flows and Phase 3's copy-latest fallback
|
||||
// would never run. Validation must reject the file.
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" status="SUCCESS" time="1.0"/>
|
||||
<testcase name="B"`, // truncated mid-tag
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when an input XML has unclosed tags (would otherwise partial-parse)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" status="SUCCESS" time="1.0"/>
|
||||
</testsuite>`, // missing </testsuites>
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when any input XML parses but has no testcases (even if others have testcases)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?><testsuites><testsuite><testcase name="A" status="SUCCESS" time="1.0"/></testsuite></testsuites>`,
|
||||
'/tmp/r/attempt-1.xml': `<?xml version="1.0"?><testsuites></testsuites>`,
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('preserves duplicate names within the same attempt', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="Dup" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Dup" status="SUCCESS" time="2.0" />
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
|
||||
|
||||
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
|
||||
const parsed = new XMLParser({ ignoreAttributes: false, isArray: n => n === 'testcase' }).parse(
|
||||
out
|
||||
);
|
||||
expect(parsed.testsuites.testsuite.testcase).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyLatestAttemptXml', () => {
|
||||
it('picks the highest attempt number', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': '<a/>',
|
||||
'/tmp/r/android-maestro-junit-attempt-1.xml': '<b/>',
|
||||
'/tmp/r/android-maestro-junit-attempt-2.xml': '<c/>',
|
||||
});
|
||||
await copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' });
|
||||
expect(await fs.readFile('/tmp/out.xml', 'utf-8')).toBe('<c/>');
|
||||
});
|
||||
|
||||
it('throws when source has no xml files', async () => {
|
||||
vol.fromJSON({ '/tmp/r/.keep': '' });
|
||||
await expect(
|
||||
copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,642 +0,0 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import * as discovery from '../maestroFlowDiscovery';
|
||||
import * as parser from '../maestroResultParser';
|
||||
import { createMaestroTestsBuildFunction } from '../maestroTests';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/retry', () => ({
|
||||
...jest.requireActual('../../../utils/retry'),
|
||||
sleepAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
const SPAWN_SUCCESS = {
|
||||
status: 0,
|
||||
pid: 1,
|
||||
output: [''],
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
signal: null,
|
||||
} as any;
|
||||
|
||||
const rejectExit1 = (): Error => {
|
||||
const e: any = new Error('exit 1');
|
||||
e.status = 1;
|
||||
return e;
|
||||
};
|
||||
|
||||
function createStep(
|
||||
callInputs?: Record<string, unknown>,
|
||||
options: { env?: Record<string, string | undefined> } = {}
|
||||
): ReturnType<
|
||||
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
|
||||
> {
|
||||
const logger = createMockLogger();
|
||||
const fn = createMaestroTestsBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({ logger });
|
||||
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
|
||||
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
|
||||
}
|
||||
|
||||
describe('createMaestroTestsBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
// Default-stub the merge helper so tests that don't care about it don't
|
||||
// hit the real disk. Individual tests override this.
|
||||
jest.restoreAllMocks();
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
});
|
||||
|
||||
it('exports a factory that returns a BuildFunction instance', () => {
|
||||
expect(createMaestroTestsBuildFunction()).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets all outputs before running any flows', async () => {
|
||||
// Make spawn reject so the step still throws; we're locking in that outputs
|
||||
// are set BEFORE the spawn call (downstream `upload_artifact` fails if
|
||||
// `final_report_path` is empty).
|
||||
mockedSpawn.mockRejectedValue(new Error('stop'));
|
||||
|
||||
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow();
|
||||
|
||||
expect(step.getOutputValueByName('junit_report_directory')).toMatch(
|
||||
/\.maestro\/tests\/junit-reports$/
|
||||
);
|
||||
expect(step.getOutputValueByName('tests_directory')).toMatch(/\.maestro\/tests$/);
|
||||
expect(step.getOutputValueByName('final_report_path')).toMatch(
|
||||
/\.maestro\/tests\/android-maestro-junit\.xml$/
|
||||
);
|
||||
});
|
||||
|
||||
it('succeeds when maestro exits 0 on first attempt', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
const [cmd, args] = mockedSpawn.mock.calls[0];
|
||||
expect(cmd).toBe('maestro');
|
||||
expect(args).toEqual(expect.arrayContaining(['test', 'flows/a.yaml', 'flows/b.yaml']));
|
||||
expect(args).toEqual(expect.arrayContaining(['--format=JUNIT']));
|
||||
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
|
||||
});
|
||||
|
||||
it('throws SystemError when spawn fails with ENOENT (binary missing)', async () => {
|
||||
mockedSpawn.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws SystemError for unknown-shape spawn rejections', async () => {
|
||||
mockedSpawn.mockRejectedValue(new Error('mystery'));
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws SystemError on signal-only spawn rejections (OOM / SIGTERM)', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('killed'), {
|
||||
status: null,
|
||||
signal: 'SIGKILL',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('treats reject-with-status as a retryable test failure', async () => {
|
||||
const rejectWithExit = (status: number): Error =>
|
||||
Object.assign(new Error(`maestro exited with non-zero code: ${status}`), {
|
||||
status,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
});
|
||||
mockedSpawn.mockRejectedValueOnce(rejectWithExit(1)).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
// Default `retries` is 0, so even though the first attempt rejects with
|
||||
// `status: 1`, no retry occurs and the post-loop check throws UserError.
|
||||
// Spawn ran once.
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries only the failed flows on the next attempt (junit mode)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Attempt 0 saw all 3
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
// Attempt 1 saw only the failed one
|
||||
const a1Args = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1Args).toContain('flows/b.yaml');
|
||||
expect(a1Args).not.toContain('flows/a.yaml');
|
||||
expect(a1Args).not.toContain('flows/c.yaml');
|
||||
});
|
||||
|
||||
it('retries all flows when parseFailedFlowsFromJUnit returns null', async () => {
|
||||
const spawnMock = mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(null);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Both attempts saw all 3 flows
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
expect(spawnMock.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('always retries all flows when output_format is not junit', async () => {
|
||||
const spawnMock = mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
expect(spawnMock.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('runs all original flows on every retry when retry_failed_only=false', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest
|
||||
.spyOn(parser, 'parseFailedFlowsFromJUnit')
|
||||
.mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
retry_failed_only: false,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Both attempts must see both flows in the original input order. Tail-slice
|
||||
// equality locks order, not just membership — guards against any future
|
||||
// failed-first reshuffling of the dumb-retry list.
|
||||
const a0 = mockedSpawn.mock.calls[0][1]!;
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a0.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
|
||||
expect(a1.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('subsets to failing flows on retry when retry_failed_only=true (default behaviour)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
retry_failed_only: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1).toContain('flows/b.yaml');
|
||||
expect(a1).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('falls back to all-flows retry when retry_failed_only=true but output_format!=junit', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'html',
|
||||
retry_failed_only: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to all-flows retry when nameToPath is null (duplicate flow names)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(discovery, 'buildFlowNameToPathMap').mockResolvedValue(null);
|
||||
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
retry_failed_only: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults retry_failed_only to true when omitted from inputs', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1).toContain('flows/b.yaml');
|
||||
expect(a1).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('writes merged junit on success', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mergeSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceDir: expect.stringContaining('junit-reports'),
|
||||
outputPath: expect.stringContaining('android-maestro-junit.xml'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to copyLatestAttemptXml when mergeJUnitReports throws a data error', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
|
||||
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(copySpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows merge and copy-latest failures when maestro succeeded', async () => {
|
||||
// Throwing on copy failure would mask the real reason for early-failing
|
||||
// maestro runs (bad YAML writes no *.xml) and would also wrap a user-side
|
||||
// failure as SystemError, which cancels the build's billing.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
|
||||
jest
|
||||
.spyOn(parser, 'copyLatestAttemptXml')
|
||||
.mockRejectedValue(Object.assign(new Error('no space'), { code: 'ENOSPC' }));
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces ERR_MAESTRO_TESTS_FAILED (not copy error) when maestro fails and copy fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('exit 1'), {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
jest.spyOn(parser, 'copyLatestAttemptXml').mockRejectedValue(new Error('No *.xml files found'));
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries are exhausted with a non-zero exit', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('exit 1'), {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/a.yaml']);
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: 2,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError early on empty flow_path', async () => {
|
||||
const step = createStep({ flow_path: [], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
|
||||
// whole-directory upload picks it up.
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
|
||||
expect(outputArg).not.toMatch(/junit-reports/);
|
||||
});
|
||||
|
||||
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'HTML',
|
||||
platform: 'ios',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/\.maestro\/tests\/ios-maestro-html\.html$/);
|
||||
});
|
||||
|
||||
it('does not include --output when output_format is undefined', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: '',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
expect(args.some(a => a.startsWith('--output'))).toBe(false);
|
||||
expect(args).not.toContain('--output');
|
||||
expect(args).not.toContain('--output=undefined');
|
||||
expect(args).not.toContain('--output=null');
|
||||
expect(args).not.toContain('--output=');
|
||||
});
|
||||
|
||||
it('accepts output_format casing variations (e.g. JUNIT) case-insensitively', async () => {
|
||||
// output_format.toLowerCase() must propagate end-to-end: final_report_path
|
||||
// populated, per-attempt XMLs written into junit-reports/, merge invoked.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'JUNIT',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Phase 1: final_report_path populated
|
||||
expect(step.getOutputValueByName('final_report_path')).toMatch(
|
||||
/\.maestro\/tests\/android-maestro-junit\.xml$/
|
||||
);
|
||||
// Retry loop: --output written into junit-reports/ (not legacy path)
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/junit-reports\/android-maestro-junit-attempt-0\.xml$/);
|
||||
// Phase 3: merge invoked
|
||||
expect(mergeSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceDir: expect.stringContaining('junit-reports'),
|
||||
outputPath: expect.stringContaining('android-maestro-junit.xml'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes include_tags, exclude_tags, and shards through to maestro argv', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
shards: 3,
|
||||
include_tags: 'smoke,critical',
|
||||
exclude_tags: 'slow',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('--shard-split=3');
|
||||
expect(args).toContain('--include-tags=smoke,critical');
|
||||
expect(args).toContain('--exclude-tags=slow');
|
||||
});
|
||||
|
||||
it('passes cwd and env to spawn so relative flow paths resolve correctly', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
|
||||
cwd?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
expect(spawnOptions).toBeDefined();
|
||||
expect(typeof spawnOptions.cwd).toBe('string');
|
||||
expect(spawnOptions.cwd!.length).toBeGreaterThan(0);
|
||||
expect(spawnOptions.env).toBeDefined();
|
||||
expect(spawnOptions.env!.HOME).toBe('/home/expo');
|
||||
});
|
||||
|
||||
it('exports MAESTRO_TESTS_DIR to maestro so flows can reference ${MAESTRO_TESTS_DIR}', async () => {
|
||||
// Public docs (EAS workflows pre-packaged-jobs) instruct users to write
|
||||
// screenshots/recordings into ${MAESTRO_TESTS_DIR}. The step must export
|
||||
// it in the spawn env.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
expect(spawnOptions.env!.MAESTRO_TESTS_DIR).toBe('/home/expo/.maestro/tests');
|
||||
});
|
||||
|
||||
it('uses env.HOME (not os.homedir()) for maestro tests output paths', async () => {
|
||||
// Even when os.homedir() differs, the step should derive paths from env.HOME
|
||||
// so maestro (which receives env) and the step (which inspects those paths)
|
||||
// agree on where reports land.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
},
|
||||
{ env: { HOME: '/custom/home' } }
|
||||
);
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('tests_directory')).toBe('/custom/home/.maestro/tests');
|
||||
expect(step.getOutputValueByName('junit_report_directory')).toBe(
|
||||
'/custom/home/.maestro/tests/junit-reports'
|
||||
);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
'/custom/home/.maestro/tests/android-maestro-junit.xml'
|
||||
);
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(
|
||||
/^--output=\/custom\/home\/\.maestro\/tests\/junit-reports\/android-maestro-junit-attempt-0\.xml$/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws SystemError when env.HOME is unset', async () => {
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws UserError on non-string entries in flow_path', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 2 as unknown as string],
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries is negative', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: -1,
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries is a non-integer number', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: 1.5,
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is zero', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 0 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is negative', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: -1 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is a non-integer', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 1.5 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('accepts shards: undefined (optional input)', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
|
||||
await step.executeAsync(); // should succeed, not throw
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { parseAndReportXcactivitylog } from '../../utils/ios/xcactivitylog';
|
||||
import { parseXcactivitylogFunction } from '../parseXcactivitylog';
|
||||
|
||||
jest.mock('../../utils/ios/xcactivitylog');
|
||||
|
||||
describe(parseXcactivitylogFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('passes proxyBaseUrl from env into parseAndReportXcactivitylog', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
globalContext.updateEnv({ EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com' });
|
||||
|
||||
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
proxyBaseUrl: 'https://cache.example.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes xclogparserVersion as undefined when the input is not provided', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
xclogparserVersion: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves derived_data_path and workspace_path inputs against stepCtx.workingDirectory', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {
|
||||
derived_data_path: 'my-app/ios/Build',
|
||||
workspace_path: 'my-app/ios',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
derivedDataPath: expect.stringMatching(/\/my-app\/ios\/Build$/),
|
||||
workspacePath: expect.stringMatching(/\/my-app\/ios$/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts absolute paths for derived_data_path and workspace_path', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {
|
||||
derived_data_path: '/abs/derived',
|
||||
workspace_path: '/abs/workspace',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
derivedDataPath: '/abs/derived',
|
||||
workspacePath: '/abs/workspace',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { readAppConfig } from '../../../utils/appConfig';
|
||||
import { createReadAppConfigBuildFunction } from '../readAppConfig';
|
||||
|
||||
jest.mock('../../../utils/appConfig');
|
||||
|
||||
describe(createReadAppConfigBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('reads and logs app configuration', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({
|
||||
logger,
|
||||
staticContextContent: { metadata: { sdkVersion: '54.0.0' } },
|
||||
});
|
||||
globalContext.updateEnv({ EXPO_PUBLIC_TEST: '1' });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest
|
||||
.mocked(readAppConfig)
|
||||
.mockResolvedValue({ exp: { name: 'my-app', slug: 'my-app-slug', version: '1.2.3' } } as any);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(readAppConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectDir: globalContext.defaultWorkingDirectory,
|
||||
env: expect.objectContaining({ EXPO_PUBLIC_TEST: '1' }),
|
||||
logger: stepLogger,
|
||||
sdkVersion: '54.0.0',
|
||||
})
|
||||
);
|
||||
expect(stepLogger.info).toHaveBeenCalledWith('Using app configuration:');
|
||||
expect(stepLogger.info).toHaveBeenCalledWith(
|
||||
JSON.stringify({ name: 'my-app', slug: 'my-app-slug', version: '1.2.3' }, null, 2)
|
||||
);
|
||||
const firstOutput = buildStep.outputById.app_config.value;
|
||||
expect(firstOutput).toBeDefined();
|
||||
expect(JSON.parse(firstOutput!)).toEqual({
|
||||
name: 'my-app',
|
||||
slug: 'my-app-slug',
|
||||
version: '1.2.3',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not throw if reading app config fails', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAppConfig).mockRejectedValue(new Error('failed to read app config'));
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('exports app fields as returned by app config', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest
|
||||
.mocked(readAppConfig)
|
||||
.mockResolvedValue({ exp: { name: 'my-app', slug: 123, version: undefined } } as any);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const secondOutput = buildStep.outputById.app_config.value;
|
||||
expect(secondOutput).toBeDefined();
|
||||
expect(JSON.parse(secondOutput!)).toEqual({
|
||||
name: 'my-app',
|
||||
slug: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { readAndLogPackageJson } from '../../../utils/project';
|
||||
import { createReadPackageJsonBuildFunction } from '../readPackageJson';
|
||||
|
||||
jest.mock('../../../utils/project');
|
||||
|
||||
describe(createReadPackageJsonBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('logs package.json contents', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({ logger });
|
||||
const buildStep =
|
||||
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAndLogPackageJson).mockReturnValue({ name: 'app', version: '1.0.0' });
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(readAndLogPackageJson).toHaveBeenCalledWith(
|
||||
stepLogger,
|
||||
globalContext.defaultWorkingDirectory
|
||||
);
|
||||
expect(buildStep.outputById.package_json.value).toBe(
|
||||
JSON.stringify({ name: 'app', version: '1.0.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw if reading package.json fails', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({ logger });
|
||||
const buildStep =
|
||||
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAndLogPackageJson).mockImplementation(() => {
|
||||
throw new Error('failed to read package.json');
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
expect(stepLogger.error).toHaveBeenCalledWith({
|
||||
err: expect.any(Error),
|
||||
});
|
||||
expect(buildStep.outputById.package_json.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+2
-45
@@ -72,7 +72,7 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
it('parses JUnit results and calls GraphQL mutation', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': JUNIT_PASS,
|
||||
'/root/project/.maestro/home.yml': '',
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
@@ -83,11 +83,7 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
},
|
||||
});
|
||||
|
||||
await createStep({
|
||||
callInputs: {
|
||||
flow_path: ['.maestro/home.yml'],
|
||||
},
|
||||
}).executeAsync();
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
|
||||
@@ -242,45 +238,6 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends per-attempt results with same name but different retryCount', async () => {
|
||||
vol.fromJSON({
|
||||
// Two JUnit files for same flow (per-attempt)
|
||||
'/junit/junit-report-flow-1-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
|
||||
' <failure>Tap failed</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/junit/junit-report-flow-1-attempt-1.xml': JUNIT_PASS,
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
'/tests/2026-01-28_055420/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }, { id: 'id-2' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
|
||||
expect(variables.input.testCaseResults).toHaveLength(2);
|
||||
expect(variables.input.testCaseResults[0]).toEqual(
|
||||
expect.objectContaining({ name: 'home', status: 'FAILED', retryCount: 0 })
|
||||
);
|
||||
expect(variables.input.testCaseResults[1]).toEqual(
|
||||
expect.objectContaining({ name: 'home', status: 'PASSED', retryCount: 1 })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses default directories when inputs are not provided', async () => {
|
||||
vol.fromJSON({
|
||||
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
|
||||
|
||||
@@ -26,7 +26,6 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
cloneAsync: jest.fn(),
|
||||
startAsync: jest.fn(),
|
||||
waitForReadyAsync: jest.fn(),
|
||||
disableWindowAndTransitionAnimationsAsync: jest.fn(),
|
||||
deleteAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -34,15 +33,15 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
|
||||
|
||||
function createStep(callInputs?: Record<string, unknown>) {
|
||||
const logger = createMockLogger();
|
||||
const fn = createStartAndroidEmulatorBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({ logger });
|
||||
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home', ...envOverrides });
|
||||
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
|
||||
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home' });
|
||||
return fn.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs,
|
||||
});
|
||||
return Object.assign(step, { logger });
|
||||
}
|
||||
|
||||
function createStartResult(serialId: string) {
|
||||
@@ -58,9 +57,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-default'));
|
||||
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
|
||||
|
||||
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
|
||||
@@ -100,12 +97,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
timeoutMs: 120_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-2222',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-1111',
|
||||
@@ -156,24 +147,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
timeoutMs: 60_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-base',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-clone-1-attempt-2',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-clone-2-attempt-1',
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -201,74 +174,4 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('skips animation scale adjustments when opt out env var is disabled', async () => {
|
||||
const step = createStep(undefined, {
|
||||
ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE: 'false',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails early on Linux when emulator host support check fails', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('accel check failed') as any);
|
||||
|
||||
const step = createStep();
|
||||
await expect(step.executeAsync()).rejects.toThrow(/nested virtualization/i);
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(false);
|
||||
});
|
||||
|
||||
it('continues startup on Linux when emulator host support check succeeds', async () => {
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips emulator host support check when opt out env var is enabled', async () => {
|
||||
const step = createStep(undefined, {
|
||||
EAS_NO_EMULATOR_HOST_SUPPORT_CHECK: '1',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command.includes('/emulator/emulator'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to empty SDK path when Android SDK path env vars are missing', async () => {
|
||||
await createStep(undefined, {
|
||||
ANDROID_HOME: undefined,
|
||||
ANDROID_SDK_ROOT: undefined,
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses ANDROID_SDK_ROOT when ANDROID_HOME is missing', async () => {
|
||||
await createStep(undefined, {
|
||||
ANDROID_HOME: undefined,
|
||||
ANDROID_SDK_ROOT: '/android/sdk/root',
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'/android/sdk/root/emulator/emulator',
|
||||
['-accel-check'],
|
||||
{
|
||||
env: expect.any(Object),
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
isClosedVersionTrainError,
|
||||
isInvalidBundleIdentifierError,
|
||||
isMissingPurposeStringError,
|
||||
isSdkVersionIssueError,
|
||||
parseMissingUsageDescriptionKeys,
|
||||
} from '../uploadToAsc';
|
||||
|
||||
@@ -59,20 +58,6 @@ describe(isMissingPurposeStringError, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(isSdkVersionIssueError, () => {
|
||||
it('returns true when all errors are SDK-version-issue codes', () => {
|
||||
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isSdkVersionIssueError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMissingUsageDescriptionKeys, () => {
|
||||
it('extracts missing UsageDescription keys from ASC messages', () => {
|
||||
expect(
|
||||
|
||||
@@ -34,20 +34,18 @@ export function calculateEASUpdateRuntimeVersionFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { env, inputs, outputs }) => {
|
||||
const appConfig = (
|
||||
await readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
|
||||
})
|
||||
).exp;
|
||||
const appConfig = readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
|
||||
}).exp;
|
||||
|
||||
const platform =
|
||||
(inputs.platform.value as Platform) ?? stepCtx.global.staticContext.job.platform;
|
||||
|
||||
@@ -42,20 +42,18 @@ export function configureEASUpdateIfInstalledFunction(): BuildFunction {
|
||||
assert(job.platform, 'Configuring EAS Update in generic jobs is not supported.');
|
||||
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
|
||||
|
||||
const appConfig = (
|
||||
await readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: metadata?.sdkVersion,
|
||||
})
|
||||
).exp;
|
||||
const appConfig = readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: metadata?.sdkVersion,
|
||||
}).exp;
|
||||
|
||||
const channelInput = inputs.channel.value as string | undefined;
|
||||
const runtimeVersionInput = inputs.runtime_version.value as string | undefined;
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import assert from 'assert';
|
||||
|
||||
import { configureCredentialsAsync } from '../utils/ios/configure';
|
||||
@@ -31,14 +26,9 @@ export function configureIosCredentialsFunction(): BuildFunction {
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'target_names',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, outputs }) => {
|
||||
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
|
||||
fn: async (stepCtx, { inputs }) => {
|
||||
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
|
||||
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
|
||||
stripUnknown: true,
|
||||
convert: true,
|
||||
abortEarly: false,
|
||||
@@ -60,7 +50,6 @@ export function configureIosCredentialsFunction(): BuildFunction {
|
||||
inputs.build_configuration.value as string | undefined
|
||||
),
|
||||
});
|
||||
outputs.target_names.set(JSON.stringify(Object.keys(credentials.targetProvisioningProfiles)));
|
||||
|
||||
stepCtx.logger.info('Successfully configured iOS credentials');
|
||||
},
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Ios, UserError } from '@expo/eas-build-job';
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import assert from 'assert';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { updateVersionsAsync } from '../utils/ios/configure';
|
||||
import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials';
|
||||
@@ -18,15 +17,10 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'credentials',
|
||||
required: false,
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
defaultValue: '${ eas.job.secrets.buildCredentials }',
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'target_names',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_configuration',
|
||||
required: false,
|
||||
@@ -44,6 +38,19 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs }) => {
|
||||
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
|
||||
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
|
||||
stripUnknown: true,
|
||||
convert: true,
|
||||
abortEarly: false,
|
||||
});
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const credentialsManager = new IosCredentialsManager(value);
|
||||
const credentials = await credentialsManager.prepare(stepCtx.logger);
|
||||
|
||||
assert(stepCtx.global.staticContext.job, 'Job is not defined');
|
||||
const job = stepCtx.global.staticContext.job as Ios.Job;
|
||||
|
||||
@@ -52,9 +59,8 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
const appVersion =
|
||||
(inputs.app_version.value as string | undefined) ?? job.version?.appVersion;
|
||||
if (appVersion && !semver.valid(appVersion)) {
|
||||
throw new UserError(
|
||||
'EAS_CONFIGURE_IOS_VERSION_INVALID_APP_VERSION',
|
||||
`App version provided by the "app_version" input is not a valid semver version: ${appVersion}`
|
||||
throw new Error(
|
||||
`App verrsion provided by the "app_version" input is not a valid semver version: ${appVersion}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,33 +71,6 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetNamesInput = inputs.target_names.value;
|
||||
let targetNames: string[];
|
||||
if (targetNamesInput !== undefined) {
|
||||
const parsed = z.array(z.string()).safeParse(targetNamesInput);
|
||||
if (!parsed.success) {
|
||||
throw new UserError(
|
||||
'EAS_CONFIGURE_IOS_VERSION_INVALID_TARGET_NAMES',
|
||||
'"target_names" input must be an array of strings.',
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
targetNames = parsed.data;
|
||||
} else {
|
||||
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
|
||||
stripUnknown: true,
|
||||
convert: true,
|
||||
abortEarly: false,
|
||||
});
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const credentialsManager = new IosCredentialsManager(value);
|
||||
const credentials = await credentialsManager.prepare(stepCtx.logger);
|
||||
targetNames = Object.keys(credentials.targetProvisioningProfiles);
|
||||
}
|
||||
|
||||
stepCtx.logger.info('Setting iOS version...');
|
||||
if (buildNumber) {
|
||||
stepCtx.logger.info(`Build number: ${buildNumber}`);
|
||||
@@ -108,7 +87,7 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
appVersion,
|
||||
},
|
||||
{
|
||||
targetNames,
|
||||
targetNames: Object.keys(credentials.targetProvisioningProfiles),
|
||||
buildConfiguration: resolveBuildConfiguration(
|
||||
job,
|
||||
inputs.build_configuration.value as string | undefined
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { PipeMode, bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
|
||||
import { runEasCliCommand } from '../../utils/easCli';
|
||||
|
||||
export function createEasDeployBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'deploy',
|
||||
name: 'Deploy',
|
||||
__metricsId: 'eas/deploy',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'alias',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'prod',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'source_maps',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
// Match eas/export default `output_dir`
|
||||
BuildStepInput.createProvider({
|
||||
id: 'export_dir',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
defaultValue: 'dist',
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_json',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_url',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_deployment_url',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_identifier',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_dashboard_url',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_alias_url',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs, outputs, env }) => {
|
||||
const alias = inputs.alias.value as string | undefined;
|
||||
const prod = inputs.prod.value as boolean | undefined;
|
||||
const sourceMaps = inputs.source_maps.value as boolean | undefined;
|
||||
const exportDir = inputs.export_dir.value as string;
|
||||
|
||||
const deployCommand = getDeployCommand({
|
||||
exportDir,
|
||||
alias,
|
||||
prod,
|
||||
sourceMaps,
|
||||
});
|
||||
stepsCtx.logger.info(`Running deploy command: eas ${deployCommand.join(' ')}`);
|
||||
|
||||
try {
|
||||
const result = await runEasCliCommand({
|
||||
args: deployCommand,
|
||||
options: {
|
||||
cwd: stepsCtx.workingDirectory,
|
||||
env,
|
||||
logger: stepsCtx.logger,
|
||||
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
|
||||
},
|
||||
});
|
||||
|
||||
const deployJson = result.stdout.toString();
|
||||
outputs.deploy_json.set(deployJson);
|
||||
const parsedDeploymentOutput = parseDeploymentOutput({
|
||||
deployJson,
|
||||
logger: stepsCtx.logger,
|
||||
});
|
||||
outputs.deploy_url.set(parsedDeploymentOutput.deploy_url);
|
||||
outputs.deploy_deployment_url.set(parsedDeploymentOutput.deploy_deployment_url);
|
||||
outputs.deploy_identifier.set(parsedDeploymentOutput.deploy_identifier);
|
||||
outputs.deploy_dashboard_url.set(parsedDeploymentOutput.deploy_dashboard_url);
|
||||
outputs.deploy_alias_url.set(parsedDeploymentOutput.deploy_alias_url);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'unknown error';
|
||||
throw new UserError(
|
||||
'EAS_DEPLOY_FAILED',
|
||||
'Deploy command failed.\n\n' +
|
||||
`This can happen when deploy inputs are invalid, the export directory "${exportDir}" is missing, or EAS CLI authentication/network checks fail.\n` +
|
||||
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.\n' +
|
||||
`Original error: ${errorMessage}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getDeployCommand({
|
||||
exportDir,
|
||||
alias,
|
||||
prod,
|
||||
sourceMaps,
|
||||
}: {
|
||||
exportDir: string;
|
||||
alias?: string;
|
||||
prod?: boolean;
|
||||
sourceMaps?: boolean;
|
||||
}): string[] {
|
||||
const deployCommand = ['deploy', '--non-interactive', '--json', '--export-dir', exportDir];
|
||||
if (alias) {
|
||||
deployCommand.push('--alias', alias);
|
||||
}
|
||||
if (prod) {
|
||||
deployCommand.push('--prod');
|
||||
}
|
||||
if (sourceMaps) {
|
||||
deployCommand.push('--source-maps');
|
||||
}
|
||||
return deployCommand;
|
||||
}
|
||||
|
||||
function parseDeploymentOutput({ deployJson, logger }: { deployJson: string; logger: bunyan }): {
|
||||
deploy_url?: string;
|
||||
deploy_deployment_url?: string;
|
||||
deploy_identifier?: string;
|
||||
deploy_dashboard_url?: string;
|
||||
deploy_alias_url?: string;
|
||||
} {
|
||||
try {
|
||||
const deployObject = JSON.parse(deployJson);
|
||||
return {
|
||||
deploy_url:
|
||||
deployObject.production?.url || deployObject.aliases?.[0]?.url || deployObject.url,
|
||||
deploy_deployment_url: deployObject.url,
|
||||
deploy_identifier: deployObject.identifier,
|
||||
deploy_dashboard_url: deployObject.dashboardUrl,
|
||||
deploy_alias_url: deployObject.aliases?.[0]?.url,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Failed to parse "eas deploy" JSON output. Some outputs expected from this step will be undefined.'
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function eagerBundleBuildFunction(): BuildFunction {
|
||||
throw new Error('Eager bundle is not supported for SDK version < 52');
|
||||
}
|
||||
|
||||
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
|
||||
const packageManager = resolvePackageManager(stepsCtx.workingDirectory);
|
||||
const resolvedEASUpdateRuntimeVersion = inputs.resolved_eas_update_runtime_version.value as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
|
||||
import { resolvePackageManager } from '../../utils/packageManager';
|
||||
import { runExpoCliCommand } from '../../utils/project';
|
||||
|
||||
export function createEasExportBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'export',
|
||||
name: 'Export',
|
||||
__metricsId: 'eas/export',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'output_dir',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
defaultValue: 'dist',
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'dev',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'minify',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'dump_assetmap',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ssg',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'api_only',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'platform',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
defaultValue: 'web',
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'export_dir',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs, outputs, env }) => {
|
||||
const outputDir = inputs.output_dir.value as string;
|
||||
const dev = inputs.dev.value as boolean | undefined;
|
||||
const minify = inputs.minify.value as boolean | undefined;
|
||||
const dumpAssetmap = inputs.dump_assetmap.value as boolean | undefined;
|
||||
const ssg = inputs.ssg.value as boolean | undefined;
|
||||
const apiOnly = inputs.api_only.value as boolean | undefined;
|
||||
const platform = inputs.platform.value as string;
|
||||
|
||||
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
|
||||
const exportCommand = getExportCommand({
|
||||
outputDir,
|
||||
dev,
|
||||
minify,
|
||||
dumpAssetmap,
|
||||
ssg,
|
||||
apiOnly,
|
||||
platform,
|
||||
});
|
||||
stepsCtx.logger.info(`Running export command: expo ${exportCommand.join(' ')}`);
|
||||
|
||||
try {
|
||||
await runExpoCliCommand({
|
||||
packageManager,
|
||||
args: exportCommand,
|
||||
options: {
|
||||
cwd: stepsCtx.workingDirectory,
|
||||
env,
|
||||
logger: stepsCtx.logger,
|
||||
stdio: 'pipe',
|
||||
},
|
||||
});
|
||||
outputs.export_dir.set(outputDir);
|
||||
} catch (error) {
|
||||
throw new UserError(
|
||||
'EXPO_EXPORT_FAILED',
|
||||
`Export command failed: ${error instanceof Error ? error.message : 'unknown error'}`
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getExportCommand({
|
||||
outputDir,
|
||||
dev,
|
||||
minify,
|
||||
dumpAssetmap,
|
||||
ssg,
|
||||
apiOnly,
|
||||
platform,
|
||||
}: {
|
||||
outputDir: string;
|
||||
dev?: boolean;
|
||||
minify?: boolean;
|
||||
dumpAssetmap?: boolean;
|
||||
ssg?: boolean;
|
||||
apiOnly?: boolean;
|
||||
platform: string;
|
||||
}): string[] {
|
||||
const exportCommand = ['export', '--output-dir', outputDir, '--platform', platform];
|
||||
if (dev) {
|
||||
exportCommand.push('--dev');
|
||||
}
|
||||
if (minify === false) {
|
||||
exportCommand.push('--no-minify');
|
||||
}
|
||||
if (dumpAssetmap) {
|
||||
exportCommand.push('--dump-assetmap');
|
||||
}
|
||||
if (ssg === false) {
|
||||
exportCommand.push('--no-ssg');
|
||||
}
|
||||
if (apiOnly) {
|
||||
exportCommand.push('--api-only');
|
||||
}
|
||||
return exportCommand;
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@exp
|
||||
import { templateString } from '@expo/template-file';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials';
|
||||
@@ -37,10 +36,6 @@ const DEFAULT_CREDENTIALS_TEMPLATE = `
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("<%- LOGS_DIRECTORY %>")
|
||||
|
||||
derived_data_path("<%- DERIVED_DATA_PATH %>")
|
||||
result_bundle(true)
|
||||
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
|
||||
|
||||
output_directory("<%- OUTPUT_DIRECTORY %>")
|
||||
`;
|
||||
|
||||
@@ -60,9 +55,6 @@ const DEFAULT_SIMULATOR_TEMPLATE = `
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("<%- LOGS_DIRECTORY %>")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
|
||||
`;
|
||||
|
||||
export function generateGymfileFromTemplateFunction(): BuildFunction {
|
||||
@@ -189,7 +181,6 @@ export function generateGymfileFromTemplateFunction(): BuildFunction {
|
||||
ICLOUD_CONTAINER_ENVIRONMENT,
|
||||
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
|
||||
DERIVED_DATA_PATH: './build',
|
||||
RESULT_BUNDLE_PATH: path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`),
|
||||
...(PROFILES ? { PROFILES } : {}),
|
||||
...(credentials
|
||||
? {
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function installNodeModules(
|
||||
env: BuildStepEnv
|
||||
): Promise<void> {
|
||||
const { logger } = stepCtx;
|
||||
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
|
||||
const packageManager = resolvePackageManager(stepCtx.workingDirectory);
|
||||
const packagerRunDir = findPackagerRootDir(stepCtx.workingDirectory);
|
||||
|
||||
if (packagerRunDir !== stepCtx.workingDirectory) {
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
import { GenericArtifactType } from '@expo/eas-build-job';
|
||||
import { PipeMode, bunyan } from '@expo/logger';
|
||||
import { Result, asyncResult, result } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
AndroidDeviceSerialId,
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync';
|
||||
import { retryAsync } from '../../utils/retry';
|
||||
import { PlatformToProperNounMap } from '../../utils/strings';
|
||||
|
||||
export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: '__maestro_test',
|
||||
__metricsId: 'eas/__maestro_test',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'platform',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
id: 'flow_paths',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
id: 'retries',
|
||||
defaultValue: 1,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'include_tags',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'exclude_tags',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
id: 'shards',
|
||||
defaultValue: 1,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'output_format',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
id: 'record_screen',
|
||||
defaultValue: false,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'test_reports_artifact_id',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'junit_report_directory',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs: _inputs, env, outputs }) => {
|
||||
// inputs come in form of { value: unknown }. Here we parse them into a typed and validated object.
|
||||
const {
|
||||
platform,
|
||||
flow_paths,
|
||||
retries,
|
||||
include_tags,
|
||||
exclude_tags,
|
||||
shards,
|
||||
output_format,
|
||||
record_screen,
|
||||
} = z
|
||||
.object({
|
||||
platform: z.enum(['ios', 'android']),
|
||||
flow_paths: z.array(z.string()),
|
||||
retries: z.number().default(1),
|
||||
include_tags: z.string().optional(),
|
||||
exclude_tags: z.string().optional(),
|
||||
shards: z.number().default(1),
|
||||
output_format: z.string().optional(),
|
||||
record_screen: z.boolean().default(false),
|
||||
})
|
||||
.parse(
|
||||
Object.fromEntries(Object.entries(_inputs).map(([key, value]) => [key, value.value]))
|
||||
);
|
||||
|
||||
const flowPathsToExecute: string[] = [];
|
||||
for (const flowPath of flow_paths) {
|
||||
const flowPaths = await findMaestroPathsFlowsToExecuteAsync({
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
flowPath,
|
||||
logger: stepCtx.logger,
|
||||
includeTags: include_tags ? include_tags.split(',') : undefined,
|
||||
excludeTags: exclude_tags ? exclude_tags.split(',') : undefined,
|
||||
});
|
||||
if (flowPaths.length === 0) {
|
||||
stepCtx.logger.warn(`No flows to execute found in "${flowPath}".`);
|
||||
continue;
|
||||
}
|
||||
stepCtx.logger.info(
|
||||
`Marking for execution:\n- ${flowPaths
|
||||
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
|
||||
.join('\n- ')}`
|
||||
);
|
||||
stepCtx.logger.info('');
|
||||
flowPathsToExecute.push(...flowPaths);
|
||||
}
|
||||
|
||||
// TODO: Add support for shards. (Shouldn't be too difficult.)
|
||||
if (shards > 1) {
|
||||
stepCtx.logger.warn(
|
||||
'Sharding support has been temporarily disabled. Running tests on a single shard.'
|
||||
);
|
||||
}
|
||||
|
||||
// eas/__maestro_test does not start devices, it expects a single device to be already running
|
||||
// and configured with the app. Here we find the booted device and stop it.
|
||||
|
||||
let sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
const bootedDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'booted',
|
||||
});
|
||||
if (bootedDevices.length === 0) {
|
||||
throw new Error('No booted iOS Simulator found.');
|
||||
} else if (bootedDevices.length > 1) {
|
||||
throw new Error('Multiple booted iOS Simulators found.');
|
||||
}
|
||||
|
||||
const device = bootedDevices[0];
|
||||
stepCtx.logger.info(`Running tests on iOS Simulator: ${device.name}.`);
|
||||
|
||||
stepCtx.logger.info(`Preparing Simulator for tests...`);
|
||||
await spawnAsync('xcrun', ['simctl', 'shutdown', device.udid], {
|
||||
logger: stepCtx.logger,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
sourceDeviceIdentifier = device.name;
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
const connectedDevices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env });
|
||||
if (connectedDevices.length === 0) {
|
||||
throw new Error('No booted Android Emulator found.');
|
||||
} else if (connectedDevices.length > 1) {
|
||||
throw new Error('Multiple booted Android Emulators found.');
|
||||
}
|
||||
|
||||
const { serialId } = connectedDevices[0];
|
||||
const adbEmuAvdNameResult = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
|
||||
mode: PipeMode.COMBINED,
|
||||
env,
|
||||
});
|
||||
const avdName = adbEmuAvdNameResult.stdout
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')[0] as AndroidVirtualDeviceName;
|
||||
stepCtx.logger.info(`Running tests on Android Emulator: ${avdName}.`);
|
||||
|
||||
stepCtx.logger.info(`Preparing Emulator for tests...`);
|
||||
await spawnAsync('adb', ['-s', serialId, 'emu', 'kill'], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
// Waiting for emulator to get killed, see ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL.
|
||||
await setTimeout(1000);
|
||||
|
||||
sourceDeviceIdentifier = avdName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// During tests we generate reports and device logs. We store them in temporary directories
|
||||
// and upload them once all tests are done. When a test is retried, new reports overwrite
|
||||
// the old ones. The files are named "flow-${index}" for easier identification.
|
||||
const maestroReportsDir = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'maestro-reports-')
|
||||
);
|
||||
const deviceLogsDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'device-logs-'));
|
||||
|
||||
const failedFlows: string[] = [];
|
||||
|
||||
for (const [flowIndex, flowPath] of flowPathsToExecute.entries()) {
|
||||
stepCtx.logger.info('');
|
||||
|
||||
// If output_format is empty or noop, we won't use this.
|
||||
const outputPath = path.join(
|
||||
maestroReportsDir,
|
||||
[
|
||||
`${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}`,
|
||||
MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('.')
|
||||
);
|
||||
|
||||
for (let attemptCount = 0; attemptCount < retries; attemptCount++) {
|
||||
const localDeviceName = `eas-simulator-${flowIndex}-${attemptCount}` as
|
||||
| IosSimulatorName
|
||||
| AndroidVirtualDeviceName;
|
||||
|
||||
// If the test passes, but the recording fails, we don't want to make the test fail,
|
||||
// so we return two separate results.
|
||||
const {
|
||||
fnResult: { fnResult, recordingResult },
|
||||
logsResult,
|
||||
} = await withCleanDeviceAsync({
|
||||
platform,
|
||||
sourceDeviceIdentifier,
|
||||
localDeviceName,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
fn: async ({ deviceIdentifier }) => {
|
||||
return await maybeWithScreenRecordingAsync({
|
||||
shouldRecord: record_screen,
|
||||
platform,
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
fn: async () => {
|
||||
stepCtx.logger.info('');
|
||||
|
||||
const [command, ...args] = getMaestroTestCommand({
|
||||
flow_path: flowPath,
|
||||
output_format,
|
||||
output_path: outputPath,
|
||||
});
|
||||
|
||||
try {
|
||||
await spawnAsync(command, args, {
|
||||
logger: stepCtx.logger,
|
||||
cwd: stepCtx.workingDirectory,
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} finally {
|
||||
stepCtx.logger.info('');
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Move device logs to the device logs directory.
|
||||
if (logsResult?.ok) {
|
||||
try {
|
||||
const extension = path.extname(logsResult.value.outputPath);
|
||||
const destinationPath = path.join(deviceLogsDir, `flow-${flowIndex}${extension}`);
|
||||
|
||||
await fs.promises.rm(destinationPath, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
});
|
||||
await fs.promises.rename(logsResult.value.outputPath, destinationPath);
|
||||
} catch (err) {
|
||||
stepCtx.logger.warn({ err }, 'Failed to prepare device logs for upload.');
|
||||
}
|
||||
} else if (logsResult?.reason) {
|
||||
stepCtx.logger.error({ err: logsResult.reason }, 'Failed to collect device logs.');
|
||||
}
|
||||
|
||||
const isLastAttempt = fnResult.ok || attemptCount === retries - 1;
|
||||
if (isLastAttempt && recordingResult.value) {
|
||||
try {
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
logger: stepCtx.logger,
|
||||
artifact: {
|
||||
// TODO(sjchmiela): Add metadata to artifacts so we don't need to encode flow path and attempt in the name.
|
||||
name: `Screen Recording (${flowIndex}-${path.basename(
|
||||
flowPath,
|
||||
path.extname(flowPath)
|
||||
)})`,
|
||||
paths: [recordingResult.value],
|
||||
type: GenericArtifactType.OTHER,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
stepCtx.logger.warn({ err }, 'Failed to upload screen recording.');
|
||||
}
|
||||
}
|
||||
|
||||
if (fnResult.ok) {
|
||||
stepCtx.logger.info(`Test passed.`);
|
||||
// Break out of the retry loop.
|
||||
break;
|
||||
}
|
||||
|
||||
if (attemptCount < retries - 1) {
|
||||
stepCtx.logger.info(`Retrying test...`);
|
||||
stepCtx.logger.info('');
|
||||
continue;
|
||||
}
|
||||
|
||||
// fnResult.reason is not super interesting, but it does print out the full command so we can keep it for debugging purposes.
|
||||
stepCtx.logger.error({ err: fnResult.reason }, 'Test errored.');
|
||||
failedFlows.push(flowPath);
|
||||
}
|
||||
}
|
||||
|
||||
stepCtx.logger.info('');
|
||||
|
||||
// When all tests are done, we upload the reports and device logs.
|
||||
const generatedMaestroReports = await fs.promises.readdir(maestroReportsDir);
|
||||
if (generatedMaestroReports.length === 0) {
|
||||
stepCtx.logger.warn('No reports were generated.');
|
||||
} else {
|
||||
stepCtx.logger.info(`Uploading reports...`);
|
||||
try {
|
||||
const { artifactId } = await ctx.runtimeApi.uploadArtifact({
|
||||
logger: stepCtx.logger,
|
||||
artifact: {
|
||||
name: `${PlatformToProperNounMap[platform]} Maestro Test Reports (${output_format})`,
|
||||
paths: [maestroReportsDir],
|
||||
type: GenericArtifactType.OTHER,
|
||||
},
|
||||
});
|
||||
if (artifactId) {
|
||||
outputs.test_reports_artifact_id.set(artifactId);
|
||||
}
|
||||
} catch (err) {
|
||||
stepCtx.logger.error({ err }, 'Failed to upload reports.');
|
||||
}
|
||||
}
|
||||
|
||||
if (output_format === 'junit') {
|
||||
outputs.junit_report_directory.set(maestroReportsDir);
|
||||
}
|
||||
|
||||
const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir);
|
||||
if (generatedDeviceLogs.length === 0) {
|
||||
stepCtx.logger.warn('No device logs were successfully collected.');
|
||||
} else {
|
||||
stepCtx.logger.info(`Uploading device logs...`);
|
||||
try {
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
logger: stepCtx.logger,
|
||||
artifact: {
|
||||
name: `Maestro Test Device Logs`,
|
||||
paths: [deviceLogsDir],
|
||||
type: GenericArtifactType.OTHER,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
stepCtx.logger.error({ err }, 'Failed to upload device logs.');
|
||||
}
|
||||
}
|
||||
|
||||
stepCtx.logger.info('');
|
||||
|
||||
// If any tests failed, we throw an error to mark the step as failed.
|
||||
if (failedFlows.length > 0) {
|
||||
throw new Error(
|
||||
`Some Maestro tests failed:\n- ${failedFlows
|
||||
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
|
||||
.join('\n- ')}`
|
||||
);
|
||||
} else {
|
||||
stepCtx.logger.info('All Maestro tests passed.');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getMaestroTestCommand(params: {
|
||||
flow_path: string;
|
||||
output_format: string | undefined;
|
||||
/** Unused if `output_format` is undefined */
|
||||
output_path: string;
|
||||
}): [command: string, ...args: string[]] {
|
||||
let outputFormatFlags: string[] = [];
|
||||
if (params.output_format) {
|
||||
outputFormatFlags = [`--format`, params.output_format, `--output`, params.output_path];
|
||||
}
|
||||
|
||||
return ['maestro', 'test', ...outputFormatFlags, params.flow_path] as [
|
||||
command: string,
|
||||
...args: string[],
|
||||
];
|
||||
}
|
||||
|
||||
const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
|
||||
junit: 'xml',
|
||||
html: 'html',
|
||||
};
|
||||
|
||||
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
|
||||
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
|
||||
|
||||
async function withCleanDeviceAsync<TResult>({
|
||||
platform,
|
||||
sourceDeviceIdentifier,
|
||||
localDeviceName,
|
||||
env,
|
||||
logger,
|
||||
fn,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
platform: 'ios' | 'android';
|
||||
sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
|
||||
localDeviceName: IosSimulatorName | AndroidVirtualDeviceName;
|
||||
fn: ({
|
||||
deviceIdentifier,
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
|
||||
}) => Promise<TResult>;
|
||||
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
|
||||
// Clone and start the device
|
||||
|
||||
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
logger.info(`Cloning iOS Simulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
|
||||
await IosSimulatorUtils.cloneAsync({
|
||||
sourceDeviceIdentifier: sourceDeviceIdentifier as IosSimulatorName,
|
||||
destinationDeviceName: localDeviceName as IosSimulatorName,
|
||||
env,
|
||||
});
|
||||
logger.info(`Starting iOS Simulator ${localDeviceName}...`);
|
||||
const { udid } = await IosSimulatorUtils.startAsync({
|
||||
deviceIdentifier: localDeviceName as IosSimulatorName,
|
||||
env,
|
||||
});
|
||||
logger.info(`Waiting for iOS Simulator ${localDeviceName} to be ready...`);
|
||||
await IosSimulatorUtils.waitForReadyAsync({
|
||||
udid,
|
||||
env,
|
||||
});
|
||||
localDeviceIdentifier = localDeviceName as IosSimulatorName;
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
const attempt = attemptCount + 1;
|
||||
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
|
||||
let serialId: AndroidDeviceSerialId | null = null;
|
||||
try {
|
||||
logger.info(
|
||||
`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
|
||||
);
|
||||
await AndroidEmulatorUtils.cloneAsync({
|
||||
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
|
||||
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(
|
||||
`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
|
||||
);
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
serialId = startResult.serialId;
|
||||
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
env,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
localDeviceIdentifier = serialId;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`
|
||||
);
|
||||
try {
|
||||
if (serialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId,
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
logger.warn(
|
||||
{ err: cleanupErr },
|
||||
`Failed to clean up Android Emulator ${localDeviceName}.`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: ANDROID_STARTUP_RETRIES_COUNT,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!localDeviceIdentifier) {
|
||||
throw new Error('Device did not return an identifier after startup.');
|
||||
}
|
||||
|
||||
// Run the function
|
||||
|
||||
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
|
||||
|
||||
// Stop the device
|
||||
|
||||
let logsResult: Result<{ outputPath: string }> | null = null;
|
||||
|
||||
try {
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
logger.info(`Collecting logs from ${localDeviceName}...`);
|
||||
logsResult = await asyncResult(
|
||||
IosSimulatorUtils.collectLogsAsync({
|
||||
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
|
||||
env,
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Cleaning up ${localDeviceName}...`);
|
||||
await IosSimulatorUtils.deleteAsync({
|
||||
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
|
||||
env,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
logger.info(`Collecting logs from ${localDeviceName}...`);
|
||||
logsResult = await asyncResult(
|
||||
AndroidEmulatorUtils.collectLogsAsync({
|
||||
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
|
||||
env,
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Cleaning up ${localDeviceName}...`);
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
|
||||
env,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error cleaning up device: ${err}`);
|
||||
}
|
||||
|
||||
return { fnResult: fnResult.enforceValue(), logsResult };
|
||||
}
|
||||
|
||||
/** Runs provided `fn` function, optionally wrapping it with starting and stopping screen recording. */
|
||||
async function maybeWithScreenRecordingAsync<TResult>({
|
||||
shouldRecord,
|
||||
platform,
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
fn,
|
||||
}: {
|
||||
// As weird as it is, it's more convenient to have this function like `maybeWith...`
|
||||
// than "withScreenRecordingAsync" and `withScreenRecordingAsync(fn)` vs `fn` in the caller.
|
||||
shouldRecord: boolean;
|
||||
platform: 'ios' | 'android';
|
||||
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
fn: () => Promise<TResult>;
|
||||
}): Promise<{ fnResult: Result<TResult>; recordingResult: Result<string | null> }> {
|
||||
if (!shouldRecord) {
|
||||
return { fnResult: await asyncResult(fn()), recordingResult: result(null) };
|
||||
}
|
||||
|
||||
let recordingResult: Result<{
|
||||
recordingSpawn: SpawnPromise<SpawnResult>;
|
||||
outputPath?: string;
|
||||
}>;
|
||||
|
||||
// Start screen recording
|
||||
|
||||
logger.info(`Starting screen recording on ${deviceIdentifier}...`);
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
recordingResult = await asyncResult(
|
||||
IosSimulatorUtils.startScreenRecordingAsync({
|
||||
deviceIdentifier: deviceIdentifier as IosSimulatorName,
|
||||
env,
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
recordingResult = await asyncResult(
|
||||
AndroidEmulatorUtils.startScreenRecordingAsync({
|
||||
serialId: deviceIdentifier as AndroidDeviceSerialId,
|
||||
env,
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recordingResult.ok) {
|
||||
logger.warn('Failed to start screen recording.', recordingResult.reason);
|
||||
}
|
||||
|
||||
// Run the function
|
||||
|
||||
const fnResult = await asyncResult(fn());
|
||||
|
||||
// If recording failed there's nothing to stop, so we return the results
|
||||
|
||||
if (!recordingResult.ok) {
|
||||
return { fnResult, recordingResult: result(recordingResult.reason) };
|
||||
}
|
||||
|
||||
// If recording started, finish it
|
||||
|
||||
try {
|
||||
logger.info(`Stopping screen recording on ${deviceIdentifier}...`);
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
await IosSimulatorUtils.stopScreenRecordingAsync({
|
||||
recordingSpawn: recordingResult.value.recordingSpawn,
|
||||
});
|
||||
return {
|
||||
fnResult,
|
||||
// We know outputPath is defined, because startIosScreenRecording() should have filled it.
|
||||
recordingResult: result(recordingResult.value.outputPath!),
|
||||
};
|
||||
}
|
||||
case 'android': {
|
||||
const { outputPath } = await AndroidEmulatorUtils.stopScreenRecordingAsync({
|
||||
serialId: deviceIdentifier as AndroidDeviceSerialId,
|
||||
recordingSpawn: recordingResult.value.recordingSpawn,
|
||||
env,
|
||||
});
|
||||
return { fnResult, recordingResult: result(outputPath) };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to stop screen recording.', err);
|
||||
|
||||
return { fnResult, recordingResult: result(err as Error) };
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import yaml from 'yaml';
|
||||
|
||||
const YAML_EXT = /\.ya?ml$/i;
|
||||
const WORKSPACE_CONFIG_BASENAME = 'config.yaml';
|
||||
|
||||
export interface BuildFlowNameToPathMapArgs {
|
||||
inputFlowPaths: string[];
|
||||
projectRoot: string;
|
||||
logger: bunyan;
|
||||
}
|
||||
|
||||
interface FlowEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
realpath: string;
|
||||
}
|
||||
|
||||
export async function buildFlowNameToPathMap(
|
||||
args: BuildFlowNameToPathMapArgs
|
||||
): Promise<Map<string, string> | null> {
|
||||
try {
|
||||
const flows = await discoverFlows(args);
|
||||
return dedupAndDetectDuplicates(flows, args.logger);
|
||||
} catch (err: any) {
|
||||
args.logger.warn(`buildFlowNameToPathMap failed unexpectedly: ${err?.message ?? String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverFlows({
|
||||
inputFlowPaths,
|
||||
projectRoot,
|
||||
logger,
|
||||
}: BuildFlowNameToPathMapArgs): Promise<FlowEntry[]> {
|
||||
const realRootResult = await asyncResult(fs.realpath(projectRoot));
|
||||
const realRoot = realRootResult.ok ? realRootResult.value : projectRoot;
|
||||
const fileLists = await Promise.all(
|
||||
inputFlowPaths.map(async input => {
|
||||
const abs = path.resolve(projectRoot, input);
|
||||
const statResult = await asyncResult(fs.stat(abs));
|
||||
if (!statResult.ok) {
|
||||
logger.warn(`flow_path entry "${input}" not found, skipping`);
|
||||
return [];
|
||||
}
|
||||
const stat = statResult.value;
|
||||
if (stat.isFile() && YAML_EXT.test(abs)) {
|
||||
return [abs];
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
const out: string[] = [];
|
||||
await walkDir(abs, new Set(), out, logger);
|
||||
return out;
|
||||
}
|
||||
return [];
|
||||
})
|
||||
);
|
||||
return Promise.all(fileLists.flat().map(absFile => makeEntry(absFile, realRoot)));
|
||||
}
|
||||
|
||||
async function makeEntry(absFile: string, realRoot: string): Promise<FlowEntry> {
|
||||
const [name, realFileResult] = await Promise.all([
|
||||
readFlowName(absFile),
|
||||
asyncResult(fs.realpath(absFile)),
|
||||
]);
|
||||
const realFile = realFileResult.ok ? realFileResult.value : absFile;
|
||||
const rel = path.relative(realRoot, realFile);
|
||||
const value = rel.startsWith('..') || path.isAbsolute(rel) ? absFile : rel;
|
||||
return { name, path: value, realpath: realFile };
|
||||
}
|
||||
|
||||
function dedupAndDetectDuplicates(flows: FlowEntry[], logger: bunyan): Map<string, string> | null {
|
||||
const byKey = new Map<string, FlowEntry>();
|
||||
for (const f of flows) {
|
||||
if (!byKey.has(f.realpath)) {
|
||||
byKey.set(f.realpath, f);
|
||||
}
|
||||
}
|
||||
const unique = [...byKey.values()];
|
||||
|
||||
const nameToPath = new Map<string, string>();
|
||||
const duplicates = new Map<string, string[]>();
|
||||
for (const f of unique) {
|
||||
if (nameToPath.has(f.name)) {
|
||||
const existing = nameToPath.get(f.name)!;
|
||||
const list = duplicates.get(f.name) ?? [existing];
|
||||
list.push(f.path);
|
||||
duplicates.set(f.name, list);
|
||||
}
|
||||
nameToPath.set(f.name, f.path);
|
||||
}
|
||||
if (duplicates.size > 0) {
|
||||
for (const [name, paths] of duplicates) {
|
||||
logger.warn(`Duplicate Maestro flow name "${name}" across paths: ${paths.join(', ')}.`);
|
||||
}
|
||||
logger.warn(
|
||||
'Retry-failed-only is disabled for this run; will retry all flows on failure. ' +
|
||||
'Give each Maestro flow a unique name (file basename or top-level name) to enable retry-failed-only.'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return nameToPath;
|
||||
}
|
||||
|
||||
export async function walkDir(
|
||||
dir: string,
|
||||
visited: Set<string>,
|
||||
out: string[],
|
||||
logger: bunyan
|
||||
): Promise<void> {
|
||||
// Cycle protection: dedup on realpath (fall back to the original path when
|
||||
// realpath fails so we still make progress on missing or restricted dirs).
|
||||
const realResult = await asyncResult(fs.realpath(dir));
|
||||
const real = realResult.ok ? realResult.value : dir;
|
||||
if (visited.has(real)) {
|
||||
return;
|
||||
}
|
||||
visited.add(real);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch (err: any) {
|
||||
logger.warn(`readdir failed for ${dir}: ${err?.message ?? String(err)}`);
|
||||
return;
|
||||
}
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
const tgtResult = await asyncResult(fs.stat(full));
|
||||
if (!tgtResult.ok) {
|
||||
continue;
|
||||
}
|
||||
const tgt = tgtResult.value;
|
||||
if (tgt.isDirectory()) {
|
||||
await walkDir(full, visited, out, logger);
|
||||
} else if (tgt.isFile() && isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
} else if (entry.isDirectory()) {
|
||||
await walkDir(full, visited, out, logger);
|
||||
} else if (entry.isFile()) {
|
||||
if (isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFlowName(absFile: string): Promise<string> {
|
||||
const ext = path.extname(absFile);
|
||||
const fallback = path.basename(absFile, ext);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absFile, 'utf-8');
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let firstDoc;
|
||||
try {
|
||||
const docs = yaml.parseAllDocuments(content);
|
||||
firstDoc = docs[0];
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
if (!firstDoc || firstDoc.errors.length > 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = firstDoc.toJS();
|
||||
const name = parsed?.name;
|
||||
if (typeof name === 'string' && name.length > 0) {
|
||||
return name;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function isYamlFile(name: string): boolean {
|
||||
return YAML_EXT.test(name);
|
||||
}
|
||||
|
||||
function isWorkspaceConfig(name: string): boolean {
|
||||
return name.toLowerCase() === WORKSPACE_CONFIG_BASENAME;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface MaestroFlowResult {
|
||||
name: string;
|
||||
@@ -13,8 +14,13 @@ export interface MaestroFlowResult {
|
||||
properties: Record<string, string>;
|
||||
}
|
||||
|
||||
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
|
||||
const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
||||
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
|
||||
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
|
||||
|
||||
export function extractFlowKey(filename: string, prefix: string): string | null {
|
||||
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
export interface JUnitTestCaseResult {
|
||||
name: string;
|
||||
@@ -32,86 +38,6 @@ const xmlParser = new XMLParser({
|
||||
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
||||
});
|
||||
|
||||
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
const results: JUnitTestCaseResult[] = [];
|
||||
try {
|
||||
const parsed = xmlParser.parse(content);
|
||||
|
||||
const testsuites = parsed?.testsuites?.testsuite;
|
||||
if (!Array.isArray(testsuites)) {
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const suite of testsuites) {
|
||||
const testcases = suite?.testcase;
|
||||
if (!Array.isArray(testcases)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const tc of testcases) {
|
||||
const name = tc['@_name'];
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timeStr = tc['@_time'];
|
||||
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
|
||||
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
|
||||
|
||||
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
|
||||
const failureText =
|
||||
tc.failure != null
|
||||
? typeof tc.failure === 'string'
|
||||
? tc.failure
|
||||
: (tc.failure?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorText =
|
||||
tc.error != null
|
||||
? typeof tc.error === 'string'
|
||||
? tc.error
|
||||
: (tc.error?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorMessage: string | null = failureText ?? errorText ?? null;
|
||||
|
||||
const rawProperties: { '@_name': string; '@_value': string }[] =
|
||||
tc.properties?.property ?? [];
|
||||
const properties: Record<string, string> = {};
|
||||
for (const prop of rawProperties) {
|
||||
const propName = prop['@_name'];
|
||||
const value = prop['@_value'];
|
||||
if (typeof propName !== 'string' || typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
properties[propName] = value;
|
||||
}
|
||||
|
||||
const tagsValue = properties['tags'];
|
||||
const tags: string[] = tagsValue
|
||||
? tagsValue
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
delete properties['tags'];
|
||||
|
||||
results.push({ name, status, duration, errorMessage, tags, properties });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed XML — return whatever we collected before the parser bailed.
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
return parseJUnitContent(content);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
@@ -125,307 +51,214 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
|
||||
return [];
|
||||
}
|
||||
|
||||
const perFile = await Promise.all(
|
||||
xmlFiles.map(f => parseJUnitFile(path.join(junitDirectory, f)))
|
||||
);
|
||||
return perFile.flat();
|
||||
}
|
||||
const results: JUnitTestCaseResult[] = [];
|
||||
|
||||
export async function parseMaestroResults(
|
||||
junitDirectory: string,
|
||||
nameToPath: Map<string, string> | null
|
||||
): Promise<MaestroFlowResult[]> {
|
||||
let junitEntries: string[];
|
||||
try {
|
||||
junitEntries = await fs.readdir(junitDirectory);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
|
||||
if (xmlFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
interface JUnitResultWithSource {
|
||||
result: JUnitTestCaseResult;
|
||||
sourceFile: string;
|
||||
}
|
||||
|
||||
const junitResultsWithSource: JUnitResultWithSource[] = [];
|
||||
for (const xmlFile of xmlFiles) {
|
||||
const fileResults = await parseJUnitFile(path.join(junitDirectory, xmlFile));
|
||||
for (const result of fileResults) {
|
||||
junitResultsWithSource.push({ result, sourceFile: xmlFile });
|
||||
}
|
||||
}
|
||||
try {
|
||||
const content = await fs.readFile(path.join(junitDirectory, xmlFile), 'utf-8');
|
||||
const parsed = xmlParser.parse(content);
|
||||
|
||||
if (junitResultsWithSource.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const testsuites = parsed?.testsuites?.testsuite;
|
||||
if (!Array.isArray(testsuites)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const results: MaestroFlowResult[] = [];
|
||||
for (const suite of testsuites) {
|
||||
const testcases = suite?.testcase;
|
||||
if (!Array.isArray(testcases)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resultsByName = new Map<string, JUnitResultWithSource[]>();
|
||||
for (const entry of junitResultsWithSource) {
|
||||
const group = resultsByName.get(entry.result.name) ?? [];
|
||||
group.push(entry);
|
||||
resultsByName.set(entry.result.name, group);
|
||||
}
|
||||
for (const tc of testcases) {
|
||||
const name = tc['@_name'];
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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 timeStr = tc['@_time'];
|
||||
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
|
||||
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
|
||||
|
||||
for (const { result, attemptIndex } of sorted) {
|
||||
results.push({
|
||||
name: flowName,
|
||||
path: flowPath,
|
||||
status: result.status,
|
||||
errorMessage: result.errorMessage,
|
||||
duration: result.duration,
|
||||
retryCount: attemptIndex,
|
||||
tags: result.tags,
|
||||
properties: result.properties,
|
||||
});
|
||||
// Use @_status as primary indicator (more robust than checking <failure> presence)
|
||||
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
|
||||
// Extract error message from <failure> or <error> elements
|
||||
const failureText =
|
||||
tc.failure != null
|
||||
? typeof tc.failure === 'string'
|
||||
? tc.failure
|
||||
: (tc.failure?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorText =
|
||||
tc.error != null
|
||||
? typeof tc.error === 'string'
|
||||
? tc.error
|
||||
: (tc.error?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorMessage: string | null = failureText ?? errorText ?? null;
|
||||
|
||||
// Extract properties
|
||||
const rawProperties: { '@_name': string; '@_value': string }[] =
|
||||
tc.properties?.property ?? [];
|
||||
const properties: Record<string, string> = {};
|
||||
|
||||
for (const prop of rawProperties) {
|
||||
const propName = prop['@_name'];
|
||||
const value = prop['@_value'];
|
||||
if (typeof propName !== 'string' || typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
properties[propName] = value;
|
||||
}
|
||||
|
||||
// Extract tags from "tags" property (Maestro 2.2.0+, comma-separated)
|
||||
const tagsValue = properties['tags'];
|
||||
const tags: string[] = tagsValue
|
||||
? tagsValue
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
delete properties['tags'];
|
||||
|
||||
results.push({ name, status, duration, errorMessage, tags, properties });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed XML files
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const FlowMetadataFileSchema = z.object({
|
||||
flow_name: z.string(),
|
||||
flow_file_path: z.string(),
|
||||
});
|
||||
|
||||
type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
|
||||
*
|
||||
* 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.
|
||||
* 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 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;
|
||||
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 (testcases.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failing = testcases.filter(tc => tc.status === 'failed');
|
||||
if (failing.length === 0) {
|
||||
export async function parseMaestroResults(
|
||||
junitDirectory: string,
|
||||
testsDirectory: string,
|
||||
projectRoot: string
|
||||
): Promise<MaestroFlowResult[]> {
|
||||
// 1. Parse JUnit XML files (primary source)
|
||||
const junitResults = await parseJUnitTestCases(junitDirectory);
|
||||
if (junitResults.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;
|
||||
}
|
||||
// 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 matched: string[] = [];
|
||||
for (const tc of failing) {
|
||||
const p = args.nameToPath.get(tc.name);
|
||||
if (p === undefined) {
|
||||
return null;
|
||||
}
|
||||
matched.push(p);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
|
||||
|
||||
/**
|
||||
* 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;
|
||||
for (const dir of timestampDirs) {
|
||||
const dirPath = path.join(testsDirectory, dir);
|
||||
let files: string[];
|
||||
try {
|
||||
parsed = xmlParser.parse(content);
|
||||
} catch (err) {
|
||||
throw new Error(`mergeJUnitReports: failed to parse ${filename}`, { cause: err });
|
||||
files = await fs.readdir(dirPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const testsuites = parsed?.testsuites?.testsuite;
|
||||
if (!Array.isArray(testsuites)) {
|
||||
throw new Error(`mergeJUnitReports: no <testsuite> array in ${filename}`);
|
||||
}
|
||||
const match = filename.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
const testcasesByName = new Map<string, unknown[]>();
|
||||
for (const suite of testsuites) {
|
||||
const cases = suite?.testcase;
|
||||
if (!Array.isArray(cases)) {
|
||||
|
||||
for (const file of files) {
|
||||
const flowKey = extractFlowKey(file, 'ai');
|
||||
if (!flowKey) {
|
||||
continue;
|
||||
}
|
||||
for (const tc of cases) {
|
||||
const name = tc?.['@_name'];
|
||||
if (typeof name !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const group = testcasesByName.get(name) ?? [];
|
||||
group.push(tc);
|
||||
testcasesByName.set(name, group);
|
||||
}
|
||||
}
|
||||
if (testcasesByName.size === 0) {
|
||||
throw new Error(`mergeJUnitReports: no parseable testcases in ${filename}`);
|
||||
}
|
||||
fileGroups.push({ attemptIndex, filename, content, testcasesByName });
|
||||
}
|
||||
|
||||
// Single attempt: copy the original XML so suite-level metadata (testsuite
|
||||
// attributes, <system-out>, etc.) survives. The rebuild path below would
|
||||
// collapse those to a single attribute-less <testsuite>.
|
||||
if (fileGroups.length === 1) {
|
||||
await fs.writeFile(args.outputPath, fileGroups[0].content);
|
||||
return;
|
||||
}
|
||||
|
||||
// For each unique name, pick the file with the highest attempt index that
|
||||
// contains it (ties broken by sorted filename — later wins). Preserve every
|
||||
// <testcase> element from the winning file for that name, so same-attempt
|
||||
// duplicates survive.
|
||||
const nameToWinningFile = new Map<string, FileGroup>();
|
||||
for (const group of fileGroups) {
|
||||
for (const name of group.testcasesByName.keys()) {
|
||||
const current = nameToWinningFile.get(name);
|
||||
if (!current || group.attemptIndex >= current.attemptIndex) {
|
||||
nameToWinningFile.set(name, group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit in first-seen order (iteration over `fileGroups` yields stable order
|
||||
// matching the sorted filename list).
|
||||
const testcases: unknown[] = [];
|
||||
const emitted = new Set<string>();
|
||||
for (const group of fileGroups) {
|
||||
for (const [name, cases] of group.testcasesByName) {
|
||||
if (emitted.has(name)) {
|
||||
const metadata = await parseFlowMetadata(path.join(dirPath, file));
|
||||
if (!metadata) {
|
||||
continue;
|
||||
}
|
||||
const winner = nameToWinningFile.get(name);
|
||||
if (winner === group) {
|
||||
for (const tc of cases) {
|
||||
testcases.push(tc);
|
||||
}
|
||||
emitted.add(name);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// 3. Merge: JUnit results + ai-*.json metadata
|
||||
const results: MaestroFlowResult[] = [];
|
||||
|
||||
/**
|
||||
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
|
||||
* Used as a fallback when mergeJUnitReports fails due to data issues but the
|
||||
* step still needs to produce final_report_path.
|
||||
*
|
||||
* Throws if sourceDir contains no *.xml files or if the copy fails.
|
||||
*/
|
||||
export async function copyLatestAttemptXml(args: {
|
||||
sourceDir: string;
|
||||
outputPath: string;
|
||||
}): Promise<void> {
|
||||
const entries = await fs.readdir(args.sourceDir);
|
||||
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
|
||||
if (xmlFiles.length === 0) {
|
||||
throw new Error(`No *.xml files found in ${args.sourceDir}`);
|
||||
for (const junit of junitResults) {
|
||||
const flowFilePath = flowPathMap.get(junit.name);
|
||||
const relativePath = flowFilePath
|
||||
? await relativizePathAsync(flowFilePath, projectRoot)
|
||||
: junit.name; // fallback: use flow name if ai-*.json not found
|
||||
|
||||
const occurrences = flowOccurrences.get(junit.name) ?? 0;
|
||||
const retryCount = Math.max(0, occurrences - 1);
|
||||
|
||||
results.push({
|
||||
name: junit.name,
|
||||
path: relativePath,
|
||||
status: junit.status,
|
||||
errorMessage: junit.errorMessage,
|
||||
duration: junit.duration,
|
||||
retryCount,
|
||||
tags: junit.tags,
|
||||
properties: junit.properties,
|
||||
});
|
||||
}
|
||||
|
||||
// Pick the file with the highest attempt index. Files without the marker are
|
||||
// treated as attempt 0. Ties are broken by sorted filename — later wins
|
||||
// (same rule as mergeJUnitReports).
|
||||
let winner = xmlFiles[0];
|
||||
let winnerAttempt = (() => {
|
||||
const m = winner.match(ATTEMPT_PATTERN);
|
||||
return m ? parseInt(m[1], 10) : 0;
|
||||
})();
|
||||
for (let i = 1; i < xmlFiles.length; i++) {
|
||||
const candidate = xmlFiles[i];
|
||||
const match = candidate.match(ATTEMPT_PATTERN);
|
||||
const attempt = match ? parseInt(match[1], 10) : 0;
|
||||
if (attempt >= winnerAttempt) {
|
||||
winner = candidate;
|
||||
winnerAttempt = attempt;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function relativizePathAsync(flowFilePath: string, projectRoot: string): Promise<string> {
|
||||
if (!path.isAbsolute(flowFilePath)) {
|
||||
return flowFilePath;
|
||||
}
|
||||
|
||||
await fs.copyFile(path.join(args.sourceDir, winner), args.outputPath);
|
||||
// Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
|
||||
let resolvedRoot = projectRoot;
|
||||
let resolvedFlow = flowFilePath;
|
||||
try {
|
||||
resolvedRoot = await fs.realpath(projectRoot);
|
||||
} catch {}
|
||||
try {
|
||||
resolvedFlow = await fs.realpath(flowFilePath);
|
||||
} catch {}
|
||||
|
||||
const relative = path.relative(resolvedRoot, resolvedFlow);
|
||||
if (relative.startsWith('..')) {
|
||||
return flowFilePath;
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowsFromJUnit,
|
||||
} from './maestroResultParser';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
const FlowPathSchema = z.array(z.string().min(1)).min(1);
|
||||
const RetriesSchema = z.number().int().min(0).default(0);
|
||||
const ShardsSchema = z.number().int().min(1).optional();
|
||||
|
||||
function parseInput<S extends z.ZodTypeAny>(
|
||||
schema: S,
|
||||
value: unknown,
|
||||
message: string
|
||||
): z.output<S> {
|
||||
const result = schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new UserError('ERR_MAESTRO_INVALID_INPUT', message, { cause: result.error });
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// ENOENT is excluded — "input XML missing" is a data issue, not a storage
|
||||
// fault, so the post-loop merge should fall through to copy-latest instead
|
||||
// of throwing.
|
||||
function isFilesystemError(err: any): boolean {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const code = err.code;
|
||||
return (
|
||||
code === 'ENOSPC' || code === 'EACCES' || code === 'EROFS' || code === 'EIO' || code === 'EPERM'
|
||||
);
|
||||
}
|
||||
|
||||
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
|
||||
// other declared formats pass an explicit path so downstream upload steps
|
||||
// know where to find the result.
|
||||
function buildMaestroArgs(args: {
|
||||
flow_path: string[];
|
||||
outputPath: string | null;
|
||||
output_format: string | undefined;
|
||||
shards: number | undefined;
|
||||
include_tags: string | undefined;
|
||||
exclude_tags: string | undefined;
|
||||
}): string[] {
|
||||
const out: string[] = ['test'];
|
||||
if (args.output_format) {
|
||||
out.push(`--format=${args.output_format.toUpperCase()}`);
|
||||
}
|
||||
if (args.outputPath) {
|
||||
out.push(`--output=${args.outputPath}`);
|
||||
}
|
||||
if (args.shards !== undefined) {
|
||||
out.push(`--shard-split=${args.shards}`);
|
||||
}
|
||||
if (args.include_tags) {
|
||||
out.push(`--include-tags=${args.include_tags}`);
|
||||
}
|
||||
if (args.exclude_tags) {
|
||||
out.push(`--exclude-tags=${args.exclude_tags}`);
|
||||
}
|
||||
out.push(...args.flow_path);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'maestro_tests',
|
||||
name: 'Run Maestro Tests',
|
||||
__metricsId: 'eas/maestro_tests',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'flow_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'retries',
|
||||
required: false,
|
||||
defaultValue: 0,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'retry_failed_only',
|
||||
required: false,
|
||||
defaultValue: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'shards',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'include_tags',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'exclude_tags',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'output_format',
|
||||
required: false,
|
||||
defaultValue: 'junit',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'platform',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
|
||||
BuildStepOutput.createProvider({ id: 'final_report_path', required: false }),
|
||||
BuildStepOutput.createProvider({ id: 'tests_directory', required: true }),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, outputs, env, signal }) => {
|
||||
const { logger, global } = stepCtx;
|
||||
const platformInput = inputs.platform.value as string | undefined;
|
||||
const outputFormat = (inputs.output_format.value as string | undefined)?.toLowerCase();
|
||||
const includeTags = inputs.include_tags.value as string | undefined;
|
||||
const excludeTags = inputs.exclude_tags.value as string | undefined;
|
||||
|
||||
const platform: 'ios' | 'android' =
|
||||
platformInput === 'ios' || platformInput === 'android'
|
||||
? platformInput
|
||||
: global.runtimePlatform === BuildRuntimePlatform.DARWIN
|
||||
? 'ios'
|
||||
: 'android';
|
||||
|
||||
// Paths derive from env.HOME (not os.homedir()). Maestro is spawned with
|
||||
// this env and writes debug output under $HOME/.maestro/tests; the step
|
||||
// must read from the same place or stale files leak across runs.
|
||||
const home = env.HOME;
|
||||
if (!home) {
|
||||
throw new SystemError('HOME env var is not set');
|
||||
}
|
||||
const testsDirectory = path.join(home, '.maestro', 'tests');
|
||||
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
|
||||
const finalReportPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
|
||||
: undefined;
|
||||
|
||||
// Public docs (EAS workflows pre-packaged-jobs) document
|
||||
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
|
||||
// the uploaded dir.
|
||||
const spawnEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
|
||||
|
||||
// Outputs are published BEFORE any throw below so downstream
|
||||
// `if: always()` upload steps still see populated values when this
|
||||
// step fails early.
|
||||
outputs.tests_directory.set(testsDirectory);
|
||||
outputs.junit_report_directory.set(junitReportDirectory);
|
||||
if (finalReportPath !== undefined) {
|
||||
outputs.final_report_path.set(finalReportPath);
|
||||
}
|
||||
|
||||
const flowPaths = parseInput(
|
||||
FlowPathSchema,
|
||||
inputs.flow_path.value,
|
||||
'flow_path must be a non-empty array of non-empty strings.'
|
||||
);
|
||||
const retries = parseInput(
|
||||
RetriesSchema,
|
||||
inputs.retries.value,
|
||||
'retries must be a non-negative integer.'
|
||||
);
|
||||
const shards = parseInput(
|
||||
ShardsSchema,
|
||||
inputs.shards.value,
|
||||
'shards must be a positive integer.'
|
||||
);
|
||||
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
|
||||
|
||||
try {
|
||||
await fs.mkdir(junitReportDirectory, { recursive: true });
|
||||
} catch (err) {
|
||||
throw new SystemError('Failed to create JUnit report directory', { cause: err });
|
||||
}
|
||||
|
||||
// null → duplicate flow names; retry-failed-only disabled, fall through to
|
||||
// dumb retry (re-run everything) on failure.
|
||||
const nameToPath = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: flowPaths,
|
||||
projectRoot: stepCtx.workingDirectory,
|
||||
logger,
|
||||
});
|
||||
|
||||
// Retry loop. spawn-async error shapes:
|
||||
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
|
||||
// numeric err.status → maestro exited non-zero → retry.
|
||||
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
|
||||
// downgraded to "tests failed".
|
||||
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
|
||||
// flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
|
||||
// trusted; we then fall through to dumb retry (re-run everything).
|
||||
let flowsToRun: string[] = flowPaths;
|
||||
let lastAttemptExitCode: number | null = null;
|
||||
|
||||
const totalAttempts = retries + 1;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
const outputPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
|
||||
: outputFormat
|
||||
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
|
||||
: null;
|
||||
|
||||
const maestroArgs = buildMaestroArgs({
|
||||
flow_path: flowsToRun,
|
||||
outputPath,
|
||||
output_format: outputFormat,
|
||||
shards,
|
||||
include_tags: includeTags,
|
||||
exclude_tags: excludeTags,
|
||||
});
|
||||
logger.info(
|
||||
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
|
||||
);
|
||||
|
||||
try {
|
||||
await spawn('maestro', maestroArgs, {
|
||||
cwd: stepCtx.workingDirectory,
|
||||
env: spawnEnv,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
lastAttemptExitCode = 0;
|
||||
} catch (err: any) {
|
||||
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
|
||||
throw new SystemError('Failed to invoke maestro', { cause: err });
|
||||
}
|
||||
if (err && typeof err.status === 'number') {
|
||||
lastAttemptExitCode = err.status;
|
||||
} else {
|
||||
throw new SystemError('Unexpected spawn failure invoking maestro', { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
if (lastAttemptExitCode === 0 || attempt === retries) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (retryFailedOnly && outputFormat === 'junit' && outputPath && nameToPath) {
|
||||
const failed = await parseFailedFlowsFromJUnit({
|
||||
junitFile: outputPath,
|
||||
nameToPath,
|
||||
});
|
||||
if (failed !== null && failed.length > 0) {
|
||||
flowsToRun = failed;
|
||||
logger.info(
|
||||
`Test failed; retrying ${failed.length} failed flow(s): ${failed.join(', ')}`
|
||||
);
|
||||
} else {
|
||||
flowsToRun = flowPaths;
|
||||
logger.info('Test failed; could not determine failed subset, retrying all flows');
|
||||
}
|
||||
} else {
|
||||
flowsToRun = flowPaths;
|
||||
logger.info('Test failed, retrying all flows');
|
||||
}
|
||||
|
||||
await sleepAsync(2000);
|
||||
}
|
||||
|
||||
// Smart merge first; on data errors (bad XML, missing input) fall back
|
||||
// to copy-latest so the caller still gets a single JUnit file.
|
||||
// Filesystem errors short-circuit straight to SystemError.
|
||||
if (finalReportPath !== undefined) {
|
||||
try {
|
||||
await mergeJUnitReports({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
});
|
||||
} catch (mergeErr: any) {
|
||||
if (isFilesystemError(mergeErr)) {
|
||||
throw new SystemError('Failed to write final_report_path', { cause: mergeErr });
|
||||
}
|
||||
logger.warn({ err: mergeErr }, 'Smart merge failed; falling back to copy-latest.');
|
||||
try {
|
||||
await copyLatestAttemptXml({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
});
|
||||
} catch (copyErr: any) {
|
||||
// Swallow: a copy failure here usually means maestro itself failed
|
||||
// early (bad YAML wrote no *.xml). Throwing SystemError would mask
|
||||
// the real reason and cancel billing for a user-side failure — let
|
||||
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
|
||||
logger.warn(
|
||||
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The retry loop exits via success (0), numeric status (retryable),
|
||||
// or throw (infra). A non-null non-zero status means the user's tests
|
||||
// failed every attempt.
|
||||
if (lastAttemptExitCode !== 0) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_TESTS_FAILED',
|
||||
`Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import path from 'path';
|
||||
|
||||
import { parseAndReportXcactivitylog } from '../utils/ios/xcactivitylog';
|
||||
|
||||
export function parseXcactivitylogFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'parse_xcactivitylog',
|
||||
name: 'Analyze build performance',
|
||||
__metricsId: 'eas/parse_xcactivitylog',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'derived_data_path',
|
||||
required: false,
|
||||
defaultValue: 'ios/build',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'workspace_path',
|
||||
required: false,
|
||||
defaultValue: 'ios',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'xclogparser_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, env }) => {
|
||||
const derivedDataPath = inputs.derived_data_path.value as string;
|
||||
const workspacePath = inputs.workspace_path.value as string;
|
||||
const version = inputs.xclogparser_version.value as string | undefined;
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: path.resolve(stepCtx.workingDirectory, derivedDataPath),
|
||||
workspacePath: path.resolve(stepCtx.workingDirectory, workspacePath),
|
||||
xclogparserVersion: version,
|
||||
logger: stepCtx.logger,
|
||||
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
env,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function createPrebuildBuildFunction(): BuildFunction {
|
||||
fn: async (stepCtx, { inputs, env }) => {
|
||||
const { logger } = stepCtx;
|
||||
const appleTeamId = inputs.apple_team_id.value as string | undefined;
|
||||
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
|
||||
const packageManager = resolvePackageManager(stepCtx.workingDirectory);
|
||||
const defaultPlatform = process.platform === 'darwin' ? 'ios' : 'android';
|
||||
|
||||
const job = stepCtx.global.staticContext.job;
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Metadata } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepOutput } from '@expo/steps';
|
||||
|
||||
import { readAppConfig } from '../../utils/appConfig';
|
||||
|
||||
export function createReadAppConfigBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'read_app_config',
|
||||
name: 'Read app config',
|
||||
__metricsId: 'eas/read_app_config',
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'app_config',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { env, outputs }) => {
|
||||
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
|
||||
try {
|
||||
const appConfig = (
|
||||
await readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: metadata?.sdkVersion,
|
||||
})
|
||||
).exp;
|
||||
stepCtx.logger.info('Using app configuration:');
|
||||
stepCtx.logger.info(JSON.stringify(appConfig, null, 2));
|
||||
outputs.app_config.set(JSON.stringify(appConfig));
|
||||
} catch {
|
||||
// readAppConfig logs underlying failures.
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { BuildFunction, BuildStepOutput } from '@expo/steps';
|
||||
|
||||
import { readAndLogPackageJson } from '../../utils/project';
|
||||
|
||||
export function createReadPackageJsonBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'read_package_json',
|
||||
name: 'Read package.json',
|
||||
__metricsId: 'eas/read_package_json',
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'package_json',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { outputs }) => {
|
||||
try {
|
||||
const packageJson = readAndLogPackageJson(stepCtx.logger, stepCtx.workingDirectory);
|
||||
outputs.package_json.set(JSON.stringify(packageJson));
|
||||
} catch (err: unknown) {
|
||||
stepCtx.logger.error({ err });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import resolveFrom from 'resolve-from';
|
||||
|
||||
import { COMMON_FASTLANE_ENV } from '../../common/fastlane';
|
||||
import IosCredentialsManager from '../utils/ios/credentials/manager';
|
||||
import { resolvePackageVersionAsync } from '../../utils/packageManager';
|
||||
|
||||
export function createRepackBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -52,11 +51,6 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'js_bundle_only',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'repack_version',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
@@ -88,10 +82,7 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
);
|
||||
}
|
||||
|
||||
const repackSpawnAsync = createSpawnAsyncStepAdapter({
|
||||
verbose,
|
||||
logger: stepsCtx.logger,
|
||||
});
|
||||
const repackSpawnAsync = createSpawnAsyncStepAdapter({ verbose, logger: stepsCtx.logger });
|
||||
|
||||
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-`));
|
||||
const workingDirectory = path.join(tmpDir, 'working-directory');
|
||||
@@ -107,20 +98,13 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
sourcemapOutput: undefined,
|
||||
}
|
||||
: undefined;
|
||||
const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false;
|
||||
|
||||
const resolvedRepackVersion =
|
||||
(await resolvePackageVersionAsync({
|
||||
logger: stepsCtx.logger,
|
||||
packageName: inputs.repack_package.value as string,
|
||||
distTag: inputs.repack_version.value as string,
|
||||
})) ?? (inputs.repack_version.value as string);
|
||||
stepsCtx.logger.info(
|
||||
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})`
|
||||
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value}`
|
||||
);
|
||||
const repackApp = await installAndImportRepackAsync({
|
||||
packageName: inputs.repack_package.value as string,
|
||||
version: resolvedRepackVersion,
|
||||
version: inputs.repack_version.value as string,
|
||||
});
|
||||
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
|
||||
|
||||
@@ -134,7 +118,6 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
outputPath,
|
||||
workingDirectory,
|
||||
exportEmbedOptions,
|
||||
jsBundleOnly,
|
||||
iosSigningOptions: await resolveIosSigningOptionsAsync({
|
||||
job: stepsCtx.global.staticContext.job,
|
||||
logger: stepsCtx.logger,
|
||||
@@ -163,7 +146,6 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
outputPath,
|
||||
workingDirectory,
|
||||
exportEmbedOptions,
|
||||
jsBundleOnly,
|
||||
androidSigningOptions,
|
||||
logger: stepsCtx.logger,
|
||||
spawnAsync: repackSpawnAsync,
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
const FlowPathSchema = z.array(z.string().min(1)).min(1);
|
||||
|
||||
const CREATE_MUTATION = graphql(`
|
||||
mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
|
||||
workflowDeviceTestCaseResult {
|
||||
@@ -42,11 +38,6 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
defaultValue: '${{ env.HOME }}/.maestro/tests',
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'flow_path',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs }) => {
|
||||
const { logger } = stepsCtx;
|
||||
@@ -60,46 +51,27 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
|
||||
logger.info('No JUnit directory provided, skipping test results report');
|
||||
return;
|
||||
}
|
||||
|
||||
const flowPathRaw = inputs.flow_path.value;
|
||||
let nameToPath: Map<string, string> | null = null;
|
||||
if (flowPathRaw !== undefined) {
|
||||
const parsed = FlowPathSchema.safeParse(flowPathRaw);
|
||||
if (parsed.success) {
|
||||
nameToPath = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: parsed.data,
|
||||
projectRoot: stepsCtx.workingDirectory,
|
||||
logger,
|
||||
});
|
||||
} else {
|
||||
logger.warn(
|
||||
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
|
||||
);
|
||||
}
|
||||
}
|
||||
const testsDirectory = inputs.tests_directory.value as string;
|
||||
|
||||
try {
|
||||
const flowResults = await parseMaestroResults(junitDirectory, nameToPath);
|
||||
const flowResults = await parseMaestroResults(
|
||||
junitDirectory,
|
||||
testsDirectory,
|
||||
stepsCtx.workingDirectory
|
||||
);
|
||||
if (flowResults.length === 0) {
|
||||
logger.info('No maestro test results found, skipping report');
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.name}:${r.retryCount}`;
|
||||
if (seen.has(key)) {
|
||||
conflicting.add(r.name);
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
if (conflicting.size > 0) {
|
||||
// Maestro allows overriding flow names via config, so different flow files can share
|
||||
// the same name. JUnit XML only contains names (not file paths), making it impossible
|
||||
// to map duplicates back to their original flow files. Skip and let the user fix it.
|
||||
const names = flowResults.map(r => r.name);
|
||||
const duplicates = names.filter((n, i) => names.indexOf(n) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
logger.error(
|
||||
`Duplicate test case names found in JUnit output: ${[...conflicting].join(
|
||||
`Duplicate test case names found in JUnit output: ${[...new Set(duplicates)].join(
|
||||
', '
|
||||
)}. Skipping report. Ensure each Maestro flow has a unique name.`
|
||||
);
|
||||
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
BuildStepInputValueTypeName,
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import fs from 'fs';
|
||||
import nullthrows from 'nullthrows';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { sendCcacheStatsAsync } from './ccacheStats';
|
||||
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
|
||||
@@ -19,8 +16,7 @@ import {
|
||||
generateDefaultBuildCacheKeyAsync,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
|
||||
import { TurtleFetchError } from '../../utils/turtleFetch';
|
||||
|
||||
export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -54,15 +50,6 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
|
||||
if (platform === Platform.ANDROID) {
|
||||
await restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -189,124 +176,6 @@ export async function restoreCcacheAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EAS_GRADLE_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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(os.homedir(), '.gradle', 'init.d');
|
||||
await fs.promises.mkdir(initScriptDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
|
||||
[
|
||||
'def cacheDir = new File(System.getProperty("user.home"), ".gradle/caches/build-cache-1")',
|
||||
'def countBefore = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
|
||||
'println "[EAS] Gradle build cache entries before cleanup: ${countBefore}"',
|
||||
'',
|
||||
'beforeSettings { settings ->',
|
||||
' try {',
|
||||
' settings.caches {',
|
||||
' cleanup = Cleanup.ALWAYS',
|
||||
' buildCache {',
|
||||
' setRemoveUnusedEntriesAfterDays(3)',
|
||||
' }',
|
||||
' }',
|
||||
' println "[EAS] Configured Gradle cache cleanup via init script"',
|
||||
' } catch (Exception e) {',
|
||||
' println "[EAS] Failed to configure cache cleanup: ${e.message}"',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'gradle.buildFinished {',
|
||||
' def countAfter = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
|
||||
' println "[EAS] Gradle build cache entries after build: ${countAfter} (was ${countBefore})"',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
|
||||
logger.info(`Restoring Gradle cache key: ${cacheKey}`);
|
||||
|
||||
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
|
||||
|
||||
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
|
||||
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [buildCachePath],
|
||||
key: cacheKey,
|
||||
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
|
||||
platform: Platform.ANDROID,
|
||||
});
|
||||
|
||||
await fs.promises.mkdir(gradleCachesPath, { recursive: true });
|
||||
await decompressCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory: gradleCachesPath,
|
||||
verbose: env.EXPO_DEBUG === '1',
|
||||
logger,
|
||||
});
|
||||
|
||||
const hitType = matchedKey === cacheKey ? 'direct_hit' : 'prefix_match';
|
||||
logger.info(
|
||||
`Gradle cache restored to ${gradleCachesPath} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
|
||||
);
|
||||
|
||||
try {
|
||||
await turtleFetch(new URL('v2/turtle-builds/logs', expoApiServerURL).toString(), 'POST', {
|
||||
json: {
|
||||
buildId: jobId,
|
||||
message: `Gradle cache restored (${hitType})`,
|
||||
tags: {
|
||||
event: 'gradle_cache_restored',
|
||||
cache_hit_type: hitType,
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
shouldThrowOnNotOk: false,
|
||||
});
|
||||
} catch {}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof TurtleFetchError && err.response?.status === 404) {
|
||||
logger.info('No Gradle cache found for this key');
|
||||
} else {
|
||||
logger.warn('Failed to restore Gradle cache: ', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function cacheStatsAsync({
|
||||
logger,
|
||||
env,
|
||||
|
||||
@@ -10,13 +10,8 @@ import {
|
||||
import fs from 'fs';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
|
||||
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
|
||||
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -51,15 +46,6 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
|
||||
if (platform === Platform.ANDROID) {
|
||||
await saveGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -149,66 +135,3 @@ export async function saveCcacheAsync({
|
||||
logger.error({ err }, 'Failed to save cache');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EAS_GRADLE_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
|
||||
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
|
||||
|
||||
try {
|
||||
await fs.promises.access(buildCachePath);
|
||||
} catch {
|
||||
logger.warn('No Gradle build cache found, skipping save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
|
||||
logger.info(`Saving Gradle cache key: ${cacheKey}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
|
||||
logger.info('Compressing Gradle build cache...');
|
||||
const { archivePath } = await compressCacheAsync({
|
||||
paths: [buildCachePath],
|
||||
workingDirectory: gradleCachesPath,
|
||||
verbose: env.EXPO_DEBUG === '1',
|
||||
logger,
|
||||
});
|
||||
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
logger.info(`Gradle cache archive size: ${formatBytes(size)}`);
|
||||
|
||||
await uploadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key: cacheKey,
|
||||
paths: [buildCachePath],
|
||||
size,
|
||||
platform: Platform.ANDROID,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save Gradle cache');
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
ensureCloudflaredInstalledAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
spawnDetached,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForFileAsync,
|
||||
waitForMatchInOutputAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
|
||||
const SRC_DIR = '/tmp/agent-device-src';
|
||||
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_agent_device_remote_session',
|
||||
name: 'Start agent device remote session',
|
||||
__metricsId: 'eas/start_agent_device_remote_session',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env }) => {
|
||||
// Fail fast before any expensive setup if the orchestrator-injected
|
||||
// DEVICE_RUN_SESSION_ID env var is missing — without it we cannot
|
||||
// report the remote config back to the API server.
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
}
|
||||
|
||||
logger.info('Ensuring cloudflared is installed.');
|
||||
const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
packageVersion
|
||||
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
|
||||
: `Cloning agent-device (latest) into ${SRC_DIR}.`
|
||||
);
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
||||
|
||||
logger.info('Installing agent-device dependencies.');
|
||||
await spawn('bun', ['install', '--production'], {
|
||||
cwd: SRC_DIR,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Launching agent-device daemon.');
|
||||
spawnDetached({
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: SRC_DIR,
|
||||
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
|
||||
});
|
||||
|
||||
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
|
||||
const { port: daemonPort, token: daemonToken } = await waitForFileAsync({
|
||||
filePath: DAEMON_JSON_PATH,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'agent-device daemon credentials',
|
||||
parse: parseDaemonInfo,
|
||||
});
|
||||
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
|
||||
|
||||
logger.info(`Starting cloudflared tunnel to http://localhost:${daemonPort}.`);
|
||||
const cloudflared = spawnDetached({
|
||||
command: cloudflaredCommand,
|
||||
args: ['tunnel', '--url', `http://localhost:${daemonPort}`],
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info('Waiting for a public tunnel URL.');
|
||||
const agentDeviceRemoteSessionUrl = await waitForMatchInOutputAsync({
|
||||
process: cloudflared,
|
||||
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'cloudflared tunnel',
|
||||
});
|
||||
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({
|
||||
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('Remote session is live. Keeping the job alive until the session is stopped.');
|
||||
// Keep the turtle job alive so the daemon and tunnel stay reachable
|
||||
// until stopDeviceRunSession cancels the run.
|
||||
await new Promise<never>(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function cloneAgentDeviceAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
|
||||
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
function parseDaemonInfo(raw: string): { port: number; token: string } {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
typeof (parsed as { httpPort: unknown }).httpPort !== 'number' ||
|
||||
typeof (parsed as { token: unknown }).token !== 'string'
|
||||
) {
|
||||
throw new SystemError(
|
||||
'Expected daemon credentials to contain { "httpPort": <number>, "token": "..." }.'
|
||||
);
|
||||
}
|
||||
const { httpPort, token } = parsed as { httpPort: number; token: string };
|
||||
return { port: httpPort, token };
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import {
|
||||
@@ -50,14 +44,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env }) => {
|
||||
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
|
||||
await assertAndroidEmulatorHostSupportAsync({ env });
|
||||
} else {
|
||||
logger.info(
|
||||
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
|
||||
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
|
||||
@@ -71,15 +57,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
const systemImagePackage = `${inputs.system_image_package.value}`;
|
||||
// We can cast because allowedValueTypeName validated this is a string.
|
||||
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
|
||||
const shouldAdjustAnimationScale =
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
|
||||
|
||||
if (!shouldAdjustAnimationScale) {
|
||||
logger.info(
|
||||
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Making sure system image is installed');
|
||||
await retryAsync(
|
||||
@@ -130,13 +107,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
if (shouldAdjustAnimationScale) {
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
env,
|
||||
logger,
|
||||
serialId: attemptSerialId,
|
||||
});
|
||||
}
|
||||
logger.info(`${deviceName} is ready.`);
|
||||
|
||||
serialId = attemptSerialId;
|
||||
@@ -221,13 +191,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
if (shouldAdjustAnimationScale) {
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
env,
|
||||
logger,
|
||||
serialId: cloneSerialId,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`${cloneIdentifier} is ready.`);
|
||||
} catch (err) {
|
||||
@@ -267,32 +230,3 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function assertAndroidEmulatorHostSupportAsync({
|
||||
env,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
const isAndroidEmulatorHostSupported = await isAndroidEmulatorHostSupportedAsync(env);
|
||||
if (!isAndroidEmulatorHostSupported) {
|
||||
throw new UserError(
|
||||
'ANDROID_EMULATOR_ACCEL_CHECK_UNAVAILABLE',
|
||||
'Could not start the Android emulator on this runner.\n\n' +
|
||||
'Android emulator requires nested virtualization on Linux. This job does not have the required virtualization support.\n\n' +
|
||||
'Update your workflow YAML to use a nested-virtualization Linux runner, for example:\n' +
|
||||
' runs_on: linux-medium-nested-virtualization\n' +
|
||||
' runs_on: linux-large-nested-virtualization\n\n' +
|
||||
'See https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idruns_on for more information.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function isAndroidEmulatorHostSupportedAsync(env: NodeJS.ProcessEnv): Promise<boolean> {
|
||||
const androidSdkPath = env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT ?? '';
|
||||
try {
|
||||
await spawn(`${androidSdkPath}/emulator/emulator`, ['-accel-check'], { env });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
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 { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
ensureCloudflaredInstalledAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
spawnDetached,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForFileAsync,
|
||||
waitForMatchInOutputAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
|
||||
const ARGENT_STATE_FILE = path.join(os.homedir(), '.argent', 'tool-server.json');
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
const ArgentToolServerStateSchema = z.object({ port: z.number() });
|
||||
|
||||
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 orchestrator-injected
|
||||
// DEVICE_RUN_SESSION_ID env var is missing — without it we cannot
|
||||
// report the remote config back to the API server.
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
logger.info(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
}
|
||||
|
||||
logger.info('Ensuring cloudflared is installed.');
|
||||
const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
// Stale state from a previous run would mask the new server's port.
|
||||
await fs.promises.rm(ARGENT_STATE_FILE, { force: true });
|
||||
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} via bunx.`);
|
||||
// `argent mcp` is the public entry that triggers @argent/tools-client
|
||||
// to spawn the tool-server detached + unref'd, so the tool-server
|
||||
// outlives this MCP process. ARGENT_IDLE_TIMEOUT_MINUTES=0 disables the
|
||||
// 30-min idle shutdown that would otherwise tear the tunnel down.
|
||||
spawnDetached({
|
||||
command: 'bunx',
|
||||
args: [`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'mcp'],
|
||||
env: { ...env, ARGENT_IDLE_TIMEOUT_MINUTES: '0' },
|
||||
});
|
||||
|
||||
logger.info(`Waiting for argent tool-server state at ${ARGENT_STATE_FILE}.`);
|
||||
const { port: toolServerPort } = await waitForFileAsync({
|
||||
filePath: ARGENT_STATE_FILE,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'argent tool-server state',
|
||||
parse: parseArgentToolServerState,
|
||||
});
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
|
||||
logger.info(`Starting cloudflared tunnel to http://localhost:${toolServerPort}.`);
|
||||
const cloudflared = spawnDetached({
|
||||
command: cloudflaredCommand,
|
||||
args: ['tunnel', '--url', `http://localhost:${toolServerPort}`],
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info('Waiting for a public tunnel URL.');
|
||||
const toolsUrl = await waitForMatchInOutputAsync({
|
||||
process: cloudflared,
|
||||
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'cloudflared tunnel',
|
||||
});
|
||||
logger.info(`Tunnel is ready at ${toolsUrl}.`);
|
||||
|
||||
// serve-sim is iOS-only — Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
const serveSim = await startServeSimWithTunnelAsync({
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
webPreviewUrl = serveSim.previewUrl;
|
||||
logger.info(`Web preview URL: ${webPreviewUrl}`);
|
||||
}
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl,
|
||||
...(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>(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgentToolServerState(raw: string): { port: number } {
|
||||
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;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
ensureBrewPackageInstalledAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartServeSimRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_serve_sim_remote_session',
|
||||
name: 'Start serve-sim remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
|
||||
logger.info('Starting serve-sim remote session.');
|
||||
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
|
||||
logger.info('Ensuring cloudflared is installed.');
|
||||
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
|
||||
|
||||
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync({
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Preview URL: ${previewUrl}`);
|
||||
logger.info(`Stream URL: ${streamUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: { previewUrl, streamUrl },
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
|
||||
// Keep the turtle job alive so the serve-sim tunnel stays reachable
|
||||
// until stopDeviceRunSession cancels the run.
|
||||
await new Promise<never>(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -266,31 +266,12 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
}
|
||||
|
||||
if (state.state === 'FAILED') {
|
||||
if (isSdkVersionIssueError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
|
||||
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
|
||||
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
|
||||
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isInvalidBundleIdentifierError(errors)) {
|
||||
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
|
||||
const ipaBundleIdentifier = ipaInfoResult.ok
|
||||
? ipaInfoResult.value.bundleIdentifier
|
||||
: null;
|
||||
|
||||
let visibleAppsSummary: string | null = null;
|
||||
try {
|
||||
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
|
||||
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
|
||||
} catch {
|
||||
// Ok to fail, this is just trying to be helpful.
|
||||
}
|
||||
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
|
||||
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
|
||||
@@ -298,12 +279,7 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
|
||||
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
|
||||
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
|
||||
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
|
||||
'\n\nOther App Store Connect apps visible to this API key:\n' +
|
||||
(visibleAppsSummary ?? ' (unable to retrieve)'),
|
||||
{
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
}
|
||||
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).'
|
||||
);
|
||||
}
|
||||
if (isMissingPurposeStringError(errors)) {
|
||||
@@ -355,10 +331,6 @@ export function isClosedVersionTrainError(messages: { code: string }[]): boolean
|
||||
);
|
||||
}
|
||||
|
||||
export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90725');
|
||||
}
|
||||
|
||||
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -34,13 +34,11 @@ export namespace AscApiUtils {
|
||||
|
||||
let visibleAppsSummary: string | null = null;
|
||||
try {
|
||||
const apps = await AscApiUtils.getAppsAsync({ client, limit: 10 });
|
||||
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
|
||||
visibleAppsSummary = await getVisibleAppsSummaryAsync(client);
|
||||
} catch {
|
||||
// Don't hide the original NOT_FOUND error with a secondary lookup failure.
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
|
||||
`App Store Connect app for application identifier ${appleAppIdentifier} was not found. ` +
|
||||
@@ -110,26 +108,19 @@ export namespace AscApiUtils {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAppsAsync({
|
||||
client,
|
||||
limit,
|
||||
}: {
|
||||
client: Pick<AscApiClient, 'getAsync'>;
|
||||
limit?: number;
|
||||
}): Promise<AscApiClientGetApi['/v1/apps']['response']['data']> {
|
||||
const appsResponse = await client.getAsync('/v1/apps', {
|
||||
'fields[apps]': ['bundleId', 'name'],
|
||||
limit: limit ?? 10,
|
||||
});
|
||||
return appsResponse.data;
|
||||
async function getVisibleAppsSummaryAsync(
|
||||
client: Pick<AscApiClient, 'getAsync'>
|
||||
): Promise<string | null> {
|
||||
const appsResponse = await client.getAsync('/v1/apps', {
|
||||
'fields[apps]': ['bundleId', 'name'],
|
||||
limit: 10,
|
||||
});
|
||||
if (appsResponse.data.length === 0) {
|
||||
return ' (none)';
|
||||
}
|
||||
|
||||
export function formatAppsList(apps: AscApiClientGetApi['/v1/apps']['response']['data']): string {
|
||||
return (
|
||||
apps
|
||||
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
|
||||
.join('\n') || ' (none)'
|
||||
);
|
||||
}
|
||||
return appsResponse.data
|
||||
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
|
||||
.join('\n');
|
||||
}
|
||||
@@ -60,8 +60,8 @@ describe('AscApiUtils', () => {
|
||||
expect.objectContaining({
|
||||
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
message: expect.stringMatching(
|
||||
/App Store Connect app for application identifier 1234567890 was not found[\s\S]*- Visible App \(com\.visible\.app\) \(ID: 1111111111\)/
|
||||
message: expect.stringContaining(
|
||||
'App Store Connect app for application identifier 1234567890 was not found'
|
||||
),
|
||||
})
|
||||
);
|
||||
@@ -96,31 +96,6 @@ describe('AscApiUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAppsList', () => {
|
||||
it('formats visible apps', () => {
|
||||
expect(
|
||||
AscApiUtils.formatAppsList([
|
||||
{
|
||||
type: 'apps',
|
||||
id: '1111111111',
|
||||
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
|
||||
},
|
||||
{
|
||||
type: 'apps',
|
||||
id: '2222222222',
|
||||
attributes: { name: 'Second App', bundleId: 'com.second.app' },
|
||||
},
|
||||
])
|
||||
).toBe(
|
||||
'- Visible App (com.visible.app) (ID: 1111111111)\n- Second App (com.second.app) (ID: 2222222222)'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a placeholder when no visible apps are found', () => {
|
||||
expect(AscApiUtils.formatAppsList([])).toBe(' (none)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBuildUploadAsync', () => {
|
||||
it('throws UserError when ASC duplicate version error is returned', async () => {
|
||||
const payload = {
|
||||
|
||||
@@ -50,47 +50,6 @@ describe(configureCredentialsAsync, () => {
|
||||
vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8')
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
it('configures credentials with Apple Development identity for development profile', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
|
||||
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
|
||||
'utf-8'
|
||||
),
|
||||
'ios/testapp/AppDelegate.m': 'placeholder',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
const options = {
|
||||
credentials: {
|
||||
keychainPath: 'fake/path',
|
||||
targetProvisioningProfiles: {
|
||||
testapp: {
|
||||
path: 'fake/path.mobileprovision',
|
||||
target: 'testapp',
|
||||
bundleIdentifier: 'abc',
|
||||
teamId: 'ABCDEFGH',
|
||||
uuid: 'abc',
|
||||
name: 'profile name',
|
||||
developerCertificate: Buffer.from('test'),
|
||||
certificateCommonName: 'Apple Development: Test User',
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
},
|
||||
},
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
teamId: 'ABCDEFGH',
|
||||
applicationTargetProvisioningProfile: {} as ProvisioningProfile,
|
||||
},
|
||||
buildConfiguration: 'Release',
|
||||
};
|
||||
await configureCredentialsAsync({ info: jest.fn() } as any, '/app', options);
|
||||
const pbxproj = vol.readFileSync(
|
||||
'/app/ios/testapp.xcodeproj/project.pbxproj',
|
||||
'utf-8'
|
||||
) as string;
|
||||
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
|
||||
expect(pbxproj).not.toContain('"iPhone Distribution"');
|
||||
});
|
||||
it('configures credentials for multi target project', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
|
||||
@@ -1,810 +0,0 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../../sentry';
|
||||
import {
|
||||
buildTargetMetrics,
|
||||
collectTopLevelCompileSteps,
|
||||
formatReport,
|
||||
isCompileStep,
|
||||
parseAndReportXcactivitylog,
|
||||
} from '../xcactivitylog';
|
||||
|
||||
jest.mock('@expo/downloader');
|
||||
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 = {
|
||||
schema: { name: 'TestProject' },
|
||||
subSteps: [
|
||||
{
|
||||
title: 'Build target ModuleA',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'cCompilation',
|
||||
duration: 5.0,
|
||||
startTimestamp: 100.0,
|
||||
endTimestamp: 103.0,
|
||||
signature: 'CompileC foo.c',
|
||||
subSteps: [],
|
||||
},
|
||||
{
|
||||
detailStepType: 'other',
|
||||
signature: 'SwiftCompile normal bar.swift',
|
||||
duration: 3.0,
|
||||
startTimestamp: 101.0,
|
||||
endTimestamp: 104.0,
|
||||
subSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Build target ModuleB',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'compileAssetsCatalog',
|
||||
duration: 2.0,
|
||||
startTimestamp: 200.0,
|
||||
endTimestamp: 202.0,
|
||||
signature: 'CompileAssetCatalog',
|
||||
subSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Build target TinyModule',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'cCompilation',
|
||||
duration: 0.1,
|
||||
startTimestamp: 300.0,
|
||||
endTimestamp: 300.1,
|
||||
signature: 'CompileC tiny.c',
|
||||
subSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Fixture: empty data
|
||||
const FIXTURE_EMPTY = {
|
||||
schema: { name: 'EmptyProject' },
|
||||
subSteps: [],
|
||||
};
|
||||
|
||||
// Fixture: no compile steps (only non-compile sub-steps)
|
||||
const FIXTURE_NO_COMPILE = {
|
||||
schema: { name: 'NoCompileProject' },
|
||||
subSteps: [
|
||||
{
|
||||
title: 'Build target SomeTarget',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'linkCommand',
|
||||
duration: 5.0,
|
||||
startTimestamp: 100.0,
|
||||
endTimestamp: 105.0,
|
||||
signature: 'Ld something',
|
||||
subSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Fixture: missing timestamps
|
||||
const FIXTURE_MISSING_TIMESTAMPS = {
|
||||
schema: { name: 'MissingTimestamps' },
|
||||
subSteps: [
|
||||
{
|
||||
title: 'Build target PartialModule',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'cCompilation',
|
||||
duration: 3.0,
|
||||
signature: 'CompileC partial.c',
|
||||
subSteps: [],
|
||||
// no startTimestamp/endTimestamp
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Fixture: nested compile steps (compile step inside non-compile step)
|
||||
const FIXTURE_NESTED = {
|
||||
schema: { name: 'NestedProject' },
|
||||
subSteps: [
|
||||
{
|
||||
title: 'Build target NestedModule',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'scriptExecution',
|
||||
duration: 10.0,
|
||||
startTimestamp: 100.0,
|
||||
endTimestamp: 110.0,
|
||||
signature: 'PhaseScriptExecution',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'cCompilation',
|
||||
duration: 2.0,
|
||||
startTimestamp: 101.0,
|
||||
endTimestamp: 103.0,
|
||||
signature: 'CompileC nested.c',
|
||||
subSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 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),
|
||||
}: {
|
||||
tempDir?: string;
|
||||
jsonOutput?: string;
|
||||
} = {}) {
|
||||
jest.spyOn(fs, 'mkdtemp').mockImplementation(async () => tempDir);
|
||||
jest.spyOn(fs, 'chmod').mockImplementation(async () => undefined);
|
||||
jest.spyOn(fs, 'readFile').mockImplementation(async () => jsonOutput);
|
||||
jest.spyOn(fs, 'rm').mockImplementation(async () => undefined);
|
||||
|
||||
return {
|
||||
reportPath: path.join(tempDir, 'xcactivitylog.json'),
|
||||
tempDir,
|
||||
};
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const logger = createMockLogger();
|
||||
const { tempDir, reportPath } = mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(1, 'xclogparser', ['version'], {
|
||||
stdio: 'pipe',
|
||||
env: TEST_ENV,
|
||||
});
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'unzip',
|
||||
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'), '-d', tempDir],
|
||||
{ stdio: 'pipe', env: TEST_ENV }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
path.join(tempDir, '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(fs.readFile).toHaveBeenCalledWith(reportPath, 'utf8');
|
||||
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
|
||||
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 () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile
|
||||
.mockRejectedValueOnce(new Error('proxy failed'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
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',
|
||||
{ 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',
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the direct GCS URL when the proxy download succeeds but unpacking fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
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);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
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',
|
||||
{ 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',
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Xcode Build — Compile Metrics by Module')
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports phase "parsing_xclogparser_output" 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(
|
||||
parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "parsing_xclogparser_output"',
|
||||
expect.any(Error),
|
||||
{ tags: { phase: 'parsing_xclogparser_output' } }
|
||||
);
|
||||
});
|
||||
|
||||
it('reports phase "parsing_xclogparser_output" when the JSON output does not match the expected schema', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem({
|
||||
jsonOutput: JSON.stringify({
|
||||
schema: { name: 'BadProject' },
|
||||
subSteps: [{ title: 123, subSteps: [] }],
|
||||
}),
|
||||
});
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await expect(
|
||||
parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "parsing_xclogparser_output"',
|
||||
expect.any(Error),
|
||||
{ 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.toBeUndefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "running_xclogparser"',
|
||||
expect.objectContaining({ message: 'spawn xclogparser ENOENT' }),
|
||||
{ tags: { phase: 'running_xclogparser' } }
|
||||
);
|
||||
});
|
||||
|
||||
it('reports phase "downloading_xclogparser" when the direct download fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockRejectedValue(new Error('network unreachable'));
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await expect(
|
||||
parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "downloading_xclogparser"',
|
||||
expect.objectContaining({ message: 'network unreachable' }),
|
||||
{ tags: { phase: 'downloading_xclogparser' } }
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows cleanup failures without emitting additional logs', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
jest.spyOn(fs, 'rm').mockImplementation(async () => {
|
||||
throw new Error('cleanup failed');
|
||||
});
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await expect(
|
||||
parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.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', () => {
|
||||
it('identifies cCompilation', () => {
|
||||
expect(isCompileStep({ detailStepType: 'cCompilation', signature: '' })).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies compileAssetsCatalog', () => {
|
||||
expect(isCompileStep({ detailStepType: 'compileAssetsCatalog', signature: '' })).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies compileStoryboard', () => {
|
||||
expect(isCompileStep({ detailStepType: 'compileStoryboard', signature: '' })).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies SwiftCompile by signature prefix', () => {
|
||||
expect(
|
||||
isCompileStep({ detailStepType: 'other', signature: 'SwiftCompile normal foo.swift' })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies SwiftGeneratePch by signature prefix', () => {
|
||||
expect(isCompileStep({ detailStepType: 'other', signature: 'SwiftGeneratePch normal' })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-compile steps', () => {
|
||||
expect(isCompileStep({ detailStepType: 'linkCommand', signature: 'Ld something' })).toBe(false);
|
||||
});
|
||||
|
||||
it('handles missing fields gracefully', () => {
|
||||
expect(isCompileStep({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectTopLevelCompileSteps', () => {
|
||||
it('collects direct compile children', () => {
|
||||
const target = FIXTURE_TWO_MODULES.subSteps[0]; // ModuleA
|
||||
const result = collectTopLevelCompileSteps(target);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('finds nested compile steps through non-compile parents', () => {
|
||||
const target = FIXTURE_NESTED.subSteps[0]; // NestedModule
|
||||
const result = collectTopLevelCompileSteps(target);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].detailStepType).toBe('cCompilation');
|
||||
});
|
||||
|
||||
it('returns empty array when no compile steps', () => {
|
||||
const target = FIXTURE_NO_COMPILE.subSteps[0];
|
||||
const result = collectTopLevelCompileSteps(target);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTargetMetrics', () => {
|
||||
it('returns metrics sorted by taskSeconds descending', () => {
|
||||
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i - 1].taskSeconds).toBeGreaterThanOrEqual(results[i].taskSeconds);
|
||||
}
|
||||
});
|
||||
|
||||
it('computes correct taskSeconds for ModuleA', () => {
|
||||
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
|
||||
const moduleA = results.find(r => r.moduleName === 'ModuleA');
|
||||
expect(moduleA).toBeDefined();
|
||||
expect(moduleA!.taskSeconds).toBeCloseTo(8.0); // 5.0 + 3.0
|
||||
});
|
||||
|
||||
it('computes correct wallSpan for ModuleA with overlapping intervals', () => {
|
||||
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
|
||||
const moduleA = results.find(r => r.moduleName === 'ModuleA');
|
||||
expect(moduleA).toBeDefined();
|
||||
// intervals: [100, 103] and [101, 104] → wall span = 104 - 100 = 4.0
|
||||
expect(moduleA!.wallSpan).toBeCloseTo(4.0);
|
||||
});
|
||||
|
||||
it('filters out modules below minTaskSeconds threshold', () => {
|
||||
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
|
||||
const tinyModule = results.find(r => r.moduleName === 'TinyModule');
|
||||
expect(tinyModule).toBeUndefined(); // 0.1s < 0.5s default threshold
|
||||
});
|
||||
|
||||
it('allows custom minTaskSeconds threshold', () => {
|
||||
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES, { minTaskSeconds: 0 });
|
||||
const tinyModule = results.find(r => r.moduleName === 'TinyModule');
|
||||
expect(tinyModule).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns empty results for empty data', () => {
|
||||
const { results, totalTaskSeconds } = buildTargetMetrics(FIXTURE_EMPTY);
|
||||
expect(results).toHaveLength(0);
|
||||
expect(totalTaskSeconds).toBe(0);
|
||||
});
|
||||
|
||||
it('handles missing timestamps gracefully', () => {
|
||||
const { results } = buildTargetMetrics(FIXTURE_MISSING_TIMESTAMPS);
|
||||
const mod = results.find(r => r.moduleName === 'PartialModule');
|
||||
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', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
it('does not include modules below threshold', () => {
|
||||
const report = formatReport(FIXTURE_TWO_MODULES);
|
||||
expect(report).not.toContain('TinyModule');
|
||||
});
|
||||
|
||||
it('handles empty data without crashing', () => {
|
||||
const report = formatReport(FIXTURE_EMPTY);
|
||||
expect(report).toContain('Xcode Build — Compile Metrics by Module');
|
||||
expect(report).toContain('TOTAL');
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import uniq from 'lodash/uniq';
|
||||
import path from 'path';
|
||||
|
||||
import { Credentials } from './credentials/manager';
|
||||
import { DistributionType } from './credentials/provisioningProfile';
|
||||
|
||||
export async function configureCredentialsAsync(
|
||||
logger: bunyan,
|
||||
@@ -30,9 +29,6 @@ export async function configureCredentialsAsync(
|
||||
profileName: profile.name,
|
||||
appleTeamId: profile.teamId,
|
||||
buildConfiguration,
|
||||
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
|
||||
codeSignIdentity: profile.certificateCommonName,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ export interface ProvisioningProfileData {
|
||||
export enum DistributionType {
|
||||
AD_HOC = 'ad-hoc',
|
||||
APP_STORE = 'app-store',
|
||||
DEVELOPMENT = 'development',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
@@ -134,10 +133,6 @@ export default class ProvisioningProfile {
|
||||
if (plistData.ProvisionsAllDevices) {
|
||||
return DistributionType.ENTERPRISE;
|
||||
} else if (plistData.ProvisionedDevices) {
|
||||
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
|
||||
if (entitlements?.['get-task-allow']) {
|
||||
return DistributionType.DEVELOPMENT;
|
||||
}
|
||||
return DistributionType.AD_HOC;
|
||||
} else {
|
||||
return DistributionType.APP_STORE;
|
||||
|
||||
Loaded 100 of 419 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user