mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
351
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a62289b988 | ||
|
|
78c0584331 | ||
|
|
c4a7b7c9f1 | ||
|
|
f448bc75b5 | ||
|
|
0dda7ba880 | ||
|
|
9679af9670 | ||
|
|
3e0e823e56 | ||
|
|
fbb8af6fca | ||
|
|
c72cee372f | ||
|
|
73b2a716da | ||
|
|
55191f9697 | ||
|
|
a08beb6392 | ||
|
|
643cc9e387 | ||
|
|
7c44d5515a | ||
|
|
6fd172ee30 | ||
|
|
4edf0dc547 | ||
|
|
f82bdb57e6 | ||
|
|
9641735781 | ||
|
|
1ce05e3b28 | ||
|
|
b2f34e5c03 | ||
|
|
8204d18951 | ||
|
|
69024620f9 | ||
|
|
c9442988c7 | ||
|
|
2a258e80a9 | ||
|
|
4b1f785abe | ||
|
|
2e2e8ffacc | ||
|
|
708bddd2ad | ||
|
|
6d02728a25 | ||
|
|
9a94201ce5 | ||
|
|
6189ae9ed3 | ||
|
|
9d373d9778 | ||
|
|
43662ca9f5 | ||
|
|
cc42fdc28d | ||
|
|
0727ae467a | ||
|
|
fc0ebff305 | ||
|
|
f89e9ddef0 | ||
|
|
b9d59e2628 | ||
|
|
502f713467 | ||
|
|
48bdd42bc3 | ||
|
|
81d09514fc | ||
|
|
081e1a9cf7 | ||
|
|
bc6369204d | ||
|
|
4855745d67 | ||
|
|
a848411278 | ||
|
|
58968ddcd9 | ||
|
|
72ca5c2527 | ||
|
|
8aa4cae097 | ||
|
|
8a66d3808e | ||
|
|
6697add2c0 | ||
|
|
cd47185f29 | ||
|
|
88c51c3ccc | ||
|
|
9247f3cf78 | ||
|
|
bde331da63 | ||
|
|
fd055f08b3 | ||
|
|
785d690e65 | ||
|
|
47335f02f0 | ||
|
|
75d9be813f | ||
|
|
cdaa011ce3 | ||
|
|
ee6e197587 | ||
|
|
63baff4daf | ||
|
|
85a4054b17 | ||
|
|
15cd0c5ae2 | ||
|
|
efc78f43d0 | ||
|
|
a905a50f65 | ||
|
|
b36b5b36d7 | ||
|
|
f867b7b9f2 | ||
|
|
99fba8c814 | ||
|
|
49101c06c1 | ||
|
|
4071f4834c | ||
|
|
55fe28cf63 | ||
|
|
88a404e45f | ||
|
|
cb2daddbfc | ||
|
|
b2388fdfa0 | ||
|
|
0986682159 | ||
|
|
24354688b6 | ||
|
|
490457976c | ||
|
|
787fe4d3a7 | ||
|
|
2dae2cdd0e | ||
|
|
967dff73df | ||
|
|
3a3995817e | ||
|
|
8a392544f2 | ||
|
|
84015ebe32 | ||
|
|
7a38191692 | ||
|
|
3e393650dc | ||
|
|
5e2ecb5997 | ||
|
|
51aceb1db0 | ||
|
|
90f78ab2ae | ||
|
|
203e9997ee | ||
|
|
792af28a5d | ||
|
|
e62bb1a840 | ||
|
|
f8c2b2daf4 | ||
|
|
22fc4da732 | ||
|
|
1c7c79d445 | ||
|
|
b30dd9054d | ||
|
|
7af74c605e | ||
|
|
a5fccedf4e | ||
|
|
f1aa26d9f3 | ||
|
|
68a0847058 | ||
|
|
1431a20b86 | ||
|
|
d2ed1feb67 | ||
|
|
cc49343b39 | ||
|
|
10f9edc910 | ||
|
|
9d1a6b7796 | ||
|
|
d8cca2c8e8 | ||
|
|
fb818fce54 | ||
|
|
11263c23a0 | ||
|
|
80baa13ffa | ||
|
|
58982ee212 | ||
|
|
33225d2b73 | ||
|
|
7a97f297ee | ||
|
|
5f952fd33b | ||
|
|
45354d12da | ||
|
|
598f0050be | ||
|
|
543261b68a | ||
|
|
e1148f2b00 | ||
|
|
2c1837f8ba | ||
|
|
7bdc009c5e | ||
|
|
b5592e16ad | ||
|
|
890387b3b6 | ||
|
|
b93c550d12 | ||
|
|
e648961eca | ||
|
|
15bbb3150d | ||
|
|
45c4cf3c32 | ||
|
|
0ab2a04b67 | ||
|
|
544739a613 | ||
|
|
86a891b377 | ||
|
|
d51ca46f9c | ||
|
|
cfc2c76ae4 | ||
|
|
489f38a6e5 | ||
|
|
5f26518feb | ||
|
|
2fc5f203b6 | ||
|
|
4a18363061 | ||
|
|
27a2e2abc7 | ||
|
|
149f5d86a4 | ||
|
|
18c2e82385 | ||
|
|
43ffe46428 | ||
|
|
fb9b49cd5b | ||
|
|
17eb5f0687 | ||
|
|
6297ac19d9 | ||
|
|
6d86f91dab | ||
|
|
cedfabe18a | ||
|
|
20a21908d0 | ||
|
|
18b1dd8d9d | ||
|
|
fce2a77909 | ||
|
|
c562f08da9 | ||
|
|
5dee409567 | ||
|
|
ea2cd4f318 | ||
|
|
639a7cce61 | ||
|
|
b0db6d1dfa | ||
|
|
783033acb9 | ||
|
|
4366f517c1 | ||
|
|
1e9a0981a5 | ||
|
|
43953d6f16 | ||
|
|
7bb8c0e749 | ||
|
|
150176debc | ||
|
|
fa6c877a81 | ||
|
|
9ee8758aac | ||
|
|
d084e9ab9d | ||
|
|
ec0ac26fc3 | ||
|
|
51fb25c689 | ||
|
|
81cfeede9a | ||
|
|
4bff24b71c | ||
|
|
503b62b73d | ||
|
|
aaab3c98a1 | ||
|
|
b64f25b36d | ||
|
|
1e209b00a8 | ||
|
|
6ec2ea0530 | ||
|
|
d5a6b13dae | ||
|
|
41eb9f1499 | ||
|
|
5c9b93fc34 | ||
|
|
fbc57831cc | ||
|
|
34ca7b0ab9 | ||
|
|
7d6e4db4e8 | ||
|
|
32355274ec | ||
|
|
50ca4cbec1 | ||
|
|
2dbb4372d8 | ||
|
|
4bf38452a9 | ||
|
|
fc7c2950e7 | ||
|
|
30248528ad | ||
|
|
ecf58ab8a8 | ||
|
|
10c7ac64ba | ||
|
|
7ee81e856b | ||
|
|
8328172827 | ||
|
|
29909b223d | ||
|
|
dbf597957d | ||
|
|
4d2b406dc5 | ||
|
|
774195ff74 | ||
|
|
255590cd45 | ||
|
|
d15641948c | ||
|
|
6cbcc8537c | ||
|
|
5ec4eaf052 | ||
|
|
08a9b5ff7f | ||
|
|
501af5213e | ||
|
|
d48f931d77 | ||
|
|
99b18a2189 | ||
|
|
9ece20157f | ||
|
|
985cd9d52f | ||
|
|
576c731d87 | ||
|
|
efe26a729e | ||
|
|
3dcfef1e2c | ||
|
|
c0e86f837e | ||
|
|
ae65e74ed0 | ||
|
|
2ba012bb30 | ||
|
|
48c47bcc0c | ||
|
|
6ad59488b0 | ||
|
|
c476f07818 | ||
|
|
329a02d06f | ||
|
|
f46b469267 | ||
|
|
d708aaefab | ||
|
|
0d1c91aad2 | ||
|
|
4f670f90b6 | ||
|
|
53d294330d | ||
|
|
ed4b39c55e | ||
|
|
a04d336921 | ||
|
|
f83d3b8ee0 | ||
|
|
9b5f237bf2 | ||
|
|
66cc6d2cf9 | ||
|
|
d9ff560dab | ||
|
|
1f53177ecf | ||
|
|
b1f606979a | ||
|
|
71f968e971 | ||
|
|
687e8897c1 | ||
|
|
0d5ef2ee89 | ||
|
|
f856d4022f | ||
|
|
5585ef99c8 | ||
|
|
4eb0753a06 | ||
|
|
bfaff08894 | ||
|
|
64874f7dfe | ||
|
|
daa4c60e9b | ||
|
|
ba4e4eddbc | ||
|
|
6bda9c7fa0 | ||
|
|
4dec5b3765 | ||
|
|
0e3216e681 | ||
|
|
2a191700f7 | ||
|
|
4da5b40520 | ||
|
|
d23248e4ac | ||
|
|
3318392620 | ||
|
|
b5fac2a066 | ||
|
|
138f7973ae | ||
|
|
51311f1813 | ||
|
|
a85b60c11e | ||
|
|
cacd48bf66 | ||
|
|
bc213a51b9 | ||
|
|
ddd8bb7fdd | ||
|
|
ed251cb0d1 | ||
|
|
5176495e9e | ||
|
|
32211614eb | ||
|
|
ce5b48244c | ||
|
|
ece1d00ca7 | ||
|
|
82dd218a2b | ||
|
|
9f763c6957 | ||
|
|
e74c93d80c | ||
|
|
3d691d602c | ||
|
|
7fde621858 | ||
|
|
91c9331e43 | ||
|
|
02ba5880c4 | ||
|
|
8229dc8265 | ||
|
|
15a456970b | ||
|
|
e74751c26c | ||
|
|
0e2a1756ce | ||
|
|
766e278aec | ||
|
|
77e22c740a | ||
|
|
8fdf47aa7d | ||
|
|
2279b650d5 | ||
|
|
c6a36e2e47 | ||
|
|
27cb8c12d6 | ||
|
|
1122a7f374 | ||
|
|
c06d18138c | ||
|
|
3f4ff1dc3e | ||
|
|
e852ab1cb7 | ||
|
|
d941398ce6 | ||
|
|
bae7684996 | ||
|
|
714aec69c7 | ||
|
|
bec6e670de | ||
|
|
900ad765bf | ||
|
|
7c7fad4a13 | ||
|
|
7226c72122 | ||
|
|
ae6e0fd7f6 | ||
|
|
d070f73bc5 | ||
|
|
325bb29b57 | ||
|
|
82a8e3c2d0 | ||
|
|
ec310dd115 | ||
|
|
84b09c4a28 | ||
|
|
d9338799a0 | ||
|
|
b22d89e41f | ||
|
|
81fe0f237f | ||
|
|
b9ddc04cf7 | ||
|
|
d6a8a4f694 | ||
|
|
7f163e3e52 | ||
|
|
f821cb176c | ||
|
|
3a70a5d9b8 | ||
|
|
4d74298bb7 | ||
|
|
3155fc26e7 | ||
|
|
0fb6dfc334 | ||
|
|
dd84646768 | ||
|
|
c3fbf63a71 | ||
|
|
7c0d7b60c7 | ||
|
|
5a8e1b4ba3 | ||
|
|
12b648a603 | ||
|
|
8c170af829 | ||
|
|
ad4c5299e3 | ||
|
|
dee07ae0a2 | ||
|
|
4ad12340f2 | ||
|
|
c6d4faefe7 | ||
|
|
2f8712a0f3 | ||
|
|
a439721054 | ||
|
|
2e2dddb59a | ||
|
|
b7df3ff4f8 | ||
|
|
8a74758d56 | ||
|
|
26fdf8fb75 | ||
|
|
9328063547 | ||
|
|
fc66b8251e | ||
|
|
394576368a | ||
|
|
f80e34cffe | ||
|
|
59ad2c2ca0 | ||
|
|
4e95a29797 | ||
|
|
66d8e3d6b8 | ||
|
|
5e4a88094f | ||
|
|
7a4c81f4e3 | ||
|
|
c9cdfd1eb5 | ||
|
|
8115dc8766 | ||
|
|
113467c91d | ||
|
|
be967f4b9a | ||
|
|
660303b0c3 | ||
|
|
5514289648 | ||
|
|
7924e33e96 | ||
|
|
e699a06c07 | ||
|
|
ce819c1a14 | ||
|
|
92ad6b5ba3 | ||
|
|
d63d51f548 | ||
|
|
bfb12f0317 | ||
|
|
b162162007 | ||
|
|
6ed7e14a69 | ||
|
|
37910b17e1 | ||
|
|
0d3c56f4d6 | ||
|
|
bc8e60d385 | ||
|
|
99b8d67d3f | ||
|
|
4e202db843 | ||
|
|
ab209463ab | ||
|
|
99e9d367a1 | ||
|
|
a6f68462c9 | ||
|
|
1c58823991 | ||
|
|
6a804ab8d7 | ||
|
|
74eff9bf67 | ||
|
|
d20c465ddf | ||
|
|
0c17846e67 | ||
|
|
d5a3fc06f9 | ||
|
|
c51ac27e25 | ||
|
|
88506d01a6 | ||
|
|
77e36e0743 | ||
|
|
ce8f438610 |
No files matched your search
@@ -0,0 +1,11 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "eas-cli"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
mise trust
|
||||
mise install
|
||||
yarn
|
||||
yarn typecheck
|
||||
'''
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
|
||||
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->
|
||||
|
||||
# Why
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Keep SHA-pinned GitHub Actions fresh. Without this, pins never receive
|
||||
# upstream security patches because tag movements no longer reach us.
|
||||
- package-ecosystem: github-actions
|
||||
directories:
|
||||
- /
|
||||
- /.github/actions/setup-mise
|
||||
- /.github/internal-actions/setup-gcloud
|
||||
- /.github/internal-actions/notify-slack-on-fail-or-recover
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- '*'
|
||||
@@ -12,7 +12,7 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Get previous workflow run status
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
id: run-status
|
||||
with:
|
||||
script: |
|
||||
@@ -28,7 +28,7 @@ runs:
|
||||
|
||||
- name: Send Slack Success Notification
|
||||
if: fromJSON(steps.run-status.outputs.result).previous == 'failure' && fromJSON(steps.run-status.outputs.result).current == 'success'
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
|
||||
env:
|
||||
SLACK_CHANNEL: ${{ inputs.channel }}
|
||||
SLACK_COLOR: good
|
||||
@@ -41,7 +41,7 @@ runs:
|
||||
|
||||
- name: Send Slack Failure Notification
|
||||
if: fromJSON(steps.run-status.outputs.result).current == 'failure'
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
|
||||
env:
|
||||
SLACK_CHANNEL: ${{ inputs.channel }}
|
||||
SLACK_COLOR: danger
|
||||
|
||||
@@ -4,7 +4,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Auth gcloud
|
||||
uses: google-github-actions/auth@v2
|
||||
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
|
||||
with:
|
||||
workload_identity_provider: 'projects/321830142373/locations/global/workloadIdentityPools/github/providers/expo'
|
||||
project_id: exponentjs
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: CHANGELOG.md PR check
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
@@ -17,7 +18,7 @@ jobs:
|
||||
name: Check CHANGELOG.md updated
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
|
||||
- name: Check if CHANGELOG.md was updated
|
||||
id: changelog-updated
|
||||
uses: tj-actions/changed-files@b1ba699b304f2083b602164e06a89b868c84f076
|
||||
@@ -25,11 +26,11 @@ jobs:
|
||||
files: CHANGELOG.md
|
||||
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
|
||||
if: steps.changelog-updated.outputs.any_changed == 'false' && !contains(github.event.pull_request.labels.*.name, 'no changelog')
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.setFailed('Please add a changelog entry!')
|
||||
- uses: mshick/add-pr-comment@v2.1.0
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
if: always()
|
||||
with:
|
||||
message-id: changelog-entry-check
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
name: codemention
|
||||
on:
|
||||
# pull_request_target is required so fork PRs get a write token to post the
|
||||
# mention comment. Safe because there is no checkout — the action only reads
|
||||
# PR metadata via the GitHub API; no fork code is fetched or executed.
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, ready_for_review]
|
||||
jobs:
|
||||
@@ -9,6 +12,9 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: tobyhs/[email protected]
|
||||
# Pinned to a commit SHA (not a tag or branch) because this runs under
|
||||
# pull_request_target with a write-scoped token — a re-pointed tag or
|
||||
# moving branch would allow unreviewed upstream code to run here.
|
||||
- uses: tobyhs/codemention@ebac5877393ab8693fd73b42708c8552043e9a3b # post-v1.5.2
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
if: "${{ contains(github.event.label.name, 'incomplete issue: missing or invalid repro') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
if: "${{ contains(github.event.label.name, 'incomplete issue: missing info') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
if: github.event.label.name == 'issue accepted'
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
if: "${{ contains(github.event.label.name, 'invalid issue: question') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
if: "${{ contains(github.event.label.name, 'invalid issue: feature request') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
|
||||
steps:
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2
|
||||
with:
|
||||
registry-url: "https://registry.npmjs.org/"
|
||||
scope: "expo"
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
steps:
|
||||
- id: create_release
|
||||
uses: actions/create-release@v1
|
||||
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: ./.github/actions/setup-mise
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
cat /tmp/changelog.slack.md >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
- name: Slack Notification
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
|
||||
env:
|
||||
SLACK_CHANNEL: eas-cli
|
||||
SLACK_COLOR: ${{ job.status }}
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v4
|
||||
- uses: actions/stale@a20b814fb01b71def3bd6f56e7494d667ddf28da # v4
|
||||
with:
|
||||
ascending: false
|
||||
operations-per-run: 300
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
name: Ensure GraphQL schema and generated code is up-to-date
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
- run: yarn install --immutable
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
- run: yarn install --immutable
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
coverage: true
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
env:
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
working-directory: ./scripts
|
||||
env:
|
||||
YARN_ENABLE_HARDENED_MODE: ${{ matrix.coverage == true && '1' || '0' }}
|
||||
- uses: codecov/codecov-action@v1
|
||||
- uses: codecov/codecov-action@29386c70ef20e286228c72b668a06fd0e8399192 # v1
|
||||
if: ${{ matrix.coverage }}
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
name: Notify Slack
|
||||
if: ${{ github.ref == 'refs/heads/main' && always() }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
|
||||
- name: Notify Slack
|
||||
uses: ./.github/internal-actions/notify-slack-on-fail-or-recover
|
||||
with:
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"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"
|
||||
]
|
||||
}
|
||||
Vendored
+2
-1
@@ -5,5 +5,6 @@
|
||||
"**/*.code-search": true,
|
||||
"**/src/**/build/**": false,
|
||||
"**/src/build/**": false,
|
||||
}
|
||||
},
|
||||
"js/ts.tsdk.path": "node_modules/typescript/lib"
|
||||
}
|
||||
@@ -1,2 +1,9 @@
|
||||
defaultSemverRangePrefix: ''
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 2d
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- '@expo/*'
|
||||
- 'eslint-plugin-universe'
|
||||
- 'oxlint-config-universe'
|
||||
@@ -3,3 +3,4 @@
|
||||
When working with this repository follow instructions from CLAUDE.md.
|
||||
|
||||
- [./CLAUDE.md](./CLAUDE.md)
|
||||
- Before committing, run the formatter on modified files.
|
||||
+340
-1
@@ -8,10 +8,349 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas project:delete` command to delete a project — the current directory's project by default, or any project passed as an argument (`@account/slug` or ID). Confirmation requires the project's full name, typed at the interactive prompt or passed via `--dangerously-confirm-deletion` (required in non-interactive mode). Deletion requires sudo mode: the command prompts for your password (and OTP) to upgrade the session when the server requires it. Deletion was previously only possible from the website. ([#4012](https://github.com/expo/eas-cli/pull/4012) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `eas browse` to open the project page on expo.dev in a web browser, with an optional page argument (e.g. `eas browse build`, `eas browse hosting`) for project subpages and a `--no-browser` flag to print the URL. `eas open` is now an alias of `eas browse`. ([#4003](https://github.com/expo/eas-cli/pull/4003) by [@jonsamp](https://github.com/jonsamp))
|
||||
- [eas-cli] Add `--dev-domain` flag to `eas deploy` to choose the project's preview URL (`<name>.expo.app`) without prompting, allowing first-time deploys with `--non-interactive`. ([#4004](https://github.com/expo/eas-cli/pull/4004) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `-p` as an alias for `--platform` in `eas simulator:start`. ([#3902](https://github.com/expo/eas-cli/pull/3902) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli][build-tools][local-build-plugin][worker] Bump `tar` to `7.5.19`. ([#3971](https://github.com/expo/eas-cli/pull/3971) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [21.0.0](https://github.com/expo/eas-cli/releases/tag/v21.0.0) - 2026-07-13
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Remove `eas onboarding` command (aliases `init:onboarding`, `project:onboarding`). ([#3993](https://github.com/expo/eas-cli/pull/3993) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp))
|
||||
- [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/posthog_capture_event` workflow function to send PostHog events from workflow runs. ([#3934](https://github.com/expo/eas-cli/pull/3934) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/posthog_flag_rollout` workflow function to update PostHog feature flags from workflow runs. ([#3944](https://github.com/expo/eas-cli/pull/3944) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/posthog_wait_for_metric` workflow function to gate workflow runs on a PostHog metric. ([#3945](https://github.com/expo/eas-cli/pull/3945) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/posthog_upload_sourcemaps` workflow function to upload source maps to PostHog. ([#3947](https://github.com/expo/eas-cli/pull/3947) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/posthog_annotation` workflow function to create PostHog annotations from workflow runs. ([#3948](https://github.com/expo/eas-cli/pull/3948) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/posthog_wait_for_query` workflow function to gate workflow runs on a PostHog query. ([#3949](https://github.com/expo/eas-cli/pull/3949) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Rethrow Apple errors during capability sync instead of silently succeeding. ([#3989](https://github.com/expo/eas-cli/pull/3989) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Skip embedded bundle upload for development client builds instead of warning that the bundle is missing. ([#3940](https://github.com/expo/eas-cli/pull/3940) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Accept a navigation metric name in observe:metrics when passed in on the command line. ([#3973](https://github.com/expo/eas-cli/pull/3973) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Retry uploading assets that don't finish processing during `eas update`, instead of failing the update. ([#3918](https://github.com/expo/eas-cli/pull/3918) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] add agent detection to analytics events. ([#3983](https://github.com/expo/eas-cli/pull/3983) by [@davidmokos](https://github.com/davidmokos))
|
||||
- Bump `glob` 10.x to a patched version in the root lockfile. ([#3979](https://github.com/expo/eas-cli/pull/3979) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Bump `jws` to a patched version in the root lockfile. ([#3978](https://github.com/expo/eas-cli/pull/3978) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [worker] Bump `koa` to `3.1.2`. ([#3974](https://github.com/expo/eas-cli/pull/3974) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [steps] Bump `cross-spawn` to a patched version in the TypeScript custom function fixture. ([#3977](https://github.com/expo/eas-cli/pull/3977) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Bump `node-forge` to `1.4.0`. ([#3970](https://github.com/expo/eas-cli/pull/3970) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Bump `fast-xml-builder` to `1.2.1` to resolve [Dependabot alert 400](https://github.com/expo/eas-cli/security/dependabot/400). ([#3966](https://github.com/expo/eas-cli/pull/3966) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Bump `form-data` to patched releases to resolve [Dependabot alert 465](https://github.com/expo/eas-cli/security/dependabot/465). ([#3962](https://github.com/expo/eas-cli/pull/3962) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Bump `fast-xml-parser` to `4.5.7` to resolve [Dependabot alert 286](https://github.com/expo/eas-cli/security/dependabot/286). ([#3960](https://github.com/expo/eas-cli/pull/3960) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Bump `shell-quote` to `1.9.0` to resolve [Dependabot alert 440](https://github.com/expo/eas-cli/security/dependabot/440). ([#3959](https://github.com/expo/eas-cli/pull/3959) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [20.5.1](https://github.com/expo/eas-cli/releases/tag/v20.5.1) - 2026-07-01
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix Free plan users being warned they had reached their build limit while still well below it. Overage warnings are now shown only for paid plans with an actual billable overage cost. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8))
|
||||
- [eas-cli] Select correct tvOS build target for non-interactive Apple builds when EXPO_TV env variable is set. ([#3907](https://github.com/expo/eas-cli/pull/3907) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.22`. ([#3913](https://github.com/expo/eas-cli/pull/3913) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [20.5.0](https://github.com/expo/eas-cli/releases/tag/v20.5.0) - 2026-06-29
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] `eas integrations:posthog:connect` now supports existing PostHog user connection. ([#3895](https://github.com/expo/eas-cli/pull/3895) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [20.4.0](https://github.com/expo/eas-cli/releases/tag/v20.4.0) - 2026-06-25
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] New command `observe:session` for inspecting events by session ID. ([#3868](https://github.com/expo/eas-cli/pull/3868) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add tiered build-credit usage warnings (approaching/at/over) before each build, lowering the warning threshold to 80% to match email notifications. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix `eas update --channel` failing with "Channel has no branches associated with it" when the channel was auto-created by a build. A branch with the channel's name is now created and linked, matching the behavior for a channel that doesn't exist yet. ([#3891](https://github.com/expo/eas-cli/pull/3891) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [20.3.0](https://github.com/expo/eas-cli/releases/tag/v20.3.0) - 2026-06-18
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas account:audit [ACCOUNT_NAME]` command to view an account's audit logs. ([#3863](https://github.com/expo/eas-cli/pull/3863) by [@keith-kurak](https://github.com/keith-kurak))
|
||||
- [eas-cli] `eas update:view [GROUP_ID]` now also accepts a platform-specific update ID, resolving it to and displaying its update group. ([#3864](https://github.com/expo/eas-cli/pull/3864) by [@keith-kurak](https://github.com/keith-kurak))
|
||||
- [eas-cli] Add `eas integrations:posthog:connect` command. ([#3836](https://github.com/expo/eas-cli/pull/3836) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas integrations:posthog:dashboard` command. ([#3837](https://github.com/expo/eas-cli/pull/3837) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas integrations:posthog:disconnect` command. ([#3838](https://github.com/expo/eas-cli/pull/3838) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] `eas integrations:posthog:connect` no longer aborts when `npx expo install` reports an error (for example, on a dynamic app config); it finishes writing the environment variables and prints any remaining manual steps. ([#3877](https://github.com/expo/eas-cli/pull/3877) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Sync new app agent templates from `expo/llm-configs` before publishing. ([#3865](https://github.com/expo/eas-cli/pull/3865) by [@davidmokos](https://github.com/davidmokos))
|
||||
- [eas-cli] Simplify 2FA now that SMS is no longer supported. ([#3859](https://github.com/expo/eas-cli/pull/3859) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [20.2.0](https://github.com/expo/eas-cli/releases/tag/v20.2.0) - 2026-06-15
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Non-interactive iOS App Store and Enterprise builds can now use the App Store Connect API key stored in EAS credentials as a submission key to validate and repair provisioning profiles on Apple servers, without requiring `EXPO_ASC_*` environment variables or an interactive Apple login. ([#3805](https://github.com/expo/eas-cli/pull/3805) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Add support for non-interactive `eas update:rollback [GROUP_ID]`. The given update group must be the latest for its branch and runtime version; the previous update group is republished, or a roll back to the embedded update is published if there is none. ([#3825](https://github.com/expo/eas-cli/pull/3825) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [expo-cocoapods-proxy] Fix iOS worker tarball build failing on macOS Tahoe due to bundler incompatibility with RubyGems 4. ([#3824](https://github.com/expo/eas-cli/pull/3824) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Fix `update:embedded` topic showing the `update:embedded:delete` subcommand description in the command listing. ([#3866](https://github.com/expo/eas-cli/pull/3866) by [@jc-expo](https://github.com/jc-expo))
|
||||
|
||||
## [20.1.0](https://github.com/expo/eas-cli/releases/tag/v20.1.0) - 2026-06-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas update:embedded:view` command. ([#3810](https://github.com/expo/eas-cli/pull/3810) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas update:embedded:list` command. ([#3811](https://github.com/expo/eas-cli/pull/3811) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas update:embedded:delete` command. ([#3809](https://github.com/expo/eas-cli/pull/3809) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [20.0.0](https://github.com/expo/eas-cli/releases/tag/v20.0.0) - 2026-05-29
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Rename observe commands: `observe:logs` → `observe:events` (events emitted via `logEvent`), previous `observe:events` → `observe:metrics` (individual performance metric samples), previous `observe:metrics` → `observe:metrics-summary` (aggregated stats by app version). ([#3778](https://github.com/expo/eas-cli/pull/3778) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] `eas go` now prompts to select an Expo SDK version interactively when `--sdk-version` is not provided. ([#3768](https://github.com/expo/eas-cli/pull/3768) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas update:embedded:upload` command. ([#3720](https://github.com/expo/eas-cli/pull/3720) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Fix `eas/start_ios_simulator` hanging on Xcode 26.4 by writing the readiness screenshot to a temp file instead of `/dev/null`. ([#3794](https://github.com/expo/eas-cli/pull/3794) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Disable `apsd` in the iOS Simulator after boot to stop a push-registration log storm that pegs `diagnosticd` CPU for the lifetime of the Simulator. ([#3795](https://github.com/expo/eas-cli/pull/3795) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [19.1.0](https://github.com/expo/eas-cli/releases/tag/v19.1.0) - 2026-05-25
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--refresh-ad-hoc-provisioning-profile` flag to refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials in non-interactive mode. ([#3716](https://github.com/expo/eas-cli/pull/3716) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-build-job] Add optional `refreshAdHocProvisioningProfile` field to iOS build jobs. ([#3717](https://github.com/expo/eas-cli/pull/3717) by [@sswrk](https://github.com/sswrk))
|
||||
- [build-tools] Pass `refreshAdHocProvisioningProfile` through `eas build:internal` for git-based integration builds. ([#3770](https://github.com/expo/eas-cli/pull/3770) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## [19.0.8](https://github.com/expo/eas-cli/releases/tag/v19.0.8) - 2026-05-21
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] `eas go` now pre-selects the SDK version from the current project's `app.json` or `app.config.js` when available. ([#3776](https://github.com/expo/eas-cli/pull/3776) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [19.0.7](https://github.com/expo/eas-cli/releases/tag/v19.0.7) - 2026-05-21
|
||||
|
||||
## [19.0.6](https://github.com/expo/eas-cli/releases/tag/v19.0.6) - 2026-05-21
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Update the minimum Expo version required for iOS precompiled modules to 55.0.26. ([#3771](https://github.com/expo/eas-cli/pull/3771) by [@chrfalch](https://github.com/chrfalch))
|
||||
|
||||
## [19.0.5](https://github.com/expo/eas-cli/releases/tag/v19.0.5) - 2026-05-20
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Improve Sentry diagnostics for managed iOS entitlements config fallback. ([#3763](https://github.com/expo/eas-cli/pull/3763) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [19.0.4](https://github.com/expo/eas-cli/releases/tag/v19.0.4) - 2026-05-20
|
||||
|
||||
## [19.0.1](https://github.com/expo/eas-cli/releases/tag/v19.0.1) - 2026-05-19
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Disable dotenv loading when resolving managed iOS entitlements. ([#3752](https://github.com/expo/eas-cli/pull/3752) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Restore the bundled config fallback for managed iOS entitlements introspection. ([#3753](https://github.com/expo/eas-cli/pull/3753) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [19.0.0](https://github.com/expo/eas-cli/releases/tag/v19.0.0) - 2026-05-19
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Make browser-based authentication the default for `eas login`. Use `--no-browser` for CLI-based login. ([#3746](https://github.com/expo/eas-cli/pull/3746) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Allow command `observe:routes` to filter results to specific route names. ([#3744](https://github.com/expo/eas-cli/pull/3744) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Check that `expo` is resolvable before assuming it's installed ([#3748](https://github.com/expo/eas-cli/pull/3748) by [@kitten](https://github.com/kitten))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [worker] Report build phase durations stats. ([#3732](https://github.com/expo/eas-cli/pull/3732) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [18.13.1](https://github.com/expo/eas-cli/releases/tag/v18.13.1) - 2026-05-18
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Send the installed `expo` package version in build metadata. ([#3725](https://github.com/expo/eas-cli/pull/3725) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Prevent `npx` invocations that can be unreliable and fail when retrieving entitlements or project configs ([#3282](https://github.com/expo/eas-cli/pull/3282) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [18.13.0](https://github.com/expo/eas-cli/releases/tag/v18.13.0) - 2026-05-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] New command `observe:routes` for navigation metrics. ([#3730](https://github.com/expo/eas-cli/pull/3730) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Refactor observe commands to extract common flag definitions. ([#3731](https://github.com/expo/eas-cli/pull/3731) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [18.12.3](https://github.com/expo/eas-cli/releases/tag/v18.12.3) - 2026-05-13
|
||||
|
||||
## [18.12.2](https://github.com/expo/eas-cli/releases/tag/v18.12.2) - 2026-05-12
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix `eas go` to use server-side project setup and support multiple Expo Go SDK versions. ([#3657](https://github.com/expo/eas-cli/pull/3657) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [ci] Audit GitHub Actions workflows: harden `pull_request_target` usage, pin all external actions to commit SHAs, and add Dependabot to refresh them. ([#3718](https://github.com/expo/eas-cli/pull/3718) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [18.12.1](https://github.com/expo/eas-cli/releases/tag/v18.12.1) - 2026-05-12
|
||||
|
||||
## [18.12.0](https://github.com/expo/eas-cli/releases/tag/v18.12.0) - 2026-05-12
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Automatically save the Convex deployment URL as an EAS environment variable when connecting a Convex project. ([#3685](https://github.com/expo/eas-cli/pull/3685) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Update the minimum Expo version required for iOS precompiled modules. ([#3677](https://github.com/expo/eas-cli/pull/3677) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] recommend that users read the docs after connecting Convex ([#3683](https://github.com/expo/eas-cli/pull/3683) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
## [18.11.0](https://github.com/expo/eas-cli/releases/tag/v18.11.0) - 2026-05-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--simulator` to `eas build:dev` and `eas build:run` to select which iOS simulator to install and run builds on. ([#3637](https://github.com/expo/eas-cli/pull/3637) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix Convex team invite output after skipped or unnecessary invitations. ([#3672](https://github.com/expo/eas-cli/pull/3672) by [@fiberjw](https://github.com/fiberjw))
|
||||
- Make directories marked as read-only writable when creating a tar, to prevent the cross-filesystem issues when extracting the tar. ([#3489](https://github.com/expo/eas-cli/pull/3489) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## [18.10.0](https://github.com/expo/eas-cli/releases/tag/v18.10.0) - 2026-05-04
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas integrations:convex` commands to manage Convex integrations for EAS projects. ([#3575](https://github.com/expo/eas-cli/pull/3575) by [@fiberjw](https://github.com/fiberjw))
|
||||
- [eas-cli] New command `observe:logs` for custom events. ([#3638](https://github.com/expo/eas-cli/pull/3638) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Create portable project archives on all platforms to normalize cross-platform tar metadata and permissions. ([#3234](https://github.com/expo/eas-cli/pull/3234) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Remove hardcoded `builderEnvironment.image` override in `eas build:resign`. ([#3661](https://github.com/expo/eas-cli/pull/3661) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Fix `eas update --json` intermittently failing with JSON parse errors during "Computing project fingerprints" by passing `silent: true` to `@expo/fingerprint` to suppress subprocess stdout pollution. ([#3659](https://github.com/expo/eas-cli/pull/3659) by [@Mookiies](https://github.com/Mookiies))
|
||||
|
||||
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
|
||||
|
||||
## [18.9.0](https://github.com/expo/eas-cli/releases/tag/v18.9.0) - 2026-04-30
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas integrations:asc` commands to manage App Store Connect integrations for EAS projects. ([#3558](https://github.com/expo/eas-cli/pull/3558) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Allow `eas build:download` to accept a build ID. ([#3655](https://github.com/expo/eas-cli/pull/3655) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `--id` alias for `--build-id` flag in `eas build:download`. ([#3656](https://github.com/expo/eas-cli/pull/3656) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [steps] Fix workflow job context interpolation when a string contains multiple `${{ }}` expressions on the same line (for example a changelog built from two context values). ([#3644](https://github.com/expo/eas-cli/pull/3644) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [18.8.1](https://github.com/expo/eas-cli/releases/tag/v18.8.1) - 2026-04-23
|
||||
|
||||
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Support custom params in `observe:events` results. ([#3620](https://github.com/expo/eas-cli/pull/3620) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Add `eas/deploy` function for EAS Hosting web deployments. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/export` function for Expo web exports. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas update:insights <groupId>` command to display launch, crash, unique-user, and payload-size metrics for an update group. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `--insights` flag to `eas update:view <groupId>` to append the same metrics below the existing output. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `eas channel:insights --channel <name> --runtime-version <version>` command to display adoption and crash metrics for a channel + runtime version. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix `metadata:push` not deleting video previews from App Store Connect when removed from config. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Warn and skip unknown preview types in `metadata:push` with a helpful suggestion when the `APP_` screenshot prefix is mistakenly used. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Delete all existing previews in a preview set before uploading to avoid Apple's "Too many app previews" error. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Fixes for `observe` commands, including an issue for apps with many update IDs. ([#3609](https://github.com/expo/eas-cli/pull/3609) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add existing capability identifiers. ([#3615](https://github.com/expo/eas-cli/pull/3615) by [@jakex7](https://github.com/jakex7))
|
||||
|
||||
## [18.7.0](https://github.com/expo/eas-cli/releases/tag/v18.7.0) - 2026-04-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
## [18.6.0](https://github.com/expo/eas-cli/releases/tag/v18.6.0) - 2026-04-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [build-tools] Add `working_directory` input to `eas/build` function group for custom builds. ([#3582](https://github.com/expo/eas-cli/pull/3582) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [steps] Coerce numeric env values to strings in workflow step configuration. ([#3583](https://github.com/expo/eas-cli/pull/3583) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
|
||||
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
|
||||
- [build-tools] Early stop `eas/start_android_emulator` when Android emulator host setup is invalid. ([#3580](https://github.com/expo/eas-cli/pull/3580) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.18` to fix `metadata:push` failing on `ageRatingDeclarations` due to the removed `gamblingAndContests` attribute. ([#3588](https://github.com/expo/eas-cli/pull/3588) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.19` to fix image and video uploads via `metadata:push` getting stuck in `AWAITING_UPLOAD` state. The asset client was inheriting Bearer token injection from the App Store Connect API client, which caused S3 presigned URL uploads to be silently mishandled by Apple's CDN. Fixes screenshots, previews, and App Clip header image uploads. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] `metadata:pull` now preserves screenshot, video preview, and App Clip header image entries with placeholder paths when the asset is in an unrendered state, so users can recover broken records by replacing the file or removing the entry instead of having entries silently dropped from `store.config.json`. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Surface hosting deployment's asset upload errors sooner ([#3600](https://github.com/expo/eas-cli/pull/3600) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add `observe:metrics`, `observe:events`, and `observe:versions` commands. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)), [@douglowder](https://github.com/douglowder)
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
|
||||
- Fix `hasIgnoredIosProjectAsync()` always returning `false` for ignored iOS projects. ([#3562](https://github.com/expo/eas-cli/pull/3562) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
|
||||
- [eas-cli][build-tools][worker] Bump `@expo/config` and `@expo/config-plugins`. ([#3527](https://github.com/expo/eas-cli/pull/3527) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Auto-detect non-interactive mode in CI and non-TTY environments. ([#3486](https://github.com/expo/eas-cli/pull/3486) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- Add `skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
## [18.3.0](https://github.com/expo/eas-cli/releases/tag/v18.3.0) - 2026-03-10
|
||||
|
||||
### 🎉 New features
|
||||
@@ -28,6 +367,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Check all certificates in provisioning profile during verification instead of only the first one. ([#3484](https://github.com/expo/eas-cli/pull/3484) by [@qwertey6](https://github.com/qwertey6))
|
||||
- Provide an override for the new --environment flag requirement in the update command. ([#3442](https://github.com/expo/eas-cli/pull/3442) by [@douglowder](https://github.com/douglowder))
|
||||
- Add missing `--include=dev` for `npm install` commands ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
|
||||
- Add missing `--production false` for `yarn install` commands for Yarn Classic ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
|
||||
@@ -1672,7 +2012,6 @@ 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": "18.3.0",
|
||||
"version": "21.0.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -3,4 +3,6 @@ _.path = "./tools/bin"
|
||||
MISE_NODE_COREPACK = "true"
|
||||
|
||||
[tools]
|
||||
gcloud = "570.0.0"
|
||||
jq = "1.8.1"
|
||||
node = "20.19.4"
|
||||
+2
-1
@@ -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 && rm -rf node_modules coverage"
|
||||
"clean": "lerna run clean && rimraf node_modules coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
@@ -30,6 +30,7 @@
|
||||
"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,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
bin/record-sim
|
||||
|
||||
.secrets*
|
||||
!.secrets.example
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.2.0",
|
||||
"version": "21.0.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -23,33 +23,36 @@
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"prebuild": "yarn gql",
|
||||
"build": "tsc",
|
||||
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rm -rf dist *.tsbuildinfo && yarn gql && tsc -p tsconfig.build.json",
|
||||
"prepack": "rimraf 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": "rm -rf node_modules dist coverage *.tsbuildinfo",
|
||||
"clean": "rimraf 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": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/downloader": "18.0.1",
|
||||
"@expo/eas-build-job": "18.2.0",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.1",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.2.0",
|
||||
"@expo/template-file": "18.0.1",
|
||||
"@expo/turtle-spawn": "18.0.1",
|
||||
"@expo/steps": "21.0.1",
|
||||
"@expo/template-file": "21.0.0",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@google-cloud/storage": "^7.11.2",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "^6.0.1",
|
||||
"bplist-parser": "0.3.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
@@ -69,13 +72,13 @@
|
||||
"resolve-from": "^5.0.0",
|
||||
"retry": "^0.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"tar": "7.5.7",
|
||||
"tar": "7.5.19",
|
||||
"uuid": "9.0.1",
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/repack-app": "~0.2.5",
|
||||
"@expo/repack-app": "~0.6.1",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
@@ -91,6 +94,7 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
DerivedData/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,28 @@
|
||||
// swift-tools-version: 5.9
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "record-sim",
|
||||
platforms: [
|
||||
.macOS(.v14),
|
||||
],
|
||||
products: [
|
||||
.library(name: "RecordSim", targets: ["RecordSim"]),
|
||||
.executable(name: "record-sim", targets: ["record-sim"]),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "RecordSim",
|
||||
linkerSettings: [
|
||||
.linkedFramework("AVFoundation"),
|
||||
.linkedFramework("CoreGraphics"),
|
||||
.linkedFramework("CoreMedia"),
|
||||
.linkedFramework("CoreVideo"),
|
||||
.linkedFramework("IOSurface"),
|
||||
.linkedFramework("UniformTypeIdentifiers"),
|
||||
]
|
||||
),
|
||||
.executableTarget(name: "record-sim", dependencies: ["RecordSim"]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
# RecordSim
|
||||
|
||||
Small Swift library for non-exclusive iOS Simulator screen recording.
|
||||
|
||||
It implements one recording path:
|
||||
|
||||
1. Listen to SimRenderServer framebuffer callbacks.
|
||||
2. Copy the live `IOSurface` immediately into an owned `CVPixelBuffer`.
|
||||
3. Feed one continuous `AVAssetWriter`.
|
||||
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
|
||||
|
||||
The output is suitable for uploading while recording. `session.json` contains
|
||||
the ordered segment metadata needed to build a playlist later.
|
||||
|
||||
## Output
|
||||
|
||||
```text
|
||||
session/
|
||||
init.mp4
|
||||
session.json
|
||||
segments/
|
||||
segment-000000.m4s
|
||||
segment-000001.m4s
|
||||
```
|
||||
|
||||
`init.mp4` is the fMP4 initialization segment. Individual `.m4s` files are not
|
||||
standalone MP4 files. `session.json` stores the wall-clock timestamp for the
|
||||
first video frame plus the metadata needed to build a playlist: HLS version,
|
||||
target duration, media sequence, `initSegment`, and the ordered `segments` array
|
||||
with file path and duration for each media segment.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
swift run record-sim \
|
||||
--udid <BOOTED_SIMULATOR_UDID> \
|
||||
--output /tmp/sim-recording \
|
||||
--segment-duration 120 \
|
||||
--fps 30 \
|
||||
--bitrate 30000000 \
|
||||
--codec h264
|
||||
```
|
||||
|
||||
For sparse 2h sessions, `--segment-duration 120` is a reasonable default: about
|
||||
60 media objects plus the init segment. The recorder appends a single duplicate
|
||||
hold frame near each segment boundary when the simulator is idle, so long idle
|
||||
periods still produce uploadable segments without encoding idle frames at 60fps.
|
||||
Use `--segment-duration 0` to write one continuous `recording.mp4` instead of
|
||||
`init.mp4` plus media segments.
|
||||
|
||||
Frame timestamps use monotonic host time, not wall-clock time. The recorder also
|
||||
probes the framebuffer seed once per second; if the surface changes without
|
||||
callbacks for 5 seconds, it captures that frame and rewires the private callback
|
||||
registration.
|
||||
|
||||
## Library
|
||||
|
||||
```swift
|
||||
let recorder = SimulatorRecorder(
|
||||
configuration: SimulatorRecordingConfiguration(
|
||||
deviceUDID: udid,
|
||||
outputDirectory: sessionDirectory,
|
||||
segmentDuration: 120
|
||||
)
|
||||
)
|
||||
|
||||
recorder.onSegment = { segment in
|
||||
// Enqueue upload of segment.url.
|
||||
}
|
||||
|
||||
try recorder.start()
|
||||
try recorder.waitUntilFirstFrame()
|
||||
// ...
|
||||
let manifest = try recorder.stop()
|
||||
```
|
||||
|
||||
`session.json` contains the precise wall-clock time for video PTS zero. In
|
||||
segmented mode it also contains the init segment and ordered media segment
|
||||
file/duration entries; in single-file mode it points at `recording.mp4`.
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
import IOSurface
|
||||
import ObjectiveC
|
||||
|
||||
final class FramebufferDisplaySource {
|
||||
struct SurfaceSnapshot {
|
||||
let surface: IOSurface
|
||||
let width: Int
|
||||
let height: Int
|
||||
let seed: UInt32
|
||||
}
|
||||
|
||||
private let deviceUDID: String
|
||||
private let callbackQueue: DispatchQueue
|
||||
private let onFrame: () -> Void
|
||||
private let onSurfaceChange: () -> Void
|
||||
private var descriptors: [NSObject] = []
|
||||
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
|
||||
private var retainedBlocks: [AnyObject] = []
|
||||
private var ioClient: NSObject?
|
||||
|
||||
init(
|
||||
deviceUDID: String,
|
||||
callbackQueue: DispatchQueue,
|
||||
onFrame: @escaping () -> Void,
|
||||
onSurfaceChange: @escaping () -> Void
|
||||
) {
|
||||
self.deviceUDID = deviceUDID
|
||||
self.callbackQueue = callbackQueue
|
||||
self.onFrame = onFrame
|
||||
self.onSurfaceChange = onSurfaceChange
|
||||
}
|
||||
|
||||
func start() throws {
|
||||
try PrivateSimulatorFrameworks.load()
|
||||
guard let device = SimulatorDeviceLookup.find(udid: deviceUDID) else {
|
||||
throw RecorderError.make(1, "Simulator \(deviceUDID) not found")
|
||||
}
|
||||
let state = device.value(forKey: "stateString") as? String ?? "unknown"
|
||||
guard state == "Booted" else {
|
||||
throw RecorderError.make(2, "Simulator \(deviceUDID) is not booted (state: \(state))")
|
||||
}
|
||||
guard
|
||||
let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject
|
||||
else {
|
||||
throw RecorderError.make(3, "Failed to get simulator IO client")
|
||||
}
|
||||
ioClient = io
|
||||
try wireUpFramebuffer()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
unregisterCallbacks()
|
||||
descriptors.removeAll()
|
||||
retainedBlocks.removeAll()
|
||||
ioClient = nil
|
||||
}
|
||||
|
||||
func surfaceSnapshot() -> SurfaceSnapshot? {
|
||||
let surfaceSelector = NSSelectorFromString("framebufferSurface")
|
||||
var bestSnapshot: SurfaceSnapshot?
|
||||
var bestArea = 0
|
||||
for descriptor in descriptors {
|
||||
guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue()
|
||||
else {
|
||||
continue
|
||||
}
|
||||
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
|
||||
let width = IOSurfaceGetWidth(surface)
|
||||
let height = IOSurfaceGetHeight(surface)
|
||||
let area = width * height
|
||||
if area > bestArea {
|
||||
bestSnapshot = SurfaceSnapshot(
|
||||
surface: surface,
|
||||
width: width,
|
||||
height: height,
|
||||
seed: IOSurfaceGetSeed(surface)
|
||||
)
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
return bestSnapshot
|
||||
}
|
||||
|
||||
func rewireFramebuffer() throws {
|
||||
try wireUpFramebuffer()
|
||||
}
|
||||
|
||||
private func wireUpFramebuffer() throws {
|
||||
guard let io = ioClient else {
|
||||
throw RecorderError.make(3, "No simulator IO client")
|
||||
}
|
||||
io.perform(NSSelectorFromString("updateIOPorts"))
|
||||
let nextDescriptors = try findFramebufferDescriptors(io: io)
|
||||
unregisterCallbacks()
|
||||
descriptors = nextDescriptors
|
||||
retainedBlocks.removeAll()
|
||||
do {
|
||||
for descriptor in descriptors {
|
||||
try registerCallbacks(descriptor: descriptor)
|
||||
}
|
||||
} catch {
|
||||
unregisterCallbacks()
|
||||
descriptors.removeAll()
|
||||
retainedBlocks.removeAll()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func findFramebufferDescriptors(io: NSObject) throws -> [NSObject] {
|
||||
guard let ports = io.value(forKey: "deviceIOPorts") as? [NSObject] else {
|
||||
throw RecorderError.make(4, "Failed to read simulator IO ports")
|
||||
}
|
||||
let portIdentifierSelector = NSSelectorFromString("portIdentifier")
|
||||
let descriptorSelector = NSSelectorFromString("descriptor")
|
||||
let surfaceSelector = NSSelectorFromString("framebufferSurface")
|
||||
|
||||
var candidates: [NSObject] = []
|
||||
for port in ports {
|
||||
guard port.responds(to: portIdentifierSelector),
|
||||
let portIdentifier = port.perform(portIdentifierSelector)?.takeUnretainedValue(),
|
||||
"\(portIdentifier)" == "com.apple.framebuffer.display",
|
||||
port.responds(to: descriptorSelector),
|
||||
let descriptor = port.perform(descriptorSelector)?.takeUnretainedValue()
|
||||
as? NSObject,
|
||||
descriptor.responds(to: surfaceSelector)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
candidates.append(descriptor)
|
||||
}
|
||||
if candidates.isEmpty {
|
||||
throw RecorderError.make(5, "No framebuffer display descriptor found")
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
private func registerCallbacks(descriptor: NSObject) throws {
|
||||
let selector = NSSelectorFromString(
|
||||
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
|
||||
)
|
||||
guard descriptor.responds(to: selector) else {
|
||||
throw RecorderError.make(6, "Framebuffer descriptor does not support screen callbacks")
|
||||
}
|
||||
guard let msgSendPointer = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "objc_msgSend")
|
||||
else {
|
||||
throw RecorderError.make(7, "objc_msgSend not found")
|
||||
}
|
||||
typealias MsgSend =
|
||||
@convention(c) (
|
||||
AnyObject, Selector, AnyObject, AnyObject, AnyObject, AnyObject, AnyObject
|
||||
) -> Void
|
||||
let msgSend = unsafeBitCast(msgSendPointer, to: MsgSend.self)
|
||||
|
||||
let uuid = NSUUID()
|
||||
callbackUUIDs[ObjectIdentifier(descriptor)] = uuid
|
||||
|
||||
let frameCallback: @convention(block) () -> Void = { [weak self] in
|
||||
self?.onFrame()
|
||||
}
|
||||
let surfacesCallback: @convention(block) (AnyObject?, AnyObject?) -> Void = {
|
||||
[weak self] _, _ in
|
||||
self?.onSurfaceChange()
|
||||
}
|
||||
let propertiesCallback: @convention(block) () -> Void = {}
|
||||
retainedBlocks.append(frameCallback as AnyObject)
|
||||
retainedBlocks.append(surfacesCallback as AnyObject)
|
||||
retainedBlocks.append(propertiesCallback as AnyObject)
|
||||
|
||||
msgSend(
|
||||
descriptor,
|
||||
selector,
|
||||
uuid,
|
||||
callbackQueue as AnyObject,
|
||||
frameCallback as AnyObject,
|
||||
surfacesCallback as AnyObject,
|
||||
propertiesCallback as AnyObject
|
||||
)
|
||||
}
|
||||
|
||||
private func unregisterCallbacks() {
|
||||
let selector = NSSelectorFromString("unregisterScreenCallbacksWithUUID:")
|
||||
for descriptor in descriptors {
|
||||
if let uuid = callbackUUIDs[ObjectIdentifier(descriptor)],
|
||||
descriptor.responds(to: selector)
|
||||
{
|
||||
descriptor.perform(selector, with: uuid)
|
||||
}
|
||||
}
|
||||
callbackUUIDs.removeAll()
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
import ObjectiveC
|
||||
|
||||
enum XcodeDeveloperDirectory {
|
||||
static let current: String = {
|
||||
if let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"],
|
||||
!developerDir.isEmpty
|
||||
{
|
||||
return developerDir
|
||||
}
|
||||
|
||||
let fallback = "/Applications/Xcode.app/Contents/Developer"
|
||||
let pipe = Pipe()
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
|
||||
process.arguments = ["-p"]
|
||||
process.standardOutput = pipe
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
process.waitUntilExit()
|
||||
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback
|
||||
}()
|
||||
}
|
||||
|
||||
enum PrivateSimulatorFrameworks {
|
||||
static func load() throws {
|
||||
try loadResult.get()
|
||||
}
|
||||
|
||||
private static let loadResult: Result<Void, Error> = {
|
||||
let developerDir = XcodeDeveloperDirectory.current
|
||||
let candidates = [
|
||||
"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
|
||||
"\(developerDir)/Library/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
|
||||
"\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit",
|
||||
"\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit",
|
||||
]
|
||||
var failures: [String] = []
|
||||
for path in candidates {
|
||||
if dlopen(path, RTLD_NOW) == nil {
|
||||
let message = dlerror().map { String(cString: $0) } ?? "unknown error"
|
||||
failures.append("\(path): \(message)")
|
||||
}
|
||||
}
|
||||
guard NSClassFromString("SimServiceContext") != nil else {
|
||||
let tried = candidates.joined(separator: ", ")
|
||||
let errors = failures.isEmpty ? "no dlopen errors" : failures.joined(separator: "; ")
|
||||
return .failure(
|
||||
RecorderError.make(
|
||||
8,
|
||||
"Failed to load CoreSimulator/SimulatorKit (tried: \(tried); errors: \(errors))"
|
||||
)
|
||||
)
|
||||
}
|
||||
return .success(())
|
||||
}()
|
||||
}
|
||||
|
||||
enum SimulatorDeviceLookup {
|
||||
private static let context: NSObject? = {
|
||||
guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type else {
|
||||
return nil
|
||||
}
|
||||
let sharedSelector = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:")
|
||||
return contextClass.perform(
|
||||
sharedSelector, with: XcodeDeveloperDirectory.current, with: nil)?
|
||||
.takeUnretainedValue() as? NSObject
|
||||
}()
|
||||
|
||||
static func find(udid: String) -> NSObject? {
|
||||
guard let context else {
|
||||
return nil
|
||||
}
|
||||
let deviceSetSelector = NSSelectorFromString("defaultDeviceSetWithError:")
|
||||
guard
|
||||
let deviceSet = context.perform(deviceSetSelector, with: nil)?
|
||||
.takeUnretainedValue() as? NSObject
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
guard let devices = deviceSet.value(forKey: "devices") as? [NSObject] else {
|
||||
return nil
|
||||
}
|
||||
return devices.first {
|
||||
($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid
|
||||
}
|
||||
}
|
||||
|
||||
static func stateString(udid: String) -> String? {
|
||||
find(udid: udid)?.value(forKey: "stateString") as? String
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
|
||||
public enum RecorderCodec: String, Codable, Sendable {
|
||||
case h264
|
||||
case hevc
|
||||
}
|
||||
|
||||
public struct SimulatorRecordingConfiguration: Sendable {
|
||||
public var deviceUDID: String
|
||||
public var outputDirectory: URL
|
||||
public var fps: Int
|
||||
public var bitrate: Int
|
||||
public var codec: RecorderCodec
|
||||
public var segmentDuration: TimeInterval
|
||||
public var maxPendingFrames: Int
|
||||
public var overwrite: Bool
|
||||
|
||||
public init(
|
||||
deviceUDID: String,
|
||||
outputDirectory: URL,
|
||||
fps: Int = 30,
|
||||
bitrate: Int = 30_000_000,
|
||||
codec: RecorderCodec = .h264,
|
||||
segmentDuration: TimeInterval = 120,
|
||||
maxPendingFrames: Int = 8,
|
||||
overwrite: Bool = true
|
||||
) {
|
||||
self.deviceUDID = deviceUDID
|
||||
self.outputDirectory = outputDirectory
|
||||
self.fps = fps
|
||||
self.bitrate = bitrate
|
||||
self.codec = codec
|
||||
self.segmentDuration = segmentDuration
|
||||
self.maxPendingFrames = maxPendingFrames
|
||||
self.overwrite = overwrite
|
||||
}
|
||||
}
|
||||
|
||||
public enum SegmentKind: String, Codable, Sendable {
|
||||
case initialization
|
||||
case media
|
||||
}
|
||||
|
||||
public struct SegmentOutput: Sendable {
|
||||
public let kind: SegmentKind
|
||||
public let relativePath: String
|
||||
public let url: URL
|
||||
public let byteCount: Int
|
||||
public let durationSeconds: Double?
|
||||
}
|
||||
|
||||
public struct MediaSegmentRecord: Codable, Sendable {
|
||||
public let file: String
|
||||
public let durationSeconds: Double
|
||||
}
|
||||
|
||||
public struct FirstFrameWallClock: Codable, Sendable {
|
||||
public let unixMs: Int64
|
||||
public let iso8601: String
|
||||
}
|
||||
|
||||
public struct RecordingManifest: Codable, Sendable {
|
||||
public let firstFrameWallClock: FirstFrameWallClock
|
||||
public let hlsVersion: Int?
|
||||
public let hlsTargetDurationSeconds: Int?
|
||||
public let hlsMediaSequence: Int?
|
||||
public let recording: String?
|
||||
public let initSegment: String?
|
||||
public let segments: [MediaSegmentRecord]
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
|
||||
private let rootDirectory: URL
|
||||
private let segmentsDirectory: URL
|
||||
let singleRecordingURL: URL
|
||||
private let stateQueue = DispatchQueue(label: "record-sim.segment-writer")
|
||||
private let notificationQueue = DispatchQueue(label: "record-sim.segment-notifications")
|
||||
private let onSegment: ((SegmentOutput) -> Void)?
|
||||
private var initSegmentPath: String?
|
||||
private var segments: [MediaSegmentRecord] = []
|
||||
private var nextMediaSegmentIndex = 0
|
||||
private var firstError: Error?
|
||||
|
||||
init(rootDirectory: URL, onSegment: ((SegmentOutput) -> Void)?) {
|
||||
self.rootDirectory = rootDirectory
|
||||
self.segmentsDirectory = rootDirectory.appendingPathComponent("segments", isDirectory: true)
|
||||
self.singleRecordingURL = rootDirectory.appendingPathComponent("recording.mp4")
|
||||
self.onSegment = onSegment
|
||||
super.init()
|
||||
}
|
||||
|
||||
func prepare(overwrite: Bool, segmented: Bool) throws {
|
||||
if overwrite {
|
||||
try? FileManager.default.removeItem(at: rootDirectory)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
|
||||
if segmented {
|
||||
try FileManager.default.createDirectory(at: segmentsDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
||||
var error: Error? {
|
||||
stateQueue.sync { firstError }
|
||||
}
|
||||
|
||||
func assetWriter(
|
||||
_ writer: AVAssetWriter,
|
||||
didOutputSegmentData segmentData: Data,
|
||||
segmentType: AVAssetSegmentType,
|
||||
segmentReport: AVAssetSegmentReport?
|
||||
) {
|
||||
var notification: SegmentOutput?
|
||||
|
||||
stateQueue.sync {
|
||||
if firstError != nil {
|
||||
return
|
||||
}
|
||||
do {
|
||||
switch segmentType {
|
||||
case .initialization:
|
||||
let relativePath = "init.mp4"
|
||||
let url = rootDirectory.appendingPathComponent(relativePath)
|
||||
try segmentData.write(to: url, options: .atomic)
|
||||
initSegmentPath = relativePath
|
||||
notification = SegmentOutput(
|
||||
kind: .initialization,
|
||||
relativePath: relativePath,
|
||||
url: url,
|
||||
byteCount: segmentData.count,
|
||||
durationSeconds: nil
|
||||
)
|
||||
case .separable:
|
||||
let index = nextMediaSegmentIndex
|
||||
let relativePath = String(format: "segments/segment-%06d.m4s", index)
|
||||
let url = rootDirectory.appendingPathComponent(relativePath)
|
||||
try segmentData.write(to: url, options: .atomic)
|
||||
|
||||
let videoReport = segmentReport?.trackReports.first(where: { $0.mediaType == .video })
|
||||
let duration = videoReport.flatMap { timeSeconds($0.duration) } ?? 0
|
||||
let record = MediaSegmentRecord(
|
||||
file: relativePath,
|
||||
durationSeconds: duration
|
||||
)
|
||||
nextMediaSegmentIndex += 1
|
||||
segments.append(record)
|
||||
notification = SegmentOutput(
|
||||
kind: .media,
|
||||
relativePath: relativePath,
|
||||
url: url,
|
||||
byteCount: segmentData.count,
|
||||
durationSeconds: duration
|
||||
)
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
firstError = error
|
||||
}
|
||||
}
|
||||
|
||||
if let notification, let onSegment {
|
||||
notificationQueue.async {
|
||||
onSegment(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func writeManifest(
|
||||
configuration: SimulatorRecordingConfiguration,
|
||||
firstFrameWallClock: Date
|
||||
) throws -> RecordingManifest {
|
||||
try stateQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
let segmented = configuration.segmentDuration > 0
|
||||
if segmented, initSegmentPath == nil {
|
||||
throw RecorderError.make(50, "Missing initialization segment")
|
||||
}
|
||||
if segmented, segments.isEmpty {
|
||||
throw RecorderError.make(51, "No media segments were written")
|
||||
}
|
||||
let targetDuration = segmented
|
||||
? max(1, Int(ceil(segments.map(\.durationSeconds).max() ?? configuration.segmentDuration)))
|
||||
: nil
|
||||
let manifest = RecordingManifest(
|
||||
firstFrameWallClock: FirstFrameWallClock(
|
||||
unixMs: unixMs(firstFrameWallClock),
|
||||
iso8601: iso8601(firstFrameWallClock)
|
||||
),
|
||||
hlsVersion: segmented ? 7 : nil,
|
||||
hlsTargetDurationSeconds: targetDuration,
|
||||
hlsMediaSequence: segmented ? 0 : nil,
|
||||
recording: segmented ? nil : "recording.mp4",
|
||||
initSegment: segmented ? initSegmentPath : nil,
|
||||
segments: segments
|
||||
)
|
||||
let data = try JSONEncoder.prettySorted.encode(manifest)
|
||||
try data.write(to: rootDirectory.appendingPathComponent("session.json"), options: .atomic)
|
||||
return manifest
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
import AVFoundation
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import IOSurface
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public final class SimulatorRecorder {
|
||||
public var onSegment: ((SegmentOutput) -> Void)?
|
||||
public var onSimulatorStopped: ((String) -> Void)?
|
||||
|
||||
private static let callbackStalenessTimeout: TimeInterval = 5
|
||||
private static let firstFrameRewireInterval: TimeInterval = 1
|
||||
private let configuration: SimulatorRecordingConfiguration
|
||||
private let callbackQueue = DispatchQueue(
|
||||
label: "record-sim.frame-callbacks", qos: .userInteractive)
|
||||
private let writerQueue = DispatchQueue(label: "record-sim.asset-writer", qos: .userInitiated)
|
||||
private let eventQueue = DispatchQueue(label: "record-sim.events", qos: .utility)
|
||||
private let pendingLock = NSLock()
|
||||
private var pendingFrames = 0
|
||||
private var displaySource: FramebufferDisplaySource?
|
||||
private var outputWriter: RecordingOutputWriter?
|
||||
private var writer: AVAssetWriter?
|
||||
private var input: AVAssetWriterInput?
|
||||
private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
|
||||
private var boundaryTimer: DispatchSourceTimer?
|
||||
private var monotonicClock = MonotonicClock()
|
||||
private var firstAcceptedCaptureTime: CMTime?
|
||||
private var firstAcceptedWallClock: Date?
|
||||
private var lastPTS: CMTime?
|
||||
private var lastSeed: UInt32?
|
||||
private var lastFrameCallbackElapsed: TimeInterval?
|
||||
private var lastFirstFrameRewireElapsed: TimeInterval = 0
|
||||
private var lastAppendedPixelBuffer: CVPixelBuffer?
|
||||
private var nextBoundaryElapsed: TimeInterval = 0
|
||||
private var simulatorStoppedReason: String?
|
||||
private var stopped = false
|
||||
private var firstFrameReady = false
|
||||
private let firstFrameSemaphore = DispatchSemaphore(value: 0)
|
||||
private var firstError: Error?
|
||||
|
||||
public init(configuration: SimulatorRecordingConfiguration) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
public func start() throws {
|
||||
try validateConfiguration()
|
||||
|
||||
let outputWriter = RecordingOutputWriter(
|
||||
rootDirectory: configuration.outputDirectory,
|
||||
onSegment: { [weak self] segment in
|
||||
self?.onSegment?(segment)
|
||||
})
|
||||
try outputWriter.prepare(
|
||||
overwrite: configuration.overwrite, segmented: configuration.segmentDuration > 0)
|
||||
self.outputWriter = outputWriter
|
||||
|
||||
monotonicClock = MonotonicClock()
|
||||
nextBoundaryElapsed = configuration.segmentDuration
|
||||
lastFirstFrameRewireElapsed = 0
|
||||
stopped = false
|
||||
|
||||
let source = FramebufferDisplaySource(
|
||||
deviceUDID: configuration.deviceUDID,
|
||||
callbackQueue: callbackQueue,
|
||||
onFrame: { [weak self] in
|
||||
self?.captureFrame(force: false, reason: .callback)
|
||||
},
|
||||
onSurfaceChange: { [weak self] in
|
||||
self?.captureFrame(force: true, reason: .surfaceChange)
|
||||
}
|
||||
)
|
||||
try source.start()
|
||||
displaySource = source
|
||||
|
||||
startBoundaryTimer()
|
||||
}
|
||||
|
||||
public func waitUntilFirstFrame(timeoutSeconds: TimeInterval = 15) throws {
|
||||
let alreadyReady = try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
return firstFrameReady
|
||||
}
|
||||
if alreadyReady {
|
||||
return
|
||||
}
|
||||
if firstFrameSemaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
|
||||
throw RecorderError.make(20, "Timed out waiting for first frame")
|
||||
}
|
||||
try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
if !firstFrameReady {
|
||||
throw RecorderError.make(21, "Recorder stopped before first frame")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func simulatorStopReason() -> String? {
|
||||
callbackQueue.sync {
|
||||
simulatorStoppedReason
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func stop() throws -> RecordingManifest {
|
||||
callbackQueue.sync {
|
||||
stopped = true
|
||||
boundaryTimer?.cancel()
|
||||
boundaryTimer = nil
|
||||
displaySource?.stop()
|
||||
displaySource = nil
|
||||
}
|
||||
|
||||
return try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
guard let writer, let input, let outputWriter else {
|
||||
let error = RecorderError.make(22, "No frames were captured")
|
||||
firstError = error
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
throw error
|
||||
}
|
||||
try appendTailFrameIfNeeded(finalPTS: monotonicClock.elapsedTime())
|
||||
input.markAsFinished()
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
writer.finishWriting {
|
||||
semaphore.signal()
|
||||
}
|
||||
if semaphore.wait(timeout: .now() + 60) == .timedOut {
|
||||
throw RecorderError.make(23, "Timed out finishing AVAssetWriter")
|
||||
}
|
||||
if writer.status == .failed {
|
||||
throw writer.error ?? RecorderError.make(24, "AVAssetWriter failed")
|
||||
}
|
||||
if let error = outputWriter.error {
|
||||
throw error
|
||||
}
|
||||
guard let firstAcceptedWallClock else {
|
||||
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
|
||||
}
|
||||
return try outputWriter.writeManifest(
|
||||
configuration: configuration,
|
||||
firstFrameWallClock: firstAcceptedWallClock
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum CaptureReason {
|
||||
case callback
|
||||
case surfaceChange
|
||||
case boundary
|
||||
case healthProbe
|
||||
}
|
||||
|
||||
private func validateConfiguration() throws {
|
||||
guard !configuration.deviceUDID.isEmpty else {
|
||||
throw RecorderError.make(30, "deviceUDID must not be empty")
|
||||
}
|
||||
guard configuration.fps > 0, configuration.fps <= 120 else {
|
||||
throw RecorderError.make(31, "fps must be between 1 and 120")
|
||||
}
|
||||
guard configuration.bitrate > 0 else {
|
||||
throw RecorderError.make(32, "bitrate must be positive")
|
||||
}
|
||||
guard configuration.segmentDuration >= 0 else {
|
||||
throw RecorderError.make(33, "segmentDuration must be non-negative")
|
||||
}
|
||||
guard configuration.maxPendingFrames > 0 else {
|
||||
throw RecorderError.make(34, "maxPendingFrames must be positive")
|
||||
}
|
||||
}
|
||||
|
||||
private func startBoundaryTimer() {
|
||||
let timer = DispatchSource.makeTimerSource(queue: callbackQueue)
|
||||
timer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100))
|
||||
timer.setEventHandler { [weak self] in
|
||||
self?.probeSimulatorState()
|
||||
self?.probeFramebufferHealth()
|
||||
self?.appendBoundaryFrameIfNeeded()
|
||||
}
|
||||
timer.resume()
|
||||
boundaryTimer = timer
|
||||
}
|
||||
|
||||
private func appendBoundaryFrameIfNeeded() {
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
guard configuration.segmentDuration > 0 else {
|
||||
return
|
||||
}
|
||||
writerQueue.sync {
|
||||
guard firstAcceptedCaptureTime != nil else {
|
||||
return
|
||||
}
|
||||
let currentPTSSeconds = CMTimeGetSeconds(
|
||||
normalizedPresentationTime(for: monotonicClock.elapsedTime()))
|
||||
guard currentPTSSeconds >= nextBoundaryElapsed else {
|
||||
return
|
||||
}
|
||||
let boundary = nextBoundaryElapsed
|
||||
guard let lastPTS else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if CMTimeGetSeconds(lastPTS) < boundary {
|
||||
let boundaryPTS = CMTime(seconds: boundary, preferredTimescale: 1_000_000_000)
|
||||
guard try appendHeldFrame(at: boundaryPTS) else {
|
||||
return
|
||||
}
|
||||
}
|
||||
nextBoundaryElapsed = boundary + configuration.segmentDuration
|
||||
} catch {
|
||||
firstError = error
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func captureFrame(force: Bool, reason: CaptureReason) {
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
if reason == .callback {
|
||||
lastFrameCallbackElapsed = monotonicClock.elapsedSeconds()
|
||||
}
|
||||
guard let snapshot = displaySource?.surfaceSnapshot() else {
|
||||
return
|
||||
}
|
||||
let surface = snapshot.surface
|
||||
let surfaceWidth = snapshot.width
|
||||
let surfaceHeight = snapshot.height
|
||||
guard surfaceWidth > 0, surfaceHeight > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let seed = snapshot.seed
|
||||
let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
|
||||
if !force, matchesLastAppendedSeed {
|
||||
return
|
||||
}
|
||||
|
||||
guard reservePendingFrame() else {
|
||||
return
|
||||
}
|
||||
|
||||
let capturedAt = monotonicClock.elapsedTime()
|
||||
let capturedAtWallClock = Date()
|
||||
do {
|
||||
let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
|
||||
surface,
|
||||
width: surfaceWidth,
|
||||
height: surfaceHeight
|
||||
)
|
||||
appendOwned(
|
||||
pixelBuffer: pixelBuffer,
|
||||
seed: seed,
|
||||
capturedAt: capturedAt,
|
||||
capturedAtWallClock: capturedAtWallClock
|
||||
)
|
||||
} catch {
|
||||
releasePendingFrame()
|
||||
writerQueue.async {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func probeFramebufferHealth() {
|
||||
guard !stopped, let displaySource else {
|
||||
return
|
||||
}
|
||||
let elapsed = monotonicClock.elapsedSeconds()
|
||||
if !isFirstFrameReady(),
|
||||
elapsed - lastFirstFrameRewireElapsed >= Self.firstFrameRewireInterval
|
||||
{
|
||||
lastFirstFrameRewireElapsed = elapsed
|
||||
rewireFramebuffer()
|
||||
captureFrame(force: true, reason: .healthProbe)
|
||||
return
|
||||
}
|
||||
|
||||
guard let snapshot = displaySource.surfaceSnapshot(),
|
||||
snapshot.width > 0,
|
||||
snapshot.height > 0
|
||||
else {
|
||||
rewireFramebuffer()
|
||||
return
|
||||
}
|
||||
|
||||
let observedSeed = snapshot.seed
|
||||
let lastAppendedSeed = writerQueue.sync { lastSeed }
|
||||
guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
|
||||
return
|
||||
}
|
||||
|
||||
let lastCallback = lastFrameCallbackElapsed ?? 0
|
||||
if elapsed - lastCallback >= Self.callbackStalenessTimeout {
|
||||
captureFrame(force: false, reason: .healthProbe)
|
||||
rewireFramebuffer()
|
||||
}
|
||||
}
|
||||
|
||||
private func probeSimulatorState() {
|
||||
guard !stopped, simulatorStoppedReason == nil else {
|
||||
return
|
||||
}
|
||||
let state = SimulatorDeviceLookup.stateString(udid: configuration.deviceUDID) ?? "missing"
|
||||
guard state != "Booted" else {
|
||||
return
|
||||
}
|
||||
simulatorStoppedReason = state
|
||||
let error = RecorderError.make(26, "Simulator stopped before first frame (state: \(state))")
|
||||
writerQueue.async {
|
||||
if self.firstError == nil, !self.firstFrameReady {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
let onSimulatorStopped = onSimulatorStopped
|
||||
eventQueue.async {
|
||||
onSimulatorStopped?(state)
|
||||
}
|
||||
}
|
||||
|
||||
private func rewireFramebuffer() {
|
||||
guard let displaySource else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try displaySource.rewireFramebuffer()
|
||||
} catch {
|
||||
writerQueue.async {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reservePendingFrame() -> Bool {
|
||||
pendingLock.lock()
|
||||
defer { pendingLock.unlock() }
|
||||
if pendingFrames >= configuration.maxPendingFrames {
|
||||
return false
|
||||
}
|
||||
pendingFrames += 1
|
||||
return true
|
||||
}
|
||||
|
||||
private func releasePendingFrame() {
|
||||
pendingLock.lock()
|
||||
pendingFrames = max(0, pendingFrames - 1)
|
||||
pendingLock.unlock()
|
||||
}
|
||||
|
||||
private func copySurfaceToOwnedPixelBuffer(
|
||||
_ surface: IOSurface,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws -> CVPixelBuffer {
|
||||
let pool = try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
if writer == nil {
|
||||
try startWriter(width: width, height: height)
|
||||
}
|
||||
guard let pool = adaptor?.pixelBufferPool else {
|
||||
throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
var output: CVPixelBuffer?
|
||||
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &output)
|
||||
guard status == kCVReturnSuccess, let destination = output else {
|
||||
throw RecorderError.make(41, "Failed to allocate pixel buffer from pool: \(status)")
|
||||
}
|
||||
|
||||
let destinationLockStatus = CVPixelBufferLockBaseAddress(destination, [])
|
||||
guard destinationLockStatus == kCVReturnSuccess else {
|
||||
throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)")
|
||||
}
|
||||
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
|
||||
guard surfaceLockStatus == KERN_SUCCESS else {
|
||||
CVPixelBufferUnlockBaseAddress(destination, [])
|
||||
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
|
||||
}
|
||||
defer {
|
||||
IOSurfaceUnlock(surface, .readOnly, nil)
|
||||
CVPixelBufferUnlockBaseAddress(destination, [])
|
||||
}
|
||||
|
||||
guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else {
|
||||
throw RecorderError.make(42, "Pixel buffer has no base address")
|
||||
}
|
||||
let sourceAddress = IOSurfaceGetBaseAddress(surface)
|
||||
let sourceStride = IOSurfaceGetBytesPerRow(surface)
|
||||
let destinationStride = CVPixelBufferGetBytesPerRow(destination)
|
||||
let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination))
|
||||
let copyHeight = min(
|
||||
height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination))
|
||||
let copyBytes = min(copyWidth * 4, sourceStride, destinationStride)
|
||||
for row in 0..<copyHeight {
|
||||
memcpy(
|
||||
destinationAddress.advanced(by: row * destinationStride),
|
||||
sourceAddress.advanced(by: row * sourceStride),
|
||||
copyBytes
|
||||
)
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
private func appendOwned(
|
||||
pixelBuffer: CVPixelBuffer,
|
||||
seed: UInt32,
|
||||
capturedAt: CMTime,
|
||||
capturedAtWallClock: Date
|
||||
) {
|
||||
writerQueue.async {
|
||||
defer { self.releasePendingFrame() }
|
||||
if self.firstError != nil {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try self.appendOnWriterQueue(
|
||||
pixelBuffer: pixelBuffer,
|
||||
seed: seed,
|
||||
capturedAt: capturedAt,
|
||||
capturedAtWallClock: capturedAtWallClock
|
||||
)
|
||||
} catch {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func appendOnWriterQueue(
|
||||
pixelBuffer: CVPixelBuffer,
|
||||
seed: UInt32,
|
||||
capturedAt: CMTime,
|
||||
capturedAtWallClock: Date
|
||||
) throws {
|
||||
guard let writer, let input, let adaptor else {
|
||||
throw RecorderError.make(43, "AVAssetWriter is not initialized")
|
||||
}
|
||||
let isFirstFrame = firstAcceptedCaptureTime == nil
|
||||
let pts = isFirstFrame ? .zero : normalizedPresentationTime(for: capturedAt)
|
||||
if let lastPTS, CMTimeCompare(pts, lastPTS) <= 0 {
|
||||
return
|
||||
}
|
||||
guard try appendPendingBoundaryFrames(before: pts) else {
|
||||
return
|
||||
}
|
||||
guard input.isReadyForMoreMediaData else {
|
||||
return
|
||||
}
|
||||
if !adaptor.append(pixelBuffer, withPresentationTime: pts) {
|
||||
throw writer.error ?? RecorderError.make(44, "Failed to append pixel buffer")
|
||||
}
|
||||
if isFirstFrame {
|
||||
firstAcceptedCaptureTime = capturedAt
|
||||
firstAcceptedWallClock = capturedAtWallClock
|
||||
}
|
||||
lastPTS = pts
|
||||
lastSeed = seed
|
||||
lastAppendedPixelBuffer = pixelBuffer
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
|
||||
private func appendTailFrameIfNeeded(finalPTS: CMTime) throws {
|
||||
let deadline = Date().addingTimeInterval(5)
|
||||
while true {
|
||||
if try appendTailFrame(finalPTS: finalPTS) {
|
||||
return
|
||||
}
|
||||
if Date() >= deadline {
|
||||
throw RecorderError.make(45, "Timed out waiting to append tail frame")
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
private func appendTailFrame(finalPTS: CMTime) throws -> Bool {
|
||||
let pts = normalizedPresentationTime(for: finalPTS)
|
||||
return try appendHeldFrame(at: pts)
|
||||
}
|
||||
|
||||
private func appendPendingBoundaryFrames(before pts: CMTime) throws -> Bool {
|
||||
guard configuration.segmentDuration > 0,
|
||||
firstAcceptedCaptureTime != nil
|
||||
else {
|
||||
return true
|
||||
}
|
||||
|
||||
while CMTimeGetSeconds(pts) > nextBoundaryElapsed {
|
||||
if let lastPTS, CMTimeGetSeconds(lastPTS) >= nextBoundaryElapsed {
|
||||
nextBoundaryElapsed += configuration.segmentDuration
|
||||
continue
|
||||
}
|
||||
|
||||
let boundaryPTS = CMTime(
|
||||
seconds: nextBoundaryElapsed, preferredTimescale: 1_000_000_000)
|
||||
guard try appendHeldFrame(at: boundaryPTS) else {
|
||||
return false
|
||||
}
|
||||
nextBoundaryElapsed += configuration.segmentDuration
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func appendHeldFrame(at pts: CMTime) throws -> Bool {
|
||||
guard let input,
|
||||
let adaptor,
|
||||
let lastPTS,
|
||||
let lastAppendedPixelBuffer
|
||||
else {
|
||||
return true
|
||||
}
|
||||
if CMTimeCompare(pts, lastPTS) <= 0 {
|
||||
return true
|
||||
}
|
||||
guard input.isReadyForMoreMediaData else {
|
||||
return false
|
||||
}
|
||||
if !adaptor.append(lastAppendedPixelBuffer, withPresentationTime: pts) {
|
||||
throw writer?.error ?? RecorderError.make(45, "Failed to append held frame")
|
||||
}
|
||||
self.lastPTS = pts
|
||||
return true
|
||||
}
|
||||
|
||||
private func normalizedPresentationTime(for capturedAt: CMTime) -> CMTime {
|
||||
let start = firstAcceptedCaptureTime ?? capturedAt
|
||||
return CMTimeSubtract(capturedAt, start)
|
||||
}
|
||||
|
||||
private func startWriter(width: Int, height: Int) throws {
|
||||
guard let outputWriter else {
|
||||
throw RecorderError.make(46, "Missing output writer")
|
||||
}
|
||||
|
||||
let writer: AVAssetWriter
|
||||
if configuration.segmentDuration > 0 {
|
||||
writer = AVAssetWriter(contentType: .mpeg4Movie)
|
||||
writer.delegate = outputWriter
|
||||
writer.outputFileTypeProfile = .mpeg4AppleHLS
|
||||
writer.preferredOutputSegmentInterval = CMTime(
|
||||
seconds: configuration.segmentDuration,
|
||||
preferredTimescale: 600
|
||||
)
|
||||
} else {
|
||||
writer = try AVAssetWriter(outputURL: outputWriter.singleRecordingURL, fileType: .mp4)
|
||||
}
|
||||
writer.initialSegmentStartTime = .zero
|
||||
|
||||
let input = AVAssetWriterInput(
|
||||
mediaType: .video,
|
||||
outputSettings: assetWriterSettings(width: width, height: height)
|
||||
)
|
||||
input.expectsMediaDataInRealTime = true
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferWidthKey as String: width,
|
||||
kCVPixelBufferHeightKey as String: height,
|
||||
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
|
||||
]
|
||||
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
|
||||
assetWriterInput: input,
|
||||
sourcePixelBufferAttributes: attributes
|
||||
)
|
||||
|
||||
guard writer.canAdd(input) else {
|
||||
throw RecorderError.make(47, "AVAssetWriter cannot add video input")
|
||||
}
|
||||
writer.add(input)
|
||||
guard writer.startWriting() else {
|
||||
throw writer.error ?? RecorderError.make(48, "AVAssetWriter failed to start")
|
||||
}
|
||||
writer.startSession(atSourceTime: .zero)
|
||||
|
||||
self.writer = writer
|
||||
self.input = input
|
||||
self.adaptor = adaptor
|
||||
}
|
||||
|
||||
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
|
||||
let codecType: AVVideoCodecType = configuration.codec == .hevc ? .hevc : .h264
|
||||
var compression: [String: Any] = [
|
||||
AVVideoAverageBitRateKey: configuration.bitrate,
|
||||
AVVideoExpectedSourceFrameRateKey: configuration.fps,
|
||||
AVVideoAllowFrameReorderingKey: true,
|
||||
]
|
||||
if configuration.segmentDuration > 0 {
|
||||
compression[AVVideoMaxKeyFrameIntervalKey] = max(
|
||||
1,
|
||||
Int((configuration.segmentDuration * Double(configuration.fps)).rounded())
|
||||
)
|
||||
}
|
||||
if configuration.codec == .h264 {
|
||||
compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel
|
||||
}
|
||||
return [
|
||||
AVVideoCodecKey: codecType,
|
||||
AVVideoWidthKey: width,
|
||||
AVVideoHeightKey: height,
|
||||
AVVideoCompressionPropertiesKey: compression,
|
||||
]
|
||||
}
|
||||
|
||||
private func signalFirstFrameReadyIfNeeded() {
|
||||
if firstFrameReady {
|
||||
return
|
||||
}
|
||||
firstFrameReady = true
|
||||
firstFrameSemaphore.signal()
|
||||
}
|
||||
|
||||
private func isFirstFrameReady() -> Bool {
|
||||
writerQueue.sync {
|
||||
firstFrameReady
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
enum RecorderError {
|
||||
static func make(_ code: Int, _ message: String) -> NSError {
|
||||
NSError(
|
||||
domain: "RecordSim",
|
||||
code: code,
|
||||
userInfo: [NSLocalizedDescriptionKey: message]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONEncoder {
|
||||
static var prettySorted: JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return encoder
|
||||
}
|
||||
}
|
||||
|
||||
func timeSeconds(_ time: CMTime) -> Double? {
|
||||
guard time.isValid, !time.isIndefinite, !time.isNegativeInfinity, !time.isPositiveInfinity else {
|
||||
return nil
|
||||
}
|
||||
return CMTimeGetSeconds(time)
|
||||
}
|
||||
|
||||
func iso8601(_ date: Date) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
func unixMs(_ date: Date) -> Int64 {
|
||||
Int64((date.timeIntervalSince1970 * 1000).rounded())
|
||||
}
|
||||
|
||||
struct MonotonicClock {
|
||||
private static let timescale: CMTimeScale = 1_000_000_000
|
||||
|
||||
let startedAtUptimeNanoseconds: UInt64
|
||||
|
||||
init() {
|
||||
startedAtUptimeNanoseconds = DispatchTime.now().uptimeNanoseconds
|
||||
}
|
||||
|
||||
func elapsedSeconds() -> TimeInterval {
|
||||
TimeInterval(elapsedNanoseconds()) / TimeInterval(Self.timescale)
|
||||
}
|
||||
|
||||
func elapsedTime() -> CMTime {
|
||||
CMTime(value: CMTimeValue(elapsedNanoseconds()), timescale: Self.timescale)
|
||||
}
|
||||
|
||||
private func elapsedNanoseconds() -> UInt64 {
|
||||
DispatchTime.now().uptimeNanoseconds - startedAtUptimeNanoseconds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import Foundation
|
||||
import RecordSim
|
||||
|
||||
struct CLIOptions {
|
||||
var udid: String?
|
||||
var output: String?
|
||||
var segmentDuration: TimeInterval = 120
|
||||
var fps = 30
|
||||
var bitrate = 30_000_000
|
||||
var codec: RecorderCodec = .h264
|
||||
}
|
||||
|
||||
private var stopRequested: CInt = 0
|
||||
private var simulatorStopped: CInt = 0
|
||||
|
||||
private func handleStopSignal(_: CInt) {
|
||||
stopRequested = 1
|
||||
}
|
||||
|
||||
private func isStopRequested() -> Bool {
|
||||
stopRequested != 0 || simulatorStopped != 0
|
||||
}
|
||||
|
||||
func usage() -> String {
|
||||
"""
|
||||
Usage:
|
||||
record-sim --udid <UDID> --output <session-dir> [options]
|
||||
|
||||
Options:
|
||||
--segment-duration <seconds> Target fMP4 media segment duration. Use 0 for one MP4. Default: 120
|
||||
--fps <fps> Expected source frame rate. Default: 30
|
||||
--bitrate <bits/sec> AVAssetWriter average bitrate. Default: 30000000
|
||||
--codec <h264|hevc> Video codec. Default: h264
|
||||
-h, --help Show this help
|
||||
"""
|
||||
}
|
||||
|
||||
func parseOptions(_ args: [String]) throws -> CLIOptions {
|
||||
var options = CLIOptions()
|
||||
var i = 0
|
||||
|
||||
func next(_ flag: String) throws -> String {
|
||||
i += 1
|
||||
guard i < args.count else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing value for \(flag)"])
|
||||
}
|
||||
return args[i]
|
||||
}
|
||||
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
switch arg {
|
||||
case "--udid", "-u":
|
||||
options.udid = try next(arg)
|
||||
case "--output", "--output-dir", "--out", "-o":
|
||||
options.output = try next(arg)
|
||||
case "--segment-duration":
|
||||
options.segmentDuration = Double(try next(arg)) ?? -1
|
||||
case "--fps":
|
||||
options.fps = Int(try next(arg)) ?? 0
|
||||
case "--bitrate":
|
||||
options.bitrate = Int(try next(arg)) ?? 0
|
||||
case "--codec":
|
||||
let raw = try next(arg)
|
||||
guard let codec = RecorderCodec(rawValue: raw) else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--codec must be h264 or hevc"])
|
||||
}
|
||||
options.codec = codec
|
||||
case "--help", "-h":
|
||||
print(usage())
|
||||
exit(0)
|
||||
default:
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unknown argument: \(arg)"])
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
guard options.udid != nil else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --udid"])
|
||||
}
|
||||
guard options.output != nil else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --output"])
|
||||
}
|
||||
guard options.segmentDuration >= 0 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--segment-duration must be non-negative"])
|
||||
}
|
||||
guard options.fps > 0, options.fps <= 120 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--fps must be between 1 and 120"])
|
||||
}
|
||||
guard options.bitrate > 0 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--bitrate must be positive"])
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
do {
|
||||
signal(SIGINT, handleStopSignal)
|
||||
signal(SIGTERM, handleStopSignal)
|
||||
|
||||
let options = try parseOptions(Array(CommandLine.arguments.dropFirst()))
|
||||
let configuration = SimulatorRecordingConfiguration(
|
||||
deviceUDID: options.udid!,
|
||||
outputDirectory: URL(fileURLWithPath: options.output!),
|
||||
fps: options.fps,
|
||||
bitrate: options.bitrate,
|
||||
codec: options.codec,
|
||||
segmentDuration: options.segmentDuration
|
||||
)
|
||||
|
||||
let recorder = SimulatorRecorder(configuration: configuration)
|
||||
recorder.onSimulatorStopped = { state in
|
||||
simulatorStopped = 1
|
||||
fputs("[record-sim] simulator stopped state=\(state); finalizing recording\n", stderr)
|
||||
}
|
||||
recorder.onSegment = { segment in
|
||||
switch segment.kind {
|
||||
case .initialization:
|
||||
fputs("[record-sim] init \(segment.relativePath) bytes=\(segment.byteCount)\n", stderr)
|
||||
case .media:
|
||||
let duration = segment.durationSeconds ?? 0
|
||||
fputs(String(format: "[record-sim] segment %@ duration=%.3fs bytes=%d\n", segment.relativePath, duration, segment.byteCount), stderr)
|
||||
}
|
||||
}
|
||||
|
||||
try recorder.start()
|
||||
try recorder.waitUntilFirstFrame()
|
||||
fputs("[record-sim] recording -> \(options.output!)\n", stderr)
|
||||
|
||||
while !isStopRequested() {
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
|
||||
let manifest = try recorder.stop()
|
||||
if let recording = manifest.recording {
|
||||
fputs("[record-sim] done recording=\(recording)\n", stderr)
|
||||
} else {
|
||||
fputs("[record-sim] done segments=\(manifest.segments.count)\n", stderr)
|
||||
}
|
||||
fputs("[record-sim] manifest \(URL(fileURLWithPath: options.output!).appendingPathComponent("session.json").path)\n", stderr)
|
||||
} catch {
|
||||
fputs("error: \(error.localizedDescription)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { fs } from 'memfs';
|
||||
|
||||
const fsRealpath = fs.realpath;
|
||||
(fsRealpath as any).native = fsRealpath;
|
||||
|
||||
const fsRm = (path: string, options: object): Promise<void> => {
|
||||
return fs.promises.rm(path, options);
|
||||
};
|
||||
|
||||
module.exports = { ...fs.promises, realpath: fsRealpath, rm: fsRm };
|
||||
module.exports = { ...fs.promises, rm: fsRm };
|
||||
@@ -1,14 +1,38 @@
|
||||
import { BuildTrigger, Job, Metadata } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildPhase,
|
||||
BuildPhaseResult,
|
||||
BuildTrigger,
|
||||
Job,
|
||||
Metadata,
|
||||
Platform,
|
||||
} 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(),
|
||||
log: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const datadogDistributionMock = jest.mocked(Datadog.distribution);
|
||||
const datadogLogMock = jest.mocked(Datadog.log);
|
||||
|
||||
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 });
|
||||
@@ -116,4 +140,139 @@ describe('BuildContext', () => {
|
||||
foo: 'bar',
|
||||
});
|
||||
});
|
||||
|
||||
it('overwrites existing environment variables when updating env', async () => {
|
||||
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
|
||||
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
secrets: {},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
EXISTING_ENV: 'old-value',
|
||||
REMOVED_ENV: 'old-value',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
|
||||
ctx.updateEnv({
|
||||
EXISTING_ENV: 'new-value',
|
||||
REMOVED_ENV: undefined,
|
||||
NEW_ENV: 'new-env-value',
|
||||
});
|
||||
|
||||
expect(ctx.env.EXISTING_ENV).toBe('new-value');
|
||||
expect(ctx.env.REMOVED_ENV).toBeUndefined();
|
||||
expect(ctx.env.NEW_ENV).toBe('new-env-value');
|
||||
const logMessage = datadogLogMock.mock.calls[0][0];
|
||||
expect(logMessage).toContain('BuildContext.updateEnv merge order produced different env');
|
||||
expect(logMessage).toContain('different_env_names=');
|
||||
expect(logMessage).toContain('EXISTING_ENV');
|
||||
expect(logMessage).toContain('REMOVED_ENV');
|
||||
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('old-value');
|
||||
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('new-value');
|
||||
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('new-env-value');
|
||||
});
|
||||
|
||||
it('logs when updateEnv merge order produces the same env without values', async () => {
|
||||
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
|
||||
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
secrets: {},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
EXISTING_ENV: 'same-value',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
|
||||
ctx.updateEnv({
|
||||
EXISTING_ENV: 'same-value',
|
||||
UNSET_ENV: undefined,
|
||||
});
|
||||
|
||||
expect(datadogLogMock).toHaveBeenCalledWith(
|
||||
'BuildContext.updateEnv merge order produced same env'
|
||||
);
|
||||
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('same-value');
|
||||
});
|
||||
|
||||
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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
import { Datadog } from '../datadog';
|
||||
import { Sentry } from '../sentry';
|
||||
import { turtleFetch } from '../utils/turtleFetch';
|
||||
|
||||
jest.mock('../sentry', () => ({
|
||||
Sentry: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../utils/turtleFetch', () => ({
|
||||
turtleFetch: jest.fn(),
|
||||
}));
|
||||
|
||||
const turtleFetchMock = jest.mocked(turtleFetch);
|
||||
const sentryCaptureMock = jest.mocked(Sentry.capture);
|
||||
|
||||
describe('Datadog singleton', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Datadog.setup(null);
|
||||
});
|
||||
|
||||
it('POSTs distribution metrics to the turtle build metrics endpoint for build targets', () => {
|
||||
turtleFetchMock.mockResolvedValueOnce({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1234, {
|
||||
build_phase: 'install_dependencies',
|
||||
platform: 'ios',
|
||||
result: 'success',
|
||||
});
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/turtle-builds/build-id/metrics',
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'eas.build.phase_duration',
|
||||
type: 'distribution',
|
||||
value: 1234,
|
||||
tags: {
|
||||
build_phase: 'install_dependencies',
|
||||
platform: 'ios',
|
||||
result: 'success',
|
||||
target: 'build',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
headers: { Authorization: 'Bearer token-abc' },
|
||||
retries: 2,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('POSTs distribution metrics to the turtle job run metrics endpoint for jobRun targets', () => {
|
||||
turtleFetchMock.mockResolvedValueOnce({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.maestro.install', 1, {
|
||||
maestro_version: '2.1.0',
|
||||
});
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'eas.maestro.install',
|
||||
type: 'distribution',
|
||||
value: 1,
|
||||
tags: {
|
||||
maestro_version: '2.1.0',
|
||||
target: 'job_run',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
headers: { Authorization: 'Bearer token-abc' },
|
||||
retries: 2,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('POSTs logs to the turtle build logs endpoint with buildId for build targets', () => {
|
||||
turtleFetchMock.mockResolvedValueOnce({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
|
||||
Datadog.log('artifact dry-run matched', {
|
||||
event: 'find_artifacts_absolute_path_dry_run',
|
||||
status: 'match',
|
||||
});
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/turtle-builds/logs',
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
buildId: 'build-id',
|
||||
message: 'artifact dry-run matched',
|
||||
tags: {
|
||||
event: 'find_artifacts_absolute_path_dry_run',
|
||||
status: 'match',
|
||||
target: 'build',
|
||||
},
|
||||
},
|
||||
headers: { Authorization: 'Bearer token-abc' },
|
||||
shouldThrowOnNotOk: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('POSTs logs to the turtle job run logs endpoint with jobRunId for jobRun targets', () => {
|
||||
turtleFetchMock.mockResolvedValueOnce({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
|
||||
});
|
||||
|
||||
Datadog.log('Gradle cache restored (hit)', {
|
||||
cache_type: 'gradle',
|
||||
});
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/turtle-job-runs/logs',
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
jobRunId: 'job-run-id',
|
||||
message: 'Gradle cache restored (hit)',
|
||||
tags: {
|
||||
cache_type: 'gradle',
|
||||
target: 'job_run',
|
||||
},
|
||||
},
|
||||
headers: { Authorization: 'Bearer token-abc' },
|
||||
shouldThrowOnNotOk: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op when setup is null', () => {
|
||||
Datadog.setup(null);
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
Datadog.log('artifact dry-run matched');
|
||||
|
||||
expect(turtleFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows metric upload failures for build targets', async () => {
|
||||
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
await flushPromises();
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalled();
|
||||
expect(sentryCaptureMock).toHaveBeenCalledWith(
|
||||
'Failed to report turtle build metric',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'eas.build.phase_duration',
|
||||
type: 'distribution',
|
||||
value: 1,
|
||||
tags: { target: 'build' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('labels metric upload failures for jobRun targets with the jobRun kind', async () => {
|
||||
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.maestro.install', 1);
|
||||
await flushPromises();
|
||||
|
||||
expect(sentryCaptureMock).toHaveBeenCalledWith(
|
||||
'Failed to report turtle jobRun metric',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'eas.maestro.install',
|
||||
type: 'distribution',
|
||||
value: 1,
|
||||
tags: { target: 'job_run' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows log upload failures for build targets', async () => {
|
||||
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
|
||||
Datadog.log('artifact dry-run matched');
|
||||
await flushPromises();
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalled();
|
||||
expect(sentryCaptureMock).toHaveBeenCalledWith(
|
||||
'Failed to report turtle build log',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: {
|
||||
log: {
|
||||
buildId: 'build-id',
|
||||
message: 'artifact dry-run matched',
|
||||
tags: { target: 'build' },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('labels log upload failures for jobRun targets with the jobRun kind', async () => {
|
||||
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
|
||||
});
|
||||
|
||||
Datadog.log('Gradle cache restored (hit)');
|
||||
await flushPromises();
|
||||
|
||||
expect(sentryCaptureMock).toHaveBeenCalledWith(
|
||||
'Failed to report turtle jobRun log',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: {
|
||||
log: {
|
||||
jobRunId: 'job-run-id',
|
||||
message: 'Gradle cache restored (hit)',
|
||||
tags: { target: 'job_run' },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the latest setup options', () => {
|
||||
turtleFetchMock.mockResolvedValue({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'first-token',
|
||||
target: { kind: 'build', turtleBuildId: 'first-build-id' },
|
||||
});
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'second-token',
|
||||
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
Datadog.log('artifact dry-run matched');
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer second-token' },
|
||||
})
|
||||
);
|
||||
expect(turtleFetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://api.expo.test/v2/turtle-job-runs/logs',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer second-token' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('flushes pending metric uploads', async () => {
|
||||
let resolveUpload!: (response: Response) => void;
|
||||
turtleFetchMock.mockReturnValueOnce(
|
||||
new Promise<Response>(resolve => {
|
||||
resolveUpload = resolve;
|
||||
})
|
||||
);
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
|
||||
let flushed = false;
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
const flushPromise = Datadog.flushAsync().then(() => {
|
||||
flushed = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(flushed).toBe(false);
|
||||
|
||||
resolveUpload({} as Response);
|
||||
await flushPromise;
|
||||
|
||||
expect(flushed).toBe(true);
|
||||
});
|
||||
|
||||
it('flushes pending log uploads', async () => {
|
||||
let resolveUpload!: (response: Response) => void;
|
||||
turtleFetchMock.mockReturnValueOnce(
|
||||
new Promise<Response>(resolve => {
|
||||
resolveUpload = resolve;
|
||||
})
|
||||
);
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
|
||||
let flushed = false;
|
||||
Datadog.log('artifact dry-run matched');
|
||||
const flushPromise = Datadog.flushAsync().then(() => {
|
||||
flushed = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(flushed).toBe(false);
|
||||
|
||||
resolveUpload({} as Response);
|
||||
await flushPromise;
|
||||
|
||||
expect(flushed).toBe(true);
|
||||
});
|
||||
|
||||
it('only awaits uploads enqueued since the previous flush', async () => {
|
||||
let resolveSecondUpload!: (response: Response) => void;
|
||||
turtleFetchMock.mockReturnValueOnce(new Promise<Response>(() => {})).mockReturnValueOnce(
|
||||
new Promise<Response>(resolve => {
|
||||
resolveSecondUpload = resolve;
|
||||
})
|
||||
);
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
|
||||
// The first upload never settles; the first flush rotates it out of the queue.
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
void Datadog.flushAsync();
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 2);
|
||||
let flushed = false;
|
||||
const flushPromise = Datadog.flushAsync().then(() => {
|
||||
flushed = true;
|
||||
});
|
||||
|
||||
resolveSecondUpload({} as Response);
|
||||
await flushPromise;
|
||||
|
||||
expect(flushed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
@@ -111,6 +111,37 @@ describe(runGenericJobAsync, () => {
|
||||
expect(rejected).toBe(true);
|
||||
});
|
||||
|
||||
it('passes the job hooks into the steps parser', async () => {
|
||||
// @expo/steps is fully mocked here — assert the plumbing only (real
|
||||
// insertion behavior is covered by the parser's own tests).
|
||||
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
|
||||
}));
|
||||
|
||||
mockCtx.job.hooks = { before_install_node_modules: [{ run: 'echo hi' }] };
|
||||
await runGenericJobAsync(mockCtx);
|
||||
|
||||
expect(StepsConfigParser).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ hooks: mockCtx.job.hooks })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes hooks: undefined into the steps parser for a hook-less job (dormancy)', async () => {
|
||||
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
|
||||
}));
|
||||
|
||||
await runGenericJobAsync(mockCtx);
|
||||
|
||||
expect(StepsConfigParser).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ hooks: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws before workflow execution when expoApiV2BaseUrl is missing', async () => {
|
||||
mockCtx.expoApiV2BaseUrl = undefined;
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import { RuntimeSettings } from '../runtimeSettings';
|
||||
import { Sentry } from '../sentry';
|
||||
|
||||
jest.mock('node-fetch', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../sentry', () => ({
|
||||
Sentry: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('runtimeSettings', () => {
|
||||
const originalCacheUrls = {
|
||||
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
|
||||
EAS_NODEJS_CACHE_URL: process.env.EAS_NODEJS_CACHE_URL,
|
||||
EAS_MAVEN_CACHE_URL: process.env.EAS_MAVEN_CACHE_URL,
|
||||
EAS_COCOAPODS_CACHE_URL: process.env.EAS_COCOAPODS_CACHE_URL,
|
||||
};
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
jest.restoreAllMocks();
|
||||
jest.mocked(Sentry.capture).mockReset();
|
||||
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
|
||||
restoreEnv('EAS_NODEJS_CACHE_URL', originalCacheUrls.EAS_NODEJS_CACHE_URL);
|
||||
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
|
||||
restoreEnv('EAS_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_COCOAPODS_CACHE_URL);
|
||||
mockProcessPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({}));
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
it('fetches runtime settings from the staging and production buckets', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({}));
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'production' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'test' });
|
||||
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
|
||||
{
|
||||
signal: expect.anything(),
|
||||
}
|
||||
);
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
|
||||
{
|
||||
signal: expect.anything(),
|
||||
}
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not use caches by default when remote settings are unavailable', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({}, 404));
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith('Failed to fetch worker runtime settings', {
|
||||
extras: {
|
||||
status: 404,
|
||||
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
|
||||
},
|
||||
level: 'warning',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not override existing settings when remote settings are invalid', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(response({ caches: { linux: { npm: true } } }))
|
||||
.mockResolvedValueOnce(response({ iosPrecompiledModules: 'enabled' }));
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
|
||||
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to load worker runtime settings',
|
||||
expect.any(Error),
|
||||
{
|
||||
extras: {
|
||||
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
|
||||
},
|
||||
level: 'warning',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts partial runtime settings and ignores unknown fields', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
|
||||
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
|
||||
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { npm: false, nodejs: true, maven: true },
|
||||
darwin: { npm: true, maven: true, cocoapods: false },
|
||||
},
|
||||
iosPrecompiledModules: true,
|
||||
unknownFutureSetting: true,
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://node.example');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
|
||||
mockProcessPlatform('darwin');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
|
||||
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
|
||||
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('uses Maven and CocoaPods caches when enabled for the current platform and their environment variables exist', async () => {
|
||||
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
|
||||
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { maven: true },
|
||||
darwin: { maven: true, cocoapods: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
|
||||
mockProcessPlatform('darwin');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
|
||||
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://pods.example');
|
||||
});
|
||||
|
||||
it('uses job environment flags to enable cache URLs from worker environment variables', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'test',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
|
||||
});
|
||||
|
||||
it('returns null when job env enables cache but worker env var is empty', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = '';
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'test',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not use cache URLs from the job environment', async () => {
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'test',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
EAS_NPM_CACHE_URL: 'https://job-npm.example',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not enable cache when runtime settings explicitly disable it, even with job env flag', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: false } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('disables cache when EAS_BUILD_DISABLE_NPM_CACHE is set', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_BUILD_DISABLE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('disables cache when EAS_BUILD_DISABLE_MAVEN_CACHE is set', async () => {
|
||||
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { maven: true } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_BUILD_DISABLE_MAVEN_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('disables cache when EAS_BUILD_DISABLE_COCOAPODS_CACHE is set', async () => {
|
||||
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { darwin: { cocoapods: true } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_BUILD_DISABLE_COCOAPODS_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('darwin');
|
||||
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('allows job environment flags to disable remotely enabled cache URLs', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '0',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not use caches when runtime settings are not loaded', () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
|
||||
jest.isolateModules(() => {
|
||||
const { RuntimeSettings } = require('../runtimeSettings');
|
||||
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports when loaded runtime settings omit the current platform cache settings', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
|
||||
mockProcessPlatform('darwin');
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Runtime settings are missing cache settings for platform "darwin"',
|
||||
}),
|
||||
{
|
||||
extras: {
|
||||
configuredPlatforms: ['linux'],
|
||||
platform: 'darwin',
|
||||
},
|
||||
level: 'error',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function mockProcessPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: platform,
|
||||
});
|
||||
}
|
||||
|
||||
function response(body: unknown, status = 200): any {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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;
|
||||
}
|
||||
@@ -10,7 +10,10 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { createTestIosJob } from './job';
|
||||
import { createMockLogger } from './logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
export class MockContextProvider implements ExternalBuildContextProvider {
|
||||
private _env: BuildStepEnv = {};
|
||||
@@ -72,6 +75,19 @@ export function createStepContextMock({
|
||||
});
|
||||
}
|
||||
|
||||
export function createCustomBuildContextMock(): CustomBuildContext {
|
||||
const ctx = new BuildContext(createTestIosJob({}), {
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
});
|
||||
return new CustomBuildContext(ctx);
|
||||
}
|
||||
|
||||
export function createGlobalContextMock({
|
||||
logger,
|
||||
skipCleanup,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Android, Env, Job, Platform } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import assert from 'assert';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
|
||||
export async function ensureLFLineEndingsInGradlewScript<TJob extends Job>(
|
||||
ctx: BuildContext<TJob>
|
||||
@@ -31,55 +29,51 @@ export async function runGradleCommand(
|
||||
logger.info(`Running 'gradlew ${gradleCommand}' in ${androidDir}`);
|
||||
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
|
||||
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : '';
|
||||
const shouldResetOOMScore =
|
||||
ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux';
|
||||
|
||||
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
|
||||
cwd: androidDir,
|
||||
logger,
|
||||
lineTransformer: (line?: string) => {
|
||||
if (!line || /^\.+$/.exec(line)) {
|
||||
return null;
|
||||
} else {
|
||||
return line;
|
||||
}
|
||||
},
|
||||
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx) },
|
||||
});
|
||||
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
|
||||
adjustOOMScore(spawnPromise, logger);
|
||||
}
|
||||
|
||||
await spawnPromise;
|
||||
await spawn(
|
||||
'bash',
|
||||
[
|
||||
'-c',
|
||||
getGradleShellCommand({
|
||||
gradleCommand,
|
||||
oomScoreAdj: shouldResetOOMScore ? 0 : undefined,
|
||||
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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OOM Killer sometimes kills worker server while build is exceeding memory limits.
|
||||
* `oom_score_adj` is a value between -1000 and 1000 and it defaults to 0.
|
||||
* It defines which process is more likely to get killed (higher value more likely).
|
||||
*
|
||||
* This function sets oom_score_adj for Gradle process and all its child processes.
|
||||
*/
|
||||
function adjustOOMScore(spawnPromise: SpawnPromise<SpawnResult>, logger: bunyan): void {
|
||||
setTimeout(
|
||||
async () => {
|
||||
try {
|
||||
assert(spawnPromise.child.pid);
|
||||
const pids = await getParentAndDescendantProcessPidsAsync(spawnPromise.child.pid);
|
||||
await Promise.all(
|
||||
pids.map(async (pid: number) => {
|
||||
// Value 800 is just a guess here. It's probably higher than most other
|
||||
// process. I didn't want to set it any higher, because I'm not sure if OOM Killer
|
||||
// can start killing processes when there is still enough memory left.
|
||||
const oomScoreOverride = 800;
|
||||
await fs.writeFile(`/proc/${pid}/oom_score_adj`, `${oomScoreOverride}\n`);
|
||||
})
|
||||
);
|
||||
} catch (err: any) {
|
||||
logger.debug({ err, stderr: err?.stderr }, 'Failed to override oom_score_adj');
|
||||
}
|
||||
},
|
||||
// Wait 20 seconds to make sure all child processes are started
|
||||
20000
|
||||
);
|
||||
export function getGradleShellCommand({
|
||||
gradleCommand,
|
||||
oomScoreAdj,
|
||||
verboseFlag,
|
||||
}: {
|
||||
gradleCommand: string;
|
||||
oomScoreAdj?: number;
|
||||
verboseFlag: string;
|
||||
}): string {
|
||||
const oomScoreAdjPrefix =
|
||||
oomScoreAdj === undefined ? '' : `echo ${oomScoreAdj} > /proc/$$/oom_score_adj || true; `;
|
||||
return `${oomScoreAdjPrefix}exec ./gradlew ${gradleCommand} --profile ${verboseFlag}`;
|
||||
}
|
||||
|
||||
// Version envs should be set at the beginning of the build, but when building
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { AndroidConfig } from '@expo/config-plugins';
|
||||
import { Android } from '@expo/eas-build-job';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { EasBuildGradle } from '../templates/EasBuildGradle';
|
||||
const APPLY_EAS_BUILD_GRADLE_LINE = 'apply from: "./eas-build.gradle"';
|
||||
|
||||
export async function configureBuildGradle(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
ctx.logger.info('Injecting signing config into build.gradle');
|
||||
if (await fs.pathExists(getEasBuildGradlePath(ctx.getReactNativeProjectDirectory()))) {
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
ctx.logger.warn('eas-build.gradle script is deprecated, please remove it from your project.');
|
||||
}
|
||||
await deleteEasBuildGradle(ctx.getReactNativeProjectDirectory());
|
||||
await createEasBuildGradle(ctx.getReactNativeProjectDirectory());
|
||||
await addApplyToBuildGradle(ctx.getReactNativeProjectDirectory());
|
||||
}
|
||||
|
||||
async function deleteEasBuildGradle(projectRoot: string): Promise<void> {
|
||||
const easBuildGradlePath = getEasBuildGradlePath(projectRoot);
|
||||
await fs.remove(easBuildGradlePath);
|
||||
}
|
||||
|
||||
function getEasBuildGradlePath(projectRoot: string): string {
|
||||
return path.join(projectRoot, 'android/app/eas-build.gradle');
|
||||
}
|
||||
|
||||
async function createEasBuildGradle(projectRoot: string): Promise<void> {
|
||||
const easBuildGradlePath = getEasBuildGradlePath(projectRoot);
|
||||
await fs.writeFile(easBuildGradlePath, EasBuildGradle);
|
||||
}
|
||||
|
||||
async function addApplyToBuildGradle(projectRoot: string): Promise<void> {
|
||||
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectRoot);
|
||||
const buildGradleContents = await fs.readFile(path.join(buildGradlePath), 'utf8');
|
||||
|
||||
if (hasLine(buildGradleContents, APPLY_EAS_BUILD_GRADLE_LINE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
buildGradlePath,
|
||||
`${buildGradleContents.trim()}\n${APPLY_EAS_BUILD_GRADLE_LINE}\n`
|
||||
);
|
||||
}
|
||||
|
||||
function hasLine(haystack: string, needle: string): boolean {
|
||||
return (
|
||||
haystack
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
// Check for both single and double quotes
|
||||
.some(line => line === needle || line === needle.replace(/"/g, "'"))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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,28 +67,26 @@ export async function resolveBuildPhaseErrorAsync(
|
||||
logLines: string[],
|
||||
errorContext: ErrorContext,
|
||||
buildLogsDirectory: string
|
||||
): Promise<errors.BuildError> {
|
||||
): Promise<errors.ExpoError> {
|
||||
const { phase } = errorContext;
|
||||
if (error instanceof errors.BuildError) {
|
||||
if (error instanceof errors.ExpoError) {
|
||||
error.buildPhase ??= phase;
|
||||
return error;
|
||||
}
|
||||
const xcodeBuildLogs = await maybeReadXcodeBuildLogs(phase, buildLogsDirectory);
|
||||
const userError =
|
||||
error instanceof errors.UserError
|
||||
? error
|
||||
: (resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
|
||||
new errors.UnknownError(errorContext.phase));
|
||||
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.BuildError(userError.message, {
|
||||
errorCode: userError.errorCode,
|
||||
return new errors.UserError(userError.errorCode, userError.message, {
|
||||
trackingCode,
|
||||
docsUrl: userError.docsUrl,
|
||||
cause: error,
|
||||
buildPhase: phase,
|
||||
metadata: buildError?.metadata,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { Android, BuildPhase, BuildPhaseResult } from '@expo/eas-build-job';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createTestAndroidJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { restoreCredentials } from '../../android/credentials';
|
||||
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
|
||||
import androidBuilder from '../android';
|
||||
import { runBuilderWithHooksAsync } from '../common';
|
||||
import {
|
||||
injectConfigureVersionGradleConfig,
|
||||
injectCredentialsGradleConfig,
|
||||
} from '../../steps/utils/android/gradleConfig';
|
||||
|
||||
jest.mock('../common', () => ({
|
||||
runBuilderWithHooksAsync: jest.fn(async (ctx, buildFn) => {
|
||||
await buildFn(ctx);
|
||||
return ctx.artifacts;
|
||||
}),
|
||||
}));
|
||||
jest.mock('../custom', () => ({
|
||||
runCustomBuildAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../android/credentials');
|
||||
jest.mock('../../android/gradle', () => ({
|
||||
ensureLFLineEndingsInGradlewScript: jest.fn(),
|
||||
resolveGradleCommand: jest.fn(() => ':app:bundleRelease'),
|
||||
runGradleCommand: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../common/eagerBundle', () => ({
|
||||
eagerBundleAsync: jest.fn(),
|
||||
shouldUseEagerBundle: jest.fn(() => false),
|
||||
}));
|
||||
jest.mock('../../common/prebuild', () => ({
|
||||
prebuildAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../common/setup', () => ({
|
||||
setupAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../steps/functions/restoreBuildCache', () => ({
|
||||
cacheStatsAsync: jest.fn(),
|
||||
restoreCcacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../steps/functions/saveBuildCache', () => ({
|
||||
saveCcacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../steps/utils/android/gradleConfig', () => ({
|
||||
...jest.requireActual('../../steps/utils/android/gradleConfig'),
|
||||
injectConfigureVersionGradleConfig: jest.fn(),
|
||||
injectCredentialsGradleConfig: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/artifacts', () => ({
|
||||
uploadApplicationArchive: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/expoUpdates', () => ({
|
||||
configureExpoUpdatesIfInstalledAsync: jest.fn(),
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
|
||||
}));
|
||||
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
|
||||
uploadEmbeddedBundleAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/hooks', () => ({
|
||||
Hook: {
|
||||
POST_INSTALL: 'POST_INSTALL',
|
||||
PRE_UPLOAD_ARTIFACTS: 'PRE_UPLOAD_ARTIFACTS',
|
||||
},
|
||||
runHookIfPresent: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/prepareBuildExecutable', () => ({
|
||||
prepareExecutableAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const datadogDistributionMock = jest.spyOn(Datadog, 'distribution');
|
||||
|
||||
describe(androidBuilder, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vol.fromJSON(
|
||||
{
|
||||
'/workingdir/env/.gitkeep': '',
|
||||
'/workingdir/build/package.json': JSON.stringify({
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
},
|
||||
'/'
|
||||
);
|
||||
});
|
||||
|
||||
it('injects Android version config without build credentials', async () => {
|
||||
const job: Android.Job = {
|
||||
...createTestAndroidJob(),
|
||||
secrets: {
|
||||
buildCredentials: undefined,
|
||||
},
|
||||
version: {
|
||||
versionCode: '42',
|
||||
versionName: '1.2.3',
|
||||
},
|
||||
};
|
||||
const ctx = new BuildContext(job, {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(injectConfigureVersionGradleConfig).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/workingdir/build',
|
||||
{
|
||||
versionCode: '42',
|
||||
versionName: '1.2.3',
|
||||
}
|
||||
);
|
||||
expect(injectCredentialsGradleConfig).not.toHaveBeenCalled();
|
||||
expect(restoreCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('injects Android credentials config when build credentials are present', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(injectCredentialsGradleConfig).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/workingdir/build'
|
||||
);
|
||||
expect(restoreCredentials).toHaveBeenCalledWith(ctx);
|
||||
expect(injectConfigureVersionGradleConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the configure Android version phase as warning for legacy eas-build.gradle', async () => {
|
||||
const job: Android.Job = {
|
||||
...createTestAndroidJob(),
|
||||
secrets: {
|
||||
buildCredentials: undefined,
|
||||
},
|
||||
version: {
|
||||
versionCode: '42',
|
||||
versionName: '1.2.3',
|
||||
},
|
||||
};
|
||||
const ctx = new BuildContext(job, {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
|
||||
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the prepare credentials phase as warning for legacy eas-build.gradle', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
|
||||
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('injects credentials and version config when both are configured', async () => {
|
||||
const job: Android.Job = {
|
||||
...createTestAndroidJob(),
|
||||
version: {
|
||||
versionCode: '42',
|
||||
versionName: '1.2.3',
|
||||
},
|
||||
};
|
||||
const ctx = new BuildContext(job, {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
|
||||
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(injectCredentialsGradleConfig).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/workingdir/build'
|
||||
);
|
||||
expect(restoreCredentials).toHaveBeenCalledWith(ctx);
|
||||
expect(injectConfigureVersionGradleConfig).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/workingdir/build',
|
||||
{
|
||||
versionCode: '42',
|
||||
versionName: '1.2.3',
|
||||
}
|
||||
);
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('runs the builder through hooks', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
|
||||
});
|
||||
|
||||
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { prepareProjectSourcesAsync } from '../../common/projectSources';
|
||||
import { BuildContext } from '../../context';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { findAndUploadXcodeBuildLogsAsync } from '../../ios/xcodeBuildLogs';
|
||||
import { runCustomBuildAsync } from '../custom';
|
||||
|
||||
@@ -17,6 +18,7 @@ const findAndUploadXcodeBuildLogsAsyncMock = jest.mocked(findAndUploadXcodeBuild
|
||||
|
||||
describe(runCustomBuildAsync, () => {
|
||||
let ctx: BuildContext<BuildJob>;
|
||||
const datadogLogSpy = jest.spyOn(Datadog, 'log');
|
||||
|
||||
beforeEach(() => {
|
||||
const job = createTestIosJob();
|
||||
@@ -27,10 +29,24 @@ describe(runCustomBuildAsync, () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'test.yaml': `
|
||||
functions:
|
||||
test_function:
|
||||
name: Test function
|
||||
path: ./custom-function
|
||||
shell: /bin/zsh
|
||||
supported_platforms:
|
||||
- darwin
|
||||
- linux
|
||||
inputs:
|
||||
- name
|
||||
- profile
|
||||
outputs:
|
||||
- artifact_path
|
||||
build:
|
||||
steps:
|
||||
- eas/checkout
|
||||
`,
|
||||
'custom-function/package.json': '{}',
|
||||
},
|
||||
'/workingdir/temporary-custom-build'
|
||||
);
|
||||
@@ -77,6 +93,21 @@ describe(runCustomBuildAsync, () => {
|
||||
expect(prepareProjectSourcesAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports user-provided custom functions when parsing the custom build config', async () => {
|
||||
await runCustomBuildAsync(ctx);
|
||||
|
||||
expect(datadogLogSpy).toHaveBeenCalledWith('Custom build saw user-provided function', {
|
||||
event: 'custom_build_user_provided_function',
|
||||
custom_function_id: 'test_function',
|
||||
custom_function_input_count: '2',
|
||||
custom_function_module_path: '/workingdir/temporary-custom-build/custom-function',
|
||||
custom_function_name: 'Test function',
|
||||
custom_function_output_count: '1',
|
||||
custom_function_shell: '/bin/zsh',
|
||||
custom_function_supported_runtime_platforms: 'darwin,linux',
|
||||
});
|
||||
});
|
||||
|
||||
it('awaits drainPendingMetricUploads after workflow execution', async () => {
|
||||
let resolveDrain!: () => void;
|
||||
const drainSpy = jest
|
||||
|
||||
@@ -10,18 +10,29 @@ import {
|
||||
resolveGradleCommand,
|
||||
runGradleCommand,
|
||||
} from '../android/gradle';
|
||||
import { configureBuildGradle } from '../android/gradleConfig';
|
||||
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 } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
restoreGradleCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveGradleCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
injectConfigureVersionGradleConfig,
|
||||
injectCredentialsGradleConfig,
|
||||
warnIfLegacyEasBuildGradleExists,
|
||||
} from '../steps/utils/android/gradleConfig';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
|
||||
@@ -77,6 +88,12 @@ 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 () => {
|
||||
@@ -89,7 +106,7 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
appConfig: ctx.appConfig,
|
||||
appConfig: await ctx.appConfig,
|
||||
platform: ctx.job.platform,
|
||||
workflow: ctx.job.type,
|
||||
env: ctx.env,
|
||||
@@ -101,10 +118,22 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
nullthrows(ctx.job.secrets, 'Secrets must be defined for non-custom builds').buildCredentials
|
||||
) {
|
||||
await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
|
||||
await warnIfLegacyEasBuildGradleExists(ctx);
|
||||
await restoreCredentials(ctx);
|
||||
await configureBuildGradle(ctx);
|
||||
await injectCredentialsGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.job.version?.versionCode || ctx.job.version?.versionName) {
|
||||
await ctx.runBuildPhase(BuildPhase.CONFIGURE_ANDROID_VERSION, async () => {
|
||||
await warnIfLegacyEasBuildGradleExists(ctx);
|
||||
await injectConfigureVersionGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory(), {
|
||||
versionCode: ctx.job.version?.versionCode,
|
||||
versionName: ctx.job.version?.versionName,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CONFIGURE_EXPO_UPDATES, async () => {
|
||||
await configureExpoUpdatesIfInstalledAsync(ctx, {
|
||||
resolvedRuntimeVersion: resolvedExpoUpdatesRuntimeVersion?.runtimeVersion ?? null,
|
||||
@@ -154,6 +183,20 @@ 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);
|
||||
});
|
||||
@@ -166,6 +209,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
|
||||
if (ctx.isLocal) {
|
||||
ctx.logger.info('Local builds do not support saving cache.');
|
||||
@@ -180,6 +229,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await saveGradleCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { BuildJob, BuildPhase, BuildTrigger, Ios, Platform } from '@expo/eas-build-job';
|
||||
import { BuildConfigParser, BuildStepGlobalContext, StepsConfigParser, errors } from '@expo/steps';
|
||||
import {
|
||||
BuildConfigParser,
|
||||
BuildStepGlobalContext,
|
||||
BuildWorkflow,
|
||||
StepsConfigParser,
|
||||
errors,
|
||||
} from '@expo/steps';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs/promises';
|
||||
import nullthrows from 'nullthrows';
|
||||
@@ -9,6 +15,7 @@ import { resolveEnvFromBuildProfileAsync } from '../common/easBuildInternal';
|
||||
import { prepareProjectSourcesAsync } from '../common/projectSources';
|
||||
import { Artifacts, BuildContext } from '../context';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { Datadog } from '../datadog';
|
||||
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
|
||||
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
|
||||
import { getEasFunctions } from '../steps/easFunctions';
|
||||
@@ -56,6 +63,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
|
||||
externalFunctions: easFunctions,
|
||||
externalFunctionGroups: easFunctionGroups,
|
||||
steps: ctx.job.steps,
|
||||
hooks: ctx.job.hooks,
|
||||
})
|
||||
: new BuildConfigParser(globalContext, {
|
||||
externalFunctions: easFunctions,
|
||||
@@ -81,6 +89,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
|
||||
throw parseError;
|
||||
}
|
||||
});
|
||||
logUserProvidedCustomFunctions(workflow);
|
||||
try {
|
||||
try {
|
||||
await workflow.executeAsync();
|
||||
@@ -103,3 +112,26 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
|
||||
|
||||
return ctx.artifacts;
|
||||
}
|
||||
|
||||
function logUserProvidedCustomFunctions(workflow: BuildWorkflow): void {
|
||||
for (const buildFunction of Object.values(workflow.buildFunctions)) {
|
||||
if (!buildFunction.customFunctionModulePath) {
|
||||
continue;
|
||||
}
|
||||
Datadog.log('Custom build saw user-provided function', {
|
||||
event: 'custom_build_user_provided_function',
|
||||
custom_function_id: buildFunction.getFullId(),
|
||||
custom_function_module_path: buildFunction.customFunctionModulePath,
|
||||
custom_function_input_count: String(buildFunction.inputProviders?.length ?? 0),
|
||||
custom_function_output_count: String(buildFunction.outputProviders?.length ?? 0),
|
||||
...(buildFunction.name ? { custom_function_name: buildFunction.name } : {}),
|
||||
...(buildFunction.shell ? { custom_function_shell: buildFunction.shell } : {}),
|
||||
...(buildFunction.supportedRuntimePlatforms
|
||||
? {
|
||||
custom_function_supported_runtime_platforms:
|
||||
buildFunction.supportedRuntimePlatforms.join(','),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ 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';
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
@@ -51,6 +54,7 @@ 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();
|
||||
@@ -103,7 +107,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
appConfig: ctx.appConfig,
|
||||
appConfig: await ctx.appConfig,
|
||||
platform: ctx.job.platform,
|
||||
workflow: ctx.job.type,
|
||||
env: ctx.env,
|
||||
@@ -146,10 +150,10 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
|
||||
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
|
||||
const scheme = resolveScheme(ctx);
|
||||
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
|
||||
await runFastlaneGym(ctx, {
|
||||
return await runFastlaneGym(ctx, {
|
||||
credentials,
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
@@ -171,6 +175,29 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
|
||||
if (ctx.isLocal) {
|
||||
ctx.logger.info('Local builds skip build performance analysis.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
|
||||
const { skipped } = await parseAndReportXcactivitylog({
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
logger: ctx.logger,
|
||||
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
env: ctx.env,
|
||||
});
|
||||
if (skipped) {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
}
|
||||
} catch (err: any) {
|
||||
Sentry.capture('Failed to parse xcactivitylog', err);
|
||||
ctx.markBuildPhaseSkipped();
|
||||
}
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
|
||||
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
|
||||
});
|
||||
@@ -183,6 +210,12 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
|
||||
if (ctx.isLocal) {
|
||||
ctx.logger.info('Local builds do not support saving cache.');
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { ArchiveSourceType, BuildJob, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
|
||||
import { resolveEasCommandPrefixAndEnvAsync } from '../../utils/easCli';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/easCli', () => ({
|
||||
...jest.requireActual('../../utils/easCli'),
|
||||
resolveEasCommandPrefixAndEnvAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('easBuildInternal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
|
||||
cmd: 'npx',
|
||||
args: ['-y', 'eas-cli@latest'],
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('calls resolveEasCommandPrefixAndEnvAsync in runEasBuildInternalAsync', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const job = {
|
||||
platform: 'android',
|
||||
secrets: { robotAccessToken: 'token' },
|
||||
} as unknown as BuildJob;
|
||||
|
||||
await expect(
|
||||
runEasBuildInternalAsync({
|
||||
job,
|
||||
logger,
|
||||
env: {},
|
||||
cwd: '/tmp/project',
|
||||
})
|
||||
).rejects.toThrow('build profile is missing in a build from git-based integration.');
|
||||
|
||||
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls resolveEasCommandPrefixAndEnvAsync in resolveEnvFromBuildProfileAsync', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
const ctx = {
|
||||
logger,
|
||||
env: {},
|
||||
job: { platform: 'ios' },
|
||||
} as any;
|
||||
|
||||
await expect(resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' })).rejects.toThrow(
|
||||
'build profile is missing in a build from git-based integration.'
|
||||
);
|
||||
|
||||
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const internalJob = {
|
||||
platform: Platform.IOS,
|
||||
type: Workflow.GENERIC,
|
||||
triggeredBy: 'EAS_CLI',
|
||||
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
|
||||
projectRootDirectory: '.',
|
||||
secrets: {
|
||||
buildCredentials: {
|
||||
testapp: {
|
||||
distributionCertificate: {
|
||||
dataBase64: 'YmluYXJ5Y29udGVudDE=',
|
||||
password: 'distCertPassword',
|
||||
},
|
||||
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
|
||||
},
|
||||
},
|
||||
},
|
||||
initiatingUserId: 'user-id',
|
||||
appId: 'app-id',
|
||||
};
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue({
|
||||
stdout: Buffer.from(
|
||||
JSON.stringify({
|
||||
job: internalJob,
|
||||
metadata: {},
|
||||
})
|
||||
),
|
||||
stderr: Buffer.from(''),
|
||||
} as any);
|
||||
|
||||
const job = {
|
||||
platform: Platform.IOS,
|
||||
buildProfile: 'preview',
|
||||
refreshAdHocProvisioningProfile: true,
|
||||
appId: 'app-id',
|
||||
initiatingUserId: 'user-id',
|
||||
secrets: { robotAccessToken: 'token' },
|
||||
} as unknown as BuildJob;
|
||||
|
||||
await runEasBuildInternalAsync({
|
||||
job,
|
||||
logger,
|
||||
env: {},
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
expect.arrayContaining([
|
||||
'build:internal',
|
||||
'--platform',
|
||||
Platform.IOS,
|
||||
'--profile',
|
||||
'preview',
|
||||
'--refresh-ad-hoc-provisioning-profile',
|
||||
]),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves refreshAdHocProvisioningProfile from the incoming job', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const internalJob = {
|
||||
platform: Platform.IOS,
|
||||
type: Workflow.GENERIC,
|
||||
triggeredBy: 'EAS_CLI',
|
||||
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
|
||||
projectRootDirectory: '.',
|
||||
secrets: {
|
||||
buildCredentials: {
|
||||
testapp: {
|
||||
distributionCertificate: {
|
||||
dataBase64: 'YmluYXJ5Y29udGVudDE=',
|
||||
password: 'distCertPassword',
|
||||
},
|
||||
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
|
||||
},
|
||||
},
|
||||
},
|
||||
initiatingUserId: 'user-id',
|
||||
appId: 'app-id',
|
||||
};
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue({
|
||||
stdout: Buffer.from(
|
||||
JSON.stringify({
|
||||
job: internalJob,
|
||||
metadata: {},
|
||||
})
|
||||
),
|
||||
stderr: Buffer.from(''),
|
||||
} as any);
|
||||
|
||||
const job = {
|
||||
platform: Platform.IOS,
|
||||
buildProfile: 'preview',
|
||||
refreshAdHocProvisioningProfile: true,
|
||||
appId: 'app-id',
|
||||
initiatingUserId: 'user-id',
|
||||
secrets: { robotAccessToken: 'token' },
|
||||
} as unknown as BuildJob;
|
||||
|
||||
const { newJob } = await runEasBuildInternalAsync({
|
||||
job,
|
||||
logger,
|
||||
env: {},
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
expect((newJob as any).refreshAdHocProvisioningProfile).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { PackageManager } from '../../utils/packageManager';
|
||||
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => jest.fn());
|
||||
jest.mock('../../sentry', () => ({
|
||||
Sentry: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('retries without NPM_CONFIG_REGISTRY when npm cache install fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
|
||||
const error = Object.assign(
|
||||
new Error('npm install --include=dev exited with non-zero code: 1'),
|
||||
{
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
|
||||
}
|
||||
);
|
||||
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReturnValueOnce(createSpawnPromise(Promise.reject(error)))
|
||||
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
|
||||
|
||||
await installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
EAS_VERBOSE: '1',
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(2);
|
||||
expect(spawn).toHaveBeenNthCalledWith(1, 'npm', ['install', '--include=dev', '--verbose'], {
|
||||
cwd: '/tmp/build',
|
||||
logger,
|
||||
infoCallbackFn: undefined,
|
||||
lineTransformer: expect.any(Function),
|
||||
env: {
|
||||
EAS_VERBOSE: '1',
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
},
|
||||
});
|
||||
expect(spawn).toHaveBeenNthCalledWith(2, 'npm', ['install', '--include=dev', '--verbose'], {
|
||||
cwd: '/tmp/build',
|
||||
logger,
|
||||
infoCallbackFn: undefined,
|
||||
env: {
|
||||
EAS_VERBOSE: '1',
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
},
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Failed to install dependencies using npm cache registry',
|
||||
name: 'NpmCacheRegistryInstallError',
|
||||
}),
|
||||
{
|
||||
level: 'warning',
|
||||
tags: {
|
||||
packageManager: 'npm',
|
||||
},
|
||||
extras: {
|
||||
cwd: '/tmp/build',
|
||||
npmCacheUrl,
|
||||
useFrozenLockfile: false,
|
||||
originalErrorMessage: 'npm install --include=dev exited with non-zero code: 1',
|
||||
status: 1,
|
||||
signal: null,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('reports non-fatal npm cache registry errors from successful installs', async () => {
|
||||
const logger = createMockLogger();
|
||||
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
|
||||
const auditErrorLine = `npm verbose audit error FetchError: request to ${npmCacheUrl}/-/npm/v1/security/advisories/bulk failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`;
|
||||
|
||||
jest.mocked(spawn).mockImplementationOnce((_command, _args, options: any) => {
|
||||
options.lineTransformer(auditErrorLine);
|
||||
options.lineTransformer(
|
||||
`npm http cache left-pad@${npmCacheUrl}/left-pad/-/left-pad-1.3.0.tgz 0ms (cache hit)`
|
||||
);
|
||||
return createSpawnPromise(Promise.resolve(createSpawnResult()));
|
||||
});
|
||||
|
||||
await installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Non-fatal npm cache registry error during dependency install',
|
||||
name: 'NpmCacheRegistryNonFatalError',
|
||||
}),
|
||||
{
|
||||
level: 'warning',
|
||||
tags: {
|
||||
packageManager: 'npm',
|
||||
},
|
||||
extras: {
|
||||
cwd: '/tmp/build',
|
||||
npmCacheUrl,
|
||||
useFrozenLockfile: false,
|
||||
firstErrorLine: auditErrorLine,
|
||||
errorLineCount: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not retry when install failure does not reference npm cache registry', async () => {
|
||||
const logger = createMockLogger();
|
||||
const error = Object.assign(
|
||||
new Error('npm install --include=dev exited with non-zero code: 1'),
|
||||
{
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: 'npm error command sh -c node postinstall.js',
|
||||
}
|
||||
);
|
||||
|
||||
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
|
||||
|
||||
await expect(
|
||||
installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
EAS_BUILD_NPM_CACHE_URL: 'http://npm.staging.caches.eas-build.internal',
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
})
|
||||
).rejects.toThrow(error);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry when the npm cache is not enabled', async () => {
|
||||
const logger = createMockLogger();
|
||||
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
|
||||
const error = Object.assign(
|
||||
new Error('npm install --include=dev exited with non-zero code: 1'),
|
||||
{
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
|
||||
}
|
||||
);
|
||||
|
||||
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
|
||||
|
||||
await expect(
|
||||
installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
})
|
||||
).rejects.toThrow(error);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('installs through the npm cache registry without retrying when the cache install succeeds', async () => {
|
||||
const logger = createMockLogger();
|
||||
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
|
||||
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
|
||||
|
||||
await installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith('npm', ['install', '--include=dev'], {
|
||||
cwd: '/tmp/build',
|
||||
logger,
|
||||
infoCallbackFn: undefined,
|
||||
lineTransformer: expect.any(Function),
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
},
|
||||
});
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
|
||||
return Object.assign(result, {
|
||||
child: {
|
||||
pid: 123,
|
||||
},
|
||||
}) as SpawnPromise<SpawnResult>;
|
||||
}
|
||||
|
||||
function createSpawnResult(): SpawnResult {
|
||||
return {
|
||||
pid: 123,
|
||||
output: ['', ''],
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
status: 0,
|
||||
signal: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { ExpoRunFormatter } from '@expo/xcpretty';
|
||||
import fg from 'fast-glob';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { XcodeBuildLogger } from '../xcpretty';
|
||||
|
||||
jest.mock('@expo/spawn-async');
|
||||
jest.mock('@expo/xcpretty');
|
||||
jest.mock('fast-glob');
|
||||
jest.mock('../../sentry', () => ({
|
||||
Sentry: {
|
||||
setup: jest.fn(),
|
||||
capture: jest.fn(),
|
||||
flush: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe(XcodeBuildLogger, () => {
|
||||
let stdout: EventEmitter;
|
||||
let formatter: { pipe: jest.Mock; getBuildSummary: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
stdout = new EventEmitter();
|
||||
const spawnPromise = Object.assign(new Promise(() => {}), {
|
||||
child: { stdout, kill: jest.fn() },
|
||||
});
|
||||
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
|
||||
|
||||
formatter = {
|
||||
pipe: jest.fn(),
|
||||
getBuildSummary: jest.fn().mockReturnValue('summary'),
|
||||
};
|
||||
jest.mocked(ExpoRunFormatter.create).mockReturnValue(formatter as any);
|
||||
|
||||
jest.mocked(fg).mockResolvedValue(['build.log'] as any);
|
||||
});
|
||||
|
||||
async function startWatchingAsync(logger: ReturnType<typeof createMockLogger>): Promise<void> {
|
||||
const buildLogger = new XcodeBuildLogger(logger, '/project');
|
||||
await buildLogger.watchLogFiles('/logs');
|
||||
}
|
||||
|
||||
it('logs formatted lines', async () => {
|
||||
const logger = createMockLogger();
|
||||
await startWatchingAsync(logger);
|
||||
|
||||
formatter.pipe.mockReturnValue(['line1', 'line2']);
|
||||
stdout.emit('data', 'raw xcodebuild output');
|
||||
|
||||
expect(formatter.pipe).toHaveBeenCalledWith('raw xcodebuild output');
|
||||
expect(logger.info).toHaveBeenCalledWith('line1');
|
||||
expect(logger.info).toHaveBeenCalledWith('line2');
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to raw logs when the formatter throws', async () => {
|
||||
const logger = createMockLogger();
|
||||
await startWatchingAsync(logger);
|
||||
|
||||
formatter.pipe.mockImplementation(() => {
|
||||
throw new RangeError('Invalid array length');
|
||||
});
|
||||
stdout.emit('data', 'offending output');
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('offending output');
|
||||
});
|
||||
|
||||
it('reports the formatter error to Sentry only once', async () => {
|
||||
const logger = createMockLogger();
|
||||
await startWatchingAsync(logger);
|
||||
|
||||
const error = new RangeError('Invalid array length');
|
||||
formatter.pipe.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
stdout.emit('data', 'offending output');
|
||||
stdout.emit('data', 'more offending output');
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('offending output');
|
||||
expect(logger.info).toHaveBeenCalledWith('more offending output');
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'xcpretty formatter failed to parse xcodebuild logs',
|
||||
error,
|
||||
{ extras: { data: 'offending output' } }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
BuildJob,
|
||||
EasCliNpmTags,
|
||||
Env,
|
||||
Ios,
|
||||
Metadata,
|
||||
Platform,
|
||||
sanitizeBuildJob,
|
||||
sanitizeMetadata,
|
||||
} from '@expo/eas-build-job';
|
||||
@@ -14,7 +15,7 @@ import Joi from 'joi';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { isAtLeastNpm7Async } from '../utils/packageManager';
|
||||
import { resolveEasCommandPrefixAndEnvAsync } from '../utils/easCli';
|
||||
|
||||
const EasBuildInternalResultSchema = Joi.object<{ job: object; metadata: object }>({
|
||||
job: Joi.object().unknown(),
|
||||
@@ -49,38 +50,48 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
|
||||
autoSubmitArgs.push('--auto-submit');
|
||||
}
|
||||
|
||||
const result = await spawn(
|
||||
cmd,
|
||||
[
|
||||
...args,
|
||||
'build:internal',
|
||||
'--platform',
|
||||
job.platform,
|
||||
'--profile',
|
||||
buildProfile,
|
||||
...autoSubmitArgs,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...env,
|
||||
EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds')
|
||||
.robotAccessToken,
|
||||
...extraEnv,
|
||||
EAS_PROJECT_ROOT: projectRootOverride,
|
||||
},
|
||||
logger,
|
||||
// This prevents printing stdout with job secrets and credentials to logs.
|
||||
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
|
||||
}
|
||||
);
|
||||
const refreshAdHocProvisioningProfileArgs = [];
|
||||
if (job.platform === Platform.IOS && job.refreshAdHocProvisioningProfile === true) {
|
||||
refreshAdHocProvisioningProfileArgs.push('--refresh-ad-hoc-provisioning-profile');
|
||||
}
|
||||
|
||||
const stdout = result.stdout.toString();
|
||||
const parsed = JSON.parse(stdout);
|
||||
return validateEasBuildInternalResult({
|
||||
result: parsed,
|
||||
oldJob: job,
|
||||
});
|
||||
try {
|
||||
const result = await spawn(
|
||||
cmd,
|
||||
[
|
||||
...args,
|
||||
'build:internal',
|
||||
'--platform',
|
||||
job.platform,
|
||||
'--profile',
|
||||
buildProfile,
|
||||
...autoSubmitArgs,
|
||||
...refreshAdHocProvisioningProfileArgs,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...env,
|
||||
EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds')
|
||||
.robotAccessToken,
|
||||
...extraEnv,
|
||||
EAS_PROJECT_ROOT: projectRootOverride,
|
||||
},
|
||||
logger,
|
||||
// This prevents printing stdout with job secrets and credentials to logs.
|
||||
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
|
||||
}
|
||||
);
|
||||
|
||||
const stdout = result.stdout.toString();
|
||||
const parsed = JSON.parse(stdout);
|
||||
return validateEasBuildInternalResult({
|
||||
result: parsed,
|
||||
oldJob: job,
|
||||
});
|
||||
} catch (err: any) {
|
||||
throw new Error('Failed to run eas build:internal');
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
|
||||
@@ -121,33 +132,6 @@ 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,
|
||||
@@ -168,6 +152,9 @@ function validateEasBuildInternalResult<TJob extends BuildJob>({
|
||||
// We want to retain values that we have set on the job.
|
||||
appId: oldJob.appId,
|
||||
initiatingUserId: oldJob.initiatingUserId,
|
||||
...(oldJob.platform === Platform.IOS && oldJob.refreshAdHocProvisioningProfile === true
|
||||
? { refreshAdHocProvisioningProfile: true }
|
||||
: null),
|
||||
}) as TJob;
|
||||
assert(newJob.platform === oldJob.platform, 'eas-cli returned a job for a wrong platform');
|
||||
const newMetadata = sanitizeMetadata(value.metadata);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Job } from '@expo/eas-build-job';
|
||||
import { Job, SystemError } from '@expo/eas-build-job';
|
||||
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { Sentry } from '../sentry';
|
||||
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
|
||||
import { isUsingModernYarnVersion } from '../utils/project';
|
||||
|
||||
@@ -11,6 +12,7 @@ export async function installDependenciesAsync({
|
||||
env,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
lineTransformer,
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
}: {
|
||||
@@ -19,6 +21,7 @@ export async function installDependenciesAsync({
|
||||
cwd: string;
|
||||
logger: Exclude<SpawnOptions['logger'], undefined>;
|
||||
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
|
||||
lineTransformer?: SpawnOptions['lineTransformer'];
|
||||
useFrozenLockfile: boolean;
|
||||
}): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
|
||||
let args: string[];
|
||||
@@ -69,11 +72,108 @@ export async function installDependenciesAsync({
|
||||
cwd,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
lineTransformer,
|
||||
env,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager,
|
||||
env,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
}: {
|
||||
packageManager: PackageManager;
|
||||
env: Record<string, string | undefined>;
|
||||
cwd: string;
|
||||
logger: Exclude<SpawnOptions['logger'], undefined>;
|
||||
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
|
||||
useFrozenLockfile: boolean;
|
||||
}): Promise<void> {
|
||||
const npmCacheUrl = env.EAS_BUILD_NPM_CACHE_URL;
|
||||
|
||||
if (!npmCacheUrl) {
|
||||
await (
|
||||
await installDependenciesAsync({
|
||||
packageManager,
|
||||
env,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
})
|
||||
).spawnPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Installing dependencies using the npm cache registry (${npmCacheUrl}).`);
|
||||
|
||||
let firstErrorLine: string | undefined;
|
||||
let errorLineCount = 0;
|
||||
|
||||
try {
|
||||
await (
|
||||
await installDependenciesAsync({
|
||||
packageManager,
|
||||
env: { ...env, NPM_CONFIG_REGISTRY: npmCacheUrl },
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
lineTransformer: (line: string) => {
|
||||
if (isNpmCacheRegistryErrorLine(line, npmCacheUrl)) {
|
||||
firstErrorLine ??= line;
|
||||
errorLineCount += 1;
|
||||
}
|
||||
return line;
|
||||
},
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
})
|
||||
).spawnPromise;
|
||||
|
||||
if (firstErrorLine) {
|
||||
Sentry.capture(new NpmCacheRegistryNonFatalError(), {
|
||||
level: 'warning',
|
||||
tags: { packageManager },
|
||||
extras: { cwd, npmCacheUrl, useFrozenLockfile, firstErrorLine, errorLineCount },
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!isNpmCacheInstallFailure(err, npmCacheUrl)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
|
||||
);
|
||||
Sentry.capture(new NpmCacheRegistryInstallError(err), {
|
||||
level: 'warning',
|
||||
tags: { packageManager },
|
||||
extras: {
|
||||
cwd,
|
||||
npmCacheUrl,
|
||||
useFrozenLockfile,
|
||||
originalErrorMessage: err instanceof Error ? err.message : String(err),
|
||||
status: err instanceof Error ? (err as any).status : undefined,
|
||||
signal: err instanceof Error ? (err as any).signal : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await (
|
||||
await installDependenciesAsync({
|
||||
packageManager,
|
||||
env,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
})
|
||||
).spawnPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackagerDir(ctx: BuildContext<Job>): string {
|
||||
const packagerRunDir = findPackagerRootDir(ctx.getReactNativeProjectDirectory());
|
||||
if (packagerRunDir !== ctx.getReactNativeProjectDirectory()) {
|
||||
@@ -87,3 +187,36 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
|
||||
}
|
||||
return packagerRunDir;
|
||||
}
|
||||
|
||||
function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
|
||||
return getErrorOutput(err).includes(npmCacheUrl);
|
||||
}
|
||||
|
||||
function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean {
|
||||
return (
|
||||
line.includes(npmCacheUrl) &&
|
||||
/(?:error|failed|ENOTFOUND|ECONN|ETIMEDOUT|EAI_AGAIN|FetchError)/i.test(line)
|
||||
);
|
||||
}
|
||||
|
||||
function getErrorOutput(err: unknown): string {
|
||||
if (!(err instanceof Error)) {
|
||||
return '';
|
||||
}
|
||||
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
|
||||
return [stdout, stderr].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
class NpmCacheRegistryNonFatalError extends SystemError {
|
||||
override name = 'NpmCacheRegistryNonFatalError';
|
||||
constructor() {
|
||||
super('Non-fatal npm cache registry error during dependency install');
|
||||
}
|
||||
}
|
||||
|
||||
class NpmCacheRegistryInstallError extends SystemError {
|
||||
override name = 'NpmCacheRegistryInstallError';
|
||||
constructor(cause: unknown) {
|
||||
super('Failed to install dependencies using npm cache registry', { cause });
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import { BuildJob } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { SpawnOptions } from '@expo/turtle-spawn';
|
||||
|
||||
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
|
||||
import {
|
||||
installDependenciesWithNpmCacheFallbackAsync,
|
||||
resolvePackagerDir,
|
||||
} from './installDependencies';
|
||||
import { BuildContext } from '../context';
|
||||
import { runExpoCliCommand } from '../utils/project';
|
||||
|
||||
@@ -30,17 +33,14 @@ export async function prebuildAsync<TJob extends BuildJob>(
|
||||
options: spawnOptions,
|
||||
packageManager: ctx.packageManager,
|
||||
});
|
||||
const installDependenciesSpawnPromise = (
|
||||
await installDependenciesAsync({
|
||||
packageManager: ctx.packageManager,
|
||||
env: ctx.env,
|
||||
logger,
|
||||
cwd: resolvePackagerDir(ctx),
|
||||
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
|
||||
useFrozenLockfile: false,
|
||||
})
|
||||
).spawnPromise;
|
||||
await installDependenciesSpawnPromise;
|
||||
await installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: ctx.packageManager,
|
||||
env: ctx.env,
|
||||
logger,
|
||||
cwd: resolvePackagerDir(ctx),
|
||||
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
|
||||
useFrozenLockfile: false,
|
||||
});
|
||||
}
|
||||
|
||||
function getPrebuildCommandArgs<TJob extends BuildJob>(ctx: BuildContext<TJob>): string[] {
|
||||
|
||||
@@ -233,7 +233,7 @@ async function uploadProjectMetadataAsync(
|
||||
}
|
||||
`),
|
||||
{
|
||||
buildId: ctx.env.EAS_BUILD_ID,
|
||||
buildId: nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'),
|
||||
projectMetadataFile: {
|
||||
type: 'GCS',
|
||||
bucketKey: uploadSession.bucketKey,
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
shouldUseFrozenLockfile,
|
||||
} from '../utils/packageManager';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
import { readEasJsonContents, readPackageJson } from '../utils/project';
|
||||
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
|
||||
import { retryAsync } from '../utils/retry';
|
||||
|
||||
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
|
||||
@@ -82,10 +82,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
});
|
||||
|
||||
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
|
||||
ctx.logger.info('Using package.json:');
|
||||
const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
|
||||
ctx.logger.info(JSON.stringify(packageJson, null, 2));
|
||||
return packageJson;
|
||||
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
|
||||
@@ -113,7 +110,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
|
||||
const appConfig = ctx.appConfig;
|
||||
const appConfig = await ctx.appConfig;
|
||||
ctx.logger.info('Using app configuration:');
|
||||
ctx.logger.info(JSON.stringify(appConfig, null, 2));
|
||||
await validateAppConfigAsync(ctx, appConfig);
|
||||
@@ -121,12 +118,13 @@ 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 () => {
|
||||
if (!ctx.appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
|
||||
const appConfig = await ctx.appConfig;
|
||||
if (!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 (!ctx.appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
|
||||
if (!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.'
|
||||
);
|
||||
|
||||
+18
-3
@@ -6,11 +6,14 @@ import fg from 'fast-glob';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
|
||||
const CHECK_FILE_INTERVAL_MS = 1000;
|
||||
|
||||
export class XcodeBuildLogger {
|
||||
private loggerError?: Error;
|
||||
private flushing: boolean = false;
|
||||
private reportedFormatterError: boolean = false;
|
||||
private logReaderPromise?: SpawnPromise<SpawnResult>;
|
||||
private logsPath?: string;
|
||||
|
||||
@@ -60,9 +63,21 @@ export class XcodeBuildLogger {
|
||||
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
|
||||
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
|
||||
this.logReaderPromise.child.stdout.on('data', (data: string) => {
|
||||
const lines = formatter.pipe(data.toString());
|
||||
for (const line of lines) {
|
||||
this.logger.info(line);
|
||||
try {
|
||||
const lines = formatter.pipe(data.toString());
|
||||
for (const line of lines) {
|
||||
this.logger.info(line);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// The formatter can throw on unexpected xcodebuild output. Fall back to
|
||||
// raw logs instead of crashing the process with an uncaught exception.
|
||||
if (!this.reportedFormatterError) {
|
||||
this.reportedFormatterError = true;
|
||||
Sentry.capture('xcpretty formatter failed to parse xcodebuild logs', err, {
|
||||
extras: { data: data.toString().slice(-2000) },
|
||||
});
|
||||
}
|
||||
this.logger.info(data.toString());
|
||||
}
|
||||
});
|
||||
await this.logReaderPromise;
|
||||
@@ -2,7 +2,6 @@ import { ExpoConfig } from '@expo/config';
|
||||
import {
|
||||
BuildPhase,
|
||||
BuildPhaseResult,
|
||||
BuildPhaseStats,
|
||||
Env,
|
||||
EnvironmentSecretType,
|
||||
GenericArtifactType,
|
||||
@@ -20,6 +19,7 @@ import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError';
|
||||
import { Datadog } from './datadog';
|
||||
import { readAppConfig } from './utils/appConfig';
|
||||
import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets';
|
||||
import { PackageManager, resolvePackageManager } from './utils/packageManager';
|
||||
@@ -45,6 +45,7 @@ export type ArtifactToUpload =
|
||||
type: GenericArtifactType;
|
||||
name: string;
|
||||
paths: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface BuildContextOptions {
|
||||
@@ -62,9 +63,8 @@ export interface BuildContextOptions {
|
||||
reportError?: (
|
||||
msg: string,
|
||||
err?: Error,
|
||||
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
|
||||
options?: { tags?: Record<string, string>; extras?: Record<string, string | undefined> }
|
||||
) => void;
|
||||
reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
|
||||
skipNativeBuild?: boolean;
|
||||
metadata?: Metadata;
|
||||
expoApiV2BaseUrl?: string;
|
||||
@@ -72,6 +72,41 @@ export interface BuildContextOptions {
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
|
||||
function logEnvComparison({
|
||||
oldMergeOrderEnv,
|
||||
newMergeOrderEnv,
|
||||
}: {
|
||||
oldMergeOrderEnv: Env;
|
||||
newMergeOrderEnv: Env;
|
||||
}): void {
|
||||
const envNames = new Set([...Object.keys(oldMergeOrderEnv), ...Object.keys(newMergeOrderEnv)]);
|
||||
const sameEnvNames: string[] = [];
|
||||
const differentEnvNames: string[] = [];
|
||||
|
||||
for (const key of envNames) {
|
||||
const hasOldValue = key in oldMergeOrderEnv;
|
||||
const hasNewValue = key in newMergeOrderEnv;
|
||||
if (hasOldValue === hasNewValue && oldMergeOrderEnv[key] === newMergeOrderEnv[key]) {
|
||||
sameEnvNames.push(key);
|
||||
} else {
|
||||
differentEnvNames.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (differentEnvNames.length === 0) {
|
||||
Datadog.log('BuildContext.updateEnv merge order produced same env');
|
||||
return;
|
||||
}
|
||||
|
||||
Datadog.log(
|
||||
`BuildContext.updateEnv merge order produced different env: compared_env_count=${
|
||||
envNames.size
|
||||
} different_env_count=${differentEnvNames.length} different_env_names=${differentEnvNames.join(
|
||||
','
|
||||
)} same_env_count=${sameEnvNames.length} same_env_names=${sameEnvNames.join(',')}`
|
||||
);
|
||||
}
|
||||
|
||||
export class BuildContext<TJob extends Job = Job> {
|
||||
public readonly workingdir: string;
|
||||
public logger: bunyan;
|
||||
@@ -80,7 +115,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
public readonly reportError?: (
|
||||
msg: string,
|
||||
err?: Error,
|
||||
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
|
||||
options?: { tags?: Record<string, string>; extras?: Record<string, string | undefined> }
|
||||
) => void;
|
||||
public readonly skipNativeBuild?: boolean;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
@@ -96,8 +131,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
private buildPhase?: BuildPhase;
|
||||
private buildPhaseSkipped = false;
|
||||
private buildPhaseHasWarnings = false;
|
||||
private _appConfig?: ExpoConfig;
|
||||
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
|
||||
private _appConfig?: Promise<ExpoConfig>;
|
||||
public readonly graphqlClient: Client;
|
||||
|
||||
constructor(job: TJob, options: BuildContextOptions) {
|
||||
@@ -113,7 +147,6 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
this._metadata = options.metadata;
|
||||
this.skipNativeBuild = options.skipNativeBuild;
|
||||
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
|
||||
this.reportBuildPhaseStats = options.reportBuildPhaseStats;
|
||||
|
||||
const environmentSecrets = this.getEnvironmentSecrets(job);
|
||||
this._env = {
|
||||
@@ -173,16 +206,18 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
return path.join(this.workingdir, 'env');
|
||||
}
|
||||
public get packageManager(): PackageManager {
|
||||
return resolvePackageManager(this.getReactNativeProjectDirectory());
|
||||
return resolvePackageManager(this.getReactNativeProjectDirectory(), {
|
||||
env: this.env,
|
||||
});
|
||||
}
|
||||
public get appConfig(): ExpoConfig {
|
||||
public get appConfig(): Promise<ExpoConfig> {
|
||||
if (!this._appConfig) {
|
||||
this._appConfig = readAppConfig({
|
||||
projectDir: this.getReactNativeProjectDirectory(),
|
||||
env: this.env,
|
||||
logger: this.logger,
|
||||
sdkVersion: this.metadata?.sdkVersion,
|
||||
}).exp;
|
||||
}).then(config => config.exp);
|
||||
}
|
||||
return this._appConfig;
|
||||
}
|
||||
@@ -247,11 +282,24 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
'Updating environment variables is only allowed when build was triggered by a git-based integration.'
|
||||
);
|
||||
}
|
||||
this._env = {
|
||||
|
||||
const oldMergeOrderEnv: Env = {
|
||||
...env,
|
||||
...this._env,
|
||||
__EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory,
|
||||
};
|
||||
const newMergeOrderEnv: Env = {
|
||||
...this._env,
|
||||
...env,
|
||||
__EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory,
|
||||
};
|
||||
|
||||
logEnvComparison({
|
||||
oldMergeOrderEnv,
|
||||
newMergeOrderEnv,
|
||||
});
|
||||
|
||||
this._env = newMergeOrderEnv;
|
||||
this._env.PATH = this._env.PATH
|
||||
? [this.buildExecutablesDirectory, this._env.PATH].join(':')
|
||||
: this.buildExecutablesDirectory;
|
||||
@@ -287,7 +335,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
private async handleBuildPhaseErrorAsync(
|
||||
err: any,
|
||||
buildPhase: BuildPhase
|
||||
): Promise<errors.BuildError> {
|
||||
): Promise<errors.ExpoError> {
|
||||
const buildError = await resolveBuildPhaseErrorAsync(
|
||||
err,
|
||||
this.logBuffer.getPhaseLogs(buildPhase),
|
||||
@@ -352,7 +400,13 @@ 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(
|
||||
|
||||
@@ -9,12 +9,18 @@ import {
|
||||
StaticJobInterpolationContext,
|
||||
} from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, ExternalBuildContextProvider, StepMetric } from '@expo/steps';
|
||||
import {
|
||||
BuildRuntimePlatform,
|
||||
ExternalBuildContextProvider,
|
||||
StepMetric,
|
||||
WorkflowHookMetric,
|
||||
} from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import assert from 'assert';
|
||||
import path from 'path';
|
||||
|
||||
import { ArtifactToUpload, BuildContext } from './context';
|
||||
import { reportWorkflowHookMetricToDatadog } from './utils/hookMetrics';
|
||||
import { uploadStepMetricsToWwwAsync } from './utils/stepMetrics';
|
||||
|
||||
const platformToBuildRuntimePlatform: Record<Platform, BuildRuntimePlatform> = {
|
||||
@@ -165,6 +171,10 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.pendingMetricUploads.push(p);
|
||||
}
|
||||
|
||||
public reportWorkflowHookMetric(metric: WorkflowHookMetric): void {
|
||||
reportWorkflowHookMetricToDatadog(metric);
|
||||
}
|
||||
|
||||
public async drainPendingMetricUploads(): Promise<void> {
|
||||
await Promise.allSettled(this.pendingMetricUploads);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Sentry } from './sentry';
|
||||
import { turtleFetch } from './utils/turtleFetch';
|
||||
|
||||
type MetricsTarget =
|
||||
| { kind: 'build'; turtleBuildId: string }
|
||||
| { kind: 'jobRun'; turtleJobRunId: string };
|
||||
|
||||
type DatadogSetupOptions = {
|
||||
expoApiV2BaseUrl: string;
|
||||
robotAccessToken: string;
|
||||
target: MetricsTarget;
|
||||
};
|
||||
|
||||
let setupOptions: DatadogSetupOptions | null = null;
|
||||
let pendingUploads: Promise<unknown>[] = [];
|
||||
|
||||
function withTargetTag(
|
||||
tags: Record<string, string>,
|
||||
target: MetricsTarget
|
||||
): Record<string, string> {
|
||||
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
|
||||
}
|
||||
|
||||
export const Datadog = {
|
||||
setup(opts: DatadogSetupOptions | null): void {
|
||||
setupOptions = opts;
|
||||
},
|
||||
|
||||
distribution(name: string, value: number, tags: Record<string, string> = {}): void {
|
||||
if (!setupOptions) {
|
||||
return;
|
||||
}
|
||||
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
|
||||
const metrics = [
|
||||
{
|
||||
name,
|
||||
type: 'distribution' as const,
|
||||
value,
|
||||
tags: withTargetTag(tags, target),
|
||||
},
|
||||
];
|
||||
|
||||
const metricsPath =
|
||||
target.kind === 'build'
|
||||
? `turtle-builds/${target.turtleBuildId}/metrics`
|
||||
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
|
||||
|
||||
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
|
||||
json: { metrics },
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
}).catch(err => {
|
||||
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
|
||||
extras: { metrics },
|
||||
});
|
||||
});
|
||||
|
||||
pendingUploads.push(uploadPromise);
|
||||
},
|
||||
|
||||
log(message: string, tags: Record<string, string> = {}): void {
|
||||
if (!setupOptions) {
|
||||
return;
|
||||
}
|
||||
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
|
||||
const tagsWithTarget = withTargetTag(tags, target);
|
||||
const log =
|
||||
target.kind === 'build'
|
||||
? { buildId: target.turtleBuildId, message, tags: tagsWithTarget }
|
||||
: { jobRunId: target.turtleJobRunId, message, tags: tagsWithTarget };
|
||||
const logsPath = target.kind === 'build' ? 'turtle-builds/logs' : 'turtle-job-runs/logs';
|
||||
|
||||
const uploadPromise = turtleFetch(new URL(logsPath, expoApiV2BaseUrl).toString(), 'POST', {
|
||||
json: log,
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
shouldThrowOnNotOk: false,
|
||||
}).catch(err => {
|
||||
Sentry.capture(`Failed to report turtle ${target.kind} log`, err, {
|
||||
extras: { log },
|
||||
});
|
||||
});
|
||||
|
||||
pendingUploads.push(uploadPromise);
|
||||
},
|
||||
|
||||
async flushAsync(): Promise<void> {
|
||||
// Rotate so each flush only awaits its own batch; uploads enqueued during the
|
||||
// await land in the fresh array for a later flush.
|
||||
const uploads = pendingUploads;
|
||||
pendingUploads = [];
|
||||
await Promise.allSettled(uploads);
|
||||
},
|
||||
};
|
||||
@@ -47,6 +47,7 @@ export async function runGenericJobAsync(
|
||||
externalFunctions: getEasFunctions(customBuildCtx),
|
||||
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
|
||||
steps: ctx.job.steps,
|
||||
hooks: ctx.job.hooks,
|
||||
});
|
||||
|
||||
const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => {
|
||||
|
||||
@@ -20,4 +20,11 @@ export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
|
||||
|
||||
export { Hook, runHookIfPresent } from './utils/hooks';
|
||||
|
||||
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
|
||||
export type { GradleProfileTask } from './android/gradleProfile';
|
||||
|
||||
export * from './generic';
|
||||
|
||||
export { Datadog } from './datadog';
|
||||
export { Sentry } from './sentry';
|
||||
export * from './runtimeSettings';
|
||||
@@ -9,6 +9,7 @@ exports[`fastfile createFastfileForResigningBuild should create Fastfile with al
|
||||
"com.example.app" => "/path/to/profiles/main.mobileprovision",
|
||||
"com.example.app.widget" => "/path/to/profiles/widget.mobileprovision",
|
||||
},
|
||||
use_app_entitlements: true,
|
||||
keychain_path: "/Users/expo/Library/Keychains/login.keychain"
|
||||
)
|
||||
end
|
||||
@@ -22,6 +23,7 @@ exports[`fastfile createFastfileForResigningBuild should handle empty provisioni
|
||||
signing_identity: "iPhone Distribution",
|
||||
provisioning_profile: {
|
||||
},
|
||||
use_app_entitlements: true,
|
||||
keychain_path: "/tmp/keychain"
|
||||
)
|
||||
end
|
||||
@@ -39,6 +41,7 @@ exports[`fastfile createFastfileForResigningBuild should handle multiple provisi
|
||||
"com.example.app.extension" => "/profiles/extension.mobileprovision",
|
||||
"com.example.app.intents" => "/profiles/intents.mobileprovision",
|
||||
},
|
||||
use_app_entitlements: true,
|
||||
keychain_path: "/tmp/keychain"
|
||||
)
|
||||
end
|
||||
@@ -53,6 +56,7 @@ exports[`fastfile createFastfileForResigningBuild should handle paths with speci
|
||||
provisioning_profile: {
|
||||
"com.example.app" => "/path/with spaces/profile (1).mobileprovision",
|
||||
},
|
||||
use_app_entitlements: true,
|
||||
keychain_path: "/Users/expo/Library/Keychains/login keychain.keychain"
|
||||
)
|
||||
end
|
||||
@@ -67,6 +71,7 @@ exports[`fastfile createFastfileForResigningBuild should handle single provision
|
||||
provisioning_profile: {
|
||||
"com.example.app" => "/tmp/profile.mobileprovision",
|
||||
},
|
||||
use_app_entitlements: true,
|
||||
keychain_path: "/tmp/keychain"
|
||||
)
|
||||
end
|
||||
@@ -81,6 +86,7 @@ exports[`fastfile createFastfileForResigningBuild should produce valid Ruby synt
|
||||
provisioning_profile: {
|
||||
"com.example.app" => "/path/to/profile.mobileprovision",
|
||||
},
|
||||
use_app_entitlements: true,
|
||||
keychain_path: "/tmp/keychain"
|
||||
)
|
||||
end
|
||||
|
||||
@@ -22,6 +22,10 @@ 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")
|
||||
"
|
||||
`;
|
||||
@@ -45,6 +49,10 @@ 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")
|
||||
"
|
||||
`;
|
||||
@@ -70,6 +78,10 @@ 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")
|
||||
"
|
||||
`;
|
||||
@@ -97,6 +109,10 @@ 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")
|
||||
"
|
||||
`;
|
||||
@@ -117,6 +133,9 @@ destination("generic/platform=iOS Simulator")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -134,6 +153,9 @@ destination("platform=iOS Simulator,name=iPhone 15")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -153,5 +175,8 @@ destination("generic/platform=tvOS Simulator")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
@@ -165,6 +165,52 @@ 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(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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,6 +58,8 @@ describe('gymfile', () => {
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Release',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -94,6 +96,8 @@ describe('gymfile', () => {
|
||||
credentials: mockCredentials,
|
||||
scheme: 'TestScheme',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: false,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -131,6 +135,8 @@ describe('gymfile', () => {
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Release',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
entitlements: {
|
||||
@@ -192,6 +198,8 @@ describe('gymfile', () => {
|
||||
credentials: mockCredentials,
|
||||
scheme: 'MyApp',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -210,6 +218,7 @@ 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',
|
||||
@@ -226,6 +235,7 @@ 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',
|
||||
@@ -243,6 +253,7 @@ describe('gymfile', () => {
|
||||
scheme: 'MyTVApp',
|
||||
buildConfiguration: 'Debug',
|
||||
derivedDataPath: '/tmp/derived',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
simulatorDestination: 'generic/platform=tvOS Simulator',
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { installPods } from '../pod';
|
||||
|
||||
jest.mock('@expo/eas-build-job', () => ({
|
||||
...jest.requireActual('@expo/eas-build-job'),
|
||||
getInstalledExpoPackageVersionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const WORKING_DIR = '/workingdir';
|
||||
const PROJECT_DIR = path.join(WORKING_DIR, 'build');
|
||||
const IOS_DIR = path.join(PROJECT_DIR, 'ios');
|
||||
|
||||
function makeIosBuildContext({
|
||||
expoVersion,
|
||||
cocoapodsCacheUrl,
|
||||
usePrecompiledModules = true,
|
||||
expoUsePrecompiledModules,
|
||||
}: {
|
||||
expoVersion?: string;
|
||||
cocoapodsCacheUrl?: string;
|
||||
usePrecompiledModules?: boolean;
|
||||
expoUsePrecompiledModules?: string;
|
||||
}): BuildContext<Ios.Job> {
|
||||
vol.fromJSON({
|
||||
[path.join(IOS_DIR, '.keep')]: '',
|
||||
...(expoVersion
|
||||
? {
|
||||
[path.join(PROJECT_DIR, 'node_modules/expo/package.json')]: JSON.stringify({
|
||||
version: expoVersion,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const job: Ios.Job = {
|
||||
...createTestIosJob(),
|
||||
builderEnvironment: {
|
||||
env: {
|
||||
...(usePrecompiledModules ? { EAS_USE_PRECOMPILED_MODULES: '1' } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return new BuildContext(job, {
|
||||
workingdir: WORKING_DIR,
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
...(expoUsePrecompiledModules
|
||||
? { EXPO_USE_PRECOMPILED_MODULES: expoUsePrecompiledModules }
|
||||
: {}),
|
||||
...(cocoapodsCacheUrl ? { EAS_BUILD_COCOAPODS_CACHE_URL: cocoapodsCacheUrl } : {}),
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe(installPods, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
jest.mocked(getInstalledExpoPackageVersionAsync).mockImplementation(async ({ projectDir }) => {
|
||||
return (await fs.readJson(path.join(projectDir, 'node_modules/expo/package.json'))).version;
|
||||
});
|
||||
(spawn as jest.Mock).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: '999.0.0' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect(getInstalledExpoPackageVersionAsync).toHaveBeenCalledWith({
|
||||
env: expect.objectContaining({
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
}),
|
||||
projectDir: PROJECT_DIR,
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: IOS_DIR,
|
||||
env: expect.objectContaining({
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL:
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules/',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
|
||||
);
|
||||
});
|
||||
|
||||
it('proxies precompiled modules through CocoaPods cache when enabled', async () => {
|
||||
const ctx = makeIosBuildContext({
|
||||
expoVersion: '999.0.0',
|
||||
cocoapodsCacheUrl: 'https://cocoapods-cache.expo.test/',
|
||||
});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: IOS_DIR,
|
||||
env: expect.objectContaining({
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL:
|
||||
'https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: '55.0.25' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/^Detected expo=55\.0\.25; not enabling precompiled modules use because precompiled modules require expo>=55\.0\.26\./
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars when Expo version is invalid', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: 'invalid-version' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars when Expo version detection fails', async () => {
|
||||
const ctx = makeIosBuildContext({});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
|
||||
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars without the builder flag', async () => {
|
||||
const ctx = makeIosBuildContext({
|
||||
expoVersion: '999.0.0',
|
||||
usePrecompiledModules: false,
|
||||
});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(
|
||||
(spawn as jest.Mock).mock.calls[0][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
|
||||
).toBeUndefined();
|
||||
expect(ctx.logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not override EXPO_USE_PRECOMPILED_MODULES=0 even when builder flag is enabled', async () => {
|
||||
const ctx = makeIosBuildContext({
|
||||
expoVersion: '999.0.0',
|
||||
usePrecompiledModules: true,
|
||||
expoUsePrecompiledModules: '0',
|
||||
});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBe('0');
|
||||
expect(
|
||||
(spawn as jest.Mock).mock.calls[0][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
|
||||
).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'EXPO_USE_PRECOMPILED_MODULES=0 is set; not enabling precompiled modules use.'
|
||||
);
|
||||
expect(getInstalledExpoPackageVersionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ 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(
|
||||
@@ -56,6 +57,9 @@ async function configureCredentialsAsync(
|
||||
profileName: profile.name,
|
||||
appleTeamId: profile.teamId,
|
||||
buildConfiguration,
|
||||
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
|
||||
codeSignIdentity: profile.certificateCommonName,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { provisioningProfile } from './fixtures';
|
||||
import { BuildContext } from '../../../context';
|
||||
@@ -73,5 +74,45 @@ describe('ProvisioningProfile class', () => {
|
||||
await pp.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('should match a certificate that is not at index 0 in DeveloperCertificates', async () => {
|
||||
const pp = new ProvisioningProfile(
|
||||
ctx,
|
||||
Buffer.from(provisioningProfile.dataBase64, 'base64'),
|
||||
keychain.data.path,
|
||||
'testapp',
|
||||
'Abc 123'
|
||||
);
|
||||
|
||||
try {
|
||||
await pp.init();
|
||||
|
||||
// Prepend an unrelated certificate so the real one moves to index 1
|
||||
const dummyCert = Buffer.from('dummy-certificate-data');
|
||||
(pp as any).developerCertificates = [dummyCert, ...(pp as any).developerCertificates];
|
||||
|
||||
// The original cert fingerprint should still match
|
||||
expect(() => {
|
||||
pp.verifyCertificate(provisioningProfile.certFingerprint);
|
||||
}).not.toThrow();
|
||||
|
||||
// A non-matching fingerprint should still fail
|
||||
expect(() => {
|
||||
pp.verifyCertificate('2137');
|
||||
}).toThrowError(/don't match/);
|
||||
|
||||
// The dummy cert's fingerprint should also match
|
||||
const dummyFingerprint = crypto
|
||||
.createHash('sha1')
|
||||
.update(new Uint8Array(dummyCert))
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
expect(() => {
|
||||
pp.verifyCertificate(dummyFingerprint);
|
||||
}).not.toThrow();
|
||||
} finally {
|
||||
await pp.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ export interface ProvisioningProfileData {
|
||||
export enum DistributionType {
|
||||
AD_HOC = 'ad-hoc',
|
||||
APP_STORE = 'app-store',
|
||||
DEVELOPMENT = 'development',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
@@ -43,6 +44,7 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
|
||||
private readonly profilePath: string;
|
||||
private profileData?: ProvisioningProfileData;
|
||||
private developerCertificates: Buffer[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly ctx: BuildContext<TJob>,
|
||||
@@ -77,11 +79,12 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
}
|
||||
|
||||
public verifyCertificate(fingerprint: string): void {
|
||||
const devCertFingerprint = this.genDerCertFingerprint();
|
||||
if (devCertFingerprint !== fingerprint) {
|
||||
const devCertFingerprints = this.getAllDerCertFingerprints();
|
||||
if (!devCertFingerprints.includes(fingerprint)) {
|
||||
throw new errors.CredentialsDistCertMismatchError(
|
||||
`Provisioning profile and distribution certificate don't match.
|
||||
Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}`
|
||||
`Provisioning profile and distribution certificate don't match.\n` +
|
||||
`Profile's certificate fingerprints = [${devCertFingerprints.join(', ')}], ` +
|
||||
`distribution certificate fingerprint = ${fingerprint}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -114,6 +117,10 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
] as string;
|
||||
const bundleIdentifier = applicationIdentifier.replace(/^.+?\./, '');
|
||||
|
||||
this.developerCertificates = (plistData.DeveloperCertificates as string[]).map((cert: string) =>
|
||||
Buffer.from(cert, 'base64')
|
||||
);
|
||||
|
||||
this.profileData = {
|
||||
path: this.profilePath,
|
||||
target: this.target,
|
||||
@@ -121,7 +128,7 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
teamId: (plistData.TeamIdentifier as string[])[0],
|
||||
uuid: plistData.UUID as string,
|
||||
name: plistData.Name as string,
|
||||
developerCertificate: Buffer.from((plistData.DeveloperCertificates as string[])[0], 'base64'),
|
||||
developerCertificate: this.developerCertificates[0],
|
||||
certificateCommonName: this.certificateCommonName,
|
||||
distributionType: this.resolveDistributionType(plistData),
|
||||
};
|
||||
@@ -131,17 +138,19 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
if (plistData.ProvisionsAllDevices) {
|
||||
return DistributionType.ENTERPRISE;
|
||||
} else if (plistData.ProvisionedDevices) {
|
||||
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
|
||||
if (entitlements?.['get-task-allow']) {
|
||||
return DistributionType.DEVELOPMENT;
|
||||
}
|
||||
return DistributionType.AD_HOC;
|
||||
} else {
|
||||
return DistributionType.APP_STORE;
|
||||
}
|
||||
}
|
||||
|
||||
private genDerCertFingerprint(): string {
|
||||
return crypto
|
||||
.createHash('sha1')
|
||||
.update(new Uint8Array(this.data.developerCertificate))
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
private getAllDerCertFingerprints(): string[] {
|
||||
return this.developerCertificates.map(cert =>
|
||||
crypto.createHash('sha1').update(new Uint8Array(cert)).digest('hex').toUpperCase()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,15 @@ import { bunyan } from '@expo/logger';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import nullthrows from 'nullthrows';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import type { Credentials } from './credentials/manager';
|
||||
import { createFastfileForResigningBuild } from './fastfile';
|
||||
import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile';
|
||||
import { isTVOS } from './tvos';
|
||||
import { XcodeBuildLogger } from './xcpretty';
|
||||
import { COMMON_FASTLANE_ENV } from '../common/fastlane';
|
||||
import { XcodeBuildLogger } from '../common/xcpretty';
|
||||
import { BuildContext, SkipNativeBuildError } from '../context';
|
||||
|
||||
export async function runFastlaneGym<TJob extends Ios.Job>(
|
||||
@@ -28,7 +29,13 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
|
||||
entitlements: object | null;
|
||||
extraEnv?: Env;
|
||||
}
|
||||
): Promise<void> {
|
||||
): Promise<{
|
||||
derivedDataPath: string;
|
||||
workspacePath: string;
|
||||
}> {
|
||||
const workspacePath = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
|
||||
const derivedDataPath = path.join(workspacePath, 'build');
|
||||
|
||||
await ensureGymfileExists(ctx, {
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
@@ -43,13 +50,15 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
|
||||
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
|
||||
try {
|
||||
await runFastlane(['gym'], {
|
||||
cwd: path.join(ctx.getReactNativeProjectDirectory(), 'ios'),
|
||||
cwd: workspacePath,
|
||||
logger: ctx.logger,
|
||||
env: { ...ctx.env, ...extraEnv },
|
||||
});
|
||||
} finally {
|
||||
await buildLogger.flush();
|
||||
}
|
||||
|
||||
return { derivedDataPath, workspacePath };
|
||||
}
|
||||
|
||||
export async function runFastlaneResign<TJob extends Ios.Job>(
|
||||
@@ -84,7 +93,7 @@ export async function runFastlane(
|
||||
cwd,
|
||||
}: {
|
||||
logger?: bunyan;
|
||||
env?: Record<string, string>;
|
||||
env?: Env;
|
||||
cwd?: string;
|
||||
} = {}
|
||||
): Promise<SpawnResult> {
|
||||
@@ -122,6 +131,8 @@ 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`;
|
||||
@@ -131,6 +142,7 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
|
||||
scheme,
|
||||
buildConfiguration: buildConfiguration ?? 'release',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath,
|
||||
clean: false,
|
||||
logsDirectory,
|
||||
simulatorDestination,
|
||||
@@ -142,6 +154,8 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
outputDirectory: './build',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath,
|
||||
clean: false,
|
||||
logsDirectory,
|
||||
entitlements: entitlements ?? undefined,
|
||||
|
||||
@@ -11,6 +11,8 @@ interface ArchiveBuildOptions {
|
||||
scheme: string;
|
||||
buildConfiguration?: string;
|
||||
outputDirectory: string;
|
||||
derivedDataPath: string;
|
||||
resultBundlePath: string;
|
||||
clean: boolean;
|
||||
logsDirectory: string;
|
||||
entitlements?: object;
|
||||
@@ -21,6 +23,7 @@ interface SimulatorBuildOptions {
|
||||
scheme: string;
|
||||
buildConfiguration?: string;
|
||||
derivedDataPath: string;
|
||||
resultBundlePath: string;
|
||||
clean: boolean;
|
||||
logsDirectory: string;
|
||||
simulatorDestination: string;
|
||||
@@ -34,6 +37,8 @@ export async function createGymfileForArchiveBuild({
|
||||
buildConfiguration,
|
||||
entitlements,
|
||||
outputDirectory,
|
||||
derivedDataPath,
|
||||
resultBundlePath,
|
||||
logsDirectory,
|
||||
}: ArchiveBuildOptions): Promise<void> {
|
||||
const PROFILES: { BUNDLE_ID: string; UUID: string }[] = [];
|
||||
@@ -59,6 +64,8 @@ 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,
|
||||
@@ -74,6 +81,7 @@ export async function createGymfileForSimulatorBuild({
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
derivedDataPath,
|
||||
resultBundlePath,
|
||||
logsDirectory,
|
||||
simulatorDestination,
|
||||
}: SimulatorBuildOptions): Promise<void> {
|
||||
@@ -86,6 +94,7 @@ 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,9 +1,14 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { Env, Ios, getInstalledExpoPackageVersionAsync } 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
|
||||
@@ -12,6 +17,7 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
|
||||
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
||||
const cocoapodsDeploymentFlag = ctx.env['POD_INSTALL_DEPLOYMENT'] === '1' ? ['--deployment'] : [];
|
||||
const precompiledModulesEnv = await resolvePrecompiledModulesPodInstallEnvAsync(ctx);
|
||||
|
||||
return {
|
||||
spawnPromise: spawn('pod', ['install', ...verboseFlag, ...cocoapodsDeploymentFlag], {
|
||||
@@ -20,6 +26,7 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
env: {
|
||||
...ctx.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
...precompiledModulesEnv,
|
||||
},
|
||||
lineTransformer: (line?: string) => {
|
||||
if (
|
||||
@@ -35,3 +42,72 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>
|
||||
): Promise<Env> {
|
||||
if (ctx.env.EXPO_USE_PRECOMPILED_MODULES === '0') {
|
||||
ctx.logger.info('EXPO_USE_PRECOMPILED_MODULES=0 is set; not enabling precompiled modules use.');
|
||||
return {};
|
||||
}
|
||||
|
||||
if (ctx.env.EAS_USE_PRECOMPILED_MODULES !== '1') {
|
||||
return {};
|
||||
}
|
||||
|
||||
let expoPackageVersion: string;
|
||||
try {
|
||||
expoPackageVersion = await getInstalledExpoPackageVersionAsync({
|
||||
env: ctx.env,
|
||||
projectDir: ctx.getReactNativeProjectDirectory(),
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.info(
|
||||
{ err },
|
||||
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
const validExpoPackageVersion = semver.valid(expoPackageVersion);
|
||||
if (!validExpoPackageVersion) {
|
||||
ctx.logger.info(
|
||||
`Detected expo=${expoPackageVersion}; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
if (semver.lt(validExpoPackageVersion, MIN_PRECOMPILED_MODULES_EXPO_VERSION)) {
|
||||
ctx.logger.info(
|
||||
`Detected expo=${validExpoPackageVersion}; not enabling precompiled modules use because precompiled modules require expo>=${MIN_PRECOMPILED_MODULES_EXPO_VERSION}.`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
const env: Env = {
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(ctx),
|
||||
};
|
||||
|
||||
ctx.logger.info(
|
||||
`Detected expo=${validExpoPackageVersion}; enabling precompiled modules use. Installing pods with additional environment variables.\n${Object.entries(
|
||||
env
|
||||
)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')}\nPrecompiled modules pod install environment is configured.`
|
||||
);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
|
||||
if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
|
||||
return PRECOMPILED_MODULES_BASE_URL;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
|
||||
return PRECOMPILED_MODULES_BASE_URL.replace(
|
||||
`${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
`${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import fetch from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from './sentry';
|
||||
|
||||
const ENVIRONMENT_TO_RUNTIME_SETTINGS_URL: Record<string, string | undefined> = {
|
||||
staging: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
|
||||
production: 'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
|
||||
};
|
||||
|
||||
const RuntimeSettingsSchema = z
|
||||
.object({
|
||||
caches: z.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
npm: z.boolean(),
|
||||
nodejs: z.boolean(),
|
||||
maven: z.boolean(),
|
||||
cocoapods: z.boolean(),
|
||||
})
|
||||
.partial()
|
||||
),
|
||||
iosPrecompiledModules: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
let runtimeSettings: z.infer<typeof RuntimeSettingsSchema> = {};
|
||||
let runtimeEnvironment: Env = {};
|
||||
|
||||
export namespace RuntimeSettings {
|
||||
export async function loadAsync({
|
||||
environment,
|
||||
env: nextRuntimeEnvironment = {},
|
||||
}: {
|
||||
environment: string;
|
||||
env?: Env;
|
||||
}): Promise<void> {
|
||||
runtimeEnvironment = nextRuntimeEnvironment;
|
||||
const url = ENVIRONMENT_TO_RUNTIME_SETTINGS_URL[environment];
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
Sentry.capture('Failed to fetch worker runtime settings', {
|
||||
extras: { url, status: response.status },
|
||||
level: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeSettings = RuntimeSettingsSchema.parse(await response.json());
|
||||
if (runtimeSettings.caches && !runtimeSettings.caches[process.platform]) {
|
||||
Sentry.capture(
|
||||
new Error(
|
||||
`Runtime settings are missing cache settings for platform "${process.platform}"`
|
||||
),
|
||||
{
|
||||
extras: {
|
||||
configuredPlatforms: Object.keys(runtimeSettings.caches),
|
||||
platform: process.platform,
|
||||
},
|
||||
level: 'error',
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.capture(
|
||||
'Failed to load worker runtime settings',
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
{
|
||||
extras: { url },
|
||||
level: 'warning',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function isUsingIosPrecompiledModulesEnabled(): boolean {
|
||||
return runtimeSettings.iosPrecompiledModules ?? false;
|
||||
}
|
||||
|
||||
export function getNpmCacheUrl(): string | null {
|
||||
if (runtimeEnvironment['EAS_BUILD_DISABLE_NPM_CACHE'] === '1') {
|
||||
return null;
|
||||
}
|
||||
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.npm;
|
||||
if (runtimeEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
const envOverride = runtimeEnvironment['EAS_USE_NPM_CACHE'];
|
||||
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
|
||||
return enabled ? process.env.EAS_NPM_CACHE_URL || null : null;
|
||||
}
|
||||
|
||||
export function getNodeJsCacheUrl(): string | null {
|
||||
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.nodejs;
|
||||
if (runtimeEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
const envOverride = runtimeEnvironment['EAS_USE_NODEJS_CACHE'];
|
||||
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
|
||||
return enabled ? process.env.EAS_NODEJS_CACHE_URL || null : null;
|
||||
}
|
||||
|
||||
export function getMavenCacheUrl(): string | null {
|
||||
if (runtimeEnvironment['EAS_BUILD_DISABLE_MAVEN_CACHE'] === '1') {
|
||||
return null;
|
||||
}
|
||||
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.maven;
|
||||
if (runtimeEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
const envOverride = runtimeEnvironment['EAS_USE_MAVEN_CACHE'];
|
||||
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
|
||||
return enabled ? process.env.EAS_MAVEN_CACHE_URL || null : null;
|
||||
}
|
||||
|
||||
export function getCocoapodsCacheUrl(): string | null {
|
||||
if (runtimeEnvironment['EAS_BUILD_DISABLE_COCOAPODS_CACHE'] === '1') {
|
||||
return null;
|
||||
}
|
||||
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.cocoapods;
|
||||
if (runtimeEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
const envOverride = runtimeEnvironment['EAS_USE_COCOAPODS_CACHE'];
|
||||
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
|
||||
return enabled ? process.env.EAS_COCOAPODS_CACHE_URL || null : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
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']));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { HookAnchorId } from '@expo/eas-build-job';
|
||||
import { StepsConfigParser } from '@expo/steps';
|
||||
|
||||
import {
|
||||
createCustomBuildContextMock,
|
||||
createGlobalContextMock,
|
||||
} from '../../__tests__/utils/context';
|
||||
import { getEasFunctionGroups } from '../easFunctionGroups';
|
||||
import { getEasFunctions } from '../easFunctions';
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
describe('hook anchor self-declarations', () => {
|
||||
it('exactly the four v0 functions declare anchors, with these bindings', () => {
|
||||
const declarations: Record<string, HookAnchorId> = {};
|
||||
for (const buildFunction of getEasFunctions(createCustomBuildContextMock())) {
|
||||
if (buildFunction.__hookId !== undefined) {
|
||||
declarations[buildFunction.getFullId()] = buildFunction.__hookId;
|
||||
}
|
||||
}
|
||||
// An empty declaration set must fail: without these, every user-authored
|
||||
// unstamped `uses:` step and every `eas/build` expansion loses hooks.
|
||||
expect(declarations).toEqual({
|
||||
'eas/checkout': 'checkout',
|
||||
'eas/install_node_modules': 'install_node_modules',
|
||||
'eas/upload_to_asc': 'submit',
|
||||
'eas/maestro_tests': 'maestro_tests',
|
||||
});
|
||||
});
|
||||
|
||||
it('no function group id collides with a declaring function id', () => {
|
||||
// Group lookup shadows function lookup in StepsConfigParser, so a
|
||||
// collision would silently unbind the anchor.
|
||||
const declaringFunctionIds = getEasFunctions(createCustomBuildContextMock())
|
||||
.filter(buildFunction => buildFunction.__hookId !== undefined)
|
||||
.map(buildFunction => buildFunction.getFullId());
|
||||
const functionGroupIds = getEasFunctionGroups(createCustomBuildContextMock()).map(
|
||||
functionGroup => functionGroup.getFullId()
|
||||
);
|
||||
for (const functionId of declaringFunctionIds) {
|
||||
expect(functionGroupIds).not.toContain(functionId);
|
||||
}
|
||||
});
|
||||
|
||||
it('attaches hooks inside a uses: eas/build expansion (declaration-driven anchor discovery)', async () => {
|
||||
// This is the guard against a semantically stale declaration — the
|
||||
// declaration existence test alone cannot catch a binding that no longer
|
||||
// corresponds to what the group actually expands to.
|
||||
const customBuildCtx = createCustomBuildContextMock();
|
||||
const globalContext = createGlobalContextMock({});
|
||||
const parser = new StepsConfigParser(globalContext, {
|
||||
steps: [{ uses: 'eas/build' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }],
|
||||
after_install_node_modules: [{ run: 'echo after', id: 'after-hook' }],
|
||||
},
|
||||
externalFunctions: getEasFunctions(customBuildCtx),
|
||||
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
|
||||
});
|
||||
const workflow = await parser.parseAsync();
|
||||
|
||||
const installStep = workflow.buildSteps.find(step => step.__hookId === 'install_node_modules');
|
||||
expect(installStep).toBeDefined();
|
||||
const anchorHooks = workflow.hooksByAnchorStep.get(installStep!);
|
||||
expect(anchorHooks?.anchor).toBe('install_node_modules');
|
||||
expect(anchorHooks?.before[0].steps[0].id).toBe('before-hook');
|
||||
expect(anchorHooks?.after[0].steps[0].id).toBe('after-hook');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['eas/checkout', 'before_checkout', 'after_checkout', undefined],
|
||||
[
|
||||
'eas/install_node_modules',
|
||||
'before_install_node_modules',
|
||||
'after_install_node_modules',
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
'eas/upload_to_asc',
|
||||
'before_submit',
|
||||
'after_submit',
|
||||
{
|
||||
ipa_path: 'app.ipa',
|
||||
asc_api_key_path: 'key.p8',
|
||||
apple_app_identifier: '1234567890',
|
||||
bundle_version: '1',
|
||||
bundle_short_version: '1.0.0',
|
||||
},
|
||||
],
|
||||
[
|
||||
'eas/maestro_tests',
|
||||
'before_maestro_tests',
|
||||
'after_maestro_tests',
|
||||
{ flow_path: ['flow.yaml'] },
|
||||
],
|
||||
] as const)(
|
||||
'an unstamped user-authored %s step fires its hook keys end-to-end',
|
||||
async (functionId, beforeKey, afterKey, callInputs) => {
|
||||
const customBuildCtx = createCustomBuildContextMock();
|
||||
const globalContext = createGlobalContextMock({});
|
||||
const parser = new StepsConfigParser(globalContext, {
|
||||
steps: [{ uses: functionId, ...(callInputs !== undefined ? { with: callInputs } : null) }],
|
||||
hooks: {
|
||||
[beforeKey]: [{ run: 'echo before', id: 'before-hook' }],
|
||||
[afterKey]: [{ run: 'echo after', id: 'after-hook' }],
|
||||
},
|
||||
externalFunctions: getEasFunctions(customBuildCtx),
|
||||
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
|
||||
});
|
||||
const workflow = await parser.parseAsync();
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(1);
|
||||
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
|
||||
expect(anchorHooks.before[0].steps[0].id).toBe('before-hook');
|
||||
expect(anchorHooks.after[0].steps[0].id).toBe('after-hook');
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BuildFunction } from '@expo/steps';
|
||||
|
||||
import { calculateEASUpdateRuntimeVersionFunction } from './functions/calculateEASUpdateRuntimeVersion';
|
||||
import { createCapturePosthogEventFunction } from './functions/capturePosthogEvent';
|
||||
import { createCheckoutBuildFunction } from './functions/checkout';
|
||||
import { configureAndroidVersionFunction } from './functions/configureAndroidVersion';
|
||||
import { configureEASUpdateIfInstalledFunction } from './functions/configureEASUpdateIfInstalled';
|
||||
@@ -9,17 +10,21 @@ 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 { createFinishIosSimulatorRecordingsBuildFunction } from './functions/finishIosSimulatorRecordings';
|
||||
import { generateGymfileFromTemplateFunction } from './functions/generateGymfileFromTemplate';
|
||||
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
|
||||
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
|
||||
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';
|
||||
@@ -29,17 +34,29 @@ 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 { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
|
||||
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 { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
|
||||
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
|
||||
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
|
||||
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
|
||||
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
|
||||
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
|
||||
import { createPosthogAnnotationFunction } from './functions/createPosthogAnnotation';
|
||||
import { createWaitForPosthogQueryFunction } from './functions/waitForPosthogQuery';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
|
||||
export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
@@ -48,10 +65,14 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createDownloadArtifactFunction(),
|
||||
createUploadArtifactBuildFunction(ctx),
|
||||
createSetUpNpmrcBuildFunction(),
|
||||
createReadPackageJsonBuildFunction(),
|
||||
createReadAppConfigBuildFunction(),
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createDownloadBuildFunction(),
|
||||
createDownloadBuildFunction(ctx),
|
||||
createEasExportBuildFunction(),
|
||||
createEasDeployBuildFunction(),
|
||||
createRepackBuildFunction(),
|
||||
createRestoreCacheFunction(),
|
||||
createRestoreBuildCacheFunction(),
|
||||
@@ -68,22 +89,34 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
configureIosVersionFunction(),
|
||||
generateGymfileFromTemplateFunction(),
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
createStartIosSimulatorRecordingsBuildFunction(),
|
||||
createFinishIosSimulatorRecordingsBuildFunction(),
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartServeSimRemoteSessionBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
|
||||
createInstallPodsBuildFunction(),
|
||||
createSendSlackMessageFunction(),
|
||||
createCapturePosthogEventFunction(),
|
||||
createRolloutPosthogFlagFunction(),
|
||||
createWaitForPosthogMetricFunction(),
|
||||
createUploadPosthogSourcemapsFunction(),
|
||||
createPosthogAnnotationFunction(),
|
||||
createWaitForPosthogQueryFunction(),
|
||||
|
||||
calculateEASUpdateRuntimeVersionFunction(),
|
||||
|
||||
createSubmissionEntityFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
|
||||
createInternalEasMaestroTestFunction(ctx),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(ctx),
|
||||
];
|
||||
|
||||
if (ctx.hasBuildJob()) {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
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,5 +1,12 @@
|
||||
import { BuildJob, Platform } from '@expo/eas-build-job';
|
||||
import { BuildFunctionGroup, BuildStep, BuildStepGlobalContext } from '@expo/steps';
|
||||
import {
|
||||
BuildFunctionGroup,
|
||||
BuildStep,
|
||||
BuildStepGlobalContext,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import path from 'path';
|
||||
|
||||
import { shouldUseEagerBundle } from '../../common/eagerBundle';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -30,6 +37,7 @@ import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
|
||||
interface HelperFunctionsInput {
|
||||
globalCtx: BuildStepGlobalContext;
|
||||
buildToolsContext: CustomBuildContext<BuildJob>;
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
export function createEasBuildBuildFunctionGroup(
|
||||
@@ -38,17 +46,30 @@ export function createEasBuildBuildFunctionGroup(
|
||||
return new BuildFunctionGroup({
|
||||
namespace: 'eas',
|
||||
id: 'build',
|
||||
createBuildStepsFromFunctionGroupCall: globalCtx => {
|
||||
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;
|
||||
|
||||
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) {
|
||||
@@ -56,11 +77,13 @@ export function createEasBuildBuildFunctionGroup(
|
||||
return createStepsForAndroidBuildWithoutCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
} else {
|
||||
return createStepsForAndroidBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -73,16 +96,19 @@ 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: './ios',
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -91,6 +117,7 @@ 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 }',
|
||||
@@ -98,18 +125,26 @@ function createStepsForIosSimulatorBuild({
|
||||
});
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
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 }',
|
||||
@@ -117,29 +152,35 @@ function createStepsForIosSimulatorBuild({
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
];
|
||||
}
|
||||
|
||||
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 }',
|
||||
},
|
||||
@@ -147,16 +188,18 @@ function createStepsForIosBuildWithCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
}
|
||||
);
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: './ios',
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -166,6 +209,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
const generateGymfile = generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
credentials: '${ eas.job.secrets.buildCredentials }',
|
||||
},
|
||||
@@ -173,6 +217,7 @@ 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 }',
|
||||
@@ -181,6 +226,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
@@ -188,10 +234,15 @@ function createStepsForIosBuildWithCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
resolveAppleTeamIdFromCredentials,
|
||||
prebuildStep,
|
||||
@@ -199,11 +250,20 @@ function createStepsForIosBuildWithCredentials({
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'configure_ios_credentials',
|
||||
workingDirectory,
|
||||
}),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
|
||||
},
|
||||
}),
|
||||
...(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 }',
|
||||
@@ -215,24 +275,29 @@ function createStepsForIosBuildWithCredentials({
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
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:
|
||||
@@ -242,6 +307,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -249,6 +315,7 @@ 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 }',
|
||||
@@ -257,6 +324,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -264,18 +332,26 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
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 }',
|
||||
@@ -286,24 +362,29 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
runGradle,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
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:
|
||||
@@ -313,6 +394,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -320,6 +402,7 @@ 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 }',
|
||||
@@ -328,6 +411,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -335,21 +419,33 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
configureEASUpdate,
|
||||
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
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 }',
|
||||
@@ -359,8 +455,10 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
: []),
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -51,7 +51,6 @@ 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
|
||||
@@ -97,7 +96,6 @@ 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
|
||||
@@ -134,7 +132,6 @@ export function createEasMaestroTestFunctionGroup(
|
||||
steps.push(
|
||||
new BuildStep(globalCtx, {
|
||||
id: BuildStep.getNewId(),
|
||||
name: 'maestro_test',
|
||||
ifCondition: '${ always() }',
|
||||
displayName: `maestro test ${flowPath}`,
|
||||
command: `maestro test ${flowPath}`,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createCapturePosthogEventFunction } from '../capturePosthogEvent';
|
||||
|
||||
jest.mock('@expo/logger');
|
||||
jest.mock('node-fetch');
|
||||
|
||||
describe(createCapturePosthogEventFunction, () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const captureEvent = createCapturePosthogEventFunction();
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
function createStep(
|
||||
callInputs: Record<string, unknown>,
|
||||
env: Record<string, string>
|
||||
): ReturnType<typeof captureEvent.createBuildStepFromFunctionCall> {
|
||||
return captureEvent.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs,
|
||||
env,
|
||||
id: captureEvent.id,
|
||||
});
|
||||
}
|
||||
|
||||
it('sends the event to the ingestion endpoint using the env key', async () => {
|
||||
fetchMock.mockResolvedValue({ status: 200, ok: true } as Response);
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished', distinct_id: 'account-1' },
|
||||
{
|
||||
EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test',
|
||||
EXPO_PUBLIC_POSTHOG_HOST: 'https://us.posthog.com',
|
||||
}
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://us.posthog.com/i/v0/e/');
|
||||
const body = JSON.parse(init?.body as string);
|
||||
expect(body).toMatchObject({
|
||||
api_key: 'phc_test',
|
||||
event: 'workflow_finished',
|
||||
distinct_id: 'account-1',
|
||||
});
|
||||
expect(body.properties?.$process_person_profile).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends an anonymous system event when no distinct_id is given', async () => {
|
||||
fetchMock.mockResolvedValue({ status: 200, ok: true } as Response);
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished' },
|
||||
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
|
||||
expect(body.distinct_id).toBe('eas-workflow');
|
||||
expect(body.properties.$process_person_profile).toBe(false);
|
||||
});
|
||||
|
||||
it('skips when no api key is available and ignore_error is set', async () => {
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished', distinct_id: 'account-1', ignore_error: true },
|
||||
{}
|
||||
);
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringMatching(/Ignoring error\.$/)
|
||||
);
|
||||
});
|
||||
|
||||
it('logs and does not throw on a non-2xx response when ignore_error is set', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
status: 400,
|
||||
ok: false,
|
||||
text: async () => '',
|
||||
} as unknown as Response);
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished', distinct_id: 'account-1', ignore_error: true },
|
||||
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
|
||||
);
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Sending PostHog event "workflow_finished" failed with status 400. Ignoring error.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let properties override the anonymous marker', async () => {
|
||||
fetchMock.mockResolvedValue({ status: 200, ok: true } as Response);
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished', properties: { $process_person_profile: true, foo: 'bar' } },
|
||||
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
|
||||
expect(body.properties.$process_person_profile).toBe(false);
|
||||
expect(body.properties.foo).toBe('bar');
|
||||
});
|
||||
|
||||
it('throws on a network error by default', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished' },
|
||||
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'Sending PostHog event "workflow_finished" failed.'
|
||||
);
|
||||
});
|
||||
|
||||
it('logs and does not throw on a network error when ignore_error is set', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished', ignore_error: true },
|
||||
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
|
||||
);
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Sending PostHog event "workflow_finished" failed. Ignoring error.'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws with the PostHog error detail on failure by default', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
status: 400,
|
||||
ok: false,
|
||||
text: async () => JSON.stringify({ detail: 'Invalid event name' }),
|
||||
} as unknown as Response);
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished' },
|
||||
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/failed with status 400: Invalid event name/);
|
||||
});
|
||||
|
||||
it('falls back to the raw error body when PostHog omits a detail', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
status: 400,
|
||||
ok: false,
|
||||
text: async () => JSON.stringify({ error: 'nope' }),
|
||||
} as unknown as Response);
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished' },
|
||||
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/failed with status 400: {"error":"nope"}/);
|
||||
});
|
||||
|
||||
it('throws on missing key by default', async () => {
|
||||
const step = createStep({ event: 'workflow_finished' }, {});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/PostHog API key not provided/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
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']));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createPosthogAnnotationFunction } from '../createPosthogAnnotation';
|
||||
|
||||
jest.mock('@expo/logger');
|
||||
jest.mock('node-fetch');
|
||||
|
||||
function jsonResponse(data: unknown, { ok = true, status = 200 } = {}): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => data,
|
||||
text: async () => JSON.stringify(data),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
const ENV = {
|
||||
POSTHOG_CLI_API_KEY: 'phx_test',
|
||||
POSTHOG_CLI_PROJECT_ID: '123',
|
||||
POSTHOG_CLI_HOST: 'https://us.posthog.com',
|
||||
};
|
||||
|
||||
describe(createPosthogAnnotationFunction, () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const annotation = createPosthogAnnotationFunction();
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
function createStep(
|
||||
callInputs: Record<string, unknown>,
|
||||
env: Record<string, string> = ENV
|
||||
): ReturnType<typeof annotation.createBuildStepFromFunctionCall> {
|
||||
return annotation.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs,
|
||||
env,
|
||||
id: annotation.id,
|
||||
});
|
||||
}
|
||||
|
||||
it('posts the annotation with an explicit date_marker', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ id: 1 }, { status: 201 }));
|
||||
const step = createStep({ content: 'Released abc123', date_marker: '2026-07-03T00:00:00Z' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://us.posthog.com/api/projects/123/annotations/');
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
content: 'Released abc123',
|
||||
date_marker: '2026-07-03T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults date_marker to now when not given', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ id: 1 }, { status: 201 }));
|
||||
const step = createStep({ content: 'Deploy' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
|
||||
expect(body.content).toBe('Deploy');
|
||||
expect(typeof body.date_marker).toBe('string');
|
||||
});
|
||||
|
||||
it('throws when credentials are missing by default', async () => {
|
||||
const step = createStep({ content: 'Deploy' }, {});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
/Missing PostHog credentials: personal API key, project id/
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and skips when credentials are missing and ignore_error is set', async () => {
|
||||
const step = createStep({ content: 'Deploy', ignore_error: true }, {});
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringMatching(/Ignoring error/)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a network error by default', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
|
||||
const step = createStep({ content: 'Deploy' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/Creating the PostHog annotation failed/);
|
||||
});
|
||||
|
||||
it('warns and continues on a network error when ignore_error is set', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
|
||||
const step = createStep({ content: 'Deploy', ignore_error: true });
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringMatching(/Ignoring error/)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a 403 without swallowing it', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 403 }));
|
||||
const step = createStep({ content: 'Deploy' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/annotation:write/);
|
||||
});
|
||||
|
||||
it('throws with the PostHog error detail on a non-2xx response', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ detail: 'Invalid date_marker' }, { ok: false, status: 400 })
|
||||
);
|
||||
const step = createStep({ content: 'Deploy' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
/failed with status 400: Invalid date_marker/
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and continues on a non-2xx response when ignore_error is set', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 500 }));
|
||||
const step = createStep({ content: 'Deploy', ignore_error: true });
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringMatching(/Ignoring error/)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
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,4 +1,6 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import { Client, CombinedError } from '@urql/core';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
@@ -17,22 +19,63 @@ const APP_TAR_GZ_BUFFER = Buffer.from(
|
||||
'base64'
|
||||
);
|
||||
|
||||
const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.tar.gz`;
|
||||
|
||||
function createMockGraphqlClient({
|
||||
applicationArchiveUrl,
|
||||
error,
|
||||
}: {
|
||||
applicationArchiveUrl?: string | null;
|
||||
error?: Error;
|
||||
}): Client {
|
||||
const toPromise = jest.fn().mockResolvedValue(
|
||||
error
|
||||
? { error, data: undefined }
|
||||
: {
|
||||
data: {
|
||||
builds: {
|
||||
byId: {
|
||||
id: randomUUID(),
|
||||
platform: 'IOS',
|
||||
artifacts: {
|
||||
applicationArchiveUrl: applicationArchiveUrl ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
query: jest.fn().mockReturnValue({ toPromise }),
|
||||
} as unknown as Client;
|
||||
}
|
||||
|
||||
describe('downloadBuild', () => {
|
||||
it('should handle an archive', async () => {
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
} as unknown as Response);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
expoApiServerURL: 'http://api.expo.test',
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
expect(artifactPath).toBeDefined();
|
||||
expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe(
|
||||
'i am executable\n'
|
||||
@@ -40,16 +83,21 @@ describe('downloadBuild', () => {
|
||||
});
|
||||
|
||||
it('should handle a straight-up file', async () => {
|
||||
const applicationArchiveUrl = `https://expo.dev/artifacts/eas/${randomUUID()}.apk`;
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(Buffer.from('hello')),
|
||||
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.txt?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
|
||||
url: applicationArchiveUrl,
|
||||
} as unknown as Response);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
expoApiServerURL: 'http://api.expo.test',
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
@@ -58,18 +106,114 @@ describe('downloadBuild', () => {
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('throws UserError when the build has no application archive url', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({ applicationArchiveUrl: null });
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
|
||||
message: 'Build does not have an application archive url',
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request fails with a network error', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Network request failed'),
|
||||
}),
|
||||
});
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request returns 5xx', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: Object.assign(new Error('Internal Server Error'), {
|
||||
response: { status: 500 },
|
||||
}),
|
||||
});
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: Internal Server Error`,
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws UserError when GraphQL returns a client error', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: [{ message: 'Build not found' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(UserError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
|
||||
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if no matching files are found', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
expoApiServerURL: 'http://api.expo.test',
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
})
|
||||
@@ -79,7 +223,11 @@ describe('downloadBuild', () => {
|
||||
|
||||
describe('createDownloadBuildFunction', () => {
|
||||
it('should download a build', async () => {
|
||||
const downloadBuild = createDownloadBuildFunction();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
const downloadBuild = createDownloadBuildFunction({ graphqlClient } as any);
|
||||
const logger = createMockLogger();
|
||||
|
||||
const buildStep = downloadBuild.createBuildStepFromFunctionCall(
|
||||
@@ -92,7 +240,7 @@ describe('createDownloadBuildFunction', () => {
|
||||
}),
|
||||
{
|
||||
callInputs: {
|
||||
build_id: randomUUID(),
|
||||
build_id: buildId,
|
||||
extensions: ['app'],
|
||||
},
|
||||
}
|
||||
@@ -105,5 +253,9 @@ describe('createDownloadBuildFunction', () => {
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow('Internal Server Error');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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,90 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { Datadog } from '../../../datadog';
|
||||
import { createInstallMaestroBuildFunction } from '../installMaestro';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../datadog', () => ({
|
||||
Datadog: {
|
||||
distribution: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
describe('createInstallMaestroBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// `maestro --version` reports an installed version; `java -version` succeeds.
|
||||
// With no requested version and Maestro present, the step skips installation.
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout: command === 'maestro' ? '1.41.0\n' : '',
|
||||
})) as any);
|
||||
});
|
||||
|
||||
it('reports the detected Maestro version to Datadog on EAS Build VMs', async () => {
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock();
|
||||
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.41.0');
|
||||
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
|
||||
maestro_version: '1.41.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not report to Datadog outside EAS Build VMs', async () => {
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: {},
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(Datadog.distribution).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extracts the version when `maestro --version` prints an analytics notice', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout:
|
||||
command === 'maestro'
|
||||
? 'Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS environment variable to any value before running Maestro.\n2.0.10\n'
|
||||
: '',
|
||||
})) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock();
|
||||
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
|
||||
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
|
||||
maestro_version: '2.0.10',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the trailing version when the notice itself contains an earlier version-like string', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout: command === 'maestro' ? 'Analytics schema v2.0.0 enabled.\n2.0.10\n' : '',
|
||||
})) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock();
|
||||
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
|
||||
});
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { buildFlowNameToPathMap, readFlowName, walkDir } from '../maestroFlowDiscovery';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
|
||||
describe(readFlowName, () => {
|
||||
beforeEach(() => vol.reset());
|
||||
|
||||
it('returns top-level `name` when present', async () => {
|
||||
vol.fromJSON({
|
||||
'/p/login.yml': `appId: com.example\nname: Login Flow\n---\n- launchApp\n`,
|
||||
});
|
||||
expect(await readFlowName('/p/login.yml')).toBe('Login Flow');
|
||||
});
|
||||
|
||||
it('falls back to basename without extension when name absent', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `appId: com.example\n---\n- launchApp\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when name is empty string', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `name: ""\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when name is non-string', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `name: 42\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when YAML is malformed', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `appId: [unclosed\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when file is empty', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': '' });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when file does not exist', async () => {
|
||||
expect(await readFlowName('/p/missing.yml')).toBe('missing');
|
||||
});
|
||||
|
||||
it('only parses the first YAML document (commands document errors are ignored)', async () => {
|
||||
vol.fromJSON({
|
||||
'/p/login.yml': `name: Login\n---\n- this is: { malformed yaml\n`,
|
||||
});
|
||||
expect(await readFlowName('/p/login.yml')).toBe('Login');
|
||||
});
|
||||
|
||||
it('handles .yaml extension', async () => {
|
||||
vol.fromJSON({ '/p/checkout.yaml': `name: Checkout\n` });
|
||||
expect(await readFlowName('/p/checkout.yaml')).toBe('Checkout');
|
||||
});
|
||||
|
||||
it('handles a leading `---` document marker', async () => {
|
||||
vol.fromJSON({
|
||||
'/p/login.yml': `---\nappId: com.example\nname: Login\n---\n- launchApp\n`,
|
||||
});
|
||||
expect(await readFlowName('/p/login.yml')).toBe('Login');
|
||||
});
|
||||
});
|
||||
|
||||
describe(walkDir, () => {
|
||||
beforeEach(() => vol.reset());
|
||||
|
||||
it('collects all .yml/.yaml files recursively, sorted', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/p/flows/a.yml': '',
|
||||
'/p/flows/b.yaml': '',
|
||||
'/p/flows/sub/c.yml': '',
|
||||
'/p/flows/sub/d.YAML': '',
|
||||
'/p/flows/readme.md': '',
|
||||
});
|
||||
const out: string[] = [];
|
||||
await walkDir('/p/flows', new Set(), out, logger);
|
||||
expect(out.sort()).toEqual([
|
||||
'/p/flows/a.yml',
|
||||
'/p/flows/b.yaml',
|
||||
'/p/flows/sub/c.yml',
|
||||
'/p/flows/sub/d.YAML',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips files named config.yaml (case-insensitive)', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/p/flows/config.yaml': '',
|
||||
'/p/flows/CONFIG.yaml': '',
|
||||
'/p/flows/login.yml': '',
|
||||
});
|
||||
const out: string[] = [];
|
||||
await walkDir('/p/flows', new Set(), out, logger);
|
||||
expect(out.sort()).toEqual(['/p/flows/login.yml']);
|
||||
});
|
||||
|
||||
it('returns silently when realpath fails (e.g. missing dir)', async () => {
|
||||
const logger = createMockLogger();
|
||||
const out: string[] = [];
|
||||
await walkDir('/p/missing', new Set(), out, logger);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildFlowNameToPathMap, () => {
|
||||
beforeEach(() => vol.reset());
|
||||
|
||||
it('builds a map from a single file input (basename name)', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/flows/login.yml': 'appId: x\n' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['flows/login.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['login', 'flows/login.yml']]));
|
||||
});
|
||||
|
||||
it('uses top-level name when present', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/flows/a.yml': 'name: Login Flow\n' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['flows/a.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['Login Flow', 'flows/a.yml']]));
|
||||
});
|
||||
|
||||
it('walks a directory input and adds each flow', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/login.yml': 'name: Login\n',
|
||||
'/proj/.maestro/checkout.yml': '',
|
||||
'/proj/.maestro/sub/profile.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(
|
||||
new Map([
|
||||
['Login', '.maestro/login.yml'],
|
||||
['checkout', '.maestro/checkout.yml'],
|
||||
['profile', '.maestro/sub/profile.yml'],
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('merges file + directory inputs', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/a.yml': '',
|
||||
'/proj/extra/b.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro', 'extra/b.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(
|
||||
new Map([
|
||||
['a', '.maestro/a.yml'],
|
||||
['b', 'extra/b.yml'],
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('skips non-existent inputs with warn', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/flows/a.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['flows/missing.yml', 'flows/a.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['a', 'flows/a.yml']]));
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('flows/missing.yml'));
|
||||
});
|
||||
|
||||
it('skips workspace config.yaml inside directory inputs', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/config.yaml': 'flows:\n - "*.yml"\n',
|
||||
'/proj/.maestro/login.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
|
||||
});
|
||||
|
||||
it('does NOT skip config.yaml when passed as an explicit file input', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/config.yaml': 'name: Workspace Defaults\nflows:\n - "*.yml"\n',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro/config.yaml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['Workspace Defaults', '.maestro/config.yaml']]));
|
||||
});
|
||||
|
||||
it('returns null when two files share top-level name', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/a.yml': 'name: Login\n',
|
||||
'/proj/b.yml': 'name: Login\n',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['a.yml', 'b.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('"Login"'));
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('a.yml'));
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('b.yml'));
|
||||
});
|
||||
|
||||
it('returns null when two files share basename across directories', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/login/index.yml': '',
|
||||
'/proj/checkout/index.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['login', 'checkout'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when name collides with another file basename', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/login.yml': 'name: Other\n',
|
||||
'/proj/Other.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['login.yml', 'Other.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
});
|
||||
|
||||
it('dedupes the same physical file passed via overlapping inputs', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/.maestro/login.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro', '.maestro/login.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses absolute fallback when input file lives outside projectRoot', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/elsewhere/a.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['/elsewhere/a.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['a', '/elsewhere/a.yml']]));
|
||||
});
|
||||
|
||||
it('absolute directory input under projectRoot yields project-relative children', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/.maestro/a.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['/proj/.maestro'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['a', '.maestro/a.yml']]));
|
||||
});
|
||||
|
||||
it('returns null and warns on unexpected helper failure', async () => {
|
||||
const logger = createMockLogger();
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: [Symbol('bad') as unknown as string],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('buildFlowNameToPathMap failed unexpectedly')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +1,22 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import {
|
||||
parseFlowMetadata,
|
||||
copyLatestAttemptXml,
|
||||
isFileAttrRun,
|
||||
junitFileHasFileAttrs,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
} from '../maestroResultParser';
|
||||
|
||||
describe(parseFlowMetadata, () => {
|
||||
it('parses valid ai-*.json', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toEqual({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when flow_name is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when flow_file_path is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid JSON', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': 'not json',
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMaestroResults, () => {
|
||||
it('parses JUnit results and enriches with ai-*.json metadata', async () => {
|
||||
it('parses JUnit results and enriches with name→path map', async () => {
|
||||
vol.fromJSON({
|
||||
// JUnit XML (primary data)
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
@@ -69,18 +28,15 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Debug output (for flow_file_path)
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
const results = await parseMaestroResults(
|
||||
'/junit',
|
||||
new Map([
|
||||
['home', '.maestro/home.yml'],
|
||||
['login', '.maestro/login.yml'],
|
||||
])
|
||||
);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -108,7 +64,7 @@ describe(parseMaestroResults, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('calculates retryCount from timestamp directory occurrences', async () => {
|
||||
it('uses flow name as fallback path when nameToPath is null', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
@@ -118,49 +74,33 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Two timestamp dirs = 1 retry
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results[0].retryCount).toBe(1);
|
||||
});
|
||||
|
||||
it('uses flow name as fallback path when ai-*.json not found', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// No ai-*.json files
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
const results = await parseMaestroResults('/junit', null);
|
||||
expect(results[0].path).toBe('home');
|
||||
expect(results[0].retryCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty array when no JUnit files found', async () => {
|
||||
it('uses flow name as fallback path when name not in map', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/.gitkeep': '',
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
const results = await parseMaestroResults('/junit', new Map());
|
||||
expect(results[0].path).toBe('home');
|
||||
});
|
||||
|
||||
it('returns empty array when no JUnit files found', async () => {
|
||||
vol.fromJSON({ '/junit/.gitkeep': '' });
|
||||
|
||||
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -179,47 +119,152 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
|
||||
expect(results[0].tags).toEqual(['e2e', 'smoke']);
|
||||
expect(results[0].properties).toEqual({ env: 'staging' });
|
||||
});
|
||||
|
||||
it('handles reuse_devices=true (junit_report_directory == tests_directory)', async () => {
|
||||
it('returns per-attempt results when multiple JUnit files exist for the same flow', async () => {
|
||||
vol.fromJSON({
|
||||
// Same directory for both JUnit and debug output
|
||||
'/maestro-tests/android-maestro-junit.xml': [
|
||||
// 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': [
|
||||
'<?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"/>',
|
||||
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
|
||||
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);
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'home',
|
||||
path: '.maestro/home.yml',
|
||||
name: 'login',
|
||||
path: '.maestro/login.yml',
|
||||
status: 'failed',
|
||||
errorMessage: 'Timeout',
|
||||
duration: 5000,
|
||||
retryCount: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'login',
|
||||
path: '.maestro/login.yml',
|
||||
status: 'passed',
|
||||
errorMessage: null,
|
||||
duration: 3000,
|
||||
retryCount: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles reuse_devices=false (separate junit_report_directory)', async () => {
|
||||
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 () => {
|
||||
vol.fromJSON({
|
||||
// JUnit in temp dir (per-flow files)
|
||||
'/tmp/maestro-reports-abc123/junit-report-flow-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
@@ -238,21 +283,14 @@ describe(parseMaestroResults, () => {
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Debug output in default location
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults(
|
||||
'/tmp/maestro-reports-abc123',
|
||||
'/tests',
|
||||
'/root/project'
|
||||
new Map([
|
||||
['home', '.maestro/home.yml'],
|
||||
['login', '.maestro/login.yml'],
|
||||
])
|
||||
);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual(
|
||||
@@ -262,54 +300,171 @@ describe(parseMaestroResults, () => {
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses raw path when flow_file_path is outside project_root', async () => {
|
||||
describe(parseMaestroResultsFromFileAttrs, () => {
|
||||
it('uses file= as the path', 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>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file=".maestro/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</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');
|
||||
const results = await parseMaestroResultsFromFileAttrs('/junit');
|
||||
expect(results[0]).toEqual(
|
||||
expect.objectContaining({ name: 'login', path: '.maestro/login.yaml' })
|
||||
);
|
||||
});
|
||||
|
||||
it('filters out non-timestamp directories when scanning debug output', async () => {
|
||||
it('keeps two same-named flows separate by their file= path', 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>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="login" file="b/login.yaml" status="ERROR" time="1.0"><failure>x</failure></testcase>',
|
||||
'</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 parseMaestroResultsFromFileAttrs('/junit');
|
||||
expect(results.map(r => r.path).sort()).toEqual(['a/login.yaml', 'b/login.yaml']);
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
// Only 1 occurrence (not-a-timestamp dir should be ignored)
|
||||
expect(results[0].retryCount).toBe(0);
|
||||
it('returns per-attempt results keyed by file= across attempt files', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/android-maestro-junit-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="flows/login.yaml" time="1.0"><failure>x</failure></testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
'/junit/android-maestro-junit-attempt-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseMaestroResultsFromFileAttrs('/junit');
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ path: 'flows/login.yaml', status: 'failed', retryCount: 0 }),
|
||||
expect.objectContaining({ path: 'flows/login.yaml', status: 'passed', retryCount: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when the directory does not exist', async () => {
|
||||
const results = await parseMaestroResultsFromFileAttrs('/nope');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips testcases without file= instead of emitting an undefined path (contract guard)', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="b" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseMaestroResultsFromFileAttrs('/junit');
|
||||
expect(results).toEqual([expect.objectContaining({ name: 'a', path: 'a.yaml' })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('junitFileHasFileAttrs', () => {
|
||||
it('returns true when every testcase has a non-empty file= attribute', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="b" file="b.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any testcase lacks file= (mixed report)', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="b" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when file= is an empty string', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" file="" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a legacy report with no file= attributes', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the file cannot be read', async () => {
|
||||
expect(await junitFileHasFileAttrs('/missing.xml')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFileAttrRun', () => {
|
||||
it('returns false for an empty list (an empty report is not a file-attr run)', () => {
|
||||
expect(isFileAttrRun([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseJUnitTestCases, () => {
|
||||
it('extracts the file= attribute when present', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file=".maestro/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].file).toBe('.maestro/login.yaml');
|
||||
});
|
||||
|
||||
it('treats a missing or empty file= attribute as undefined', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="b" file="" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results.find(r => r.name === 'a')!.file).toBeUndefined();
|
||||
expect(results.find(r => r.name === 'b')!.file).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses a single testcase with SUCCESS status', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
@@ -592,3 +747,426 @@ describe(parseJUnitTestCases, () => {
|
||||
expect(results[0].properties).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseFailedFlowsFromFileAttrs, () => {
|
||||
it('returns the failing flows by file= (duplicate names retry correctly)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>
|
||||
<testcase name="login" file="b/login.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/proj/b/login.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toEqual(['b/login.yaml']);
|
||||
});
|
||||
|
||||
it('returns null when a file= path does not exist on disk (dumb retry, never legacy)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="gone" file="missing/flow.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the report lacks file= attributes (callers must pre-check)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" file="a.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="B" time="1.0"><failure>y</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/proj/a.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('dedupes repeated failing file= paths (same flow failed twice in one attempt)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="login" file="flows/login.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="login" file="flows/login.yaml" time="1.0"><failure>y</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/proj/flows/login.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toEqual(['flows/login.yaml']);
|
||||
});
|
||||
|
||||
it('resolves and existence-checks an absolute file= path', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="ext" file="/outside/flow.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/outside/flow.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toEqual(['/outside/flow.yaml']);
|
||||
});
|
||||
|
||||
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" file="a.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="B" file="b.yaml"`, // intentionally truncated mid-tag
|
||||
'/proj/a.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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('keeps a same-named flow from an earlier attempt when keyed by file=', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>
|
||||
<testcase name="login" file="b/login.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/tmp/r/android-maestro-junit-attempt-1.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="login" file="b/login.yaml" status="SUCCESS" time="1.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 files = testcases.map((t: any) => t['@_file']).sort();
|
||||
expect(files).toEqual(['a/login.yaml', 'b/login.yaml']);
|
||||
});
|
||||
|
||||
it('throws when source directory contains no *.xml files', async () => {
|
||||
// No per-attempt JUnit files were ever written (maestro crashed before
|
||||
// producing output). Phase 3 in runMaestroTests treats this as a SystemError
|
||||
// — without a throw here, mergeJUnitReports would silently write an empty
|
||||
// <testsuite> and the caller would upload a misleading empty report.
|
||||
vol.fromJSON({
|
||||
'/tmp/r/.gitkeep': '',
|
||||
});
|
||||
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow(/no \*\.xml files/);
|
||||
});
|
||||
|
||||
it('throws on invalid XML so caller can fall back to copyLatestAttemptXml', async () => {
|
||||
// Without a throw, mergeJUnitReports would silently emit an empty merged
|
||||
// document and the copy-latest fallback (which only triggers on throw)
|
||||
// would never run.
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': 'not xml at all',
|
||||
});
|
||||
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow(/invalid XML/);
|
||||
});
|
||||
|
||||
it('throws when every input has <testsuites> but no <testcase> elements', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite></testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow(/no parseable testcases/);
|
||||
});
|
||||
|
||||
it('throws when any input XML is malformed (even if others parse)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?><testsuites><testsuite><testcase name="A" status="SUCCESS" time="1.0"/></testsuite></testsuites>`,
|
||||
'/tmp/r/attempt-1.xml': 'garbage not xml',
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when an input XML is truncated mid-tag (would otherwise partial-parse)', async () => {
|
||||
// fast-xml-parser is lenient and can return partial results from truncated
|
||||
// XML. Without strict validation, mergeJUnitReports would emit a merged
|
||||
// report missing the cut-off flows and Phase 3's copy-latest fallback
|
||||
// would never run. Validation must reject the file.
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" status="SUCCESS" time="1.0"/>
|
||||
<testcase name="B"`, // truncated mid-tag
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when an input XML has unclosed tags (would otherwise partial-parse)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" status="SUCCESS" time="1.0"/>
|
||||
</testsuite>`, // missing </testsuites>
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when any input XML parses but has no testcases (even if others have testcases)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?><testsuites><testsuite><testcase name="A" status="SUCCESS" time="1.0"/></testsuite></testsuites>`,
|
||||
'/tmp/r/attempt-1.xml': `<?xml version="1.0"?><testsuites></testsuites>`,
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('preserves duplicate names within the same attempt', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="Dup" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Dup" status="SUCCESS" time="2.0" />
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
|
||||
|
||||
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
|
||||
const parsed = new XMLParser({ ignoreAttributes: false, isArray: n => n === 'testcase' }).parse(
|
||||
out
|
||||
);
|
||||
expect(parsed.testsuites.testsuite.testcase).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyLatestAttemptXml', () => {
|
||||
it('picks the highest attempt number', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': '<a/>',
|
||||
'/tmp/r/android-maestro-junit-attempt-1.xml': '<b/>',
|
||||
'/tmp/r/android-maestro-junit-attempt-2.xml': '<c/>',
|
||||
});
|
||||
await copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' });
|
||||
expect(await fs.readFile('/tmp/out.xml', 'utf-8')).toBe('<c/>');
|
||||
});
|
||||
|
||||
it('throws when source has no xml files', async () => {
|
||||
vol.fromJSON({ '/tmp/r/.keep': '' });
|
||||
await expect(
|
||||
copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import {
|
||||
type HarvestedScreenshot,
|
||||
computePureFailureFlowNames,
|
||||
harvestFailureScreenshotsAsync,
|
||||
parseFailureScreenshotFilename,
|
||||
selectFailureScreenshots,
|
||||
} from '../maestroScreenshots';
|
||||
|
||||
describe(parseFailureScreenshotFilename, () => {
|
||||
it('parses a plain failure screenshot', () => {
|
||||
expect(parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login Flow).png')).toEqual({
|
||||
flowName: 'Login Flow',
|
||||
capturedAtMs: 1781186692250,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a sharded failure screenshot', () => {
|
||||
expect(
|
||||
parseFailureScreenshotFilename('screenshot-shard-2-❌-1781186692250-(Login Flow).png')
|
||||
).toEqual({
|
||||
flowName: 'Login Flow',
|
||||
capturedAtMs: 1781186692250,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles parentheses inside the flow name (right-to-left parse)', () => {
|
||||
expect(
|
||||
parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login (staging) Flow).png')
|
||||
).toEqual({
|
||||
flowName: 'Login (staging) Flow',
|
||||
capturedAtMs: 1781186692250,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-failure screenshots (✅, ⚠️) and unrelated files', () => {
|
||||
expect(
|
||||
parseFailureScreenshotFilename('screenshot-✅-1781186692250-(Login Flow).png')
|
||||
).toBeNull();
|
||||
expect(parseFailureScreenshotFilename('commands-(Login Flow).json')).toBeNull();
|
||||
expect(parseFailureScreenshotFilename('android-maestro-junit-attempt-0.xml')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(harvestFailureScreenshotsAsync, () => {
|
||||
const logger = createMockLogger();
|
||||
let testsDirectory: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testsDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'maestro-harvest-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testsDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function makeDebugDir(name: string, files: string[], mtimeMs: number): Promise<void> {
|
||||
const dir = path.join(testsDirectory, name);
|
||||
await fs.mkdir(dir);
|
||||
for (const file of files) {
|
||||
await fs.writeFile(path.join(dir, file), '');
|
||||
}
|
||||
const when = new Date(mtimeMs);
|
||||
await fs.utimes(dir, when, when);
|
||||
}
|
||||
|
||||
it('returns shots from dirs modified at/after sinceMtimeMs, attributed to the attempt', async () => {
|
||||
const sinceMtimeMs = 1_000_000;
|
||||
await makeDebugDir(
|
||||
'attempt-dir',
|
||||
['screenshot-❌-1781186692250-(Login Flow).png', 'commands.json'],
|
||||
sinceMtimeMs + 5_000
|
||||
);
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 1,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([
|
||||
{
|
||||
fileAbsPath: path.join(
|
||||
testsDirectory,
|
||||
'attempt-dir',
|
||||
'screenshot-❌-1781186692250-(Login Flow).png'
|
||||
),
|
||||
displayName: 'Failure Screenshot: Login Flow (attempt 2)',
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: 'Login Flow',
|
||||
attemptIndex: 1,
|
||||
capturedAtMs: 1781186692250,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores dirs older than sinceMtimeMs (previous attempts)', async () => {
|
||||
const sinceMtimeMs = 1_000_000;
|
||||
await makeDebugDir(
|
||||
'old-dir',
|
||||
['screenshot-❌-1781186692250-(Old Flow).png'],
|
||||
sinceMtimeMs - 5_000
|
||||
);
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([]);
|
||||
});
|
||||
|
||||
it('scans multiple new dirs (maestro <2.5.0 split-dir bug)', async () => {
|
||||
const sinceMtimeMs = 1_000_000;
|
||||
await makeDebugDir(
|
||||
'dir-a',
|
||||
[`screenshot-❌-${sinceMtimeMs + 100}-(Flow A).png`],
|
||||
sinceMtimeMs + 1_000
|
||||
);
|
||||
await makeDebugDir(
|
||||
'dir-b',
|
||||
[`screenshot-❌-${sinceMtimeMs + 200}-(Flow B).png`],
|
||||
sinceMtimeMs + 2_000
|
||||
);
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots.map(shot => shot.metadata.flowName).sort()).toEqual(['Flow A', 'Flow B']);
|
||||
});
|
||||
|
||||
it('keeps the disk flow name (slashes already substituted) and strips the shard prefix', async () => {
|
||||
const sinceMtimeMs = 1_000_000;
|
||||
await makeDebugDir(
|
||||
'sharded-dir',
|
||||
['screenshot-shard-3-❌-1781186692250-(Login_Sub Flow).png'],
|
||||
sinceMtimeMs + 1_000
|
||||
);
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toHaveLength(1);
|
||||
expect(shots[0].metadata.flowName).toBe('Login_Sub Flow');
|
||||
});
|
||||
|
||||
it('ignores a stale screenshot whose capturedAtMs predates sinceMtimeMs (dir touched later)', async () => {
|
||||
const sinceMtimeMs = 1_000_000;
|
||||
// The dir mtime is AFTER this attempt started (so it passes the dir gate), but the
|
||||
// screenshot inside was captured BEFORE it — a prior attempt's shot in a dir touched later.
|
||||
await makeDebugDir(
|
||||
'touched-dir',
|
||||
['screenshot-❌-999000-(Stale Flow).png'],
|
||||
sinceMtimeMs + 5_000
|
||||
);
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 1,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([]);
|
||||
});
|
||||
|
||||
it('logs and returns [] when the tests dir cannot be read', async () => {
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory: path.join(testsDirectory, 'does-not-exist'),
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(computePureFailureFlowNames, () => {
|
||||
it('classifies a flow that failed every attempt as pure', () => {
|
||||
const pure = computePureFailureFlowNames([
|
||||
{ name: 'Login Flow', status: 'failed' },
|
||||
{ name: 'Login Flow', status: 'failed' },
|
||||
]);
|
||||
expect([...pure]).toEqual(['Login Flow']);
|
||||
});
|
||||
|
||||
it('excludes a flaky flow (failed then passed)', () => {
|
||||
const pure = computePureFailureFlowNames([
|
||||
{ name: 'Flaky Flow', status: 'failed' },
|
||||
{ name: 'Flaky Flow', status: 'passed' },
|
||||
]);
|
||||
expect(pure.size).toBe(0);
|
||||
});
|
||||
|
||||
it('excludes a fail-pass-fail flow (it passed at some point)', () => {
|
||||
const pure = computePureFailureFlowNames([
|
||||
{ name: 'FPF', status: 'failed' },
|
||||
{ name: 'FPF', status: 'passed' },
|
||||
{ name: 'FPF', status: 'failed' },
|
||||
]);
|
||||
expect(pure.size).toBe(0);
|
||||
});
|
||||
|
||||
it('excludes a flow that only passed', () => {
|
||||
expect(computePureFailureFlowNames([{ name: 'Happy', status: 'passed' }]).size).toBe(0);
|
||||
});
|
||||
|
||||
it('normalizes the JUnit name to the screenshot form (/ -> _)', () => {
|
||||
const pure = computePureFailureFlowNames([{ name: 'sub/Login', status: 'failed' }]);
|
||||
expect([...pure]).toEqual(['sub_Login']);
|
||||
});
|
||||
|
||||
it('excludes flows that collide only after / -> _ normalization (one failed, one passed)', () => {
|
||||
const pure = computePureFailureFlowNames([
|
||||
{ name: 'sub/Login', status: 'failed' },
|
||||
{ name: 'sub_Login', status: 'passed' },
|
||||
]);
|
||||
expect(pure.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns an empty set for no testcases', () => {
|
||||
expect(computePureFailureFlowNames([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe(selectFailureScreenshots, () => {
|
||||
function makeShot(flowName: string, attemptIndex: number): HarvestedScreenshot {
|
||||
return {
|
||||
fileAbsPath: `/tmp/${flowName}-${attemptIndex}.png`,
|
||||
displayName: `Failure Screenshot: ${flowName} (attempt ${attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName,
|
||||
attemptIndex,
|
||||
capturedAtMs: 1_000 + attemptIndex,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('keeps only the final attempt for a pure-failure flow', () => {
|
||||
const shots = [makeShot('Pure', 0), makeShot('Pure', 1), makeShot('Pure', 2)];
|
||||
const selected = selectFailureScreenshots(shots, new Set(['Pure']));
|
||||
expect(selected.map(shot => shot.metadata.attemptIndex)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('keeps every attempt for a flow not classified pure (flaky)', () => {
|
||||
const shots = [makeShot('Flaky', 0), makeShot('Flaky', 1)];
|
||||
const selected = selectFailureScreenshots(shots, new Set());
|
||||
expect(selected.map(shot => shot.metadata.attemptIndex)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('reduces pure flows while preserving flaky flows and input order', () => {
|
||||
const shots = [
|
||||
makeShot('Pure', 0),
|
||||
makeShot('Flaky', 0),
|
||||
makeShot('Pure', 1),
|
||||
makeShot('Flaky', 1),
|
||||
];
|
||||
const selected = selectFailureScreenshots(shots, new Set(['Pure']));
|
||||
expect(selected.map(shot => `${shot.metadata.flowName}#${shot.metadata.attemptIndex}`)).toEqual(
|
||||
['Flaky#0', 'Pure#1', 'Flaky#1']
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps all when the pure set is empty', () => {
|
||||
const shots = [makeShot('A', 0), makeShot('A', 1)];
|
||||
expect(selectFailureScreenshots(shots, new Set())).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,890 @@
|
||||
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import * as discovery from '../maestroFlowDiscovery';
|
||||
import * as parser from '../maestroResultParser';
|
||||
import * as maestroScreenshots from '../maestroScreenshots';
|
||||
import { createMaestroTestsBuildFunction } from '../maestroTests';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/retry', () => ({
|
||||
...jest.requireActual('../../../utils/retry'),
|
||||
sleepAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Partial mock: stub the harvest (filesystem) but keep the real reduction helpers
|
||||
// (computePureFailureFlowNames / selectFailureScreenshots) the step now calls post-loop.
|
||||
jest.mock('../maestroScreenshots', () => ({
|
||||
...jest.requireActual('../maestroScreenshots'),
|
||||
harvestFailureScreenshotsAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedHarvest = jest.mocked(maestroScreenshots.harvestFailureScreenshotsAsync);
|
||||
const mockUploadArtifact = jest.fn();
|
||||
|
||||
function makeShot(index: number): maestroScreenshots.HarvestedScreenshot {
|
||||
return {
|
||||
fileAbsPath: path.join(os.tmpdir(), `src-screenshot-${index}.png`),
|
||||
displayName: 'Failure Screenshot: Login (attempt 1)',
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: 'Login',
|
||||
attemptIndex: 0,
|
||||
capturedAtMs: 1781186692250,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const SPAWN_SUCCESS = {
|
||||
status: 0,
|
||||
pid: 1,
|
||||
output: [''],
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
signal: null,
|
||||
} as any;
|
||||
|
||||
const rejectExit1 = (): Error => {
|
||||
const e: any = new Error('exit 1');
|
||||
e.status = 1;
|
||||
return e;
|
||||
};
|
||||
|
||||
function createStep(
|
||||
callInputs?: Record<string, unknown>,
|
||||
options: { env?: Record<string, string | undefined> } = {}
|
||||
): ReturnType<
|
||||
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
|
||||
> {
|
||||
const logger = createMockLogger();
|
||||
const ctx = {
|
||||
runtimeApi: { uploadArtifact: mockUploadArtifact },
|
||||
} as unknown as CustomBuildContext;
|
||||
const fn = createMaestroTestsBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock({ logger });
|
||||
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
|
||||
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
|
||||
}
|
||||
|
||||
describe('createMaestroTestsBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
// Default-stub the merge helper so tests that don't care about it don't
|
||||
// hit the real disk. Individual tests override this.
|
||||
jest.restoreAllMocks();
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
mockedHarvest.mockReset();
|
||||
mockedHarvest.mockResolvedValue([]);
|
||||
mockUploadArtifact.mockReset();
|
||||
mockUploadArtifact.mockResolvedValue({ artifactId: 'artifact-1' });
|
||||
});
|
||||
|
||||
it('exports a factory that returns a BuildFunction instance', () => {
|
||||
expect(
|
||||
createMaestroTestsBuildFunction({
|
||||
runtimeApi: { uploadArtifact: mockUploadArtifact },
|
||||
} as unknown as CustomBuildContext)
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets all outputs before running any flows', async () => {
|
||||
// Make spawn reject so the step still throws; we're locking in that outputs
|
||||
// are set BEFORE the spawn call (downstream `upload_artifact` fails if
|
||||
// `final_report_path` is empty).
|
||||
mockedSpawn.mockRejectedValue(new Error('stop'));
|
||||
|
||||
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow();
|
||||
|
||||
expect(step.getOutputValueByName('junit_report_directory')).toMatch(
|
||||
/\.maestro\/tests\/junit-reports$/
|
||||
);
|
||||
expect(step.getOutputValueByName('tests_directory')).toMatch(/\.maestro\/tests$/);
|
||||
expect(step.getOutputValueByName('final_report_path')).toMatch(
|
||||
/\.maestro\/tests\/android-maestro-junit\.xml$/
|
||||
);
|
||||
});
|
||||
|
||||
it('succeeds when maestro exits 0 on first attempt', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
const [cmd, args] = mockedSpawn.mock.calls[0];
|
||||
expect(cmd).toBe('maestro');
|
||||
expect(args).toEqual(expect.arrayContaining(['test', 'flows/a.yaml', 'flows/b.yaml']));
|
||||
expect(args).toEqual(expect.arrayContaining(['--format=JUNIT']));
|
||||
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
|
||||
});
|
||||
|
||||
it('throws SystemError when spawn fails with ENOENT (binary missing)', async () => {
|
||||
mockedSpawn.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws SystemError for unknown-shape spawn rejections', async () => {
|
||||
mockedSpawn.mockRejectedValue(new Error('mystery'));
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws SystemError on signal-only spawn rejections (OOM / SIGTERM)', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('killed'), {
|
||||
status: null,
|
||||
signal: 'SIGKILL',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('treats reject-with-status as a retryable test failure', async () => {
|
||||
const rejectWithExit = (status: number): Error =>
|
||||
Object.assign(new Error(`maestro exited with non-zero code: ${status}`), {
|
||||
status,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
});
|
||||
mockedSpawn.mockRejectedValueOnce(rejectWithExit(1)).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
// Default `retries` is 0, so even though the first attempt rejects with
|
||||
// `status: 1`, no retry occurs and the post-loop check throws UserError.
|
||||
// Spawn ran once.
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries only the failed flows on the next attempt (junit mode)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Attempt 0 saw all 3
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
// Attempt 1 saw only the failed one
|
||||
const a1Args = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1Args).toContain('flows/b.yaml');
|
||||
expect(a1Args).not.toContain('flows/a.yaml');
|
||||
expect(a1Args).not.toContain('flows/c.yaml');
|
||||
});
|
||||
|
||||
it('retries all flows when parseFailedFlowsFromJUnit returns null', async () => {
|
||||
const spawnMock = mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(null);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Both attempts saw all 3 flows
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
expect(spawnMock.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('always retries all flows when output_format is not junit', async () => {
|
||||
const spawnMock = mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
expect(spawnMock.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('runs all original flows on every retry when retry_failed_only=false', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest
|
||||
.spyOn(parser, 'parseFailedFlowsFromJUnit')
|
||||
.mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
retry_failed_only: false,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Both attempts must see both flows in the original input order. Tail-slice
|
||||
// equality locks order, not just membership — guards against any future
|
||||
// failed-first reshuffling of the dumb-retry list.
|
||||
const a0 = mockedSpawn.mock.calls[0][1]!;
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a0.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
|
||||
expect(a1.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('subsets to failing flows on retry when retry_failed_only=true (default behaviour)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
retry_failed_only: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1).toContain('flows/b.yaml');
|
||||
expect(a1).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('falls back to all-flows retry when retry_failed_only=true but output_format!=junit', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'html',
|
||||
retry_failed_only: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the file= path on reports that carry it, without scanning flow files', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'junitFileHasFileAttrs').mockResolvedValue(true);
|
||||
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
|
||||
const legacyParseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
const newParseSpy = jest
|
||||
.spyOn(parser, 'parseFailedFlowsFromFileAttrs')
|
||||
.mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
retry_failed_only: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(newParseSpy).toHaveBeenCalledTimes(1);
|
||||
// The new branch needs no name→path map: no scan, no legacy parser.
|
||||
expect(discoverySpy).not.toHaveBeenCalled();
|
||||
expect(legacyParseSpy).not.toHaveBeenCalled();
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1).toContain('flows/b.yaml');
|
||||
expect(a1).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('falls back to all-flows retry when nameToPath is null (duplicate flow names)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(discovery, 'buildFlowNameToPathMap').mockResolvedValue(null);
|
||||
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
retry_failed_only: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('never scans flow files when all attempts succeed', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
|
||||
|
||||
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
|
||||
await step.executeAsync();
|
||||
|
||||
expect(discoverySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('memoizes the flow scan across retries (legacy reports)', async () => {
|
||||
// Routing note: the step lands in the legacy arm because the real
|
||||
// junitFileHasFileAttrs returns false — the attempt XML does not exist on
|
||||
// the mock fs. Creating that file with file= attrs would flip the branch.
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const discoverySpy = jest
|
||||
.spyOn(discovery, 'buildFlowNameToPathMap')
|
||||
.mockResolvedValue(new Map([['a', 'flows/a.yaml']]));
|
||||
const parseSpy = jest
|
||||
.spyOn(parser, 'parseFailedFlowsFromJUnit')
|
||||
.mockResolvedValue(['flows/a.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: 2,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Two failed attempts hit the legacy branch twice; the scan runs once.
|
||||
expect(parseSpy).toHaveBeenCalledTimes(2);
|
||||
expect(discoverySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('defaults retry_failed_only to true when omitted from inputs', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1).toContain('flows/b.yaml');
|
||||
expect(a1).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('writes merged junit on success', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mergeSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceDir: expect.stringContaining('junit-reports'),
|
||||
outputPath: expect.stringContaining('android-maestro-junit.xml'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to copyLatestAttemptXml when mergeJUnitReports throws a data error', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
|
||||
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(copySpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows merge and copy-latest failures when maestro succeeded', async () => {
|
||||
// Throwing on copy failure would mask the real reason for early-failing
|
||||
// maestro runs (bad YAML writes no *.xml) and would also wrap a user-side
|
||||
// failure as SystemError, which cancels the build's billing.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
|
||||
jest
|
||||
.spyOn(parser, 'copyLatestAttemptXml')
|
||||
.mockRejectedValue(Object.assign(new Error('no space'), { code: 'ENOSPC' }));
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces ERR_MAESTRO_TESTS_FAILED (not copy error) when maestro fails and copy fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('exit 1'), {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
jest.spyOn(parser, 'copyLatestAttemptXml').mockRejectedValue(new Error('No *.xml files found'));
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries are exhausted with a non-zero exit', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('exit 1'), {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/a.yaml']);
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: 2,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError early on empty flow_path', async () => {
|
||||
const step = createStep({ flow_path: [], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
|
||||
// whole-directory upload picks it up.
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
|
||||
expect(outputArg).not.toMatch(/junit-reports/);
|
||||
});
|
||||
|
||||
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'HTML',
|
||||
platform: 'ios',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/\.maestro\/tests\/ios-maestro-html\.html$/);
|
||||
});
|
||||
|
||||
it('does not include --output when output_format is undefined', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: '',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
expect(args.some(a => a.startsWith('--output'))).toBe(false);
|
||||
expect(args).not.toContain('--output');
|
||||
expect(args).not.toContain('--output=undefined');
|
||||
expect(args).not.toContain('--output=null');
|
||||
expect(args).not.toContain('--output=');
|
||||
});
|
||||
|
||||
it('accepts output_format casing variations (e.g. JUNIT) case-insensitively', async () => {
|
||||
// output_format.toLowerCase() must propagate end-to-end: final_report_path
|
||||
// populated, per-attempt XMLs written into junit-reports/, merge invoked.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'JUNIT',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Phase 1: final_report_path populated
|
||||
expect(step.getOutputValueByName('final_report_path')).toMatch(
|
||||
/\.maestro\/tests\/android-maestro-junit\.xml$/
|
||||
);
|
||||
// Retry loop: --output written into junit-reports/ (not legacy path)
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/junit-reports\/android-maestro-junit-attempt-0\.xml$/);
|
||||
// Phase 3: merge invoked
|
||||
expect(mergeSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceDir: expect.stringContaining('junit-reports'),
|
||||
outputPath: expect.stringContaining('android-maestro-junit.xml'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes include_tags, exclude_tags, and shards through to maestro argv', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
shards: 3,
|
||||
include_tags: 'smoke,critical',
|
||||
exclude_tags: 'slow',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('--shard-split=3');
|
||||
expect(args).toContain('--include-tags=smoke,critical');
|
||||
expect(args).toContain('--exclude-tags=slow');
|
||||
});
|
||||
|
||||
it('passes cwd and env to spawn so relative flow paths resolve correctly', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
|
||||
cwd?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
expect(spawnOptions).toBeDefined();
|
||||
expect(typeof spawnOptions.cwd).toBe('string');
|
||||
expect(spawnOptions.cwd!.length).toBeGreaterThan(0);
|
||||
expect(spawnOptions.env).toBeDefined();
|
||||
expect(spawnOptions.env!.HOME).toBe('/home/expo');
|
||||
});
|
||||
|
||||
it('exports MAESTRO_TESTS_DIR to maestro so flows can reference ${MAESTRO_TESTS_DIR}', async () => {
|
||||
// Public docs (EAS workflows pre-packaged-jobs) instruct users to write
|
||||
// screenshots/recordings into ${MAESTRO_TESTS_DIR}. The step must export
|
||||
// it in the spawn env.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
expect(spawnOptions.env!.MAESTRO_TESTS_DIR).toBe('/home/expo/.maestro/tests');
|
||||
});
|
||||
|
||||
it('uses env.HOME (not os.homedir()) for maestro tests output paths', async () => {
|
||||
// Even when os.homedir() differs, the step should derive paths from env.HOME
|
||||
// so maestro (which receives env) and the step (which inspects those paths)
|
||||
// agree on where reports land.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
},
|
||||
{ env: { HOME: '/custom/home' } }
|
||||
);
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('tests_directory')).toBe('/custom/home/.maestro/tests');
|
||||
expect(step.getOutputValueByName('junit_report_directory')).toBe(
|
||||
'/custom/home/.maestro/tests/junit-reports'
|
||||
);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
'/custom/home/.maestro/tests/android-maestro-junit.xml'
|
||||
);
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(
|
||||
/^--output=\/custom\/home\/\.maestro\/tests\/junit-reports\/android-maestro-junit-attempt-0\.xml$/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws SystemError when env.HOME is unset', async () => {
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws UserError on non-string entries in flow_path', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 2 as unknown as string],
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries is negative', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: -1,
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries is a non-integer number', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: 1.5,
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is zero', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 0 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is negative', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: -1 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is a non-integer', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 1.5 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('accepts shards: undefined (optional input)', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
|
||||
await step.executeAsync(); // should succeed, not throw
|
||||
});
|
||||
|
||||
it('uploads harvested screenshots after the retry loop with metadata', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const shot = makeShot(0);
|
||||
await fs.writeFile(shot.fileAbsPath, '');
|
||||
mockedHarvest.mockResolvedValue([shot]);
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: shot.displayName,
|
||||
metadata: shot.metadata,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const shot = makeShot(0);
|
||||
await fs.writeFile(shot.fileAbsPath, '');
|
||||
mockedHarvest.mockResolvedValue([shot]);
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('swallows upload errors without affecting the test verdict', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const shot = makeShot(0);
|
||||
await fs.writeFile(shot.fileAbsPath, '');
|
||||
mockedHarvest.mockResolvedValue([shot]);
|
||||
mockUploadArtifact.mockRejectedValue(new Error('upload boom'));
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips screenshot upload but preserves the verdict when the JUnit re-parse throws', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
mockedHarvest.mockResolvedValue([makeShot(0)]);
|
||||
jest.spyOn(parser, 'parseJUnitTestCases').mockRejectedValue(new Error('malformed junit'));
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
|
||||
|
||||
// The classify throw is swallowed; the maestro failure verdict still surfaces.
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips screenshot upload but preserves the verdict when the staging dir cannot be created', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
mockedHarvest.mockResolvedValue([makeShot(0)]);
|
||||
jest.spyOn(parser, 'parseJUnitTestCases').mockResolvedValue([]);
|
||||
jest.spyOn(fs, 'mkdtemp').mockRejectedValue(new Error('no tmp space'));
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
|
||||
|
||||
// mkdtemp failure is swallowed; the maestro failure verdict still surfaces.
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('caps uploads at 30 screenshots (excess dropped)', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const shots = Array.from({ length: 35 }, (_, index) => makeShot(index));
|
||||
await Promise.all(shots.map(shot => fs.writeFile(shot.fileAbsPath, '')));
|
||||
mockedHarvest.mockResolvedValue(shots);
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
|
||||
});
|
||||
|
||||
it('does not harvest or upload screenshots when output_format is not junit', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'html' });
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedHarvest).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const tc = (name: string, status: 'passed' | 'failed'): parser.JUnitTestCaseResult => ({
|
||||
name,
|
||||
file: undefined,
|
||||
status,
|
||||
duration: 0,
|
||||
errorMessage: null,
|
||||
tags: [],
|
||||
properties: {},
|
||||
});
|
||||
jest
|
||||
.spyOn(parser, 'parseJUnitTestCases')
|
||||
.mockResolvedValue([
|
||||
tc('PureFlow', 'failed'),
|
||||
tc('FlakyFlow', 'failed'),
|
||||
tc('FlakyFlow', 'passed'),
|
||||
]);
|
||||
|
||||
const makeShotFor = (
|
||||
flowName: string,
|
||||
attemptIndex: number
|
||||
): maestroScreenshots.HarvestedScreenshot => ({
|
||||
fileAbsPath: path.join(os.tmpdir(), `wiring-${flowName}-${attemptIndex}.png`),
|
||||
displayName: `Failure Screenshot: ${flowName} (attempt ${attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName,
|
||||
attemptIndex,
|
||||
capturedAtMs: 1781186692250 + attemptIndex,
|
||||
},
|
||||
});
|
||||
const shots = [
|
||||
makeShotFor('PureFlow', 0),
|
||||
makeShotFor('PureFlow', 1),
|
||||
makeShotFor('FlakyFlow', 0),
|
||||
makeShotFor('FlakyFlow', 1),
|
||||
];
|
||||
await Promise.all(shots.map(shot => fs.writeFile(shot.fileAbsPath, '')));
|
||||
mockedHarvest.mockResolvedValue(shots);
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
|
||||
await step.executeAsync();
|
||||
|
||||
// PureFlow keeps only its final attempt (1); FlakyFlow keeps every failed attempt.
|
||||
// Uploads run concurrently, so compare the set, not the order.
|
||||
const uploaded = mockUploadArtifact.mock.calls
|
||||
.map(([arg]) => `${arg.artifact.metadata.flowName}#${arg.artifact.metadata.attemptIndex}`)
|
||||
.sort();
|
||||
expect(uploaded).toEqual(['FlakyFlow#0', 'FlakyFlow#1', 'PureFlow#1']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loaded 100 of 649 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user